From d36bb954d10b90e5e37908f9d928cb9ea52f1e46 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 31 Jul 2026 05:06:36 +0800 Subject: [PATCH] chore: initial snapshot of 2meet-data-optimizer v0.1.0 Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY --- .gitignore | 16 + 2meet-data-optimizer.php | 331 ++ CHANGELOG.md | 114 + CLAUDE.md | 108 + DEPLOY.md | 121 + DESIGN.md | 130 + PLAN.md | 201 + README.md | 127 + SECURITY.md | 76 + admin/assets/wpdo-admin.css | 515 +++ admin/assets/wpdo-admin.js | 246 ++ admin/assets/wpdo-comment-stress-test.js | 368 ++ admin/assets/wpdo-entity-bridge.js | 295 ++ admin/assets/wpdo-migration-wizard.css | 288 ++ admin/assets/wpdo-migration-wizard.js | 314 ++ admin/assets/wpdo-post-stress-test.js | 383 ++ admin/assets/wpdo-rest-sdk.js | 247 ++ admin/assets/wpdo-stress-test.js | 339 ++ admin/assets/wpdo-term-stress-test.js | 368 ++ admin/class-tmdo-admin.php | 3710 +++++++++++++++++ admin/class-tmdo-dashboard-widget.php | 443 ++ admin/class-tmdo-export.php | 272 ++ admin/class-tmdo-help-tabs.php | 221 + admin/class-tmdo-setup-wizard.php | 474 +++ admin/templates/comment-stress-test.php | 252 ++ admin/templates/migration-wizard.php | 217 + admin/templates/post-migration-wizard.php | 298 ++ admin/templates/post-stress-test.php | 393 ++ admin/templates/term-stress-test.php | 248 ++ cli/class-tmdo-cli-member.php | 561 +++ cli/class-tmdo-cli-post.php | 844 ++++ cli/class-tmdo-cli-term-comment.php | 1013 +++++ cli/class-tmdo-cli-v2.php | 795 ++++ cli/class-tmdo-cli.php | 1755 ++++++++ composer.json | 51 + composer.lock | 2840 +++++++++++++ .../adapters/class-tmdo-adapter-comment.php | 105 + includes/adapters/class-tmdo-adapter-post.php | 93 + includes/adapters/class-tmdo-adapter-term.php | 89 + includes/adapters/class-tmdo-adapter-user.php | 87 + .../adapters/interface-entity-adapter.php | 83 + includes/advisor/class-tmdo-fsm-advisor.php | 266 ++ includes/advisor/class-tmdo-fsm-automator.php | 234 ++ .../advisor/class-tmdo-module-detector.php | 381 ++ includes/advisor/class-tmdo-module-rules.php | 171 + .../interface-wpdo-entity-adapter-alias.php | 30 + .../trait-wpdo-anti-eav-aware-alias.php | 40 + includes/class-tmdo-api.php | 183 + includes/class-tmdo-back-compat.php | 182 + includes/class-tmdo-cache-layer.php | 236 ++ includes/class-tmdo-capability.php | 49 + includes/class-tmdo-comment-stress-tester.php | 864 ++++ includes/class-tmdo-commentmeta-cleaner.php | 277 ++ includes/class-tmdo-compatibility.php | 133 + includes/class-tmdo-conflict-monitor.php | 245 ++ includes/class-tmdo-core.php | 840 ++++ includes/class-tmdo-crypto.php | 370 ++ includes/class-tmdo-custom-table-registry.php | 297 ++ includes/class-tmdo-db.php | 210 + includes/class-tmdo-feature-flags.php | 346 ++ includes/class-tmdo-hook-bus-bridge.php | 232 ++ includes/class-tmdo-installer.php | 1348 ++++++ includes/class-tmdo-logger.php | 160 + includes/class-tmdo-post-shadow-verifier.php | 437 ++ includes/class-tmdo-post-stress-tester.php | 1043 +++++ includes/class-tmdo-postmeta-cleaner.php | 229 + includes/class-tmdo-rest-api.php | 1608 +++++++ includes/class-tmdo-safe-unserialize.php | 91 + includes/class-tmdo-schema-registry.php | 287 ++ includes/class-tmdo-sqlite-compat.php | 359 ++ includes/class-tmdo-term-comment-backfill.php | 224 + ...lass-tmdo-term-comment-shadow-verifier.php | 428 ++ includes/class-tmdo-term-stress-tester.php | 880 ++++ includes/class-tmdo-termmeta-cleaner.php | 213 + includes/class-tmdo-user-stress-tester.php | 1417 +++++++ includes/class-tmdo-v2-upgrader.php | 301 ++ includes/class-tmdo-zone-classifier.php | 396 ++ .../diagnostic/class-tmdo-health-cron.php | 288 ++ .../diagnostic/class-tmdo-monthly-summary.php | 283 ++ .../diagnostic/class-tmdo-site-health.php | 491 +++ .../class-tmdo-site-metrics-collector.php | 236 ++ includes/engine/class-tmdo-audit-logger.php | 322 ++ includes/engine/class-tmdo-auto-promoter.php | 134 + .../engine/class-tmdo-cache-orchestrator.php | 205 + .../engine/class-tmdo-conflict-detector.php | 148 + includes/engine/class-tmdo-entity-health.php | 329 ++ .../class-tmdo-entity-migration-engine.php | 799 ++++ .../engine/class-tmdo-entity-registry.php | 226 + includes/engine/class-tmdo-hook-bus.php | 658 +++ includes/engine/class-tmdo-mode-manager.php | 380 ++ includes/engine/class-tmdo-query-compiler.php | 322 ++ includes/engine/class-tmdo-schema-manager.php | 341 ++ .../engine/class-tmdo-shadow-diff-logger.php | 407 ++ includes/engine/class-tmdo-type-caster.php | 165 + includes/export/class-tmdo-csv-writer.php | 83 + .../class-tmdo-demo-entity-counter.php | 227 + .../integrations/class-tmdo-member-fields.php | 498 +++ .../class-tmdo-points-manager.php | 256 ++ .../integrations/class-tmdo-post-fields.php | 487 +++ ...class-tmdo-term-comment-garbage-filter.php | 229 + .../class-tmdo-term-comment-misc-bucket.php | 412 ++ .../class-tmdo-interceptor-base.php | 195 + .../interceptors/class-tmdo-sync-bridge.php | 400 ++ .../class-tmdo-archive-migration.php | 168 + .../migration/class-tmdo-cold-migration.php | 166 + .../migration/class-tmdo-hot-migration.php | 191 + .../migration/class-tmdo-migration-base.php | 287 ++ .../migration/class-tmdo-migration-engine.php | 329 ++ .../class-tmdo-migration-orchestrator.php | 1107 +++++ .../migration/class-tmdo-post-migration.php | 628 +++ .../migration/class-tmdo-warm-migration.php | 140 + .../abstract-class-tmdo-notifier.php | 249 ++ .../class-tmdo-discord-notifier.php | 76 + .../class-tmdo-email-notifier.php | 238 ++ .../class-tmdo-slack-notifier.php | 78 + .../class-tmdo-telegram-notifier.php | 95 + .../query/class-tmdo-post-query-router.php | 320 ++ .../class-tmdo-query-interceptor-base.php | 358 ++ includes/query/class-tmdo-query-router.php | 292 ++ includes/safety/class-tmdo-fsm-guard.php | 215 + .../snapshots/class-tmdo-snapshot-manager.php | 431 ++ .../snapshots/class-tmdo-snapshot-pruner.php | 198 + .../snapshots/class-tmdo-snapshot-reader.php | 266 ++ .../snapshots/class-tmdo-snapshot-writer.php | 331 ++ includes/trait-tmdo-anti-eav-aware.php | 211 + includes/zones/class-tmdo-zone-archive.php | 271 ++ includes/zones/class-tmdo-zone-cold.php | 262 ++ includes/zones/class-tmdo-zone-hot.php | 212 + includes/zones/class-tmdo-zone-warm.php | 193 + languages/2meet-data-optimizer-zh_TW.mo | Bin 0 -> 7067 bytes languages/2meet-data-optimizer-zh_TW.po | 387 ++ languages/2meet-data-optimizer.pot | 541 +++ .../options/class-tmdo-options-manager.php | 285 ++ modules/options/index.php | 10 + phpunit-integration.xml | 15 + phpunit.xml | 21 + tests/bootstrap.php | 651 +++ .../integration/BenchmarkIntegrationTest.php | 297 ++ tests/integration/CommentStressTesterTest.php | 322 ++ .../CommentmetaCleanerIntegrationTest.php | 169 + tests/integration/CryptoMigrationTest.php | 159 + tests/integration/DemoEntityCounterTest.php | 268 ++ tests/integration/InstallerCleanupTest.php | 178 + tests/integration/InstallerV2Test.php | 118 + .../MemberBackfillIntegrationTest.php | 226 + .../integration/MigrationOrchestratorTest.php | 219 + .../PointsAtomicIntegrationTest.php | 261 ++ tests/integration/PostBackfillJsonTest.php | 224 + tests/integration/PostBenchmarkTest.php | 159 + tests/integration/PostEntityLifecycleTest.php | 260 ++ tests/integration/PostLegacyCutoverTest.php | 239 ++ tests/integration/PostMigrationTest.php | 240 ++ tests/integration/PostQueryRouterTest.php | 245 ++ tests/integration/PostShadowVerifierTest.php | 330 ++ tests/integration/PostStressTesterTest.php | 476 +++ .../PostmetaCleanerIntegrationTest.php | 204 + .../QueryRouterIntegrationTest.php | 373 ++ tests/integration/RestApiIntegrationTest.php | 322 ++ .../integration/SyncBridgeEntityGuardTest.php | 262 ++ .../integration/SyncBridgeIntegrationTest.php | 272 ++ .../TermCommentGarbageFilterTest.php | 164 + .../integration/TermCommentMiscBucketTest.php | 211 + .../TermCommentShadowVerifierTest.php | 292 ++ tests/integration/TermStressTesterTest.php | 315 ++ .../TermmetaCleanerIntegrationTest.php | 182 + tests/integration/UpgradeV2Test.php | 134 + .../WarmArchiveIntegrationTest.php | 269 ++ .../ZoneArchiveIntegrationTest.php | 203 + tests/integration/ZoneColdIntegrationTest.php | 223 + tests/integration/ZoneHotIntegrationTest.php | 126 + tests/integration/ZoneWarmIntegrationTest.php | 169 + tests/integration/bootstrap.php | 604 +++ tests/unit/Advisor/FSMAdvisorTest.php | 140 + tests/unit/Advisor/FSMAutomatorTest.php | 119 + tests/unit/Advisor/ModuleDetectorTest.php | 160 + tests/unit/BackCompatTest.php | 55 + tests/unit/CacheLayerTest.php | 158 + tests/unit/CapabilityTest.php | 92 + tests/unit/CliLintTest.php | 139 + tests/unit/ConflictMonitorTest.php | 76 + tests/unit/CryptoV2Test.php | 197 + tests/unit/CustomTableRegistryTest.php | 261 ++ tests/unit/Diagnostic/HealthCronTest.php | 132 + tests/unit/Export/CsvWriterTest.php | 48 + tests/unit/FeatureFlagsTest.php | 112 + tests/unit/HookBusBridgeTest.php | 112 + tests/unit/LoggerTest.php | 133 + .../MemberFieldsRegistrationTest.php | 202 + tests/unit/MemberFields/PointsManagerTest.php | 231 + tests/unit/MigrationEngineTest.php | 136 + .../unit/Notifications/EmailNotifierTest.php | 142 + .../MultiChannelNotifierTest.php | 197 + .../PostFields/PostFieldsRegistrationTest.php | 178 + tests/unit/PublicApiTest.php | 142 + tests/unit/RestApiTest.php | 296 ++ tests/unit/Safety/FSMGuardTest.php | 240 ++ tests/unit/SchemaRegistryTest.php | 131 + tests/unit/ShadowReadFlagTest.php | 80 + tests/unit/Snapshots/SnapshotManagerTest.php | 259 ++ tests/unit/SyncBridgeTest.php | 212 + tests/unit/ZoneArchiveTest.php | 302 ++ tests/unit/ZoneClassifierTest.php | 143 + tests/unit/ZoneColdTest.php | 229 + tests/unit/ZoneHotTest.php | 161 + tests/unit/ZoneWarmTest.php | 183 + uninstall.php | 244 ++ 206 files changed, 66538 insertions(+) create mode 100644 .gitignore create mode 100644 2meet-data-optimizer.php create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 DEPLOY.md create mode 100644 DESIGN.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 admin/assets/wpdo-admin.css create mode 100644 admin/assets/wpdo-admin.js create mode 100644 admin/assets/wpdo-comment-stress-test.js create mode 100644 admin/assets/wpdo-entity-bridge.js create mode 100644 admin/assets/wpdo-migration-wizard.css create mode 100644 admin/assets/wpdo-migration-wizard.js create mode 100644 admin/assets/wpdo-post-stress-test.js create mode 100644 admin/assets/wpdo-rest-sdk.js create mode 100644 admin/assets/wpdo-stress-test.js create mode 100644 admin/assets/wpdo-term-stress-test.js create mode 100644 admin/class-tmdo-admin.php create mode 100644 admin/class-tmdo-dashboard-widget.php create mode 100644 admin/class-tmdo-export.php create mode 100644 admin/class-tmdo-help-tabs.php create mode 100644 admin/class-tmdo-setup-wizard.php create mode 100644 admin/templates/comment-stress-test.php create mode 100644 admin/templates/migration-wizard.php create mode 100644 admin/templates/post-migration-wizard.php create mode 100644 admin/templates/post-stress-test.php create mode 100644 admin/templates/term-stress-test.php create mode 100644 cli/class-tmdo-cli-member.php create mode 100644 cli/class-tmdo-cli-post.php create mode 100644 cli/class-tmdo-cli-term-comment.php create mode 100644 cli/class-tmdo-cli-v2.php create mode 100644 cli/class-tmdo-cli.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 includes/adapters/class-tmdo-adapter-comment.php create mode 100644 includes/adapters/class-tmdo-adapter-post.php create mode 100644 includes/adapters/class-tmdo-adapter-term.php create mode 100644 includes/adapters/class-tmdo-adapter-user.php create mode 100644 includes/adapters/interface-entity-adapter.php create mode 100644 includes/advisor/class-tmdo-fsm-advisor.php create mode 100644 includes/advisor/class-tmdo-fsm-automator.php create mode 100644 includes/advisor/class-tmdo-module-detector.php create mode 100644 includes/advisor/class-tmdo-module-rules.php create mode 100644 includes/back-compat/interface-wpdo-entity-adapter-alias.php create mode 100644 includes/back-compat/trait-wpdo-anti-eav-aware-alias.php create mode 100644 includes/class-tmdo-api.php create mode 100644 includes/class-tmdo-back-compat.php create mode 100644 includes/class-tmdo-cache-layer.php create mode 100644 includes/class-tmdo-capability.php create mode 100644 includes/class-tmdo-comment-stress-tester.php create mode 100644 includes/class-tmdo-commentmeta-cleaner.php create mode 100644 includes/class-tmdo-compatibility.php create mode 100644 includes/class-tmdo-conflict-monitor.php create mode 100644 includes/class-tmdo-core.php create mode 100644 includes/class-tmdo-crypto.php create mode 100644 includes/class-tmdo-custom-table-registry.php create mode 100644 includes/class-tmdo-db.php create mode 100644 includes/class-tmdo-feature-flags.php create mode 100644 includes/class-tmdo-hook-bus-bridge.php create mode 100644 includes/class-tmdo-installer.php create mode 100644 includes/class-tmdo-logger.php create mode 100644 includes/class-tmdo-post-shadow-verifier.php create mode 100644 includes/class-tmdo-post-stress-tester.php create mode 100644 includes/class-tmdo-postmeta-cleaner.php create mode 100644 includes/class-tmdo-rest-api.php create mode 100644 includes/class-tmdo-safe-unserialize.php create mode 100644 includes/class-tmdo-schema-registry.php create mode 100644 includes/class-tmdo-sqlite-compat.php create mode 100644 includes/class-tmdo-term-comment-backfill.php create mode 100644 includes/class-tmdo-term-comment-shadow-verifier.php create mode 100644 includes/class-tmdo-term-stress-tester.php create mode 100644 includes/class-tmdo-termmeta-cleaner.php create mode 100644 includes/class-tmdo-user-stress-tester.php create mode 100644 includes/class-tmdo-v2-upgrader.php create mode 100644 includes/class-tmdo-zone-classifier.php create mode 100644 includes/diagnostic/class-tmdo-health-cron.php create mode 100644 includes/diagnostic/class-tmdo-monthly-summary.php create mode 100644 includes/diagnostic/class-tmdo-site-health.php create mode 100644 includes/diagnostic/class-tmdo-site-metrics-collector.php create mode 100644 includes/engine/class-tmdo-audit-logger.php create mode 100644 includes/engine/class-tmdo-auto-promoter.php create mode 100644 includes/engine/class-tmdo-cache-orchestrator.php create mode 100644 includes/engine/class-tmdo-conflict-detector.php create mode 100644 includes/engine/class-tmdo-entity-health.php create mode 100644 includes/engine/class-tmdo-entity-migration-engine.php create mode 100644 includes/engine/class-tmdo-entity-registry.php create mode 100644 includes/engine/class-tmdo-hook-bus.php create mode 100644 includes/engine/class-tmdo-mode-manager.php create mode 100644 includes/engine/class-tmdo-query-compiler.php create mode 100644 includes/engine/class-tmdo-schema-manager.php create mode 100644 includes/engine/class-tmdo-shadow-diff-logger.php create mode 100644 includes/engine/class-tmdo-type-caster.php create mode 100644 includes/export/class-tmdo-csv-writer.php create mode 100644 includes/integrations/class-tmdo-demo-entity-counter.php create mode 100644 includes/integrations/class-tmdo-member-fields.php create mode 100644 includes/integrations/class-tmdo-points-manager.php create mode 100644 includes/integrations/class-tmdo-post-fields.php create mode 100644 includes/integrations/class-tmdo-term-comment-garbage-filter.php create mode 100644 includes/integrations/class-tmdo-term-comment-misc-bucket.php create mode 100644 includes/interceptors/class-tmdo-interceptor-base.php create mode 100644 includes/interceptors/class-tmdo-sync-bridge.php create mode 100644 includes/migration/class-tmdo-archive-migration.php create mode 100644 includes/migration/class-tmdo-cold-migration.php create mode 100644 includes/migration/class-tmdo-hot-migration.php create mode 100644 includes/migration/class-tmdo-migration-base.php create mode 100644 includes/migration/class-tmdo-migration-engine.php create mode 100644 includes/migration/class-tmdo-migration-orchestrator.php create mode 100644 includes/migration/class-tmdo-post-migration.php create mode 100644 includes/migration/class-tmdo-warm-migration.php create mode 100644 includes/notifications/abstract-class-tmdo-notifier.php create mode 100644 includes/notifications/class-tmdo-discord-notifier.php create mode 100644 includes/notifications/class-tmdo-email-notifier.php create mode 100644 includes/notifications/class-tmdo-slack-notifier.php create mode 100644 includes/notifications/class-tmdo-telegram-notifier.php create mode 100644 includes/query/class-tmdo-post-query-router.php create mode 100644 includes/query/class-tmdo-query-interceptor-base.php create mode 100644 includes/query/class-tmdo-query-router.php create mode 100644 includes/safety/class-tmdo-fsm-guard.php create mode 100644 includes/snapshots/class-tmdo-snapshot-manager.php create mode 100644 includes/snapshots/class-tmdo-snapshot-pruner.php create mode 100644 includes/snapshots/class-tmdo-snapshot-reader.php create mode 100644 includes/snapshots/class-tmdo-snapshot-writer.php create mode 100644 includes/trait-tmdo-anti-eav-aware.php create mode 100644 includes/zones/class-tmdo-zone-archive.php create mode 100644 includes/zones/class-tmdo-zone-cold.php create mode 100644 includes/zones/class-tmdo-zone-hot.php create mode 100644 includes/zones/class-tmdo-zone-warm.php create mode 100644 languages/2meet-data-optimizer-zh_TW.mo create mode 100644 languages/2meet-data-optimizer-zh_TW.po create mode 100644 languages/2meet-data-optimizer.pot create mode 100644 modules/options/class-tmdo-options-manager.php create mode 100644 modules/options/index.php create mode 100644 phpunit-integration.xml create mode 100644 phpunit.xml create mode 100644 tests/bootstrap.php create mode 100644 tests/integration/BenchmarkIntegrationTest.php create mode 100644 tests/integration/CommentStressTesterTest.php create mode 100644 tests/integration/CommentmetaCleanerIntegrationTest.php create mode 100644 tests/integration/CryptoMigrationTest.php create mode 100644 tests/integration/DemoEntityCounterTest.php create mode 100644 tests/integration/InstallerCleanupTest.php create mode 100644 tests/integration/InstallerV2Test.php create mode 100644 tests/integration/MemberFields/MemberBackfillIntegrationTest.php create mode 100644 tests/integration/MigrationOrchestratorTest.php create mode 100644 tests/integration/PointsAtomicIntegrationTest.php create mode 100644 tests/integration/PostBackfillJsonTest.php create mode 100644 tests/integration/PostBenchmarkTest.php create mode 100644 tests/integration/PostEntityLifecycleTest.php create mode 100644 tests/integration/PostLegacyCutoverTest.php create mode 100644 tests/integration/PostMigrationTest.php create mode 100644 tests/integration/PostQueryRouterTest.php create mode 100644 tests/integration/PostShadowVerifierTest.php create mode 100644 tests/integration/PostStressTesterTest.php create mode 100644 tests/integration/PostmetaCleanerIntegrationTest.php create mode 100644 tests/integration/QueryRouterIntegrationTest.php create mode 100644 tests/integration/RestApiIntegrationTest.php create mode 100644 tests/integration/SyncBridgeEntityGuardTest.php create mode 100644 tests/integration/SyncBridgeIntegrationTest.php create mode 100644 tests/integration/TermCommentGarbageFilterTest.php create mode 100644 tests/integration/TermCommentMiscBucketTest.php create mode 100644 tests/integration/TermCommentShadowVerifierTest.php create mode 100644 tests/integration/TermStressTesterTest.php create mode 100644 tests/integration/TermmetaCleanerIntegrationTest.php create mode 100644 tests/integration/UpgradeV2Test.php create mode 100644 tests/integration/WarmArchiveIntegrationTest.php create mode 100644 tests/integration/ZoneArchiveIntegrationTest.php create mode 100644 tests/integration/ZoneColdIntegrationTest.php create mode 100644 tests/integration/ZoneHotIntegrationTest.php create mode 100644 tests/integration/ZoneWarmIntegrationTest.php create mode 100644 tests/integration/bootstrap.php create mode 100644 tests/unit/Advisor/FSMAdvisorTest.php create mode 100644 tests/unit/Advisor/FSMAutomatorTest.php create mode 100644 tests/unit/Advisor/ModuleDetectorTest.php create mode 100644 tests/unit/BackCompatTest.php create mode 100644 tests/unit/CacheLayerTest.php create mode 100644 tests/unit/CapabilityTest.php create mode 100644 tests/unit/CliLintTest.php create mode 100644 tests/unit/ConflictMonitorTest.php create mode 100644 tests/unit/CryptoV2Test.php create mode 100644 tests/unit/CustomTableRegistryTest.php create mode 100644 tests/unit/Diagnostic/HealthCronTest.php create mode 100644 tests/unit/Export/CsvWriterTest.php create mode 100644 tests/unit/FeatureFlagsTest.php create mode 100644 tests/unit/HookBusBridgeTest.php create mode 100644 tests/unit/LoggerTest.php create mode 100644 tests/unit/MemberFields/MemberFieldsRegistrationTest.php create mode 100644 tests/unit/MemberFields/PointsManagerTest.php create mode 100644 tests/unit/MigrationEngineTest.php create mode 100644 tests/unit/Notifications/EmailNotifierTest.php create mode 100644 tests/unit/Notifications/MultiChannelNotifierTest.php create mode 100644 tests/unit/PostFields/PostFieldsRegistrationTest.php create mode 100644 tests/unit/PublicApiTest.php create mode 100644 tests/unit/RestApiTest.php create mode 100644 tests/unit/Safety/FSMGuardTest.php create mode 100644 tests/unit/SchemaRegistryTest.php create mode 100644 tests/unit/ShadowReadFlagTest.php create mode 100644 tests/unit/Snapshots/SnapshotManagerTest.php create mode 100644 tests/unit/SyncBridgeTest.php create mode 100644 tests/unit/ZoneArchiveTest.php create mode 100644 tests/unit/ZoneClassifierTest.php create mode 100644 tests/unit/ZoneColdTest.php create mode 100644 tests/unit/ZoneHotTest.php create mode 100644 tests/unit/ZoneWarmTest.php create mode 100644 uninstall.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ac74fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +vendor/ +node_modules/ +.phpunit.result.cache +.phpcs-cache +*.log +*.bak +*.swp +.DS_Store +Thumbs.db +.idea/ +.vscode/ +.full-review/ +.gstack/ +.playwright-mcp/ +.claude/ +dist/ diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php new file mode 100644 index 0000000..69248a4 --- /dev/null +++ b/2meet-data-optimizer.php @@ -0,0 +1,331 @@ +

'; + printf( + /* translators: 1: required PHP version, 2: current PHP version */ + esc_html__( '2meet Data Optimizer 需要 PHP %1$s 或更高版本(目前 %2$s)。', '2meet-data-optimizer' ), + esc_html( TMDO_MIN_PHP ), + esc_html( PHP_VERSION ) + ); + echo '

'; + } + ); + return; +} + +// 互斥檢查:偵測既有 wp-data-optimizer v2.x 仍啟用時警告(avoid hook double-fire)。 +add_action( + 'admin_notices', + static function () { + if ( defined( 'WPDO_VERSION' ) && WPDO_VERSION !== TMDO_VERSION ) { + echo '

'; + esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' ); + echo '

'; + } + } +); + +// ── i18n ────────────────────────────────────────────────────────────────── +add_action( 'init', 'tmdo_load_textdomain' ); + +/** + * Load plugin text domain for translations. + */ +function tmdo_load_textdomain(): void { + load_plugin_textdomain( + '2meet-data-optimizer', + false, + dirname( plugin_basename( TMDO_FILE ) ) . '/languages' + ); +} + +// ── Core includes ───────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-capability.php'; +require_once TMDO_PATH . 'includes/class-tmdo-crypto.php'; +require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php'; +require_once TMDO_PATH . 'includes/class-tmdo-db.php'; +require_once TMDO_PATH . 'includes/class-tmdo-logger.php'; +require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php'; +require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php'; +require_once TMDO_PATH . 'includes/class-tmdo-installer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php'; +require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php'; +require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php'; +require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php'; +require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php'; +require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php'; +require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php'; + +// ── Interceptor base + Sync Bridge(核心唯一保留 interceptor)───────────── +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php'; +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; + +// ── Zone handlers ───────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php'; + +// ── Query interceptors(核心通用部分)───────────────────────────────────── +require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php'; + +// ── Migration engine ────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php'; + +// ── Integrations(核心保留 6 個通用整合,無 HP/WC/LP/2meet 依存)──────── +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php'; + +// ── Cache + Classifier ─────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php'; + +// ── Public API facade ───────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-api.php'; + +// ── v2 Upgrader ─────────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php'; + +// ── Snapshot system ────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php'; + +// ── FSM transition guard ───────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php'; + +// ── Site Health integration ────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php'; +add_action( 'init', array( 'TMDO_Site_Health', 'register' ) ); + +// ── Daily health probe cron ───────────────────────────────────────────── +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-health-cron.php'; + +// ── Notifiers ───────────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/notifications/abstract-class-tmdo-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-email-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-slack-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-discord-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-telegram-notifier.php'; +TMDO_Email_Notifier::register(); +TMDO_Slack_Notifier::register(); +TMDO_Discord_Notifier::register(); +TMDO_Telegram_Notifier::register(); + +// ── Monthly executive summary ─────────────────────────────────────────── +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-monthly-summary.php'; +TMDO_Monthly_Summary::register(); + +// ── Daily site-wide EAV health metrics ────────────────────────────────── +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-metrics-collector.php'; + +// ── FSM advisor ───────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-advisor.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-rules.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-detector.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-automator.php'; + +// ── Report export ────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/export/class-tmdo-csv-writer.php'; +if ( is_admin() ) { + require_once TMDO_PATH . 'admin/class-tmdo-export.php'; + TMDO_Export::register(); +} + +// ── REST API ────────────────────────────────────────────────────────────── +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-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'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php'; +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/engine/class-tmdo-entity-health.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.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/engine/class-tmdo-auto-promoter.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.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'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php'; +require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; + +// ── Main loader ─────────────────────────────────────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-core.php'; + +// ── Admin UI ─────────────────────────────────────────────────────────────── +if ( is_admin() ) { + require_once TMDO_PATH . 'admin/class-tmdo-admin.php'; + require_once TMDO_PATH . 'admin/class-tmdo-help-tabs.php'; + TMDO_Help_Tabs::register(); + require_once TMDO_PATH . 'admin/class-tmdo-setup-wizard.php'; + TMDO_Setup_Wizard::register(); + require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php'; + TMDO_Dashboard_Widget::register(); +} + +// ── WP-CLI ───────────────────────────────────────────────────────────────── +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once TMDO_PATH . 'cli/class-tmdo-cli.php'; + WP_CLI::add_command( 'tmdo', 'TMDO_CLI' ); + // 向後相容:保留 `wp wpdo` 指令命名空間。 + WP_CLI::add_command( 'wpdo', 'TMDO_CLI' ); + require_once TMDO_PATH . 'cli/class-tmdo-cli-v2.php'; + require_once TMDO_PATH . 'cli/class-tmdo-cli-member.php'; + require_once TMDO_PATH . 'cli/class-tmdo-cli-post.php'; + require_once TMDO_PATH . 'cli/class-tmdo-cli-term-comment.php'; +} + +// ── 對外向後相容:WPDO_* 類別 / 常數 alias ───────────────────────────── +require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php'; + +// ── Boot plugin ──────────────────────────────────────────────────────────── +/** + * Plugin boot — fires at plugins_loaded:4 (early enough for HPCT-era priority 5). + */ +function tmdo_run(): void { + $core = new TMDO_Core(); + $core->run(); + + add_action( + 'rest_api_init', + static function () { + ( new TMDO_REST_API() )->register_routes(); + } + ); +} +add_action( 'plugins_loaded', 'tmdo_run', 4 ); + +// ── Late-bind safety net ────────────────────────────────────────────────── +// Re-fire registration hooks at priority 30 to catch AddOn / partner plugin +// listeners that bootstrap on plugins_loaded:6+ (after core but before +// init). +add_action( + 'plugins_loaded', + static function () { + if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { + TMDO_Custom_Table_Registry::instance()->fire_registration(); + } + // Re-fire entity field registration so AddOns hooked at plugins_loaded:6 catch up. + if ( class_exists( 'TMDO_Entity_Registry' ) ) { + do_action( 'wpdo_register_entity_fields', 'TMDO_Entity_Registry' ); + do_action( 'tmdo_register_entity_fields', 'TMDO_Entity_Registry' ); + } + if ( class_exists( 'TMDO_Schema_Registry' ) ) { + do_action( 'wpdo_register_fields', TMDO_Schema_Registry::instance() ); + do_action( 'tmdo_register_fields', TMDO_Schema_Registry::instance() ); + } + }, + 30 +); + +// ── Lifecycle hooks ──────────────────────────────────────────────────────── +register_activation_hook( __FILE__, array( 'TMDO_Installer', 'activate' ) ); +register_deactivation_hook( __FILE__, array( 'TMDO_Installer', 'deactivate' ) ); + +// Multisite +if ( is_multisite() ) { + add_action( 'wp_initialize_site', array( 'TMDO_Installer', 'on_new_site' ), 999, 1 ); + add_action( 'wp_uninitialize_site', array( 'TMDO_Installer', 'on_site_delete' ), 1, 1 ); +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..93b7dc4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,114 @@ +# Changelog + +All notable changes to this plugin will be documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Versioning follows [Semantic Versioning](https://semver.org/). + +--- + +## [0.1.0] — 2026-05-15 (initial release, Phase 0-5 完成) + +### Phase 5 hotfix(同日完成) +- 修 `TMDO_WooCommerce::doctor_check()` callback signature mismatch(cli/class-tmdo-cli.php:264 改 call_user_func 傳 3 參數) +- 加 `includes/back-compat/trait-wpdo-anti-eav-aware-alias.php` — trait + interface 反向相容(PHP class_alias 不支援 trait/interface) + - `WPDO_Anti_EAV_Aware` trait wrapper 可讓 2meet-brandcards 等 sister plugin 直接 `use` + - `WPDO_Entity_Adapter_Interface` extends `TMDO_Entity_Adapter_Interface` +- 完整 `uninstall.php`(核心 244 行從 wp-data-optimizer 提煉,34 張表 DROP TABLE 對齊) +- HP AddOn + infocards AddOn `uninstall.php` 補 schema drift check 對齊 +- **12 plugin ZIP 全通過 10 終檢** + +### 12 ZIP 交付(dist/) +| Plugin | Size | MD5 | +|---|---:|---| +| 2meet-data-optimizer | 521 KB | a10bb191e662cb5fb53a3d23165c71e5 | +| -hivepress-addon | 117 KB | 24bd8c3211421fed86dc551658d7064a | +| -woocommerce-addon | 34 KB | fbd6f60368fc93310f2e90e58c95b53c | +| -spoke-addon | 24 KB | c1e92b755fb1d108a141086ccf1fe519 | +| -hub-addon | 22 KB | 4e010c63ed8ad1dd24f06b8741e3a1b0 | +| -infocards-addon | 22 KB | 7f095fdc4748d3ff4cd4e05f1bf3d417 | +| -latepoint-addon | 21 KB | 60eeb4ea67cdc996cb60b6ad7126f2df | +| -bookings-addon | 21 KB | ac32b85d84e912d3b5a1cdfb56f9fa96 | +| -mobile-bridge-addon | 20 KB | 59905719cfee4bd31967736517391a43 | +| -collab-addon | 20 KB | 1ed0a2983a692a94b6c42f21b62af2c2 | +| -quotation-addon | 20 KB | 5e693a4df3ce48de447cf6277deaece5 | +| -playlist-addon | 20 KB | d52552782f383abbe8f278a5b85cafef | + +### CLI smoke test(12 指令 全 PASS) +- `wp tmdo status / doctor / analyze / install / cleanup / health-snapshot / site-metrics / benchmark` +- `wp wpdo status / doctor` (alias 路徑) +- `wp tmdo spoke-sync --dry-run` / `wp tmdo spoke-force-logout --user-id=1 --dry-run` + +--- + +## [0.1.0] — 2026-05-15 (initial release, Phase 0-4 完成) + +### Added — 核心反 EAV 引擎(從 wp-data-optimizer v2.16.0 提煉) +- 4 Zone 分流(Hot / Warm / Cold / Archive)+ Sync Bridge 雙寫 +- 7 態 FSM 模組生命週期(idle → dual_write → backfill → verify → cutover → cleanup → complete) +- 4 entity 通用引擎(post / user / term / comment)+ Hook Bus + Mode Manager +- Query Router (`pre_get_posts` 改寫) + Query Compiler +- Migration Engine + Orchestrator + Backfill + Stress Tester + Shadow Verifier +- Snapshot / Safety / Diagnostic / Notifications (Email / Slack / Discord / Telegram) +- Crypto AES-256-GCM AEAD + Safe Unserialize +- WP-CLI 18+ subcommand +- Admin Dashboard + KPI Hero + Tab Groups + Setup Wizard + Help Tabs + +### Added — 對外向後相容 +- `TMDO_*` 類別 + `class_alias` 80+ 個 → 對外保留 `WPDO_*` 類別名 +- 8 個常數 alias(`WPDO_VERSION` / `WPDO_PLUGIN_DIR` / `WPDO_TABLE_PREFIX` 等) +- 9 個 hook dual-fire 橋接(`tmdo_register_fields` ↔ `wpdo_register_fields` 等) +- WP-CLI 雙命名空間:`wp tmdo` (主) + `wp wpdo` (alias) + +### Removed — 整合層搬出 +- HivePress + 12 擴充整合(13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI)→ 搬到 `2meet-data-optimizer-hivepress-addon` +- WooCommerce 整合 → `2meet-data-optimizer-woocommerce-addon` +- LatePoint 整合 → `2meet-data-optimizer-latepoint-addon` +- 2meet-infocards / bookings / quotation / mobile-bridge / collab / playlist 整合 → 各自 6 個 AddOn +- Hub-core SSO group 與自訂表註冊 → `2meet-data-optimizer-hub-addon` +- Spoke-sso SSO group 與 CLI → `2meet-data-optimizer-spoke-addon` +- `TMDO_Core::register_hivepress_defaults()` (193 行) → HP AddOn + +### Migration from `wp-data-optimizer v2.16.0` + +- `wp_wpdo_*` 表結構完全相容(不 rename,新外掛沿用既有 schema) +- 舊外掛先停用即可改用新外掛接管 +- 兩外掛**互斥**:本外掛偵測舊外掛仍 active 時會發 admin notice(plan §8.1) + +### Limitations / Known issues (Phase 5 follow-up) + +- `tests/` 目錄空殼,PHPUnit unit + integration tests 需後續搬遷 +- `phpcs.xml.dist` 缺;`composer install --dev` 後可手動跑 +- WP 6.5+ `Requires Plugins:` 強制 Plugin Name 比對;hub-core / spoke-sso 仍寫死 `wp-data-optimizer`,需更新或本 plugin 加偽 `Plugin Name: WP Data Optimizer` alias header(Phase 5) +- `TMDO_WooCommerce::doctor_check()` signature 與 Custom_Table_Registry callback 不對齊(warning level) +- Trait `Trait_TMDO_Anti_EAV_Aware` 暫無 alias wrapper(class_alias 不適用 trait) + +### Phase 0-4 統計 +- **PHP files**: 203(核心 111 + Hub 5 + Spoke 7 + HP 47 + WC 8 + LP 5 + 6 family 各 5) +- **PHP lint**: 全 PASS +- **Plugin active**: 12/12 +- **Custom tables registered**: 14(Hub 12 + Spoke 2,未含 6 family no-op) +- **SSO group fields**: 7 +- **Schema fields**: 23 (Hot 20 + Cold 3,HP 未安裝 → HP fields no-op) + +--- + +## 來源外掛累計重要 release(供考古) + +詳見 `/var/www/Studio/wp-local-dev30/wp-content/plugins/wp-data-optimizer/CHANGELOG.md` 與 `CLAUDE.md`:v2.0.0 → v2.16.0 共 47 次發版的累計改動。 + +亮點: +- v2.6.4 H-4 加密 webhook 密鑰 +- v2.13.3 audit 7/7 finding 全清零 +- v2.15.0 AES-256-CBC → AES-256-GCM AEAD +- v2.16.0 UI/UX KPI Hero + Grouped Tab Navigation + +--- + +## [Unreleased] + +### Phase 5 候補 +- Tests 套件搬遷 +- PHPCS 套件 + 0/0 達成 +- 打包腳本 10 終檢驗收 +- WPDO_ → TMDO_ migration CLI diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..430d8aa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md — 2meet Data Optimizer (核心) + +> 父環境:`/var/www/Studio/wp-local-dev/CLAUDE.md` 與 `/var/www/Studio/wp-local-dev30/CLAUDE.md` +> 全域:`~/.claude/CLAUDE.md`(含 Karpathy Guidelines) + +--- + +## Reset recovery + +1. 讀本檔(CLAUDE.md)取得 plugin 上下文 +2. 讀 [PLAN.md](PLAN.md) 看當前 phase 進度 +3. 接續 PLAN.md 的下一個未完成步驟 + +--- + +## Plugin 概述 + +**2meet-data-optimizer** 是純通用 WordPress 反 EAV 引擎,從 `wp-data-optimizer v2.16.0` 提煉。 + +- 不依存 HivePress / WooCommerce / LatePoint / 2meet-* +- 4 entity 通用(post/user/term/comment) +- 整合層全部交給 11 個獨立 AddOn + +完整拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`。 + +--- + +## 開發紀律 + +### 命名前綴 +- 類別:`TMDO_*` +- 函式:`tmdo_*` +- 常數:`TMDO_VERSION` / `TMDO_PATH` / `TMDO_URL` / `TMDO_FILE` +- Hook:`tmdo_*`(同時 dual-fire `wpdo_*` 過渡) +- Text-domain:`2meet-data-optimizer` + +### 對外相容 +- 主要 API 公開:`TMDO_API`、`TMDO_Schema_Registry`、`TMDO_Entity_Registry`、`TMDO_Custom_Table_Registry`、`TMDO_Feature_Flags`、`TMDO_DB`、`TMDO_Crypto`、`TMDO_Logger` +- **必加 `class_alias( 'TMDO_*', 'WPDO_*' )`** 對應全部上述 8 個類別 +- **必 dual-fire** `tmdo_*` 與 `wpdo_*` 共 9 個 hook(見 plan § 11.2) + +### 反 EAV 紅線(CI gate 守門) +- ❌ `SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta`(除一次性 migration / cron) +- ❌ 硬編碼表名 `wp_postmeta` / `wp_options`,必走 `TMDO_DB::table()` +- ❌ 主動 `add_action('updated_postmeta', ...)`(與 Hook Bus 衝突) +- ❌ 單外掛 `autoload=yes` 的 `wp_options` > 30 條 +- ❌ 直接 `unserialize()`,必走 `TMDO_Safe_Unserialize::run()` + +### 安全 +- 所有 SQL 走 `$wpdb->prepare()` +- 動態 IN 子句用 `array_fill('%d')` 動態佔位符 +- DROP TABLE 前白名單 + prefix 雙重驗證 +- `manage_options` capability check + nonce +- 輸出 `esc_html()` / `esc_attr()` / `esc_url()` + +### Crypto +- `TMDO_Crypto`:AES-256-GCM AEAD(`enc:v2:` 格式),向後相容讀取 `enc:v1:` (CBC) +- 加密 wp_options 內 webhook 密鑰 + +--- + +## 任務管理 + +- 開始任務前更新 `PLAN.md` +- 每完成一步驟,標記 ✅ +- 重要決策 / 踩坑記錄在 PLAN.md「Lessons learned」 + +--- + +## 常用指令 + +```bash +# Lint +find . -name "*.php" | grep -v vendor | grep -v tests | xargs php -l + +# Unit tests +./vendor/bin/phpunit --configuration phpunit.xml + +# Integration tests(需本機 MariaDB) +./vendor/bin/phpunit --configuration phpunit-integration.xml + +# WP-CLI +wp --path=/var/www/Studio/wp-local-dev30 tmdo status +wp --path=/var/www/Studio/wp-local-dev30 tmdo doctor +wp --path=/var/www/Studio/wp-local-dev30 tmdo benchmark hot_post --samples=200 + +# 打包(必經 10 終檢) +/var/www/Studio/wp-local-dev/scripts/package-plugin.sh 2meet-data-optimizer +``` + +--- + +## 邊界 + +- ✅ 本外掛觸碰:4-entity 反 EAV 引擎、zone 表、migration、wizard、admin、CLI、REST、snapshot、diagnostic、notifications +- ❌ 本外掛**不**觸碰:HP / WC / LP / 2meet-* 邏輯(全部交給 AddOn) +- ❌ 本外掛**不**註冊:HP listing 欄位、WC product 欄位、SSO sso group、infocards postmeta(全部交給 AddOn) + +--- + +## Phase 進度 + +``` +Phase 0 ✅ 骨架建立 (2026-05-15) +Phase 1 ⬜ Core v0.1.0 (W1-W2) +``` + +詳見 `PLAN.md`。 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..b801e92 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,121 @@ +# 部署流程 + +## Prerequisites + +- WordPress ≥ 6.0 +- PHP ≥ 8.1 +- MySQL ≥ 5.7 / MariaDB ≥ 10.3 +- 站台目前已停用既有 `wp-data-optimizer`(若有) + +> ⚠️ 兩個外掛**互斥**:本外掛啟動時偵測到 `WPDO_Core` 已存在會發 admin notice 要求停用舊外掛。 + +--- + +## 全新站台部署 + +```bash +# 上傳 + 啟用 +wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip --activate + +# 驗證 +wp tmdo doctor +wp tmdo status + +# 第一次健診 +wp tmdo benchmark --samples=100 +``` + +完成。系統表會自動 dbDelta 建立,無需手動操作。 + +--- + +## 從 wp-data-optimizer v2.16.0 接管 + +兩種策略: + +### 策略 A — 退場式接管(推薦) + +```bash +# 1. 停用舊外掛 +wp plugin deactivate wp-data-optimizer + +# 2. 上傳新外掛 + 啟用 +wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip --activate + +# 3. 新外掛偵測既有 wp_wpdo_* 表,自動 dbDelta 對齊(無資料變動) +wp tmdo doctor + +# 4. 驗證 4-entity migration mode 仍存在 +wp tmdo user-mode-status +wp tmdo post-mode-status + +# 5. 移除舊外掛資料夾(保留 git 紀錄) +wp plugin delete wp-data-optimizer +``` + +### 策略 B — 並行驗證(謹慎環境) + +```bash +# 1. 新外掛安裝但暫不啟用 +wp plugin install /path/to/2meet-data-optimizer-v0.1.0.zip + +# 2. 在 staging 跑 verify +wp --path=/srv/staging tmdo doctor +wp --path=/srv/staging tmdo benchmark --samples=200 + +# 3. staging 過 24h watch 後切回 production,按策略 A 接管 +``` + +--- + +## 安裝整合 AddOn + +整合 AddOn 採 `Requires Plugins:` 強制相依,需先啟用本核心: + +```bash +# 例:安裝 HivePress AddOn +wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate + +# 驗證 HP 13 adapter 已 bound +wp tmdo hivepress detect +``` + +--- + +## Rollback + +```bash +# 1. 停用新外掛 +wp plugin deactivate 2meet-data-optimizer + +# 2. 重啟舊外掛(資料完全相容) +wp plugin activate wp-data-optimizer + +# 3. 驗證 +wp wpdo doctor +``` + +每個 AddOn 卸載時**僅清自己的 schema**(系統表保留給 core / 其他 AddOn)。 + +--- + +## 監控 + +```bash +# 每日健康檢查 cron +wp tmdo health-cron status +wp tmdo health-cron run + +# 衝突偵測 +wp tmdo conflict-scan + +# Hook Bus 健診 +wp tmdo bridge-status +``` + +production 站台建議啟用通知: + +```bash +wp option update wpdo_slack_webhook 'https://hooks.slack.com/services/...' +wp option update wpdo_alert_threshold 'warning' +``` diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..afeccb7 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,130 @@ +# DESIGN — 2meet Data Optimizer 架構決策 + +## 設計目標(pillars) + +1. **Zero-side-effect**:未啟用任何 zone 的站台應感受不到此外掛存在 +2. **Non-destructive**:所有操作可 rollback(snapshot + 7-state FSM) +3. **Schema-first**:所有欄位需明確 register,禁止隱式 mapping +4. **Hook-bus only**:所有寫入必經 Hook Bus;禁止繞過 interceptor 直 SQL +5. **Anti-EAV strict**:紅線禁止 `SELECT FROM wp_*meta`(除一次性 migration / cron) + +--- + +## 七態 FSM 模組生命週期 + +``` + ┌──────────────────────────────────────────────┐ + │ │ + v │ +idle ──→ dual_write ──→ backfill ──→ verify ──→ cutover ──→ cleanup ──→ complete + │ │ + └─ rollback ─┘ +``` + +| 態 | 寫 | 讀 | 可逆 | +|---|---|---|---| +| idle | wp_*meta | wp_*meta | ✓ | +| dual_write | wp_*meta + zone | wp_*meta | ✓ | +| backfill | wp_*meta + zone | wp_*meta | ✓ | +| verify | wp_*meta + zone | wp_*meta | ✓ | +| cutover | wp_*meta + zone | zone | ✓ (rollback to verify) | +| cleanup | zone only | zone | ✗ (postmeta 已 DROP) | +| complete | zone only | zone | ✗ | + +--- + +## 四象限分配原則 + +| Zone | 條件 | 範例 | +|---|---|---| +| Hot | 同欄位被 meta_query 過濾或 ORDER BY 排序 | `_price`, `hp_featured` | +| Warm | 高頻寫入 / 短 TTL / 弱一致性 | view counter, hourly flag | +| Cold | 長文本 / 不被搜尋 / 顯示用 | `_description`, social links JSON | +| Archive | 過期資料 / gzip 壓縮 | expired listings 歷史欄位 | + +`TMDO_Zone_Classifier` 自動分析(transient 1h),但人工 register 永遠優先。 + +--- + +## Hook Bus 架構 + +Hook Bus 是寫入唯一真理之源。任何外掛要參與反 EAV 必須: + +```php +// 註冊 +add_action( 'tmdo_register_entity_fields', function ( $registry_class ) { + $registry_class::register_group( 'user', 'my_group', [...] ); +} ); + +// 訂閱事件 +add_action( 'tmdo_after_write', function ( $type, $id, $key, $value, $result ) { + // ... cross-domain logic +} ); +``` + +禁止直接 hook `update_user_meta` / `add_post_meta`(會與 Hook Bus 衝突,CI gate 偵測)。 + +--- + +## Schema Registry 雙軌 + +- **`TMDO_Schema_Registry`**:post entity 的 zone 欄位(HP listing 模式) +- **`TMDO_Entity_Registry`**:4 entity 通用 group(user/post/term/comment) + +兩者並存原因:post zone 模式較成熟,user/term/comment 較新;最終 v0.3.0 會收斂為單一 registry。 + +--- + +## Custom Table Registry + +第三方外掛的自訂表可註冊到 `TMDO_Custom_Table_Registry` 享有: +- `wp tmdo doctor` 表結構檢查 +- Schema drift 偵測(CREATE 與 DROP 對齊) +- Benchmark 整合(可選 `benchmark_callback`) +- 衝突偵測 + +僅限 schema 監控,不會被 Hook Bus 攔截寫入。 + +--- + +## 為什麼資料表沿用 `wp_wpdo_*` 不 rename 為 `wp_tmdo_*` + +- 避免 v2.16.0 → v0.1.0 資料遷移的成本與風險 +- 兩外掛同時存在時 schema 完全相容(互斥啟動) +- v0.2.0 後若決定 rename 再做(屆時提供 `wp tmdo migrate-from-wpdo`) + +--- + +## 為什麼公開 API 同時暴露 `TMDO_API` + `WPDO_API` (alias) + +- `class_alias( 'TMDO_API', 'WPDO_API' )` 確保 hub-core / spoke-sso / 其他 consumer 零修改可用 +- `wpdo_*` hook 與 `tmdo_*` hook 並存 dual-fire +- 過渡期 ≥ 2 個 minor 版本後加 `_doing_it_wrong` deprecation notice + +--- + +## CSS 設計系統 + +承襲父環境奶油莫蘭迪設計系統(見 `wp-local-dev/CLAUDE.md` § 設計系統): +- 顏色:`var(--color-*)`,禁止 HEX 硬編碼 +- 間距:`var(--space-*)` 8px 倍數 +- 圓角:`var(--radius-*)` +- 按鈕最小高度 44px +- 過渡:`var(--ease-default) + var(--duration-normal)` + +詳見 `admin/assets/wpdo-admin.css`(從 wp-data-optimizer v2.16.0 搬入)。 + +--- + +## 反 EAV 8 維評分(自評) + +承襲 wp-data-optimizer 8 維 anti-EAV 評分標準,目標 v0.1.0 達 9.5+/10: + +1. ✅ Schema-first registration +2. ✅ Hook Bus single source of truth +3. ✅ No direct `wp_*meta` SELECT in app code +4. ✅ Custom table registry 全覆蓋 +5. ✅ Sync Bridge dual-write + cutover +6. ✅ Query Router pre_get_posts 改寫 +7. ✅ Snapshot + rollback 機制 +8. ✅ Conflict detection + CI gate diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..1c4eeb3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,201 @@ +# PLAN — 2meet Data Optimizer (核心) + +> 完整四階段拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` + +--- + +## Current state + +- **Version**: 0.1.0 (scaffold + 4 phase 完成 2026-05-15) +- **Phase**: 全 4 phase 已完成 +- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉 + +--- + +## Phase 0 ✅ — 骨架建立 (2026-05-15) + +- [x] 12 plugin 目錄結構(核心 + 11 AddOn) +- [x] 主檔 `2meet-data-optimizer.php` + Plugin Header +- [x] `includes/class-tmdo-bootstrap.php` stub +- [x] `uninstall.php` stub +- [x] `composer.json` +- [x] `.gitignore` +- [x] 7 件 MD(README / PLAN / CHANGELOG / DEPLOY / SECURITY / DESIGN / CLAUDE) + +--- + +## Phase 1 ✅ — Core v0.1.0 (2026-05-15) + +- [x] rsync 從 wp-data-optimizer 複製 ~110 個核心檔案,排除 HP/WC/LP/2meet 整合 +- [x] 檔名 `class-wpdo-*` → `class-tmdo-*`(含 abstract / interface / trait) +- [x] sed 內容批次 rename:`WPDO_` → `TMDO_`(class + 常數,hook/table/option 前綴 `wpdo_` 保留共用) +- [x] 常數 `TMDO_PLUGIN_DIR` → `TMDO_PATH` 等對齊 +- [x] Text-domain `'wp-data-optimizer'` → `'2meet-data-optimizer'` +- [x] 主檔重寫成完整 loader(require chain ~150 行) +- [x] 加 `tmdo_run()` boot 函式 + `plugins_loaded:4` action +- [x] 加 `late-bind safety net` (priority 30) 確保 AddOn listener 收到 hook +- [x] 加 `includes/class-tmdo-back-compat.php` — 80+ class_alias + 8 常數 alias + 9 hook dual-fire +- [x] 移除 `register_hivepress_defaults()` (193 行) → 搬到 HP AddOn +- [x] 把 core 內所有 HP/WC/LP 整合 class 引用加 `class_exists` 守門 +- [x] PHP lint:111 files PASS +- [x] Live test:`wp tmdo doctor` 全 PASS、`wp wpdo` (alias) 通 + +**Phase 1 DoD:✅ 全達標**(除舊 unit/integration test 套件需後續 phase 1 follow-up) + +--- + +## Phase 2 ✅ — Hub + Spoke AddOn v0.1.0 (2026-05-15) + +### Hub AddOn (`2meet-data-optimizer-hub-addon`) +- [x] `TMDO_Hub_Bootstrap` 主啟動類別 +- [x] `TMDO_Hub_Vendor_Field` — 註冊 `_2meet_global_vendor_id` (hp_vendor / cold zone / indexed) +- [x] `TMDO_Hub_Custom_Tables` — 註冊 12 張 `2mhc_*` 自訂表(fallback 列表 + delegate to hub-core helper if available) +- [x] dual-emit 支援 (`tmdo_*` + `wpdo_*` 兩個 hook 路徑) +- [x] PHP lint PASS + +### Spoke AddOn (`2meet-data-optimizer-spoke-addon`) +- [x] `TMDO_Spoke_Bootstrap` 主啟動類別 +- [x] `TMDO_Spoke_SSO_Group` — 註冊 user entity `sso` group(7 欄位 → wp_wpdo_user_sso) +- [x] `TMDO_Spoke_Custom_Tables` — 註冊 `2mso_sso_config` / `2mso_user_mapping` +- [x] `TMDO_Spoke_CLI::sync()` — 從舊 `_tmso_*` usermeta 遷移 +- [x] `TMDO_Spoke_CLI::force_logout()` — 設置 token_expires_at = 過去 +- [x] CLI 雙命名空間:`wp tmdo spoke-*` + `wp wpdo spoke-*` +- [x] PHP lint PASS + +**Phase 2 Live test 結果:** +- ✅ Hub: 12 hub tables 全部 OK +- ✅ Spoke: SSO group 7 fields registered, 2 tables registered +- ✅ CLI: `wp tmdo spoke-sync --dry-run` 跑通,`spoke-force-logout --user-id=1 --dry-run` 跑通 + +--- + +## Phase 3 ✅ — HP + WC + LP AddOn v0.1.0 (2026-05-15) + +### HivePress AddOn (`2meet-data-optimizer-hivepress-addon`, ~7,400 行) +- [x] 複製 13 adapter(core / reviews / bookings / messages / memberships / requests / favorites / statistics / tags / seo / social-links / blocks / marketplace) +- [x] 複製 7 HPCT interceptor(reviews / messages / favorites / memberships / statistics / requests / listing-meta) +- [x] 複製 5 query interceptor(reviews / messages / memberships / requests / listing-meta) +- [x] 複製 補助組件(bootstrap framework / detector / conflict-guard / comment-router / cron-optimizer / attribute-bridge / suitability-scorer / benchmark / rest) +- [x] 複製 admin tab + CLI namespace +- [x] 複製 HP transient filter / HPCT import / Listing Stats / term-comment-fields +- [x] `TMDO_HP_Bootstrap` outer bootstrap(避免與 inner family bootstrap class 衝突) +- [x] PHP lint:46 files PASS + +### WooCommerce AddOn (`2meet-data-optimizer-woocommerce-addon`, ~1,316 行) +- [x] 複製 4 個 WC 整合檔 +- [x] `TMDO_Woocommerce_Bootstrap` 載入 + register +- [x] PHP lint PASS + +### LatePoint AddOn (`2meet-data-optimizer-latepoint-addon`, ~151 行) +- [x] 複製 LatePoint interceptor +- [x] `TMDO_Latepoint_Bootstrap` 載入 + register_hooks +- [x] PHP lint PASS + +**Phase 3 Live test 結果:** +- ✅ Plugin 全 active +- ✅ Hot zone 20 fields registered(mostly WC product/order) +- ✅ Cold zone 3 fields +- ✅ Total 23 fields +- ✅ HP no-op when HivePress not installed(detector check) + +--- + +## Phase 4 ✅ — 6 個 2meet 家族 AddOn v0.1.0 (2026-05-15) + +| AddOn | Source | Status | +|---|---|---| +| infocards | class-tmdo-infocards.php | ✅ | +| bookings | class-tmdo-bookings.php | ✅ | +| quotation | class-tmdo-quotation.php | ✅ | +| mobile-bridge | class-tmdo-mobile-bridge.php | ✅ | +| collab | class-tmdo-collab.php | ✅ | +| playlist | class-tmdo-playlist.php | ✅ | + +每個 AddOn 都有: +- `TMDO_{Module}_Bootstrap` 啟動類別 +- 核心整合 class(從 wp-data-optimizer 搬入) +- partner detection (early return if target plugin not installed) +- `register()` 掛 `wpdo_register_fields` / `wpdo_register_custom_tables` + +**Phase 4 Live test 結果:** +- ✅ 6 AddOn 全 active +- ✅ Target plugin 未安裝時正確 no-op (designed behavior) +- ✅ 0 fatal、0 lint error + +--- + +## 整體驗收(2026-05-15 末) + +| 指標 | 結果 | +|---|---| +| 12 plugin active | ✅ | +| PHP lint 全 files | ✅ **203 files PASS** | +| `wp tmdo` CLI | ✅ 18 個 subcommand 全 listed | +| `wp wpdo` alias CLI | ✅ 通 | +| `wp tmdo doctor` | ✅ system tables 全綠 + 14 partner tables 註冊 | +| `wp tmdo status` | ✅ 23 fields registered | +| SSO group 7 欄位 | ✅ 全 7 欄位(含 hub_global_user_id searchable) | +| Hub-addon 12 表 | ✅ 全 12 表存在 + doctor PASS | +| Spoke-addon 2 表 | ✅ 註冊 OK(DB 表 missing 是 spoke-sso 從未啟用之故) | +| WC 20 表 | ✅ 註冊 OK(DB 表 missing 是 WC 未安裝) | +| 6 family AddOn no-op | ✅ target 未安裝時正確 early return | + +--- + +## Lessons learned + +### Phase 1 +- rsync 的 `--exclude` 對 `tests/` 與 `tools/` top-level 不生效(因為 source 路徑沒這個前綴),需後續 rm -rf 清理 +- sed 全檔大寫 `WPDO_` → `TMDO_` 不影響 hook/table/option 小寫前綴 `wpdo_`,確保資料相容 +- 主檔的 require chain 無法以 sed 自動產出(需手動寫,因為 require 路徑與檔名變動) +- TMDO_Core::run() 內有 8 處對「整合層 class」的硬引用,需逐個加 `class_exists` 守門 +- `class_alias()` 對 trait 與 interface 不適用(需 wrapping wrapper,留 Phase 1 follow-up) +- WP 6.5+ `Requires Plugins:` 強制檢查 Plugin Name 而非 slug;hub-core / spoke-sso 仍寫死 `Requires Plugins: wp-data-optimizer` 故無法直接啟用 → Phase 5 計畫請求 hub-core / spoke-sso 端更新或加 `or 2meet-data-optimizer` 邏輯 + +### Phase 2 +- inner Hub class `TMDO_Hub_Bootstrap` 需與 outer AddOn bootstrap 區分名稱(衝突避免) +- `TMDO_Entity_Registry::register_group()` 是公開 API;其他 read methods (`get_group_fields` not `get_fields`) 命名不直觀 + +### Phase 3 +- HP family 內部已有 `TMDO_HivePress_Bootstrap` class(v3.0.0 family bootstrap),故 outer AddOn bootstrap 改名 `TMDO_HP_Bootstrap` 避免衝突 + +### Phase 4 +- 所有 family integration class 皆有 partner detection (e.g. `class_exists('TMEETIC_Plugin')`) → AddOn 無需自己重複偵測 + +--- + +## Phase 5 follow-up 執行狀態(2026-05-15) + +| # | 項目 | 狀態 | +|---|---|---| +| 1 | **Tests 套件搬遷**:`tests/unit/` 與 `tests/integration/` 重命名 + path 修正 | ✅ **完成** | +| 2 | PHPCS 套件:補 phpcs.xml.dist + WordPress coding standards | ⬜ 待執行 | +| 3 | **PHPUnit bootstrap**:完整重建 unit/integration test 環境 | ✅ **完成** | +| 4 | DB version migration:`TMDO_DB_VERSION` 對齊 `SCHEMA_VERSION = '2.0.0'` | ✅ **完成** | +| 5 | `Requires Plugins: 2meet-data-optimizer` — 12 個 AddOn + brandcards header 全部更新 | ✅ **完成** | +| 6 | Trait/interface alias wrappers:`Trait_TMDO_Anti_EAV_Aware` 等 | ✅ **完成** | +| 7 | Doctor callback signature 修正 | ✅ **完成** | +| 8 | 打包驗收:`scripts/package-plugin.sh` 12 次 10 終檢 PASS | ⬜ 待執行 | + +### #1 + #3 完成摘要(2026-05-15) + +**Unit tests:373/373 GREEN(771 assertions)** +- `phpunit.xml` 已建立,bootstrap `tests/bootstrap.php` 完整重建 +- 32 個 unit test 檔從 `wp-data-optimizer` 搬遷,全部 `class-wpdo-` → `class-tmdo-` 修正 +- bootstrap 新增:minimal filter registry(`$GLOBALS['_wp_filter_callbacks']`)、全域 FSM bypass filter、`TMDO_Listing_Stats` stub、`esc_sql` 用 `addslashes()` +- 跳過 HP/WC/LP 專屬 test(HivePress/*、WooCommerceIntegrationTest、ListingStatsTest) + +**Integration tests:398/398 GREEN(1139 assertions)** +- `phpunit-integration.xml` 已建立,bootstrap `tests/integration/bootstrap.php` 完整重建 +- 35 個 integration test 檔搬遷(跳過 HivepressIntegrationTest × 3、WCTermCountFilterTest、ListingMetaInterceptorTest、TermCommentBackfillTest) +- bootstrap 新增:`wp_upload_dir()`、`wp_mkdir_p()`、`TMDO_Listing_Stats` stub +- 移除 `WarmArchiveIntegrationTest` 的 listing_stats 2 個方法(需真實 HP AddOn) +- 移除 `RestApiIntegrationTest` 的 post_view increment + rate-limit 2 個方法(同上) + +--- + +## Git tag plan + +- `v0.1.0` — 本 release(Phase 0-4 完成) +- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging) +- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI diff --git a/README.md b/README.md new file mode 100644 index 0000000..216bca7 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# 2meet Data Optimizer + +> 通用 WordPress 反 EAV 引擎 — 零依賴,零 HivePress / WooCommerce / 2meet-* 綁定 + +把 `wp_postmeta` / `wp_usermeta` / `wp_termmeta` / `wp_commentmeta` 的高頻欄位自動分流到四象限扁平表(Hot / Warm / Cold / Archive),帶來 3-30× 的查詢加速。 + +從 [`wp-data-optimizer v2.16.0`](https://github.com/2meet/wp-data-optimizer) 提煉的純核心,整合層全部移到獨立 AddOn。 + +--- + +## 為什麼 + +WordPress 預設用 EAV (Entity-Attribute-Value) 模式儲存所有 meta:每筆 meta 都是 `wp_*meta` 表的一列。對少量欄位無傷,但站台一長大就出現: + +- 單一 listing 可能對應數十條 postmeta → 列表頁查詢爆炸 +- meta_query 複雜過濾必須 N 次 JOIN +- `autoload=yes` 的 wp_options 拖慢每頁載入 +- `_transient_*` 滲入 wp_options 造成持續性脹 + +`2meet-data-optimizer` 為這些 meta 建立**扁平欄位表**(每 post_type 一張),由 Sync Bridge 雙寫,由 Query Router 改寫 `meta_query` 為直接 JOIN,達到接近原生欄位的效能。 + +--- + +## 四象限 + +| Zone | 表名 | 用途 | 加速場景 | +|---|---|---|---| +| **A Hot** | `wp_wpdo_hot_{type}` | 搜尋 / 篩選欄位 | meta_query 過濾、ORDER BY 排序 | +| **B Warm** | `wp_wpdo_warm` | TTL 計數 / 短期快取 | 瀏覽計數、暫時旗標 | +| **C Cold** | `wp_wpdo_cold_{type}` | 展示 / 描述欄位 | 詳情頁讀取 | +| **D Archive** | `wp_wpdo_archive` | 過期 gzip 歸檔 | 歷史資料保留 | + +--- + +## 系統需求 + +- WordPress ≥ 6.0 +- PHP ≥ 8.1 +- MySQL ≥ 5.7 / MariaDB ≥ 10.3 (SQLite 亦支援) + +--- + +## 安裝 + +1. 從 GitHub Release 下載 `2meet-data-optimizer-v0.1.0.zip` +2. 在 WordPress 後台 → 外掛 → 安裝外掛 → 上傳外掛 +3. 啟用後執行 `wp tmdo doctor` 驗證 + +--- + +## 整合 AddOn(選用) + +主外掛只覆蓋 WordPress 原生 4 entity meta。若使用 HivePress / WooCommerce / LatePoint / 2meet-* 系列等外掛並希望也享有反 EAV 加速,請額外安裝對應 AddOn: + +| AddOn | 對應外掛 | +|---|---| +| `2meet-data-optimizer-hub-addon` | 2meet-hub-core | +| `2meet-data-optimizer-spoke-addon` | 2meet-spoke-sso | +| `2meet-data-optimizer-hivepress-addon` | hivepress + 12 HP 擴充 | +| `2meet-data-optimizer-woocommerce-addon` | woocommerce | +| `2meet-data-optimizer-latepoint-addon` | latepoint | +| `2meet-data-optimizer-infocards-addon` | 2meet-infocards | +| `2meet-data-optimizer-bookings-addon` | 2meet-bookings | +| `2meet-data-optimizer-quotation-addon` | 2meet-quotation | +| `2meet-data-optimizer-mobile-bridge-addon` | 2meet-mobile-bridge | +| `2meet-data-optimizer-collab-addon` | 2meet-collab | +| `2meet-data-optimizer-playlist-addon` | 2meet-playlist | + +--- + +## WP-CLI 速查 + +```bash +wp tmdo status # 整體狀態 +wp tmdo doctor # 健診(schema drift / 表對齊 / index) +wp tmdo benchmark hot_post --samples=200 +wp tmdo migrate hot_post # 執行 backfill +wp tmdo verify hot_post # 比對 postmeta 與 zone 表 +wp tmdo cutover hot_post # 讀寫切到 zone 表 +wp tmdo rollback hot_post # 退回 postmeta +wp tmdo cleanup --archive-expired +``` + +--- + +## 公開 API(給其他外掛) + +```php +// 通用 4-entity 讀寫 +TMDO_API::set_entity( 'user', $user_id, 'membership_level', 'gold' ); +$level = TMDO_API::get_entity( 'user', $user_id, 'membership_level' ); + +// Schema 註冊 +add_action( 'tmdo_register_entity_fields', function ( $registry_class ) { + $registry_class::register_group( 'user', 'my_group', [ + [ 'key' => 'my_field', 'type' => 'text', 'searchable' => true ], + ] ); +} ); + +// 自訂表註冊(給其他外掛的表加入 doctor 監控) +add_action( 'tmdo_register_custom_tables', function ( $registry ) { + $registry->register( 'my-plugin', [ + 'table_name' => 'my_table', + 'primary_key' => 'id', + 'expected_columns' => [ ... ], + ] ); +} ); +``` + +向後相容:`WPDO_API` / `WPDO_Schema_Registry` 等舊類別名透過 `class_alias()` 仍可使用(`v0.2.0` 起加 deprecation notice)。 + +--- + +## 授權 + +GPL-2.0-or-later + +--- + +## 文件 + +- [PLAN.md](PLAN.md) — 開發任務追蹤 +- [CHANGELOG.md](CHANGELOG.md) — 版本歷史 +- [DEPLOY.md](DEPLOY.md) — 部署流程 +- [SECURITY.md](SECURITY.md) — 安全聯絡 +- [DESIGN.md](DESIGN.md) — 架構決策 +- [CLAUDE.md](CLAUDE.md) — AI 協作指引 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..331e627 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,76 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +|---|:-:| +| 0.1.x | ✅ | + +> 來源外掛 `wp-data-optimizer v2.x` 仍接受安全修補,但新功能不再回 port。 + +--- + +## Reporting a vulnerability + +請勿在 public GitHub issue 揭露安全問題。透過以下管道私下回報: + +- Email: security@2meet.io +- PGP key: TBD(首次回報時索取) + +我們承諾: +- 24h 內初步回應 +- 7 天內提供修補時程 +- 90 天內出 patch release(CVSS ≥ 7 立即出版) + +--- + +## 安全設計重點 + +### Crypto + +- AES-256-GCM AEAD(v2 格式 `enc:v2:`) +- v1 (AES-256-CBC `enc:v1:`) 向後相容讀取 +- Auth tag 16B + tamper detection +- Key 衍生自 `AUTH_KEY` + `SECURE_AUTH_SALT`,每站獨一 + +### SQL injection + +- 100% `$wpdb->prepare()`,含動態 IN 子句用 `array_fill('%d')` 動態佔位符 +- 欄位名走 `sanitize_key()` +- DROP TABLE 前白名單 + prefix 雙重驗證 + +### Authorization + +- Capability 檢查:`manage_options`(admin tab)/ `edit_posts`(部分 REST) +- Nonce 驗證所有 AJAX + 表單 + +### Output / Input + +- 輸出:`esc_html()` / `esc_attr()` / `esc_url()` +- 輸入:`sanitize_text_field()` / `absint()` / `wp_unslash()` +- Unserialize:`WPDO_Safe_Unserialize::run()`(`['allowed_classes' => false]`) + +### File upload + +- 不接受 file upload。 + +### Rate limiting + +- REST endpoints 採 transient-based limiter(見 `class-tmdo-rest-api.php`) + +--- + +## 已知 Risk + +- (本版本暫無已知安全 risk;Phase 1 ship 時補) + +--- + +## 來源外掛安全沿革 + +- v2.6.4 H-4:加密 Slack/Discord/Telegram 密鑰 +- v2.6.5 A-4:Spoke 明文 JWT 持久化完全移除 +- v2.13.3 audit 7/7 finding 全清零(M-AUTH-1 / M-LOGIC-1 / L-AUTH-1 / L-CFG-1 / L-DESER-1 / L-SSRF-1 / L-CRYPTO-1) +- v2.15.0 AES-256-CBC → AES-256-GCM AEAD 升級 + +詳見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` § 1.1。 diff --git a/admin/assets/wpdo-admin.css b/admin/assets/wpdo-admin.css new file mode 100644 index 0000000..78722e0 --- /dev/null +++ b/admin/assets/wpdo-admin.css @@ -0,0 +1,515 @@ +/** + * WP Data Optimizer — Admin Styles (Morandi Design System) + * + * Morandi tokens are provided via the 'morandi-design-system' CSS dependency + * registered in WPDO_Admin::enqueue_assets(). No @import needed. + */ + +/* ── KPI Hero Strip (v2.16.0) ────────────────────────────────────────── */ + +.wpdo-kpi-hero { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); + margin: var(--space-5) 0; +} + +.wpdo-kpi-card { + background: var(--color-bg-secondary, #FAF9F6); + border: 1px solid var(--color-bg-divider, #E8E3D9); + border-radius: var(--radius-lg); + padding: var(--space-4) var(--space-5); + box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08)); + position: relative; + overflow: hidden; + transition: transform var(--duration-normal, 200ms) var(--ease-default, ease-out), + box-shadow var(--duration-normal, 200ms) var(--ease-default, ease-out); +} + +.wpdo-kpi-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, + var(--color-accent-warm, #c4a986), + var(--color-accent-warm, #c4a986)); + opacity: 0.6; +} + +.wpdo-kpi-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md, 0 4px 12px rgba(58,53,48,0.10)); +} + +.wpdo-kpi-label { + font-family: var(--font-family-body); + font-size: var(--text-sm, 13px); + color: var(--color-text-secondary, #6b6256); + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: var(--space-2); +} + +.wpdo-kpi-value { + font-family: var(--font-family-display); + font-size: 2.25rem; + line-height: 1.1; + font-weight: 700; + color: var(--color-text-primary, #3a3530); + margin-bottom: var(--space-2); + letter-spacing: -0.02em; +} + +.wpdo-kpi-unit { + font-size: 1.1rem; + color: var(--color-text-secondary, #6b6256); + font-weight: 500; + margin-left: 2px; +} + +.wpdo-kpi-empty { + color: var(--color-text-tertiary, #9a8e7e); + font-weight: 400; +} + +.wpdo-kpi-sub { + font-family: var(--font-family-body); + font-size: var(--text-xs, 12px); + color: var(--color-text-secondary, #6b6256); + line-height: 1.4; +} + +/* Per-KPI accent stripe color */ +.wpdo-kpi--ratio::before { background: linear-gradient(90deg, #c4a986, #d4b89a); } +.wpdo-kpi--speedup::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); } +.wpdo-kpi--coverage::before { background: linear-gradient(90deg, #8b7eb3, #a397c9); } + +.wpdo-kpi--health-excellent::before { background: linear-gradient(90deg, #5a9a85, #7eb39e); } +.wpdo-kpi--health-good::before { background: linear-gradient(90deg, #c4a986, #d4b89a); } +.wpdo-kpi--health-warn::before { background: linear-gradient(90deg, #d4a574, #e0b888); } +.wpdo-kpi--health-crit::before { background: linear-gradient(90deg, #c47a7a, #d49090); } + +.wpdo-kpi--health-excellent .wpdo-kpi-value { color: #4a8270; } +.wpdo-kpi--health-good .wpdo-kpi-value { color: #a8906f; } +.wpdo-kpi--health-warn .wpdo-kpi-value { color: #b88955; } +.wpdo-kpi--health-crit .wpdo-kpi-value { color: #a86060; } + +@media (max-width: 600px) { + .wpdo-kpi-hero { + grid-template-columns: 1fr 1fr; + gap: var(--space-3); + } + .wpdo-kpi-value { + font-size: 1.75rem; + } +} + +/* ── Grouped Tab Navigation (v2.16.0) ────────────────────────────────── */ + +.wpdo-tab-nav { + display: flex; + flex-wrap: wrap; + gap: var(--space-3) var(--space-4); + align-items: flex-end; + padding-bottom: 0; + border-bottom: 1px solid var(--color-bg-divider, #E8E3D9); + margin-bottom: var(--space-4); +} + +.wpdo-tab-group { + display: inline-flex; + flex-direction: column; + gap: 4px; + margin-right: var(--space-2); + padding-right: var(--space-3); + position: relative; +} + +.wpdo-tab-group:not(:last-child)::after { + content: ""; + position: absolute; + right: 0; + top: 24px; + bottom: 4px; + width: 1px; + background: var(--color-bg-divider, #E8E3D9); +} + +.wpdo-tab-group-label { + font-family: var(--font-family-body); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-tertiary, #9a8e7e); + font-weight: 500; + padding-left: var(--space-2); + user-select: none; + pointer-events: none; +} + +.wpdo-tab-group .nav-tab { + margin: 0 2px -1px 0; + transition: background-color var(--duration-fast, 120ms) ease, + color var(--duration-fast, 120ms) ease; +} + +.wpdo-tab-group .nav-tab:hover { + background-color: var(--color-bg-tertiary, #F2EEE8); + color: var(--color-text-primary, #3a3530); +} + +@media (max-width: 768px) { + .wpdo-tab-nav { + gap: var(--space-2); + } + .wpdo-tab-group { + margin-right: 0; + padding-right: var(--space-2); + } + .wpdo-tab-group-label { + font-size: 10px; + } +} + +/* ── Layout ──────────────────────────────────────────────────────────── */ +.wpdo-wrap .wpdo-tab-content { + margin-top: var(--space-5); +} + +.wpdo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--space-5); + margin-bottom: var(--space-5); +} + +.wpdo-card { + background: var(--color-bg-secondary, #FAF9F6); + border: 1px solid var(--color-bg-divider, #E8E3D9); + border-radius: var(--radius-lg); + padding: var(--space-4) var(--space-5); + box-shadow: var(--shadow-sm, 0 1px 3px rgba(58,53,48,0.08)); + overflow-x: auto; /* WCAG 1.4.10 Reflow: tables scroll within card, not page */ +} + +.wpdo-card h2 { + margin-top: 0; + padding-top: 0; + border-bottom: 1px solid var(--color-bg-divider, #E8E3D9); + padding-bottom: var(--space-3); + font-family: var(--font-family-display); +} + +.wpdo-card h3, +.wpdo-card h4 { + margin-top: var(--space-3); + font-family: var(--font-family-display); +} + +.wpdo-card-wide { + grid-column: 1 / -1; +} + +/* Utility spacers replacing inline style="margin-top:N" */ +.wpdo-mt-1 { margin-top: var(--space-2); } +.wpdo-mt-2 { margin-top: var(--space-3); } +.wpdo-mt-3 { margin-top: var(--space-5); } +.wpdo-mb-2 { margin-bottom: var(--space-3); } +.wpdo-table--narrow { max-width: 480px; } +.wpdo-table--medium { max-width: 700px; } +.wpdo-block--wide { max-width: 900px; } +.wpdo-col-param { width: 180px; } + +/* Inline form row */ +.wpdo-form-row { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + margin: var(--space-3) 0; +} + +/* ── Tables ──────────────────────────────────────────────────────────── */ +.wpdo-table { + font-size: var(--text-sm); + font-family: var(--font-family-body); +} + +.wpdo-table code, +.wpdo-wrap code { + font-size: var(--text-xs); + background: var(--color-bg-tertiary, #F2EEE8); + padding: 2px var(--space-1); + border-radius: var(--radius-sm); + font-family: var(--font-family-mono); +} + +.wpdo-log-msg { + max-width: 400px; + word-break: break-word; +} + +/* ── Touch targets (CLAUDE.md: 44px minimum) ─────────────────────────── */ +.wpdo-wrap .button, +.wpdo-wrap .button-secondary, +.wpdo-wrap .button-primary, +.wpdo-wrap input[type="number"], +.wpdo-wrap input[type="text"], +.wpdo-wrap input[type="search"], +.wpdo-wrap select { + min-height: 44px; + line-height: 1.4; +} + +.wpdo-wrap .button-small { + min-height: 40px; /* WCAG 2.2 AA: 24px min; 40px exceeds 2.5.8 while remaining compact */ + padding: 0 var(--space-2); +} + +.wpdo-wrap input[type="number"] { + padding: 6px var(--space-2); +} + +.wpdo-wrap input[type="number"].wpdo-input--xs { + width: 72px; /* was inline 60px — bumped to fit 44px height without clipping digits */ +} + +.wpdo-wrap .button:focus-visible, +.wpdo-wrap input:focus-visible, +.wpdo-wrap select:focus-visible { + outline: 2px solid var(--color-terra-primary, #C4897A); + outline-offset: 2px; +} + +/* ── Method pill for REST API tab (was inline #0073aa) ───────────────── */ +.wpdo-method-pill { + background: var(--color-blue-primary, #9EB3C2); + color: var(--color-text-primary, #3A3530); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + font-family: var(--font-family-mono); +} + +/* Code block for curl examples (was inline #EFEFEF) */ +.wpdo-wrap .wpdo-code-block { + background: var(--color-bg-tertiary, #F2EEE8); + color: var(--color-text-primary, #3A3530); + padding: var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-family: var(--font-family-mono); + overflow: auto; + white-space: pre; + border: 1px solid var(--color-bg-divider, #E8E3D9); +} + +/* ── Zone badges (icon + text, not colour alone — WCAG 1.4.1) ────────── */ +.wpdo-zone { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary, #3A3530); + line-height: 1.4; +} + +.wpdo-zone::before { + font-family: var(--font-family-mono); + font-weight: var(--font-weight-bold); +} + +.wpdo-zone-hot { + background: var(--color-terra-primary, #C4897A); +} +.wpdo-zone-hot::before { content: "🔥"; } + +.wpdo-zone-warm { + background: var(--color-apricot-medium, #C8B090); +} +.wpdo-zone-warm::before { content: "◐"; } + +.wpdo-zone-cold { + background: var(--color-blue-medium, #8AA0B2); +} +.wpdo-zone-cold::before { content: "❄"; } + +.wpdo-zone-archive { + background: var(--color-gray-medium, #AFA8A0); +} +.wpdo-zone-archive::before { content: "▦"; } + +/* ── State badges (shape-prefixed for colour-blind legibility) ───────── */ +.wpdo-state { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.4; +} + +.wpdo-state::before { + font-family: var(--font-family-mono); +} + +.wpdo-state-idle { + background: var(--color-bg-tertiary, #F2EEE8); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-idle::before { content: "○"; } + +.wpdo-state-dual_write { + background: var(--color-apricot-lightest, #EDD9C0); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-dual_write::before { content: "⇄"; } + +.wpdo-state-backfill { + background: var(--color-blue-lightest, #C8D6DE); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-backfill::before { content: "⟲"; } + +.wpdo-state-verify { + background: var(--color-apricot-lightest, #EDD9C0); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-verify::before { content: "⚠"; } + +.wpdo-state-cutover { + background: var(--color-green-lightest, #C8D5C4); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-cutover::before { content: "✓"; } + +.wpdo-state-cleanup { + background: var(--color-blue-lightest, #C8D6DE); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-cleanup::before { content: "✂"; } + +.wpdo-state-complete { + background: var(--color-green-primary, #A8B9A4); + color: var(--color-text-primary, #3A3530); +} +.wpdo-state-complete::before { content: "✔"; } + +/* ── Generic badges ──────────────────────────────────────────────────── */ +.wpdo-badge { + display: inline-block; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + background: var(--color-bg-tertiary, #F2EEE8); + color: var(--color-text-primary, #3A3530); +} + +.wpdo-badge-ok { + background: var(--color-green-lightest, #C8D5C4); + color: var(--color-text-primary, #3A3530); +} + +.wpdo-badge-warn { + background: var(--color-apricot-lightest, #EDD9C0); + color: var(--color-text-primary, #3A3530); +} + +/* ── Progress bars (transform-based — no layout-thrashing) ───────────── */ +.wpdo-progress { + display: inline-block; + width: 120px; + height: 14px; + background: var(--color-bg-tertiary, #F2EEE8); + border-radius: var(--radius-full); + overflow: hidden; + vertical-align: middle; + margin-right: var(--space-2); +} + +.wpdo-progress-bar { + height: 100%; + width: 100%; + background: var(--color-blue-primary, #9EB3C2); + border-radius: var(--radius-full); + transform-origin: left center; + transform: scaleX(var(--wpdo-progress, 0)); + transition: transform var(--duration-slow, 0.4s) var(--ease-default, ease); +} + +.wpdo-progress-text { + font-size: var(--text-xs); + color: var(--color-text-primary, #3A3530); +} + +/* ── Confidence bar ──────────────────────────────────────────────────── */ +.wpdo-confidence { + display: inline-block; + width: 60px; + height: 10px; + background: var(--color-bg-tertiary, #F2EEE8); + border-radius: var(--radius-full); + overflow: hidden; + vertical-align: middle; + margin-right: var(--space-1); +} + +.wpdo-confidence-bar { + height: 100%; + width: 100%; + background: var(--color-green-primary, #A8B9A4); + border-radius: var(--radius-full); + transform-origin: left center; + transform: scaleX(var(--wpdo-confidence, 0)); +} + +/* ── Screen reader only ──────────────────────────────────────────────── */ +.wpdo-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + border: 0; +} + +/* ── Tab red-dot badge (v2.8.1) ──────────────────────────────────────── */ +.nav-tab .wpdo-tab-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #dc3232; + margin-left: 6px; + vertical-align: middle; + box-shadow: 0 0 6px rgba(220, 50, 50, 0.6); + animation: wpdo-tab-dot-pulse 2s ease infinite; +} + +@keyframes wpdo-tab-dot-pulse { + 0% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0.7); } + 70% { box-shadow: 0 0 0 6px rgba(220, 50, 50, 0); } + 100% { box-shadow: 0 0 0 0 rgba(220, 50, 50, 0); } +} + +/* ── Reduced motion ──────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .wpdo-progress-bar { + transition-duration: 0.01ms; + } + .nav-tab .wpdo-tab-dot { + animation: none; + } +} diff --git a/admin/assets/wpdo-admin.js b/admin/assets/wpdo-admin.js new file mode 100644 index 0000000..73a38e0 --- /dev/null +++ b/admin/assets/wpdo-admin.js @@ -0,0 +1,246 @@ +/** + * WP Data Optimizer — Admin JS (native fetch, no jQuery) + * + * @package WP_Data_Optimizer + */ + +( function () { + 'use strict'; + + var WPDO = window.wpdoAdmin || {}; + var i18n = WPDO.i18n || {}; + + document.addEventListener( + 'DOMContentLoaded', + function () { + bindFlushCacheButtons(); + bindStatusRefresh(); + } + ); + + /** + * Announce a message via the shared aria-live region. + * Falls back to creating the region on first call. + */ + function announce( message ) { + var region = document.getElementById( 'wpdo-aria-live' ); + if ( ! region ) { + region = document.createElement( 'div' ); + region.id = 'wpdo-aria-live'; + region.className = 'wpdo-sr-only'; + region.setAttribute( 'role', 'status' ); + region.setAttribute( 'aria-live', 'polite' ); + region.setAttribute( 'aria-atomic', 'true' ); + document.body.appendChild( region ); + } + region.textContent = ''; + setTimeout( + function () { + region.textContent = message; }, + 50 + ); + } + + /** + * Render an inline admin notice at the top of .wpdo-wrap. + * Dismissable by user. Replaces legacy alert() for errors. + */ + function showNotice( message, type ) { + type = type || 'error'; + var wrap = document.querySelector( '.wpdo-wrap' ); + if ( ! wrap ) { + return; + } + + var notice = document.createElement( 'div' ); + notice.className = 'notice notice-' + type + ' is-dismissible'; + notice.setAttribute( 'role', type === 'error' ? 'alert' : 'status' ); + + var p = document.createElement( 'p' ); + p.textContent = message; + notice.appendChild( p ); + + var dismiss = document.createElement( 'button' ); + dismiss.type = 'button'; + dismiss.className = 'notice-dismiss'; + dismiss.setAttribute( 'aria-label', i18n.dismiss || 'Dismiss' ); + dismiss.addEventListener( + 'click', + function () { + notice.remove(); + } + ); + notice.appendChild( dismiss ); + + // Insert after the H1 so it appears at the top of the content area. + var h1 = wrap.querySelector( 'h1' ); + if ( h1 && h1.nextSibling ) { + wrap.insertBefore( notice, h1.nextSibling ); + } else { + wrap.prepend( notice ); + } + + announce( message ); + } + + /** + * POST to admin-ajax.php using native fetch + FormData. + * Returns a Promise resolving to the parsed JSON response. + */ + function wpdoAjax( actionType, data ) { + var body = new FormData(); + body.append( 'action', 'wpdo_admin_action' ); + body.append( 'nonce', WPDO.nonce || '' ); + body.append( 'action_type', actionType ); + Object.keys( data || {} ).forEach( + function ( key ) { + body.append( key, data[ key ] ); + } + ); + + return fetch( + WPDO.ajaxUrl, + { + method: 'POST', + credentials: 'same-origin', + body: body, + } + ) + .then( + function ( res ) { + if ( ! res.ok ) { + throw new Error( 'HTTP ' + res.status ); + } + return res.json(); + } + ); + } + + /** + * Flush cache button handler — delegated click listener. + */ + function bindFlushCacheButtons() { + document.addEventListener( + 'click', + function ( e ) { + var btn = e.target.closest( '.wpdo-flush-cache' ); + if ( ! btn ) { + return; + } + e.preventDefault(); + + var postType = btn.getAttribute( 'data-post-type' ); + if ( ! postType ) { + return; + } + + var originalLabel = btn.textContent; + btn.disabled = true; + btn.textContent = i18n.flushing || 'Flushing…'; + announce( btn.textContent ); + + wpdoAjax( 'flush_cache', { post_type: postType } ) + .then( + function ( response ) { + if ( response && response.success ) { + btn.textContent = i18n.flushed || 'Flushed!'; + announce( btn.textContent ); + setTimeout( + function () { + btn.disabled = false; + btn.textContent = i18n.flushLabel || originalLabel; + }, + 2000 + ); + } else { + btn.disabled = false; + btn.textContent = i18n.flushLabel || originalLabel; + var msg = ( response && response.data ) ? String( response.data ) : ( i18n.error || 'Unknown error' ); + showNotice( msg, 'error' ); + } + } + ) + .catch( + function () { + btn.disabled = false; + btn.textContent = i18n.flushLabel || originalLabel; + showNotice( i18n.error || 'Request failed.', 'error' ); + } + ); + } + ); + } + + /** + * Auto-refresh module status on the Dashboard tab (every 30s). + */ + function bindStatusRefresh() { + var isDashboard = document.querySelector( '.wpdo-wrap' ) + && window.location.search.indexOf( 'tab=dashboard' ) !== -1; + if ( ! isDashboard ) { + return; + } + + var timer = setInterval( + function () { + if ( ! document.hidden ) { + refreshStatus(); + } + }, + 30000 + ); + + window.addEventListener( + 'beforeunload', + function () { + clearInterval( timer ); + } + ); + } + + function refreshStatus() { + wpdoAjax( 'module_status', {} ) + .then( + function ( response ) { + if ( ! response || ! response.success || ! response.data ) { + return; + } + var data = response.data; + var changed = 0; + var changedSummaries = []; + + document.querySelectorAll( '.wpdo-state' ).forEach( + function ( el ) { + var row = el.closest( 'tr' ); + var codeEl = row ? row.querySelector( 'code' ) : null; + if ( ! codeEl ) { + return; + } + var module = codeEl.textContent; + if ( ! module || ! ( module in data ) ) { + return; + } + var newState = data[ module ]; + if ( el.textContent === newState ) { + return; + } + el.textContent = newState; + el.className = 'wpdo-state wpdo-state-' + newState; + changed++; + changedSummaries.push( module + ' → ' + newState ); + } + ); + + if ( changed > 0 ) { + announce( changedSummaries.join( '、' ) ); + } + } + ) + .catch( + function () { + // Fail silently on polling — user will see the stale state; not worth a notice. + } + ); + } + +} )(); diff --git a/admin/assets/wpdo-comment-stress-test.js b/admin/assets/wpdo-comment-stress-test.js new file mode 100644 index 0000000..3446bc8 --- /dev/null +++ b/admin/assets/wpdo-comment-stress-test.js @@ -0,0 +1,368 @@ +/** + * WPDO Comment Stress Test — admin tab JS (v2.13.1) + * + * Mirrors wpdo-term-stress-test.js (v2.13.0) but: + * - Talks to /wpdo/v1/comment-stress-test/* endpoints + * - Sends post_id in start payload (not taxonomy) + * - DOM IDs prefixed wpdo-cst-* (comment stress test) + * + * @since 2.13.1 + */ +( function () { + 'use strict'; + + const cfg = window.wpdoCommentStressTest; + if ( ! cfg || ! cfg.restUrl ) { + return; + } + if ( ! document.querySelector( '.wpdo-comment-stress-test-tab' ) ) { + return; + } + + const $ = ( sel ) => document.querySelector( sel ); + const restUrl = cfg.restUrl.replace( /\/$/, '' ); + const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }; + + let pollTimer = null; + + // ── API helpers ───────────────────────────────────────────────────────── + + async function apiCall( path, method = 'GET', body = null ) { + const opts = { method, headers, credentials: 'same-origin' }; + if ( body ) { + opts.body = JSON.stringify( body ); + } + const resp = await fetch( restUrl + path, opts ); + const text = await resp.text(); + try { + return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null }; + } catch ( e ) { + return { ok: false, status: resp.status, data: { error: text } }; + } + } + + // ── Rendering ─────────────────────────────────────────────────────────── + + function fmt( n ) { + if ( n === null || n === undefined ) { + return '—'; + } + return Number( n ).toLocaleString(); + } + + function setText( sel, text ) { + const el = $( sel ); + if ( el ) { + el.textContent = String( text ); + } + } + + function esc( s ) { + if ( s === null || s === undefined ) { + return ''; + } + return String( s ).replace( /[&<>"']/g, ( c ) => ( { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ c ] ) ); + } + + function renderProgress( state ) { + const isRunning = state.status === 'running' || state.status === 'benchmarking'; + const card = $( '#wpdo-cst-progress-card' ); + if ( card ) { + card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none'; + } + setText( '#wpdo-cst-pg-status', state.status || 'idle' ); + setText( '#wpdo-cst-pg-post-id', state.post_id ? ( 'post #' + state.post_id ) : '' ); + setText( '#wpdo-cst-pg-mode', state.mode || '' ); + setText( '#wpdo-cst-pg-pct', ( state.pct || 0 ) + '%' ); + setText( '#wpdo-cst-pg-processed', fmt( state.processed || 0 ) ); + setText( '#wpdo-cst-pg-target', fmt( state.target || 0 ) ); + setText( '#wpdo-cst-pg-rate', state.rate_per_sec || 0 ); + setText( '#wpdo-cst-pg-elapsed', state.elapsed_sec || 0 ); + setText( '#wpdo-cst-pg-eta', state.eta_sec || 0 ); + setText( '#wpdo-cst-pg-batches', state.batches_done || 0 ); + setText( '#wpdo-cst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) ); + + const bar = $( '#wpdo-cst-pg-bar' ); + if ( bar ) { + bar.style.width = ( state.pct || 0 ) + '%'; + } + + const count = state.test_comment_count || 0; + setText( '#wpdo-cst-count', fmt( count ) ); + setText( '#wpdo-cst-count-mirror', fmt( count ) ); + + const startBtn = $( '#wpdo-cst-start' ); + const cancelBtn = $( '#wpdo-cst-cancel' ); + const cleanupBtn = $( '#wpdo-cst-cleanup' ); + const benchBtn = $( '#wpdo-cst-rerun-bench' ); + if ( startBtn ) { + startBtn.disabled = isRunning; + } + if ( cancelBtn ) { + cancelBtn.disabled = ! isRunning; + } + if ( cleanupBtn ) { + cleanupBtn.disabled = isRunning || count === 0; + } + if ( benchBtn ) { + benchBtn.disabled = isRunning || count === 0; + } + } + + function renderBenchmark( bench ) { + if ( ! bench ) { + return; + } + const card = $( '#wpdo-cst-bench-card' ); + const content = $( '#wpdo-cst-bench-content' ); + if ( ! card || ! content ) { + return; + } + card.style.display = ''; + + const w = bench.write || {}; + const dbSizes = bench.db_sizes || []; + const q = bench.query || {}; + + const labels = { + point_rating_5: 'Point lookup (hp_rating = 5)', + range_rating_top: 'Range scan (hp_rating >= 4 ORDER BY DESC)', + eav_baseline: 'EAV baseline (wp_commentmeta 直查 hp_rating)', + }; + + let html = ''; + // Write metrics + html += '

▍ 寫入指標

'; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += '
模式${ esc( w.mode ) }
Post ID#${ esc( w.post_id ) }
完成 / 目標${ fmt( w.processed ) } / ${ fmt( w.target ) }
總耗時${ fmt( w.elapsed_sec ) } 秒
平均速率${ fmt( w.rate_per_sec ) } comments/sec
批次數${ fmt( w.batches_done ) }
批次最快/平均/最慢${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms
PHP Peak Memory${ fmt( w.peak_memory_mb ) } MB
'; + + // DB sizes + html += '

▍ DB 容量(comment 相關表)

'; + html += ''; + html += ''; + html += ''; + dbSizes.forEach( ( r ) => { + html += ``; + html += ``; + html += ``; + } ); + html += '
TableRowsData MBIndex MBTotal MBAvg bytes/row
${ esc( r.table ) }${ fmt( r.rows ) }${ r.data_mb ?? '—' }${ r.index_mb ?? '—' }${ r.total_mb ?? '—' }${ fmt( r.avg_bytes ) }
'; + + // Query perf — 3 probes with EAV baseline last for visual speedup comparison + html += '

▍ 查詢效能

'; + html += ''; + html += ''; + html += ''; + + const baselineMs = q.eav_baseline?.duration_ms ?? null; + Object.keys( q ).forEach( ( key ) => { + const v = q[ key ]; + if ( ! v || typeof v.duration_ms !== 'number' ) { + return; + } + const label = labels[ key ] || key; + let speedup = ''; + if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) { + const ratio = baselineMs / v.duration_ms; + if ( ratio >= 1 ) { + speedup = ` (${ ratio.toFixed( 2 ) }× faster)`; + } + } + html += ``; + } ); + html += '
測試項目耗時 (ms)
${ esc( label ) }${ speedup }${ v.duration_ms }
'; + html += '

EAV baseline 走 wp_commentmeta,flat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。

'; + + content.innerHTML = html; + } + + // ── Polling ───────────────────────────────────────────────────────────── + + async function poll() { + const r = await apiCall( '/comment-stress-test/status' ); + if ( ! r.ok || ! r.data ) { + return; + } + renderProgress( r.data ); + if ( r.data.benchmark ) { + renderBenchmark( r.data.benchmark ); + } + if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) { + stopPolling(); + } + } + + function startPolling() { + stopPolling(); + poll(); + pollTimer = setInterval( poll, 2000 ); + } + + function stopPolling() { + if ( pollTimer ) { + clearInterval( pollTimer ); + pollTimer = null; + } + } + + // ── Event handlers ────────────────────────────────────────────────────── + + async function handleStart() { + const post_id = parseInt( $( '#wpdo-cst-post-id' ).value, 10 ); + const target = parseInt( $( '#wpdo-cst-target' ).value, 10 ); + const batch = parseInt( $( '#wpdo-cst-batch' ).value, 10 ); + const mode = document.querySelector( 'input[name="wpdo-cst-mode"]:checked' ).value; + + if ( ! post_id || post_id < 1 ) { + alert( '請選擇目標 post' ); + return; + } + if ( ! target || target < 1 ) { + alert( '請輸入有效的 comment 數量' ); + return; + } + if ( mode === 'realistic' && batch > 50 ) { + if ( ! confirm( `Realistic 模式每個 comment 約需 30-100 ms(wp_insert_comment + update_comment_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) { + return; + } + } + + // Soft warn for combinations that won't demonstrate反 EAV 優化效果 + const commentMode = String( cfg.commentMode || 'disabled' ); + if ( mode === 'fast' && commentMode === 'aeav_only' ) { + if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 comment mode=aeav_only 也會寫滿 wp_commentmeta。\n\n要驗證反 EAV 優化效果(wp_commentmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) { + return; + } + } else if ( mode === 'realistic' && commentMode !== 'aeav_only' ) { + if ( ! confirm( `⚠️ 目前 comment mode = ${ commentMode },Realistic 寫入仍會雙寫 wp_commentmeta(不展示優化效果)。\n\n要看 wp_commentmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ commentMode } 模式繼續嗎?` ) ) { + return; + } + } + + const big = target >= 5000; + const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 comment(attached to post #${ post_id })${ big ? '(規模較大)' : '' }。\n\n所有 comment 的 author email 會以 @wpdo-stress.local 結尾,可一鍵清除。\n\n確定要開始嗎?`; + if ( ! confirm( msg ) ) { + return; + } + + const r = await apiCall( '/comment-stress-test/start', 'POST', { + post_id, + target, + mode, + batch_size: batch, + } ); + if ( ! r.ok ) { + alert( '啟動失敗:' + ( r.data?.error || r.status ) ); + return; + } + const card = $( '#wpdo-cst-progress-card' ); + if ( card ) { + card.style.display = ''; + } + const benchCard = $( '#wpdo-cst-bench-card' ); + if ( benchCard ) { + benchCard.style.display = 'none'; + } + setText( '#wpdo-cst-pg-status', 'running' ); + setText( '#wpdo-cst-pg-post-id', 'post #' + post_id ); + setText( '#wpdo-cst-pg-mode', mode ); + setText( '#wpdo-cst-pg-target', target.toLocaleString() ); + startPolling(); + } + + async function handleCancel() { + if ( ! confirm( '確定要取消當前測試?已建立的 comment 不會被刪除。' ) ) { + return; + } + const cancelBtn = $( '#wpdo-cst-cancel' ); + if ( cancelBtn ) { + cancelBtn.disabled = true; + cancelBtn.textContent = '⏹ 取消中…'; + } + setText( '#wpdo-cst-pg-status', 'cancelling' ); + + const r = await apiCall( '/comment-stress-test/cancel', 'POST' ); + if ( ! r.ok ) { + alert( '取消失敗:' + ( r.data?.error || r.status ) ); + if ( cancelBtn ) { + cancelBtn.disabled = false; + cancelBtn.textContent = '⏹ 取消'; + } + return; + } + poll(); + if ( cancelBtn ) { + cancelBtn.textContent = '⏹ 取消'; + } + } + + async function handleCleanup() { + if ( ! confirm( '確定要清除所有 stress test comments?\n\n此動作會:\n- DELETE 所有 email 後綴 @wpdo-stress.local 的 comment\n- DELETE 對應 wp_commentmeta\n- DELETE flat 表中對應 comment_id 的列\n\n不可復原!' ) ) { + return; + } + const r = await apiCall( '/comment-stress-test/cleanup', 'DELETE' ); + if ( ! r.ok ) { + alert( '清除失敗:' + ( r.data?.error || r.status ) ); + return; + } + alert( `已清除 ${ r.data.deleted } 筆測試 comment。` ); + const card = $( '#wpdo-cst-progress-card' ); + const benchCard = $( '#wpdo-cst-bench-card' ); + if ( card ) card.style.display = 'none'; + if ( benchCard ) benchCard.style.display = 'none'; + poll(); + } + + async function handleRerunBench() { + const btn = $( '#wpdo-cst-rerun-bench' ); + if ( btn ) { + btn.disabled = true; + btn.textContent = '⏳ 執行中...'; + } + const r = await apiCall( '/comment-stress-test/benchmark', 'POST' ); + if ( btn ) { + btn.disabled = false; + btn.textContent = '📊 重跑 Benchmark(不新增資料)'; + } + if ( ! r.ok ) { + alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) ); + return; + } + renderBenchmark( r.data.benchmark ); + } + + // ── Init ──────────────────────────────────────────────────────────────── + + document.addEventListener( 'DOMContentLoaded', () => { + const startBtn = $( '#wpdo-cst-start' ); + const cancelBtn = $( '#wpdo-cst-cancel' ); + const cleanupBtn = $( '#wpdo-cst-cleanup' ); + const benchBtn = $( '#wpdo-cst-rerun-bench' ); + + if ( startBtn ) startBtn.addEventListener( 'click', handleStart ); + if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel ); + if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup ); + if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench ); + + poll().then( () => { + const status = ( $( '#wpdo-cst-pg-status' )?.textContent || '' ).trim(); + if ( status === 'running' || status === 'benchmarking' ) { + startPolling(); + } + } ); + } ); +} )(); diff --git a/admin/assets/wpdo-entity-bridge.js b/admin/assets/wpdo-entity-bridge.js new file mode 100644 index 0000000..44f3da2 --- /dev/null +++ b/admin/assets/wpdo-entity-bridge.js @@ -0,0 +1,295 @@ +/** + * WPDO Entity Bridge — Admin UI controller + * + * Features: + * - Polls /wp-json/wpdo/v1/entity-bridge/health every 5 s during active backfill + * - Updates coverage progress bars and migration badges in real time + * - Handles Backfill / Promote / Demote button clicks with confirmation + * + * Depends on: wpdoEntityBridge (wp_localize_script data) + */ + +/* global wpdoEntityBridge */ +( function () { + 'use strict'; + + var cfg = window.wpdoEntityBridge || {}; + var restUrl = cfg.restUrl || ''; + var nonce = cfg.nonce || ''; + var pollInterval = 5000; // ms between polls + var timer = null; + var activeBackfills = {}; // {entity_type: {group_name: true}} — track in-progress + + // ─── Helpers ───────────────────────────────────────────────────────────── + + function apiFetch( method, path, body ) { + var url = restUrl + path; + var opts = { + method: method, + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + }, + }; + if ( body ) { + opts.body = JSON.stringify( body ); + } + return fetch( url, opts ).then( function ( res ) { + if ( ! res.ok ) { + return res.json().then( function ( e ) { throw new Error( e.message || e.error || res.status ); } ); + } + return res.json(); + } ); + } + + function modeBadgeClass( mode ) { + return { + disabled: 'wpdo-mode-disabled', + dual_write: 'wpdo-mode-dual-write', + shadow_read: 'wpdo-mode-shadow-read', + aeav_only: 'wpdo-mode-aeav-only', + }[ mode ] || 'wpdo-mode-disabled'; + } + + function modeLabel( mode ) { + return { + disabled: 'disabled', + dual_write: 'dual_write', + shadow_read: 'shadow_read', + aeav_only: 'aeav_only', + }[ mode ] || mode; + } + + function pct( n ) { + return Math.min( 100, Math.max( 0, parseFloat( n ) || 0 ) ).toFixed( 1 ); + } + + // ─── Update UI from health data ────────────────────────────────────────── + + function updateAll( data ) { + var hasActive = false; + + Object.keys( data ).forEach( function ( type ) { + var card = document.querySelector( '[data-entity-type="' + type + '"]' ); + if ( ! card ) return; + + var info = data[ type ]; + + // Mode badge + var badge = card.querySelector( '.wpdo-mode-badge' ); + if ( badge ) { + badge.textContent = modeLabel( info.mode ); + badge.className = 'wpdo-mode-badge ' + modeBadgeClass( info.mode ); + } + + // Pipeline dots + var pipeline = card.querySelector( '.wpdo-pipeline' ); + if ( pipeline ) { + pipeline.innerHTML = buildPipeline( info.mode ); + } + + // Mode days + var daysEl = card.querySelector( '.wpdo-mode-days' ); + if ( daysEl ) { + daysEl.textContent = info.mode_days + ' 天'; + } + + // Groups + ( info.groups || [] ).forEach( function ( g ) { + var groupEl = card.querySelector( '[data-group="' + g.name + '"]' ); + if ( ! groupEl ) return; + + // Progress bar + var bar = groupEl.querySelector( '.wpdo-cov-bar-fill' ); + if ( bar ) bar.style.width = pct( g.coverage_pct ) + '%'; + + var pctEl = groupEl.querySelector( '.wpdo-cov-pct' ); + if ( pctEl ) pctEl.textContent = pct( g.coverage_pct ) + '%'; + + var rowsEl = groupEl.querySelector( '.wpdo-cov-rows' ); + if ( rowsEl ) rowsEl.textContent = g.flat_rows + ' / ' + g.eav_rows; + + // Migration badge + var migBadge = groupEl.querySelector( '.wpdo-mig-status' ); + if ( migBadge ) { + migBadge.textContent = g.migration_status; + migBadge.className = 'wpdo-mig-status wpdo-mig-' + g.migration_status.replace( /_/g, '-' ); + } + + // Is this group's backfill active? + if ( g.migration_status === 'running' ) { + hasActive = true; + } + } ); + + // Shadow diffs + var diffsEl = card.querySelector( '.wpdo-shadow-diffs' ); + if ( diffsEl ) diffsEl.textContent = info.shadow_diffs; + + // Recommendation + var recEl = card.querySelector( '.wpdo-recommendation' ); + if ( recEl ) recEl.textContent = info.recommendation; + + // Auto-promote eligible badge + var apEl = card.querySelector( '.wpdo-auto-promote-eligible' ); + if ( apEl ) { + if ( info.auto_promote && info.auto_promote.eligible ) { + apEl.textContent = '✓ 可升級'; + apEl.style.color = '#155724'; + } else { + apEl.textContent = '— ' + ( ( info.auto_promote || {} ).reason || '' ); + apEl.style.color = '#856404'; + } + } + + // Button states — update promote/demote targets + var promoteBtn = card.querySelector( '.wpdo-btn-promote' ); + if ( promoteBtn ) { + promoteBtn.disabled = ! info.next_mode; + promoteBtn.dataset.nextMode = info.next_mode || ''; + promoteBtn.title = info.next_mode ? '升級到 ' + info.next_mode : '已在最高模式'; + } + + var demoteBtn = card.querySelector( '.wpdo-btn-demote' ); + if ( demoteBtn ) { + demoteBtn.disabled = ! info.prev_mode; + demoteBtn.dataset.prevMode = info.prev_mode || ''; + demoteBtn.title = info.prev_mode ? '降級到 ' + info.prev_mode : '已在最低模式'; + } + + // Backfill active indicator + if ( info.backfill_active ) hasActive = true; + } ); + + // Manage poll timer + if ( hasActive ) { + startPolling(); + } + } + + function buildPipeline( currentMode ) { + var modes = [ 'disabled', 'dual_write', 'shadow_read', 'aeav_only' ]; + var labels = { disabled: 'disabled', dual_write: 'dual_write', shadow_read: 'shadow_read', aeav_only: 'aeav_only' }; + return modes.map( function ( m ) { + var active = m === currentMode ? ' wpdo-pipeline-active' : ''; + return '' + + '' + + '' + labels[ m ] + '' + + ''; + } ).join( '' ); + } + + // ─── Polling ───────────────────────────────────────────────────────────── + + function poll() { + apiFetch( 'GET', '/entity-bridge/health' ) + .then( updateAll ) + .catch( function ( e ) { console.warn( '[WPDO] Health poll error:', e ); } ); + } + + function startPolling() { + if ( timer ) return; + timer = setInterval( poll, pollInterval ); + } + + function stopPolling() { + if ( timer ) { clearInterval( timer ); timer = null; } + } + + // ─── Button handlers ───────────────────────────────────────────────────── + + function handleBackfill( btn ) { + var entityType = btn.dataset.entityType; + var groupName = btn.dataset.groupName; + if ( ! confirm( '確定要啟動 ' + entityType + '/' + groupName + ' 的 Backfill 遷移嗎?這將清除現有進度並重新開始。' ) ) return; + + btn.disabled = true; + btn.textContent = '排程中…'; + + apiFetch( 'POST', '/entity-bridge/backfill', { entity_type: entityType, group_name: groupName } ) + .then( function () { + btn.textContent = '已排程 ✓'; + startPolling(); + // Refresh once immediately + setTimeout( poll, 1000 ); + } ) + .catch( function ( e ) { + alert( '啟動 Backfill 失敗:' + e.message ); + btn.disabled = false; + btn.textContent = '啟動 Backfill'; + } ); + } + + function handlePromote( btn ) { + var entityType = btn.dataset.entityType; + var nextMode = btn.dataset.nextMode; + if ( ! nextMode ) return; + + if ( ! confirm( '確定要將 ' + entityType + ' 升級到 ' + nextMode + ' 嗎?' ) ) return; + + btn.disabled = true; + btn.textContent = '更新中…'; + + apiFetch( 'POST', '/entity-bridge/promote', { entity_type: entityType } ) + .then( function () { + poll(); // immediate refresh + } ) + .catch( function ( e ) { + alert( '升級失敗:' + e.message ); + btn.disabled = false; + btn.textContent = '升級模式'; + } ); + } + + function handleDemote( btn ) { + var entityType = btn.dataset.entityType; + var prevMode = btn.dataset.prevMode; + if ( ! prevMode ) return; + + if ( ! confirm( '確定要將 ' + entityType + ' 降級到 ' + prevMode + ' 嗎?降級後讀取會切回 EAV。' ) ) return; + + btn.disabled = true; + btn.textContent = '更新中…'; + + apiFetch( 'POST', '/entity-bridge/demote', { entity_type: entityType } ) + .then( function () { + poll(); + } ) + .catch( function ( e ) { + alert( '降級失敗:' + e.message ); + btn.disabled = false; + btn.textContent = '降級模式'; + } ); + } + + // ─── Event delegation ──────────────────────────────────────────────────── + + document.addEventListener( 'click', function ( e ) { + var btn = e.target.closest( 'button[data-wpdo-action]' ); + if ( ! btn ) return; + + var action = btn.dataset.wpdoAction; + + if ( action === 'backfill' ) { + handleBackfill( btn ); + } else if ( action === 'promote' ) { + handlePromote( btn ); + } else if ( action === 'demote' ) { + handleDemote( btn ); + } + } ); + + // ─── Init ──────────────────────────────────────────────────────────────── + + // Initial poll on page load if we're on the entity-bridge tab. + if ( document.querySelector( '.wpdo-entity-bridge-tab' ) ) { + poll(); + + // Auto-start polling if any backfill is already running (check server state). + // A subsequent poll() response will call startPolling() if needed. + } + + // Stop polling when navigating away. + window.addEventListener( 'beforeunload', stopPolling ); + +} )(); diff --git a/admin/assets/wpdo-migration-wizard.css b/admin/assets/wpdo-migration-wizard.css new file mode 100644 index 0000000..7799f03 --- /dev/null +++ b/admin/assets/wpdo-migration-wizard.css @@ -0,0 +1,288 @@ +/** + * WP Data Optimizer — Migration Wizard styling. + * + * Consumes the Morandi design system tokens (var(--color-*), var(--space-*), + * var(--radius-*), var(--shadow-*), var(--ease-default), var(--duration-normal), + * var(--font-family-display), var(--font-family-body)). + * + * @since 2.8.0 + */ + +.wpdo-migration-wizard { + max-width: 1100px; + font-family: var(--font-family-body, system-ui); + color: var(--color-ink, #2c2618); +} + +.wpdo-mw-header h2 { + font-family: var(--font-family-display, serif); + font-size: 1.6rem; + margin-bottom: var(--space-2, 8px); +} + +.wpdo-mw-panel { + background: var(--color-surface-card, #fbf7f0); + border: 1px solid var(--color-border-soft, #e5dccd); + border-radius: var(--radius-md, 12px); + padding: var(--space-6, 24px); + margin-bottom: var(--space-5, 20px); + box-shadow: var(--shadow-sm, 0 1px 3px rgba(60, 40, 20, 0.06)); +} + +.wpdo-mw-panel h3 { + margin-top: 0; + font-family: var(--font-family-display, serif); +} + +/* ── Pre-flight metrics grid ─────────────────────────────────── */ +.wpdo-mw-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-3, 12px); + margin-bottom: var(--space-4, 16px); +} + +.wpdo-mw-metric { + background: var(--color-surface, #fff); + border-radius: var(--radius-sm, 8px); + padding: var(--space-3, 12px) var(--space-4, 16px); + border: 1px solid var(--color-border-soft, #e5dccd); +} + +.wpdo-mw-metric-primary { + background: var(--color-accent-soft, #f4e9d4); + border-color: var(--color-accent, #c9a55a); +} + +.wpdo-mw-metric-label { + display: block; + font-size: 0.78rem; + color: var(--color-ink-soft, #6b5d44); + margin-bottom: var(--space-1, 4px); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.wpdo-mw-metric-value { + display: block; + font-size: 1.4rem; + font-weight: 600; + font-family: var(--font-family-display, serif); +} + +.wpdo-mw-mode { + font-size: 0.95rem; + font-family: var(--font-family-body, monospace); + padding: var(--space-1, 4px) var(--space-2, 8px); + border-radius: var(--radius-pill, 999px); +} + +.wpdo-mw-mode-aeav_only { background: #d8efd8; color: #2c5a2c; } +.wpdo-mw-mode-shadow_read { background: #fff3cd; color: #7c5e1e; } +.wpdo-mw-mode-dual_write { background: #d8e4f0; color: #2c4a6a; } +.wpdo-mw-mode-disabled { background: #eaeaea; color: #555; } + +.wpdo-mw-zero { color: var(--color-ink-soft, #aaa); } + +.wpdo-mw-groups summary { + cursor: pointer; + margin-bottom: var(--space-2, 8px); + color: var(--color-ink-soft, #6b5d44); +} + +.wpdo-mw-groups table { + margin-top: var(--space-2, 8px); +} + +/* ── Run panel ───────────────────────────────────────────────── */ +.wpdo-mw-nothing-todo { + text-align: center; + padding: var(--space-8, 32px); + color: var(--color-ink-soft, #6b5d44); +} + +.wpdo-mw-nothing-todo h3 { + font-size: 1.4rem; + margin-bottom: var(--space-3, 12px); +} + +.wpdo-mw-estimate { + background: var(--color-surface, #fff); + border-left: 3px solid var(--color-accent, #c9a55a); + padding: var(--space-3, 12px) var(--space-4, 16px); + margin: var(--space-4, 16px) 0; + border-radius: var(--radius-sm, 8px); +} + +.wpdo-mw-options { + border: 1px solid var(--color-border-soft, #e5dccd); + border-radius: var(--radius-sm, 8px); + padding: var(--space-4, 16px); + margin: var(--space-4, 16px) 0; +} + +.wpdo-mw-options legend { + padding: 0 var(--space-2, 8px); + font-weight: 600; +} + +.wpdo-mw-options label { + display: block; + margin: var(--space-2, 8px) 0; + cursor: pointer; + transition: color var(--duration-normal, 200ms) var(--ease-default, ease); +} + +.wpdo-mw-options label:hover { + color: var(--color-accent, #c9a55a); +} + +.wpdo-mw-confirm { + background: var(--color-warn-soft, #fff3cd); + border: 1px solid var(--color-warn, #d4a04f); + border-radius: var(--radius-sm, 8px); + padding: var(--space-3, 12px) var(--space-4, 16px); + margin: var(--space-4, 16px) 0; +} + +.wpdo-mw-confirm label { + cursor: pointer; +} + +#wpdo-mw-start { + min-height: 44px; + font-size: 1.05rem; + padding: var(--space-3, 12px) var(--space-6, 24px); + border-radius: var(--radius-md, 12px); + transition: transform var(--duration-normal, 200ms) var(--ease-default, ease); +} + +#wpdo-mw-start:not([disabled]):hover { + transform: translateY(-1px); +} + +/* ── Progress panel ──────────────────────────────────────────── */ +.wpdo-mw-progress-bar { + position: relative; + background: var(--color-surface, #fff); + border: 1px solid var(--color-border-soft, #e5dccd); + border-radius: var(--radius-pill, 999px); + height: 28px; + overflow: hidden; + margin: var(--space-4, 16px) 0; +} + +.wpdo-mw-progress-fill { + background: linear-gradient( + 90deg, + var(--color-accent, #c9a55a), + var(--color-accent-strong, #b08c3a) + ); + height: 100%; + transition: width var(--duration-normal, 300ms) var(--ease-default, ease); +} + +.wpdo-mw-progress-pct { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-weight: 600; + font-family: var(--font-family-display, serif); + mix-blend-mode: difference; + color: #fff; +} + +.wpdo-mw-current { + display: flex; + justify-content: space-between; + align-items: center; + margin: var(--space-3, 12px) 0; +} + +.wpdo-mw-current code { + background: var(--color-surface, #fff); + padding: var(--space-1, 4px) var(--space-2, 8px); + border-radius: var(--radius-sm, 8px); + font-family: var(--font-family-body, monospace); +} + +.wpdo-mw-elapsed { + font-family: var(--font-family-display, serif); + font-size: 1.1rem; + color: var(--color-ink-soft, #6b5d44); + min-width: 70px; + text-align: right; +} + +.wpdo-mw-live-ratio { + background: var(--color-accent-soft, #f4e9d4); + padding: var(--space-2, 8px) var(--space-4, 16px); + border-radius: var(--radius-sm, 8px); + margin: var(--space-3, 12px) 0; + font-family: var(--font-family-display, serif); +} + +.wpdo-mw-log-wrap h4 { + margin: var(--space-4, 16px) 0 var(--space-2, 8px); + font-family: var(--font-family-display, serif); +} + +.wpdo-mw-log { + background: var(--color-ink, #2c2618); + color: var(--color-paper, #f4ecdb); + padding: var(--space-4, 16px); + border-radius: var(--radius-sm, 8px); + font-family: var(--font-family-body, monospace); + font-size: 0.85rem; + line-height: 1.6; + max-height: 320px; + overflow-y: auto; + white-space: pre-wrap; + margin: 0; +} + +.wpdo-mw-log-flash { + animation: wpdo-mw-log-pulse var(--duration-normal, 350ms) var(--ease-default, ease); +} + +@keyframes wpdo-mw-log-pulse { + 0% { box-shadow: inset 0 0 0 0 var(--color-accent, #c9a55a); } + 30% { box-shadow: inset 0 0 30px -8px var(--color-accent, #c9a55a); } + 100% { box-shadow: inset 0 0 0 0 transparent; } +} + +.wpdo-mw-actions { + display: flex; + gap: var(--space-2, 8px); + margin-top: var(--space-4, 16px); +} + +.wpdo-mw-failed { + color: var(--color-error, #b03d3d); +} + +/* ── Done panel ──────────────────────────────────────────────── */ +.wpdo-mw-done { + background: var(--color-success-soft, #d8efd8); + border-color: var(--color-success, #5a8c5a); +} + +.wpdo-mw-done-summary { + margin: var(--space-4, 16px) 0; +} + +.wpdo-mw-done-line { + padding: var(--space-2, 8px) 0; + border-bottom: 1px dashed var(--color-border-soft, #e5dccd); +} + +.wpdo-mw-done-line:last-child { + border-bottom: 0; +} + +.wpdo-mw-done-line span { + display: inline-block; + min-width: 100px; + color: var(--color-ink-soft, #6b5d44); +} diff --git a/admin/assets/wpdo-migration-wizard.js b/admin/assets/wpdo-migration-wizard.js new file mode 100644 index 0000000..109e4c4 --- /dev/null +++ b/admin/assets/wpdo-migration-wizard.js @@ -0,0 +1,314 @@ +/** + * WP Data Optimizer — One-click User Migration Wizard frontend. + * + * - Confirms options + checkbox before starting. + * - Calls REST endpoints under /wp-json/wpdo/v1/migration/. + * - Polls /status every 500ms while job is active. + * - Streams log lines with fade-in; live-updates ratio + progress bar. + * + * @since 2.8.0 + */ +(function () { + 'use strict'; + + const POLL_INTERVAL_MS = 500; + const config = window.wpdoMigrationWizard || {}; + const i18n = config.i18n || {}; + const restUrl = (config.restUrl || '').replace(/\/$/, ''); + const nonce = config.nonce || ''; + + const $ = (id) => document.getElementById(id); + + const root = document.querySelector('.wpdo-migration-wizard'); + if (!root) return; + + const els = { + runPanel: $('wpdo-mw-run-panel'), + progressPanel: $('wpdo-mw-progress-panel'), + donePanel: $('wpdo-mw-done-panel'), + startBtn: $('wpdo-mw-start'), + cancelBtn: $('wpdo-mw-cancel'), + resumeBtn: $('wpdo-mw-resume'), + resetBtn: $('wpdo-mw-reset'), + confirmBox: $('wpdo-mw-confirm-backup'), + optBackup: $('wpdo-mw-opt-backup'), + optStrict: $('wpdo-mw-opt-strict'), + opt24h: $('wpdo-mw-opt-24h'), + optAsync: $('wpdo-mw-opt-async'), + optDryRun: $('wpdo-mw-opt-dryrun'), + progressFill: $('wpdo-mw-progress-fill'), + progressPct: $('wpdo-mw-progress-pct'), + progressTitle: $('wpdo-mw-progress-title'), + phaseName: $('wpdo-mw-current-phase-name'), + elapsed: $('wpdo-mw-elapsed'), + liveRatio: $('wpdo-mw-live-ratio-text'), + log: $('wpdo-mw-log'), + ratio: $('wpdo-mw-ratio'), + residue: $('wpdo-mw-residue'), + usermeta: $('wpdo-mw-usermeta'), + doneSummary: $('wpdo-mw-done-summary'), + }; + + let pollTimer = null; + let startedAt = 0; + let elapsedTimer = null; + + function show(panel) { + [els.runPanel, els.progressPanel, els.donePanel].forEach((p) => { + if (!p) return; + if (p === panel) { + p.removeAttribute('hidden'); + } else { + p.setAttribute('hidden', ''); + } + }); + } + + function fetchJson(path, options = {}) { + const url = restUrl + path; + const opts = Object.assign( + { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' }, + }, + options + ); + return fetch(url, opts).then(async (r) => { + let body; + try { + body = await r.json(); + } catch (e) { + body = null; + } + return { ok: r.ok, status: r.status, body }; + }); + } + + // ── Start handling ─────────────────────────────────────────────────── + + if (els.confirmBox && els.startBtn) { + els.confirmBox.addEventListener('change', () => { + els.startBtn.disabled = !els.confirmBox.checked; + }); + } + + if (els.startBtn) { + els.startBtn.addEventListener('click', () => { + if (!confirm(i18n.confirmStart || 'Start migration?')) return; + els.startBtn.disabled = true; + els.startBtn.textContent = '…'; + + const opts = { + auto_backup: els.optBackup ? !!els.optBackup.checked : true, + verify_strict: els.optStrict ? !!els.optStrict.checked : true, + verify_24h: els.opt24h ? !!els.opt24h.checked : false, + force_async: els.optAsync ? !!els.optAsync.checked : false, + dry_run: els.optDryRun ? !!els.optDryRun.checked : false, + }; + + fetchJson('/migration/start', { + method: 'POST', + body: JSON.stringify(opts), + }).then((res) => { + if (!res.ok) { + if (res.body && res.body.reason === 'nothing_to_do') { + alert(i18n.nothingToDo); + els.startBtn.disabled = false; + els.startBtn.textContent = '🚀'; + return; + } + alert((res.body && res.body.error) || 'Start failed'); + els.startBtn.disabled = false; + els.startBtn.textContent = '🚀'; + return; + } + startedAt = Date.now(); + show(els.progressPanel); + startElapsedTimer(); + startPolling(); + }); + }); + } + + // ── Cancel & Resume ────────────────────────────────────────────────── + + if (els.cancelBtn) { + els.cancelBtn.addEventListener('click', () => { + if (!confirm(i18n.confirmCancel || 'Cancel?')) return; + els.cancelBtn.disabled = true; + fetchJson('/migration/cancel', { method: 'POST' }).then(() => { + stopPolling(); + stopElapsedTimer(); + setTimeout(() => location.reload(), 600); + }); + }); + } + + if (els.resumeBtn) { + els.resumeBtn.addEventListener('click', () => { + els.resumeBtn.disabled = true; + fetchJson('/migration/resume', { method: 'POST' }).then((res) => { + if (res.ok) { + els.resumeBtn.setAttribute('hidden', ''); + els.cancelBtn.disabled = false; + startPolling(); + } else { + alert((res.body && res.body.error) || 'Resume failed'); + els.resumeBtn.disabled = false; + } + }); + }); + } + + if (els.resetBtn) { + els.resetBtn.addEventListener('click', () => location.reload()); + } + + // ── Polling ────────────────────────────────────────────────────────── + + function startPolling() { + if (pollTimer) return; + const tick = () => { + fetchJson('/migration/status').then((res) => { + if (!res.ok || !res.body) { + pollTimer = setTimeout(tick, POLL_INTERVAL_MS); + return; + } + renderStatus(res.body); + const state = res.body.state; + if (state === 'running') { + pollTimer = setTimeout(tick, POLL_INTERVAL_MS); + } else if (state === 'completed') { + stopPolling(); + stopElapsedTimer(); + renderDone(res.body); + } else if (state === 'failed') { + stopPolling(); + stopElapsedTimer(); + renderFailed(res.body); + } else if (state === 'cancelled' || state === 'idle') { + stopPolling(); + stopElapsedTimer(); + } + }); + }; + tick(); + } + + function stopPolling() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function startElapsedTimer() { + if (elapsedTimer) return; + elapsedTimer = setInterval(() => { + if (!els.elapsed) return; + const sec = (Date.now() - startedAt) / 1000; + els.elapsed.textContent = sec.toFixed(1) + 's'; + }, 100); + } + + function stopElapsedTimer() { + if (elapsedTimer) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + } + + // ── Render ─────────────────────────────────────────────────────────── + + function renderStatus(s) { + if (els.progressFill) { + els.progressFill.style.width = (s.overall_progress || 0) + '%'; + } + if (els.progressPct) { + els.progressPct.textContent = (s.overall_progress || 0) + '%'; + } + if (els.phaseName) { + els.phaseName.textContent = s.phase || ''; + } + if (els.liveRatio && s.metrics) { + els.liveRatio.textContent = + '1:' + (s.metrics.ratio_start || '?') + + ' → 1:' + (s.metrics.ratio_now || '?'); + } + // Update top metrics live too + if (els.ratio && s.metrics && s.metrics.ratio_now != null) { + els.ratio.textContent = '1:' + s.metrics.ratio_now; + } + if (els.residue && s.metrics && s.metrics.eav_rows_now != null) { + els.residue.textContent = String(s.metrics.eav_rows_now); + } + updateLog(s.log || []); + } + + let lastLogLength = 0; + function updateLog(lines) { + if (!els.log) return; + if (lines.length === lastLogLength) return; + els.log.textContent = lines.join('\n'); + els.log.scrollTop = els.log.scrollHeight; + lastLogLength = lines.length; + // Apply a subtle highlight on the last line via CSS animation + els.log.classList.remove('wpdo-mw-log-flash'); + // Force reflow so re-adding the class re-triggers the animation + // eslint-disable-next-line no-unused-expressions + void els.log.offsetWidth; + els.log.classList.add('wpdo-mw-log-flash'); + } + + function renderDone(s) { + show(els.donePanel); + if (!els.doneSummary) return; + const m = s.metrics || {}; + const elapsed = ((s.completed_at || 0) - (s.started_at || 0)); + els.doneSummary.innerHTML = ''; + const lines = [ + ['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')], + ['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)], + ['mode', (m.mode_start || '?') + ' → aeav_only'], + ['耗時', elapsed + ' 秒'], + ['備份', s.backup_path || '(無)'], + ]; + lines.forEach(([label, value]) => { + const div = document.createElement('div'); + div.className = 'wpdo-mw-done-line'; + const lab = document.createElement('span'); lab.textContent = label + ':'; + const val = document.createElement('strong'); val.textContent = value; + div.append(lab, val); + els.doneSummary.appendChild(div); + }); + } + + function renderFailed(s) { + els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel'); + els.progressTitle.classList.add('wpdo-mw-failed'); + if (els.resumeBtn) { + els.resumeBtn.removeAttribute('hidden'); + els.resumeBtn.disabled = false; + } + } + + // ── On-load: if a job is already running, hop into progress mode ──── + const initialState = root.getAttribute('data-state'); + if (initialState === 'running' || initialState === 'paused') { + startedAt = Date.now(); + show(els.progressPanel); + startElapsedTimer(); + startPolling(); + } else if (initialState === 'failed') { + show(els.progressPanel); + renderFailed({ state: 'failed' }); + } else if (initialState === 'completed') { + // Already shown via PHP hidden flag — but populate summary if available + fetchJson('/migration/status').then((res) => { + if (res.ok && res.body && res.body.state === 'completed') { + renderDone(res.body); + } + }); + } +})(); diff --git a/admin/assets/wpdo-post-stress-test.js b/admin/assets/wpdo-post-stress-test.js new file mode 100644 index 0000000..4e7ddd0 --- /dev/null +++ b/admin/assets/wpdo-post-stress-test.js @@ -0,0 +1,383 @@ +/** + * WPDO Post Stress Test — admin tab JS (v2.11.4) + * + * Mirrors wpdo-stress-test.js (user side) but: + * - Talks to /wpdo/v1/post-stress-test/* endpoints + * - Sends post_type in start payload (user side has no entity selector) + * - DOM IDs prefixed wpdo-pst-* to coexist on the same admin page + * - Renders a smaller benchmark report (3 probes per post_type vs 6 for user) + * + * @since 2.11.4 + */ +( function () { + 'use strict'; + + const cfg = window.wpdoPostStressTest; + if ( ! cfg || ! cfg.restUrl ) { + return; + } + if ( ! document.querySelector( '.wpdo-post-stress-test-tab' ) ) { + return; + } + + const $ = ( sel ) => document.querySelector( sel ); + const restUrl = cfg.restUrl.replace( /\/$/, '' ); + const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }; + + let pollTimer = null; + + // ── API helpers ───────────────────────────────────────────────────────── + + async function apiCall( path, method = 'GET', body = null ) { + const opts = { method, headers, credentials: 'same-origin' }; + if ( body ) { + opts.body = JSON.stringify( body ); + } + const resp = await fetch( restUrl + path, opts ); + const text = await resp.text(); + try { + return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null }; + } catch ( e ) { + return { ok: false, status: resp.status, data: { error: text } }; + } + } + + // ── Rendering ─────────────────────────────────────────────────────────── + + function fmt( n ) { + if ( n === null || n === undefined ) { + return '—'; + } + return Number( n ).toLocaleString(); + } + + function setText( sel, text ) { + const el = $( sel ); + if ( el ) { + el.textContent = String( text ); + } + } + + function esc( s ) { + if ( s === null || s === undefined ) { + return ''; + } + return String( s ).replace( /[&<>"']/g, ( c ) => ( { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ c ] ) ); + } + + function renderProgress( state ) { + const isRunning = state.status === 'running' || state.status === 'benchmarking'; + const card = $( '#wpdo-pst-progress-card' ); + if ( card ) { + card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none'; + } + setText( '#wpdo-pst-pg-status', state.status || 'idle' ); + setText( '#wpdo-pst-pg-post-type', state.post_type || '' ); + setText( '#wpdo-pst-pg-mode', state.mode || '' ); + setText( '#wpdo-pst-pg-pct', ( state.pct || 0 ) + '%' ); + setText( '#wpdo-pst-pg-processed', fmt( state.processed || 0 ) ); + setText( '#wpdo-pst-pg-target', fmt( state.target || 0 ) ); + setText( '#wpdo-pst-pg-rate', state.rate_per_sec || 0 ); + setText( '#wpdo-pst-pg-elapsed', state.elapsed_sec || 0 ); + setText( '#wpdo-pst-pg-eta', state.eta_sec || 0 ); + setText( '#wpdo-pst-pg-batches', state.batches_done || 0 ); + setText( '#wpdo-pst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) ); + + const bar = $( '#wpdo-pst-pg-bar' ); + if ( bar ) { + bar.style.width = ( state.pct || 0 ) + '%'; + } + + const count = state.test_post_count || 0; + setText( '#wpdo-pst-count', fmt( count ) ); + setText( '#wpdo-pst-count-mirror', fmt( count ) ); + + const startBtn = $( '#wpdo-pst-start' ); + const cancelBtn = $( '#wpdo-pst-cancel' ); + const cleanupBtn = $( '#wpdo-pst-cleanup' ); + const benchBtn = $( '#wpdo-pst-rerun-bench' ); + if ( startBtn ) { + startBtn.disabled = isRunning; + } + if ( cancelBtn ) { + cancelBtn.disabled = ! isRunning; + } + if ( cleanupBtn ) { + cleanupBtn.disabled = isRunning || count === 0; + } + if ( benchBtn ) { + benchBtn.disabled = isRunning || count === 0; + } + } + + function renderBenchmark( bench ) { + if ( ! bench ) { + return; + } + const card = $( '#wpdo-pst-bench-card' ); + const content = $( '#wpdo-pst-bench-content' ); + if ( ! card || ! content ) { + return; + } + card.style.display = ''; + + const w = bench.write || {}; + const dbSizes = bench.db_sizes || []; + const q = bench.query || {}; + + // Friendly labels for the per-post_type query probes + const labels = { + point_stock_status: 'Point lookup (_stock_status)', + point_status: 'Point lookup (hp_status)', + point_verified: 'Point lookup (hp_verified)', + point_alt_present: 'Point lookup (alt 文字)', + point_type: 'Point lookup (_menu_item_type)', + point_thumbnail: 'Point lookup (_thumbnail_id)', + range_price_above: 'Range scan (price > 100)', + range_budget_above: 'Range scan (budget > 100)', + range_rate_above: 'Range scan (hourly_rate > 50)', + range_id_above: 'Range scan (post_id 排序)', + eav_baseline: 'EAV baseline (wp_postmeta 直查)', + }; + + let html = ''; + // Write metrics + html += '

▍ 寫入指標

'; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += '
模式${ esc( w.mode ) }
Post Type${ esc( w.post_type ) }
完成 / 目標${ fmt( w.processed ) } / ${ fmt( w.target ) }
總耗時${ fmt( w.elapsed_sec ) } 秒
平均速率${ fmt( w.rate_per_sec ) } posts/sec
批次數${ fmt( w.batches_done ) }
批次最快/平均/最慢${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms
PHP Peak Memory${ fmt( w.peak_memory_mb ) } MB
'; + + // DB sizes (wp_posts + wp_postmeta + flat table for this run) + html += '

▍ DB 容量(post 相關表)

'; + html += ''; + html += ''; + html += ''; + dbSizes.forEach( ( r ) => { + html += ``; + html += ``; + html += ``; + } ); + html += '
TableRowsData MBIndex MBTotal MBAvg bytes/row
${ esc( r.table ) }${ fmt( r.rows ) }${ r.data_mb ?? '—' }${ r.index_mb ?? '—' }${ r.total_mb ?? '—' }${ fmt( r.avg_bytes ) }
'; + + // Query perf — 3 probes per post_type, with EAV baseline last for visual comparison + html += '

▍ 查詢效能

'; + html += ''; + html += ''; + html += ''; + + const baselineMs = q.eav_baseline?.duration_ms ?? null; + Object.keys( q ).forEach( ( key ) => { + const v = q[ key ]; + if ( ! v || typeof v.duration_ms !== 'number' ) { + return; + } + const label = labels[ key ] || key; + let speedup = ''; + if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) { + const ratio = baselineMs / v.duration_ms; + if ( ratio >= 1 ) { + speedup = ` (${ ratio.toFixed( 2 ) }× faster)`; + } + } + html += ``; + } ); + html += '
測試項目耗時 (ms)
${ esc( label ) }${ speedup }${ v.duration_ms }
'; + html += '

EAV baseline 走 wp_postmeta,flat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。

'; + + content.innerHTML = html; + } + + // ── Polling ───────────────────────────────────────────────────────────── + + async function poll() { + const r = await apiCall( '/post-stress-test/status' ); + if ( ! r.ok || ! r.data ) { + return; + } + renderProgress( r.data ); + if ( r.data.benchmark ) { + renderBenchmark( r.data.benchmark ); + } + if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) { + stopPolling(); + } + } + + function startPolling() { + stopPolling(); + poll(); + pollTimer = setInterval( poll, 2000 ); + } + + function stopPolling() { + if ( pollTimer ) { + clearInterval( pollTimer ); + pollTimer = null; + } + } + + // ── Event handlers ────────────────────────────────────────────────────── + + async function handleStart() { + const postType = $( '#wpdo-pst-post-type' ).value; + const target = parseInt( $( '#wpdo-pst-target' ).value, 10 ); + const batch = parseInt( $( '#wpdo-pst-batch' ).value, 10 ); + const mode = document.querySelector( 'input[name="wpdo-pst-mode"]:checked' ).value; + + if ( ! postType ) { + alert( '請選擇 post_type' ); + return; + } + if ( ! target || target < 1 ) { + alert( '請輸入有效的 post 數量' ); + return; + } + // Realistic 模式每 post 100-300ms,batch 太大會撞 nginx 60s timeout 之前的 8s deadline + if ( mode === 'realistic' && batch > 30 ) { + if ( ! confirm( `Realistic 模式每個 post 約需 100-300 ms,batch_size=${ batch } 可能超過後端 8s deadline 上限。建議使用 batch=10-20。要繼續嗎?` ) ) { + return; + } + } + + // Soft warn for combinations that won't demonstrate反 EAV 優化效果 + const postMode = String( cfg.postMode || 'disabled' ); + if ( mode === 'fast' && postMode === 'aeav_only' ) { + if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 post mode=aeav_only 也會寫滿 wp_postmeta(fixture 用途,非優化驗證)。\n\n如果你想驗證反 EAV 優化效果(wp_postmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) { + return; + } + } else if ( mode === 'realistic' && postMode !== 'aeav_only' ) { + if ( ! confirm( `⚠️ 目前 post mode = ${ postMode },此模式下 Realistic 寫入仍會雙寫 wp_postmeta(不會展示優化效果,ratio 不變)。\n\n要看 wp_postmeta 完全短路(0 寫入)需要先把 mode 升到 aeav_only(設定 tab)。\n\n仍以 ${ postMode } 模式繼續測試嗎?` ) ) { + return; + } + } + + const big = target >= 5000; + const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ postType } 測試 post${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n所有 post 的 post_title 會以 WPDO_STRESS_TEST_ 開頭,可一鍵清除。\n\n確定要開始嗎?`; + if ( ! confirm( msg ) ) { + return; + } + + const r = await apiCall( '/post-stress-test/start', 'POST', { + post_type: postType, + target, + mode, + batch_size: batch, + } ); + if ( ! r.ok ) { + alert( '啟動失敗:' + ( r.data?.error || r.status ) ); + return; + } + // 啟動後立即顯示進度卡片 + const card = $( '#wpdo-pst-progress-card' ); + if ( card ) { + card.style.display = ''; + } + // Hide the previous benchmark card (a fresh run will replace it) + const benchCard = $( '#wpdo-pst-bench-card' ); + if ( benchCard ) { + benchCard.style.display = 'none'; + } + setText( '#wpdo-pst-pg-status', 'running' ); + setText( '#wpdo-pst-pg-post-type', postType ); + setText( '#wpdo-pst-pg-mode', mode ); + setText( '#wpdo-pst-pg-target', target.toLocaleString() ); + startPolling(); + } + + async function handleCancel() { + if ( ! confirm( '確定要取消當前測試?已建立的 post 不會被刪除。' ) ) { + return; + } + const cancelBtn = $( '#wpdo-pst-cancel' ); + if ( cancelBtn ) { + cancelBtn.disabled = true; + cancelBtn.textContent = '⏹ 取消中…'; + } + setText( '#wpdo-pst-pg-status', 'cancelling' ); + + const r = await apiCall( '/post-stress-test/cancel', 'POST' ); + if ( ! r.ok ) { + alert( '取消失敗:' + ( r.data?.error || r.status ) ); + if ( cancelBtn ) { + cancelBtn.disabled = false; + cancelBtn.textContent = '⏹ 取消'; + } + return; + } + // realistic 模式 in-flight batch 可能還要 ~1 秒才會真正中止 + poll(); + if ( cancelBtn ) { + cancelBtn.textContent = '⏹ 取消'; + } + } + + async function handleCleanup() { + if ( ! confirm( '確定要清除所有 stress test posts?\n\n此動作會:\n- DELETE 所有 post_title 前綴 WPDO_STRESS_TEST_ 的 post\n- DELETE 對應 wp_postmeta\n- DELETE 7 張 wp_wpdo_post_* flat tables 中對應 post_id 的列\n\n不可復原!' ) ) { + return; + } + const r = await apiCall( '/post-stress-test/cleanup', 'DELETE' ); + if ( ! r.ok ) { + alert( '清除失敗:' + ( r.data?.error || r.status ) ); + return; + } + alert( `已清除 ${ r.data.deleted } 筆測試 post。` ); + const card = $( '#wpdo-pst-progress-card' ); + const benchCard = $( '#wpdo-pst-bench-card' ); + if ( card ) card.style.display = 'none'; + if ( benchCard ) benchCard.style.display = 'none'; + poll(); + } + + async function handleRerunBench() { + const btn = $( '#wpdo-pst-rerun-bench' ); + if ( btn ) { + btn.disabled = true; + btn.textContent = '⏳ 執行中...'; + } + const r = await apiCall( '/post-stress-test/benchmark', 'POST' ); + if ( btn ) { + btn.disabled = false; + btn.textContent = '📊 重跑 Benchmark(不新增資料)'; + } + if ( ! r.ok ) { + alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) ); + return; + } + renderBenchmark( r.data.benchmark ); + } + + // ── Init ──────────────────────────────────────────────────────────────── + + document.addEventListener( 'DOMContentLoaded', () => { + const startBtn = $( '#wpdo-pst-start' ); + const cancelBtn = $( '#wpdo-pst-cancel' ); + const cleanupBtn = $( '#wpdo-pst-cleanup' ); + const benchBtn = $( '#wpdo-pst-rerun-bench' ); + + if ( startBtn ) startBtn.addEventListener( 'click', handleStart ); + if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel ); + if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup ); + if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench ); + + // 初始 poll:若 status==running 自動接手 polling;若 completed 渲染 benchmark + poll().then( () => { + const status = ( $( '#wpdo-pst-pg-status' )?.textContent || '' ).trim(); + if ( status === 'running' || status === 'benchmarking' ) { + startPolling(); + } + } ); + } ); +} )(); diff --git a/admin/assets/wpdo-rest-sdk.js b/admin/assets/wpdo-rest-sdk.js new file mode 100644 index 0000000..cdc72cd --- /dev/null +++ b/admin/assets/wpdo-rest-sdk.js @@ -0,0 +1,247 @@ +/** + * WP Data Optimizer — REST API JavaScript SDK + * + * Lightweight client for /wp-json/wpdo/v1/ endpoints. + * No dependencies required. + * + * Usage: + * const wpdo = new WpdoClient(); + * + * // Fetch listings (Zone A) + * const { items, total } = await wpdo.getListings({ hp_price_min: 100, per_page: 20 }); + * + * // Single listing (Zone A + Zone C) + * const listing = await wpdo.getListing(123); + * + * // View count (Zone B) + * const { view_count } = await wpdo.getStats(123); + * + * // Increment view count (Zone B, requires WP REST nonce) + * const { view_count: updated } = await wpdo.incrementView(123); + * + * // Iterate all pages + * for await (const page of wpdo.paginateListings({ hp_featured: 1 })) { + * console.log(page.items); + * } + * + * @package WP_Data_Optimizer + */ + +/* global wpdo_sdk_config */ + +( function ( global ) { + 'use strict'; + + /** + * WpdoClient — REST API wrapper for WP Data Optimizer. + * + * @param {object} [options] + * @param {string} [options.baseUrl] REST base URL (default: auto-detected from wpdo_sdk_config or /wp-json) + * @param {string} [options.nonce] WP REST nonce for authenticated requests + * @param {string} [options.postType] Default post type (default: 'hp_listing') + */ + function WpdoClient( options ) { + options = options || {}; + + var config = ( typeof wpdo_sdk_config !== 'undefined' ) ? wpdo_sdk_config : {}; + + this._base = options.baseUrl || config.rest_url || '/wp-json/wpdo/v1'; + this._nonce = options.nonce || config.nonce || ''; + this._postType = options.postType || config.post_type || 'hp_listing'; + } + + // ── Core fetch ──────────────────────────────────────────────────────────── + + /** + * Internal GET fetch helper. + * + * @param {string} path Relative path (e.g. '/listings') + * @param {object} params Query parameters + * @return {Promise<{data: *, headers: Headers, status: number}>} + */ + WpdoClient.prototype._fetch = function ( path, params ) { + var url = this._base + path; + + if ( params && Object.keys( params ).length ) { + var qs = Object.keys( params ) + .filter( + function ( k ) { + return params[ k ] !== null && params[ k ] !== undefined && params[ k ] !== ''; } + ) + .map( + function ( k ) { + return encodeURIComponent( k ) + '=' + encodeURIComponent( params[ k ] ); } + ) + .join( '&' ); + if ( qs ) { + url += '?' + qs; + } + } + + var headers = { 'Content-Type': 'application/json' }; + if ( this._nonce ) { + headers[ 'X-WP-Nonce' ] = this._nonce; + } + + return fetch( url, { headers: headers } ).then( + function ( res ) { + return res.json().then( + function ( data ) { + return { data: data, headers: res.headers, status: res.status }; + } + ); + } + ); + }; + + /** + * Internal POST fetch helper. + * + * @param {string} path Relative path + * @param {object} body JSON body (optional) + * @return {Promise<{data: *, status: number}>} + */ + WpdoClient.prototype._post = function ( path, body ) { + var headers = { 'Content-Type': 'application/json' }; + if ( this._nonce ) { + headers[ 'X-WP-Nonce' ] = this._nonce; + } + + return fetch( + this._base + path, + { + method: 'POST', + headers: headers, + body: body ? JSON.stringify( body ) : null, + } + ).then( + function ( res ) { + return res.json().then( + function ( data ) { + return { data: data, status: res.status }; + } + ); + } + ); + }; + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Fetch a page of listings from Zone A. + * + * @param {object} [params] + * @param {string} [params.post_type] Default: configured post type + * @param {number} [params.per_page] 1–100, default 20 + * @param {number} [params.page] Default 1 + * @param {string} [params.orderby] Column name, default 'post_id' + * @param {string} [params.order] 'ASC' | 'DESC', default 'DESC' + * @param {number} [params.*_min] Numeric range filter (e.g. hp_price_min) + * @param {number} [params.*_max] Numeric range filter (e.g. hp_price_max) + * @param {*} [params.*] Exact match filter (e.g. hp_featured: 1) + * @return {Promise<{items: Array, total: number, totalPages: number}>} + */ + WpdoClient.prototype.getListings = function ( params ) { + var merged = Object.assign( { post_type: this._postType }, params || {} ); + return this._fetch( '/listings', merged ).then( + function ( res ) { + return { + items: res.data, + total: parseInt( res.headers.get( 'X-WP-Total' ) || '0', 10 ), + totalPages: parseInt( res.headers.get( 'X-WP-TotalPages' ) || '0', 10 ), + status: res.status, + }; + } + ); + }; + + /** + * Fetch a single listing (Zone A + Zone C merged). + * + * @param {number} id Post ID + * @return {Promise} + */ + WpdoClient.prototype.getListing = function ( id ) { + return this._fetch( '/listings/' + id, null ).then( + function ( res ) { + return res.data; + } + ); + }; + + /** + * Fetch Zone B view count for a post. + * + * @param {number} id Post ID + * @return {Promise<{post_id: number, view_count: number}>} + */ + WpdoClient.prototype.getStats = function ( id ) { + return this._fetch( '/stats/' + id, null ).then( + function ( res ) { + return res.data; + } + ); + }; + + /** + * Increment Zone B view count for a post (requires WP REST nonce). + * + * The nonce must be passed via the `nonce` constructor option or + * via `wpdo_sdk_config.nonce` (wp_create_nonce('wp_rest')). + * + * @param {number} id Post ID + * @return {Promise<{post_id: number, view_count: number}>} + * + * Example: + * // Auto-tracks a view when a listing page loads: + * document.addEventListener('DOMContentLoaded', () => { + * const postId = parseInt(document.body.dataset.postId); + * if (postId) wpdo.incrementView(postId); + * }); + */ + WpdoClient.prototype.incrementView = function ( id ) { + return this._post( '/listings/' + id + '/view', null ).then( + function ( res ) { + return res.data; + } + ); + }; + + /** + * Async generator — iterates every page of listings. + * + * @param {object} [params] Same params as getListings (page is managed internally) + * @yields {{items: Array, total: number, page: number, totalPages: number}} + * + * Example: + * for await (const page of wpdo.paginateListings({ hp_featured: 1 })) { + * page.items.forEach(item => console.log(item)); + * } + */ + WpdoClient.prototype.paginateListings = async function * ( params ) { + var page = 1; + var total = Infinity; + var perPage = ( params && params.per_page ) ? params.per_page : 20; + + while ( ( page - 1 ) * perPage < total ) { + var merged = Object.assign( {}, params || {}, { page: page } ); + var result = await this.getListings( merged ); + total = result.total; + yield { items: result.items, total: total, page: page, totalPages: result.totalPages }; + if ( page >= result.totalPages ) { + break; + } + page++; + } + }; + + // ── Export ──────────────────────────────────────────────────────────────── + + global.WpdoClient = WpdoClient; + + // Auto-instantiate as window.wpdo if config is present. + if ( typeof wpdo_sdk_config !== 'undefined' ) { + global.wpdo = new WpdoClient(); + } + +}( window ) ); diff --git a/admin/assets/wpdo-stress-test.js b/admin/assets/wpdo-stress-test.js new file mode 100644 index 0000000..6b4b072 --- /dev/null +++ b/admin/assets/wpdo-stress-test.js @@ -0,0 +1,339 @@ +/** + * WPDO User Stress Test — admin tab JS + * + * - 2s polling while running/benchmarking + * - Start / Cancel / Cleanup / Re-run benchmark + * - Renders progress + benchmark report + * + * @since 2.6.7 + */ +( function () { + 'use strict'; + + const cfg = window.wpdoStressTest; + if ( ! cfg || ! cfg.restUrl ) { + return; + } + if ( ! document.querySelector( '.wpdo-stress-test-tab' ) ) { + return; + } + + const $ = ( sel ) => document.querySelector( sel ); + const restUrl = cfg.restUrl.replace( /\/$/, '' ); + const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }; + + let pollTimer = null; + + // ── API helpers ───────────────────────────────────────────────────────── + + async function apiCall( path, method = 'GET', body = null ) { + const opts = { method, headers, credentials: 'same-origin' }; + if ( body ) { + opts.body = JSON.stringify( body ); + } + const resp = await fetch( restUrl + path, opts ); + const text = await resp.text(); + try { + return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null }; + } catch ( e ) { + return { ok: false, status: resp.status, data: { error: text } }; + } + } + + // ── Rendering ─────────────────────────────────────────────────────────── + + function fmt( n ) { + if ( n === null || n === undefined ) { + return '—'; + } + return Number( n ).toLocaleString(); + } + + function renderProgress( state ) { + const isRunning = state.status === 'running' || state.status === 'benchmarking'; + const card = $( '#wpdo-st-progress-card' ); + if ( card ) { + card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none'; + } + setText( '#wpdo-st-pg-status', state.status || 'idle' ); + setText( '#wpdo-st-pg-mode', state.mode || '' ); + setText( '#wpdo-st-pg-pct', ( state.pct || 0 ) + '%' ); + setText( '#wpdo-st-pg-processed', fmt( state.processed || 0 ) ); + setText( '#wpdo-st-pg-target', fmt( state.target || 0 ) ); + setText( '#wpdo-st-pg-rate', state.rate_per_sec || 0 ); + setText( '#wpdo-st-pg-elapsed', state.elapsed_sec || 0 ); + setText( '#wpdo-st-pg-eta', state.eta_sec || 0 ); + setText( '#wpdo-st-pg-batches', state.batches_done || 0 ); + setText( '#wpdo-st-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) ); + + const bar = $( '#wpdo-st-pg-bar' ); + if ( bar ) { + bar.style.width = ( state.pct || 0 ) + '%'; + } + + setText( '#wpdo-st-count', fmt( state.test_user_count || 0 ) ); + + const startBtn = $( '#wpdo-st-start' ); + const cancelBtn = $( '#wpdo-st-cancel' ); + const cleanupBtn = $( '#wpdo-st-cleanup' ); + const benchBtn = $( '#wpdo-st-rerun-bench' ); + if ( startBtn ) { + startBtn.disabled = isRunning; + } + if ( cancelBtn ) { + cancelBtn.disabled = ! isRunning; + } + if ( cleanupBtn ) { + cleanupBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0; + } + if ( benchBtn ) { + benchBtn.disabled = isRunning || ( state.test_user_count || 0 ) === 0; + } + } + + function renderBenchmark( bench ) { + if ( ! bench ) { + return; + } + const card = $( '#wpdo-st-bench-card' ); + const content = $( '#wpdo-st-bench-content' ); + if ( ! card || ! content ) { + return; + } + card.style.display = ''; + + const w = bench.write || {}; + const dbSizes = bench.db_sizes || []; + const q = bench.query || {}; + + const labels = { + get_field_membership_level: 'WPDO_API::get_field (membership_level) ×100', + get_entity_full: 'WPDO_API::get_entity (整筆) ×100', + range_gold_high_points: '索引範圍:gold + points>5000', + sort_recent_active_100: '排序:last_active_at DESC LIMIT 100', + join_top_gold_active: 'JOIN:top gold + active LIMIT 100', + eav_range_baseline: '原生 EAV 等價查詢(baseline)', + }; + + let html = ''; + // Write metrics + html += '

▍ 寫入指標

'; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += '
模式${ esc( w.mode ) }
完成 / 目標${ fmt( w.processed ) } / ${ fmt( w.target ) }
總耗時${ fmt( w.elapsed_sec ) } 秒
平均速率${ fmt( w.rate_per_sec ) } users/sec
批次數${ fmt( w.batches_done ) }
批次最快/平均/最慢${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms
PHP Peak Memory${ fmt( w.peak_memory_mb ) } MB
'; + + // DB sizes + html += '

▍ DB 容量(user 相關表)

'; + html += ''; + html += ''; + html += ''; + dbSizes.forEach( ( r ) => { + html += ``; + html += ``; + html += ``; + } ); + html += '
TableRowsData MBIndex MBTotal MBAvg bytes/row
${ esc( r.table ) }${ fmt( r.rows ) }${ r.data_mb ?? '—' }${ r.index_mb ?? '—' }${ r.total_mb ?? '—' }${ fmt( r.avg_bytes ) }
'; + + // Query perf + html += '

▍ 查詢效能

'; + html += ''; + html += ''; + html += ''; + Object.keys( labels ).forEach( ( key ) => { + const v = q[ key ]; + if ( ! v ) { + return; + } + const total = v.total_ms ?? v.duration_ms ?? '—'; + html += ``; + html += ``; + } ); + html += '
測試項目樣本總時間 (ms)平均 (ms)QPS
${ esc( labels[ key ] ) }${ v.n || 1 }${ total }${ v.avg_ms ?? '—' }${ v.qps ?? '—' }
'; + html += '

原生 EAV baseline 與 flat table 範圍查詢的時間差,即代表此規模下反 EAV 帶來的查詢加速倍數。

'; + + content.innerHTML = html; + } + + function setText( sel, text ) { + const el = $( sel ); + if ( el ) { + el.textContent = String( text ); + } + } + + function esc( s ) { + if ( s === null || s === undefined ) { + return ''; + } + return String( s ).replace( /[&<>"']/g, ( c ) => ( { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ c ] ) ); + } + + // ── Polling ───────────────────────────────────────────────────────────── + + async function poll() { + const r = await apiCall( '/stress-test/status' ); + if ( ! r.ok || ! r.data ) { + return; + } + renderProgress( r.data ); + if ( r.data.benchmark ) { + renderBenchmark( r.data.benchmark ); + } + if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) { + stopPolling(); + } + } + + function startPolling() { + stopPolling(); + poll(); + pollTimer = setInterval( poll, 2000 ); + } + + function stopPolling() { + if ( pollTimer ) { + clearInterval( pollTimer ); + pollTimer = null; + } + } + + // ── Event handlers ────────────────────────────────────────────────────── + + async function handleStart() { + const target = parseInt( $( '#wpdo-st-target' ).value, 10 ); + let batch = parseInt( $( '#wpdo-st-batch' ).value, 10 ); + const mode = document.querySelector( 'input[name="wpdo-st-mode"]:checked' ).value; + + if ( ! target || target < 1 ) { + alert( '請輸入有效的使用者數量' ); + return; + } + // Realistic 模式每 user ~3.5 秒,batch_size 太大會撞 nginx 60s timeout + // 後端有 25s wall-clock deadline 保護,但前端先給友善提示 + if ( mode === 'realistic' && batch > 10 ) { + if ( ! confirm( `Realistic 模式每個 user 約需 3 秒,batch_size=${ batch } 會超過後端 25s deadline 上限。建議使用 batch=10。要繼續嗎?` ) ) { + return; + } + } + const big = target >= 10000; + const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆測試使用者${ big ? '(規模較大,可能耗時數分鐘)' : '' }。\n\n密碼一律為 PassWord2026!,user_login 為 test{n}。\n\n確定要開始嗎?`; + if ( ! confirm( msg ) ) { + return; + } + + const r = await apiCall( '/stress-test/start', 'POST', { target, mode, batch_size: batch } ); + if ( ! r.ok ) { + alert( '啟動失敗:' + ( r.data?.error || r.status ) ); + return; + } + // 啟動後立即顯示進度卡片,不必等第一次 poll 才出現 + const card = $( '#wpdo-st-progress-card' ); + if ( card ) { + card.style.display = ''; + } + setText( '#wpdo-st-pg-status', 'running' ); + setText( '#wpdo-st-pg-mode', mode ); + setText( '#wpdo-st-pg-target', target.toLocaleString() ); + startPolling(); + } + + async function handleCancel() { + if ( ! confirm( '確定要取消當前測試?已建立的使用者不會被刪除。' ) ) { + return; + } + // 立刻 UI 反饋:避免使用者再點一次 + const cancelBtn = $( '#wpdo-st-cancel' ); + if ( cancelBtn ) { + cancelBtn.disabled = true; + cancelBtn.textContent = '⏹ 取消中…'; + } + setText( '#wpdo-st-pg-status', 'cancelling' ); + + const r = await apiCall( '/stress-test/cancel', 'POST' ); + if ( ! r.ok ) { + alert( '取消失敗:' + ( r.data?.error || r.status ) ); + if ( cancelBtn ) { + cancelBtn.disabled = false; + cancelBtn.textContent = '⏹ 取消'; + } + return; + } + // realistic 模式 in-flight batch 可能還要 ~3 秒才會真正中止;polling 會看到 cancelled + poll(); + if ( cancelBtn ) { + cancelBtn.textContent = '⏹ 取消'; + } + } + + async function handleCleanup() { + if ( ! confirm( '確定要清除所有 test_* 使用者?\n\n此動作會:\n- DELETE 所有 user_login LIKE "test%" 的使用者\n- DELETE 對應 wp_usermeta\n- DELETE 所有 wp_wpdo_user_* flat tables 中對應 user_id 的列\n\n不可復原!' ) ) { + return; + } + const r = await apiCall( '/stress-test/cleanup', 'DELETE' ); + if ( ! r.ok ) { + alert( '清除失敗:' + ( r.data?.error || r.status ) ); + return; + } + alert( `已清除 ${ r.data.deleted } 筆測試使用者。` ); + const card = $( '#wpdo-st-progress-card' ); + const benchCard = $( '#wpdo-st-bench-card' ); + if ( card ) card.style.display = 'none'; + if ( benchCard ) benchCard.style.display = 'none'; + poll(); + } + + async function handleRerunBench() { + const btn = $( '#wpdo-st-rerun-bench' ); + if ( btn ) { + btn.disabled = true; + btn.textContent = '⏳ 執行中...'; + } + const r = await apiCall( '/stress-test/benchmark', 'POST' ); + if ( btn ) { + btn.disabled = false; + btn.textContent = '📊 重跑 Benchmark(不新增資料)'; + } + if ( ! r.ok ) { + alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) ); + return; + } + renderBenchmark( r.data.benchmark ); + } + + // ── Init ──────────────────────────────────────────────────────────────── + + document.addEventListener( 'DOMContentLoaded', () => { + const startBtn = $( '#wpdo-st-start' ); + const cancelBtn = $( '#wpdo-st-cancel' ); + const cleanupBtn = $( '#wpdo-st-cleanup' ); + const benchBtn = $( '#wpdo-st-rerun-bench' ); + + if ( startBtn ) startBtn.addEventListener( 'click', handleStart ); + if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel ); + if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup ); + if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench ); + + // 初始 poll,如果正在執行就會自動開始 polling + poll().then( () => { + const card = $( '#wpdo-st-progress-card' ); + if ( card && card.style.display !== 'none' ) { + const status = ( $( '#wpdo-st-pg-status' )?.textContent || '' ).trim(); + if ( status === 'running' || status === 'benchmarking' ) { + startPolling(); + } + } + } ); + } ); +} )(); diff --git a/admin/assets/wpdo-term-stress-test.js b/admin/assets/wpdo-term-stress-test.js new file mode 100644 index 0000000..41c6322 --- /dev/null +++ b/admin/assets/wpdo-term-stress-test.js @@ -0,0 +1,368 @@ +/** + * WPDO Term Stress Test — admin tab JS (v2.13.0) + * + * Mirrors wpdo-post-stress-test.js (v2.11.4) but: + * - Talks to /wpdo/v1/term-stress-test/* endpoints + * - Sends taxonomy in start payload (not post_type) + * - DOM IDs prefixed wpdo-tst-* (term stress test) + * + * @since 2.13.0 + */ +( function () { + 'use strict'; + + const cfg = window.wpdoTermStressTest; + if ( ! cfg || ! cfg.restUrl ) { + return; + } + if ( ! document.querySelector( '.wpdo-term-stress-test-tab' ) ) { + return; + } + + const $ = ( sel ) => document.querySelector( sel ); + const restUrl = cfg.restUrl.replace( /\/$/, '' ); + const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }; + + let pollTimer = null; + + // ── API helpers ───────────────────────────────────────────────────────── + + async function apiCall( path, method = 'GET', body = null ) { + const opts = { method, headers, credentials: 'same-origin' }; + if ( body ) { + opts.body = JSON.stringify( body ); + } + const resp = await fetch( restUrl + path, opts ); + const text = await resp.text(); + try { + return { ok: resp.ok, status: resp.status, data: text ? JSON.parse( text ) : null }; + } catch ( e ) { + return { ok: false, status: resp.status, data: { error: text } }; + } + } + + // ── Rendering ─────────────────────────────────────────────────────────── + + function fmt( n ) { + if ( n === null || n === undefined ) { + return '—'; + } + return Number( n ).toLocaleString(); + } + + function setText( sel, text ) { + const el = $( sel ); + if ( el ) { + el.textContent = String( text ); + } + } + + function esc( s ) { + if ( s === null || s === undefined ) { + return ''; + } + return String( s ).replace( /[&<>"']/g, ( c ) => ( { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ c ] ) ); + } + + function renderProgress( state ) { + const isRunning = state.status === 'running' || state.status === 'benchmarking'; + const card = $( '#wpdo-tst-progress-card' ); + if ( card ) { + card.style.display = ( isRunning || state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled' ) ? '' : 'none'; + } + setText( '#wpdo-tst-pg-status', state.status || 'idle' ); + setText( '#wpdo-tst-pg-taxonomy', state.taxonomy || '' ); + setText( '#wpdo-tst-pg-mode', state.mode || '' ); + setText( '#wpdo-tst-pg-pct', ( state.pct || 0 ) + '%' ); + setText( '#wpdo-tst-pg-processed', fmt( state.processed || 0 ) ); + setText( '#wpdo-tst-pg-target', fmt( state.target || 0 ) ); + setText( '#wpdo-tst-pg-rate', state.rate_per_sec || 0 ); + setText( '#wpdo-tst-pg-elapsed', state.elapsed_sec || 0 ); + setText( '#wpdo-tst-pg-eta', state.eta_sec || 0 ); + setText( '#wpdo-tst-pg-batches', state.batches_done || 0 ); + setText( '#wpdo-tst-pg-mem', ( ( state.peak_memory || 0 ) / 1048576 ).toFixed( 1 ) ); + + const bar = $( '#wpdo-tst-pg-bar' ); + if ( bar ) { + bar.style.width = ( state.pct || 0 ) + '%'; + } + + const count = state.test_term_count || 0; + setText( '#wpdo-tst-count', fmt( count ) ); + setText( '#wpdo-tst-count-mirror', fmt( count ) ); + + const startBtn = $( '#wpdo-tst-start' ); + const cancelBtn = $( '#wpdo-tst-cancel' ); + const cleanupBtn = $( '#wpdo-tst-cleanup' ); + const benchBtn = $( '#wpdo-tst-rerun-bench' ); + if ( startBtn ) { + startBtn.disabled = isRunning; + } + if ( cancelBtn ) { + cancelBtn.disabled = ! isRunning; + } + if ( cleanupBtn ) { + cleanupBtn.disabled = isRunning || count === 0; + } + if ( benchBtn ) { + benchBtn.disabled = isRunning || count === 0; + } + } + + function renderBenchmark( bench ) { + if ( ! bench ) { + return; + } + const card = $( '#wpdo-tst-bench-card' ); + const content = $( '#wpdo-tst-bench-content' ); + if ( ! card || ! content ) { + return; + } + card.style.display = ''; + + const w = bench.write || {}; + const dbSizes = bench.db_sizes || []; + const q = bench.query || {}; + + const labels = { + point_default: 'Point lookup (hp_default = 1)', + range_sort_top: 'Range scan (hp_sort_order > 0 ORDER BY DESC)', + eav_baseline: 'EAV baseline (wp_termmeta 直查 hp_default)', + }; + + let html = ''; + // Write metrics + html += '

▍ 寫入指標

'; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += '
模式${ esc( w.mode ) }
Taxonomy${ esc( w.taxonomy ) }
完成 / 目標${ fmt( w.processed ) } / ${ fmt( w.target ) }
總耗時${ fmt( w.elapsed_sec ) } 秒
平均速率${ fmt( w.rate_per_sec ) } terms/sec
批次數${ fmt( w.batches_done ) }
批次最快/平均/最慢${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms
PHP Peak Memory${ fmt( w.peak_memory_mb ) } MB
'; + + // DB sizes + html += '

▍ DB 容量(term 相關表)

'; + html += ''; + html += ''; + html += ''; + dbSizes.forEach( ( r ) => { + html += ``; + html += ``; + html += ``; + } ); + html += '
TableRowsData MBIndex MBTotal MBAvg bytes/row
${ esc( r.table ) }${ fmt( r.rows ) }${ r.data_mb ?? '—' }${ r.index_mb ?? '—' }${ r.total_mb ?? '—' }${ fmt( r.avg_bytes ) }
'; + + // Query perf — 3 probes with EAV baseline last for visual speedup comparison + html += '

▍ 查詢效能

'; + html += ''; + html += ''; + html += ''; + + const baselineMs = q.eav_baseline?.duration_ms ?? null; + Object.keys( q ).forEach( ( key ) => { + const v = q[ key ]; + if ( ! v || typeof v.duration_ms !== 'number' ) { + return; + } + const label = labels[ key ] || key; + let speedup = ''; + if ( baselineMs !== null && key !== 'eav_baseline' && v.duration_ms > 0 ) { + const ratio = baselineMs / v.duration_ms; + if ( ratio >= 1 ) { + speedup = ` (${ ratio.toFixed( 2 ) }× faster)`; + } + } + html += ``; + } ); + html += '
測試項目耗時 (ms)
${ esc( label ) }${ speedup }${ v.duration_ms }
'; + html += '

EAV baseline 走 wp_termmeta,flat probes 走 wpdo_term_hp_taxonomy。倍率即此規模下反 EAV 的查詢加速。

'; + + content.innerHTML = html; + } + + // ── Polling ───────────────────────────────────────────────────────────── + + async function poll() { + const r = await apiCall( '/term-stress-test/status' ); + if ( ! r.ok || ! r.data ) { + return; + } + renderProgress( r.data ); + if ( r.data.benchmark ) { + renderBenchmark( r.data.benchmark ); + } + if ( r.data.status !== 'running' && r.data.status !== 'benchmarking' ) { + stopPolling(); + } + } + + function startPolling() { + stopPolling(); + poll(); + pollTimer = setInterval( poll, 2000 ); + } + + function stopPolling() { + if ( pollTimer ) { + clearInterval( pollTimer ); + pollTimer = null; + } + } + + // ── Event handlers ────────────────────────────────────────────────────── + + async function handleStart() { + const taxonomy = $( '#wpdo-tst-taxonomy' ).value; + const target = parseInt( $( '#wpdo-tst-target' ).value, 10 ); + const batch = parseInt( $( '#wpdo-tst-batch' ).value, 10 ); + const mode = document.querySelector( 'input[name="wpdo-tst-mode"]:checked' ).value; + + if ( ! taxonomy ) { + alert( '請選擇 taxonomy' ); + return; + } + if ( ! target || target < 1 ) { + alert( '請輸入有效的 term 數量' ); + return; + } + if ( mode === 'realistic' && batch > 50 ) { + if ( ! confirm( `Realistic 模式每個 term 約需 50-150 ms(wp_insert_term + 3 個 update_term_meta),batch_size=${ batch } 可能超過後端 8s deadline。建議 batch=10-30。要繼續嗎?` ) ) { + return; + } + } + + // Soft warn for combinations that won't demonstrate反 EAV 優化效果 + const termMode = String( cfg.termMode || 'disabled' ); + if ( mode === 'fast' && termMode === 'aeav_only' ) { + if ( ! confirm( `⚠️ Fast 模式直接 $wpdb->insert 繞過 Hook Bus,即使 term mode=aeav_only 也會寫滿 wp_termmeta。\n\n要驗證反 EAV 優化效果(wp_termmeta 應為 0),請改用 🐢 Realistic 模式。\n\n仍以 Fast 模式繼續嗎?` ) ) { + return; + } + } else if ( mode === 'realistic' && termMode !== 'aeav_only' ) { + if ( ! confirm( `⚠️ 目前 term mode = ${ termMode },Realistic 寫入仍會雙寫 wp_termmeta(不展示優化效果)。\n\n要看 wp_termmeta 完全短路請先升 mode 至 aeav_only(設定 tab)。\n\n仍以 ${ termMode } 模式繼續嗎?` ) ) { + return; + } + } + + const big = target >= 5000; + const msg = `即將以 ${ mode } 模式建立 ${ target.toLocaleString() } 筆 ${ taxonomy } 測試 term${ big ? '(規模較大)' : '' }。\n\n所有 term 的 slug 會以 wpdo-stress- 開頭,可一鍵清除。\n\n確定要開始嗎?`; + if ( ! confirm( msg ) ) { + return; + } + + const r = await apiCall( '/term-stress-test/start', 'POST', { + taxonomy, + target, + mode, + batch_size: batch, + } ); + if ( ! r.ok ) { + alert( '啟動失敗:' + ( r.data?.error || r.status ) ); + return; + } + const card = $( '#wpdo-tst-progress-card' ); + if ( card ) { + card.style.display = ''; + } + const benchCard = $( '#wpdo-tst-bench-card' ); + if ( benchCard ) { + benchCard.style.display = 'none'; + } + setText( '#wpdo-tst-pg-status', 'running' ); + setText( '#wpdo-tst-pg-taxonomy', taxonomy ); + setText( '#wpdo-tst-pg-mode', mode ); + setText( '#wpdo-tst-pg-target', target.toLocaleString() ); + startPolling(); + } + + async function handleCancel() { + if ( ! confirm( '確定要取消當前測試?已建立的 term 不會被刪除。' ) ) { + return; + } + const cancelBtn = $( '#wpdo-tst-cancel' ); + if ( cancelBtn ) { + cancelBtn.disabled = true; + cancelBtn.textContent = '⏹ 取消中…'; + } + setText( '#wpdo-tst-pg-status', 'cancelling' ); + + const r = await apiCall( '/term-stress-test/cancel', 'POST' ); + if ( ! r.ok ) { + alert( '取消失敗:' + ( r.data?.error || r.status ) ); + if ( cancelBtn ) { + cancelBtn.disabled = false; + cancelBtn.textContent = '⏹ 取消'; + } + return; + } + poll(); + if ( cancelBtn ) { + cancelBtn.textContent = '⏹ 取消'; + } + } + + async function handleCleanup() { + if ( ! confirm( '確定要清除所有 stress test terms?\n\n此動作會:\n- DELETE 所有 slug 前綴 wpdo-stress- 的 term\n- DELETE 對應 wp_termmeta + wp_term_taxonomy\n- DELETE flat 表中對應 term_id 的列\n\n不可復原!' ) ) { + return; + } + const r = await apiCall( '/term-stress-test/cleanup', 'DELETE' ); + if ( ! r.ok ) { + alert( '清除失敗:' + ( r.data?.error || r.status ) ); + return; + } + alert( `已清除 ${ r.data.deleted } 筆測試 term。` ); + const card = $( '#wpdo-tst-progress-card' ); + const benchCard = $( '#wpdo-tst-bench-card' ); + if ( card ) card.style.display = 'none'; + if ( benchCard ) benchCard.style.display = 'none'; + poll(); + } + + async function handleRerunBench() { + const btn = $( '#wpdo-tst-rerun-bench' ); + if ( btn ) { + btn.disabled = true; + btn.textContent = '⏳ 執行中...'; + } + const r = await apiCall( '/term-stress-test/benchmark', 'POST' ); + if ( btn ) { + btn.disabled = false; + btn.textContent = '📊 重跑 Benchmark(不新增資料)'; + } + if ( ! r.ok ) { + alert( 'Benchmark 失敗:' + ( r.data?.error || r.status ) ); + return; + } + renderBenchmark( r.data.benchmark ); + } + + // ── Init ──────────────────────────────────────────────────────────────── + + document.addEventListener( 'DOMContentLoaded', () => { + const startBtn = $( '#wpdo-tst-start' ); + const cancelBtn = $( '#wpdo-tst-cancel' ); + const cleanupBtn = $( '#wpdo-tst-cleanup' ); + const benchBtn = $( '#wpdo-tst-rerun-bench' ); + + if ( startBtn ) startBtn.addEventListener( 'click', handleStart ); + if ( cancelBtn ) cancelBtn.addEventListener( 'click', handleCancel ); + if ( cleanupBtn ) cleanupBtn.addEventListener( 'click', handleCleanup ); + if ( benchBtn ) benchBtn.addEventListener( 'click', handleRerunBench ); + + poll().then( () => { + const status = ( $( '#wpdo-tst-pg-status' )?.textContent || '' ).trim(); + if ( status === 'running' || status === 'benchmarking' ) { + startPolling(); + } + } ); + } ); +} )(); diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php new file mode 100644 index 0000000..baa0540 --- /dev/null +++ b/admin/class-tmdo-admin.php @@ -0,0 +1,3710 @@ + WP Data Optimizer page with tabs: + * Dashboard, Zones, Migration, Classifier, HPCT Import, Logs. + */ +class TMDO_Admin { + + /** Menu slug. */ + private const MENU_SLUG = '2meet-data-optimizer'; + + /** Nonce action. */ + private const NONCE_ACTION = 'wpdo_admin_action'; + + /** + * Boot admin hooks. + */ + public static function init(): void { + add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) ); + add_action( 'network_admin_menu', array( __CLASS__, 'register_menu' ) ); + add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); + add_action( 'network_admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); + add_action( 'wp_ajax_wpdo_admin_action', array( __CLASS__, 'handle_ajax' ) ); + add_action( 'admin_notices', array( __CLASS__, 'maybe_nginx_backup_notice' ) ); + } + + /** + * Show a one-time admin notice when the site runs on nginx and snapshot files exist. + * Nginx ignores .htaccess so wpdo-backups/ is publicly accessible without a deny rule. + * + * @return void + */ + public static function maybe_nginx_backup_notice(): void { + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + // Only fire on nginx — Apache's .htaccess already protects the directory. + $server_software = isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : ''; + if ( stripos( $server_software, 'nginx' ) === false ) { + return; + } + // Only warn when the backup directory actually contains snapshot files. + if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) { + return; + } + $dir = TMDO_Snapshot_Manager::backup_dir(); + $files = glob( trailingslashit( $dir ) . '*.gz' ); + if ( empty( $files ) ) { + return; + } + $notice_id = 'wpdo_nginx_backup_dismissed'; + if ( get_user_meta( get_current_user_id(), $notice_id, true ) ) { + return; + } + $snippet = 'location ~* /wpdo-backups/ { deny all; }'; + printf( + '

WP Data Optimizer: %s
%s

', + esc_attr( $notice_id ), + esc_html__( 'Your server runs nginx. Snapshot backup files may be publicly accessible. Add this rule to your nginx site config:', '2meet-data-optimizer' ), + esc_html( $snippet ) + ); + } + + /** + * Register the admin menu page under Tools. + */ + public static function register_menu(): void { + add_management_page( + __( 'WP Data Optimizer', '2meet-data-optimizer' ), + __( 'WP Data Optimizer', '2meet-data-optimizer' ), + 'manage_options', + self::MENU_SLUG, + array( __CLASS__, 'render_page' ) + ); + } + + /** + * Enqueue admin CSS/JS on our page only. + * + * @param string $hook_suffix The current admin page hook suffix. + * @return void + */ + public static function enqueue_assets( string $hook_suffix ): void { + $wpdo_pages = array( 'tools_page_wp-data-optimizer', 'tools_page_wpdo-setup-wizard' ); + if ( ! in_array( $hook_suffix, $wpdo_pages, true ) ) { + return; + } + + // Register Morandi design system so wpdo-admin can declare it as a CSS dependency + // instead of doing a blocking @import. Skip registration if the file is missing — + // the plugin still functions, just without the design tokens. + $morandi_path = get_stylesheet_directory() . '/morandi-design-system.css'; + $morandi_url = get_stylesheet_directory_uri() . '/morandi-design-system.css'; + $deps = array(); + + if ( file_exists( $morandi_path ) && ! wp_style_is( 'morandi-design-system', 'registered' ) ) { + wp_register_style( + 'morandi-design-system', + $morandi_url, + array(), + (string) filemtime( $morandi_path ) + ); + } + + if ( wp_style_is( 'morandi-design-system', 'registered' ) ) { + $deps[] = 'morandi-design-system'; + } + + wp_enqueue_style( + 'wpdo-admin', + TMDO_URL . 'admin/assets/wpdo-admin.css', + $deps, + TMDO_VERSION + ); + + wp_enqueue_script( + 'wpdo-admin', + TMDO_URL . 'admin/assets/wpdo-admin.js', + array(), + TMDO_VERSION, + true + ); + + wp_localize_script( + 'wpdo-admin', + 'wpdoAdmin', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( self::NONCE_ACTION ), + 'exportNonce' => wp_create_nonce( 'wpdo_export' ), + 'i18n' => array( + 'flushing' => __( 'Flushing…', '2meet-data-optimizer' ), + 'flushed' => __( 'Flushed!', '2meet-data-optimizer' ), + 'flushLabel' => __( 'Flush Cache', '2meet-data-optimizer' ), + 'error' => __( '操作失敗,請重試。', '2meet-data-optimizer' ), + 'retry' => __( '重試', '2meet-data-optimizer' ), + 'dismiss' => __( '關閉', '2meet-data-optimizer' ), + /* translators: 1: module name (e.g. hot_hp_listing), 2: new state (e.g. cutover) */ + 'moduleState' => __( '模組 %1$s 狀態已更新為 %2$s', '2meet-data-optimizer' ), + ), + ) + ); + + // Enqueue REST SDK and inject config (for REST API tab preview). + wp_enqueue_script( + 'wpdo-rest-sdk', + TMDO_URL . 'admin/assets/wpdo-rest-sdk.js', + array(), + TMDO_VERSION, + true + ); + + wp_localize_script( + 'wpdo-rest-sdk', + 'wpdo_sdk_config', + array( + 'rest_url' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'post_type' => 'hp_listing', + ) + ); + + // v2.6.6: Entity Bridge polling + actions JS. + wp_enqueue_script( + 'wpdo-entity-bridge', + TMDO_URL . 'admin/assets/wpdo-entity-bridge.js', + array(), + TMDO_VERSION, + true + ); + + wp_localize_script( + 'wpdo-entity-bridge', + 'wpdoEntityBridge', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + + // v2.6.7: User stress-test JS. + wp_enqueue_script( + 'wpdo-stress-test', + TMDO_URL . 'admin/assets/wpdo-stress-test.js', + array(), + TMDO_VERSION, + true + ); + wp_localize_script( + 'wpdo-stress-test', + 'wpdoStressTest', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + + // v2.11.4: Post stress-test JS (separate script, coexists with user side). + wp_enqueue_script( + 'wpdo-post-stress-test', + TMDO_URL . 'admin/assets/wpdo-post-stress-test.js', + array(), + TMDO_VERSION, + true + ); + wp_localize_script( + 'wpdo-post-stress-test', + 'wpdoPostStressTest', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'postMode' => class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'post' ) : 'disabled', + ) + ); + + // v2.13.0: Term stress-test JS. + wp_enqueue_script( + 'wpdo-term-stress-test', + TMDO_URL . 'admin/assets/wpdo-term-stress-test.js', + array(), + TMDO_VERSION, + true + ); + wp_localize_script( + 'wpdo-term-stress-test', + 'wpdoTermStressTest', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'termMode' => class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled', + ) + ); + + // v2.13.1: Comment stress-test JS. + wp_enqueue_script( + 'wpdo-comment-stress-test', + TMDO_URL . 'admin/assets/wpdo-comment-stress-test.js', + array(), + TMDO_VERSION, + true + ); + wp_localize_script( + 'wpdo-comment-stress-test', + 'wpdoCommentStressTest', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'commentMode' => class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled', + ) + ); + + // v2.8.0: One-click User Migration Wizard. + wp_enqueue_style( + 'wpdo-migration-wizard', + TMDO_URL . 'admin/assets/wpdo-migration-wizard.css', + array( 'wpdo-admin' ), + TMDO_VERSION + ); + wp_enqueue_script( + 'wpdo-migration-wizard', + TMDO_URL . 'admin/assets/wpdo-migration-wizard.js', + array(), + TMDO_VERSION, + true + ); + wp_localize_script( + 'wpdo-migration-wizard', + 'wpdoMigrationWizard', + array( + 'restUrl' => esc_url_raw( rest_url( 'wpdo/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'i18n' => array( + 'confirmStart' => __( '確定執行?流程啟動後會立即開始備份 + 遷移。', '2meet-data-optimizer' ), + 'confirmCancel' => __( '確定要取消?已執行的階段會自動 rollback 到 dual_write。', '2meet-data-optimizer' ), + 'nothingToDo' => __( '✅ 所有 entity group 已完成遷移,無事可做。', '2meet-data-optimizer' ), + 'failedRetry' => __( '失敗 — 點選「Resume」重試,或「Cancel」結束 job。', '2meet-data-optimizer' ), + ), + ) + ); + } + + /** + * Render the admin page. + */ + public static function render_page(): void { + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + + // Handle rate limit stats reset. + if ( isset( $_GET['wpdo_reset_rl_stats'] ) && check_admin_referer( 'wpdo_reset_rl_stats' ) ) { + delete_option( 'wpdo_rl_stats' ); + wp_safe_redirect( remove_query_arg( array( 'wpdo_reset_rl_stats', '_wpnonce' ) ) ); + exit; + } + + // v2.5.0 M16: one-click enable a module from suggestions tab. + if ( isset( $_GET['wpdo_enable_module'] ) && check_admin_referer( 'wpdo_enable_module' ) && TMDO_Capability::current_user_can_admin() ) { + $module = sanitize_key( wp_unslash( (string) $_GET['wpdo_enable_module'] ) ); + $flag = 'enable_failed'; + if ( '' !== $module && class_exists( 'TMDO_Feature_Flags' ) ) { + $result = TMDO_Feature_Flags::set( $module, 'dual_write' ); + if ( true === $result ) { + $flag = 'module_enabled'; + // Bust detector transient + cache so the suggestion disappears immediately. + if ( class_exists( 'TMDO_Module_Detector' ) ) { + delete_transient( 'wpdo_module_detector_results' ); + } + } elseif ( $result instanceof \WP_Error ) { + $flag = 'enable_blocked'; + } + } + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'module-suggestions', + 'wpdo_msg' => $flag, + 'wpdo_module' => $module, + ), + remove_query_arg( array( 'wpdo_enable_module', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.4.0 M10 + v2.5.0 M13: settings save (email alerts + automator). + if ( isset( $_POST['wpdo_save_settings'] ) && check_admin_referer( 'wpdo_save_settings' ) && TMDO_Capability::current_user_can_admin() ) { + update_option( + 'wpdo_email_alerts_enabled', + isset( $_POST['wpdo_email_alerts_enabled'] ) ? '1' : '0', + false + ); + $recipient = isset( $_POST['wpdo_alert_email'] ) ? sanitize_email( wp_unslash( (string) $_POST['wpdo_alert_email'] ) ) : ''; + update_option( 'wpdo_alert_email', $recipient, false ); + $throttle = isset( $_POST['wpdo_alert_throttle_hours'] ) ? max( 1, min( 168, (int) $_POST['wpdo_alert_throttle_hours'] ) ) : 24; + update_option( 'wpdo_alert_throttle_hours', $throttle, false ); + + // v2.5.0 M13: FSM Automator settings. + update_option( + 'wpdo_automator_enabled', + isset( $_POST['wpdo_automator_enabled'] ) ? '1' : '0', + false + ); + $blacklist = isset( $_POST['wpdo_automator_blacklist'] ) && is_array( $_POST['wpdo_automator_blacklist'] ) + ? array_values( array_filter( array_map( 'sanitize_key', wp_unslash( $_POST['wpdo_automator_blacklist'] ) ) ) ) + : array(); + update_option( 'wpdo_automator_blacklist', $blacklist, false ); + + // 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(); + } + if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { + TMDO_Hook_Bus_Bridge::reset_cache(); + } + + // v2.11.6: HivePress transient filter toggle. + update_option( + 'wpdo_hp_transient_filter_enabled', + isset( $_POST['wpdo_hp_transient_filter_enabled'] ) ? '1' : '0', + false + ); + + // v2.12.1: Term + Comment garbage filter toggle. + update_option( + 'wpdo_term_comment_garbage_filter_enabled', + isset( $_POST['wpdo_term_comment_garbage_filter_enabled'] ) ? '1' : '0', + false + ); + + // v2.12.3: WC term count filter toggle. + update_option( + 'wpdo_wc_term_count_filter_enabled', + isset( $_POST['wpdo_wc_term_count_filter_enabled'] ) ? '1' : '0', + false + ); + + // v2.12.4: Term + Comment misc bucket toggle. + update_option( + 'wpdo_term_comment_misc_bucket_enabled', + isset( $_POST['wpdo_term_comment_misc_bucket_enabled'] ) ? '1' : '0', + false + ); + + // v2.5.0 M15: multi-channel notifiers. + foreach ( array( 'slack', 'discord', 'telegram' ) as $ch ) { + update_option( "wpdo_{$ch}_enabled", isset( $_POST[ "wpdo_{$ch}_enabled" ] ) ? '1' : '0', false ); + $thr = isset( $_POST[ "wpdo_{$ch}_throttle_hours" ] ) ? max( 1, min( 168, (int) $_POST[ "wpdo_{$ch}_throttle_hours" ] ) ) : 24; + update_option( "wpdo_{$ch}_throttle_hours", $thr, false ); + $sev = isset( $_POST[ "wpdo_{$ch}_severity" ] ) ? sanitize_key( wp_unslash( (string) $_POST[ "wpdo_{$ch}_severity" ] ) ) : 'critical_only'; + if ( ! in_array( $sev, array( 'critical_only', 'critical_and_recommended' ), true ) ) { + $sev = 'critical_only'; + } + update_option( "wpdo_{$ch}_severity", $sev, false ); + } + // Webhook secrets — encrypt at rest via TMDO_Crypto. + if ( isset( $_POST['wpdo_slack_webhook'] ) ) { + $webhook = esc_url_raw( wp_unslash( (string) $_POST['wpdo_slack_webhook'] ) ); + if ( '' !== $webhook ) { + class_exists( 'TMDO_Crypto' ) + ? TMDO_Crypto::set_option( 'wpdo_slack_webhook', $webhook ) + : update_option( 'wpdo_slack_webhook', $webhook, false ); + } + } + if ( isset( $_POST['wpdo_discord_webhook'] ) ) { + $webhook = esc_url_raw( wp_unslash( (string) $_POST['wpdo_discord_webhook'] ) ); + if ( '' !== $webhook ) { + class_exists( 'TMDO_Crypto' ) + ? TMDO_Crypto::set_option( 'wpdo_discord_webhook', $webhook ) + : update_option( 'wpdo_discord_webhook', $webhook, false ); + } + } + if ( isset( $_POST['wpdo_telegram_bot_token'] ) ) { + $token = sanitize_text_field( wp_unslash( (string) $_POST['wpdo_telegram_bot_token'] ) ); + if ( '' !== $token ) { + class_exists( 'TMDO_Crypto' ) + ? TMDO_Crypto::set_option( 'wpdo_telegram_bot_token', $token ) + : update_option( 'wpdo_telegram_bot_token', $token, false ); + } + } + if ( isset( $_POST['wpdo_telegram_chat_id'] ) ) { + $chat = sanitize_text_field( wp_unslash( (string) $_POST['wpdo_telegram_chat_id'] ) ); + update_option( 'wpdo_telegram_chat_id', $chat, false ); + } + + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'settings', + 'wpdo_msg' => 'settings_saved', + ), + remove_query_arg( array( '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.3.0 M6: run health check on demand from Doctor tab. + if ( isset( $_GET['wpdo_run_health'] ) && check_admin_referer( 'wpdo_run_health' ) && class_exists( 'TMDO_Health_Cron' ) ) { + $res = TMDO_Health_Cron::run(); + $flag = ( $res['critical_count'] ?? 0 ) > 0 ? 'health_critical' : 'health_ok'; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'doctor', + 'wpdo_msg' => $flag, + ), + remove_query_arg( array( 'wpdo_run_health', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.2.0 M4: snapshot create / delete admin actions. + if ( isset( $_GET['wpdo_create_snapshot'] ) && check_admin_referer( 'wpdo_create_snapshot' ) && class_exists( 'TMDO_Snapshot_Manager' ) ) { + $result = TMDO_Snapshot_Manager::create( + 'manual', + array(), + array( + 'notes' => __( 'Created from admin UI', '2meet-data-optimizer' ), + ) + ); + $flag = ! empty( $result['ok'] ) ? 'created' : 'create_failed'; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'snapshots', + 'wpdo_msg' => $flag, + ), + remove_query_arg( array( 'wpdo_create_snapshot', '_wpnonce' ) ) + ) + ); + exit; + } + if ( isset( $_GET['wpdo_delete_snapshot'] ) && check_admin_referer( 'wpdo_delete_snapshot' ) && class_exists( 'TMDO_Snapshot_Manager' ) ) { + $id = sanitize_text_field( wp_unslash( (string) $_GET['wpdo_delete_snapshot'] ) ); + $ok = '' !== $id && TMDO_Snapshot_Manager::delete( $id ); + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'snapshots', + 'wpdo_msg' => $ok ? 'deleted' : 'delete_failed', + ), + remove_query_arg( array( 'wpdo_delete_snapshot', '_wpnonce' ) ) + ) + ); + exit; + } + if ( isset( $_GET['wpdo_prune_snapshots'] ) && check_admin_referer( 'wpdo_prune_snapshots' ) && class_exists( 'TMDO_Snapshot_Manager' ) ) { + $res = TMDO_Snapshot_Manager::prune(); + $msg = sprintf( 'pruned_%d', (int) ( $res['pruned'] ?? 0 ) ); + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'snapshots', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_prune_snapshots', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.10.0: Post Migration Wizard — backfill all 7 groups. + if ( isset( $_GET['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 ) { + try { + $result = TMDO_Post_Migration::backfill_group( $group ); + $total_migrated += (int) $result['migrated']; + } catch ( \Throwable $e ) { + $errors[] = $group . ':' . $e->getMessage(); + } + } + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_backfill_all', + array( + 'total_migrated' => $total_migrated, + 'errors' => $errors, + ) + ); + } + $msg = $errors ? 'err_backfill_failed' : 'backfill_' . $total_migrated; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-migration-wizard', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_backfill_all', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.10.0: Post Migration Wizard — copy legacy hot table. + if ( isset( $_GET['wpdo_post_cutover_legacy'] ) && check_admin_referer( 'wpdo_post_cutover_legacy' ) && class_exists( 'TMDO_Post_Migration' ) ) { + global $wpdb; + $hot = $wpdb->prefix . 'wpdo_hot_hp_listing'; + $flat = $wpdb->prefix . 'wpdo_post_hp_listing_core'; + $copied = 0; + $err = ''; + try { + $result = TMDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', $hot, $flat ); + $verify = TMDO_Post_Migration::verify_legacy_cutover( $hot, $flat ); + $copied = (int) $result['copied']; + if ( ! $verify['ok'] ) { + $err = 'verify_mismatched_' . $verify['mismatched_rows']; + } + } catch ( \Throwable $e ) { + $err = $e->getMessage(); + } + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_cutover_legacy_admin', + array( + 'copied' => $copied, + 'error' => $err, + ) + ); + } + $msg = $err ? 'err_cutover_failed' : 'cutover_' . $copied; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-migration-wizard', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_cutover_legacy', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.10.0: Post Migration Wizard — promote mode (dual_write or aeav_only). + if ( ( isset( $_GET['wpdo_post_promote_dual_write'] ) || isset( $_GET['wpdo_post_promote_aeav'] ) ) && class_exists( 'TMDO_Post_Migration' ) ) { + $target = isset( $_GET['wpdo_post_promote_dual_write'] ) ? 'dual_write' : 'aeav_only'; + $nonce = isset( $_GET['wpdo_post_promote_dual_write'] ) ? 'wpdo_post_promote_dual_write' : 'wpdo_post_promote_aeav'; + if ( check_admin_referer( $nonce ) ) { + $result = TMDO_Post_Migration::set_mode( $target ); + $err = is_wp_error( $result ) ? $result->get_error_message() : ''; + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_promote_mode', + array( + 'target' => $target, + 'error' => $err, + ) + ); + } + $msg = $err ? 'err_promote_' . str_replace( ' ', '_', sanitize_key( $err ) ) : 'promote_' . $target; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-migration-wizard', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_promote_dual_write', 'wpdo_post_promote_aeav', '_wpnonce' ) ) + ) + ); + exit; + } + } + + // v2.11.0: Post Stress Test — bulk create test posts. + // v2.11.2: optional `mode` query arg (fast|realistic). + if ( isset( $_GET['wpdo_post_stress_create'] ) && check_admin_referer( 'wpdo_post_stress_create' ) && class_exists( 'TMDO_Post_Stress_Tester' ) ) { + $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( (string) $_GET['post_type'] ) ) : 'product'; + $count = isset( $_GET['count'] ) ? max( 1, min( 10000, absint( wp_unslash( $_GET['count'] ) ) ) ) : 100; + $mode = isset( $_GET['mode'] ) && 'realistic' === sanitize_key( wp_unslash( (string) $_GET['mode'] ) ) ? 'realistic' : 'fast'; + try { + $result = 'realistic' === $mode + ? TMDO_Post_Stress_Tester::create_realistic( $post_type, $count ) + : TMDO_Post_Stress_Tester::create( $post_type, $count ); + $msg = 'stress_' . $mode . '_' . (int) $result['created']; + } catch ( \Throwable $e ) { + $msg = 'err_stress_create'; + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'post_stress_create_admin', 'create', $e->getMessage() ); + } + } + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-stress-test', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_stress_create', 'post_type', 'count', 'mode', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.11.0: Post Stress Test — cleanup all test posts. + if ( isset( $_GET['wpdo_post_stress_cleanup'] ) && check_admin_referer( 'wpdo_post_stress_cleanup' ) && class_exists( 'TMDO_Post_Stress_Tester' ) ) { + try { + $result = TMDO_Post_Stress_Tester::cleanup(); + $msg = 'stress_cleanup_' . (int) $result['deleted_posts']; + } catch ( \Throwable $e ) { + $msg = 'err_stress_cleanup'; + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'post_stress_cleanup_admin', 'cleanup', $e->getMessage() ); + } + } + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-stress-test', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_stress_cleanup', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.11.0: Post Stress Test — run benchmark on all 7 groups. + if ( isset( $_GET['wpdo_post_stress_bench'] ) && check_admin_referer( 'wpdo_post_stress_bench' ) && class_exists( 'TMDO_Post_Migration' ) ) { + $samples = isset( $_GET['samples'] ) ? max( 10, min( 1000, absint( wp_unslash( $_GET['samples'] ) ) ) ) : 100; + set_transient( 'wpdo_post_stress_bench_samples', $samples, 60 ); + $msg = 'stress_bench_ready_' . $samples; + wp_safe_redirect( + add_query_arg( + array( + 'tab' => 'post-stress-test', + 'wpdo_msg' => $msg, + ), + remove_query_arg( array( 'wpdo_post_stress_bench', 'samples', '_wpnonce' ) ) + ) + ); + exit; + } + + // v2.9.0 Phase 0: wp_postmeta garbage cleanup (transients/_wp_old_date/stale _edit_lock). + if ( isset( $_GET['wpdo_postmeta_cleanup'] ) && check_admin_referer( 'wpdo_postmeta_cleanup' ) && class_exists( 'TMDO_Postmeta_Cleaner' ) ) { + $deleted = TMDO_Postmeta_Cleaner::delete_garbage( TMDO_Postmeta_Cleaner::TARGET_ALL ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'postmeta_cleanup_admin', + array( + 'transients' => $deleted['transients'], + 'wp_old_date' => $deleted['wp_old_date'], + 'edit_locks' => $deleted['edit_locks'], + 'total' => $deleted['total'], + ) + ); + } + wp_safe_redirect( + add_query_arg( + array( + 'wpdo_msg' => 'postmeta_cleanup_done_' . (int) $deleted['total'], + ), + remove_query_arg( array( 'wpdo_postmeta_cleanup', '_wpnonce' ) ) + ) + ); + exit; + } + + $tabs = array( + 'dashboard' => __( '儀表板', '2meet-data-optimizer' ), + 'entity-bridge' => __( 'Entity Bridge', '2meet-data-optimizer' ), + 'migration-wizard' => __( 'User 遷移精靈', '2meet-data-optimizer' ), + 'post-migration-wizard' => __( 'Post 遷移精靈', '2meet-data-optimizer' ), + 'stress-test' => __( 'User 壓力測試', '2meet-data-optimizer' ), + 'post-stress-test' => __( 'Post 壓力測試', '2meet-data-optimizer' ), + 'term-stress-test' => __( 'Term 壓力測試', '2meet-data-optimizer' ), + 'comment-stress-test' => __( 'Comment 壓力測試', '2meet-data-optimizer' ), + 'zones' => __( 'Zone 配置', '2meet-data-optimizer' ), + 'classifier' => __( '自動分類', '2meet-data-optimizer' ), + 'snapshots' => __( '備份快照', '2meet-data-optimizer' ), + 'conflicts' => __( '衝突檢測', '2meet-data-optimizer' ), + 'doctor' => __( '健康檢查', '2meet-data-optimizer' ), + 'module-suggestions' => __( '模組建議', '2meet-data-optimizer' ), + 'settings' => __( '設定', '2meet-data-optimizer' ), + 'logs' => __( '日誌', '2meet-data-optimizer' ), + 'rest-api' => __( 'REST API', '2meet-data-optimizer' ), + 'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ), + ); + + $tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); + // v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry. + if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) { + $tab = 'entity-bridge'; + } + if ( ! array_key_exists( $tab, $tabs ) ) { + $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' ) ) { + $attn = TMDO_Migration_Orchestrator::needs_attention(); + if ( ! empty( $attn['needs'] ) || 'failed' === ( $attn['job_state'] ?? '' ) ) { + $needs_dot['migration-wizard'] = (int) ( $attn['eav_rows'] ?? 0 ); + } + } + ?> +
+

+ + array( + 'label' => esc_html__( '📊 概覽', '2meet-data-optimizer' ), + 'tabs' => array( 'dashboard', 'doctor', 'module-suggestions', 'conflicts', 'logs' ), + ), + 'wizards' => array( + 'label' => esc_html__( '🧙 遷移精靈', '2meet-data-optimizer' ), + 'tabs' => array( 'migration-wizard', 'post-migration-wizard', 'hpct-import' ), + ), + 'stress' => array( + 'label' => esc_html__( '⚡ 壓力測試', '2meet-data-optimizer' ), + 'tabs' => array( 'stress-test', 'post-stress-test', 'term-stress-test', 'comment-stress-test' ), + ), + 'config' => array( + 'label' => esc_html__( '⚙️ 配置', '2meet-data-optimizer' ), + 'tabs' => array( 'entity-bridge', 'zones', 'classifier', 'settings', 'rest-api', 'snapshots' ), + ), + ); + // Fallback bucket for any tab not explicitly listed (resilient to future tabs). + $grouped_slugs = array(); + foreach ( $tab_groups as $g ) { + $grouped_slugs = array_merge( $grouped_slugs, $g['tabs'] ); + } + $ungrouped = array_diff( array_keys( $tabs ), $grouped_slugs ); + if ( ! empty( $ungrouped ) ) { + $tab_groups['other'] = array( + 'label' => esc_html__( '其他', '2meet-data-optimizer' ), + 'tabs' => array_values( $ungrouped ), + ); + } + ?> + + +
+ +
+
+ get_stats(); + $flags = TMDO_Feature_Flags::all(); + $compat = TMDO_Compatibility::check(); + $cache = TMDO_Cache_Layer::get_stats(); + + // ── Rate limit stats ────────────────────────────────────────────── + $rl_stats = get_option( 'wpdo_rl_stats', array() ); + $rl_total = array_sum( $rl_stats ); + arsort( $rl_stats ); + $rl_top = array_slice( $rl_stats, 0, 10, true ); + + // ── Zone B / Zone D stats (60s transient — dashboard is read-heavy) ── + $cache_key = 'wpdo_dashboard_stats_v1'; + $cached = get_transient( $cache_key ); + if ( false === $cached ) { + $warm_table = TMDO_Zone_Warm::table(); + $now_sql = TMDO_DB::now(); + + $warm_active = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal helper, no user input + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$warm_table}` WHERE expires_at IS NULL OR expires_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $now_sql + ) + ); + $warm_soon = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$warm_table}` WHERE expires_at IS NOT NULL AND expires_at > %s AND expires_at < DATE_ADD(%s, INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $now_sql, + $now_sql + ) + ); + $top_views = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->prepare( + "SELECT post_id, CAST(meta_value AS UNSIGNED) as views FROM `{$warm_table}` WHERE meta_key = %s AND (expires_at IS NULL OR expires_at > %s) ORDER BY views DESC LIMIT 10", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + TMDO_Listing_Stats::VIEW_KEY, + $now_sql + ), + ARRAY_A + ); + + $cached = array( + 'warm_active' => $warm_active, + 'warm_soon' => $warm_soon, + 'top_views' => $top_views, + 'archive_stats' => TMDO_Zone_Archive::stats(), + ); + set_transient( $cache_key, $cached, MINUTE_IN_SECONDS ); + } + + $warm_active = (int) $cached['warm_active']; + $warm_soon = (int) $cached['warm_soon']; + $top_views = $cached['top_views']; + $archive_stats = $cached['archive_stats']; + + // v2.16.0: KPI hero metrics — read-only, transient-cached 5min. + $kpi = self::compute_dashboard_kpi(); + ?> + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ 0 ) { + echo esc_html( number_format_i18n( $kpi['speedup_x'], 1 ) ) . '×'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- unit span hardcoded. + } else { + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- hardcoded. + } + ?> +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ /100 +
+
+ +
+
+
+ +
+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Active' : 'N/A' ); ?>
+ Imported'; + } elseif ( $compat['hpct_active'] ) { + echo 'Needs Import'; + } else { + echo 'N/A'; + } + ?> +
+ External' : 'Built-in'; ?> + + Flush Support + +
+
+ +
+

+ + + + + + + + + + + +
Zone
Hot (A)
Warm (B)
Cold (C)
Archive (D)
+
+
+ +
+

+
+
+

HPCT Modules

+ + + + $state ) : ?> + + + + + + +
+
+
+

Zone Modules

+ + + + $state ) : ?> + + + + + + +
+
+
+
+ +
+

+
+ +
+

Warm (B)

+ + + + + + + + + + + +
+ + +

+ + + + + + + + + + + + + + + +
Post ID
+ +

+ +
+ +
+

Archive (D)

+ + + + + + + + + + + +
+ 0 + ? round( $archive_stats['compressed_rows'] / $archive_stats['total_rows'] * 100 ) + : 0; + echo esc_html( number_format_i18n( $archive_stats['compressed_rows'] ) ); + echo ' ' . (int) $pct . '%'; + ?> +
+ + +

+ + + + + + + + + + + + + + + +
Post Type
+ +

+ +
+ +
+
+ +
+

+

+ + 0 ) : ?> + + +

+ + + + + + + + + + + +
+ + +

+ + + + + + + + + $count ) : ?> + + + + + + +
Post ID
+ +

+ +
+ +
+

+ 'background:#e0e0e0;color:#444', + 'dual_write' => 'background:#d4edda;color:#155724', + 'shadow_read' => 'background:#fff3cd;color:#856404', + 'aeav_only' => 'background:#cce5ff;color:#004085', + ); + ?> + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+

+ + + +  |  + + + +

+
+ get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" ); + $postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" ); + $ratio_num = $posts_count > 0 ? $postmeta_count / $posts_count : 0; + + // 2) Latest benchmark speedup. + $bench_table = $wpdb->prefix . 'wpdo_benchmarks'; + $row = $wpdb->get_row( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from internal helper. + "SELECT module, native_ms, custom_ms, created_at FROM `{$bench_table}` WHERE custom_ms > 0 ORDER BY id DESC LIMIT 1", + ARRAY_A + ); + $speedup_x = 0.0; + $speedup_label = ''; + if ( $row && (float) $row['custom_ms'] > 0 ) { + $speedup_x = (float) $row['native_ms'] / (float) $row['custom_ms']; + if ( $speedup_x >= 1.0 ) { + $speedup_label = sprintf( + /* translators: 1: module, 2: timestamp */ + __( '最近 %1$s @ %2$s', '2meet-data-optimizer' ), + (string) $row['module'], + mysql2date( get_option( 'date_format', 'Y-m-d' ), (string) $row['created_at'] ) + ); + } + } + + // 3) Optimized field coverage from Schema Registry. + $fields_hot = 0; + $fields_cold = 0; + if ( class_exists( 'TMDO_Schema_Registry' ) ) { + $registry = TMDO_Schema_Registry::instance(); + $hot_post_types = $registry->get_hot_post_types(); + $cold_post_types = $registry->get_cold_post_types(); + foreach ( $hot_post_types as $pt ) { + $fields_hot += count( $registry->get_hot_columns( $pt ) ); + } + foreach ( $cold_post_types as $pt ) { + $fields_cold += count( $registry->get_cold_meta_keys( $pt ) ); + } + } + + // 4) Health score: starts at 100, deducts for recent errors / conflicts / mode mismatch. + $errors_24h = 0; + $err_table = $wpdb->prefix . 'wpdo_errors'; + $err_exists = (bool) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $err_table + ) + ); + if ( $err_exists ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from internal helper, severity literals are static. + $err_sql = "SELECT COUNT(*) FROM `{$err_table}` WHERE severity IN ('error','critical') AND created_at >= %s"; + $errors_24h = (int) $wpdb->get_var( + $wpdb->prepare( $err_sql, gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ); + } + + $conflicts = 0; + if ( class_exists( 'TMDO_Conflict_Monitor' ) ) { + $summary = TMDO_Conflict_Monitor::get_summary(); + $conflicts = (int) ( $summary['total'] ?? 0 ); + } + + $score = 100; + $score -= min( 30, $errors_24h * 3 ); + $score -= min( 30, $conflicts * 5 ); + $score = max( 0, min( 100, $score ) ); + + $health_class = $score >= 90 ? 'excellent' : ( $score >= 70 ? 'good' : ( $score >= 40 ? 'warn' : 'crit' ) ); + + $kpi = array( + 'ratio_str' => $ratio_num > 0 ? number_format_i18n( $ratio_num, 2 ) : '0', + 'posts' => $posts_count, + 'postmeta' => $postmeta_count, + 'speedup_x' => $speedup_x, + 'speedup_label' => $speedup_label, + 'fields_total' => $fields_hot + $fields_cold, + 'fields_hot' => $fields_hot, + 'fields_cold' => $fields_cold, + 'health_score' => $score, + 'health_class' => $health_class, + 'errors_24h' => $errors_24h, + 'conflicts' => $conflicts, + ); + + set_transient( 'wpdo_kpi_hero_v1', $kpi, 5 * MINUTE_IN_SECONDS ); + return $kpi; + } + + /** + * Entity Bridge tab: health cards + migration wizard for user/term/comment. + */ + private static function render_entity_bridge(): void { + $mode_order = array( 'disabled', 'dual_write', 'shadow_read', 'aeav_only' ); + $mode_labels = array( + 'disabled' => 'disabled', + 'dual_write' => 'dual_write', + 'shadow_read' => 'shadow_read', + 'aeav_only' => 'aeav_only', + ); + $mode_style = array( + 'disabled' => 'background:#e0e0e0;color:#444', + 'dual_write' => 'background:#d4edda;color:#155724', + 'shadow_read' => 'background:#fff3cd;color:#856404', + 'aeav_only' => 'background:#cce5ff;color:#004085', + ); + + $health_data = class_exists( 'TMDO_Entity_Health' ) ? TMDO_Entity_Health::get_all() : array(); + // v2.11.0: post entity health is computed on-demand (post is not in + // TMDO_Entity_Health::MANAGED_TYPES yet — kept out to preserve user-side + // JS polling assumption of 3 types). Add post to the render loop only. + if ( class_exists( 'TMDO_Entity_Health' ) ) { + $health_data['post'] = TMDO_Entity_Health::get_one( 'post' ); + } + $bridge_page = admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ); + + ?> +
+

+

+ +

+ +
+ +
+ + +
+
+

Entity

+ + + + + 天 + +
+ + + ⟳ + + +
+ + +
+ $m ) : + $active = ( $m === $mode ); + ?> + 0 ) : ?> + + + + + + +
+ + + +
+ + + + + + + | + + + + + +
+ + + + +
+ + + + = 99 ? '#28a745' : ( $cov_pct >= 50 ? '#ffc107' : '#dc3545' ); + ?> +
+
+ + + 欄位 +   + + + + +
+
+
+
+
+
+ + % + +
+
+ + + / EAV + +
+ +
+ +
+ +
+ +
+ +
+

+ +

+

+ +

+
+ + + + +
+ + + + + 0 ) : ?> + + + + +
+ + + + +
+ + + + ✓ + + — + + + + + + + +
+ + + + +
+ +
+ + + +
+ + +
+ +
+ +
+ + +
+

+
    +
  1. + disabled → dual_write: + +
  2. +
  3. + : + +
  4. +
  5. + dual_write → shadow_read: + +
  6. +
  7. + : + +
  8. +
  9. + shadow_read → aeav_only: + +
  10. +
+

+ + + + 。 +

+
+ +
+ 'idle' ); + $template = TMDO_PATH . 'admin/templates/migration-wizard.php'; + if ( file_exists( $template ) ) { + include $template; + } + } + + /** + * Post Migration Wizard tab — Post Entity Bridge orchestration UI (v2.10.0). + * + * Independent of user-side migration-wizard tab (frozen contract). Sync + * execution model — each action runs to completion in one request. Async + * polling / cron pump are out of scope for v2.10.0; admin can re-trigger + * manually if a phase needs to be re-run. + * + * @return void + */ + /** + * Post Stress Test tab — sync-execution sister of render_stress_test(). + * + * Simplified UI introduced in v2.11.0 — async progress polling deferred + * (user side uses ~700 lines of JS + REST). Sync model: each button + * POSTs back, runs to completion, redirects with msg flag. + * + * @return void + */ + private static function render_post_stress_test(): void { + $test_post_count = class_exists( 'TMDO_Post_Stress_Tester' ) + ? TMDO_Post_Stress_Tester::count_test_posts() + : 0; + $diagnose = class_exists( 'TMDO_Post_Migration' ) + ? TMDO_Post_Migration::diagnose() + : array( + 'posts' => 0, + 'postmeta' => 0, + 'ratio' => 0, + 'mode' => 'disabled', + 'groups' => array(), + ); + + $template = TMDO_PATH . 'admin/templates/post-stress-test.php'; + if ( file_exists( $template ) ) { + include $template; + } + } + + /** + * Term Stress Test tab — async progress polling sister of post-stress-test + * (v2.13.0). Reuses the same UI patterns; entity-specific is taxonomy + * dropdown + flat-table reports. + * + * @return void + */ + private static function render_term_stress_test(): void { + $state = class_exists( 'TMDO_Term_Stress_Tester' ) ? TMDO_Term_Stress_Tester::get_progress( false ) : array(); + $test_term_count = class_exists( 'TMDO_Term_Stress_Tester' ) ? TMDO_Term_Stress_Tester::count_test_terms() : 0; + + // Available taxonomies for the dropdown (filter to non-system, public + meaningful internals). + $all_taxonomies = get_taxonomies( array(), 'objects' ); + $taxonomies = array(); + foreach ( $all_taxonomies as $slug => $tax ) { + if ( in_array( $slug, array( 'nav_menu', 'link_category', 'post_format' ), true ) ) { + continue; + } + $taxonomies[ $slug ] = $tax->labels->singular_name ?? $slug; + } + + $template = TMDO_PATH . 'admin/templates/term-stress-test.php'; + if ( file_exists( $template ) ) { + include $template; + } + } + + /** + * Comment Stress Test tab — async progress polling sister of term-stress-test + * (v2.13.1). Entity-specific is post_id dropdown (target post for comments) + * + flat-table reports for wpdo_comment_hp_review / wpdo_comment_misc. + * + * @return void + */ + private static function render_comment_stress_test(): void { + $state = class_exists( 'TMDO_Comment_Stress_Tester' ) ? TMDO_Comment_Stress_Tester::get_progress( false ) : array(); + $test_comment_count = class_exists( 'TMDO_Comment_Stress_Tester' ) ? TMDO_Comment_Stress_Tester::count_test_comments() : 0; + + // Available posts for the dropdown (top 20 by comment_count, fallback to most recent). + global $wpdb; + $posts = array(); + if ( isset( $wpdb ) ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ID, post_title, comment_count FROM {$wpdb->posts} + WHERE post_status = %s AND post_type IN ('post','page','hp_listing') + ORDER BY comment_count DESC, ID DESC LIMIT 20", + 'publish' + ) + ); + if ( is_array( $rows ) ) { + foreach ( $rows as $r ) { + $posts[ (int) $r->ID ] = sprintf( + '#%d %s (%d)', + (int) $r->ID, + $r->post_title ?: '(無標題)', + (int) $r->comment_count + ); + } + } + } + + $template = TMDO_PATH . 'admin/templates/comment-stress-test.php'; + if ( file_exists( $template ) ) { + include $template; + } + } + + /** + * Post Migration Wizard tab — render handler (v2.9.3+). + * + * @return void + */ + private static function render_post_migration_wizard(): void { + $diagnose = class_exists( 'TMDO_Post_Migration' ) + ? TMDO_Post_Migration::diagnose() + : array( + 'posts' => 0, + 'postmeta' => 0, + 'ratio' => 0, + 'mode' => 'disabled', + 'groups' => array(), + ); + + $garbage = class_exists( 'TMDO_Postmeta_Cleaner' ) + ? TMDO_Postmeta_Cleaner::count_garbage( 'all' ) + : array( + 'total' => 0, + 'transients' => 0, + 'wp_old_date' => 0, + 'edit_locks' => 0, + ); + + $template = TMDO_PATH . 'admin/templates/post-migration-wizard.php'; + if ( file_exists( $template ) ) { + include $template; + } + } + + /** + * Stress-test tab — User Entity stress test & benchmark UI. + */ + private static function render_stress_test(): void { + $state = class_exists( 'TMDO_User_Stress_Tester' ) ? TMDO_User_Stress_Tester::get_progress() : array(); + $status = $state['status'] ?? 'idle'; + $test_user_count = isset( $state['test_user_count'] ) ? (int) $state['test_user_count'] : ( class_exists( 'TMDO_User_Stress_Tester' ) ? TMDO_User_Stress_Tester::count_test_users() : 0 ); + $is_running = ( 'running' === $status || 'benchmarking' === $status ); + ?> +
+

+ + +
+ ⚠️ + +
+ +

+ +

+ +
+ + +
+

+ + + + + + + + + + + + + + +
+ +

+
+ + +
+ +

+
+ +

+ + +

+
+ + +
+

+

+ + + + +

+

+ +

+

+ +

+ +
+ +

+ +

+

+ +

+
+
+ + +
+

+
+
+ · mode + % +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
/ users/sec
MB
+
+ + +
+

+
+ + + +
+
+ +
+ +

+ + + + + + + + + + +
/
users/sec
/ / ms
MB
+ +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ +

+ + + + + + + + + + + + __( 'TMDO_API::get_field (membership_level) ×100', '2meet-data-optimizer' ), + 'get_entity_full' => __( 'TMDO_API::get_entity (整筆) ×100', '2meet-data-optimizer' ), + 'range_gold_high_points' => __( '索引範圍:gold + points>5000', '2meet-data-optimizer' ), + 'sort_recent_active_100' => __( '排序:last_active_at DESC LIMIT 100', '2meet-data-optimizer' ), + 'join_top_gold_active' => __( 'JOIN:top gold + active LIMIT 100', '2meet-data-optimizer' ), + 'eav_range_baseline' => __( '原生 EAV 等價查詢(baseline)', '2meet-data-optimizer' ), + ); + foreach ( $query_labels as $key => $label ) : + $q = $query[ $key ] ?? null; + if ( ! $q ) { + continue; + } + ?> + + + + + + + + + +
+

+ +

+ all(); + + ?> +

+

+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Post TypeMeta KeyZoneColumnData TypeIndexedProvider
+ + +
+

+

+ +

+ +
+

+
+ + + '#e0e0e0', + 'dual_write' => '#d4edda', + 'shadow_read' => '#fff3cd', + 'aeav_only' => '#cce5ff', + ); + $badge_bg = $e_mode_bg[ $e_mode ] ?? '#e0e0e0'; + ?> +

+ + + + +

+ + +

+ + + ✓ ' . esc_html( $e_tbl ) . ''; + } else { + echo ' ⏳ ' . esc_html__( '表格尚未建立', '2meet-data-optimizer' ) . ''; + } + } + ?> +

+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +

+

+ + + + + + + + + + + + + + + +
+ +
+ + + + get_col( + "SELECT DISTINCT p.post_type + FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID + WHERE p.post_type NOT IN ('revision', 'nav_menu_item', 'customize_changeset', 'oembed_cache') + ORDER BY p.post_type ASC + LIMIT 200" + ); + set_transient( 'wpdo_classifier_post_types_v1', $types, HOUR_IN_SECONDS ); + } + + $post_type = sanitize_key( wp_unslash( $_GET['classify_type'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only classifier filter; no state change. + if ( $post_type && ! in_array( $post_type, $types ?: array(), true ) ) { + $post_type = ''; + } + + ?> +

+

+ +
+ + + + + +
+ +

' . esc_html__( '此 post type 沒有可分析的 postmeta 欄位。', '2meet-data-optimizer' ) . '

'; + return; + } + + $summary = TMDO_Zone_Classifier::summary( $post_type ); + ?> + +
+
+

+ + + + + + + + +
Hot
Warm
Cold
Archive
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Meta Key
+ +
+
+
+ +
+ + + + + +
+

' . sprintf( esc_html__( '已清除 %1$d 筆超過 %2$d 天的日誌。', '2meet-data-optimizer' ), (int) $deleted, (int) $days ) . '

'; + } + + $errors = TMDO_Logger::get_recent( '', 100 ); + + ?> +

+ +
+ + + + + +
+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + +
IDZoneHook
+ + +

+

+ + 'POST', + 'path' => '/listings/{id}/view', + 'desc' => __( '增加 Zone B 瀏覽計數。需要 WP REST nonce(X-WP-Nonce header)。warm zone cutover 前自動 fallback postmeta hp_view_count。', '2meet-data-optimizer' ), + 'params' => array( + 'id' => __( 'Post ID(路徑參數)', '2meet-data-optimizer' ), + 'X-WP-Nonce' => __( 'WP REST nonce(wp_create_nonce("wp_rest"))', '2meet-data-optimizer' ), + ), + 'headers' => array(), + 'curl' => "curl -X POST '{$base}/listings/123/view' -H 'X-WP-Nonce: '", + ), + array( + 'method' => 'GET', + 'path' => '/listings', + 'desc' => __( '查詢 Zone A 扁平欄位(搜尋/篩選),支援分頁與排序。', '2meet-data-optimizer' ), + 'params' => array( + 'post_type' => __( 'post type(預設 hp_listing)', '2meet-data-optimizer' ), + 'per_page' => __( '每頁筆數 1–100(預設 20)', '2meet-data-optimizer' ), + 'page' => __( '頁碼(預設 1)', '2meet-data-optimizer' ), + 'orderby' => __( '排序欄位(預設 post_id)', '2meet-data-optimizer' ), + 'order' => __( 'ASC 或 DESC(預設 DESC)', '2meet-data-optimizer' ), + '{col}_min' => __( '數值篩選下限,例如 hp_price_min=1000', '2meet-data-optimizer' ), + '{col}_max' => __( '數值篩選上限,例如 hp_price_max=5000', '2meet-data-optimizer' ), + '{col}' => __( '精確值篩選,例如 hp_featured=1', '2meet-data-optimizer' ), + ), + 'headers' => array( + 'X-WP-Total' => __( '符合條件的總筆數', '2meet-data-optimizer' ), + 'X-WP-TotalPages' => __( '總頁數', '2meet-data-optimizer' ), + ), + 'curl' => "curl '{$base}/listings?per_page=5&hp_price_min=1000&order=ASC'", + ), + array( + 'method' => 'GET', + 'path' => '/listings/{id}', + 'desc' => __( '單筆 listing:Zone A(熱區欄位)+ Zone C(JSON blob)合併回傳。Zone 未啟用時自動 fallback postmeta。', '2meet-data-optimizer' ), + 'params' => array( 'id' => __( 'Post ID(路徑參數)', '2meet-data-optimizer' ) ), + 'headers' => array(), + 'curl' => "curl '{$base}/listings/123'", + ), + array( + 'method' => 'GET', + 'path' => '/stats/{id}', + 'desc' => __( 'Zone B 瀏覽計數(warm zone),warm 未啟用時 fallback hp_view_count postmeta。', '2meet-data-optimizer' ), + 'params' => array( 'id' => __( 'Post ID(路徑參數)', '2meet-data-optimizer' ) ), + 'headers' => array(), + 'curl' => "curl '{$base}/stats/123'", + ), + array( + 'method' => 'GET', + 'path' => '/status', + 'desc' => __( '版本、引擎、欄位統計、模組狀態。需要 manage_options 權限(帶 WP Nonce)。', '2meet-data-optimizer' ), + 'params' => array(), + 'headers' => array(), + 'curl' => "curl '{$base}/status' -H 'X-WP-Nonce: '", + ), + ); + foreach ( $endpoints as $ep ) : + ?> +
+

+ + +

+

+ + + + + + + + $pdesc ) : ?> + + + + + + +
+ + +

+ $hdesc ) : ?> +   +

+ +
+
+ + +

+

+ + WpdoClient + +

+

+
+		 rest_url( 'wpdo/v1' ),
+    'nonce'     => wp_create_nonce( 'wp_rest' ),
+    'post_type' => 'hp_listing',
+] );"
+		);
+		?>
+		
+ +

+
+		
+		
+ +

+

+
+		 console.log(r));'
+		);
+		?>
+		
+

' . esc_html( $result->get_error_message() ) . '

'; + } else { + echo '

' . esc_html__( 'HPCT 匯入成功!建議停用 HP Custom Tables 外掛。', '2meet-data-optimizer' ) . '

'; + } + } + + $can_import = TMDO_HPCT_Import::can_import(); + $is_imported = TMDO_HPCT_Import::is_imported(); + + ?> +

+ + +
+

+
+ +

+ + +

+ + + + + + + + + + + + + + + + + +
+ +
+ + +
+ + +
+

+
+ + 'Cache flushed successfully.' ) ); + } + wp_send_json_error( 'Missing post_type' ); + break; + + case 'module_status': + $flags = TMDO_Feature_Flags::all(); + wp_send_json_success( $flags ); + break; + + default: + wp_send_json_error( 'Unknown action' ); + } + } + + // ─── v2.2.0 M4 — Snapshots / Conflicts / Doctor tabs ────────────────── + + /** + * Snapshots tab — list / create / restore / delete (v2.2.0 M1+M4). + */ + private static function render_snapshots(): void { + if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) { + echo '

' . esc_html__( 'Snapshot system not available.', '2meet-data-optimizer' ) . '

'; + return; + } + + // Show post-action notice. + $msg_key = isset( $_GET['wpdo_msg'] ) ? sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( '' !== $msg_key ) { + $msg_map = array( + 'created' => array( 'success', __( '快照已成功建立。', '2meet-data-optimizer' ) ), + 'create_failed' => array( 'error', __( '快照建立失敗,請查看日誌。', '2meet-data-optimizer' ) ), + 'deleted' => array( 'success', __( '快照已刪除。', '2meet-data-optimizer' ) ), + 'delete_failed' => array( 'error', __( '快照刪除失敗。', '2meet-data-optimizer' ) ), + ); + if ( str_starts_with( $msg_key, 'pruned_' ) ) { + $n = (int) substr( $msg_key, 7 ); + printf( + '

%s

', + esc_html( sprintf( /* translators: %d: count */ __( '已清除 %d 個過期快照。', '2meet-data-optimizer' ), $n ) ) + ); + } elseif ( isset( $msg_map[ $msg_key ] ) ) { + printf( + '

%s

', + esc_attr( $msg_map[ $msg_key ][0] ), + esc_html( $msg_map[ $msg_key ][1] ) + ); + } + } + + $rows = TMDO_Snapshot_Manager::list_recent( 50, null ); + ?> +

+

+ +

+ +

+ + + + + + +

+ + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +

+ + wp wpdo snapshot restore <snapshot_id> --apply +

+ + +

+

+ + wp wpdo conflict-scan +

+ ' . esc_html__( '衝突監控模組未載入。', '2meet-data-optimizer' ) . '

'; + return; + } + $summary = TMDO_Conflict_Monitor::get_summary(); + $total = (int) ( $summary['total'] ?? 0 ); + ?> + + + + + + + + + + + + + + + +
+ + + + + + + +
Hook overlap
UAEPG overlap
+ 0 ) : ?> +

+
+			
+			
+ + +

+

+ +

+

+ + + +

+ ' . esc_html__( 'Site Health 模組未載入。', '2meet-data-optimizer' ) . '

'; + return; + } + + $tests = array( + 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ), + 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ), + 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ), + 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ), + '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' ) ), + ); + ?> + + + + + + + + + + $info ) { + $result = call_user_func( array( 'TMDO_Site_Health', $info[0] ) ); + $status = (string) ( $result['status'] ?? 'good' ); + $badge = 'good' === $status ? '✅' : ( 'critical' === $status ? '🔴' : '🟡' ); + printf( + '', + esc_html( $info[1] ), + esc_html( $badge ), + esc_html( $status ), + wp_kses_post( (string) ( $result['description'] ?? '' ) ) + ); + } + ?> + +
%s%s %s%s
+

+ +

+

%s

', + esc_html( + sprintf( + /* translators: %s: module name */ + __( '✅ Module %s 已切換至 dual_write 狀態。建議觀察 ≥ 24h 後再推進到 shadow_read(用 Entity Bridge tab 或 wp wpdo bridge-mode-set)。', '2meet-data-optimizer' ), + $mod + ) + ) + ); + } elseif ( 'enable_blocked' === $msg ) { + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %s: module name */ + __( '⛔ Module %s 啟用被 FSM Guard 擋下(可能 module 已不在 idle 狀態)。', '2meet-data-optimizer' ), + $mod + ) + ) + ); + } elseif ( 'enable_failed' === $msg ) { + echo '

' . esc_html__( '⛔ 啟用失敗,請查日誌。', '2meet-data-optimizer' ) . '

'; + } + + echo '

' . esc_html__( '🤖 模組建議', '2meet-data-optimizer' ) . '

'; + echo '

' . esc_html__( '系統依環境自動偵測哪些 module 適合啟用。每筆建議含 confidence score + reasons + blockers。一鍵啟用會把 module 推進到 dual_write(FSM 第 1 個 active state,FSM Guard 確保不越級)。', '2meet-data-optimizer' ) . '

'; + + if ( ! class_exists( 'TMDO_Module_Detector' ) ) { + echo '

' . esc_html__( '模組偵測器未載入。', '2meet-data-optimizer' ) . '

'; + return; + } + + $all = TMDO_Module_Detector::detect_all( true ); + // Sort: actionable enable (high confidence first) → wait → skip. + $buckets = array( + 'enable' => array(), + 'wait' => array(), + 'skip' => array(), + ); + foreach ( $all as $module => $r ) { + $rec = $r['recommendation'] ?? 'skip'; + if ( ! isset( $buckets[ $rec ] ) ) { + $rec = 'skip'; + } + $buckets[ $rec ][ $module ] = $r; + } + uasort( $buckets['enable'], static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] ); + + // ─── enable bucket(重點)───────────────────────────────── + $enable = $buckets['enable']; + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of recommended modules */ + __( '✅ 建議啟用(%d 個 module)', '2meet-data-optimizer' ), + count( $enable ) + ) + ) + ); + if ( empty( $enable ) ) { + echo '

' . esc_html__( '目前沒有可立即啟用的 module 建議。', '2meet-data-optimizer' ) . '

'; + } else { + echo ''; + printf( + '', + esc_html__( 'Module', '2meet-data-optimizer' ), + esc_html__( 'Confidence', '2meet-data-optimizer' ), + esc_html__( '說明 / 理由', '2meet-data-optimizer' ), + esc_html__( '當前狀態', '2meet-data-optimizer' ), + esc_html__( '操作', '2meet-data-optimizer' ) + ); + echo ''; + foreach ( $enable as $module => $r ) { + $conf = (float) $r['confidence']; + $bar_w = (int) round( $conf * 100 ); + $color = $conf >= 0.7 ? '#46b450' : ( $conf >= 0.5 ? '#dba617' : '#c3c4c7' ); + $enable_url = wp_nonce_url( + add_query_arg( 'wpdo_enable_module', $module, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ), + 'wpdo_enable_module' + ); + printf( + '', + esc_html( $module ), + esc_attr( $color ), + (int) $bar_w, + esc_html( sprintf( '%.2f', $conf ) ) + ); + echo ''; + printf( '', esc_html( (string) $r['current_state'] ) ); + printf( + '', + esc_url( $enable_url ), + esc_js( __( '確定啟用此 module(推進到 dual_write)?', '2meet-data-optimizer' ) ), + esc_html__( '✅ 啟用', '2meet-data-optimizer' ) + ); + echo ''; + } + echo '
%s%s%s%s%s
%s
%s
'; + if ( ! empty( $r['description'] ) ) { + echo '' . esc_html( (string) $r['description'] ) . '
'; + } + if ( ! empty( $r['reasons'] ) ) { + echo '' . esc_html( implode( ' · ', $r['reasons'] ) ) . ''; + } + echo '
%s%s
'; + } + + // ─── wait bucket(條件未滿)──────────────────────────────── + $wait = $buckets['wait']; + if ( ! empty( $wait ) ) { + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of modules not yet ready */ + __( '⏳ 條件未滿 / 暫不建議(%d 個)', '2meet-data-optimizer' ), + count( $wait ) + ) + ) + ); + echo ''; + foreach ( $wait as $module => $r ) { + printf( + '', + esc_html( $module ), + esc_html( implode( ' · ', $r['blockers'] ?? array() ) ) + ); + } + echo '
%s%s
'; + } + + // ─── skip bucket(已啟用 / 不適用)──────────────────────── + $skip = $buckets['skip']; + if ( ! empty( $skip ) ) { + $skip_count = count( $skip ); + printf( + '
%s', + esc_html( + sprintf( + /* translators: %d: number of modules already enabled or not applicable */ + __( '已啟用 / 不適用(%d 個)— 點擊展開', '2meet-data-optimizer' ), + $skip_count + ) + ) + ); + echo ''; + foreach ( $skip as $module => $r ) { + printf( + '', + esc_html( $module ), + esc_html( implode( ' · ', $r['blockers'] ?? array() ) ) + ); + } + echo '
%s%s
'; + } + + echo '

' . esc_html__( '結果由 1 小時 transient 快取;每日健康檢查 cron 也會自動更新。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Settings tab — email alerts + throttle (v2.4.0 M10). + */ + private static function render_settings(): void { + if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + echo '

' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '

'; + } + $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; + ?> +

+
+ + + +

+

+ + + + + + + + + + + + + + + + + + array( '💬 Slack', 'webhook_url', 'wpdo_slack_webhook', 'https://hooks.slack.com/services/...' ), + 'discord' => array( '🎮 Discord', 'webhook_url', 'wpdo_discord_webhook', 'https://discord.com/api/webhooks/...' ), + 'telegram' => array( '📱 Telegram', 'bot', null, null ), + ); + foreach ( $channels as $ch => $cfg ) { + $ch_enabled = '1' === (string) get_option( "wpdo_{$ch}_enabled", '0' ); + $ch_throttle = (int) get_option( "wpdo_{$ch}_throttle_hours", 24 ); + $ch_severity = (string) get_option( "wpdo_{$ch}_severity", 'critical_only' ); + echo ''; + echo '' . esc_html( $cfg[0] ) . ''; + echo ''; + printf( + '', + esc_html__( '啟用', '2meet-data-optimizer' ), + esc_attr( $ch ), + checked( $ch_enabled, true, false ), + esc_html__( '啟用', '2meet-data-optimizer' ) + ); + if ( 'slack' === $ch || 'discord' === $ch ) { + $webhook = class_exists( 'TMDO_Crypto' ) + ? TMDO_Crypto::get_option( (string) $cfg[2] ) + : (string) get_option( $cfg[2], '' ); + printf( + '', + esc_attr( $cfg[2] ), + esc_attr( $cfg[2] ), + esc_attr( $cfg[2] ), + esc_attr( $webhook ), + esc_attr( $cfg[3] ) + ); + } + if ( 'telegram' === $ch ) { + $token = class_exists( 'TMDO_Crypto' ) + ? TMDO_Crypto::get_option( 'wpdo_telegram_bot_token' ) + : (string) get_option( 'wpdo_telegram_bot_token', '' ); + $chat = (string) get_option( 'wpdo_telegram_chat_id', '' ); + printf( + '', + esc_attr( $token ) + ); + printf( + '', + esc_attr( $chat ) + ); + } + printf( + '', + esc_html__( 'Throttle 小時', '2meet-data-optimizer' ), + esc_attr( $ch ), + absint( $ch_throttle ), + esc_html__( '同 fingerprint 不重發(1-168)', '2meet-data-optimizer' ) + ); + printf( + '', + esc_html__( 'Severity 訂閱', '2meet-data-optimizer' ), + esc_attr( $ch ), + selected( 'critical_only', $ch_severity, false ), + selected( 'critical_and_recommended', $ch_severity, false ) + ); + echo ''; + echo ''; + } + ?> + +

+

+
+ + disabled + dual_write + shadow_read + aeav_only +

+ + + + + + + __( 'disabled — 原生 EAV', '2meet-data-optimizer' ), + 'dual_write' => __( 'dual_write — 雙寫,讀走 EAV(安全起點)', '2meet-data-optimizer' ), + 'shadow_read' => __( 'shadow_read — 雙寫 + 讀走 flat(驗證期)', '2meet-data-optimizer' ), + 'aeav_only' => __( 'aeav_only — 僅 flat table(生產模式)', '2meet-data-optimizer' ), + ); + foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) { + $current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( $entity_type ) : 'disabled'; + $field_id = 'wpdo_bridge_mode_' . $entity_type; + ?> + + + + + + + + + get_var( + "SELECT COUNT(*) FROM {$wpdb->options} + WHERE option_name LIKE '\\_transient\\_wpdo\\_hp\\_pm\\_%' + OR option_name LIKE '\\_transient\\_timeout\\_wpdo\\_hp\\_pm\\_%'" + ); + ?> +

+

+ \', $value) 把 TTL cache 寫進 wp_postmeta(每個 hp_listing publish 觸發 8-16 個 transient row)。本 filter 在 metadata 層攔截並重新路由到 wp_options(native transient API),HivePress 完全無感,wp_postmeta 保持乾淨。Filter 是 metadata 層運作,不依賴 mode promote。', '2meet-data-optimizer' ); ?> +

+ + + + + + + + + + + + + + get_var( + "SELECT COUNT(*) FROM {$wpdb->termmeta} + WHERE meta_key LIKE '\\_wxr\\_import\\_%' + OR meta_key LIKE '\\_2meet\\_demo\\_%'" + ); + $comment_garbage_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->commentmeta} + WHERE meta_key LIKE '\\_wxr\\_import\\_%' + OR meta_key LIKE '\\_2meet\\_demo\\_%' + OR meta_key IN ('_hp_price','_hp_status','_hp_featured','_hp_verified','_hp_view_count','_thumbnail_id','_edit_lock','_edit_last')" + ); + ?> +

+

+ +

+ + + + + + + + + + + + + + get_var( + "SELECT COUNT(*) FROM {$wpdb->options} + WHERE option_name LIKE '\\_transient\\_wpdo\\_wc\\_termcount\\_%'" + ); + ?> +

+

+ cache rows,重新路由到 wp_options(native transient 結構)。WC 自身 cache 失效邏輯不變(每次新增/刪除 product 時會重算寫入),僅儲存位置改變。Read 路徑亦會優先從 wp_options 讀回,cache miss 才 fall-through 至 wp_termmeta(向後相容)。', '2meet-data-optimizer' ); ?> +

+ + + + + + + + + + + + + + +

+

+ +

+ + + + + + + + + + + + + +

+

+ + + + + + + + + + + + + + + + '1' ), $page_url ), 'wpdo_run_health' ); + $create_snap_url = wp_nonce_url( add_query_arg( array( 'wpdo_create_snapshot' => '1' ), $page_url ), 'wpdo_create_snapshot' ); + $bridge_url = add_query_arg( 'tab', 'entity-bridge', $page_url ); + + $status = self::compute_status(); + $light = self::traffic_light( $status['level'] ); + ?> + + +

+ + + 0 ) : ?> + · + + +

+ + +

+ + +
+
+ + +
+
+ + +
+
+ + + + 0 ) : ?> + + + +
+
+ + +
+
+ + = 0.5 ) { + ++$actionable_count; + } + } + } + } + if ( $actionable_count > 0 ) : + $ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url ); + ?> +

+ 🤖 + + +

+ + + false ); + + if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) : + $wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url ); + ?> +

+ 🔴 + %1$s 行 wp_usermeta EAV 殘留橫跨 %2$s 個 entity group(當前 ratio 1:%3$s)—', '2meet-data-optimizer' ); + printf( + wp_kses( $msg, array( 'strong' => array() ) ), + esc_html( number_format_i18n( (int) $attn['eav_rows'] ) ), + esc_html( (string) (int) $attn['groups_with_residue'] ), + esc_html( (string) $attn['ratio'] ) + ); + ?> + +

+ +

+ ⏳ + +

+ + + 0 ); + if ( ! empty( $gc['total'] ) && (int) $gc['total'] > 0 ) : + $cleanup_url = wp_nonce_url( + add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ), + 'wpdo_postmeta_cleanup' + ); + $confirm_msg = sprintf( + /* translators: %s: total garbage row count */ + esc_html__( '即將從 wp_postmeta 刪除 %s 行垃圾資料(transients + _wp_old_date + 過期 _edit_lock)。確認執行?', '2meet-data-optimizer' ), + number_format_i18n( (int) $gc['total'] ) + ); + ?> +

+ 🧹 + %1$s 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)—', '2meet-data-optimizer' ); + printf( + wp_kses( $gc_msg, array( 'strong' => array() ) ), + esc_html( number_format_i18n( (int) $gc['total'] ) ), + esc_html( number_format_i18n( (int) $gc['transients'] ) ), + esc_html( number_format_i18n( (int) $gc['wp_old_date'] ) ), + esc_html( number_format_i18n( (int) $gc['edit_locks'] ) ) + ); + ?> + + + +

+ + + 0 ) : + $total_eav = 0; + foreach ( $post_diag['groups'] as $g ) { + $total_eav += (int) $g['eav_rows']; + } + $post_color = $total_eav > 0 ? '#dba617' : '#46b450'; + $post_bg = $total_eav > 0 ? '#fffbe5' : '#ecf7ed'; + $post_label = $total_eav > 0 + ? sprintf( + /* translators: 1: total EAV rows, 2: ratio */ + __( 'Post Entity:偵測到 %1$s 行 wp_postmeta EAV 殘留(當前 ratio 1:%2$s,mode=%3$s)—', '2meet-data-optimizer' ), + '%1$s', + '%2$s', + '%3$s' + ) + : sprintf( + /* translators: 1: ratio */ + __( 'Post Entity:無 EAV 殘留(ratio 1:%1$s,mode=%2$s)', '2meet-data-optimizer' ), + '%1$s', + '%2$s' + ); + ?> +

+ 📦 + 0 ) { + printf( + wp_kses( + /* translators: 1: total EAV rows, 2: ratio, 3: mode */ + __( 'Post Entity:偵測到 %1$s 行 wp_postmeta EAV 殘留(當前 ratio 1:%2$s,mode=%3$s)— v2.9.5 將提供一鍵遷移 UI。', '2meet-data-optimizer' ), + array( 'strong' => array() ) + ), + esc_html( number_format_i18n( $total_eav ) ), + esc_html( (string) $post_diag['ratio'] ), + esc_html( $post_diag['mode'] ) + ); + } else { + printf( + wp_kses( + /* translators: 1: ratio, 2: mode */ + __( 'Post Entity:✓ 無 EAV 殘留(ratio 1:%1$s,mode=%2$s)', '2meet-data-optimizer' ), + array( 'strong' => array() ) + ), + esc_html( (string) $post_diag['ratio'] ), + esc_html( $post_diag['mode'] ) + ); + } + ?> +

+ + + + 0 ) { + $level = 'critical'; + $msg = sprintf( /* translators: %d: count */ __( '%d 項 critical 警告', '2meet-data-optimizer' ), $crit ); + } elseif ( $rec > 0 ) { + $level = 'warn'; + $msg = sprintf( /* translators: %d: count */ __( '%d 項 recommended 提示', '2meet-data-optimizer' ), $rec ); + } else { + $level = 'good'; + $streak = TMDO_Health_Cron::consecutive_green_days(); + } + $msg .= ' · ' . sprintf( + /* translators: %s: timestamp */ + __( '最後執行:%s', '2meet-data-optimizer' ), + (string) $last['ran_at'] + ); + } else { + $msg = __( '尚未執行過健康檢查', '2meet-data-optimizer' ); + } + + // Last snapshot age. + $snap_table = $wpdb->prefix . 'wpdo_snapshots'; + $snap_exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $snap_table + ) + ); + $last_snapshot = __( '從未', '2meet-data-optimizer' ); + if ( 1 === $snap_exists ) { + $ts = (string) $wpdb->get_var( "SELECT created_at FROM `{$snap_table}` ORDER BY created_at DESC LIMIT 1" ); // phpcs:ignore WordPress.DB + if ( '' !== $ts ) { + $diff = time() - strtotime( $ts . ' UTC' ); + $last_snapshot = $diff < 0 ? $ts : self::human_time_diff( $diff ); + } + } + + // Largest zone table by row count (rough sample). + $largest = '—'; + if ( 1 === $snap_exists ) { + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_warm`" ); // phpcs:ignore WordPress.DB + if ( $row_count > 0 ) { + $largest = sprintf( 'wpdo_warm (%s)', number_format_i18n( $row_count ) ); + } + } + + // Conflict count (cheap — uses cached summary). + $conflicts = 0; + if ( class_exists( 'TMDO_Conflict_Monitor' ) ) { + $summary = TMDO_Conflict_Monitor::get_summary(); + $conflicts = (int) ( $summary['total'] ?? 0 ); + } + + // wp_postmeta size (informational). + $pm_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB + + $result = array( + 'level' => $level, + 'summary_msg' => $msg, + 'streak' => $streak, + 'last_snapshot' => $last_snapshot, + 'largest_zone' => $largest, + 'conflicts' => $conflicts, + 'postmeta_human' => number_format_i18n( $pm_count ), + ); + set_transient( 'wpdo_dashboard_widget_status', $result, self::CACHE_TTL ); + return $result; + } + + /** + * Map level → CSS class + label. + * + * @param string $level Level slug. + * @return array {class:string,label:string} + */ + private static function traffic_light( string $level ): array { + switch ( $level ) { + case 'good': + return array( + 'class' => 'good', + 'label' => __( '正常', '2meet-data-optimizer' ), + ); + case 'warn': + return array( + 'class' => 'warn', + 'label' => __( '注意', '2meet-data-optimizer' ), + ); + case 'critical': + return array( + 'class' => 'crit', + 'label' => __( '警告', '2meet-data-optimizer' ), + ); + default: + return array( + 'class' => 'gray', + 'label' => __( '未知', '2meet-data-optimizer' ), + ); + } + } + + /** + * Human-readable time-diff (seconds → "5 minutes ago" style). Uses WP + * `human_time_diff` when available; falls back to simple math otherwise. + * + * @param int $seconds Seconds delta (>=0). + * @return string + */ + private static function human_time_diff( int $seconds ): string { + if ( $seconds < 60 ) { + return $seconds . 's'; + } + if ( function_exists( 'human_time_diff' ) ) { + return sprintf( + /* translators: %s: human-readable time difference */ + __( '%s 前', '2meet-data-optimizer' ), + human_time_diff( time() - $seconds, time() ) + ); + } + if ( $seconds < 3600 ) { + return floor( $seconds / 60 ) . 'm'; + } + if ( $seconds < 86400 ) { + return floor( $seconds / 3600 ) . 'h'; + } + return floor( $seconds / 86400 ) . 'd'; + } +} diff --git a/admin/class-tmdo-export.php b/admin/class-tmdo-export.php new file mode 100644 index 0000000..eea536a --- /dev/null +++ b/admin/class-tmdo-export.php @@ -0,0 +1,272 @@ + '2meet-data-optimizer', + 'wpdo_export' => $type, + 'format' => $format, + ); + if ( 'health' === $type ) { + $args['days'] = $days; + } + return wp_nonce_url( add_query_arg( $args, admin_url( 'tools.php' ) ), self::NONCE ); + } + + // ─── handlers ────────────────────────────────────────────────────── + + /** + * Send health export. + * + * @param string $format csv|json. + * @param int $days Number of days to include. + * @return void + */ + private static function send_health( string $format, int $days ): void { + global $wpdb; + $audit = $wpdb->prefix . 'wpdo_audit'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $audit + ) + ); + $rows = array(); + if ( 1 === $exists ) { + $raw = (array) $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$audit} is a trusted table name via $wpdb->prefix + "SELECT ts, op, value_after FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) ORDER BY ts DESC", + $days + ), + ARRAY_A + ); + foreach ( $raw as $r ) { + $ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : array(); + if ( ! is_array( $ctx ) ) { + $ctx = array(); + } + $rows[] = array( + 'ts' => (string) $r['ts'], + 'critical' => (int) ( $ctx['critical'] ?? 0 ), + 'recommended' => (int) ( $ctx['recommended'] ?? 0 ), + 'duration_ms' => (int) ( $ctx['duration_ms'] ?? 0 ), + 'autoload_kb' => (int) ( $ctx['autoload_kb'] ?? 0 ), + 'conflicts' => (int) ( $ctx['conflicts'] ?? 0 ), + 'module_suggestions_count' => (int) ( $ctx['module_suggestions_count'] ?? 0 ), + ); + } + } + $headers = array( 'ts', 'critical', 'recommended', 'duration_ms', 'autoload_kb', 'conflicts', 'module_suggestions_count' ); + self::respond( $format, 'health', $headers, $rows ); + } + + /** + * Send snapshots export. + * + * @param string $format csv|json. + * @return void + */ + private static function send_snapshots( string $format ): void { + global $wpdb; + $snap = $wpdb->prefix . 'wpdo_snapshots'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $snap + ) + ); + $rows = array(); + if ( 1 === $exists ) { + $rows = (array) $wpdb->get_results( + "SELECT snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, file_sha256, notes, created_at, expires_at FROM `{$snap}` ORDER BY created_at DESC", // phpcs:ignore WordPress.DB + ARRAY_A + ); + } + $headers = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'file_path', 'file_sha256', 'notes', 'created_at', 'expires_at' ); + self::respond( $format, 'snapshots', $headers, $rows ); + } + + /** + * Send monthly summary export. + * + * @param string $format csv|json. + * @return void + */ + private static function send_monthly( string $format ): void { + $latest = (array) get_option( 'wpdo_monthly_summary_latest', array() ); + $history = (array) get_option( 'wpdo_monthly_summary_history', array() ); + $all = array(); + if ( ! empty( $latest ) ) { + $all[] = $latest; + } + foreach ( $history as $h ) { + if ( is_array( $h ) ) { + $all[] = $h; + } + } + if ( 'json' === $format ) { + self::respond_json( 'monthly', $all ); + return; + } + // Flatten for CSV — top-level metric only. + $rows = array(); + foreach ( $all as $row ) { + $health = (array) ( $row['health'] ?? array() ); + $snap = (array) ( $row['snapshots'] ?? array() ); + $rows[] = array( + 'period_start' => (string) ( $row['period_start'] ?? '' ), + 'period_end' => (string) ( $row['period_end'] ?? '' ), + 'generated_at' => (string) ( $row['generated_at'] ?? '' ), + 'health_total' => (int) ( $health['total'] ?? 0 ), + 'health_success' => (int) ( $health['success'] ?? 0 ), + 'health_critical' => (int) ( $health['critical'] ?? 0 ), + 'snapshots_total' => (int) ( $snap['total'] ?? 0 ), + 'snapshots_bytes' => (int) ( $snap['total_bytes'] ?? 0 ), + 'autoload_size' => (int) ( $row['autoload_size'] ?? 0 ), + ); + } + $headers = array( 'period_start', 'period_end', 'generated_at', 'health_total', 'health_success', 'health_critical', 'snapshots_total', 'snapshots_bytes', 'autoload_size' ); + self::respond_csv( 'monthly', $headers, $rows ); + } + + // ─── output helpers ──────────────────────────────────────────────── + + /** + * Dispatch to csv or json responder. + * + * @param string $format csv|json. + * @param string $type Export type slug. + * @param array $headers Column headers. + * @param array $rows Data rows. + * @return void + */ + private static function respond( string $format, string $type, array $headers, array $rows ): void { + if ( 'json' === $format ) { + self::respond_json( $type, $rows ); + return; + } + self::respond_csv( $type, $headers, $rows ); + } + + /** + * Send CSV response. + * + * @param string $type Export type slug. + * @param array $headers Column headers. + * @param array $rows Data rows. + * @return void + */ + private static function respond_csv( string $type, array $headers, array $rows ): void { + $body = TMDO_CSV_Writer::build( $headers, $rows ); + self::send_attachment( $type, 'csv', 'text/csv; charset=UTF-8', $body ); + } + + /** + * Send JSON response. + * + * @param string $type Export type slug. + * @param array $rows Data rows. + * @return void + */ + private static function respond_json( string $type, array $rows ): void { + $body = wp_json_encode( $rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + self::send_attachment( $type, 'json', 'application/json; charset=UTF-8', (string) $body ); + } + + /** + * Send file attachment response. + * + * @param string $type Export type slug. + * @param string $ext File extension. + * @param string $mime MIME type. + * @param string $body File body content. + * @return void + */ + private static function send_attachment( string $type, string $ext, string $mime, string $body ): void { + $site_slug = sanitize_title( (string) get_option( 'blogname', 'site' ) ) ?: 'site'; + $filename = sprintf( 'wpdo-%s-%s-%s.%s', $type, $site_slug, gmdate( 'Ymd' ), $ext ); + nocache_headers(); + header( 'Content-Type: ' . $mime ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Content-Length: ' . strlen( $body ) ); + echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } +} diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php new file mode 100644 index 0000000..26dad11 --- /dev/null +++ b/admin/class-tmdo-help-tabs.php @@ -0,0 +1,221 @@ +add_help_tab( + array( + 'id' => 'wpdo-help-overview', + 'title' => __( '什麼是 WPDO', '2meet-data-optimizer' ), + 'content' => self::content_overview(), + ) + ); + + // Per-tab help (matches whichever tab is active). + $tab_help = array( + 'dashboard' => array( __( '儀表板閱讀指南', '2meet-data-optimizer' ), 'content_dashboard' ), + 'zones' => array( __( '4 個 Zone 是什麼', '2meet-data-optimizer' ), 'content_zones' ), + 'classifier' => array( __( 'Classifier 解讀', '2meet-data-optimizer' ), 'content_classifier' ), + 'snapshots' => array( __( '備份策略', '2meet-data-optimizer' ), 'content_snapshots' ), + 'conflicts' => array( __( '衝突處理', '2meet-data-optimizer' ), 'content_conflicts' ), + 'doctor' => array( __( '健康檢查解讀', '2meet-data-optimizer' ), 'content_doctor' ), + 'logs' => array( __( '日誌使用', '2meet-data-optimizer' ), 'content_logs' ), + 'rest-api' => array( __( 'REST API', '2meet-data-optimizer' ), 'content_rest_api' ), + ); + if ( isset( $tab_help[ $tab ] ) ) { + [ $title, $cb ] = $tab_help[ $tab ]; + $screen->add_help_tab( + array( + 'id' => 'wpdo-help-' . $tab, + 'title' => $title, + 'content' => call_user_func( array( __CLASS__, $cb ) ), + ) + ); + } + + // Sidebar with persistent links. + $screen->set_help_sidebar( + '

' . esc_html__( '更多資源', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( 'Entity Bridge — 主維運入口', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( 'WP Site Health', '2meet-data-optimizer' ) . '

' + . '

wp wpdo doctor
wp wpdo mode-audit
wp wpdo snapshot create

' + ); + } + + // ─── content templates ───────────────────────────────────────────── + + /** + * Overview help content. + * + * @return string + */ + private static function content_overview(): string { + return '

' . esc_html__( 'WP Data Optimizer 是反 EAV(meta 爆炸)的解方。', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( '核心概念:把 wp_postmeta 的高頻欄位(Hot)、TTL 暫存(Warm)、低頻欄位(Cold)、歷史資料(Archive)拆到 4 種專用表,讀寫快很多、autoload 不再爆。', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( '建議第一步:', '2meet-data-optimizer' ) . ' ' . esc_html__( '逛一遍儀表板了解現況 → 看 Entity Bridge tab 各 entity 健康卡片 → 從 1 個 entity 開始用 Migration Wizard 漸進升級 mode。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Dashboard help content. + * + * @return string + */ + private static function content_dashboard(): string { + return '

' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • ' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'Archive 統計 — 壓縮率、依 post_type 拆分', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'REST API rate limit — 429 事件 + Top 10 受限 post', '2meet-data-optimizer' ) . '
  • ' + . '
'; + } + + /** + * Zones help content. + * + * @return string + */ + private static function content_zones(): string { + return '

' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • Hot — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '
  • ' + . '
  • Warm — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '
  • ' + . '
  • Cold — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '
  • ' + . '
  • Archive — ' . esc_html__( 'Trashed / 90+ 天舊資料。可 gzip 壓縮。', '2meet-data-optimizer' ) . '
  • ' + . '
' + . '

' . esc_html__( '不確定要哪種 → 用 Classifier,它會看 access pattern 給建議。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Classifier help content. + * + * @return string + */ + private static function content_classifier(): string { + return '

' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • Confidence — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '
  • ' + . '
  • Reasons — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '
  • ' + . '
  • Already-assigned — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '
  • ' + . '
'; + } + + /** + * Snapshots help content. + * + * @return string + */ + private static function content_snapshots(): string { + return '

' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • ' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( '小於 5MB 自動 inline 到 wp_wpdo_snapshots.inline_blob,方便 wp db export 時跟著走。', '2meet-data-optimizer' ) . '
  • ' + . '
' + . '

' . esc_html__( '災難還原 drill', '2meet-data-optimizer' ) . ':' + . esc_html__( '建議每月做一次 dry-run 還原驗證 — wp wpdo snapshot restore (不加 --apply)即可預覽會還原什麼。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Conflicts help content. + * + * @return string + */ + private static function content_conflicts(): string { + return '

' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • ' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '
  • ' + . '
' + . '

' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Doctor help content. + * + * @return string + */ + private static function content_doctor(): string { + return '

' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  1. schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
  2. ' + . '
  3. error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
  4. ' + . '
  5. hook_conflicts — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '
  6. ' + . '
  7. autoload_bloat — ' . esc_html__( 'wp_options autoload 大小 > 5MB 警告', '2meet-data-optimizer' ) . '
  8. ' + . '
  9. postmeta_explosion — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '
  10. ' + . '
  11. orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
  12. ' + . '
  13. missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
  14. ' + . '
' + . '

' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '

'; + } + + /** + * Logs help content. + * + * @return string + */ + private static function content_logs(): string { + return '

' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '

' + . '
    ' + . '
  • ' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '
  • ' + . '
  • ' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '
  • ' + . '
'; + } + + /** + * REST API help content. + * + * @return string + */ + private static function content_rest_api(): string { + return '

' . esc_html__( 'REST API 提供 zone 操作 + diagnostics endpoints。', '2meet-data-optimizer' ) . '

' + . '

' . esc_html__( '所有 endpoint 用 X-WP-Nonce 認證;rate limit 預設 30/min。', '2meet-data-optimizer' ) . '

'; + } +} diff --git a/admin/class-tmdo-setup-wizard.php b/admin/class-tmdo-setup-wizard.php new file mode 100644 index 0000000..30243cb --- /dev/null +++ b/admin/class-tmdo-setup-wizard.php @@ -0,0 +1,474 @@ + WP Data Optimizer. + * + * Appears in the Tools section so admins can re-run the wizard at any time. + * The wizard itself is idempotent: re-running it never auto-enables modules. + * + * @return void + */ + public static function register_menu_page(): void { + add_submenu_page( + 'tools.php', + __( 'WPDO 設定嚮導', '2meet-data-optimizer' ), + __( 'WPDO 設定嚮導', '2meet-data-optimizer' ), + 'manage_options', + 'wpdo-setup-wizard', + array( __CLASS__, 'render_page' ) + ); + } + + /** + * Render the wizard. + * + * @return void + */ + public static function render_page(): void { + if ( ! TMDO_Capability::current_user_can_admin() ) { + wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) ); + } + $step = max( 1, min( self::TOTAL_STEPS, absint( wp_unslash( $_GET['step'] ?? 1 ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $completed = '1' === get_option( self::OPT_COMPLETED ); + ?> +
+

+ + ← + +

+

+ + +
+ + + + +
+ + +

+ + / + + +

+ +
+
+
+ +
+ +
+ +

+ + + +

+
+ +

+

+ +

+ + + + + + + + + + +
Zone
Hot
Warm
Cold
Archive
+ +

+
    +
  • 100k 開始考慮', '2meet-data-optimizer' ); ?>
  • +
  • 1M 強烈建議啟用', '2meet-data-optimizer' ); ?>
  • +
  • +
+ +
+ + + +
+ get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB + $autoload_bytes = (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB + $top_keys = $wpdb->get_results( "SELECT meta_key, COUNT(*) AS c FROM `{$wpdb->postmeta}` GROUP BY meta_key ORDER BY c DESC LIMIT 10", ARRAY_A ); // phpcs:ignore WordPress.DB + ?> +

+ + + + + + + + +
+ 1_000_000 ) : ?> + + 100_000 ) : ?> + 🟡 + + + +
+ +

+ + + + + + + +
meta_key
+ +
+ + + +
+ +

+

+ + + + +
+

+
+ +

+ +

+ + + + + + + + + + $r ) : ?> + + + + + + + +
+
+ +
+

+ + +

+
    +
  • +
  • +
  • +
+ +
+ + + +
+ +

+ +

+ +

+ +

+

+ + + +

+ 'wizard_baseline', + 'retention_days' => 365, + ) + ); + if ( ! empty( $result['ok'] ) ) { + wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) ); + exit; + } + echo '

' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '

'; + } + ?> + + +

+

+ ' . esc_html( gmdate( 'Y-m-d H:i:s', $next ) ) . '' + ); + } else { + esc_html_e( '⚠️ Daily cron 未排程;請重新啟用外掛。', '2meet-data-optimizer' ); + } + ?> +

+

+ +
+ + + +
+ +

+

+ +
    +
  • +
  • +
  • +
  • +
+ +

+ + + +

+ of available posts (top by comment_count) + * + * @package WP_Data_Optimizer + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$status_str = (string) ( $state['status'] ?? 'idle' ); +$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str ); +$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'disabled'; +$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' ); +$mode_optimal = 'aeav_only' === $current_mode; +$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' ); + +global $wpdb; +$flat_hp_review = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_hp_review" ); +$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_comment_misc" ); +$total_comments = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" ); +$total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" ); +?> + +
+

+ +
+ ⚠️ + +
+ +

+ +

+ + +
+

+ + + + + + + + + + + + + + + + + + + + + + + +
1: 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?>
wpdo_comment_hp_review rowswpdo_comment_misc rows
+
+ + +
+

+ + ✅ + + ⚠️ + ' . esc_html( $current_mode ) . '' + ); + ?> + +

+

+ +

+
    +
  1. + ', + '' + ); + ?> + + + +
  2. +
  3. + +
  4. +
+
+ +
+ + +
+

+ + + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+ + +
+ +

+
+ +

+ + +

+
+ + +
+

+

+ + + + +

+

+ +

+

+ +

+ +
+ +

+ +

+
+
+ + +
+

+
+
+ + + · post # + · mode + + % +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
/ comments/sec
MB
+
+ + +
+

+
+
+ +
diff --git a/admin/templates/migration-wizard.php b/admin/templates/migration-wizard.php new file mode 100644 index 0000000..d736601 --- /dev/null +++ b/admin/templates/migration-wizard.php @@ -0,0 +1,217 @@ + +
+ +
+

+

+ +

+
+ + +
+

+
+
+ + +
+
+ + +
+
+ + 1: +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + $g ) : ?> + + + + + + + + + +
+ 0 ) : ?> + + + 0 + +
+
+
+ + +
> + + +
+

+

+

+ +

+
+ +

+
+ ' . esc_html( number_format_i18n( $eav_residue ) ) . '', + '' . esc_html( $strategy ) . '', + '' . esc_html( (string) $est_sec ) . '' + ); + ?> +
+ +
+ + + + + + +
+ +
+ +
+ + + +
+ + +
> + +

+ +
+
+ % +
+ +
+ + + + + 0.0s +
+ +
+ + + 1: + → + 1: + +
+ +
+

+
+
+ +
+ + +
+
+ + +
> +

+
+ +
+ +
diff --git a/admin/templates/post-migration-wizard.php b/admin/templates/post-migration-wizard.php new file mode 100644 index 0000000..c39d006 --- /dev/null +++ b/admin/templates/post-migration-wizard.php @@ -0,0 +1,298 @@ + __( '🔵 disabled — wp_postmeta 仍是 source-of-truth', '2meet-data-optimizer' ), + 'dual_write' => __( '🟡 dual_write — 同時寫 wp_postmeta + flat 表', '2meet-data-optimizer' ), + 'shadow_read' => __( '🟠 shadow_read — 寫雙路徑,讀比對', '2meet-data-optimizer' ), + 'aeav_only' => __( '🟢 aeav_only — flat 表為 source-of-truth', '2meet-data-optimizer' ), +); + +// Action URLs (each carries nonce). +$cleanup_garbage_url = wp_nonce_url( + add_query_arg( array( 'wpdo_postmeta_cleanup' => '1' ), $page_url ), + 'wpdo_postmeta_cleanup' +); +$backfill_all_url = wp_nonce_url( + add_query_arg( array( 'wpdo_post_backfill_all' => '1' ), $page_url ), + 'wpdo_post_backfill_all' +); +$cutover_legacy_url = wp_nonce_url( + add_query_arg( array( 'wpdo_post_cutover_legacy' => '1' ), $page_url ), + 'wpdo_post_cutover_legacy' +); +$promote_dual_write_url = wp_nonce_url( + add_query_arg( array( 'wpdo_post_promote_dual_write' => '1' ), $page_url ), + 'wpdo_post_promote_dual_write' +); +$promote_aeav_url = wp_nonce_url( + add_query_arg( array( 'wpdo_post_promote_aeav' => '1' ), $page_url ), + 'wpdo_post_promote_aeav' +); + +// Status banner from prior action redirect. +// Read-only display banner — server-set redirect message, no form processing. +// phpcs:ignore WordPress.Security.NonceVerification.Recommended +$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : ''; + +/** + * Map redirect message code → user-facing string. + * + * @param string $code Message code from wpdo_msg query arg. + * @return string + */ +$wpdo_format_msg = static function ( string $code ): string { + $prefix = strtok( $code, '_' ); + $rest = substr( $code, strlen( (string) $prefix ) + 1 ); + switch ( $prefix ) { + case 'backfill': + return sprintf( + /* translators: %s: total flat rows */ + __( 'Backfill 完成:7 個 group 共 %s 個 post 已寫入 flat 表。', '2meet-data-optimizer' ), + $rest + ); + case 'cutover': + return sprintf( + /* translators: %s: rows */ + __( 'Legacy hot table cutover 完成:%s 行已 copy 至 flat 表。', '2meet-data-optimizer' ), + $rest + ); + case 'promote': + return sprintf( + /* translators: %s: target mode */ + __( 'Post mode 已升級至 %s。', '2meet-data-optimizer' ), + $rest + ); + case 'postmetacleanupdone': + return sprintf( + /* translators: %s: deleted rows */ + __( '已從 wp_postmeta 刪除 %s 行垃圾。', '2meet-data-optimizer' ), + $rest + ); + case 'err': + return sprintf( + /* translators: %s: error code */ + __( '錯誤:%s(請查看 wpdo_errors 日誌)。', '2meet-data-optimizer' ), + str_replace( '_', ' ', $rest ) + ); + default: + return $code; + } +}; +?> + + + +
+ + + +
+

+
+ + +

+ +

+ +

+ + +
+ + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
1:
+
+
+
+
+
+
+ + +

+ + + + + + + + + + + + $g ) : ?> + + + + + + + + + +
+ + +
+

+

+ +

+
+ + + +
+
+ +
+

+

+
+ + + +
+
+ +
+

+

+
+ + + +
+
+ +
+

+

+
+ + + +
+
+ +
+

+

+
+ + + +
+
+ +
+

+

+ + wp option update wpdo_bridge_modes '{"post":"disabled",...}' + +

+
+ +
diff --git a/admin/templates/post-stress-test.php b/admin/templates/post-stress-test.php new file mode 100644 index 0000000..1ef71be --- /dev/null +++ b/admin/templates/post-stress-test.php @@ -0,0 +1,393 @@ + '1' ), $page_url ), + 'wpdo_post_stress_cleanup' +); + +// Read-only display banner — server-set redirect message, no form processing. +// phpcs:ignore WordPress.Security.NonceVerification.Recommended +$msg_raw = isset( $_GET['wpdo_msg'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['wpdo_msg'] ) ) : ''; + +$msg_text = ''; +$msg_class = ''; +if ( '' !== $msg_raw ) { + if ( str_starts_with( $msg_raw, 'stress_cleanup_' ) ) { + $n = (int) substr( $msg_raw, strlen( 'stress_cleanup_' ) ); + $msg_text = sprintf( + /* translators: %s: count */ + __( '已清除 %s 個 stress test posts(連同 wp_postmeta + 7 張 flat 表的對應 row)。', '2meet-data-optimizer' ), + number_format_i18n( $n ) + ); + $msg_class = 'notice-success'; + } elseif ( str_starts_with( $msg_raw, 'err_' ) ) { + $msg_text = __( '錯誤:請查看 wpdo_errors 日誌。', '2meet-data-optimizer' ); + $msg_class = 'notice-error'; + } +} + +// Live state from the new state machine — drives initial render of cards. +$state = class_exists( 'TMDO_Post_Stress_Tester' ) ? TMDO_Post_Stress_Tester::get_progress( false ) : array(); +$wpdo_pst_stat = (string) ( $state['status'] ?? 'idle' ); +$is_running = ( 'running' === $wpdo_pst_stat || 'benchmarking' === $wpdo_pst_stat ); + +// Map of post_type → seed key count, surfaced inline next to the dropdown. +$post_type_options = array( + 'product' => array( 'product (WC)', 5 ), + 'hp_listing' => array( 'hp_listing (HivePress)', 7 ), + 'hp_request' => array( 'hp_request', 5 ), + 'hp_vendor' => array( 'hp_vendor', 5 ), + 'attachment' => array( 'attachment', 2 ), + 'nav_menu_item' => array( 'nav_menu_item', 5 ), + 'post' => array( 'post', 2 ), +); +?> + +
+

+ +
+ ⚠️ + +
+ +

+ +

+ + +
+

+
+ + + + +
+

+ + + + + + + + + + + + + + + + + +
1:
+
+ + +
+

+ + ✅ + + ⚠️ + ' . esc_html( $current_post_mode ) . '' + ); + ?> + +

+ +

+ +

+
    +
  1. + ', + '' + ); + ?> + + + +
  2. +
  3. + +
  4. +
+ +
+ + + + + + + + + + + > + + + + + > + + + + + > + + + + + > + + + + + +
⚡ Fast🐢 Realistic
disabledwp_postmeta 5 rows / flat 0 → 1:5(無優化)wp_postmeta 5 / flat 0 → 1:5(無優化)
dual_writewp_postmeta 5 / flat 0 → 1:5wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)
shadow_readwp_postmeta 5 / flat 0 → 1:5wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)
aeav_onlywp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️wp_postmeta 0 / flat 1 → 0:1(完全優化)
+

+ insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?> +

+
+
+ +
+ + +
+

+ + + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+ + +
+ +

+
+ +

+ + +

+
+ + +
+

+

+ + + + +

+

+ +

+

+ +

+ +
+ +

+ +

+

+ +

+
+
+ + +
+

+
+
+ + + · + · mode + + % +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
/ posts/sec
MB
+
+ + +
+

+
+
+ + +
+

+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
product_price, _regular_price, _stock, _stock_status, _skuwp_wpdo_post_wc_product
hp_listinghp_price, hp_status, hp_featured, hp_verified, hp_vendor, hp_view_count, hp_expired_timewp_wpdo_post_hp_listing_core
hp_requesthp_status, hp_user, hp_budget, hp_view_count, hp_expired_timewp_wpdo_post_hp_request_core
hp_vendorhp_user, hp_verified, hp_hourly_rate, hp_rating_count, hp_ratingwp_wpdo_post_hp_vendor_core
attachment_wp_attached_file, _wp_attachment_image_altwp_wpdo_post_attachment
nav_menu_item_menu_item_type, _menu_item_object_id, _menu_item_object, _menu_item_target, _menu_item_urlwp_wpdo_post_nav_menu_item
post_thumbnail_id, _edit_lastwp_wpdo_post_wp_core
+
+ +
diff --git a/admin/templates/term-stress-test.php b/admin/templates/term-stress-test.php new file mode 100644 index 0000000..b418d7d --- /dev/null +++ b/admin/templates/term-stress-test.php @@ -0,0 +1,248 @@ + of available taxonomies + * + * @package WP_Data_Optimizer + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$status_str = (string) ( $state['status'] ?? 'idle' ); +$is_running = ( 'running' === $status_str || 'benchmarking' === $status_str ); +$current_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'disabled'; +$mode_color = 'aeav_only' === $current_mode ? '#28a745' : ( 'shadow_read' === $current_mode ? '#dba617' : '#dc3545' ); +$mode_optimal = 'aeav_only' === $current_mode; +$settings_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' ); + +global $wpdb; +$flat_hp_taxonomy = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_hp_taxonomy" ); +$flat_misc = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_term_misc" ); +$total_terms = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" ); +$total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" ); +?> + +
+

+ +
+ ⚠️ + +
+ +

+ +

+ + +
+

+ + + + + + + + + + + + + + + + + + + + + + + +
1: 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?>
wpdo_term_hp_taxonomy rowswpdo_term_misc rows
+
+ + +
+

+ + ✅ + + ⚠️ + ' . esc_html( $current_mode ) . '' + ); + ?> + +

+

+ +

+
    +
  1. + ', + '' + ); + ?> + + + +
  2. +
  3. + +
  4. +
+
+ +
+ + +
+

+ + + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+ + +
+ +

+
+ +

+ + +

+
+ + +
+

+

+ + + + +

+

+ +

+

+ +

+ +
+ +

+ +

+
+
+ + +
+

+
+
+ + + · + · mode + + % +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
/ terms/sec
MB
+
+ + +
+

+
+
+ +
diff --git a/cli/class-tmdo-cli-member.php b/cli/class-tmdo-cli-member.php new file mode 100644 index 0000000..87a3b6a --- /dev/null +++ b/cli/class-tmdo-cli-member.php @@ -0,0 +1,561 @@ +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 ) + ); + $rows = $exists + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ) + : -1; + $items[] = array( + 'group' => $group, + 'table' => $table, + 'exists' => $exists ? 'yes' : 'NO', + 'rows' => $exists ? $rows : '(table missing)', + ); + } + + WP_CLI\Utils\format_items( 'table', $items, array( 'group', 'table', 'exists', 'rows' ) ); + WP_CLI::log( '' ); + + // Shadow diff rate. + $shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs'; + $shadow_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( 'SHOW TABLES LIKE %s', $shadow_table ) + ); + if ( $shadow_exists ) { + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + "SELECT COUNT(*) FROM `{$shadow_table}` WHERE entity_type = 'user'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + WP_CLI::log( "Shadow diffs (entity_type=user): {$total}" ); + } else { + WP_CLI::log( 'Shadow diffs table: not present.' ); + } + } + + // ── member-backfill ─────────────────────────────────────────────────────── + + /** + * Backfill wp_usermeta rows into the specified user flat-table group. + * + * Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based, + * 500 rows/batch, checkpoint stored in wpdo_migration_status. + * + * ## OPTIONS + * + * [--group=] + * : Which group to backfill: membership, activity, profile, sso. Default: membership. + * + * [--batch-size=] + * : Rows per batch (default 500, max 2000). + * + * [--dry-run] + * : Preview without writing. + * + * [--reset] + * : Clear checkpoint and restart from the beginning. + * + * ## EXAMPLES + * + * wp wpdo member-backfill --group=membership --dry-run + * wp wpdo member-backfill --group=membership + * wp wpdo member-backfill --group=sso --reset + * + * @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'] ); + + if ( ! in_array( $group, self::VALID_GROUPS, true ) ) { + WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) ); + } + + if ( $reset ) { + TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group ); + WP_CLI::log( "Checkpoint cleared for user/{$group}." ); + } + + WP_CLI::log( $dry_run ? "[dry-run] Backfill user/{$group} ..." : "Backfill user/{$group} ..." ); + + $result = TMDO_Entity_Migration_Engine::migrate_group( + 'user', + $group, + array( + 'batch_size' => $batch_size, + 'dry_run' => $dry_run, + 'resume' => ! $reset, + ) + ); + + // Pre-flight failure (adapter/group/keys/table missing). + if ( ! empty( $result['error'] ) ) { + WP_CLI::error( $result['error'] ); + } + + $migrated = (int) ( $result['migrated'] ?? 0 ); + $errors = (int) ( $result['errors'] ?? 0 ); + $skipped = (int) ( $result['skipped'] ?? 0 ); + + // Row-level failures: migrate_group() catches Throwables per-entity, increments + // $stats['errors'], and continues. The returned $stats has no 'error' key, + // so without this branch the CLI would silently report success. + if ( $errors > 0 && 0 === $migrated ) { + WP_CLI::error( + sprintf( + 'All %d row(s) failed during migration — see error_log for details.', + $errors + ) + ); + } + + if ( $errors > 0 ) { + WP_CLI::warning( + sprintf( '%d row(s) failed during migration — see error_log for details.', $errors ) + ); + } + + WP_CLI::success( + sprintf( + 'Migrated %d rows, %d errors, %d skipped (%.2f sec)%s.', + $migrated, + $errors, + $skipped, + $result['elapsed_sec'] ?? 0, + $dry_run ? ' [dry-run, no changes written]' : '' + ) + ); + } + + // ── member-shadow-report ────────────────────────────────────────────────── + + /** + * Display recent shadow_diffs rows for entity_type=user. + * + * ## OPTIONS + * + * [--limit=] + * : Max rows to display (default 20). + * + * [--format=] + * : Output format: table, json, csv. Default: table. + * + * ## EXAMPLES + * + * wp wpdo member-shadow-report + * wp wpdo member-shadow-report --limit=50 --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function member_shadow_report( $args, $assoc_args ): void { + global $wpdb; + + $limit = min( 500, max( 1, (int) ( $assoc_args['limit'] ?? 20 ) ) ); + $format = (string) ( $assoc_args['format'] ?? 'table' ); + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + + $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + WP_CLI::warning( 'wpdo_shadow_diffs table not present — no shadow diffs collected yet.' ); + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, entity_id, meta_key, postmeta_value, zone_value, diff_hash, ts FROM `{$table}` WHERE entity_type = 'user' ORDER BY ts DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $limit + ), + ARRAY_A + ); + + if ( empty( $rows ) ) { + WP_CLI::success( 'No shadow diffs found for entity_type=user.' ); + return; + } + + WP_CLI::log( count( $rows ) . " diff(s) found (limit {$limit}):" ); + WP_CLI\Utils\format_items( + $format, + $rows, + array( 'id', 'entity_id', 'meta_key', 'diff_hash', 'ts' ) + ); + } + + // ── member-cutover ──────────────────────────────────────────────────────── + + /** + * Switch the user bridge mode to aeav_only (reads and writes go to flat table only). + * + * Requires --confirm to prevent accidental invocation. + * Recommended only after shadow diff rate drops below 0.1%. + * + * ## OPTIONS + * + * [--confirm] + * : Required confirmation flag. + * + * ## EXAMPLES + * + * wp wpdo member-cutover --confirm + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function member_cutover( $args, $assoc_args ): void { + if ( ! isset( $assoc_args['confirm'] ) ) { + WP_CLI::error( 'You must pass --confirm to cut over. Check shadow diff rate first: wp wpdo member-shadow-report' ); + } + + $current = TMDO_Mode_Manager::get( 'user' ); + WP_CLI::log( "Current user mode: {$current}" ); + + if ( 'aeav_only' === $current ) { + WP_CLI::success( 'User mode is already aeav_only. Nothing to do.' ); + return; + } + + $result = TMDO_Mode_Manager::set( 'user', 'aeav_only' ); + + if ( isset( $result['error'] ) ) { + WP_CLI::error( 'Cutover failed: ' . $result['error'] ); + } + + WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' ); + } + + // ── member-points-check ─────────────────────────────────────────────────── + + /** + * Show points balance and recent ledger entries for a user. + * + * ## OPTIONS + * + * + * : WordPress user ID. + * + * [--limit=] + * : Ledger rows to show (default 10). + * + * ## EXAMPLES + * + * wp wpdo member-points-check 42 + * wp wpdo member-points-check 42 --limit=20 + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function member_points_check( $args, $assoc_args ): void { + $user_id = (int) ( $args[0] ?? 0 ); + if ( $user_id <= 0 ) { + WP_CLI::error( 'Usage: wp wpdo member-points-check ' ); + } + + $limit = min( 100, max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ) ); + + $balance = TMDO_Points_Manager::get_balance( $user_id ); + WP_CLI::log( "User {$user_id} — points balance: {$balance}" ); + WP_CLI::log( '' ); + + $ledger = TMDO_Points_Manager::get_ledger( $user_id, $limit ); + if ( empty( $ledger ) ) { + WP_CLI::log( 'No ledger entries found.' ); + return; + } + + WP_CLI\Utils\format_items( + 'table', + $ledger, + array( 'id', 'delta', 'balance_after', 'reason', 'ref_type', 'ref_id', 'created_at' ) + ); + } + + // ── member-sso-sync ─────────────────────────────────────────────────────── + + /** + * Sync Hub membership claims into the user flat table. + * + * 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 + * directly to the sso group via the Hook Bus. + * + * ## OPTIONS + * + * [--user-id=] + * : Single user ID to sync. + * + * [--all] + * : Sync all users that have _tmso_refresh_token in usermeta (batch 500). + * + * [--dry-run] + * : Preview without writing. + * + * ## EXAMPLES + * + * wp wpdo member-sso-sync --user-id=42 + * wp wpdo member-sso-sync --all --dry-run + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function member_sso_sync( $args, $assoc_args ): void { + global $wpdb; + + $user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0; + $all = isset( $assoc_args['all'] ); + $dry_run = isset( $assoc_args['dry-run'] ); + + if ( ! $user_id && ! $all ) { + WP_CLI::error( 'Provide --user-id= or --all.' ); + } + + $sso_table = $wpdb->prefix . 'wpdo_user_sso'; + $table_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( 'SHOW TABLES LIKE %s', $sso_table ) + ); + if ( ! $table_exists ) { + WP_CLI::error( "SSO flat table {$sso_table} does not exist. Run `wp wpdo doctor` first." ); + } + + $user_ids = array(); + if ( $user_id ) { + $user_ids = array( $user_id ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $user_ids = $wpdb->get_col( + "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = '_tmso_refresh_token' LIMIT 5000" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ); + } + + if ( empty( $user_ids ) ) { + WP_CLI::log( 'No users with _tmso_refresh_token found.' ); + return; + } + + $synced = 0; + $skipped = 0; + $sso_keys = array( + 'hub_global_user_id' => '_tmso_global_user_id', + 'picture_url' => '_tmso_picture_url', + 'refresh_token_enc' => '_tmso_refresh_token', + ); + + foreach ( $user_ids as $uid ) { + $uid = (int) $uid; + $data = array( 'user_id' => $uid ); + + foreach ( $sso_keys as $flat_col => $meta_key ) { + $val = get_user_meta( $uid, $meta_key, true ); + if ( '' !== $val ) { + // Reject plaintext refresh tokens — must carry the enc_vN: envelope + // written by TMSO_Crypto::encrypt(). Storing cleartext here would + // silently bypass the encryption layer. + if ( 'refresh_token_enc' === $flat_col && ! preg_match( '/^enc_v\d+:/', (string) $val ) ) { + TMDO_Logger::error( 'sso_sync', 'plaintext_token_rejected', "user_id={$uid} meta_key={$meta_key}" ); + continue; + } + $data[ $flat_col ] = $val; + } + } + + if ( count( $data ) <= 1 ) { + ++$skipped; + continue; + } + + if ( ! $dry_run ) { + $wpdb->replace( $sso_table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + } + ++$synced; + } + + WP_CLI::success( + sprintf( + 'SSO sync complete — synced: %d, skipped (no meta): %d%s.', + $synced, + $skipped, + $dry_run ? ' [dry-run]' : '' + ) + ); + } + + // ── member-force-logout ─────────────────────────────────────────────────── + + /** + * Force re-authentication by setting token_expires_at to a past datetime. + * + * 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. + * + * ## OPTIONS + * + * [--user-id=] + * : WordPress local user ID to force-logout. + * + * [--global-user-id=] + * : Hub global_user_id to force-logout (looks up via hub_global_user_id column). + * + * [--confirm] + * : Required safety flag — confirms intent to invalidate live SSO tokens. + * + * ## EXAMPLES + * + * wp wpdo member-force-logout --user-id=42 --confirm + * wp wpdo member-force-logout --global-user-id=abc-123 --confirm + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function member_force_logout( $args, $assoc_args ): void { + if ( ! isset( $assoc_args['confirm'] ) ) { + WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' ); + } + + global $wpdb; + + $sso_table = $wpdb->prefix . 'wpdo_user_sso'; + $past = '2000-01-01 00:00:00'; + + if ( isset( $assoc_args['user-id'] ) ) { + $uid = (int) $assoc_args['user-id']; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE `{$sso_table}` SET token_expires_at = %s WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $past, + $uid + ) + ); + if ( $updated ) { + TMDO_Logger::info( + 'member_force_logout', + array( + 'user_id' => $uid, + 'via' => 'user-id', + ) + ); + WP_CLI::success( "Force-logout applied to user_id={$uid}. Token invalidated." ); + } else { + WP_CLI::warning( "No SSO row found for user_id={$uid}." ); + } + return; + } + + if ( isset( $assoc_args['global-user-id'] ) ) { + $global_id = sanitize_text_field( (string) $assoc_args['global-user-id'] ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE `{$sso_table}` SET token_expires_at = %s WHERE hub_global_user_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $past, + $global_id + ) + ); + if ( $updated ) { + TMDO_Logger::info( + 'member_force_logout', + array( + 'global_user_id' => $global_id, + 'affected_rows' => $updated, + 'via' => 'global-user-id', + ) + ); + WP_CLI::success( "Force-logout applied to global_user_id={$global_id}. Affected rows: {$updated}." ); + } else { + WP_CLI::warning( "No SSO row found for global_user_id={$global_id}." ); + } + return; + } + + WP_CLI::error( 'Provide --user-id= or --global-user-id=.' ); + } +} + +// ── Register subcommands ────────────────────────────────────────────────────── +WP_CLI::add_command( 'wpdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) ); +WP_CLI::add_command( 'wpdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) ); +WP_CLI::add_command( 'wpdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) ); +WP_CLI::add_command( 'wpdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) ); +WP_CLI::add_command( 'wpdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) ); +WP_CLI::add_command( 'wpdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) ); +WP_CLI::add_command( 'wpdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) ); diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php new file mode 100644 index 0000000..10565b7 --- /dev/null +++ b/cli/class-tmdo-cli-post.php @@ -0,0 +1,844 @@ +] + * : Which garbage class to address. Default: all. + * --- + * default: all + * options: + * - all + * - transients + * - wp_old_date + * - edit_locks + * --- + * + * [--dry-run] + * : Show row counts without deleting. + * + * [--confirm] + * : Required to actually DELETE rows. Mutually exclusive with --dry-run. + * + * ## EXAMPLES + * + * wp wpdo postmeta-cleanup --dry-run + * wp wpdo postmeta-cleanup --target=transients --dry-run + * wp wpdo postmeta-cleanup --confirm + * wp wpdo postmeta-cleanup --target=edit_locks --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function postmeta_cleanup( $args, $assoc_args ): void { + $target = (string) ( $assoc_args['target'] ?? TMDO_Postmeta_Cleaner::TARGET_ALL ); + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( ! in_array( $target, TMDO_Postmeta_Cleaner::VALID_TARGETS, true ) ) { + WP_CLI::error( + 'Invalid --target. Choose: ' . implode( ', ', TMDO_Postmeta_Cleaner::VALID_TARGETS ) + ); + } + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + + // Default safety: refuse to run without an explicit choice. + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" . + 'Example: wp wpdo postmeta-cleanup --dry-run' + ); + } + + if ( $dry_run ) { + $counts = TMDO_Postmeta_Cleaner::count_garbage( $target ); + self::render_table( $counts, 'would delete' ); + WP_CLI::success( + sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] ) + ); + return; + } + + // $confirm path. + $deleted = TMDO_Postmeta_Cleaner::delete_garbage( $target ); + self::render_table( $deleted, 'deleted' ); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'postmeta_cleanup', + array( + 'target' => $target, + 'transients' => $deleted['transients'], + 'wp_old_date' => $deleted['wp_old_date'], + 'edit_locks' => $deleted['edit_locks'], + 'total' => $deleted['total'], + ) + ); + } + + WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_postmeta.', $deleted['total'] ) ); + } + + /** + * Render a table of bucket → count. + * + * @param array $counts Output from TMDO_Postmeta_Cleaner::count_garbage() / delete_garbage(). + * @param string $verb Column header verb ('would delete' / 'deleted'). + */ + private static function render_table( array $counts, string $verb ): void { + $rows = array( + array( + 'bucket' => 'transients', + $verb => $counts['transients'], + 'rule' => '_transient_% OR _transient_timeout_%', + ), + array( + 'bucket' => 'wp_old_date', + $verb => $counts['wp_old_date'], + 'rule' => "meta_key = '_wp_old_date'", + ), + array( + 'bucket' => 'edit_locks', + $verb => $counts['edit_locks'], + 'rule' => '_edit_lock older than 24h', + ), + array( + 'bucket' => 'TOTAL', + $verb => $counts['total'], + 'rule' => '', + ), + ); + WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) ); + } + + // ── post-diagnose (v2.9.3) ──────────────────────────────────────────────── + + /** + * Show wp_posts:wp_postmeta ratio + per-group EAV residue / flat row count. + * + * Read-only command. Mirrors `wp wpdo member-audit` for the post entity. + * + * ## EXAMPLES + * + * wp wpdo post-diagnose + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments (unused). + */ + public function post_diagnose( $args, $assoc_args ): void { + $result = TMDO_Post_Migration::diagnose(); + + WP_CLI::log( sprintf( 'Posts: %s', number_format_i18n( $result['posts'] ) ) ); + WP_CLI::log( sprintf( 'Postmeta: %s', number_format_i18n( $result['postmeta'] ) ) ); + WP_CLI::log( sprintf( 'Ratio: 1:%s', $result['ratio'] ) ); + WP_CLI::log( sprintf( 'Mode: %s', $result['mode'] ) ); + WP_CLI::log( '' ); + + $rows = array(); + foreach ( $result['groups'] as $name => $g ) { + $rows[] = array( + 'group' => $name, + 'post_type' => $g['post_type'] ?: '(any)', + 'keys' => count( $g['keys'] ), + 'eav_rows' => $g['eav_rows'], + 'flat_rows' => $g['flat_rows'], + ); + } + WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'post_type', 'keys', 'eav_rows', 'flat_rows' ) ); + } + + // ── post-migrate-group (v2.9.3) ─────────────────────────────────────────── + + /** + * Backfill one post entity group from wp_postmeta into its flat table + * via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). + * + * Idempotent — safe to re-run. Skips JSON-typed fields (handled by the + * row-by-row backfill phase in v2.9.4+). + * + * ## OPTIONS + * + * --group= + * : Entity group name. Required. + * --- + * options: + * - wp_core + * - attachment + * - wc_product + * - hp_listing_core + * - hp_request_core + * - hp_vendor_core + * - nav_menu_item + * --- + * + * ## EXAMPLES + * + * wp wpdo post-migrate-group --group=wc_product + * wp wpdo post-migrate-group --group=hp_listing_core + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_migrate_group( $args, $assoc_args ): void { + $group = (string) ( $assoc_args['group'] ?? '' ); + if ( '' === $group ) { + WP_CLI::error( 'Missing required --group=.' ); + } + + try { + $result = TMDO_Post_Migration::backfill_group( $group ); + } catch ( \Throwable $e ) { + WP_CLI::error( $e->getMessage() ); + } + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_migrate_group', + array( + 'group' => $result['group'], + 'post_type' => $result['post_type'], + 'migrated' => $result['migrated'], + ) + ); + } + + WP_CLI::success( + sprintf( + 'Backfilled %s (post_type=%s): %d post(s) migrated.', + $result['group'], + $result['post_type'] ?: '(any)', + $result['migrated'] + ) + ); + } + + // ── post-cleanup (v2.9.3) ───────────────────────────────────────────────── + + /** + * DELETE managed-key wp_postmeta rows after cutover. Requires post mode + * to be aeav_only (the flat table is the authoritative source). + * + * SAFETY: requires --confirm. + * + * ## OPTIONS + * + * [--dry-run] + * : Preview row count only. + * + * [--confirm] + * : Required to actually DELETE rows. + * + * ## EXAMPLES + * + * wp wpdo post-cleanup --dry-run + * wp wpdo post-cleanup --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_cleanup( $args, $assoc_args ): void { + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" . + 'Example: wp wpdo post-cleanup --dry-run' + ); + } + + $diagnose = TMDO_Post_Migration::diagnose(); + $keys = TMDO_Post_Migration::get_managed_keys(); + + global $wpdb; + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $sql = "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $candidate = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$keys ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + + if ( $dry_run ) { + WP_CLI::log( sprintf( 'Mode: %s', $diagnose['mode'] ) ); + WP_CLI::log( sprintf( 'Managed keys: %d', count( $keys ) ) ); + WP_CLI::success( + sprintf( + '[dry-run] %d wp_postmeta row(s) would be deleted. Re-run with --confirm.', + $candidate + ) + ); + return; + } + + // $confirm path — TMDO_Post_Migration::cleanup() enforces mode=aeav_only. + try { + $result = TMDO_Post_Migration::cleanup(); + } catch ( \Throwable $e ) { + WP_CLI::error( $e->getMessage() ); + } + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_cleanup', + array( + 'deleted' => $result['deleted'], + ) + ); + } + + WP_CLI::success( sprintf( 'Deleted %d wp_postmeta row(s).', $result['deleted'] ) ); + } + + // ── cleanup-hp-transients (v2.11.5) ─────────────────────────────────────── + + /** + * Purge legacy `_transient_hp_*` rows from wp_postmeta. + * + * HivePress (`hivepress/includes/components/class-cache.php`) writes + * per-post TTL caches as `_transient_` / `_transient_timeout_` + * postmeta rows. v2.11.5 ships `TMDO_Hivepress_Transient_Filter` to + * intercept new writes and reroute them to wp_options. This command does + * the one-time historical cleanup — DELETE-ing all such existing rows + * from wp_postmeta. After cleanup, HivePress re-fetches on demand. + * + * Safe to run with the filter enabled; the filter prevents new postmeta + * writes from re-bloating the table. + * + * ## OPTIONS + * + * [--dry-run] + * : Preview row count only. + * + * [--confirm] + * : Required to actually DELETE rows. + * + * ## EXAMPLES + * + * wp wpdo cleanup-hp-transients --dry-run + * wp wpdo cleanup-hp-transients --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function cleanup_hp_transients( $args, $assoc_args ): void { + unset( $args ); + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" . + 'Example: wp wpdo cleanup-hp-transients --dry-run' + ); + } + + if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { + WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' ); + } + + $count = TMDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows(); + + if ( $dry_run ) { + WP_CLI::success( + sprintf( + '[dry-run] %d HivePress transient row(s) in wp_postmeta would be deleted. Re-run with --confirm.', + $count + ) + ); + return; + } + + $deleted = TMDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows(); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'cleanup_hp_transients', + array( 'deleted' => $deleted ) + ); + } + + WP_CLI::success( + sprintf( + 'Deleted %d HivePress transient row(s) from wp_postmeta. Future writes auto-route to wp_options.', + $deleted + ) + ); + } + + // ── post-cutover-legacy (v2.9.5) ────────────────────────────────────────── + + /** + * Non-destructive copy of a legacy `wpdo_hot_` zone table + * into the new `wp_wpdo_post_` flat table. + * + * The legacy table is left UNTOUCHED (safety net for v3.0.0 rollback). + * Idempotent — re-running is safe. + * + * Currently supports hp_listing only (the only post_type with a + * legacy hot table on production deployments). Other post_types skip + * the cutover and rely on direct wp_postmeta backfill. + * + * ## OPTIONS + * + * --post-type= + * : Post type to cutover. + * --- + * default: hp_listing + * options: + * - hp_listing + * --- + * + * [--dry-run] + * : Preview row counts only. + * + * [--confirm] + * : Required to actually run the copy. + * + * ## EXAMPLES + * + * wp wpdo post-cutover-legacy --dry-run + * wp wpdo post-cutover-legacy --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_cutover_legacy( $args, $assoc_args ): void { + $post_type = (string) ( $assoc_args['post-type'] ?? 'hp_listing' ); + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to run.\n" . + 'Example: wp wpdo post-cutover-legacy --dry-run' + ); + } + + // Map post_type → (hot_table, flat_table, group). + $mapping = array( + 'hp_listing' => array( + 'hot' => 'wpdo_hot_hp_listing', + 'flat' => 'wpdo_post_hp_listing_core', + 'group' => 'hp_listing_core', + ), + ); + if ( ! isset( $mapping[ $post_type ] ) ) { + WP_CLI::error( 'Unsupported --post-type: ' . $post_type ); + } + + global $wpdb; + $hot_table = $wpdb->prefix . $mapping[ $post_type ]['hot']; + $flat_table = $wpdb->prefix . $mapping[ $post_type ]['flat']; + + // Pre-flight: verify hot table exists and report current state. + $hot_exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $hot_table ) + ); + if ( ! $hot_exists ) { + WP_CLI::warning( "Legacy hot table not present: {$hot_table}. Nothing to do." ); + return; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ); + + WP_CLI::log( sprintf( 'Hot (%s): %s rows', $hot_table, number_format_i18n( $hot_rows ) ) ); + WP_CLI::log( sprintf( 'Flat (%s): %s rows', $flat_table, number_format_i18n( $flat_rows ) ) ); + WP_CLI::log( '' ); + + if ( $dry_run ) { + WP_CLI::success( + sprintf( + '[dry-run] Would copy %s rows from hot → flat. Re-run with --confirm to apply.', + number_format_i18n( $hot_rows ) + ) + ); + return; + } + + // $confirm path. + try { + $result = TMDO_Post_Migration::copy_legacy_hot_table( $post_type, $hot_table, $flat_table ); + $verify = TMDO_Post_Migration::verify_legacy_cutover( $hot_table, $flat_table ); + } catch ( \Throwable $e ) { + WP_CLI::error( $e->getMessage() ); + } + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'post_cutover_legacy', + array( + 'post_type' => $post_type, + 'hot_table' => $hot_table, + 'flat_table' => $flat_table, + 'copied' => $result['copied'], + 'common_columns' => $result['common_columns'], + 'verify' => $verify, + ) + ); + } + + WP_CLI::log( sprintf( 'Copied %d row(s). Common columns: %s', $result['copied'], implode( ', ', $result['common_columns'] ) ) ); + WP_CLI::log( + sprintf( + 'Verify: hot=%d flat=%d mismatched=%d ok=%s', + $verify['hot_rows'], + $verify['flat_rows'], + $verify['mismatched_rows'], + $verify['ok'] ? 'yes' : 'NO' + ) + ); + + if ( ! $verify['ok'] ) { + WP_CLI::error( 'Verification failed — flat table missing rows. Legacy hot table left intact for retry.' ); + } + + WP_CLI::success( + sprintf( + 'Legacy cutover complete (%s). Hot table preserved as safety net for v3.0.0 rollback.', + $post_type + ) + ); + } + + // ── post-benchmark (v2.10.2) ────────────────────────────────────────────── + + /** + * Compare query latency between wp_postmeta JOIN and flat-table JOIN + * for a single meta_key/value combination. + * + * Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better. + * + * Read-only — does not modify any table or change post mode. Safe to run + * in any post mode (the benchmark always queries both paths regardless). + * + * ## OPTIONS + * + * --post-type= + * : Post type to filter by (e.g. product, hp_listing). + * + * --meta-key= + * : Meta key to query (must be registered in entity registry for $post_type). + * + * [--compare=] + * : Comparison operator. Default: =. + * --- + * default: = + * options: + * - "=" + * - "!=" + * - "<" + * - "<=" + * - ">" + * - ">=" + * - "LIKE" + * --- + * + * --value= + * : Value to compare against. + * + * [--samples=] + * : Number of times to run each query. Default: 50. + * + * ## EXAMPLES + * + * wp wpdo post-benchmark --post-type=hp_listing --meta-key=hp_price --compare=">=" --value=100 + * wp wpdo post-benchmark --post-type=product --meta-key=_price --compare="=" --value=99 --samples=200 + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_benchmark( $args, $assoc_args ): void { + $post_type = (string) ( $assoc_args['post-type'] ?? '' ); + $meta_key = (string) ( $assoc_args['meta-key'] ?? '' ); + $compare = (string) ( $assoc_args['compare'] ?? '=' ); + $value = (string) ( $assoc_args['value'] ?? '' ); + $samples = max( 1, (int) ( $assoc_args['samples'] ?? 50 ) ); + + if ( '' === $post_type || '' === $meta_key ) { + WP_CLI::error( 'Both --post-type and --meta-key are required.' ); + } + + // Look up the entity group for this meta_key to find the flat table. + $field = TMDO_Entity_Registry::get_field( 'post', $meta_key ); + if ( ! $field ) { + WP_CLI::error( "Meta key '{$meta_key}' is not registered for entity_type='post'." ); + } + + global $wpdb; + $flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $field['group'] ); + + try { + $result = TMDO_Post_Migration::benchmark_query( + $post_type, + $meta_key, + $compare, + $value, + $flat_table, + $samples + ); + } catch ( \Throwable $e ) { + WP_CLI::error( $e->getMessage() ); + } + + WP_CLI::log( + sprintf( + 'Benchmark: %s.%s %s %s (samples=%d)', + $post_type, + $meta_key, + $compare, + $value, + $samples + ) + ); + WP_CLI::log( '' ); + WP_CLI::log( + sprintf( + ' postmeta JOIN: %.3f ms avg (matched %d rows)', + $result['postmeta_avg_ms'], + $result['postmeta_rows'] + ) + ); + WP_CLI::log( + sprintf( + ' flat JOIN: %.3f ms avg (matched %d rows)', + $result['flat_avg_ms'], + $result['flat_rows'] + ) + ); + WP_CLI::log( '' ); + WP_CLI::log( sprintf( ' → Speedup: %.2fx', $result['speedup'] ) ); + + // Persist to wpdo_benchmarks table. + $bench_table = $wpdb->prefix . 'wpdo_benchmarks'; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->insert( + $bench_table, + array( + 'module' => 'post_router_' . $post_type, + 'zone' => 'flat', + 'query_type' => 'meta_query_' . $meta_key, + 'native_ms' => $result['postmeta_avg_ms'], + 'custom_ms' => $result['flat_avg_ms'], + 'sample_size' => $samples, + 'created_at' => gmdate( 'Y-m-d H:i:s' ), + ), + array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' ) + ); + + WP_CLI::success( sprintf( 'Benchmark recorded to %s.', $bench_table ) ); + } + + // ── post-shadow-report (v2.10.3) ────────────────────────────────────────── + + /** + * Show shadow_read divergence stats for entity_type='post'. + * + * When post mode is shadow_read, the TMDO_Post_Shadow_Verifier cron job + * runs hourly and writes any flat vs wp_postmeta divergences to + * wpdo_shadow_diffs. This command surfaces a 24h aggregate. + * + * Optionally runs an immediate sample-compare pass via --run-now. + * + * ## OPTIONS + * + * [--hours=] + * : Aggregation window in hours. Default: 24. + * + * [--limit=] + * : Max recent diff rows to display. Default: 10. + * + * [--run-now] + * : Execute one sample-compare tick immediately (alongside the report). + * + * ## EXAMPLES + * + * wp wpdo post-shadow-report + * wp wpdo post-shadow-report --hours=48 --limit=20 + * wp wpdo post-shadow-report --run-now + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_shadow_report( $args, $assoc_args ): void { + $hours = max( 1, (int) ( $assoc_args['hours'] ?? 24 ) ); + $limit = max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ); + $run_now = isset( $assoc_args['run-now'] ); + $mode = TMDO_Mode_Manager::get( 'post' ); + + WP_CLI::log( sprintf( 'Post mode: %s', $mode ) ); + WP_CLI::log( sprintf( 'Window: last %d hours', $hours ) ); + WP_CLI::log( '' ); + + if ( $run_now ) { + WP_CLI::log( 'Running sample-compare for all groups...' ); + $totals = array( + 'sampled' => 0, + 'matched' => 0, + 'diffs' => 0, + 'missing_flat' => 0, + 'missing_postmeta' => 0, + ); + global $wpdb; + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { + $keys = TMDO_Entity_Registry::get_group_keys( 'post', $group ); + if ( empty( $keys ) ) { + continue; + } + // Use the verifier's own group→post_type mapping reflectively. + $pt = self::group_post_type_for_cli( $group ); + if ( null === $pt ) { + continue; + } + $flat = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); + try { + $res = TMDO_Post_Shadow_Verifier::sample_compare( $pt, $group, $flat, $keys, 50 ); + foreach ( $totals as $k => $_ ) { + $totals[ $k ] += (int) ( $res[ $k ] ?? 0 ); + } + WP_CLI::log( + sprintf( + ' %-20s sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d', + $group, + $res['sampled'], + $res['matched'], + $res['diffs'], + $res['missing_flat'], + $res['missing_postmeta'] + ) + ); + } catch ( \Throwable $e ) { + WP_CLI::warning( " {$group}: " . $e->getMessage() ); + } + } + WP_CLI::log( '' ); + WP_CLI::log( + sprintf( + 'Run-now totals: sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d', + $totals['sampled'], + $totals['matched'], + $totals['diffs'], + $totals['missing_flat'], + $totals['missing_postmeta'] + ) + ); + WP_CLI::log( '' ); + } + + // 24h aggregate from wpdo_shadow_diffs. + $stats = TMDO_Post_Shadow_Verifier::diff_stats( $hours ); + WP_CLI::log( sprintf( 'Total diffs (last %dh): %d', $hours, $stats['total'] ) ); + if ( ! empty( $stats['by_key'] ) ) { + WP_CLI::log( '' ); + $rows = array(); + foreach ( $stats['by_key'] as $key => $count ) { + $rows[] = array( + 'meta_key' => $key, + 'diffs' => $count, + ); + } + WP_CLI\Utils\format_items( 'table', $rows, array( 'meta_key', 'diffs' ) ); + } + + // Recent diff rows. + if ( $stats['total'] > 0 ) { + $recent = TMDO_Post_Shadow_Verifier::recent_diffs( $limit ); + if ( ! empty( $recent ) ) { + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Recent %d diff(s):', $limit ) ); + WP_CLI\Utils\format_items( 'table', $recent, array( 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'ts' ) ); + } + } + + WP_CLI::success( 'Shadow report complete.' ); + } + + /** + * Helper: map entity group to its primary post_type for the run-now CLI path. + * Mirror of TMDO_Post_Shadow_Verifier's private mapping. + * + * @param string $group Entity group name. + * @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; + } + } +} + +// ── Register subcommands ────────────────────────────────────────────────────── +WP_CLI::add_command( 'wpdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) ); +WP_CLI::add_command( 'wpdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) ); +WP_CLI::add_command( 'wpdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) ); +WP_CLI::add_command( 'wpdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) ); +WP_CLI::add_command( 'wpdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) ); +WP_CLI::add_command( 'wpdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) ); +WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) ); +WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) ); diff --git a/cli/class-tmdo-cli-term-comment.php b/cli/class-tmdo-cli-term-comment.php new file mode 100644 index 0000000..3a07368 --- /dev/null +++ b/cli/class-tmdo-cli-term-comment.php @@ -0,0 +1,1013 @@ +] + * : Which garbage class to address. Default: all. + * --- + * default: all + * options: + * - all + * - wxr_import + * - demo_data + * - transients + * --- + * + * [--dry-run] + * : Show row counts without deleting. + * + * [--confirm] + * : Required to actually DELETE rows. Mutually exclusive with --dry-run. + * + * ## EXAMPLES + * + * wp wpdo termmeta-cleanup --dry-run + * wp wpdo termmeta-cleanup --target=wxr_import --dry-run + * wp wpdo termmeta-cleanup --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function termmeta_cleanup( $args, $assoc_args ): void { + $target = (string) ( $assoc_args['target'] ?? TMDO_Termmeta_Cleaner::TARGET_ALL ); + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( ! in_array( $target, TMDO_Termmeta_Cleaner::VALID_TARGETS, true ) ) { + WP_CLI::error( + 'Invalid --target. Choose: ' . implode( ', ', TMDO_Termmeta_Cleaner::VALID_TARGETS ) + ); + } + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" . + 'Example: wp wpdo termmeta-cleanup --dry-run' + ); + } + + if ( $dry_run ) { + $counts = TMDO_Termmeta_Cleaner::count_garbage( $target ); + self::render_term_table( $counts, 'would delete' ); + WP_CLI::success( + sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] ) + ); + return; + } + + $deleted = TMDO_Termmeta_Cleaner::delete_garbage( $target ); + self::render_term_table( $deleted, 'deleted' ); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'termmeta_cleanup', + array( + 'target' => $target, + 'wxr_import' => $deleted['wxr_import'], + 'demo_data' => $deleted['demo_data'], + 'transients' => $deleted['transients'], + 'total' => $deleted['total'], + ) + ); + } + + WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_termmeta.', $deleted['total'] ) ); + } + + /** + * Render a table for termmeta cleanup output. + * + * @param array $counts Output from TMDO_Termmeta_Cleaner::count_garbage / delete_garbage. + * @param string $verb Column header verb. + */ + private static function render_term_table( array $counts, string $verb ): void { + $rows = array( + array( + 'bucket' => 'wxr_import', + $verb => $counts['wxr_import'], + 'rule' => '_wxr_import_*', + ), + array( + 'bucket' => 'demo_data', + $verb => $counts['demo_data'], + 'rule' => '_2meet_demo_*', + ), + array( + 'bucket' => 'transients', + $verb => $counts['transients'], + 'rule' => '_transient_% OR _transient_timeout_%', + ), + array( + 'bucket' => 'TOTAL', + $verb => $counts['total'], + 'rule' => '', + ), + ); + WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) ); + } + + // ── commentmeta-cleanup (v2.12.0 Phase 0) ───────────────────────────────── + + /** + * Clean wp_commentmeta garbage rows. + * + * Targets: + * - wxr_import — `_wxr_import_*` rows (often dominant — dev10 had 211) + * - demo_data — `_2meet_demo_*` rows + * - transients — `_transient_*` cache rows + * - orphan_post_meta — Stray post-domain keys (`_hp_price`, `_thumbnail_id`, + * etc.) mistakenly written to commentmeta + * + * SAFETY: by default refuses to run. Pass --dry-run to preview, or + * --confirm to actually delete. + * + * ## OPTIONS + * + * [--target=] + * : Which garbage class to address. Default: all. + * --- + * default: all + * options: + * - all + * - wxr_import + * - demo_data + * - transients + * - orphan_post_meta + * --- + * + * [--dry-run] + * : Show row counts without deleting. + * + * [--confirm] + * : Required to actually DELETE rows. + * + * ## EXAMPLES + * + * wp wpdo commentmeta-cleanup --dry-run + * wp wpdo commentmeta-cleanup --target=wxr_import --confirm + * wp wpdo commentmeta-cleanup --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function commentmeta_cleanup( $args, $assoc_args ): void { + $target = (string) ( $assoc_args['target'] ?? TMDO_Commentmeta_Cleaner::TARGET_ALL ); + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( ! in_array( $target, TMDO_Commentmeta_Cleaner::VALID_TARGETS, true ) ) { + WP_CLI::error( + 'Invalid --target. Choose: ' . implode( ', ', TMDO_Commentmeta_Cleaner::VALID_TARGETS ) + ); + } + + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" . + 'Example: wp wpdo commentmeta-cleanup --dry-run' + ); + } + + if ( $dry_run ) { + $counts = TMDO_Commentmeta_Cleaner::count_garbage( $target ); + self::render_comment_table( $counts, 'would delete' ); + WP_CLI::success( + sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] ) + ); + return; + } + + $deleted = TMDO_Commentmeta_Cleaner::delete_garbage( $target ); + self::render_comment_table( $deleted, 'deleted' ); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'commentmeta_cleanup', + array( + 'target' => $target, + 'wxr_import' => $deleted['wxr_import'], + 'demo_data' => $deleted['demo_data'], + 'transients' => $deleted['transients'], + 'orphan_post_meta' => $deleted['orphan_post_meta'], + 'total' => $deleted['total'], + ) + ); + } + + WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_commentmeta.', $deleted['total'] ) ); + } + + /** + * Render a table for commentmeta cleanup output. + * + * @param array $counts Output from TMDO_Commentmeta_Cleaner::count_garbage / delete_garbage. + * @param string $verb Column header verb. + */ + private static function render_comment_table( array $counts, string $verb ): void { + $rows = array( + array( + 'bucket' => 'wxr_import', + $verb => $counts['wxr_import'], + 'rule' => '_wxr_import_*', + ), + array( + 'bucket' => 'demo_data', + $verb => $counts['demo_data'], + 'rule' => '_2meet_demo_*', + ), + array( + 'bucket' => 'transients', + $verb => $counts['transients'], + 'rule' => '_transient_% OR _transient_timeout_%', + ), + array( + 'bucket' => 'orphan_post_meta', + $verb => $counts['orphan_post_meta'], + 'rule' => '_hp_price / _hp_status / _thumbnail_id / etc.', + ), + array( + 'bucket' => 'TOTAL', + $verb => $counts['total'], + 'rule' => '', + ), + ); + WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) ); + } + + // ── term-promote-mode (v2.12.5 Phase 5) ─────────────────────────────────── + + /** + * Promote term entity mode through the FSM safe path. + * + * Wraps TMDO_Mode_Manager::set('term', $new_mode). Mode_Manager enforces + * one-step-at-a-time promotion (disabled → dual_write → shadow_read → + * aeav_only); this CLI provides friendly UX + validation. + * + * ## OPTIONS + * + * + * : Target mode. One of: disabled, dual_write, shadow_read, aeav_only. + * + * ## EXAMPLES + * + * wp wpdo term-promote-mode dual_write + * wp wpdo term-promote-mode shadow_read + * wp wpdo term-promote-mode aeav_only + * + * @param array $args Positional arguments — [new_mode]. + * @param array $assoc_args Named arguments (unused). + */ + public function term_promote_mode( $args, $assoc_args ): void { + self::promote_mode( 'term', $args, $assoc_args ); + } + + // ── comment-promote-mode (v2.12.5 Phase 5) ──────────────────────────────── + + /** + * Promote comment entity mode through the FSM safe path. + * + * ## OPTIONS + * + * + * : Target mode. One of: disabled, dual_write, shadow_read, aeav_only. + * + * ## EXAMPLES + * + * wp wpdo comment-promote-mode dual_write + * wp wpdo comment-promote-mode shadow_read + * + * @param array $args Positional arguments — [new_mode]. + * @param array $assoc_args Named arguments (unused). + */ + public function comment_promote_mode( $args, $assoc_args ): void { + self::promote_mode( 'comment', $args, $assoc_args ); + } + + /** + * Shared promote-mode implementation for both term and comment. + * + * @param string $entity_type 'term' or 'comment'. + * @param array $args Positional arguments — [new_mode]. + * @param array $assoc_args Named arguments (unused). + * @return void + */ + private static function promote_mode( string $entity_type, array $args, array $assoc_args ): void { + unset( $assoc_args ); + if ( empty( $args[0] ) ) { + WP_CLI::error( "Missing new_mode argument. Usage: wp wpdo {$entity_type}-promote-mode " ); + } + $new_mode = (string) $args[0]; + + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + WP_CLI::error( 'TMDO_Mode_Manager not loaded.' ); + } + + $current = TMDO_Mode_Manager::get( $entity_type ); + WP_CLI::log( "Current {$entity_type} mode: {$current}" ); + WP_CLI::log( "Target {$entity_type} mode: {$new_mode}" ); + + $result = TMDO_Mode_Manager::set( $entity_type, $new_mode ); + if ( true !== $result ) { + $msg = $result instanceof \WP_Error ? $result->get_error_message() : 'Unknown error'; + WP_CLI::error( "Mode promotion failed: {$msg}" ); + } + + WP_CLI::success( "Promoted {$entity_type} mode {$current} → {$new_mode}" ); + + // Hint next step. + $advice = self::next_promotion_hint( $new_mode ); + if ( '' !== $advice ) { + WP_CLI::log( '' ); + WP_CLI::log( $advice ); + } + } + + /** + * Suggest the next manual step after a mode promotion. + * + * @param string $new_mode Just-set mode. + * @return string + */ + private static function next_promotion_hint( string $new_mode ): string { + switch ( $new_mode ) { + case 'dual_write': + return 'Next: observe write paths for ≥ 24h, then promote to shadow_read with `-promote-mode shadow_read`.'; + case 'shadow_read': + return 'Next: run `wp wpdo term-comment-shadow-report` periodically; verify drift_total = 0 over 24h, then promote to aeav_only.'; + case 'aeav_only': + return 'Next: monitor wp_termmeta / wp_commentmeta row growth — should be 0 for managed keys. Cleanup historical rows with `termmeta-cleanup` / `commentmeta-cleanup`.'; + default: + return ''; + } + } + + // ── term-comment-diagnose (v2.12.5 Phase 5) ─────────────────────────────── + + /** + * Read-only diagnostic report of term + comment entity bridge state. + * + * Shows current modes, ratio, registered group keys, flat table row counts. + * + * ## EXAMPLES + * + * wp wpdo term-comment-diagnose + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments (unused). + */ + public function term_comment_diagnose( $args, $assoc_args ): void { + unset( $args, $assoc_args ); + global $wpdb; + + $term_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'unavailable'; + $comment_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'unavailable'; + + $terms_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" ); + $termmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" ); + $comments_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" ); + $commentmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" ); + + WP_CLI::log( '── Term entity ──' ); + 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_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' ); + + WP_CLI::log( '' ); + WP_CLI::log( '── Comment entity ──' ); + 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_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)' ); + + WP_CLI::log( '' ); + WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) ); + } + + /** + * Recommend next ops action based on current modes. + * + * @param string $term_mode Term entity mode. + * @param string $comment_mode Comment entity mode. + * @return string + */ + private static function recommend_next_action( string $term_mode, string $comment_mode ): string { + if ( 'aeav_only' === $term_mode && 'aeav_only' === $comment_mode ) { + return 'Both entities at aeav_only ✓. Run `termmeta-cleanup` / `commentmeta-cleanup` to drop historical wp_*meta rows; v3.0.0 will DROP the tables.'; + } + if ( 'shadow_read' === $term_mode || 'shadow_read' === $comment_mode ) { + return 'Run `term-comment-shadow-report` to verify drift = 0 over 24h before promoting to aeav_only.'; + } + if ( 'dual_write' === $term_mode || 'dual_write' === $comment_mode ) { + return 'Promote to shadow_read when ready: `term-promote-mode shadow_read` / `comment-promote-mode shadow_read`.'; + } + return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.'; + } + + /** + * Print a flat table's row count (or "(missing)" when not present). + * + * @param string $table Fully-qualified table name. + * @param string $label Human-readable name. + * @return void + */ + private static function print_flat_row( string $table, string $label ): void { + global $wpdb; + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( ! $exists ) { + WP_CLI::log( sprintf( '%-22s%s', $label . ':', '(missing)' ) ); + return; + } + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + WP_CLI::log( sprintf( '%-22s%s rows', $label . ':', number_format_i18n( $count ) ) ); + } + + /** + * Print a filter's enabled/disabled status. + * + * @param string $option_key Option storing the toggle value. + * @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' ) ); + } + + // ── term-comment-shadow-report (v2.12.5 Phase 5) ────────────────────────── + + /** + * Run the shadow verifier on demand and print a report. + * + * Uses a configurable sample size (default 100). Each registered term + + * comment entity group is sampled; per-group results are tabulated. + * + * ## OPTIONS + * + * [--samples=] + * : Number of entities to sample per group. Default: 100. + * + * ## EXAMPLES + * + * wp wpdo term-comment-shadow-report + * wp wpdo term-comment-shadow-report --samples=500 + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function term_comment_shadow_report( $args, $assoc_args ): void { + unset( $args ); + if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) { + WP_CLI::error( 'TMDO_Term_Comment_Shadow_Verifier not loaded.' ); + } + $samples = isset( $assoc_args['samples'] ) ? max( 1, (int) $assoc_args['samples'] ) : 100; + + WP_CLI::log( "Running shadow verifier (sample_size={$samples} per group)…" ); + WP_CLI::log( '' ); + + $results = TMDO_Term_Comment_Shadow_Verifier::run_all( $samples ); + if ( empty( $results ) ) { + WP_CLI::warning( 'No groups registered or registry unavailable.' ); + return; + } + + $rows = array(); + foreach ( $results as $group => $r ) { + if ( isset( $r['error'] ) ) { + $rows[] = array( + 'group' => $group, + 'entity' => '?', + 'sampled' => 0, + 'matched' => 0, + 'diffs' => 0, + 'missing_flat' => 0, + 'missing_meta' => 0, + 'note' => $r['error'], + ); + continue; + } + $rows[] = array( + 'group' => $r['group'], + 'entity' => $r['entity_type'], + 'sampled' => $r['sampled'], + 'matched' => $r['matched'], + 'diffs' => $r['diffs'], + 'missing_flat' => $r['missing_flat'], + 'missing_meta' => $r['missing_meta'], + 'note' => 0 === $r['diffs'] && 0 === $r['missing_flat'] && 0 === $r['missing_meta'] ? '✓ clean' : '⚠ drift', + ); + } + + WP_CLI\Utils\format_items( + 'table', + $rows, + array( 'group', 'entity', 'sampled', 'matched', 'diffs', 'missing_flat', 'missing_meta', 'note' ) + ); + + // Total drift summary. + $total_drift = 0; + foreach ( $results as $r ) { + if ( isset( $r['error'] ) ) { + continue; + } + $total_drift += $r['diffs'] + $r['missing_flat'] + $r['missing_meta']; + } + WP_CLI::log( '' ); + if ( 0 === $total_drift ) { + WP_CLI::success( 'Shadow verifier: 0 drift. Safe to promote shadow_read → aeav_only.' ); + } else { + WP_CLI::warning( "Shadow verifier: {$total_drift} drift. Investigate before promoting." ); + } + } + + // ── term-comment-backfill (v2.12.6 Phase 6) ─────────────────────────────── + + /** + * Backfill historical wp_termmeta / wp_commentmeta into flat tables. + * + * Pivot SQL one-shot per registered group. After v2.12.x activation, + * write-time interception covers new updates — but legacy rows that + * predate v2.12.x are still in wp_*meta only. This command reads them + * and INSERTs into the corresponding flat table (ON DUPLICATE KEY UPDATE + * with COALESCE to preserve any flat-only values). + * + * Safe to re-run: idempotent thanks to ON DUPLICATE KEY UPDATE. + * + * SAFETY: --dry-run / --confirm required (default refuses). + * + * ## OPTIONS + * + * [--group=] + * : Restrict to one group. Default: all. + * --- + * default: all + * options: + * - all + * - hp_taxonomy + * - hp_review + * --- + * + * [--dry-run] + * : Show candidate counts without writing. + * + * [--confirm] + * : Required to actually backfill. Mutually exclusive with --dry-run. + * + * ## EXAMPLES + * + * wp wpdo term-comment-backfill --dry-run + * wp wpdo term-comment-backfill --confirm + * wp wpdo term-comment-backfill --group=hp_taxonomy --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function term_comment_backfill( $args, $assoc_args ): void { + unset( $args ); + $group = isset( $assoc_args['group'] ) ? (string) $assoc_args['group'] : 'all'; + $dry_run = isset( $assoc_args['dry-run'] ); + $confirm = isset( $assoc_args['confirm'] ); + + if ( ! class_exists( 'TMDO_Term_Comment_Backfill' ) ) { + WP_CLI::error( 'TMDO_Term_Comment_Backfill not loaded.' ); + } + if ( $dry_run && $confirm ) { + WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' ); + } + if ( ! $dry_run && ! $confirm ) { + WP_CLI::error( + "Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" . + 'Example: wp wpdo term-comment-backfill --dry-run' + ); + } + + $valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' ); + if ( ! in_array( $group, $valid_groups, true ) ) { + WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) ); + } + + // Run backfill. + 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 ) ); + } + + // Render table. + $rows = array(); + $total_candidates = 0; + $total_written = 0; + foreach ( $results as $r ) { + $rows[] = array( + 'group' => $r['group'], + 'entity' => $r['entity_type'], + 'candidates' => $r['candidates'], + 'written' => $r['written'], + 'note' => $r['error'] ?? ( $r['dry_run'] ? '[dry-run]' : '✓' ), + ); + $total_candidates += (int) $r['candidates']; + $total_written += (int) $r['written']; + } + WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'entity', 'candidates', 'written', 'note' ) ); + + if ( class_exists( 'TMDO_Logger' ) && ! $dry_run ) { + TMDO_Logger::info( + 'term_comment_backfill', + array( + 'group' => $group, + 'total_candidates' => $total_candidates, + 'total_written' => $total_written, + ) + ); + } + + if ( $dry_run ) { + WP_CLI::success( + sprintf( + '[dry-run] %d candidate row(s) across %d group(s). Re-run with --confirm to apply.', + $total_candidates, + count( $results ) + ) + ); + } else { + WP_CLI::success( + sprintf( + 'Backfilled %d row(s) across %d group(s). Run `wp wpdo term-comment-shadow-report` to verify drift = 0.', + $total_written, + count( $results ) + ) + ); + } + } + + // ── comment-stress-test (v2.13.2) ───────────────────────────────────────── + + /** + * Run / inspect the Comment Stress Tester (CLI equivalent of admin tab). + * + * Wraps TMDO_Comment_Stress_Tester static API. Supports five subcommands: + * status / start / cancel / cleanup / benchmark. + * + * `start` runs synchronously: it loops `run_batch()` until the state machine + * reports `completed` / `failed` / `cancelled` (mirroring how the cron + REST + * polling drives the same state machine). For very large targets, prefer the + * admin tab so the work happens via WP-Cron and you can watch progress. + * + * ## OPTIONS + * + * + * : One of: status, start, cancel, cleanup, benchmark. + * + * [--post-id=] + * : Target post ID for `start` (comments will be attached to this post). + * + * [--target=] + * : Number of comments to create for `start`. Default: 100. + * + * [--mode=] + * : Write mode for `start`. Default: fast. + * --- + * default: fast + * options: + * - fast + * - realistic + * --- + * + * [--batch-size=] + * : Batch size for `start`. Default: 200 (fast) / 20 (realistic recommended). + * + * [--yes] + * : Skip cleanup confirmation prompt. + * + * ## EXAMPLES + * + * wp wpdo comment-stress-test status + * wp wpdo comment-stress-test start --post-id=50 --target=100 --mode=fast + * wp wpdo comment-stress-test start --post-id=50 --target=5 --mode=realistic --batch-size=5 + * wp wpdo comment-stress-test cancel + * wp wpdo comment-stress-test cleanup --yes + * wp wpdo comment-stress-test benchmark + * + * @param array $args Positional arguments — [subcommand]. + * @param array $assoc_args Named arguments. + */ + public function comment_stress_test( $args, $assoc_args ): void { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + WP_CLI::error( 'TMDO_Comment_Stress_Tester not loaded.' ); + } + if ( empty( $args[0] ) ) { + WP_CLI::error( 'Missing . One of: status, start, cancel, cleanup, benchmark.' ); + } + $sub = strtolower( (string) $args[0] ); + + switch ( $sub ) { + case 'status': + self::cst_status(); + return; + case 'start': + self::cst_start( $assoc_args ); + return; + case 'cancel': + self::cst_cancel(); + return; + case 'cleanup': + self::cst_cleanup( $assoc_args ); + return; + case 'benchmark': + self::cst_benchmark(); + return; + default: + WP_CLI::error( "Unknown subcommand '{$sub}'. One of: status, start, cancel, cleanup, benchmark." ); + } + } + + /** + * Print current state machine snapshot. + * + * @return void + */ + private static function cst_status(): void { + $progress = TMDO_Comment_Stress_Tester::get_progress( false ); + $rows = array( + array( + 'field' => 'status', + 'value' => (string) ( $progress['status'] ?? 'idle' ), + ), + array( + 'field' => 'post_id', + 'value' => (string) ( $progress['post_id'] ?? '—' ), + ), + array( + 'field' => 'mode', + 'value' => (string) ( $progress['mode'] ?? '—' ), + ), + array( + 'field' => 'processed', + 'value' => (string) ( $progress['processed'] ?? 0 ), + ), + array( + 'field' => 'target', + 'value' => (string) ( $progress['target'] ?? 0 ), + ), + array( + 'field' => 'pct', + 'value' => ( (string) ( $progress['pct'] ?? 0 ) ) . '%', + ), + array( + 'field' => 'rate_per_sec', + 'value' => (string) ( $progress['rate_per_sec'] ?? 0 ), + ), + array( + 'field' => 'elapsed_sec', + 'value' => (string) ( $progress['elapsed_sec'] ?? 0 ), + ), + array( + 'field' => 'eta_sec', + 'value' => (string) ( $progress['eta_sec'] ?? 0 ), + ), + array( + 'field' => 'batches_done', + 'value' => (string) ( $progress['batches_done'] ?? 0 ), + ), + array( + 'field' => 'test_comment_count', + 'value' => (string) ( $progress['test_comment_count'] ?? 0 ), + ), + ); + WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) ); + } + + /** + * Start a stress test run synchronously (loops run_batch until terminal). + * + * @param array $assoc_args CLI args (post-id / target / mode / batch-size). + * @return void + */ + private static function cst_start( array $assoc_args ): void { + $post_id = (int) ( $assoc_args['post-id'] ?? 0 ); + $target = (int) ( $assoc_args['target'] ?? 100 ); + $mode = (string) ( $assoc_args['mode'] ?? 'fast' ); + $batch = isset( $assoc_args['batch-size'] ) + ? (int) $assoc_args['batch-size'] + : ( 'realistic' === $mode ? 20 : TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE ); + + if ( $post_id < 1 ) { + WP_CLI::error( 'Missing or invalid --post-id (must be >= 1).' ); + } + if ( $target < 1 || $target > TMDO_Comment_Stress_Tester::MAX_COUNT ) { + WP_CLI::error( '--target must be between 1 and ' . TMDO_Comment_Stress_Tester::MAX_COUNT . '.' ); + } + + $result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch ); + if ( empty( $result['ok'] ) ) { + WP_CLI::error( 'Failed to start: ' . ( $result['error'] ?? 'unknown' ) ); + } + + WP_CLI::log( "Started: post_id={$post_id} target={$target} mode={$mode} batch_size={$batch}" ); + + // Drain the state machine synchronously (CLI equivalent of cron pump). + $max_loops = 5000; // Safety cap to avoid runaway loop on bad state. + $loops = 0; + do { + TMDO_Comment_Stress_Tester::run_batch(); + $progress = TMDO_Comment_Stress_Tester::get_progress( false ); + $status = (string) ( $progress['status'] ?? 'idle' ); + + if ( in_array( $status, array( 'completed', 'failed', 'cancelled' ), true ) ) { + break; + } + + WP_CLI::log( + sprintf( + ' batch %d done / processed %d / %d (%s%%) — %s', + (int) ( $progress['batches_done'] ?? 0 ), + (int) ( $progress['processed'] ?? 0 ), + (int) ( $progress['target'] ?? 0 ), + (string) ( $progress['pct'] ?? 0 ), + $status + ) + ); + + ++$loops; + } while ( $loops < $max_loops ); + + $final = TMDO_Comment_Stress_Tester::get_progress( false ); + WP_CLI::success( + sprintf( + 'Run %s — created %d / %d comment(s) in %d batch(es), %ss elapsed.', + (string) ( $final['status'] ?? 'unknown' ), + (int) ( $final['processed'] ?? 0 ), + (int) ( $final['target'] ?? 0 ), + (int) ( $final['batches_done'] ?? 0 ), + (string) ( $final['elapsed_sec'] ?? 0 ) + ) + ); + } + + /** + * Cancel an in-flight run. + * + * @return void + */ + private static function cst_cancel(): void { + $result = TMDO_Comment_Stress_Tester::cancel(); + if ( empty( $result['ok'] ) ) { + WP_CLI::error( 'Cancel failed: ' . ( $result['error'] ?? 'unknown' ) ); + } + if ( isset( $result['message'] ) && 'no_active_job' === $result['message'] ) { + WP_CLI::warning( 'No active job to cancel.' ); + return; + } + WP_CLI::success( 'Cancellation flagged. The next batch tick will mark state as cancelled.' ); + } + + /** + * Delete all stress-test comments + cascade. + * + * @param array $assoc_args CLI args (--yes to skip prompt). + * @return void + */ + private static function cst_cleanup( array $assoc_args ): void { + $count = TMDO_Comment_Stress_Tester::count_test_comments(); + if ( 0 === $count ) { + WP_CLI::success( 'No stress-test comments to clean up.' ); + return; + } + + if ( empty( $assoc_args['yes'] ) ) { + WP_CLI::confirm( + sprintf( + 'About to DELETE %d test comment(s) (email LIKE %%@%s) and their wp_commentmeta + flat-table rows. Continue?', + $count, + TMDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN + ) + ); + } + + $result = TMDO_Comment_Stress_Tester::cleanup(); + delete_option( TMDO_Comment_Stress_Tester::OPT_STATE ); + delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG ); + + WP_CLI::success( + sprintf( + 'Deleted %d comment(s), %d commentmeta row(s), %d flat row(s).', + (int) ( $result['deleted_comments'] ?? 0 ), + (int) ( $result['deleted_meta'] ?? 0 ), + (int) ( $result['deleted_flat'] ?? 0 ) + ) + ); + } + + /** + * Run benchmark report (write metrics + DB sizes + query perf). + * + * @return void + */ + private static function cst_benchmark(): void { + $state = TMDO_Comment_Stress_Tester::get_state(); + $report = TMDO_Comment_Stress_Tester::run_benchmark( $state ?: null ); + $query = $report['query'] ?? array(); + $db_sizes = $report['db_sizes'] ?? array(); + + WP_CLI::log( '▍ Query performance' ); + $rows = array(); + foreach ( $query as $key => $v ) { + $rows[] = array( + 'probe' => (string) $key, + 'duration_ms' => (string) ( $v['duration_ms'] ?? '—' ), + ); + } + WP_CLI\Utils\format_items( 'table', $rows, array( 'probe', 'duration_ms' ) ); + + WP_CLI::log( '▍ DB sizes (comment-related)' ); + $db_rows = array(); + foreach ( $db_sizes as $r ) { + $db_rows[] = array( + 'table' => (string) ( $r['table'] ?? '' ), + 'rows' => (string) ( $r['rows'] ?? 0 ), + 'data_mb' => (string) ( $r['data_mb'] ?? '—' ), + 'index_mb' => (string) ( $r['index_mb'] ?? '—' ), + 'total_mb' => (string) ( $r['total_mb'] ?? '—' ), + ); + } + WP_CLI\Utils\format_items( 'table', $db_rows, array( 'table', 'rows', 'data_mb', 'index_mb', 'total_mb' ) ); + + WP_CLI::success( 'Benchmark complete.' ); + } +} + +// ── Register subcommands ────────────────────────────────────────────────────── +// (Originally class closed here in v2.12.0; v2.12.5+ adds new methods inside.) + +WP_CLI::add_command( 'wpdo termmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'termmeta_cleanup' ) ); +WP_CLI::add_command( 'wpdo commentmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'commentmeta_cleanup' ) ); +WP_CLI::add_command( 'wpdo term-promote-mode', array( 'TMDO_CLI_Term_Comment', 'term_promote_mode' ) ); +WP_CLI::add_command( 'wpdo comment-promote-mode', array( 'TMDO_CLI_Term_Comment', 'comment_promote_mode' ) ); +WP_CLI::add_command( 'wpdo term-comment-diagnose', array( 'TMDO_CLI_Term_Comment', 'term_comment_diagnose' ) ); +WP_CLI::add_command( 'wpdo term-comment-shadow-report', array( 'TMDO_CLI_Term_Comment', 'term_comment_shadow_report' ) ); +WP_CLI::add_command( 'wpdo term-comment-backfill', array( 'TMDO_CLI_Term_Comment', 'term_comment_backfill' ) ); +WP_CLI::add_command( 'wpdo comment-stress-test', array( 'TMDO_CLI_Term_Comment', 'comment_stress_test' ) ); diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php new file mode 100644 index 0000000..3b18c9c --- /dev/null +++ b/cli/class-tmdo-cli-v2.php @@ -0,0 +1,795 @@ + — Toggle TMDO_Hook_Bus_Bridge. + * wp wpdo mode-audit — Per-module state + shadow flags. + * wp wpdo mode-set — Set FSM state. + * wp wpdo shadow-enable — Enable shadow_read_only sub-flag. + * wp wpdo shadow-disable — Disable shadow_read_only. + * wp wpdo conflict-scan — Run conflict monitor + emit JSON. + * wp wpdo lint --plugin= — Anti-EAV lint (Part C.2). + * + * Kept in a separate class so v1.x CLI commands stay untouched. + * + * @package WP_Data_Optimizer + * @since 2.0.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_CLI callbacks must accept ($args, $assoc_args) by signature; many subcommands ignore them. file_get_contents() reads local PHP files only — wp_remote_get N/A. + +if ( ! class_exists( 'WP_CLI' ) ) { + return; +} + +/** + * v2.0.0 CLI subcommands. Loaded only in WP_CLI context. + */ +class TMDO_CLI_V2 { + + /** + * Show Hook Bus status, conflict count, and dual_write progress per entity group. + * + * ## OPTIONS + * + * [--format=] + * : Output format for dual_write progress table. Options: table, json. Default: table. + * + * ## EXAMPLES + * + * wp wpdo bridge-status + * wp wpdo bridge-status --format=json + */ + public function bridge_status( $args, $assoc_args ): void { + global $wpdb; + + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $enabled = TMDO_Hook_Bus_Bridge::is_enabled(); + $summary = TMDO_Conflict_Monitor::get_summary(); + $status = $enabled ? 'enabled' : 'disabled'; + WP_CLI::log( "Hook Bus: {$status}" ); + WP_CLI::log( "Conflicts (total / hook_overlap / uaepg_overlap): {$summary['total']} / {$summary['hook_overlap']} / {$summary['uaepg_overlap']}" ); + if ( $summary['total'] > 0 ) { + WP_CLI::warning( 'Run `wp wpdo conflict-scan` for full report.' ); + } + + // dual_write progress: flat table rows vs EAV source rows. + $modes = TMDO_Mode_Manager::all(); + $rows = array(); + + // EAV source table per entity type. + $eav_tables = array( + 'post' => $wpdb->postmeta, + 'user' => $wpdb->usermeta, + 'term' => $wpdb->termmeta, + 'comment' => $wpdb->commentmeta, + ); + + foreach ( $modes as $entity_type => $mode ) { + if ( 'disabled' === $mode ) { + continue; + } + $groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type ); + if ( empty( $groups ) ) { + continue; + } + + foreach ( $groups as $group_name ) { + $flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + + // Check flat table existence. + $flat_exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table ) + ); + + $flat_count = $flat_exists + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) + : 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; + + if ( '' !== $eav_table && ! empty( $managed_keys ) ) { + $id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id"; + $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})"; + $eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + $pct = ( $eav_count > 0 ) ? round( $flat_count / $eav_count * 100, 1 ) : ( $flat_count > 0 ? 100.0 : 0.0 ); + $rows[] = array( + 'entity' => $entity_type, + 'group' => $group_name, + 'mode' => $mode, + 'flat_rows' => $flat_count, + 'eav_ids' => $eav_count, + 'progress_%' => $pct . '%', + ); + } + } + + if ( ! empty( $rows ) ) { + WP_CLI::log( '' ); + $format = $assoc_args['format'] ?? 'table'; + \WP_CLI\Utils\format_items( $format, $rows, array( 'entity', 'group', 'mode', 'flat_rows', 'eav_ids', 'progress_%' ) ); + } + } + + /** + * Toggle TMDO_Hook_Bus_Bridge feature flag. + * + * ## OPTIONS + * + * + * : on | off + * + * ## EXAMPLES + * + * wp wpdo bridge-set on + */ + public function bridge_set( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $state = strtolower( (string) ( $args[0] ?? '' ) ); + if ( 'on' !== $state && 'off' !== $state ) { + WP_CLI::error( 'Usage: wp wpdo bridge-set ' ); + } + update_option( TMDO_Hook_Bus_Bridge::OPTION, 'on' === $state ? '1' : '0' ); + TMDO_Hook_Bus_Bridge::reset_cache(); + WP_CLI::success( "Hook Bus: {$state}" ); + } + + /** + * Audit per-module state + shadow_read flags. + * + * ## EXAMPLES + * + * wp wpdo mode-audit + * wp wpdo mode-audit --format=json + * + * @when after_wp_load + */ + public function mode_audit( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $states = TMDO_Feature_Flags::all(); + $shadow = TMDO_Feature_Flags::all_shadow(); + + $rows = array(); + foreach ( $states as $module => $state ) { + $rows[] = array( + 'module' => $module, + 'state' => $state, + 'shadow_read' => empty( $shadow[ $module ] ) ? 'no' : 'yes', + ); + } + + $format = $assoc_args['format'] ?? 'table'; + \WP_CLI\Utils\format_items( $format, $rows, array( 'module', 'state', 'shadow_read' ) ); + } + + /** + * Set FSM state for a module. + * + * ## OPTIONS + * + * + * : Module name (e.g. hot_hp_listing) + * + * + * : One of idle, dual_write, backfill, verify, cutover, cleanup, complete + * + * ## EXAMPLES + * + * wp wpdo mode-set hot_hp_listing verify + */ + public function mode_set( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = (string) ( $args[0] ?? '' ); + $state = (string) ( $args[1] ?? '' ); + + if ( '' === $module || ! in_array( $state, TMDO_Feature_Flags::VALID_STATES, true ) ) { + WP_CLI::error( 'Usage: wp wpdo mode-set — state must be one of: ' . implode( ', ', TMDO_Feature_Flags::VALID_STATES ) ); + } + + $ok = TMDO_Feature_Flags::set( $module, $state ); + if ( ! $ok ) { + WP_CLI::error( 'Failed to update state' ); + } + WP_CLI::success( "{$module} → {$state}" ); + } + + /** + * Enable shadow_read_only sub-flag (only effective in verify state). + */ + public function shadow_enable( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = (string) ( $args[0] ?? '' ); + if ( '' === $module ) { + WP_CLI::error( 'Usage: wp wpdo shadow-enable ' ); + } + TMDO_Feature_Flags::enable_shadow_read( $module ); + $state = TMDO_Feature_Flags::get( $module ); + WP_CLI::success( "shadow_read enabled for {$module} (current state: {$state})" ); + if ( 'verify' !== $state ) { + WP_CLI::warning( 'shadow_read only takes effect when state == verify' ); + } + } + + /** + * Disable shadow_read_only sub-flag. + */ + public function shadow_disable( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = (string) ( $args[0] ?? '' ); + if ( '' === $module ) { + WP_CLI::error( 'Usage: wp wpdo shadow-disable ' ); + } + TMDO_Feature_Flags::disable_shadow_read( $module ); + WP_CLI::success( "shadow_read disabled for {$module}" ); + } + + /** + * Run conflict monitor scan + emit findings. + */ + public function conflict_scan( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + TMDO_Conflict_Monitor::reset_cache(); + $conflicts = TMDO_Conflict_Monitor::scan(); + + if ( empty( $conflicts ) ) { + WP_CLI::success( '0 conflicts detected.' ); + return; + } + + WP_CLI::warning( count( $conflicts ) . ' conflict(s) detected:' ); + + // Normalise heterogenous finding shapes to a uniform 6-column row so + // format_items() doesn't blow up on optional keys. + $rows = array_map( + static fn( array $f ) => array( + 'type' => $f['type'] ?? '', + 'hook' => $f['hook'] ?? '', + 'priority' => isset( $f['priority'] ) ? (string) $f['priority'] : '', + 'callback' => $f['callback'] ?? '', + 'entity_type' => $f['entity_type'] ?? '', + 'meta_key' => $f['meta_key'] ?? '', + ), + $conflicts + ); + + \WP_CLI\Utils\format_items( + $assoc_args['format'] ?? 'table', + $rows, + array( 'type', 'hook', 'priority', 'callback', 'entity_type', 'meta_key' ) + ); + } + + /** + * Anti-EAV strict lint for a partner plugin. + * + * Scans the target plugin / theme directory for: + * 1. Direct SELECT FROM wp_postmeta / wp_usermeta / wp_termmeta + * 2. update_post_meta() on fields registered to WPDO + * 3. autoload=yes wp_options exceeding the per-plugin cap (default 30) + * 4. meta_query with ≥3 conditions but no wpdo_register_fields + * + * Returns non-zero exit when --strict is set and findings exist (CI gate). + * + * ## OPTIONS + * + * --plugin= + * : Absolute path to plugin directory. + * + * [--strict] + * : Exit non-zero on any finding. + * + * [--max-autoload=] + * : Soft cap on autoload=yes options per plugin (default 30). + * + * ## EXAMPLES + * + * wp wpdo lint --plugin=/var/www/.../2meet-infocards --strict + */ + public function lint( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $plugin_path = (string) ( $assoc_args['plugin'] ?? '' ); + $strict = ! empty( $assoc_args['strict'] ); + $max_autoload = (int) ( $assoc_args['max-autoload'] ?? 30 ); + + if ( '' === $plugin_path || ! is_dir( $plugin_path ) ) { + WP_CLI::error( '--plugin= required and must be a directory' ); + } + + $findings = self::lint_directory( $plugin_path, $max_autoload ); + + if ( empty( $findings ) ) { + WP_CLI::success( "Anti-EAV lint passed for {$plugin_path}" ); + return; + } + + WP_CLI::warning( count( $findings ) . ' Anti-EAV violation(s) in ' . $plugin_path ); + foreach ( $findings as $f ) { + WP_CLI::log( sprintf( ' [%s] %s:%d — %s', $f['rule'], $f['file'], $f['line'], $f['message'] ) ); + } + + if ( $strict ) { + WP_CLI::error( 'Lint failed — strict mode enabled' ); + } + } + + /** + * Walk a directory tree and apply Anti-EAV lint rules to every PHP file. + * + * Static so it can be unit-tested without WP_CLI runtime. + * + * @param string $plugin_path Absolute directory path. + * @param int $max_autoload Per-plugin autoload cap. + * @return array + */ + public static function lint_directory( string $plugin_path, int $max_autoload = 30 ): array { + $findings = array(); + $plugin_path = rtrim( $plugin_path, '/' ); + + $skip_dirs = array( 'vendor', 'node_modules', 'tests', '.git', '.github' ); + + $it = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( $plugin_path, FilesystemIterator::SKIP_DOTS ), + static function ( $current ) use ( $skip_dirs ) { + if ( $current->isDir() && in_array( $current->getFilename(), $skip_dirs, true ) ) { + return false; + } + return true; + } + ) + ); + + // Patterns: regex => { rule, message }. + // Match any 'postmeta' / 'usermeta' / etc. token after SELECT ... FROM, + // regardless of how the table prefix is constructed (literal, $wpdb->prefix + // concatenation, {$wpdb->prefix} interpolation, sprintf, etc.). + $patterns = array( + '/\bSELECT\b[^;]*\bFROM\s+\S*postmeta/i' => array( + 'rule' => 'no-direct-postmeta-select', + 'message' => 'Direct SELECT FROM postmeta — use TMDO_API::query() or TMDO_API::get_field()', + ), + '/\bSELECT\b[^;]*\bFROM\s+\S*usermeta/i' => array( + 'rule' => 'no-direct-usermeta-select', + 'message' => 'Direct SELECT FROM usermeta — use TMDO_API::get_entity()', + ), + '/\bSELECT\b[^;]*\bFROM\s+\S*termmeta/i' => array( + 'rule' => 'no-direct-termmeta-select', + 'message' => 'Direct SELECT FROM termmeta — use TMDO_API::get_entity()', + ), + '/\bSELECT\b[^;]*\bFROM\s+\S*commentmeta/i' => array( + 'rule' => 'no-direct-commentmeta-select', + 'message' => 'Direct SELECT FROM commentmeta — use TMDO_API::get_entity()', + ), + "/'autoload'\s*=>\s*'yes'/" => array( + 'rule' => 'autoload-yes', + 'message' => 'autoload=yes — keep per-plugin total ≤ ' . $max_autoload, + ), + ); + + foreach ( $it as $file ) { + if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + + // Skip files that explicitly opt out. + $source = (string) file_get_contents( $file->getPathname() ); + if ( str_contains( $source, 'phpcs:ignore WPDO.AntiEAV' ) ) { + continue; + } + + $lines = explode( "\n", $source ); + foreach ( $lines as $i => $line ) { + // Skip lines marked with phpcs:ignore WPDO.AntiEAV. on the same line. + if ( str_contains( $line, 'phpcs:ignore WPDO.AntiEAV' ) ) { + continue; + } + foreach ( $patterns as $regex => $meta ) { + if ( preg_match( $regex, $line ) ) { + $findings[] = array( + 'rule' => $meta['rule'], + 'file' => $file->getPathname(), + 'line' => $i + 1, + 'message' => $meta['message'], + ); + } + } + } + } + + return $findings; + } + + // ─── snapshot subcommands (v2.2.0 M1) ───────────────────────────────── + + /** + * Create a snapshot. + * + * ## OPTIONS + * + * [--trigger=] + * : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall. + * --- + * default: manual + * --- + * + * [--scope-tables=] + * : Comma-separated table list to dump. Empty = all WPDO tables. + * + * [--scope-entities=] + * : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump. + * + * [--notes=] + * : Free-form note for the catalog. + * + * [--retention-days=] + * : Snapshot expiry. Default 30. 0 disables. + * + * ## EXAMPLES + * + * wp wpdo snapshot create --trigger=manual --notes="before reviews backfill" + * wp wpdo snapshot create --scope-tables=wp_wpdo_warm,wp_wpdo_archive + */ + public function snapshot_create( $args, $assoc_args ): void { + $trigger = (string) ( $assoc_args['trigger'] ?? 'manual' ); + $scope = array( + 'tables' => self::csv_to_array( (string) ( $assoc_args['scope-tables'] ?? '' ) ), + 'entities' => self::csv_to_array( (string) ( $assoc_args['scope-entities'] ?? '' ) ), + ); + $opts = array( + 'notes' => (string) ( $assoc_args['notes'] ?? '' ), + 'retention_days' => isset( $assoc_args['retention-days'] ) ? (int) $assoc_args['retention-days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, + ); + $result = TMDO_Snapshot_Manager::create( $trigger, $scope, $opts ); + if ( empty( $result['ok'] ) ) { + WP_CLI::error( 'snapshot create failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) ); + } + WP_CLI::success( + sprintf( + 'snapshot %s created (%d bytes, %d rows, storage=%s)', + $result['snapshot_id'], + $result['size_bytes'], + $result['row_count'], + $result['storage'] + ) + ); + } + + /** + * List recent snapshots. + * + * ## OPTIONS + * + * [--trigger=] + * : Filter by trigger type. + * + * [--limit=] + * : Default 50. + * + * [--format=] + * : table|json|csv|yaml. Default table. + * + * ## EXAMPLES + * + * wp wpdo snapshot list + * wp wpdo snapshot list --trigger=pre_fsm_transition --format=json + */ + public function snapshot_list( $args, $assoc_args ): void { + $limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 50; + $trigger = isset( $assoc_args['trigger'] ) ? (string) $assoc_args['trigger'] : null; + $rows = TMDO_Snapshot_Manager::list_recent( $limit, $trigger ); + $format = (string) ( $assoc_args['format'] ?? 'table' ); + $display_keys = array( 'snapshot_id', 'trigger_type', 'size_bytes', 'row_count', 'storage', 'created_at', 'expires_at' ); + \WP_CLI\Utils\format_items( $format, $rows, $display_keys ); + } + + /** + * Restore a snapshot. Default is dry-run (preview only). + * + * ## OPTIONS + * + * + * : Snapshot ULID (e.g. wpdo_xyz_abc). + * + * [--apply] + * : Actually run the restore (DELETE + INSERT). Without this flag, only preview. + * + * ## EXAMPLES + * + * wp wpdo snapshot restore wpdo_xyz_abc # preview + * wp wpdo snapshot restore wpdo_xyz_abc --apply # destructive! + */ + public function snapshot_restore( $args, $assoc_args ): void { + $snapshot_id = (string) ( $args[0] ?? '' ); + if ( '' === $snapshot_id ) { + WP_CLI::error( 'Usage: wp wpdo snapshot restore [--apply]' ); + } + $apply = isset( $assoc_args['apply'] ); + $result = TMDO_Snapshot_Manager::restore( $snapshot_id, ! $apply ); + if ( empty( $result['ok'] ) ) { + WP_CLI::error( 'restore failed: ' . ( $result['error'] ?? 'unknown' ) . ( isset( $result['message'] ) ? ' — ' . $result['message'] : '' ) ); + } + if ( ! $apply ) { + $preview = $result['preview']; + WP_CLI::log( sprintf( 'PREVIEW (dry-run): %d statements, %d total rows, %d bytes', $preview['statements'], $preview['total_rows'], $preview['sql_bytes'] ) ); + foreach ( $preview['tables'] as $t => $n ) { + WP_CLI::log( " {$t}: {$n} rows" ); + } + WP_CLI::warning( 'No changes applied. Re-run with --apply to actually restore.' ); + return; + } + $applied = $result['restored']; + WP_CLI::success( + sprintf( + 'Restored %d statements, %d rows across %d tables', + $applied['statements_run'], + $applied['rows_restored'], + count( $applied['tables'] ) + ) + ); + } + + /** + * Prune expired or over-cap snapshots. + * + * ## OPTIONS + * + * [--days=] + * : Older-than threshold (informational; actual TTL stored in row). + * --- + * default: 30 + * --- + * + * [--size-cap-mb=] + * : Backup directory cap (MB). 0 disables. + * --- + * default: 1024 + * --- + * + * ## EXAMPLES + * + * wp wpdo snapshot prune + * wp wpdo snapshot prune --size-cap-mb=2048 + */ + public function snapshot_prune( $args, $assoc_args ): void { + $days = isset( $assoc_args['days'] ) ? (int) $assoc_args['days'] : TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS; + $capmb = isset( $assoc_args['size-cap-mb'] ) ? (int) $assoc_args['size-cap-mb'] : 1024; + $cap = $capmb * 1024 * 1024; + $res = TMDO_Snapshot_Manager::prune( $days, $cap ); + WP_CLI::log( + sprintf( + 'Pruned %d (TTL=%d, sizecap=%d), freed %s, errors=%d', + $res['pruned'], + $res['ttl_pruned'] ?? 0, + $res['sizecap_pruned'] ?? 0, + size_format( (int) $res['freed_bytes'], 2 ), + count( $res['errors'] ) + ) + ); + if ( ! empty( $res['errors'] ) ) { + foreach ( $res['errors'] as $err ) { + WP_CLI::warning( $err ); + } + } + } + + /** + * Verify a snapshot's sha256 + readability. + * + * ## OPTIONS + * + * + * : Snapshot ULID. + */ + public function snapshot_verify( $args, $assoc_args ): void { + $snapshot_id = (string) ( $args[0] ?? '' ); + if ( '' === $snapshot_id ) { + WP_CLI::error( 'Usage: wp wpdo snapshot verify ' ); + } + $result = TMDO_Snapshot_Manager::verify( $snapshot_id ); + if ( empty( $result['ok'] ) ) { + WP_CLI::error( 'verify failed: ' . ( $result['error'] ?? 'unknown' ) ); + } + WP_CLI::success( + sprintf( + 'OK — sha256=%s size=%s storage=%s', + $result['sha256_ok'] ? '✓' : '✗', + $result['size_match'] ? '✓' : '✗', + $result['storage'] + ) + ); + } + + /** + * Helper: split comma-separated input into a clean array. + * + * @param string $csv Comma-separated input. + * @return array + */ + private static function csv_to_array( string $csv ): array { + if ( '' === $csv ) { + return array(); + } + return array_values( array_filter( array_map( 'trim', explode( ',', $csv ) ), 'strlen' ) ); + } + + // ── crypto-status / crypto-migrate (v2.15.0) ────────────────────────────── + + /** + * Show ciphertext format breakdown for `wpdo_*` options. + * + * Counts wp_options entries whose option_name starts with `wpdo_` and + * classifies each by storage format: v2 (AES-256-GCM, current), v1 + * (AES-256-CBC, legacy), plaintext, or empty. + * + * Use this to verify the v1→v2 migration ran successfully (expect + * `v1=0, v2>=count(secrets)`). + * + * ## OPTIONS + * + * [--prefix=] + * : Option name prefix to scan. Default: wpdo_. + * + * ## EXAMPLES + * + * wp wpdo crypto-status + * wp wpdo crypto-status --prefix=wpdo_ + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + * @return void + */ + public function crypto_status( $args, $assoc_args ): void { + unset( $args ); + if ( ! class_exists( 'TMDO_Crypto' ) ) { + WP_CLI::error( 'TMDO_Crypto class not loaded.' ); + } + + global $wpdb; + $prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' ); + + $option_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC", + $wpdb->esc_like( $prefix ) . '%' + ) + ); + + $counts = array( + 'v2' => 0, + 'v1' => 0, + 'plaintext' => 0, + 'empty' => 0, + ); + $rows = array(); + foreach ( (array) $option_names as $name ) { + $fmt = TMDO_Crypto::format_version( $name ); + ++$counts[ $fmt ]; + $rows[] = array( + 'option_name' => $name, + 'format' => $fmt, + ); + } + + WP_CLI\Utils\format_items( 'table', $rows, array( 'option_name', 'format' ) ); + WP_CLI::log( '' ); + WP_CLI::log( sprintf( 'Total scanned: %d', count( $rows ) ) ); + WP_CLI::log( sprintf( ' v2 (GCM): %d', $counts['v2'] ) ); + WP_CLI::log( sprintf( ' v1 (CBC): %d', $counts['v1'] ) ); + WP_CLI::log( sprintf( ' plaintext: %d', $counts['plaintext'] ) ); + WP_CLI::log( sprintf( ' empty: %d', $counts['empty'] ) ); + + if ( $counts['v1'] > 0 ) { + WP_CLI::warning( + sprintf( + '%d v1 ciphertext(s) detected. Run `wp wpdo crypto-migrate` to upgrade to v2 GCM.', + $counts['v1'] + ) + ); + } else { + WP_CLI::success( 'No v1 ciphertext remaining — all encrypted secrets are GCM-authenticated.' ); + } + } + + /** + * Migrate v1 (AES-256-CBC) ciphertext to v2 (AES-256-GCM) under `wpdo_*` options. + * + * Idempotent: already-v2, plaintext, and empty options are skipped without error. + * + * ## OPTIONS + * + * [--prefix=] + * : Option name prefix to scan. Default: wpdo_. + * + * [--dry-run] + * : Show what would change without applying. + * + * ## EXAMPLES + * + * wp wpdo crypto-migrate --dry-run + * wp wpdo crypto-migrate + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + * @return void + */ + public function crypto_migrate( $args, $assoc_args ): void { + unset( $args ); + if ( ! class_exists( 'TMDO_Crypto' ) ) { + WP_CLI::error( 'TMDO_Crypto class not loaded.' ); + } + + $prefix = (string) ( $assoc_args['prefix'] ?? 'wpdo_' ); + $dry_run = isset( $assoc_args['dry-run'] ); + + if ( $dry_run ) { + global $wpdb; + $option_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( $prefix ) . '%' + ) + ); + $v1_count = 0; + foreach ( (array) $option_names as $name ) { + if ( 'v1' === TMDO_Crypto::format_version( $name ) ) { + ++$v1_count; + } + } + WP_CLI::success( sprintf( '[dry-run] %d v1 option(s) would be migrated. Re-run without --dry-run to apply.', $v1_count ) ); + return; + } + + $counts = TMDO_Crypto::migrate_v1_to_v2( $prefix ); + + WP_CLI::log( sprintf( 'Scanned: %d', $counts['scanned'] ) ); + WP_CLI::log( sprintf( 'Migrated: %d', $counts['migrated'] ) ); + WP_CLI::log( sprintf( 'Already v2: %d', $counts['already_v2'] ) ); + WP_CLI::log( sprintf( 'Plaintext: %d', $counts['plaintext'] ) ); + WP_CLI::log( sprintf( 'Empty: %d', $counts['empty'] ) ); + WP_CLI::log( sprintf( 'Failed: %d', $counts['failed'] ) ); + + if ( $counts['failed'] > 0 ) { + foreach ( $counts['errors'] as $option_name => $reason ) { + WP_CLI::warning( sprintf( ' %s → %s', $option_name, $reason ) ); + } + WP_CLI::error( sprintf( '%d migration(s) failed; see warnings above.', $counts['failed'] ) ); + } + + // Set the migrated flag so installer's auto-migration won't re-scan. + update_option( 'wpdo_crypto_migrated_v2', '1', false ); + + WP_CLI::success( sprintf( 'Migrated %d option(s) from v1 (CBC) to v2 (GCM).', $counts['migrated'] ) ); + } +} + +WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) ); +WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); +WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); +WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); +WP_CLI::add_command( 'wpdo shadow-enable', array( 'TMDO_CLI_V2', 'shadow_enable' ) ); +WP_CLI::add_command( 'wpdo shadow-disable', array( 'TMDO_CLI_V2', 'shadow_disable' ) ); +WP_CLI::add_command( 'wpdo conflict-scan', array( 'TMDO_CLI_V2', 'conflict_scan' ) ); +WP_CLI::add_command( 'wpdo lint', array( 'TMDO_CLI_V2', 'lint' ) ); +WP_CLI::add_command( 'wpdo snapshot create', array( 'TMDO_CLI_V2', 'snapshot_create' ) ); +WP_CLI::add_command( 'wpdo snapshot list', array( 'TMDO_CLI_V2', 'snapshot_list' ) ); +WP_CLI::add_command( 'wpdo snapshot restore', array( 'TMDO_CLI_V2', 'snapshot_restore' ) ); +WP_CLI::add_command( 'wpdo snapshot prune', array( 'TMDO_CLI_V2', 'snapshot_prune' ) ); +WP_CLI::add_command( 'wpdo snapshot verify', array( 'TMDO_CLI_V2', 'snapshot_verify' ) ); +WP_CLI::add_command( 'wpdo crypto-status', array( 'TMDO_CLI_V2', 'crypto_status' ) ); +WP_CLI::add_command( 'wpdo crypto-migrate', array( 'TMDO_CLI_V2', 'crypto_migrate' ) ); diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php new file mode 100644 index 0000000..c32b9a4 --- /dev/null +++ b/cli/class-tmdo-cli.php @@ -0,0 +1,1755 @@ + + */ +class TMDO_CLI { + + /** + * Show status of all modules and zones. + * + * ## EXAMPLES + * + * wp wpdo status + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand status + */ + public function status( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + WP_CLI::log( '=== WP Data Optimizer Status ===' ); + WP_CLI::log( '' ); + + WP_CLI::log( 'Database Engine: ' . ( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ) ); + WP_CLI::log( 'Plugin Version: ' . TMDO_VERSION ); + WP_CLI::log( 'DB Version: ' . get_option( 'wpdo_db_version', 'N/A' ) ); + WP_CLI::log( '' ); + + $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( '' ); + + $stats = TMDO_Schema_Registry::instance()->get_stats(); + WP_CLI::log( 'Registered Fields:' ); + WP_CLI::log( " Hot (A): {$stats['hot']}" ); + WP_CLI::log( " Warm (B): {$stats['warm']}" ); + WP_CLI::log( " Cold (C): {$stats['cold']}" ); + WP_CLI::log( " Archive (D): {$stats['archive']}" ); + WP_CLI::log( " Total: {$stats['total']}" ); + WP_CLI::log( '' ); + + 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 ) { + WP_CLI::log( " {$module}: {$state}" ); + } + } + + $zone_flags = TMDO_Feature_Flags::zone_modules(); + if ( ! empty( $zone_flags ) ) { + WP_CLI::log( ' [Zone Modules]' ); + foreach ( $zone_flags as $module => $state ) { + WP_CLI::log( " {$module}: {$state}" ); + } + } + + WP_CLI::success( 'Status complete.' ); + } + + /** + * Install or upgrade database tables. + * + * ## EXAMPLES + * + * wp wpdo install + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand install + */ + public function install( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + WP_CLI::log( 'Installing WPDO tables...' ); + TMDO_Installer::install(); + WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) ); + } + + /** + * Run health check on all tables. + * + * ## EXAMPLES + * + * wp wpdo doctor + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand doctor + */ + public function doctor( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + global $wpdb; + + WP_CLI::log( 'Running health check...' ); + + $required_tables = array( + 'wpdo_migrations', + 'wpdo_errors', + 'wpdo_benchmarks', + 'wpdo_warm', + 'wpdo_archive', + ); + + $all_ok = true; + + foreach ( $required_tables as $table ) { + $full_name = $wpdb->prefix . $table; + + if ( TMDO_IS_SQLITE ) { + $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) ); + } else { + $exists = $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name ) + ); + } + + if ( $exists ) { + WP_CLI::log( " [OK] {$full_name}" ); + } else { + WP_CLI::warning( " [MISSING] {$full_name}" ); + $all_ok = false; + } + } + + // Check dynamic zone tables. + $registry = TMDO_Schema_Registry::instance(); + $hot_types = $registry->get_hot_post_types(); + foreach ( $hot_types as $pt ) { + if ( '' === $pt ) { + // Skip the bogus empty-string post_type bucket (legacy artifact pre-v2.1.2 normalization). + continue; + } + $t = TMDO_Zone_Hot::table( $pt ); + 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 ) { + WP_CLI::log( " [NOT CREATED] {$t} (will be created on first use)" ); + continue; + } + + // v2.1.2 doctor column-drift check: compare DB columns vs Schema_Registry declared columns. + $declared = array_keys( $registry->get_hot_columns( $pt ) ); + $existing = array(); + if ( TMDO_IS_MYSQL ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = $wpdb->get_col( "SHOW COLUMNS FROM `{$t}`" ); + $existing = is_array( $rows ) ? $rows : array(); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = $wpdb->get_results( "PRAGMA table_info(`{$t}`)", ARRAY_A ); + foreach ( (array) $rows as $row ) { + $existing[] = $row['name']; + } + } + $missing = array_diff( $declared, $existing ); + if ( empty( $missing ) ) { + WP_CLI::log( " [OK] {$t}" ); + } else { + WP_CLI::warning( " [DRIFT] {$t} — missing columns: " . implode( ', ', $missing ) . ' (run a write to trigger ensure_hot_columns auto-fix)' ); + $all_ok = false; + } + } + + $cold_types = $registry->get_cold_post_types(); + foreach ( $cold_types as $pt ) { + $t = TMDO_Zone_Cold::table( $pt ); + 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 ) ); + } + 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." ); + $all_ok = false; + } + } + + // Check partner plugin custom tables via Custom Table Registry (v2.6.0+). + if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { + $ctr = TMDO_Custom_Table_Registry::instance(); + $tables = $ctr->all(); + + if ( ! empty( $tables ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Partner plugin custom tables (' . count( $tables ) . ' registered):' ); + + $current_provider = ''; + foreach ( $tables as $cfg ) { + $provider = $cfg['provider']; + $tbl_raw = $cfg['table_name']; + $full_name = $wpdb->prefix . $tbl_raw; + + if ( $provider !== $current_provider ) { + WP_CLI::log( " [{$provider}]" ); + $current_provider = $provider; + } + + // Existence check. + if ( TMDO_IS_SQLITE ) { + $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) ); + } else { + $exists = $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name ) + ); + } + + if ( ! $exists ) { + WP_CLI::warning( " [MISSING] {$full_name}" ); + $all_ok = false; + continue; + } + + // Row count. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_name}`" ); + + // Optional doctor_callback. + // 傳入 table_suffix + rows + full_name 作為 context;多餘參數對 callable 無害。 + $cb = $cfg['doctor_callback'] ?? null; + if ( is_callable( $cb ) ) { + try { + $result = call_user_func( $cb, $cfg['table_name'], $rows, $full_name ); + $cb_ok = (bool) ( $result['ok'] ?? true ); + $cb_msg = (string) ( $result['message'] ?? '' ); + $status = $cb_ok ? '[OK]' : '[WARN]'; + $detail = '' !== $cb_msg ? " — {$cb_msg}" : ''; + $line = " {$status} {$full_name} ({$rows} rows){$detail}"; + $cb_ok ? WP_CLI::log( $line ) : WP_CLI::warning( $line ); + } catch ( \Throwable $e ) { + WP_CLI::warning( " [WARN] {$full_name} ({$rows} rows) — doctor_callback threw: " . $e->getMessage() ); + } + } else { + WP_CLI::log( " [OK] {$full_name} ({$rows} rows)" ); + } + } + } + } + + // Check recent errors. + $errors = TMDO_Logger::get_recent( '', 5 ); + if ( ! empty( $errors ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Recent errors:' ); + foreach ( $errors as $err ) { + WP_CLI::log( " [{$err['module']}] {$err['message']} ({$err['created_at']})" ); + } + } + + if ( $all_ok ) { + WP_CLI::success( 'All checks passed.' ); + } else { + WP_CLI::warning( 'Some checks failed. Run `wp wpdo install` to fix.' ); + } + } + + /** + * Analyze postmeta and suggest zone classifications. + * + * ## OPTIONS + * + * [--post-type=] + * : Post type to analyze. If omitted, analyzes hp_listing. + * + * [--format=] + * : Output format (table or json). Default: table. + * + * ## EXAMPLES + * + * wp wpdo analyze --post-type=hp_listing + * wp wpdo analyze --post-type=hp_vendor --format=json + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments (post-type, format). + * @return void + * + * @subcommand analyze + */ + public function analyze( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' ); + $format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json' ), true ) ? $assoc_args['format'] : 'table'; + + WP_CLI::log( "Analyzing postmeta for post type: {$post_type}..." ); + + $suggestions = TMDO_Zone_Classifier::analyze( $post_type ); + + if ( empty( $suggestions ) ) { + WP_CLI::warning( "No postmeta found for post type '{$post_type}'." ); + return; + } + + if ( 'json' === $format ) { + WP_CLI::log( wp_json_encode( $suggestions, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) ); + return; + } + + $table_data = array(); + foreach ( $suggestions as $s ) { + $table_data[] = array( + 'meta_key' => $s['meta_key'], + 'rows' => $s['row_count'], + 'suggested' => $s['suggested_zone'], + 'confidence' => $s['confidence'], + 'assigned' => $s['already_assigned'] ?: '—', + 'reason' => implode( '; ', $s['reasons'] ), + ); + } + + WP_CLI\Utils\format_items( 'table', $table_data, array( 'meta_key', 'rows', 'suggested', 'confidence', 'assigned', 'reason' ) ); + + $summary = TMDO_Zone_Classifier::summary( $post_type ); + WP_CLI::log( '' ); + WP_CLI::log( "Summary: Hot={$summary['hot']}, Warm={$summary['warm']}, Cold={$summary['cold']}, Archive={$summary['archive']}, Already assigned={$summary['already_assigned']}" ); + WP_CLI::success( 'Analysis complete.' ); + } + + /** + * Run data migration for a module. + * + * ## OPTIONS + * + * + * : Module name (e.g., hot_hp_listing, warm, cold_hp_vendor, archive). + * + * [--resume] + * : Resume an interrupted migration instead of starting fresh. + * + * ## EXAMPLES + * + * wp wpdo migrate hot_hp_listing + * wp wpdo migrate warm --resume + * wp wpdo migrate archive + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments (resume flag). + * @return void + * + * @subcommand migrate + */ + public function migrate( $args, $assoc_args ): void { + $module = sanitize_key( $args[0] ?? '' ); + $resume = isset( $assoc_args['resume'] ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required. Examples: hot_hp_listing, warm, cold_hp_vendor, archive' ); + } + + $migration = self::get_migration_instance( $module ); + if ( ! $migration ) { + WP_CLI::error( "Unknown module: {$module}" ); + } + + WP_CLI::log( "Starting migration for module: {$module}" . ( $resume ? ' (resuming)' : '' ) ); + + $progress = null; + $completed = $migration->run( + $resume, + function ( $processed, $total ) use ( &$progress ) { + if ( null === $progress && $total > 0 ) { + $progress = \WP_CLI\Utils\make_progress_bar( 'Migrating', $total ); + } + if ( $progress ) { + $progress->tick(); + } + } + ); + + if ( $progress ) { + $progress->finish(); + } + + if ( $completed ) { + WP_CLI::success( "Migration complete for {$module}. State: verify. Run `wp wpdo verify {$module}` next." ); + } else { + $record = $migration->get_record(); + $processed = $record ? (int) $record['processed_rows'] : 0; + $total = $record ? (int) $record['total_rows'] : 0; + WP_CLI::warning( "Migration timed out ({$processed}/{$total}). Run `wp wpdo migrate {$module} --resume` to continue." ); + } + } + + /** + * Verify data consistency for a module. + * + * ## OPTIONS + * + * + * : Module name. + * + * ## EXAMPLES + * + * wp wpdo verify hot_hp_listing + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand verify + */ + public function verify( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = sanitize_key( $args[0] ?? '' ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required.' ); + } + + $migration = self::get_migration_instance( $module ); + if ( ! $migration ) { + WP_CLI::error( "Unknown module: {$module}" ); + } + + WP_CLI::log( "Verifying data consistency for: {$module}..." ); + + if ( $migration->verify_counts() ) { + WP_CLI::success( "Verification passed for {$module}. Run `wp wpdo cutover {$module}` to switch reads." ); + } else { + WP_CLI::warning( "Verification failed — row counts don't match. Re-run migration or investigate." ); + } + } + + /** + * Switch reads to custom table (cutover). + * + * ## OPTIONS + * + * + * : Module name. + * + * ## EXAMPLES + * + * wp wpdo cutover hot_hp_listing + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand cutover + */ + public function cutover( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = sanitize_key( $args[0] ?? '' ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required.' ); + } + + $state = TMDO_Feature_Flags::get( $module ); + if ( ! in_array( $state, array( 'verify', 'dual_write', 'backfill' ), true ) ) { + WP_CLI::error( "Module {$module} is in state '{$state}' — cutover requires verify, dual_write, or backfill state." ); + } + + TMDO_Feature_Flags::set( $module, 'cutover' ); + WP_CLI::success( "Module {$module} switched to cutover. Reads now come from custom table." ); + } + + /** + * Rollback a module to idle (reads from postmeta). + * + * ## OPTIONS + * + * + * : Module name. + * + * ## EXAMPLES + * + * wp wpdo rollback hot_hp_listing + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand rollback + */ + public function rollback( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = sanitize_key( $args[0] ?? '' ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required.' ); + } + + WP_CLI::confirm( "Are you sure you want to rollback module '{$module}' to idle?" ); + + TMDO_Feature_Flags::reset( $module ); + WP_CLI::success( "Module {$module} rolled back to idle." ); + } + + /** + * Mark a module as complete (reads and writes on custom table only). + * + * ## OPTIONS + * + * + * : Module name. + * + * ## EXAMPLES + * + * wp wpdo enable hot_hp_listing + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand enable + */ + public function enable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = sanitize_key( $args[0] ?? '' ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required.' ); + } + + TMDO_Feature_Flags::set( $module, 'complete' ); + WP_CLI::success( "Module {$module} set to complete." ); + } + + /** + * Disable a module (set to idle). + * + * ## OPTIONS + * + * + * : Module name. + * + * ## EXAMPLES + * + * wp wpdo disable hot_hp_listing + * + * @param array $args Positional arguments (module name). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand disable + */ + public function disable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $module = sanitize_key( $args[0] ?? '' ); + + if ( empty( $module ) ) { + WP_CLI::error( 'Module name required.' ); + } + + TMDO_Feature_Flags::reset( $module ); + 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 + 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. + * + * ## OPTIONS + * + * [] + * : Optional module name to benchmark. + * + * [--samples=] + * : Number of samples. Default: 50. + * + * [--custom-tables] + * : Also benchmark all partner plugin custom tables registered in TMDO_Custom_Table_Registry. + * + * ## EXAMPLES + * + * wp wpdo benchmark + * wp wpdo benchmark hot_hp_listing --samples=100 + * wp wpdo benchmark --custom-tables + * + * @param array $args Positional arguments (optional module name). + * @param array $assoc_args Associative arguments (samples, custom-tables). + * @return void + * + * @subcommand benchmark + */ + public function benchmark( $args, $assoc_args ): void { + $module = sanitize_key( $args[0] ?? '' ); + $samples = absint( $assoc_args['samples'] ?? 50 ); + $custom_tables = ! empty( $assoc_args['custom-tables'] ); + + WP_CLI::log( 'Running benchmark' . ( $module ? " for {$module}" : '' ) . " ({$samples} samples)..." ); + + global $wpdb; + + // Benchmark postmeta reads vs zone reads. + $registry = TMDO_Schema_Registry::instance(); + $hot_types = $registry->get_hot_post_types(); + + if ( empty( $hot_types ) ) { + WP_CLI::warning( 'No hot zone post types registered. Register fields first.' ); + return; + } + + foreach ( $hot_types as $pt ) { + if ( $module && "hot_{$pt}" !== $module ) { + continue; + } + + $columns = $registry->get_hot_columns( $pt ); + if ( empty( $columns ) ) { + continue; + } + + // Get sample post IDs. + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d", + $pt, + $samples + ) + ); + + if ( empty( $post_ids ) ) { + WP_CLI::log( " {$pt}: No published posts found." ); + continue; + } + + $fields = $registry->get_zone_fields_for_type( 'hot', $pt ); + $first_field = reset( $fields ); + $meta_key = $first_field['meta_key']; + $column = $first_field['column']; + + // Native benchmark. + $start = microtime( true ); + foreach ( $post_ids as $pid ) { + get_post_meta( (int) $pid, $meta_key, true ); + } + $native_ms = ( microtime( true ) - $start ) * 1000; + + // Zone benchmark. + $start = microtime( true ); + foreach ( $post_ids as $pid ) { + TMDO_Zone_Hot::get( (int) $pid, $pt, $column ); + } + $zone_ms = ( microtime( true ) - $start ) * 1000; + + $speedup = $native_ms > 0 ? round( $native_ms / max( $zone_ms, 0.001 ), 1 ) : 'N/A'; + + WP_CLI::log( " hot_{$pt} ({$meta_key}):" ); + WP_CLI::log( ' Native (postmeta): ' . round( $native_ms, 2 ) . ' ms' ); + WP_CLI::log( ' Zone A (hot): ' . round( $zone_ms, 2 ) . ' ms' ); + WP_CLI::log( " Speedup: {$speedup}x" ); + + // Save to benchmarks table. + $bench_table = TMDO_DB::table( 'wpdo_benchmarks' ); + $wpdb->insert( + $bench_table, + array( + 'module' => "hot_{$pt}", + 'zone' => 'hot', + 'query_type' => 'single_read', + 'native_ms' => round( $native_ms, 3 ), + 'custom_ms' => round( $zone_ms, 3 ), + 'sample_size' => count( $post_ids ), + 'created_at' => TMDO_DB::now(), + ), + array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' ) + ); + } + + // ── Zone B (Warm) benchmark ───────────────────────────────────────────── + if ( ! $module || 'warm_hp_listing' === $module ) { + $warm_posts = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d", + 'hp_listing', + $samples + ) + ); + + if ( ! empty( $warm_posts ) ) { + // Seed warm zone entries so reads are non-trivial. + foreach ( $warm_posts as $pid ) { + TMDO_Zone_Warm::set( (int) $pid, TMDO_Listing_Stats::VIEW_KEY, '1', DAY_IN_SECONDS ); + } + + $start = microtime( true ); + foreach ( $warm_posts as $pid ) { + TMDO_Zone_Warm::get( (int) $pid, TMDO_Listing_Stats::VIEW_KEY ); + } + $warm_ms = ( microtime( true ) - $start ) * 1000; + + $start = microtime( true ); + foreach ( $warm_posts as $pid ) { + get_post_meta( (int) $pid, 'hp_view_count', true ); + } + $native_warm_ms = ( microtime( true ) - $start ) * 1000; + + $speedup = $native_warm_ms > 0 ? round( $native_warm_ms / max( $warm_ms, 0.001 ), 1 ) : 'N/A'; + + WP_CLI::log( '' ); + WP_CLI::log( ' warm_hp_listing (view count reads):' ); + WP_CLI::log( ' Native (postmeta): ' . round( $native_warm_ms, 2 ) . ' ms' ); + WP_CLI::log( ' Zone B (warm): ' . round( $warm_ms, 2 ) . ' ms' ); + WP_CLI::log( ' Speedup: ' . $speedup . 'x' ); + + $bench_table = TMDO_DB::table( 'wpdo_benchmarks' ); + $wpdb->insert( + $bench_table, + array( + 'module' => 'warm_hp_listing', + 'zone' => 'warm', + 'query_type' => 'view_count_read', + 'native_ms' => round( $native_warm_ms, 3 ), + 'custom_ms' => round( $warm_ms, 3 ), + 'sample_size' => count( $warm_posts ), + 'created_at' => TMDO_DB::now(), + ), + array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' ) + ); + } else { + WP_CLI::log( ' warm_hp_listing: No published hp_listing posts found.' ); + } + } + + // ── Zone C (Cold) benchmark ────────────────────────────────────────────── + $cold_types = $registry->get_cold_post_types(); + + foreach ( $cold_types as $pt ) { + if ( $module && "cold_{$pt}" !== $module ) { + continue; + } + + $cold_meta_keys = $registry->get_cold_meta_keys( $pt ); + if ( empty( $cold_meta_keys ) ) { + continue; + } + + $cold_post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d", + $pt, + $samples + ) + ); + + if ( empty( $cold_post_ids ) ) { + WP_CLI::log( " cold_{$pt}: No published posts found." ); + continue; + } + + // Zone C: one get_blob (JSON decode) vs N postmeta reads per post. + $start = microtime( true ); + foreach ( $cold_post_ids as $pid ) { + TMDO_Zone_Cold::get_blob( (int) $pid, $pt ); + } + $cold_ms = ( microtime( true ) - $start ) * 1000; + + $start = microtime( true ); + foreach ( $cold_post_ids as $pid ) { + foreach ( $cold_meta_keys as $key ) { + get_post_meta( (int) $pid, $key, true ); + } + } + $native_cold_ms = ( microtime( true ) - $start ) * 1000; + + $key_count = count( $cold_meta_keys ); + $speedup = $native_cold_ms > 0 ? round( $native_cold_ms / max( $cold_ms, 0.001 ), 1 ) : 'N/A'; + + WP_CLI::log( '' ); + WP_CLI::log( " cold_{$pt} ({$key_count} keys per post):" ); + WP_CLI::log( ' Native (postmeta): ' . round( $native_cold_ms, 2 ) . ' ms' ); + WP_CLI::log( ' Zone C (cold): ' . round( $cold_ms, 2 ) . ' ms' ); + WP_CLI::log( ' Speedup: ' . $speedup . 'x' ); + + $bench_table = TMDO_DB::table( 'wpdo_benchmarks' ); + $wpdb->insert( + $bench_table, + array( + 'module' => "cold_{$pt}", + 'zone' => 'cold', + 'query_type' => 'blob_read', + 'native_ms' => round( $native_cold_ms, 3 ), + 'custom_ms' => round( $cold_ms, 3 ), + 'sample_size' => count( $cold_post_ids ), + 'created_at' => TMDO_DB::now(), + ), + array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' ) + ); + } + + // ── Custom tables benchmark ─────────────────────────────────────── + if ( $custom_tables ) { + $this->benchmark_custom_tables( $samples ); + } + + WP_CLI::success( 'Benchmark complete. Results saved to wpdo_benchmarks table.' ); + } + + /** + * Benchmark all partner plugin custom tables from TMDO_Custom_Table_Registry. + * + * For tables that supply a `benchmark_callback`, the callback is invoked + * and its result used directly. For tables without a callback a built-in + * generic probe is run: COUNT(*) + a LIMIT-N sequential read. + * + * @param int $samples Number of rows to read in the sequential read probe. + * @return void + */ + private function benchmark_custom_tables( int $samples ): void { + global $wpdb; + + $registry = TMDO_Custom_Table_Registry::instance(); + $all = $registry->all(); + + if ( empty( $all ) ) { + WP_CLI::log( '' ); + WP_CLI::warning( 'No custom tables registered. Partner plugins may not be active.' ); + return; + } + + WP_CLI::log( '' ); + WP_CLI::log( sprintf( '── Custom table benchmark (%d tables, %d samples) ──', count( $all ), $samples ) ); + + $bench_table = TMDO_DB::table( 'wpdo_benchmarks' ); + $current_provider = ''; + + foreach ( $all as $key => $cfg ) { + $provider = (string) $cfg['provider']; + $table_name = (string) $cfg['table_name']; + $full_table = $wpdb->prefix . $table_name; + + if ( $provider !== $current_provider ) { + WP_CLI::log( '' ); + WP_CLI::log( " [{$provider}]" ); + $current_provider = $provider; + } + + // Check table exists. + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $full_table + ) + ); // phpcs:ignore WordPress.DB + if ( ! $exists ) { + WP_CLI::log( " {$table_name}: [MISSING]" ); + continue; + } + + $total_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB + + // Use partner-supplied callback when available. + if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) { + try { + $result = call_user_func( $cfg['benchmark_callback'], $samples ); + $duration = (float) ( $result['duration_ms'] ?? 0 ); + $sample_n = (int) ( $result['sample_size'] ?? $samples ); + $query_type = (string) ( $result['query_type'] ?? 'custom_callback' ); + WP_CLI::log( sprintf( ' %s: %s rows | callback %.2fms (%d samples)', $table_name, number_format( $total_rows ), $duration, $sample_n ) ); + } catch ( \Throwable $e ) { + WP_CLI::warning( " {$table_name}: benchmark_callback threw: " . $e->getMessage() ); + continue; + } + } else { + // Built-in generic probe: time a COUNT(*) + a sequential LIMIT read. + $probe_n = min( $samples, $total_rows ); + $query_type = 'generic_read'; + + // Count timing. + $start = microtime( true ); + $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB + $count_ms = ( microtime( true ) - $start ) * 1000; + + // Sequential read timing. + $start = microtime( true ); + $pk = sanitize_key( (string) ( $cfg['primary_key'] ?? 'id' ) ); + $wpdb->get_results( // phpcs:ignore WordPress.DB + $wpdb->prepare( "SELECT * FROM `{$full_table}` ORDER BY `{$pk}` LIMIT %d", $probe_n ) // phpcs:ignore WordPress.DB + ); + $read_ms = ( microtime( true ) - $start ) * 1000; + + $duration = $count_ms + $read_ms; + WP_CLI::log( + sprintf( + ' %s: %s rows | count %.2fms | read(%d rows) %.2fms', + $table_name, + number_format( $total_rows ), + $count_ms, + $probe_n, + $read_ms + ) + ); + } + + $wpdb->insert( + $bench_table, + array( + 'module' => "custom_{$table_name}", + 'zone' => 'custom', + 'query_type' => $query_type, + 'native_ms' => 0, + 'custom_ms' => round( $duration, 3 ), + 'sample_size' => $total_rows, + 'created_at' => TMDO_DB::now(), + ), + array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' ) + ); + } + } + + /** + * Show site-wide EAV health metrics (latest snapshot + optional history). + * + * ## OPTIONS + * + * [--collect] + * : Force-collect a fresh snapshot now (does not wait for cron). + * + * [--history=] + * : Show 30-day daily history for a specific metric key (e.g. eav.postmeta_rows). + * + * [--days=] + * : Number of days to show in history mode. Default: 30. + * + * [--format=] + * : Output format (table, json, csv). Default: table. + * + * ## EXAMPLES + * + * wp wpdo site-metrics + * wp wpdo site-metrics --collect + * wp wpdo site-metrics --history=eav.postmeta_rows --days=7 + * wp wpdo site-metrics --format=json + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand site-metrics + */ + public function site_metrics( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $do_collect = ! empty( $assoc_args['collect'] ); + // Allow dot notation (e.g. eav.postmeta_rows); sanitize without losing dots. + $history_key = isset( $assoc_args['history'] ) + ? preg_replace( '/[^a-z0-9._]/', '', strtolower( (string) $assoc_args['history'] ) ) + : ''; + $days = absint( $assoc_args['days'] ?? 30 ); + $format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json', 'csv' ), true ) + ? (string) ( $assoc_args['format'] ?? 'table' ) + : 'table'; + + if ( ! class_exists( 'TMDO_Site_Metrics_Collector' ) ) { + WP_CLI::error( 'TMDO_Site_Metrics_Collector not loaded. Upgrade to v2.6.2+.' ); + } + + if ( $do_collect ) { + WP_CLI::log( 'Collecting site metrics now...' ); + $metrics = TMDO_Site_Metrics_Collector::collect(); + WP_CLI::success( sprintf( 'Collected %d metrics.', count( $metrics ) ) ); + } + + if ( $history_key ) { + $rows = TMDO_Site_Metrics_Collector::get_history( $history_key, $days ); + if ( empty( $rows ) ) { + WP_CLI::warning( "No history for '{$history_key}' in the last {$days} days." ); + return; + } + $items = array_map( + static fn( $r ) => array( + 'date' => substr( (string) $r['collected_at'], 0, 10 ), + 'value' => $r['value'], + ), + $rows + ); + WP_CLI\Utils\format_items( $format, $items, array( 'date', 'value' ) ); + return; + } + + $snapshot = TMDO_Site_Metrics_Collector::get_latest_snapshot(); + if ( empty( $snapshot ) ) { + WP_CLI::warning( 'No metrics collected yet. Run: wp wpdo site-metrics --collect' ); + return; + } + + $items = array(); + foreach ( $snapshot as $key => $value ) { + $items[] = array( + 'metric' => $key, + 'value' => number_format( (int) $value ), + 'raw_value' => $value, + ); + } + WP_CLI\Utils\format_items( $format, $items, array( 'metric', 'value' ) ); + } + + /** + * Purge old error logs and optionally archive expired listing fields to Zone D. + * + * ## OPTIONS + * + * [--days=] + * : Delete logs older than N days. Default: 30. + * + * [--archive-expired] + * : Archive hot-zone fields of expired listings (hp_expired_time > 30 days ago) to Zone D. + * + * ## EXAMPLES + * + * wp wpdo cleanup + * wp wpdo cleanup --days=7 + * wp wpdo cleanup --archive-expired + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments (days, archive-expired). + * @return void + * + * @subcommand cleanup + */ + public function cleanup( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $days = absint( $assoc_args['days'] ?? 30 ); + $archive_expired = ! empty( $assoc_args['archive-expired'] ); + + WP_CLI::log( "Purging logs older than {$days} days..." ); + $deleted = TMDO_Logger::purge( $days ); + WP_CLI::log( "Deleted {$deleted} log entries." ); + + WP_CLI::log( 'Purging expired warm zone entries...' ); + $warm_deleted = TMDO_Zone_Warm::purge_expired(); + WP_CLI::log( "Deleted {$warm_deleted} expired warm entries." ); + + if ( $archive_expired ) { + WP_CLI::log( 'Archiving expired listing fields to Zone D...' ); + $archived = TMDO_Listing_Stats::archive_expired_listings(); + WP_CLI::log( "Archived {$archived} fields from expired listings." ); + + $stats = TMDO_Zone_Archive::stats(); + WP_CLI::log( "Zone D total: {$stats['total_rows']} rows, {$stats['compressed_rows']} compressed." ); + } + + WP_CLI::success( 'Cleanup complete.' ); + } + + // ── REST API health check ───────────────────────────────────────────── + + /** + * Health-check all /wp-json/wpdo/v1/ endpoints and report status. + * + * ## EXAMPLES + * + * wp wpdo rest-test + * wp wpdo rest-test --post-type=hp_vendor + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments (post-type). + * @return void + * + * @subcommand rest-test + */ + public function rest_test( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' ); + + WP_CLI::log( '=== WP Data Optimizer REST API Health Check ===' ); + WP_CLI::log( "Base: /wp-json/wpdo/v1 | post_type: {$post_type}" ); + WP_CLI::log( '' ); + + $pass = 0; + $fail = 0; + $items = array(); + + // ── T1: GET /listings ───────────────────────────────────────────────── + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', $post_type ); + $req->set_param( 'per_page', 5 ); + $server = rest_get_server(); + $res = $server->dispatch( $req ); + $status = $res->get_status(); + $data = $res->get_data(); + $total = $res->get_headers()['X-WP-Total'] ?? '?'; + $count = is_array( $data ) ? count( $data ) : 0; + + if ( 200 === $status ) { + WP_CLI::log( "[OK] GET /listings → HTTP {$status}, items={$count}, X-WP-Total={$total}" ); + ++$pass; + // Pick a test post ID from results. + if ( ! empty( $data[0]['id'] ) ) { + $items[] = (int) $data[0]['id']; + } + } else { + WP_CLI::warning( "[FAIL] GET /listings → HTTP {$status}" ); + ++$fail; + } + + // ── T2: GET /listings with filter ──────────────────────────────────── + $registry = TMDO_Schema_Registry::instance(); + $cols = array_keys( $registry->get_hot_columns( $post_type ) ); + + if ( $cols ) { + $req2 = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req2->set_param( 'post_type', $post_type ); + $req2->set_param( 'per_page', 3 ); + // No filter value — just verify the query doesn't error. + $res2 = $server->dispatch( $req2 ); + $status2 = $res2->get_status(); + if ( 200 === $status2 ) { + WP_CLI::log( "[OK] GET /listings?per_page=3 → HTTP {$status2}" ); + ++$pass; + } else { + WP_CLI::warning( "[FAIL] GET /listings?per_page=3 → HTTP {$status2}" ); + ++$fail; + } + } + + // ── T3: GET /listings/{id} ──────────────────────────────────────────── + $test_id = $items[0] ?? 0; + + if ( $test_id ) { + $req3 = new WP_REST_Request( 'GET', "/wpdo/v1/listings/{$test_id}" ); + $req3->set_param( 'id', $test_id ); + $res3 = $server->dispatch( $req3 ); + $status3 = $res3->get_status(); + $d3 = $res3->get_data(); + $keys = is_array( $d3 ) ? implode( ', ', array_keys( $d3 ) ) : '?'; + + if ( 200 === $status3 ) { + WP_CLI::log( "[OK] GET /listings/{$test_id} → HTTP {$status3}, keys: {$keys}" ); + ++$pass; + } else { + WP_CLI::warning( "[FAIL] GET /listings/{$test_id} → HTTP {$status3}" ); + ++$fail; + } + } else { + WP_CLI::log( "[SKIP] GET /listings/{id} — no posts found for post_type={$post_type}" ); + } + + // ── T4: GET /stats/{id} ─────────────────────────────────────────────── + if ( $test_id ) { + $req4 = new WP_REST_Request( 'GET', "/wpdo/v1/stats/{$test_id}" ); + $req4->set_param( 'id', $test_id ); + $res4 = $server->dispatch( $req4 ); + $status4 = $res4->get_status(); + $d4 = $res4->get_data(); + $views = $d4['view_count'] ?? '?'; + + if ( 200 === $status4 ) { + WP_CLI::log( "[OK] GET /stats/{$test_id} → HTTP {$status4}, view_count={$views}" ); + ++$pass; + } else { + WP_CLI::warning( "[FAIL] GET /stats/{$test_id} → HTTP {$status4}" ); + ++$fail; + } + } else { + WP_CLI::log( '[SKIP] GET /stats/{id} — no posts found' ); + } + + // ── T5: GET /status ─────────────────────────────────────────────────── + // Temporarily grant manage_options for CLI context. + add_filter( + 'user_has_cap', + function ( $caps ) { + $caps['manage_options'] = true; + return $caps; + } + ); + + $req5 = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); + $res5 = $server->dispatch( $req5 ); + $status5 = $res5->get_status(); + $d5 = $res5->get_data(); + $version = $d5['version'] ?? '?'; + $engine = $d5['engine'] ?? '?'; + + if ( 200 === $status5 ) { + WP_CLI::log( "[OK] GET /status → HTTP {$status5}, version={$version}, engine={$engine}" ); + ++$pass; + } else { + WP_CLI::warning( "[FAIL] GET /status → HTTP {$status5}" ); + ++$fail; + } + + // ── Summary ─────────────────────────────────────────────────────────── + WP_CLI::log( '' ); + $total_tests = $pass + $fail; + if ( 0 === $fail ) { + WP_CLI::success( "All {$total_tests} REST tests passed." ); + } else { + WP_CLI::error( "{$fail}/{$total_tests} REST tests failed.", false ); + } + } + + /** + * Add covering indexes to all Zone A (hot) tables. + * + * Analyses each hot table's column types and creates optimal covering indexes: + * - DECIMAL columns → single-column idx for range queries + ORDER BY + * - TINYINT + DECIMAL → compound (flag, price) for filtered sorts + * - BIGINT _time + DECIMAL → compound (expiry, price) for active listing queries + * - TINYINT + matching BIGINT _time → compound (flag, time) for featured ordering + * + * Safe to run on existing installations — skips already-present indexes. + * No-op on SQLite. + * + * ## OPTIONS + * + * [] + * : Limit to a specific post type (e.g. hp_listing). Defaults to all hot post types. + * + * ## EXAMPLES + * + * wp wpdo add-indexes + * wp wpdo add-indexes hp_listing + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + * + * @subcommand add-indexes + */ + public function add_indexes( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + if ( TMDO_IS_SQLITE ) { + WP_CLI::warning( 'Covering indexes are MySQL-only. SQLite is not supported.' ); + return; + } + + $registry = TMDO_Schema_Registry::instance(); + $post_types = $registry->get_hot_post_types(); + + // Optional filter by post_type argument. + if ( ! empty( $args[0] ) ) { + $filter = sanitize_key( $args[0] ); + $post_types = array_filter( $post_types, fn( $pt ) => sanitize_key( $pt ) === $filter ); + if ( empty( $post_types ) ) { + WP_CLI::error( "No hot zone table registered for post_type '{$args[0]}'." ); + return; + } + } + + foreach ( $post_types as $post_type ) { + $columns = $registry->get_hot_columns( $post_type ); + if ( empty( $columns ) ) { + WP_CLI::log( "[SKIP] {$post_type} — no hot columns registered." ); + continue; + } + + WP_CLI::log( "Adding covering indexes for {$post_type}..." ); + TMDO_Installer::add_covering_indexes( $post_type, $columns ); + WP_CLI::log( ' Done.' ); + } + + WP_CLI::success( 'Covering indexes applied.' ); + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * 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. + * + * @param string $module Module identifier (e.g., hot_hp_listing, warm, archive). + * @return TMDO_Migration_Base|null Migration instance, or null if module is unknown. + */ + private static function get_migration_instance( string $module ): ?TMDO_Migration_Base { + // Zone migrations. + if ( str_starts_with( $module, 'hot_' ) ) { + $post_type = substr( $module, 4 ); + return new TMDO_Hot_Migration( $post_type ); + } + + if ( str_starts_with( $module, 'cold_' ) ) { + $post_type = substr( $module, 5 ); + return new TMDO_Cold_Migration( $post_type ); + } + + if ( 'warm' === $module ) { + return new TMDO_Warm_Migration(); + } + + if ( 'archive' === $module ) { + return new TMDO_Archive_Migration(); + } + + return null; + } + + /** + * Capture a site-wide health snapshot. Writes JSON to the output dir + * (default: wp-data-optimizer/docs/snapshots/) and prints a summary. + * + * Re-running monthly produces a series of time-stamped snapshots that can + * be diffed to detect regressions (DB bloat, autoload pollution, missing + * registrations). + * + * ## OPTIONS + * + * [--out=] + * : Output directory. Defaults to plugin's docs/snapshots/. + * + * [--quiet] + * : Suppress human-readable summary; only print the JSON path. + * + * [--json-only] + * : Emit raw JSON to stdout (CI-friendly). Suppresses human prose entirely. + * + * [--diff-since=] + * : Compare against snapshot from N days ago instead of the previous one. + * + * ## EXAMPLES + * + * wp wpdo health-snapshot + * wp wpdo health-snapshot --out=/tmp + * wp wpdo health-snapshot --json-only > today.json + * wp wpdo health-snapshot --diff-since=30 + * + * @param array $args Positional args (unused). + * @param array $assoc_args Flag args. + * @return void + * + * @subcommand health-snapshot + */ + public function health_snapshot( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + global $wpdb; + $quiet = ! empty( $assoc_args['quiet'] ); + $json_only = ! empty( $assoc_args['json-only'] ); + $diff_since = isset( $assoc_args['diff-since'] ) ? max( 1, (int) $assoc_args['diff-since'] ) : 0; + // v2.1.2 fix: default to wp-content/uploads/wpdo-snapshots/ — plugin dir + // is read-only on hardened production hosts. Override with --out. + $default_outdir = ( function_exists( 'wp_upload_dir' ) ? ( wp_upload_dir()['basedir'] ?? sys_get_temp_dir() ) : sys_get_temp_dir() ) . '/wpdo-snapshots'; + $outdir = $assoc_args['out'] ?? $default_outdir; + if ( ! is_dir( $outdir ) ) { + wp_mkdir_p( $outdir ); + } + + // Trigger field registration so we capture the full picture. + do_action( 'wpdo_register_fields', TMDO_Schema_Registry::instance() ); + + // 1. DB size + top tables (v2.1.2: SQLite-aware). + if ( defined( 'TMDO_IS_SQLITE' ) && TMDO_IS_SQLITE ) { + // SQLite has no information_schema; size is the .sqlite file size. + $db_file = defined( 'DB_FILE' ) ? DB_FILE : ( WP_CONTENT_DIR . '/database/.ht.sqlite' ); + $db_size = file_exists( $db_file ) ? (int) filesize( $db_file ) : 0; + // Top tables via sqlite_master + per-table COUNT (slower but correct). + $tables = $wpdb->get_col( "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE '" . $wpdb->prefix . "2m%' OR name LIKE '" . $wpdb->prefix . "wpdo%' OR name LIKE '" . $wpdb->prefix . "tmqi%')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared + $top_tables = array(); + foreach ( (array) $tables as $t ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" ); + $top_tables[] = array( + 'TABLE_NAME' => $t, + 'TABLE_ROWS' => $rows, + 'DATA_LENGTH' => 0, + ); + } + usort( $top_tables, static fn( $a, $b ) => $b['TABLE_ROWS'] <=> $a['TABLE_ROWS'] ); + $top_tables = array_slice( $top_tables, 0, 10 ); + } else { + $db_size = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + 'SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s', + DB_NAME + ) + ); + $top_tables = $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + 'SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH FROM information_schema.TABLES + WHERE TABLE_SCHEMA = %s + AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s) + ORDER BY DATA_LENGTH DESC LIMIT 10', + DB_NAME, + $wpdb->prefix . '2m%', + $wpdb->prefix . 'wpdo%', + $wpdb->prefix . 'tmqi%' + ), + ARRAY_A + ); + } + + // 2. autoload pollution. + $autoload_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $autoload_size = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + + // 3. WPDO custom_table coverage. + $registry = TMDO_Custom_Table_Registry::instance(); + $ref = new ReflectionClass( $registry ); + $prop = $ref->getProperty( 'tables' ); + $prop->setAccessible( true ); + $all_tables = $prop->getValue( $registry ); + $by_provider = array(); + foreach ( $all_tables as $t ) { + $p = $t['provider'] ?? 'unknown'; + $by_provider[ $p ] = ( $by_provider[ $p ] ?? 0 ) + 1; + } + + // 4. Schema_Registry hot fields. + $schema = TMDO_Schema_Registry::instance(); + $pref = new ReflectionClass( $schema ); + $pp = $pref->getProperty( 'fields' ); + $pp->setAccessible( true ); + $fields = $pp->getValue( $schema ); + $fields_by_provider = array(); + foreach ( $fields as $f ) { + $p = $f['provider'] ?? 'unknown'; + $fields_by_provider[ $p ] = ( $fields_by_provider[ $p ] ?? 0 ) + 1; + } + + // 5. wpdo_errors row count. + $errors_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_errors" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + + $snapshot = array( + 'taken_at' => current_time( 'mysql' ), + 'wpdo_version' => defined( 'TMDO_VERSION' ) ? TMDO_VERSION : 'unknown', + 'db' => array( + 'total_size_bytes' => $db_size, + 'total_size_mb' => round( $db_size / 1048576, 2 ), + 'top_tables' => $top_tables ?: array(), + ), + 'autoload' => array( + 'count' => $autoload_total, + 'size_kb' => round( ( $autoload_size ?: 0 ) / 1024, 1 ), + ), + 'custom_tables' => array( + 'total' => count( $all_tables ), + 'by_provider' => $by_provider, + ), + 'schema_fields' => array( + 'total' => count( $fields ), + 'by_provider' => $fields_by_provider, + ), + 'wpdo_errors_count' => $errors_count, + ); + + $filename = sprintf( '%s/health-%s.json', rtrim( $outdir, '/' ), gmdate( 'Y-m-d-His' ) ); + $ok = file_put_contents( + $filename, + wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) + ); + + if ( false === $ok ) { + WP_CLI::error( "Failed to write snapshot to {$filename}" ); + } + + // v2.1.2 fix: prune snapshots > 24 to bound directory growth (monthly cron + // over years would otherwise fill disk + slow glob+sort). + $all_snaps = glob( rtrim( $outdir, '/' ) . '/health-*.json' ); + if ( $all_snaps && count( $all_snaps ) > 24 ) { + sort( $all_snaps ); + foreach ( array_slice( $all_snaps, 0, count( $all_snaps ) - 24 ) as $old ) { + @unlink( $old ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup + } + } + + // --json-only: emit the raw JSON to stdout and exit silently. + if ( $json_only ) { + WP_CLI::log( wp_json_encode( $snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); + return; + } + + if ( $quiet ) { + WP_CLI::log( $filename ); + return; + } + + WP_CLI::log( '═══ WPDO Health Snapshot ═══' ); + WP_CLI::log( ' Taken: ' . $snapshot['taken_at'] ); + WP_CLI::log( ' WPDO version: ' . $snapshot['wpdo_version'] ); + WP_CLI::log( ' DB total: ' . $snapshot['db']['total_size_mb'] . ' MB' ); + WP_CLI::log( ' Custom tables registered: ' . $snapshot['custom_tables']['total'] ); + WP_CLI::log( ' Schema fields registered: ' . $snapshot['schema_fields']['total'] ); + WP_CLI::log( ' Autoload entries: ' . $snapshot['autoload']['count'] . ' (' . $snapshot['autoload']['size_kb'] . ' KB)' ); + WP_CLI::log( ' wp_wpdo_errors rows: ' . $snapshot['wpdo_errors_count'] ); + WP_CLI::log( '' ); + WP_CLI::log( "Snapshot saved: {$filename}" ); + + // Prefer --diff-since=N (find snapshot from ~N days ago); fallback to + // previous snapshot in the directory. + $snapshots = glob( rtrim( $outdir, '/' ) . '/health-*.json' ); + $prev_path = null; + if ( $snapshots && count( $snapshots ) >= 2 ) { + sort( $snapshots ); + if ( $diff_since > 0 ) { + // Find the snapshot closest to (now - $diff_since days). + $target_ts = time() - $diff_since * DAY_IN_SECONDS; + $best_diff = PHP_INT_MAX; + foreach ( $snapshots as $candidate ) { + if ( preg_match( '/health-(\d{4}-\d{2}-\d{2})/', basename( $candidate ), $m ) ) { + $cand_ts = strtotime( $m[1] ); + $diff = abs( $cand_ts - $target_ts ); + if ( $diff < $best_diff && $candidate !== $filename ) { + $best_diff = $diff; + $prev_path = $candidate; + } + } + } + } else { + $prev_path = $snapshots[ count( $snapshots ) - 2 ]; // second-latest. + } + } + + if ( $prev_path ) { + $prev = json_decode( (string) file_get_contents( $prev_path ), true ); + if ( is_array( $prev ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Δ vs ' . basename( $prev_path ) . ':' ); + WP_CLI::log( sprintf( ' DB: %+0.2f MB', $snapshot['db']['total_size_mb'] - ( $prev['db']['total_size_mb'] ?? 0 ) ) ); + WP_CLI::log( sprintf( ' Tables: %+d', $snapshot['custom_tables']['total'] - ( $prev['custom_tables']['total'] ?? 0 ) ) ); + WP_CLI::log( sprintf( ' Fields: %+d', $snapshot['schema_fields']['total'] - ( $prev['schema_fields']['total'] ?? 0 ) ) ); + WP_CLI::log( sprintf( ' Errors: %+d', $snapshot['wpdo_errors_count'] - ( $prev['wpdo_errors_count'] ?? 0 ) ) ); + WP_CLI::log( sprintf( ' Autoload: %+d entries', $snapshot['autoload']['count'] - ( $prev['autoload']['count'] ?? 0 ) ) ); + + // Threshold breach detection — write to last_snapshot_alert option. + $db_growth_pct = ( $prev['db']['total_size_mb'] ?? 0 ) > 0 + ? ( ( $snapshot['db']['total_size_mb'] - $prev['db']['total_size_mb'] ) / $prev['db']['total_size_mb'] ) * 100 + : 0; + if ( $db_growth_pct > 10 ) { + update_option( + 'wpdo_health_alert', + sprintf( + 'DB grew %.1f%% (%.2f MB → %.2f MB) since %s', + $db_growth_pct, + (float) ( $prev['db']['total_size_mb'] ?? 0 ), + (float) $snapshot['db']['total_size_mb'], + basename( $prev_path ) + ), + false + ); + WP_CLI::warning( sprintf( 'DB grew %.1f%% — admin notice will fire.', $db_growth_pct ) ); + } else { + delete_option( 'wpdo_health_alert' ); + } + } + } + + WP_CLI::success( 'Health snapshot complete.' ); + } + + /** + * Generate scaffolding for a new partner plugin's WPDO integration class. + * + * Output: prints a ready-to-paste integration class to stdout (or --out file). + * Saves ~30 minutes per new plugin onboarding by following Tier 4 cookbook pattern. + * + * ## OPTIONS + * + * + * : Plugin slug (e.g. 2meet-newplugin). Used to derive class name + table prefix. + * + * [--prefix=] + * : Table prefix (e.g. 2mn for 2meet-newplugin). Defaults to first 3 chars of slug. + * + * [--class=] + * : Class name (default: derived from slug, e.g. `TMEETIC_Newplugin_WPDO`). + * + * [--out=] + * : Output file. Defaults to stdout. + * + * ## EXAMPLES + * + * wp wpdo register-stub 2meet-newplugin + * wp wpdo register-stub 2meet-newplugin --prefix=2mn --out=/tmp/stub.php + * + * @param array $args Positional args. + * @param array $assoc_args Flag args. + * @return void + * + * @subcommand register-stub + */ + public function register_stub( $args, $assoc_args ): void { + $slug = isset( $args[0] ) ? sanitize_title( (string) $args[0] ) : ''; + if ( '' === $slug ) { + WP_CLI::error( 'Usage: wp wpdo register-stub ' ); + } + + $prefix = isset( $assoc_args['prefix'] ) + ? sanitize_key( (string) $assoc_args['prefix'] ) + : substr( preg_replace( '/[^a-z]/', '', strtolower( str_replace( array( '2meet-', '-' ), '', $slug ) ) ), 0, 3 ); + + $class = isset( $assoc_args['class'] ) + ? (string) $assoc_args['class'] + : 'TMEETIC_' . str_replace( ' ', '_', ucwords( str_replace( array( '2meet-', '-' ), array( '', ' ' ), $slug ) ) ) . '_WPDO'; + + $stub = self::render_stub( $slug, $prefix, $class ); + + if ( ! empty( $assoc_args['out'] ) ) { + $ok = file_put_contents( $assoc_args['out'], $stub ); + if ( false === $ok ) { + WP_CLI::error( "Failed to write {$assoc_args['out']}" ); + } + WP_CLI::success( "Stub written to {$assoc_args['out']}" ); + return; + } + + WP_CLI::log( $stub ); + } + + /** + * Render the scaffolding template. + * + * @param string $slug Plugin slug. + * @param string $prefix Table prefix. + * @param string $class_name Integration class name. + * @return string PHP source code. + */ + private static function render_stub( string $slug, string $prefix, string $class_name ): string { + $year = gmdate( 'Y' ); + return << +\t */ +\tprivate const TABLES = array( +\t\t'{$prefix}_example_one' => array( 'post_type_link' => null, 'description' => '範例表 1' ), +\t\t'{$prefix}_example_two' => array( 'post_type_link' => null, 'description' => '範例表 2' ), +\t); + +\tpublic static function register(): void { +\t\tadd_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) ); +\t} + +\tpublic static function register_tables( \$registry = null ): void { +\t\tif ( ! class_exists( 'TMDO_Custom_Table_Registry' ) ) { +\t\t\treturn; +\t\t} +\t\t\$registry = \$registry ?: TMDO_Custom_Table_Registry::instance(); + +\t\tforeach ( self::TABLES as \$name => \$meta ) { +\t\t\t\$registry->register( +\t\t\t\t'{$slug}', +\t\t\t\tarray( +\t\t\t\t\t'table_name' => \$name, +\t\t\t\t\t'primary_key' => 'id', +\t\t\t\t\t'post_type_link' => \$meta['post_type_link'], +\t\t\t\t\t'doctor_callback' => array( __CLASS__, 'doctor_check' ), +\t\t\t\t\t'description' => \$meta['description'], +\t\t\t\t) +\t\t\t); +\t\t} +\t} + +\tpublic static function doctor_check( string \$table_suffix ): array { +\t\tglobal \$wpdb; +\t\t\$full = \$wpdb->prefix . \$table_suffix; +\t\t\$exists = (bool) \$wpdb->get_var( \$wpdb->prepare( 'SHOW TABLES LIKE %s', \$full ) ); +\t\tif ( ! \$exists ) { +\t\t\treturn array( 'ok' => false, 'message' => "{\$full} 不存在" ); +\t\t} +\t\t\$count = (int) \$wpdb->get_var( "SELECT COUNT(*) FROM `{\$full}`" ); +\t\treturn array( 'ok' => true, 'message' => "{\$full}: {\$count} rows" ); +\t} +} + +PHP; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..205ebb2 --- /dev/null +++ b/composer.json @@ -0,0 +1,51 @@ +{ + "name": "2meet/2meet-data-optimizer", + "description": "通用 WordPress 反 EAV 引擎,將四 entity meta 自動分流至 Hot/Warm/Cold/Archive 扁平表", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "keywords": ["wordpress", "performance", "anti-eav", "postmeta", "flat-table", "query-router"], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "brain/monkey": "^2.6", + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", + "php-stubs/wp-cli-stubs": "^2.12", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpcsstandards/phpcsutils": "^1.2", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.13", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "autoload": { + "classmap": [ + "includes/", + "admin/", + "cli/", + "modules/" + ] + }, + "autoload-dev": { + "psr-4": { + "TMDO\\Tests\\": "tests/" + } + }, + "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", + "stan": "phpstan analyse --no-progress", + "stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..449b3bc --- /dev/null +++ b/composer.lock @@ -0,0 +1,2840 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "cd0fc7632900a6c7831f04b981678980", + "packages": [], + "packages-dev": [ + { + "name": "antecedent/patchwork", + "version": "2.2.3", + "source": { + "type": "git", + "url": "https://github.com/antecedent/patchwork.git", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antecedent/patchwork/zipball/8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "reference": "8b6b235f405af175259c8f56aea5fc23ab9f03ce", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignas Rudaitis", + "email": "ignas.rudaitis@gmail.com" + } + ], + "description": "Method redefinition (monkey-patching) functionality for PHP.", + "homepage": "https://antecedent.github.io/patchwork/", + "keywords": [ + "aop", + "aspect", + "interception", + "monkeypatching", + "redefinition", + "runkit", + "testing" + ], + "support": { + "issues": "https://github.com/antecedent/patchwork/issues", + "source": "https://github.com/antecedent/patchwork/tree/2.2.3" + }, + "time": "2025-09-17T09:00:56+00:00" + }, + { + "name": "brain/monkey", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Brain-WP/BrainMonkey.git", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "shasum": "" + }, + "require": { + "antecedent/patchwork": "^2.1.17", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", + "php": ">=5.6.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", + "phpcompatibility/php-compatibility": "^9.3.0", + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-version/1": "1.x-dev" + } + }, + "autoload": { + "files": [ + "inc/api.php" + ], + "psr-4": { + "Brain\\Monkey\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Giuseppe Mazzapica", + "email": "giuseppe.mazzapica@gmail.com", + "homepage": "https://gmazzap.me", + "role": "Developer" + } + ], + "description": "Mocking utility for PHP functions and WordPress plugin API", + "keywords": [ + "Monkey Patching", + "interception", + "mock", + "mock functions", + "mockery", + "patchwork", + "redefinition", + "runkit", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/Brain-WP/BrainMonkey/issues", + "source": "https://github.com/Brain-WP/BrainMonkey" + }, + "time": "2026-02-05T09:22:14+00:00" + }, + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-stubs/wordpress-stubs", + "version": "v6.9.4", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/90a9412826b9944f93b10bf41d795b5fe68abcd5", + "reference": "90a9412826b9944f93b10bf41d795b5fe68abcd5", + "shasum": "" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "5.6.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "nikic/php-parser": "^5.5", + "php": "^7.4 || ^8.0", + "php-stubs/generator": "^0.8.6", + "phpdocumentor/reflection-docblock": "^6.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^9.5", + "symfony/polyfill-php80": "*", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php80": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.9.4" + }, + "time": "2026-05-01T20:36:01+00:00" + }, + { + "name": "php-stubs/wp-cli-stubs", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wp-cli-stubs.git", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wp-cli-stubs/zipball/af16401e299a3fd2229bd0fa9a037638a4174a9d", + "reference": "af16401e299a3fd2229bd0fa9a037638a4174a9d", + "shasum": "" + }, + "require": { + "php-stubs/wordpress-stubs": "^4.7 || ^5.0 || ^6.0" + }, + "require-dev": { + "php": "~7.3 || ~8.0", + "php-stubs/generator": "^0.8.0" + }, + "suggest": { + "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WP-CLI function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wp-cli-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress", + "wp-cli" + ], + "support": { + "issues": "https://github.com/php-stubs/wp-cli-stubs/issues", + "source": "https://github.com/php-stubs/wp-cli-stubs/tree/v2.12.0" + }, + "time": "2025-06-10T09:58:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T11:13:17+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-07-27T10:28:41+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.7", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921", + "reference": "692db47b9dddb0487934e5236e77d48594aef921", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-29T17:39:32+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.64" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "szepeviktor/phpstan-wordpress", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/szepeviktor/phpstan-wordpress.git", + "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/aa722f037b2d034828cd6c55ebe9e5c74961927e", + "reference": "aa722f037b2d034828cd6c55ebe9e5c74961927e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "php-stubs/wordpress-stubs": "^6.6.2", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "composer/composer": "^2.1.14", + "composer/semver": "^3.4", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.0", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.0", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" + }, + "suggest": { + "swissspidy/phpstan-no-private": "Detect usage of internal core functions, classes and methods" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "SzepeViktor\\PHPStan\\WordPress\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress extensions for PHPStan", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v2.0.3" + }, + "time": "2025-09-14T02:58:22+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2026-07-27T11:53:23+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-08-10T05:13:49+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/includes/adapters/class-tmdo-adapter-comment.php b/includes/adapters/class-tmdo-adapter-comment.php new file mode 100644 index 0000000..fdc38ec --- /dev/null +++ b/includes/adapters/class-tmdo-adapter-comment.php @@ -0,0 +1,105 @@ +commentmeta; + } + + public function get_entity_id_column(): string { + return 'comment_id'; + } + + public function get_primary_table(): string { + global $wpdb; + return $wpdb->comments; + } + + public function get_primary_id_column(): string { + return 'comment_ID'; + } + + public function get_cache_group(): string { + return 'wpdo_comment_profile'; + } + + public function get_delete_hook(): string { + return 'delete_comment'; + } + + public function extend_native_query( $query_object ): void { + if ( ! ( $query_object instanceof WP_Comment_Query ) ) { + return; + } + + $wpdo_query = $query_object->query_vars['wpdo_meta_query'] ?? null; + if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) { + return; + } + + // WP_Comment_Query 使用 comments_clauses filter + add_filter( + 'comments_clauses', + function ( $clauses ) use ( $wpdo_query ) { + $compiled = TMDO_Query_Compiler::compile( 'comment', $wpdo_query ); + + if ( ! empty( $compiled['joins'] ) ) { + $clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] ); + } + + if ( ! empty( $compiled['where'] ) ) { + $clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')'; + } + + return $clauses; + }, + 10, + 1 + ); + } + + public function get_entity_ids_after( int $after_id, int $limit ): array { + global $wpdb; + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT comment_ID FROM `{$wpdb->comments}` WHERE comment_ID > %d ORDER BY comment_ID ASC LIMIT %d", + $after_id, + $limit + ) + ) ?: array() + ); + } +} + +// 註冊 pre_get_comments hook(unit test 下 add_action 未載入時跳過) +if ( function_exists( 'add_action' ) ) { + add_action( + 'pre_get_comments', + function ( $query ) { + $adapter = TMDO_Entity_Registry::get_adapter( 'comment' ); + if ( $adapter ) { + $adapter->extend_native_query( $query ); + } + }, + 10, + 1 + ); +} diff --git a/includes/adapters/class-tmdo-adapter-post.php b/includes/adapters/class-tmdo-adapter-post.php new file mode 100644 index 0000000..6c5bdcd --- /dev/null +++ b/includes/adapters/class-tmdo-adapter-post.php @@ -0,0 +1,93 @@ +postmeta; + } + + public function get_entity_id_column(): string { + return 'post_id'; + } + + public function get_primary_table(): string { + global $wpdb; + return $wpdb->posts; + } + + public function get_primary_id_column(): string { + return 'ID'; + } + + public function get_cache_group(): string { + return 'wpdo_post_profile'; + } + + public function get_delete_hook(): string { + return 'before_delete_post'; + } + + /** + * 擴充 WP_Query 支援 wpdo_meta_query / wpdo_orderby + */ + public function extend_native_query( $query_object ): void { + if ( ! ( $query_object instanceof WP_Query ) ) { + return; + } + + $wpdo_query = $query_object->get( 'wpdo_meta_query' ); + if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) { + return; + } + + TMDO_Query_Compiler::inject_into_wp_query( $query_object, $wpdo_query ); + } + + /** + * Cursor-based 取得 post IDs + */ + public function get_entity_ids_after( int $after_id, int $limit ): array { + global $wpdb; + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM `{$wpdb->posts}` WHERE ID > %d ORDER BY ID ASC LIMIT %d", + $after_id, + $limit + ) + ) ?: array() + ); + } +} + +// 註冊 pre_get_posts 以啟用 wpdo_meta_query(unit test 下 add_action 未載入時跳過) +if ( function_exists( 'add_action' ) ) { + add_action( + 'pre_get_posts', + function ( $query ) { + $adapter = TMDO_Entity_Registry::get_adapter( 'post' ); + if ( $adapter ) { + $adapter->extend_native_query( $query ); + } + }, + 10, + 1 + ); +} diff --git a/includes/adapters/class-tmdo-adapter-term.php b/includes/adapters/class-tmdo-adapter-term.php new file mode 100644 index 0000000..0a6379d --- /dev/null +++ b/includes/adapters/class-tmdo-adapter-term.php @@ -0,0 +1,89 @@ +termmeta; + } + + public function get_entity_id_column(): string { + return 'term_id'; + } + + public function get_primary_table(): string { + global $wpdb; + return $wpdb->terms; + } + + public function get_primary_id_column(): string { + return 'term_id'; + } + + public function get_cache_group(): string { + return 'wpdo_term_profile'; + } + + public function get_delete_hook(): string { + return 'delete_term'; + } + + public function extend_native_query( $query_object ): void { + // Term 查詢使用 terms_clauses filter,不使用 $query_object + } + + /** + * Term 特有:透過 terms_clauses filter 注入 + */ + public function extend_term_clauses( array $clauses, array $taxonomies, array $args ): array { + if ( empty( $args['wpdo_meta_query'] ) || ! is_array( $args['wpdo_meta_query'] ) ) { + return $clauses; + } + return TMDO_Query_Compiler::inject_into_terms_clauses( $clauses, $args['wpdo_meta_query'] ); + } + + public function get_entity_ids_after( int $after_id, int $limit ): array { + global $wpdb; + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT term_id FROM `{$wpdb->terms}` WHERE term_id > %d ORDER BY term_id ASC LIMIT %d", + $after_id, + $limit + ) + ) ?: array() + ); + } +} + +// 註冊 terms_clauses filter(unit test 下 add_filter 未載入時跳過) +if ( function_exists( 'add_filter' ) ) { + add_filter( + 'terms_clauses', + function ( $clauses, $taxonomies, $args ) { + $adapter = TMDO_Entity_Registry::get_adapter( 'term' ); + if ( $adapter instanceof TMDO_Adapter_Term ) { + return $adapter->extend_term_clauses( $clauses, $taxonomies, $args ); + } + return $clauses; + }, + 10, + 3 + ); +} diff --git a/includes/adapters/class-tmdo-adapter-user.php b/includes/adapters/class-tmdo-adapter-user.php new file mode 100644 index 0000000..8d1df01 --- /dev/null +++ b/includes/adapters/class-tmdo-adapter-user.php @@ -0,0 +1,87 @@ +usermeta; + } + + public function get_entity_id_column(): string { + return 'user_id'; + } + + public function get_primary_table(): string { + global $wpdb; + return $wpdb->users; + } + + public function get_primary_id_column(): string { + return 'ID'; + } + + public function get_cache_group(): string { + return 'wpdo_user_profile'; + } + + public function get_delete_hook(): string { + return 'delete_user'; + } + + public function extend_native_query( $query_object ): void { + if ( ! ( $query_object instanceof WP_User_Query ) ) { + return; + } + + $wpdo_query = $query_object->get( 'wpdo_meta_query' ); + if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) { + return; + } + + TMDO_Query_Compiler::inject_into_user_query( $query_object, $wpdo_query ); + } + + public function get_entity_ids_after( int $after_id, int $limit ): array { + global $wpdb; + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM `{$wpdb->users}` WHERE ID > %d ORDER BY ID ASC LIMIT %d", + $after_id, + $limit + ) + ) ?: array() + ); + } +} + +// 註冊 pre_user_query(unit test 下 add_action 未載入時跳過) +if ( function_exists( 'add_action' ) ) { + add_action( + 'pre_user_query', + function ( $query ) { + $adapter = TMDO_Entity_Registry::get_adapter( 'user' ); + if ( $adapter ) { + $adapter->extend_native_query( $query ); + } + }, + 10, + 1 + ); +} diff --git a/includes/adapters/interface-entity-adapter.php b/includes/adapters/interface-entity-adapter.php new file mode 100644 index 0000000..fe86d55 --- /dev/null +++ b/includes/adapters/interface-entity-adapter.php @@ -0,0 +1,83 @@ + + */ + public function get_entity_ids_after( int $after_id, int $limit ): array; +} diff --git a/includes/advisor/class-tmdo-fsm-advisor.php b/includes/advisor/class-tmdo-fsm-advisor.php new file mode 100644 index 0000000..5cd666f --- /dev/null +++ b/includes/advisor/class-tmdo-fsm-advisor.php @@ -0,0 +1,266 @@ + 0, + 'dual_write' => 1, + 'backfill' => 1, + 'verify' => 7, + 'cutover' => 1, + 'cleanup' => 3, + 'complete' => 0, // terminal — never promote. + ); + + /** Max divergence ratio in `verify` state before advisor refuses to promote. */ + public const VERIFY_MAX_DIVERGENCE_RATIO = 0.001; // 0.1% + + /** + * Build advice for a single module. + * + * @param string $module Module slug. + * @return array {action:string, next_state?:string, days_remaining?:int, + * reason:string, level:'info'|'warn'|'critical', metrics:array} + */ + public static function advise( string $module ): array { + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return self::stub( 'unavailable', 'Feature_Flags 未載入' ); + } + $state = TMDO_Feature_Flags::get( $module ); + + // Terminal cases. + if ( 'idle' === $state ) { + return array( + 'action' => 'HOLD', + 'level' => 'info', + 'reason' => __( 'Module 處於 idle,無需建議。如要啟用請先讀 SOP。', '2meet-data-optimizer' ), + 'metrics' => array( 'state' => $state ), + ); + } + if ( 'complete' === $state ) { + return array( + 'action' => 'HOLD', + 'level' => 'info', + 'reason' => __( 'Module 已 complete,反 EAV 完成。', '2meet-data-optimizer' ), + 'metrics' => array( 'state' => $state ), + ); + } + + $days_in_state = self::days_in_state( $module ); + $min_soak = self::MIN_SOAK_DAYS[ $state ] ?? 1; + + $metrics = array( + 'state' => $state, + 'days_in_state' => $days_in_state, + 'min_soak_days' => $min_soak, + ); + + // Verify state requires divergence ratio check. + if ( 'verify' === $state ) { + $div = self::shadow_diff_ratio( $module ); + $metrics['shadow_diff_ratio_24h'] = $div['ratio']; + $metrics['shadow_diff_count_24h'] = $div['count']; + $metrics['shadow_diff_total_24h'] = $div['total']; + + if ( $div['count'] > 0 && $div['ratio'] > self::VERIFY_MAX_DIVERGENCE_RATIO ) { + return array( + 'action' => 'REVIEW', + 'next_state' => null, + 'level' => 'warn', + 'reason' => sprintf( + /* translators: 1: ratio, 2: max allowed */ + __( 'Verify 期間 shadow_diffs 比率 %1$.2f%% 超過上限 %2$.2f%%;建議手動 review wp_wpdo_shadow_diffs 確認分歧來源後再決定。', '2meet-data-optimizer' ), + $div['ratio'] * 100, + self::VERIFY_MAX_DIVERGENCE_RATIO * 100 + ), + 'metrics' => $metrics, + ); + } + } + + // Soak time check. + if ( null !== $days_in_state && $days_in_state < $min_soak ) { + $days_remaining = $min_soak - $days_in_state; + return array( + 'action' => 'WAIT', + 'days_remaining' => $days_remaining, + 'level' => 'info', + 'reason' => sprintf( + /* translators: 1: days left, 2: state name, 3: min days */ + __( '再等 %1$d 天即可推進。當前 %2$s 狀態需 ≥ %3$d 天 soak。', '2meet-data-optimizer' ), + $days_remaining, + $state, + $min_soak + ), + 'metrics' => $metrics, + ); + } + + // Promotion candidate. + $next = self::next_state( $state ); + if ( null === $next ) { + return array( + 'action' => 'HOLD', + 'level' => 'info', + 'reason' => __( '當前狀態為 terminal — 無下一步建議。', '2meet-data-optimizer' ), + 'metrics' => $metrics, + ); + } + return array( + 'action' => 'PROMOTE', + 'next_state' => $next, + 'level' => 'info', + 'reason' => sprintf( + /* translators: 1: from, 2: to, 3: days */ + __( '可推進:%1$s → %2$s(已 soak %3$d 天)。', '2meet-data-optimizer' ), + $state, + $next, + $days_in_state + ), + 'metrics' => $metrics, + ); + } + + /** + * Build advice for every known module. Returns map module → advice. + * + * @return array + */ + public static function advise_all(): array { + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return array(); + } + $out = array(); + foreach ( TMDO_Feature_Flags::all() as $module => $_state ) { + $out[ $module ] = self::advise( $module ); + } + return $out; + } + + // ─── private ────────────────────────────────────────────────────── + + /** + * Return days since the module entered its current state. + * + * @param string $module Module slug. + * @return int|null Days since entering current state, or null if unknown. + */ + private static function days_in_state( string $module ): ?int { + $entered = (array) get_option( 'wpdo_fsm_state_entered', array() ); + if ( ! isset( $entered[ $module ]['entered_at'] ) ) { + return null; + } + $ts = strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ); + if ( false === $ts || $ts <= 0 ) { + return null; + } + return (int) floor( ( time() - $ts ) / DAY_IN_SECONDS ); + } + + /** + * Return the shadow-diff divergence ratio for a module over the last 24 hours. + * + * @param string $module Module slug. + * @return array {ratio:float, count:int, total:int} + */ + private static function shadow_diff_ratio( string $module ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- $module reserved for per-module entity_type routing (future) + global $wpdb; + $out = array( + 'ratio' => 0.0, + 'count' => 0, + 'total' => 0, + ); + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + return $out; + } + // Module → entity_type mapping is loose — most HPCT modules map to 'post'. + // Use entity_type='post' as default proxy; downstream `level=warn` is + // honest about uncertainty. + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE entity_type = %s AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is $wpdb->prefix . 'wpdo_shadow_diffs' (no user input) + 'post' + ) + ); + $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no variables; table name is a WP core property + "SELECT COUNT(*) FROM `{$wpdb->postmeta}` WHERE meta_id > 0" + ); + $out['count'] = $count; + $out['total'] = max( 1, $total ); + $out['ratio'] = $count / $out['total']; + return $out; + } + + /** + * Forward graph (mirrors TMDO_FSM_Guard::FORWARD_GRAPH but without the + * cycle check — advisor only suggests the canonical next step). + * + * @param string $state Current state. + * @return string|null Next state, or null when terminal. + */ + private static function next_state( string $state ): ?string { + $map = array( + 'idle' => 'dual_write', + 'dual_write' => 'backfill', + 'backfill' => 'verify', + 'verify' => 'cutover', + 'cutover' => 'cleanup', + 'cleanup' => 'complete', + 'complete' => null, + ); + return $map[ $state ] ?? null; + } + + /** + * Stub advice when prerequisites are missing. + * + * @param string $action Reason code. + * @param string $reason Human reason. + * @return array + */ + private static function stub( string $action, string $reason ): array { + return array( + 'action' => strtoupper( $action ), + 'level' => 'info', + 'reason' => $reason, + 'metrics' => array(), + ); + } +} diff --git a/includes/advisor/class-tmdo-fsm-automator.php b/includes/advisor/class-tmdo-fsm-automator.php new file mode 100644 index 0000000..ecc397c --- /dev/null +++ b/includes/advisor/class-tmdo-fsm-automator.php @@ -0,0 +1,234 @@ + true, + 'executed' => 0, + 'skipped' => 0, + 'errors' => array(), + 'actions' => array(), + ); + + // Pre-flight: enabled? + if ( ! self::is_enabled() ) { + $result['ok'] = false; + $result['errors'][] = 'automator disabled'; + return $result; + } + + // Pre-flight: cool-off? + if ( ! self::cool_off_clear() ) { + $result['ok'] = false; + $result['errors'][] = 'cool-off active (critical health in last ' . self::COOL_OFF_DAYS . ' days)'; + return $result; + } + + // Iterate modules. + if ( ! class_exists( 'TMDO_Feature_Flags' ) || ! class_exists( 'TMDO_FSM_Advisor' ) ) { + $result['ok'] = false; + $result['errors'][] = 'dependencies missing'; + return $result; + } + + $blacklist = (array) get_option( self::OPT_BLACKLIST, array() ); + $last_actions = (array) get_option( self::OPT_LAST_ACTION, array() ); + + foreach ( TMDO_Feature_Flags::all() as $module => $current_state ) { + // Blacklisted? + if ( in_array( $module, $blacklist, true ) ) { + ++$result['skipped']; + continue; + } + // Get advisor recommendation. + $advice = TMDO_FSM_Advisor::advise( $module ); + if ( 'PROMOTE' !== ( $advice['action'] ?? '' ) || 'info' !== ( $advice['level'] ?? '' ) ) { + ++$result['skipped']; + continue; + } + $next_state = (string) ( $advice['next_state'] ?? '' ); + if ( '' === $next_state ) { + ++$result['skipped']; + continue; + } + // Forbidden destructive transition? + if ( self::is_forbidden_transition( $current_state, $next_state ) ) { + ++$result['skipped']; + continue; + } + // Recent action? + if ( ! self::interval_clear( $module, $last_actions ) ) { + ++$result['skipped']; + continue; + } + // All checks passed — execute. + $set_result = TMDO_Feature_Flags::set( $module, $next_state ); + if ( true === $set_result ) { + ++$result['executed']; + $result['actions'][] = array( + 'module' => $module, + 'from' => $current_state, + 'to' => $next_state, + ); + $last_actions[ $module ] = gmdate( 'Y-m-d H:i:s' ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'automator_promoted', + array( + 'module' => $module, + 'from' => $current_state, + 'to' => $next_state, + ) + ); + } + do_action( 'wpdo/automator_promoted', $module, $current_state, $next_state ); + } else { + $result['errors'][] = "{$module}: " . ( $set_result instanceof \WP_Error ? $set_result->get_error_code() : 'unknown' ); + } + } + + update_option( self::OPT_LAST_ACTION, $last_actions, false ); + return $result; + } + + // ─── settings accessors ───────────────────────────────────────────── + + /** + * Return whether the automator is enabled. + * + * @return bool + */ + public static function is_enabled(): bool { + return '1' === (string) get_option( self::OPT_ENABLED, '0' ); + } + + /** + * Return the list of blacklisted module slugs. + * + * @return array + */ + public static function blacklist(): array { + return (array) get_option( self::OPT_BLACKLIST, array() ); + } + + /** + * Return the map of module → last automated action timestamp. + * + * @return array + */ + public static function last_actions(): array { + return (array) get_option( self::OPT_LAST_ACTION, array() ); + } + + // ─── private helpers ─────────────────────────────────────────────── + + /** + * Cool-off: false when there's been a critical health alert in last N days. + * + * @return bool true = OK to act, false = blocked. + */ + private static function cool_off_clear(): bool { + if ( ! class_exists( 'TMDO_Health_Cron' ) ) { + return true; // No data — assume OK. + } + $last = TMDO_Health_Cron::get_last_run(); + if ( ! is_array( $last ) ) { + return true; + } + $crit = (int) ( $last['critical_count'] ?? 0 ); + if ( 0 === $crit ) { + return true; + } + $ts = isset( $last['ran_at'] ) ? strtotime( (string) $last['ran_at'] . ' UTC' ) : 0; + if ( $ts <= 0 ) { + return true; + } + // Critical alert exists; cool-off if within window. + return ( time() - $ts ) > ( self::COOL_OFF_DAYS * DAY_IN_SECONDS ); + } + + /** + * Per-module interval: ≥ 24h since last automated action on this module. + * + * @param string $module Module slug. + * @param array $last_actions Map of module → timestamp. + * @return bool true = OK to act. + */ + private static function interval_clear( string $module, array $last_actions ): bool { + $last = (string) ( $last_actions[ $module ] ?? '' ); + if ( '' === $last ) { + return true; + } + $ts = strtotime( $last . ' UTC' ); + if ( $ts <= 0 ) { + return true; + } + return ( time() - $ts ) >= ( self::MIN_INTERVAL_HOURS * HOUR_IN_SECONDS ); + } + + /** + * Check if (from, to) is in FORBIDDEN_TRANSITIONS. + * + * @param string $from Current state. + * @param string $to Target state. + * @return bool + */ + private static function is_forbidden_transition( string $from, string $to ): bool { + foreach ( self::FORBIDDEN_TRANSITIONS as $pair ) { + if ( $pair[0] === $from && $pair[1] === $to ) { + return true; + } + } + return false; + } +} diff --git a/includes/advisor/class-tmdo-module-detector.php b/includes/advisor/class-tmdo-module-detector.php new file mode 100644 index 0000000..fec9947 --- /dev/null +++ b/includes/advisor/class-tmdo-module-detector.php @@ -0,0 +1,381 @@ + Module slug → result. + */ + public static function detect_all( bool $force_refresh = false ): array { + if ( ! $force_refresh ) { + $cached = get_transient( self::TRANSIENT ); + if ( is_array( $cached ) ) { + return $cached; + } + } + $out = array(); + foreach ( TMDO_Module_Rules::known_modules() as $module ) { + $out[ $module ] = self::detect_one( $module ); + } + set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL ); + // Persist to wp_options for the dashboard widget (no autoload). + update_option( + self::OPTION_CACHE, + array( + 'results' => $out, + 'generated_at' => gmdate( 'Y-m-d H:i:s' ), + ), + false + ); + return $out; + } + + /** + * Detect a single module. + * + * @param string $module Module slug. + * @return array See class doc for shape. + */ + public static function detect_one( string $module ): array { + $rule = TMDO_Module_Rules::for_module( $module ); + if ( null === $rule ) { + return self::stub( $module, 'no_rule' ); + } + + $reasons = array(); + $blockers = array(); + $confidence = 0.0; + $current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle'; + + // 0. Already non-idle? Block recommendation. + if ( 'idle' !== $current_state ) { + $blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state ); + return self::build( + $module, + false, + 0.0, + 'skip', + $reasons, + $blockers, + $current_state + ); + } + + // 1. Plugin compatibility check. + $compat_required = (array) ( $rule['compat_required'] ?? array() ); + if ( ! empty( $compat_required ) ) { + $missing = self::check_compat( $compat_required ); + if ( ! empty( $missing ) ) { + $blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing ); + return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state ); + } + $reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required ); + $confidence += 0.3; + } + + // 2. Required post_type + row count. + $post_type = (string) ( $rule['post_type_required'] ?? '' ); + $min_count = (int) ( $rule['min_post_count'] ?? 0 ); + if ( '' !== $post_type ) { + $count = self::post_type_count( $post_type ); + if ( $count < $min_count ) { + $blockers[] = sprintf( + '⚠️ %s post_type 只有 %s 行(需要 ≥ %s)', + $post_type, + number_format_i18n( $count ), + number_format_i18n( $min_count ) + ); + return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); + } + $reasons[] = sprintf( + '✅ %s post_type 有 %s 行(門檻 %s)', + $post_type, + number_format_i18n( $count ), + number_format_i18n( $min_count ) + ); + $confidence += 0.4; + } + + // 3. Archive-specific: trashed count. + if ( 'archive' === $module ) { + $min_trash = (int) ( $rule['min_trash_count'] ?? 0 ); + $trash_count = self::trashed_post_count(); + if ( $trash_count < $min_trash ) { + $blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d)', $trash_count, $min_trash ); + return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); + } + $reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count ); + $confidence += 0.3; + } + + // 4. Classifier consultation (zone modules). + if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) { + $min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 ); + $cls_score = self::classifier_score( $post_type, $module ); + if ( $cls_score < $min_conf ) { + $blockers[] = sprintf( + '⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f)', + $post_type, + self::module_to_zone( $module ), + $cls_score, + $min_conf + ); + return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state ); + } + $reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f)', $cls_score, $min_conf ); + $confidence = min( 1.0, $confidence + $cls_score * 0.3 ); + } + + // 5. Priority bonus: warm/archive recommended_first. + // v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like + // `warm` (no compat / no post_type gate) crosses the actionable + // threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget + // can surface it without admin chasing config. + if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) { + $confidence = min( 1.0, $confidence + 0.5 ); + $reasons[] = '⭐ 入門首選(低風險)'; + } + + // Cap confidence. + $confidence = min( 1.0, max( 0.0, $confidence ) ); + + return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state ); + } + + /** + * Filter detect_all() down to actionable enable-recommendations + * above a confidence threshold. + * + * @param float $min_confidence Threshold (0..1, default 0.5). + * @return array + */ + public static function get_actionable( float $min_confidence = 0.5 ): array { + $all = self::detect_all(); + $out = array(); + foreach ( $all as $module => $r ) { + if ( ! empty( $r['available'] ) + && 'enable' === ( $r['recommendation'] ?? '' ) + && (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) { + $out[ $module ] = $r; + } + } + // Sort by confidence desc. + uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] ); + return $out; + } + + /** + * Get the cached results from wp_options (for dashboard widget — no live query). + * + * @return array {results:array, generated_at:string}|null + */ + public static function get_cached(): ?array { + $v = get_option( self::OPTION_CACHE ); + return is_array( $v ) ? $v : null; + } + + // ─── private helpers ───────────────────────────────────────────────── + + /** + * Build a result envelope. + * + * @param string $module Module slug. + * @param bool $available Whether module passes all checks. + * @param float $confidence 0..1. + * @param string $recommendation 'enable'|'wait'|'skip'. + * @param array $reasons Positive findings. + * @param array $blockers Negative findings. + * @param string $current_state Current FSM state. + * @return array + */ + private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array { + $rule = TMDO_Module_Rules::for_module( $module ) ?? array(); + return array( + 'module' => $module, + 'available' => $available, + 'confidence' => round( $confidence, 2 ), + 'recommendation' => $recommendation, + 'reasons' => $reasons, + 'blockers' => $blockers, + 'description' => (string) ( $rule['description'] ?? '' ), + 'current_state' => $current_state, + 'suggested_action' => $available && 'enable' === $recommendation + ? array( + 'type' => 'set_state', + 'module' => $module, + 'to_state' => 'dual_write', + 'cli' => "wp wpdo mode-set {$module} dual_write", + ) + : null, + ); + } + + /** + * Build a stub result envelope for modules that cannot be evaluated. + * + * @param string $module Module slug. + * @param string $reason Reason code or human message. + * @return array + */ + private static function stub( string $module, string $reason ): array { + return array( + 'module' => $module, + 'available' => false, + 'confidence' => 0.0, + 'recommendation' => 'skip', + 'reasons' => array(), + 'blockers' => array( $reason ), + 'description' => '', + 'current_state' => 'idle', + 'suggested_action' => null, + ); + } + + /** + * Find which required plugins are missing. + * + * @param array $required Plugins required. + * @return array Missing plugin slugs. + */ + private static function check_compat( array $required ): array { + if ( ! class_exists( 'TMDO_Compatibility' ) ) { + return $required; + } + $missing = array(); + foreach ( $required as $plugin ) { + $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, + }; + if ( ! $active ) { + $missing[] = $plugin; + } + } + return $missing; + } + + /** + * Return the published post count for a given post type. + * + * @param string $post_type Post type slug. + * @return int Row count. + */ + private static function post_type_count( string $post_type ): int { + global $wpdb; + $cached_key = 'wpdo_pt_count_' . md5( $post_type ); + $cached = get_transient( $cached_key ); + if ( false !== $cached ) { + return (int) $cached; + } + $count = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')", + $post_type + ) + ); + set_transient( $cached_key, $count, HOUR_IN_SECONDS ); + return $count; + } + + /** + * Total trashed posts (all post types). + * + * @return int + */ + private static function trashed_post_count(): int { + global $wpdb; + $cached = get_transient( 'wpdo_trash_count' ); + if ( false !== $cached ) { + return (int) $cached; + } + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB + set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS ); + return $count; + } + + /** + * Map module → target zone for Classifier consultation. + * + * @param string $module Module slug (e.g. hot_hp_listing). + * @return string Zone (hot|cold|warm|archive). + */ + private static function module_to_zone( string $module ): string { + if ( str_starts_with( $module, 'hot_' ) ) { + return 'hot'; + } + if ( str_starts_with( $module, 'cold_' ) ) { + return 'cold'; + } + return $module; // 'warm' / 'archive'. + } + + /** + * Classifier confidence aggregate for a (post_type, target_zone). + * + * @param string $post_type Post type. + * @param string $module Module slug → mapped to zone. + * @return float 0..1 average confidence of meta_keys whose suggested_zone matches. + */ + private static function classifier_score( string $post_type, string $module ): float { + if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) { + return 0.0; + } + $zone = self::module_to_zone( $module ); + try { + $results = TMDO_Zone_Classifier::analyze( $post_type, 50 ); + } catch ( Throwable $e ) { + return 0.0; + } + if ( ! is_array( $results ) || empty( $results ) ) { + return 0.0; + } + $total = 0.0; + $count = 0; + foreach ( $results as $field ) { + if ( ( $field['suggested_zone'] ?? '' ) === $zone ) { + $total += (float) ( $field['confidence'] ?? 0 ); + ++$count; + } + } + return $count > 0 ? $total / $count : 0.0; + } +} diff --git a/includes/advisor/class-tmdo-module-rules.php b/includes/advisor/class-tmdo-module-rules.php new file mode 100644 index 0000000..e88eedf --- /dev/null +++ b/includes/advisor/class-tmdo-module-rules.php @@ -0,0 +1,171 @@ + 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。', + ), + 'wc_orders' => array( + 'compat_required' => array( 'woocommerce' ), + 'post_type_required' => 'shop_order', + 'min_post_count' => 100, + 'description' => 'WooCommerce 訂單 ≥ 100 筆且未啟用 HPOS 時,建議啟用 module 把 vendor commission 搬到 wp_wpdo_wc_commissions。', + ), + 'latepoint' => array( + 'compat_required' => array( 'latepoint' ), + 'post_type_required' => null, + 'description' => 'LatePoint 預約系統啟用時建議開啟,把 booking meta 從 wp_postmeta 搬出。', + ), + + // ─── Zone modules(任何站皆可,但仍依環境推薦)──────────────────── + 'warm' => array( + 'compat_required' => array(), + 'post_type_required' => null, + 'min_post_count' => 0, + 'priority' => 'recommended_first', + 'description' => 'Warm zone 處理 view counts / TTL 暫存;任何站都可啟用,幾乎零風險。', + ), + 'archive' => array( + 'compat_required' => array(), + 'post_type_required' => null, + 'min_trash_count' => 50, + 'description' => '已 trashed posts ≥ 50 個時,啟用 archive module 可釋放 wp_postmeta 空間(自動 gzip 壓縮)。', + ), + 'hot_hp_listing' => array( + 'compat_required' => array( 'hivepress' ), + 'post_type_required' => 'hp_listing', + 'min_post_count' => 100, + 'consult_classifier' => true, + 'min_classifier_confidence' => 0.6, + 'description' => '使用 HivePress + listing ≥ 100 條 + Classifier confidence ≥ 0.6 時,把高頻欄位搬到 wp_wpdo_hot_hp_listing 可大幅加速 WP_Query。', + ), + 'cold_hp_listing' => array( + 'compat_required' => array( 'hivepress' ), + 'post_type_required' => 'hp_listing', + 'min_post_count' => 100, + 'consult_classifier' => true, + 'min_classifier_confidence' => 0.5, + 'description' => '低頻 listing meta(如 settings / preferences)搬到 cold zone,減少 hot path 的 postmeta JOIN。', + ), + 'hot_hp_vendor' => array( + 'compat_required' => array( 'hivepress' ), + 'post_type_required' => 'hp_vendor', + 'min_post_count' => 50, + 'consult_classifier' => true, + 'min_classifier_confidence' => 0.6, + 'description' => 'HivePress 商家 ≥ 50 個時,把高頻 vendor meta 搬到 hot zone 可加速 vendor 列表頁。', + ), + 'cold_hp_vendor' => array( + 'compat_required' => array( 'hivepress' ), + 'post_type_required' => 'hp_vendor', + 'min_post_count' => 50, + 'consult_classifier' => true, + 'min_classifier_confidence' => 0.5, + 'description' => '低頻 vendor meta 搬到 cold zone。', + ), + ); + + /** + * Return all rules with filter applied. + * + * @return array + */ + public static function all(): array { + $rules = self::DEFAULT_RULES; + if ( function_exists( 'apply_filters' ) ) { + $filtered = apply_filters( 'wpdo/module_rules', $rules ); + if ( is_array( $filtered ) ) { + return $filtered; + } + } + return $rules; + } + + /** + * Return rule for one module. + * + * @param string $module Module slug. + * @return array|null Rule or null when no rule registered. + */ + public static function for_module( string $module ): ?array { + $rules = self::all(); + return $rules[ $module ] ?? null; + } + + /** + * List all module slugs that have rules registered. + * + * @return array + */ + public static function known_modules(): array { + return array_keys( self::all() ); + } +} diff --git a/includes/back-compat/interface-wpdo-entity-adapter-alias.php b/includes/back-compat/interface-wpdo-entity-adapter-alias.php new file mode 100644 index 0000000..470b891 --- /dev/null +++ b/includes/back-compat/interface-wpdo-entity-adapter-alias.php @@ -0,0 +1,30 @@ + 'hp_listing', 'meta_query' => [...] ] ); + * + * @package WP_Data_Optimizer + * @since 2.0.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Public read/write facade. All methods are static and side-effect free + * outside of the underlying meta API. + * + * Backwards compatibility: this class is the long-lived contract for + * partner plugins. Internal implementation may change between versions + * (e.g. switch from postmeta passthrough to Hook Bus dispatch); the + * signatures here are guaranteed stable across the 2.x line. + */ +final class TMDO_API { + + /** + * Get a single meta field for a post. + * + * Internally: + * - When the field is registered to a Zone, reads from the zone table + * once cutover. + * - When the field is unregistered, falls back to native get_post_meta() + * transparently. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param bool $single Whether to return a single scalar. + * @return mixed Meta value (or empty string when single=true and absent). + */ + public static function get_field( int $post_id, string $meta_key, bool $single = true ) { + // Defer to standard WordPress metadata API. The Hook Bus / Sync_Bridge + // transparently routes the read to the appropriate zone table when + // the module is in a read-custom state (cutover/cleanup/complete). + // This single line of indirection is the contract that lets us swap + // internal storage without breaking callers. + return get_post_meta( $post_id, $meta_key, $single ); + } + + /** + * Get a single meta field for any entity (post/user/term/comment). + * + * @param string $entity_type One of: post, user, term, comment. + * @param int $entity_id Entity ID. + * @param string $meta_key Meta key. + * @param bool $single Whether to return a single scalar. + * @return mixed + */ + public static function get_entity( string $entity_type, int $entity_id, string $meta_key, bool $single = true ) { + return match ( $entity_type ) { + 'post' => get_post_meta( $entity_id, $meta_key, $single ), + 'user' => get_user_meta( $entity_id, $meta_key, $single ), + 'term' => get_term_meta( $entity_id, $meta_key, $single ), + 'comment' => get_comment_meta( $entity_id, $meta_key, $single ), + default => null, + }; + } + + /** + * Set a single meta field for a post. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value New value. + * @return bool|int Result of update_post_meta. + */ + public static function set_field( int $post_id, string $meta_key, $meta_value ) { + return update_post_meta( $post_id, $meta_key, $meta_value ); + } + + /** + * Set a meta field for any entity. + * + * @param string $entity_type One of: post, user, term, comment. + * @param int $entity_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value New value. + * @return bool|int + */ + public static function set_entity( string $entity_type, int $entity_id, string $meta_key, $meta_value ) { + return match ( $entity_type ) { + 'post' => update_post_meta( $entity_id, $meta_key, $meta_value ), + 'user' => update_user_meta( $entity_id, $meta_key, $meta_value ), + 'term' => update_term_meta( $entity_id, $meta_key, $meta_value ), + 'comment' => update_comment_meta( $entity_id, $meta_key, $meta_value ), + default => false, + }; + } + + /** + * Run a meta_query — currently a thin wrapper around WP_Query. + * + * Future: when v2.0.0 entity adapters are fully wired, this method will + * compile cross-entity queries via TMDO_Query_Compiler. + * + * @param array $args WP_Query-compatible arguments. + * @return WP_Query + */ + public static function query( array $args ): WP_Query { + return new WP_Query( $args ); + } + + /** + * Check whether a field is registered to WPDO (zone or entity). + * + * Useful for partner plugins to choose between TMDO_API and native APIs. + * + * @param string $entity_type One of: post, user, term, comment. + * @param string $meta_key Meta key. + * @return bool True when the field is registered. + */ + public static function is_field_registered( string $entity_type, string $meta_key ): bool { + // Zone path (post entity only): TMDO_Schema_Registry. + if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) ) { + $registry = TMDO_Schema_Registry::instance(); + // We can't filter without a post_type — search across all hot/cold/warm. + foreach ( $registry->all() as $field ) { + if ( ( $field['meta_key'] ?? '' ) === $meta_key ) { + return true; + } + } + } + + // Entity path: TMDO_Entity_Registry (PR-1 ported). + if ( class_exists( 'TMDO_Entity_Registry' ) ) { + return TMDO_Entity_Registry::is_managed( $entity_type, $meta_key ); + } + + return false; + } + + /** + * Trace the storage backend currently serving a field. + * + * Returns one of: 'postmeta' (native fallback), 'zone_hot', 'zone_warm', + * 'zone_cold', 'zone_archive', 'entity_table', 'unregistered'. + * + * Useful for `wp wpdo doctor` and Conflict Detector reports. + * + * @param string $entity_type Entity type. + * @param string $meta_key Meta key. + * @param string $post_type Post type, for zone routing (post entity only). + * @return string Storage label. + */ + public static function trace_storage( string $entity_type, string $meta_key, string $post_type = '' ): string { + if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) && '' !== $post_type ) { + $field = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key ); + if ( $field ) { + $module = ( $field['zone'] ?? 'hot' ) . '_' . sanitize_key( $post_type ); + if ( class_exists( 'TMDO_Feature_Flags' ) && TMDO_Feature_Flags::is_read_custom( $module ) ) { + return 'zone_' . $field['zone']; + } + return 'postmeta'; + } + } + + if ( class_exists( 'TMDO_Entity_Registry' ) && TMDO_Entity_Registry::is_managed( $entity_type, $meta_key ) ) { + return 'entity_table'; + } + + return 'unregistered'; + } +} diff --git a/includes/class-tmdo-back-compat.php b/includes/class-tmdo-back-compat.php new file mode 100644 index 0000000..a2353ac --- /dev/null +++ b/includes/class-tmdo-back-compat.php @@ -0,0 +1,182 @@ + 'WPDO_API', + 'TMDO_Schema_Registry' => 'WPDO_Schema_Registry', + 'TMDO_Entity_Registry' => 'WPDO_Entity_Registry', + 'TMDO_Custom_Table_Registry' => 'WPDO_Custom_Table_Registry', + 'TMDO_Feature_Flags' => 'WPDO_Feature_Flags', + 'TMDO_DB' => 'WPDO_DB', + 'TMDO_Crypto' => 'WPDO_Crypto', + 'TMDO_Logger' => 'WPDO_Logger', + 'TMDO_Capability' => 'WPDO_Capability', + 'TMDO_Safe_Unserialize' => 'WPDO_Safe_Unserialize', + // Engine / Migration / Adapters(second-tier,部分 sister plugin 已直接呼叫) + 'TMDO_Hook_Bus' => 'WPDO_Hook_Bus', + 'TMDO_Mode_Manager' => 'WPDO_Mode_Manager', + 'TMDO_Migration_Engine' => 'WPDO_Migration_Engine', + 'TMDO_Migration_Orchestrator' => 'WPDO_Migration_Orchestrator', + 'TMDO_Entity_Migration_Engine' => 'WPDO_Entity_Migration_Engine', + 'TMDO_Entity_Health' => 'WPDO_Entity_Health', + 'TMDO_Schema_Manager' => 'WPDO_Schema_Manager', + 'TMDO_Sync_Bridge' => 'WPDO_Sync_Bridge', + 'TMDO_Query_Router' => 'WPDO_Query_Router', + 'TMDO_Post_Query_Router' => 'WPDO_Post_Query_Router', + 'TMDO_Hook_Bus_Bridge' => 'WPDO_Hook_Bus_Bridge', + 'TMDO_Conflict_Monitor' => 'WPDO_Conflict_Monitor', + 'TMDO_Compatibility' => 'WPDO_Compatibility', + 'TMDO_Installer' => 'WPDO_Installer', + 'TMDO_Cache_Layer' => 'WPDO_Cache_Layer', + 'TMDO_Zone_Classifier' => 'WPDO_Zone_Classifier', + 'TMDO_Core' => 'WPDO_Core', + 'TMDO_REST_API' => 'WPDO_REST_API', + 'TMDO_Snapshot_Manager' => 'WPDO_Snapshot_Manager', + 'TMDO_Snapshot_Writer' => 'WPDO_Snapshot_Writer', + 'TMDO_Snapshot_Reader' => 'WPDO_Snapshot_Reader', + 'TMDO_Snapshot_Pruner' => 'WPDO_Snapshot_Pruner', + 'TMDO_FSM_Guard' => 'WPDO_FSM_Guard', + 'TMDO_Site_Health' => 'WPDO_Site_Health', + 'TMDO_Health_Cron' => 'WPDO_Health_Cron', + 'TMDO_Email_Notifier' => 'WPDO_Email_Notifier', + 'TMDO_Slack_Notifier' => 'WPDO_Slack_Notifier', + 'TMDO_Discord_Notifier' => 'WPDO_Discord_Notifier', + 'TMDO_Telegram_Notifier' => 'WPDO_Telegram_Notifier', + 'TMDO_Monthly_Summary' => 'WPDO_Monthly_Summary', + 'TMDO_Site_Metrics_Collector' => 'WPDO_Site_Metrics_Collector', + 'TMDO_FSM_Advisor' => 'WPDO_FSM_Advisor', + 'TMDO_Module_Detector' => 'WPDO_Module_Detector', + 'TMDO_FSM_Automator' => 'WPDO_FSM_Automator', + 'TMDO_Module_Rules' => 'WPDO_Module_Rules', + 'TMDO_CSV_Writer' => 'WPDO_CSV_Writer', + 'TMDO_Export' => 'WPDO_Export', + 'TMDO_CLI' => 'WPDO_CLI', + 'TMDO_CLI_V2' => 'WPDO_CLI_V2', + 'TMDO_CLI_Member' => 'WPDO_CLI_Member', + 'TMDO_CLI_Post' => 'WPDO_CLI_Post', + 'TMDO_CLI_Term_Comment' => 'WPDO_CLI_Term_Comment', + 'TMDO_Member_Fields' => 'WPDO_Member_Fields', + 'TMDO_Post_Fields' => 'WPDO_Post_Fields', + 'TMDO_Points_Manager' => 'WPDO_Points_Manager', + 'TMDO_Demo_Entity_Counter' => 'WPDO_Demo_Entity_Counter', + 'TMDO_Postmeta_Cleaner' => 'WPDO_Postmeta_Cleaner', + 'TMDO_Termmeta_Cleaner' => 'WPDO_Termmeta_Cleaner', + 'TMDO_Commentmeta_Cleaner' => 'WPDO_Commentmeta_Cleaner', + 'TMDO_Post_Stress_Tester' => 'WPDO_Post_Stress_Tester', + 'TMDO_User_Stress_Tester' => 'WPDO_User_Stress_Tester', + 'TMDO_Term_Stress_Tester' => 'WPDO_Term_Stress_Tester', + 'TMDO_Comment_Stress_Tester' => 'WPDO_Comment_Stress_Tester', + 'TMDO_Post_Shadow_Verifier' => 'WPDO_Post_Shadow_Verifier', + 'TMDO_Term_Comment_Shadow_Verifier' => 'WPDO_Term_Comment_Shadow_Verifier', + 'TMDO_Term_Comment_Backfill' => 'WPDO_Term_Comment_Backfill', + 'TMDO_Term_Comment_Misc_Bucket' => 'WPDO_Term_Comment_Misc_Bucket', + 'TMDO_Term_Comment_Garbage_Filter' => 'WPDO_Term_Comment_Garbage_Filter', + 'TMDO_Setup_Wizard' => 'WPDO_Setup_Wizard', + 'TMDO_Dashboard_Widget' => 'WPDO_Dashboard_Widget', + 'TMDO_Help_Tabs' => 'WPDO_Help_Tabs', + 'TMDO_Admin' => 'WPDO_Admin', + 'TMDO_V2_Upgrader' => 'WPDO_V2_Upgrader', + 'TMDO_SQLite_Compat' => 'WPDO_SQLite_Compat', + 'TMDO_Type_Caster' => 'WPDO_Type_Caster', + 'TMDO_Audit_Logger' => 'WPDO_Audit_Logger', + 'TMDO_Shadow_Diff_Logger' => 'WPDO_Shadow_Diff_Logger', + 'TMDO_Conflict_Detector' => 'WPDO_Conflict_Detector', + 'TMDO_Cache_Orchestrator' => 'WPDO_Cache_Orchestrator', + 'TMDO_Query_Compiler' => 'WPDO_Query_Compiler', + 'TMDO_Auto_Promoter' => 'WPDO_Auto_Promoter', + 'TMDO_Adapter_Post' => 'WPDO_Adapter_Post', + 'TMDO_Adapter_User' => 'WPDO_Adapter_User', + 'TMDO_Adapter_Term' => 'WPDO_Adapter_Term', + 'TMDO_Adapter_Comment' => 'WPDO_Adapter_Comment', + 'TMDO_Options_Manager' => 'WPDO_Options_Manager', + 'TMDO_Hot_Migration' => 'WPDO_Hot_Migration', + 'TMDO_Warm_Migration' => 'WPDO_Warm_Migration', + 'TMDO_Cold_Migration' => 'WPDO_Cold_Migration', + 'TMDO_Archive_Migration' => 'WPDO_Archive_Migration', + 'TMDO_Migration_Base' => 'WPDO_Migration_Base', + 'TMDO_Post_Migration' => 'WPDO_Post_Migration', + 'TMDO_Zone_Hot' => 'WPDO_Zone_Hot', + 'TMDO_Zone_Warm' => 'WPDO_Zone_Warm', + 'TMDO_Zone_Cold' => 'WPDO_Zone_Cold', + 'TMDO_Zone_Archive' => 'WPDO_Zone_Archive', + 'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base', + 'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base', + 'TMDO_Notifier' => 'WPDO_Notifier', +); + +foreach ( $tmdo_class_aliases as $tmdo_class => $wpdo_alias ) { + if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) { + class_alias( $tmdo_class, $wpdo_alias ); + } +} +unset( $tmdo_class_aliases, $tmdo_class, $wpdo_alias ); + +// trait + interface back-compat(PHP class_alias 不支援 trait/interface,用 wrapper 解決)。 +require_once __DIR__ . '/back-compat/trait-wpdo-anti-eav-aware-alias.php'; +require_once __DIR__ . '/back-compat/interface-wpdo-entity-adapter-alias.php'; + +// ── 常數別名(部分 sister plugin 直接讀取常數)───────────────────────── +if ( ! defined( 'WPDO_VERSION' ) ) { + define( 'WPDO_VERSION', TMDO_VERSION ); +} +if ( ! defined( 'WPDO_DB_VERSION' ) ) { + define( 'WPDO_DB_VERSION', TMDO_DB_VERSION ); +} +if ( ! defined( 'WPDO_PLUGIN_DIR' ) ) { + define( 'WPDO_PLUGIN_DIR', TMDO_PATH ); +} +if ( ! defined( 'WPDO_PLUGIN_URL' ) ) { + define( 'WPDO_PLUGIN_URL', TMDO_URL ); +} +if ( ! defined( 'WPDO_PLUGIN_FILE' ) ) { + define( 'WPDO_PLUGIN_FILE', TMDO_FILE ); +} +if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) { + define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX ); +} +if ( ! defined( 'WPDO_CACHE_GROUP' ) ) { + define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP ); +} +if ( ! defined( 'WPDO_IS_SQLITE' ) ) { + define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE ); +} +if ( ! defined( 'WPDO_IS_MYSQL' ) ) { + define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL ); +} + +// ── Hook dual-fire bridge ───────────────────────────────────────────────── +// Phase 1 過渡期:tmdo_* 與 wpdo_* hook 雙向轉發,讓既有 listener 仍能接收事件。 +foreach ( array( + 'wpdo_register_fields', + 'wpdo_register_entity_fields', + 'wpdo_register_custom_tables', + 'wpdo_after_write', + 'wpdo_after_delete', + 'wpdo_schema_updated', + 'wpdo_bridge_mode_changed', + 'wpdo_bridge_emergency_disabled', + 'wpdo_auto_promoted', +) as $tmdo_back_compat_hook ) { + $tmdo_new_hook = preg_replace( '/^wpdo_/', 'tmdo_', $tmdo_back_compat_hook ); + add_action( + $tmdo_new_hook, + static function ( ...$args ) use ( $tmdo_back_compat_hook ) { + do_action_ref_array( $tmdo_back_compat_hook, $args ); + }, + 1 + ); +} +unset( $tmdo_back_compat_hook, $tmdo_new_hook ); diff --git a/includes/class-tmdo-cache-layer.php b/includes/class-tmdo-cache-layer.php new file mode 100644 index 0000000..8a14909 --- /dev/null +++ b/includes/class-tmdo-cache-layer.php @@ -0,0 +1,236 @@ +get_cold_meta_keys( $post_type ); + if ( empty( $cold_keys ) ) { + return; + } + + $group = 'wpdo_cold_' . sanitize_key( $post_type ); + + // Check which IDs are already cached. + $uncached = array(); + foreach ( $post_ids as $pid ) { + $cached = wp_cache_get( "cold_{$pid}", $group ); + if ( false === $cached ) { + $uncached[] = (int) $pid; + } + } + + if ( empty( $uncached ) ) { + return; + } + + // Bulk fetch from cold table. + global $wpdb; + $table = TMDO_Zone_Cold::table( $post_type ); + $placeholders = implode( ',', array_fill( 0, count( $uncached ), '%d' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table from TMDO_Zone_Cold::table(); $placeholders built from array_fill with %d. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, data FROM `{$table}` WHERE post_id IN ({$placeholders})", + ...$uncached + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + + // Get cache TTL from registry. + $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type ); + $ttl = HOUR_IN_SECONDS; + foreach ( $fields as $field ) { + if ( ! empty( $field['cache_ttl'] ) ) { + $ttl = (int) $field['cache_ttl']; + break; + } + } + + // Index fetched rows by post_id. + $fetched = array(); + foreach ( $rows ?: array() as $row ) { + $data = json_decode( $row['data'], true ); + $fetched[ (int) $row['post_id'] ] = is_array( $data ) ? $data : array(); + } + + // 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 ); + } + } + + /** + * Warm the cache for a single post after it's saved/updated. + * + * Hooked to 'save_post' to ensure fresh data is in cache. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + */ + 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 ) ) { + return; + } + + // Force a fresh read from DB (bypasses cache). + $group = 'wpdo_cold_' . sanitize_key( $post_type ); + wp_cache_delete( "cold_{$post_id}", $group ); + + // Re-read will populate cache. + TMDO_Zone_Cold::get_blob( $post_id, $post_type ); + } + + /** + * Flush all cold cache for a post type. + * + * Useful after bulk imports or schema changes. + * + * @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. + } + + /** + * Get cache statistics for the admin dashboard. + * + * @return array{groups: array, prefetch_support: bool} + */ + public static function get_stats(): array { + $registry = TMDO_Schema_Registry::instance(); + $cold_types = $registry->get_cold_post_types(); + $groups = array(); + + foreach ( $cold_types as $pt ) { + $group = 'wpdo_cold_' . sanitize_key( $pt ); + $keys = $registry->get_cold_meta_keys( $pt ); + $fields = $registry->get_zone_fields_for_type( 'cold', $pt ); + $ttl = HOUR_IN_SECONDS; + foreach ( $fields as $field ) { + if ( ! empty( $field['cache_ttl'] ) ) { + $ttl = (int) $field['cache_ttl']; + break; + } + } + + $groups[] = array( + 'post_type' => $pt, + 'group' => $group, + 'meta_keys' => $keys, + 'ttl' => $ttl, + ); + } + + return array( + 'groups' => $groups, + 'prefetch_support' => true, + 'flush_support' => function_exists( 'wp_cache_flush_group' ), + ); + } + + /** + * Register WordPress hooks for cache warming. + */ + public static function register_hooks(): void { + add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 99, 2 ); + + // Prefetch on HivePress archive pages. + add_action( 'loop_start', array( __CLASS__, 'on_loop_start' ), 10, 1 ); + } + + /** + * Warm cache after post save. + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post object. + * @return void + */ + public static function on_save_post( int $post_id, \WP_Post $post ): void { + if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { + return; + } + + $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post->post_type ); + if ( ! empty( $cold_keys ) ) { + self::warm_post( $post_id, $post->post_type ); + } + } + + /** + * Prefetch cold data at the start of a main query loop. + * + * @param \WP_Query $query The current WP_Query object. + * @return void + */ + public static function on_loop_start( \WP_Query $query ): void { + if ( ! $query->is_main_query() || is_admin() ) { + return; + } + + $posts = $query->posts; + if ( empty( $posts ) ) { + return; + } + + $post_type = $query->get( 'post_type' ); + if ( is_array( $post_type ) ) { + $post_type = reset( $post_type ); + } + + if ( ! $post_type ) { + return; + } + + $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); + if ( empty( $cold_keys ) ) { + return; + } + + $post_ids = wp_list_pluck( $posts, 'ID' ); + self::prefetch( $post_ids, $post_type ); + } +} diff --git a/includes/class-tmdo-capability.php b/includes/class-tmdo-capability.php new file mode 100644 index 0000000..d61030a --- /dev/null +++ b/includes/class-tmdo-capability.php @@ -0,0 +1,49 @@ +comments (comment_ID, comment_post_ID, comment_author, ...) + * - Meta: $wpdb->commentmeta + * - Flat: wpdo_comment_hp_review (single hp_rating key) + wpdo_comment_misc + * - Realistic mode: wp_insert_comment() + update_comment_meta() + * - Fast mode: bulk INSERT to wp_comments + wp_commentmeta + * + * Test comments are identified by `comment_author_email LIKE '%@wpdo-stress.local'`. + * Each comment is attached to a caller-specified `comment_post_ID` (must exist). + * + * 🔒 v2.13.x frozen contract: never touches user / post / term entities or + * their flat tables. + * + * @package WP_Data_Optimizer + * @since 2.13.1 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->comments / wp_commentmeta WP-managed; meta_key strings static class constants; user-controlled values use prepare() placeholders. + +/** + * Async bulk fixture generator for comment entity stress tests. + */ +final class TMDO_Comment_Stress_Tester { + + /** Email domain marker — comments matching this are stress test fixtures. */ + public const TEST_EMAIL_DOMAIN = 'wpdo-stress.local'; + + /** Hard cap to prevent runaway calls. */ + public const MAX_COUNT = 100000; + + // State machine constants. + public const OPT_STATE = 'wpdo_comment_stress_test_state'; + public const CRON_HOOK = 'wpdo_comment_stress_test_batch'; + public const CANCEL_FLAG = 'wpdo_comment_stress_cancel_flag'; + public const DEFAULT_BATCH_SIZE = 200; + public const MAX_BATCH_SIZE = 1000; + public const MIN_BATCH_DELAY_SEC = 1; + public const BATCH_DEADLINE_SEC = 8; + public const MODE_FAST = 'fast'; + public const MODE_REALISTIC = 'realistic'; + + /** + * Per-key seed map (hp_review group: just hp_rating). + * + * @var array|null + */ + private static ?array $seed_map_cache = null; + + // ───────────────────────────────────────────────────────────────────────── + // Public API — sync helpers (also used internally by state machine batches) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Bulk-create N stress test comments on the given post via direct SQL + * (Fast mode — bypasses WP filter chain). + * + * @param int $post_id WP post ID to attach comments to. + * @param int $count Number of comments to insert. Capped at MAX_COUNT. + * @return array{created:int,post_id:int,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When inputs invalid. + */ + public static function create( int $post_id, int $count ): array { + self::validate_inputs( $post_id, $count ); + + global $wpdb; + + $first_id = null; + $last_id = null; + $created = 0; + $seed_map = self::seed_map(); + $now = current_time( 'mysql' ); + $now_gmt = current_time( 'mysql', true ); + + for ( $i = 0; $i < $count; $i++ ) { + $suffix = wp_generate_password( 8, false ); + $ok = $wpdb->insert( + $wpdb->comments, + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'WPDO Stress ' . $suffix, + 'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN, + 'comment_author_url' => '', + 'comment_author_IP' => '127.0.0.1', + 'comment_date' => $now, + 'comment_date_gmt' => $now_gmt, + 'comment_content' => 'Stress test comment ' . $suffix, + 'comment_karma' => 0, + 'comment_approved' => '1', + 'comment_agent' => 'wpdo-stress-tester', + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'user_id' => 0, + ) + ); + if ( ! $ok ) { + continue; + } + $comment_id = (int) $wpdb->insert_id; + if ( null === $first_id ) { + $first_id = $comment_id; + } + $last_id = $comment_id; + ++$created; + + // Direct INSERT to wp_commentmeta (bypassing Hook Bus). For aeav_only + // mode validation use create_realistic() instead. + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + $wpdb->insert( + $wpdb->commentmeta, + array( + 'comment_id' => $comment_id, + 'meta_key' => $meta_key, + 'meta_value' => (string) $value, + ) + ); + } + } + + return array( + 'created' => $created, + 'post_id' => $post_id, + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Realistic-mode counterpart — uses wp_insert_comment() + + * update_comment_meta() so Hook Bus / entity registry / mode_manager all + * engage on the write path. + * + * @param int $post_id WP post ID. + * @param int $count Number of comments. + * @return array{created:int,post_id:int,mode:string,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When inputs invalid. + */ + public static function create_realistic( int $post_id, int $count ): array { + self::validate_inputs( $post_id, $count ); + + $first_id = null; + $last_id = null; + $created = 0; + $seed_map = self::seed_map(); + + for ( $i = 0; $i < $count; $i++ ) { + $suffix = wp_generate_password( 8, false ); + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'WPDO Stress ' . $suffix, + 'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN, + 'comment_content' => 'Stress test comment ' . $suffix, + 'comment_approved' => 1, + 'comment_type' => 'comment', + ) + ); + if ( ! $comment_id ) { + continue; + } + $comment_id = (int) $comment_id; + if ( null === $first_id ) { + $first_id = $comment_id; + } + $last_id = $comment_id; + ++$created; + + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_comment_meta( $comment_id, $meta_key, $value ); + } + } + + return array( + 'created' => $created, + 'post_id' => $post_id, + 'mode' => 'realistic', + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Count comments whose author email ends with TEST_EMAIL_DOMAIN. + * + * @return int + */ + public static function count_test_comments(): int { + global $wpdb; + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_email LIKE %s", + '%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN ) + ) + ); + } + + /** + * Delete every stress-test comment + its commentmeta + flat table rows. + * + * @return array{deleted_comments:int,deleted_meta:int,deleted_flat:int} + */ + public static function cleanup(): array { + global $wpdb; + + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $comment_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_author_email LIKE %s", + '%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN ) + ) + ); + + if ( empty( $comment_ids ) ) { + return array( + 'deleted_comments' => 0, + 'deleted_meta' => 0, + 'deleted_flat' => 0, + ); + } + + $id_list = implode( ',', array_map( 'absint', $comment_ids ) ); + $flat_deleted = 0; + + foreach ( self::get_comment_flat_tables() as $tbl ) { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) ); + if ( ! $exists ) { + continue; + } + $rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE comment_id IN ({$id_list})" ); + $flat_deleted += $rows; + } + + $meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->commentmeta} WHERE comment_id IN ({$id_list})" ); + $comment_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->comments} WHERE comment_ID IN ({$id_list})" ); + + delete_option( self::OPT_STATE ); + delete_transient( self::CANCEL_FLAG ); + + return array( + 'deleted_comments' => $comment_deleted, + 'deleted_meta' => $meta_deleted, + 'deleted_flat' => $flat_deleted, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // State machine + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start an async stress run. + * + * @param int $post_id Post ID to attach comments to. + * @param int $target Total comments to create. + * @param string $mode MODE_FAST | MODE_REALISTIC. + * @param int $batch_size Per-batch insert count. + * @return array{ok:bool,error?:string,state?:array} + */ + public static function start( int $post_id, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array { + if ( $post_id < 1 || ! self::post_exists( $post_id ) ) { + return array( + 'ok' => false, + 'error' => 'unknown_post: ' . $post_id, + ); + } + if ( $target < 1 ) { + return array( + 'ok' => false, + 'error' => 'target must be >= 1', + ); + } + if ( $target > self::MAX_COUNT ) { + return array( + 'ok' => false, + 'error' => 'target too large (max ' . self::MAX_COUNT . ')', + ); + } + if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) { + return array( + 'ok' => false, + 'error' => 'invalid mode', + ); + } + $batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) ); + + $current = self::get_state(); + if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) { + return array( + 'ok' => false, + 'error' => 'already_running', + 'state' => $current, + ); + } + + $state = array( + 'job_id' => uniqid( 'cstress_', true ), + 'status' => 'running', + 'mode' => $mode, + 'post_id' => $post_id, + 'target' => $target, + 'batch_size' => $batch_size, + 'started_at' => time(), + 'processed' => 0, + 'batches_done' => 0, + 'batches_log' => array(), + 'errors' => array(), + 'peak_memory' => 0, + 'completed_at' => null, + 'last_pushed_at' => 0, + 'benchmark' => null, + ); + update_option( self::OPT_STATE, $state, false ); + + delete_transient( self::CANCEL_FLAG ); + + wp_clear_scheduled_hook( self::CRON_HOOK ); + wp_schedule_single_event( time(), self::CRON_HOOK ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Cancel a running stress test. + * + * @return array{ok:bool,state?:array,message?:string} + */ + public static function cancel(): array { + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'ok' => true, + 'message' => 'no_active_job', + ); + } + + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state = self::get_state(); + $state['status'] = 'cancelled'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Read raw state. + * + * @return array + */ + public static function get_state(): array { + $state = get_option( self::OPT_STATE, array() ); + return is_array( $state ) ? $state : array(); + } + + /** + * Get progress with computed pct/rate/ETA. + * + * @param bool $pump When true, opportunistically pump. + * @return array + */ + public static function get_progress( bool $pump = true ): array { + if ( $pump ) { + self::pump_if_due(); + } + + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'status' => 'idle', + 'processed' => 0, + 'target' => 0, + 'pct' => 0, + ); + } + + $processed = (int) ( $state['processed'] ?? 0 ); + $target = (int) ( $state['target'] ?? 0 ); + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? 0 ); + + $now = $ended > 0 ? $ended : time(); + $elapsed = max( 1, $now - $started ); + $rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0; + $eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0; + $pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0; + + return array_merge( + $state, + array( + 'pct' => $pct, + 'rate_per_sec' => $rate, + 'elapsed_sec' => $elapsed, + 'eta_sec' => $eta_sec, + 'test_comment_count' => self::count_test_comments(), + ) + ); + } + + /** + * Opportunistic batch pump (transient-locked). + * + * @return void + */ + public static function pump_if_due(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + $last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 ); + if ( time() - $last_pushed_at < 1 ) { + return; + } + + $lock_key = 'wpdo_comment_stress_pump_lock'; + if ( false !== get_transient( $lock_key ) ) { + return; + } + set_transient( $lock_key, 1, 30 ); + + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + + try { + self::run_batch(); + } finally { + delete_transient( $lock_key ); + } + } + + /** + * Cron entrypoint. + * + * @return void + */ + public static function run_batch(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + return; + } + + $post_id = (int) ( $state['post_id'] ?? 0 ); + $target = (int) ( $state['target'] ?? 0 ); + $processed = (int) ( $state['processed'] ?? 0 ); + $batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE ); + $mode = (string) ( $state['mode'] ?? self::MODE_FAST ); + $remaining = $target - $processed; + if ( $remaining <= 0 ) { + self::finalize( $state ); + return; + } + $this_batch_size = min( $batch_size, $remaining ); + + $batch_started = microtime( true ); + try { + if ( self::MODE_FAST === $mode ) { + $inserted = self::run_batch_fast( $post_id, $this_batch_size ); + } else { + $inserted = self::run_batch_realistic( $post_id, $this_batch_size ); + } + } catch ( \Throwable $e ) { + $state['errors'][] = array( + 'time' => time(), + 'message' => $e->getMessage(), + ); + $state['status'] = 'failed'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'comment_stress_test_batch_failed', array( 'message' => $e->getMessage() ) ); + } + return; + } + $batch_elapsed = microtime( true ) - $batch_started; + + $latest = self::get_state(); + if ( empty( $latest ) ) { + return; + } + $is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG ); + + $latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted; + $latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1; + $latest['batches_log'][] = array( + 'n' => $inserted, + 'duration_ms' => (int) round( $batch_elapsed * 1000 ), + ); + if ( count( $latest['batches_log'] ) > 200 ) { + $latest['batches_log'] = array_slice( $latest['batches_log'], -200 ); + } + $latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) ); + $latest['last_pushed_at'] = time(); + + if ( $is_cancelled ) { + update_option( self::OPT_STATE, $latest, false ); + return; + } + + update_option( self::OPT_STATE, $latest, false ); + + if ( $latest['processed'] >= $target ) { + self::finalize( $latest ); + return; + } + + wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK ); + } + + /** + * Run one fast-mode batch. + * + * @param int $post_id Post ID. + * @param int $count Batch size. + * @return int Inserted count. + */ + private static function run_batch_fast( int $post_id, int $count ): int { + $result = self::create( $post_id, $count ); + return (int) ( $result['created'] ?? 0 ); + } + + /** + * Run one realistic-mode batch with deadline + cancel check per comment. + * + * @param int $post_id Post ID. + * @param int $count Batch size. + * @return int Inserted count. + */ + private static function run_batch_realistic( int $post_id, int $count ): int { + $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC; + $inserted = 0; + $seed_map = self::seed_map(); + + for ( $i = 0; $i < $count; $i++ ) { + if ( microtime( true ) > $deadline ) { + break; + } + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + break; + } + + $suffix = wp_generate_password( 8, false ); + $comment_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'WPDO Stress ' . $suffix, + 'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN, + 'comment_content' => 'Stress test comment ' . $suffix, + 'comment_approved' => 1, + 'comment_type' => 'comment', + ) + ); + if ( ! $comment_id ) { + continue; + } + ++$inserted; + + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_comment_meta( (int) $comment_id, $meta_key, $value ); + } + } + return $inserted; + } + + /** + * Finalize: clear cron, run benchmark, persist completed state. + * + * @param array $state Pre-finalize state. + * @return void + */ + private static function finalize( array $state ): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state['status'] = 'benchmarking'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + $report = self::run_benchmark( $state ); + + $state['benchmark'] = $report; + $state['status'] = 'completed'; + update_option( self::OPT_STATE, $state, false ); + } + + /** + * Run benchmark. + * + * @param array|null $state Optional state snapshot. + * @return array + */ + public static function run_benchmark( ?array $state = null ): array { + $state = $state ?? self::get_state(); + $post_id = (int) ( $state['post_id'] ?? 0 ); + + return array( + 'generated_at' => time(), + 'post_id' => $post_id, + 'write' => self::compute_write_metrics( $state ), + 'db_sizes' => self::measure_db_sizes(), + 'query' => self::measure_query_performance(), + ); + } + + /** + * Write throughput metrics from state. + * + * @param array $state State snapshot. + * @return array + */ + private static function compute_write_metrics( array $state ): array { + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? time() ); + $processed = (int) ( $state['processed'] ?? 0 ); + $elapsed = max( 1, $ended - $started ); + $batches = $state['batches_log'] ?? array(); + + $durations = array_column( $batches, 'duration_ms' ); + $min_ms = ! empty( $durations ) ? min( $durations ) : 0; + $max_ms = ! empty( $durations ) ? max( $durations ) : 0; + $avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0; + + return array( + 'mode' => $state['mode'] ?? '', + 'post_id' => (int) ( $state['post_id'] ?? 0 ), + 'target' => (int) ( $state['target'] ?? 0 ), + 'processed' => $processed, + 'elapsed_sec' => $elapsed, + 'rate_per_sec' => round( $processed / $elapsed, 2 ), + 'batches_done' => (int) ( $state['batches_done'] ?? 0 ), + 'batch_min_ms' => $min_ms, + 'batch_max_ms' => $max_ms, + 'batch_avg_ms' => $avg_ms, + 'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ), + ); + } + + /** + * Measure DB sizes for comment-related tables. + * + * @return array + */ + private static function measure_db_sizes(): array { + global $wpdb; + + $tables = array( $wpdb->comments, $wpdb->commentmeta ); + foreach ( self::get_comment_flat_tables() as $tbl ) { + if ( self::table_exists( $tbl ) ) { + $tables[] = $tbl; + } + } + + if ( ! self::is_mysql() ) { + return array_map( + static fn( $t ) => array( + 'table' => $t, + 'rows' => self::table_row_count( $t ), + ), + $tables + ); + } + + $placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", + ...$tables + ), + ARRAY_A + ); + + $out = array(); + foreach ( (array) $rows as $r ) { + $dl = (int) $r['dl']; + $il = (int) $r['il']; + $total = $dl + $il; + $out[] = array( + 'table' => $r['t'], + 'rows' => (int) $r['rows_count'], + 'data_mb' => round( $dl / 1048576, 2 ), + 'index_mb' => round( $il / 1048576, 2 ), + 'total_mb' => round( $total / 1048576, 2 ), + 'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0, + ); + } + return $out; + } + + /** + * Measure 3 representative queries: + * - point: hp_rating = 5 lookup on flat + * - range: hp_rating > 3 ORDER BY DESC on flat + * - EAV baseline: same point query on wp_commentmeta + * + * @return array + */ + private static function measure_query_performance(): array { + global $wpdb; + $flat = $wpdb->prefix . 'wpdo_comment_hp_review'; + if ( ! self::table_exists( $flat ) ) { + return array( 'note' => 'flat_table_missing' ); + } + + return array( + 'point_rating_5' => self::time_query( + "SELECT comment_id FROM `{$flat}` WHERE hp_rating = 5 LIMIT 100" + ), + 'range_rating_top' => self::time_query( + "SELECT comment_id FROM `{$flat}` WHERE hp_rating > 3 ORDER BY hp_rating DESC LIMIT 100" + ), + 'eav_baseline' => self::time_query( + "SELECT comment_id FROM {$wpdb->commentmeta} WHERE meta_key = 'hp_rating' AND meta_value = '5' LIMIT 100" + ), + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Validate inputs for create() / create_realistic(). + * + * @param int $post_id Post ID. + * @param int $count Count. + * @return void + * @throws InvalidArgumentException When invalid. + */ + private static function validate_inputs( int $post_id, int $count ): void { + if ( $post_id < 1 ) { + throw new InvalidArgumentException( 'post_id must be >= 1.' ); + } + if ( $count <= 0 ) { + throw new InvalidArgumentException( 'Count must be > 0.' ); + } + if ( $count > self::MAX_COUNT ) { + $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + } + + /** + * Check whether $post_id exists in wp_posts. + * + * @param int $post_id Post ID. + * @return bool + */ + private static function post_exists( int $post_id ): bool { + global $wpdb; + return (bool) $wpdb->get_var( + $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", $post_id ) + ); + } + + /** + * Lazy-built seed map for hp_review group. + * + * @return array + */ + private static function seed_map(): array { + if ( null !== self::$seed_map_cache ) { + return self::$seed_map_cache; + } + + self::$seed_map_cache = array( + 'hp_rating' => static fn( int $i ) => (string) ( ( $i % 5 ) + 1 ), + ); + return self::$seed_map_cache; + } + + /** + * Names of all wp_wpdo_comment_* flat tables. + * + * @return string[] + */ + private static function get_comment_flat_tables(): array { + global $wpdb; + $prefix = $wpdb->prefix . 'wpdo_comment_'; + return array( + $prefix . 'hp_review', + $prefix . 'misc', + ); + } + + /** + * Memoized table-exists probe. + * + * @param string $table Table name. + * @return bool + */ + private static function table_exists( string $table ): bool { + global $wpdb; + static $cache = array(); + if ( isset( $cache[ $table ] ) ) { + return $cache[ $table ]; + } + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $cache[ $table ] = ( $found === $table ); + return $cache[ $table ]; + } + + /** + * Cheap row count helper (SQLite fallback). + * + * @param string $table Table name. + * @return int + */ + private static function table_row_count( string $table ): int { + global $wpdb; + if ( ! self::table_exists( $table ) ) { + return 0; + } + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + } + + /** + * Time a SQL query. + * + * @param string $sql Query. + * @return array{duration_ms:float} + */ + private static function time_query( string $sql ): array { + global $wpdb; + $start = microtime( true ); + $wpdb->get_results( $sql ); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( 'duration_ms' => round( $elapsed_ms, 2 ) ); + } + + /** + * Detect MySQL vs SQLite. + * + * @return bool + */ + private static function is_mysql(): bool { + return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) ); + } +} diff --git a/includes/class-tmdo-commentmeta-cleaner.php b/includes/class-tmdo-commentmeta-cleaner.php new file mode 100644 index 0000000..2c75650 --- /dev/null +++ b/includes/class-tmdo-commentmeta-cleaner.php @@ -0,0 +1,277 @@ + 0, + 'demo_data' => 0, + 'transients' => 0, + 'orphan_post_meta' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) { + $counts['wxr_import'] = self::count_wxr_import(); + } + if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) { + $counts['demo_data'] = self::count_demo_data(); + } + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $counts['transients'] = self::count_transients(); + } + if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) { + $counts['orphan_post_meta'] = self::count_orphan_post_meta(); + } + + $counts['total'] = $counts['wxr_import'] + $counts['demo_data'] + + $counts['transients'] + $counts['orphan_post_meta']; + return $counts; + } + + /** + * Delete garbage rows for the given target. + * + * @param string $target One of TARGET_* constants. + * @return array{wxr_import:int, demo_data:int, transients:int, orphan_post_meta:int, total:int} + * @throws InvalidArgumentException When $target is not a valid target. + */ + public static function delete_garbage( string $target = self::TARGET_ALL ): array { + self::assert_valid_target( $target ); + + $deleted = array( + 'wxr_import' => 0, + 'demo_data' => 0, + 'transients' => 0, + 'orphan_post_meta' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) { + $deleted['wxr_import'] = self::delete_wxr_import(); + } + if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) { + $deleted['demo_data'] = self::delete_demo_data(); + } + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $deleted['transients'] = self::delete_transients(); + } + if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) { + $deleted['orphan_post_meta'] = self::delete_orphan_post_meta(); + } + + $deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data'] + + $deleted['transients'] + $deleted['orphan_post_meta']; + return $deleted; + } + + /** + * Whether $target selects $bucket (i.e. target=all or target=bucket). + * + * @param string $target Selected target. + * @param string $bucket Bucket constant. + * @return bool + */ + private static function target_includes( string $target, string $bucket ): bool { + return self::TARGET_ALL === $target || $bucket === $target; + } + + /** + * Validate target parameter. + * + * @param string $target Target to validate. + * @return void + * @throws InvalidArgumentException When $target is not in VALID_TARGETS. + */ + private static function assert_valid_target( string $target ): void { + if ( in_array( $target, self::VALID_TARGETS, true ) ) { + return; + } + $msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) ); + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->commentmeta is WP-managed; meta_key patterns are static class constants. + + /** + * Count `_wxr_import_*` rows in wp_commentmeta. + * + * @return int + */ + private static function count_wxr_import(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'" + ); + } + + /** + * Delete `_wxr_import_*` rows from wp_commentmeta. + * + * @return int Affected row count. + */ + private static function delete_wxr_import(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'" + ); + } + + /** + * Count `_2meet_demo_*` rows in wp_commentmeta. + * + * @return int + */ + private static function count_demo_data(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'" + ); + } + + /** + * Delete `_2meet_demo_*` rows from wp_commentmeta. + * + * @return int Affected row count. + */ + private static function delete_demo_data(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'" + ); + } + + /** + * Count rows matching transient meta_key patterns in wp_commentmeta. + * + * @return int + */ + private static function count_transients(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + /** + * Delete rows matching transient meta_key patterns from wp_commentmeta. + * + * @return int Affected row count. + */ + private static function delete_transients(): int { + global $wpdb; + $table = $wpdb->commentmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + /** + * Count orphan post-meta keys in wp_commentmeta. + * + * @return int + */ + private static function count_orphan_post_meta(): int { + global $wpdb; + $table = $wpdb->commentmeta; + $placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) ); + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key IN ({$placeholders})", + ...self::ORPHAN_POST_META_KEYS + ) + ); + } + + /** + * Delete orphan post-meta keys from wp_commentmeta. + * + * @return int Affected row count. + */ + private static function delete_orphan_post_meta(): int { + global $wpdb; + $table = $wpdb->commentmeta; + $placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) ); + return (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` WHERE meta_key IN ({$placeholders})", + ...self::ORPHAN_POST_META_KEYS + ) + ); + } + + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared +} diff --git a/includes/class-tmdo-compatibility.php b/includes/class-tmdo-compatibility.php new file mode 100644 index 0000000..73284e8 --- /dev/null +++ b/includes/class-tmdo-compatibility.php @@ -0,0 +1,133 @@ + self::is_hpct_active(), + 'hpct_imported' => self::is_hpct_imported(), + 'hivepress' => self::is_hivepress_active(), + 'woocommerce' => self::is_woocommerce_active(), + ); + + 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. + */ + 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? + */ + public static function is_hivepress_active(): bool { + return class_exists( 'HivePress\Core' ); + } + + /** + * Is WooCommerce active? + */ + public static function is_woocommerce_active(): bool { + return class_exists( 'WooCommerce' ); + } + + /** + * Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.) + * + * LatePoint exposes the OsBookingHelper class and `latepoint_*` action + * hooks. Detect via either the helper class or the version constant. + */ + public static function is_latepoint_active(): bool { + return class_exists( 'OsBookingHelper' ) + || 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 ); + } +} diff --git a/includes/class-tmdo-conflict-monitor.php b/includes/class-tmdo-conflict-monitor.php new file mode 100644 index 0000000..25a0ee6 --- /dev/null +++ b/includes/class-tmdo-conflict-monitor.php @@ -0,0 +1,245 @@ + 0 (Part C.1 enforcer requirement) + * - Persistent log to wpdo_audit table for ops review + * - Single-source-of-truth `get_all_conflicts()` for CLI / REST surfaces + * + * This wrapper is the live monitor; the engine/class-tmdo-conflict-detector.php + * is the deeper UAEPG-aware scanner. The split keeps WPDO callable without + * UAEPG present. + * + * @package WP_Data_Optimizer + * @since 2.0.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches. + +/** + * Production conflict monitor with admin surface integration. + */ +final class TMDO_Conflict_Monitor { + + /** + * Cached conflict list per request. + * + * @var array|null + */ + private static ?array $cache = null; + + /** + * Register all hooks for production monitor surface. + * + * Called from TMDO_Core::run() after all interceptors register. + * + * @return void + */ + public static function register_hooks(): void { + // init:30 — runs after both wpdo_register_fields (init:20) and the + // UAE-port Conflict_Detector (init:25), so we observe a complete picture. + add_action( 'init', array( self::class, 'scan' ), 30 ); + + if ( is_admin() ) { + add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) ); + add_action( 'admin_bar_menu', array( self::class, 'maybe_render_admin_bar' ), 999 ); + } + } + + /** + * Run a full scan and cache the result for the request. + * + * Aggregates findings from: + * 1. TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() — same-hook callback overlap + * 2. TMDO_Conflict_Detector::scan() — UAEPG cross-plugin field overlap (if present) + * + * @return array + */ + public static function scan(): array { + if ( null !== self::$cache ) { + return self::$cache; + } + + $conflicts = array(); + + if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { + foreach ( TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() as $finding ) { + $conflicts[] = array_merge( array( 'type' => 'hook_overlap' ), $finding ); + } + } + + if ( class_exists( 'TMDO_Conflict_Detector' ) ) { + foreach ( TMDO_Conflict_Detector::scan() as $finding ) { + $conflicts[] = array_merge( array( 'type' => 'uaepg_overlap' ), $finding ); + } + } + + self::$cache = $conflicts; + + // Persist a single summary row to wpdo_audit when conflicts exist. + if ( $conflicts ) { + self::persist_audit_summary( $conflicts ); + } + + return $conflicts; + } + + /** + * Get the cached conflict list, scanning lazily if needed. + * + * @return array + */ + public static function get_all_conflicts(): array { + return self::$cache ?? self::scan(); + } + + /** + * Conflict count by type. + * + * @return array{total:int, hook_overlap:int, uaepg_overlap:int} + */ + public static function get_summary(): array { + $conflicts = self::get_all_conflicts(); + $summary = array( + 'total' => count( $conflicts ), + 'hook_overlap' => 0, + 'uaepg_overlap' => 0, + ); + foreach ( $conflicts as $c ) { + $type = $c['type'] ?? 'unknown'; + if ( isset( $summary[ $type ] ) ) { + ++$summary[ $type ]; + } + } + return $summary; + } + + /** + * Reset request cache. Test helper. + * + * @internal + */ + public static function reset_cache(): void { + self::$cache = null; + } + + /** + * Render an admin notice when conflicts are present. + * + * @return void + */ + public static function maybe_render_admin_notice(): void { + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + $summary = self::get_summary(); + if ( $summary['total'] === 0 ) { + return; + } + + $message = sprintf( + /* translators: %d: conflict count */ + esc_html__( 'WPDO Conflict Detector:偵測到 %d 個欄位 / hook 衝突 — 可能造成資料靜默遺失。', '2meet-data-optimizer' ), + $summary['total'] + ); + $link = esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ) ); + + printf( + '

%s %s

', + esc_html( $message ), + esc_url( $link ), + esc_html__( '查看詳情', '2meet-data-optimizer' ) + ); + } + + /** + * Render a warning chip in the admin bar. + * + * @param mixed $wp_admin_bar WP_Admin_Bar instance. + * @return void + */ + public static function maybe_render_admin_bar( $wp_admin_bar ): void { + if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'add_node' ) ) { + return; + } + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + $summary = self::get_summary(); + if ( $summary['total'] === 0 ) { + return; + } + + $wp_admin_bar->add_node( + array( + 'id' => 'wpdo-conflicts', + 'title' => sprintf( + /* translators: %d: conflict count */ + '⚠️ ' . esc_html__( 'Anti-EAV: %d conflicts', '2meet-data-optimizer' ), + $summary['total'] + ), + 'href' => admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ), + 'meta' => array( 'class' => 'wpdo-conflict-warning' ), + ) + ); + } + + /** + * Persist a summary row to wpdo_audit (silently swallows errors when table missing). + * + * @param array $conflicts All findings. + * @return void + */ + private static function persist_audit_summary( array $conflicts ): void { + try { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_audit'; + $wpdb->insert( + $table, + array( + 'ts' => current_time( 'mysql' ), + 'user_id' => 0, + 'entity_type' => 'system', + 'entity_id' => 0, + 'meta_key' => '', + 'op' => 'conflict_scan', + 'value_before' => null, + 'value_after' => wp_json_encode( $conflicts ), + 'source' => 'monitor', + 'trace_id' => self::generate_trace_id(), + ), + array( '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + } catch ( \Throwable $e ) { + // wpdo_audit not yet installed (pre-v2 upgrade) — skip silently. + } + } + + /** + * Generate a UUID-like trace id (v4-ish, no external deps). + * + * @return string + */ + private static function generate_trace_id(): string { + // PHP 8.1+ random_bytes is always available. + try { + $bytes = random_bytes( 16 ); + } catch ( \Throwable $e ) { + $bytes = pack( 'H*', md5( (string) microtime( true ) . wp_generate_password( 16, false ) ) ); + } + $bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 ); + $bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 ); + return vsprintf( + '%s%s-%s-%s-%s-%s%s%s', + str_split( bin2hex( $bytes ), 4 ) + ); + } +} diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php new file mode 100644 index 0000000..bb2497a --- /dev/null +++ b/includes/class-tmdo-core.php @@ -0,0 +1,840 @@ +fire_registration(); + } + if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) { + $pending = TMDO_Entity_Registry::get_pending_schemas(); + if ( ! empty( $pending ) ) { + TMDO_Schema_Manager::process_pending_migrations(); + } + } + do_action( 'wpdo_late_bind_setup' ); + }, + 1 + ); + } + + // HivePress 整合已移至 2meet-data-optimizer-hivepress-addon。 + // AddOn bootstrap 自行偵測 + register。Core 完全 HP-agnostic。 + + // 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(); + } + + // v2.0.0: register entity adapters into Entity Registry. Adapters are + // REGISTERED unconditionally (so TMDO_API::get_entity / Hook Bus can + // find them), but Hook Bus only initializes when feature flag is on + // (default off — preserves v1.3.x behaviour). + $this->register_entity_adapters(); + + // v2.0.0: boot the unified Hook Bus when explicitly enabled. + TMDO_Hook_Bus_Bridge::maybe_init_hook_bus(); + + // v2.0.0: fire the Custom Table Registry hook so partner plugins can register. + TMDO_Custom_Table_Registry::instance()->fire_registration(); + + // v2.0.0: production-time conflict monitor (admin notice + admin bar). + if ( class_exists( 'TMDO_Conflict_Monitor' ) ) { + 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 ); + + // v2.6.7: user stress-test batch cron handler. + add_action( TMDO_User_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_stress_test_batch' ) ); + + // v2.11.4: post stress-test batch cron handler. + if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) { + add_action( TMDO_Post_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_post_stress_test_batch' ) ); + } + + // v2.13.0: term stress-test batch cron handler. + if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) { + add_action( TMDO_Term_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_term_stress_test_batch' ) ); + } + + // v2.13.1: comment stress-test batch cron handler. + if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + 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' ) ) { + add_action( 'init', array( 'TMDO_Term_Comment_Garbage_Filter', 'init' ), 5 ); + } + + // v2.12.3: WooCommerce term count cache → wp_options reroute + // (product_count_ rows out of wp_termmeta). + if ( class_exists( 'TMDO_WC_Term_Count_Filter' ) ) { + add_action( 'init', array( 'TMDO_WC_Term_Count_Filter', 'init' ), 5 ); + } + + // v2.12.4: Term + Comment misc bucket (catch-all flat for unregistered keys). + // Filters at priority 99 (LAST in chain) so all other v2.12.x filters + // + Hook Bus get first crack; only truly unhandled keys land here. + if ( class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) { + add_action( 'init', array( 'TMDO_Term_Comment_Misc_Bucket', 'init' ), 5 ); + } + } + + /** + * Cron handler: 執行壓力測試的單一批次(v2.6.7)。 + */ + public static function run_stress_test_batch(): void { + if ( class_exists( 'TMDO_User_Stress_Tester' ) ) { + TMDO_User_Stress_Tester::run_batch(); + } + } + + /** + * Cron handler: post entity 壓力測試單一批次(v2.11.4)。 + * + * @return void + */ + public static function run_post_stress_test_batch(): void { + if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) { + TMDO_Post_Stress_Tester::run_batch(); + } + } + + /** + * Cron handler: term entity 壓力測試單一批次(v2.13.0)。 + * + * @return void + */ + public static function run_term_stress_test_batch(): void { + if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) { + TMDO_Term_Stress_Tester::run_batch(); + } + } + + /** + * Cron handler: comment entity 壓力測試單一批次(v2.13.1)。 + * + * @return void + */ + public static function run_comment_stress_test_batch(): void { + if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + TMDO_Comment_Stress_Tester::run_batch(); + } + } + + /** + * Register the four entity adapters into TMDO_Entity_Registry. + * + * Registration is unconditional but write/read paths only activate when + * the corresponding module is in a write-active or read-custom state + * (feature flag controlled). + * + * @return void + */ + private function register_entity_adapters(): void { + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return; + } + + TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() ); + TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() ); + TMDO_Entity_Registry::register_adapter( 'term', new TMDO_Adapter_Term() ); + TMDO_Entity_Registry::register_adapter( 'comment', new TMDO_Adapter_Comment() ); + + /** + * Action: wpdo_register_entity_fields + * + * Partner plugins register their entity meta_key → adapter mappings here. + * Mirrors `wpdo_register_fields` but for non-postmeta entities. + * + * @since 2.0.0 + */ + 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. + */ + private function register_zone_hooks(): void { + // Sync Bridge: Zone-aware dual-write + read routing for all zone fields. + $sync_bridge = new TMDO_Sync_Bridge(); + $sync_bridge->register_hooks(); + + // Query Router: Zone A flat-column JOIN rewrite for WP_Query. + $query_router = new TMDO_Query_Router(); + $query_router->register_hooks(); + + // v2.10.1 Post Entity Query Router — only registers when post mode + // is shadow_read or aeav_only (zero overhead in disabled/dual_write). + // is_router_active() guards against early-bind issues; once mode flips + // to reads_from_flat the hooks fire on subsequent requests. + if ( class_exists( 'TMDO_Post_Query_Router' ) && TMDO_Post_Query_Router::is_router_active() ) { + $post_router = new TMDO_Post_Query_Router(); + $post_router->register_hooks(); + } + + // Zone B: Warm cleanup cron handler. + add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) ); + + // v2.10.3: Post shadow_read verifier cron handler. Only fires when + // post mode is shadow_read (verifier::cron_tick() is internally gated). + if ( class_exists( 'TMDO_Post_Shadow_Verifier' ) ) { + add_action( TMDO_Post_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Post_Shadow_Verifier', 'cron_tick' ) ); + } + + // v2.12.5: Term + Comment shadow verifier cron handler. Only fires when + // either term or comment mode is shadow_read (gated internally). + if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) { + add_action( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Term_Comment_Shadow_Verifier', 'cron_tick' ) ); + } + + // v2.10.3: react to bridge mode changes — register/unregister verifier + // cron when post mode crosses the shadow_read threshold. + add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_post_verifier_cron' ), 10, 3 ); + + // v2.12.5: same listener for term/comment. + add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_term_comment_verifier_cron' ), 10, 3 ); + + // Zone D: Archive sweep cron handler. + add_action( 'wpdo_archive_sweep', array( $this, 'sweep_archive_zone' ) ); + + // Errors GC cron handler — keeps wp_wpdo_errors bounded (default 90 days). + add_action( 'wpdo_errors_gc', array( $this, 'gc_errors' ) ); + + // Monthly health-snapshot cron handler. + add_action( 'wpdo_health_snapshot_monthly', array( $this, 'monthly_health_snapshot' ) ); + + // v2.3.0 M6: daily health probe cron. + add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) ); + + // v2.3.0 M6: daily snapshot prune cron (paired with health check). + add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); + + // v2.5.0 M13: daily FSM automator cron (opt-in). + add_action( 'wpdo_fsm_automator_run', array( 'TMDO_FSM_Automator', 'run' ) ); + + // v2.6.2: daily site-wide EAV health metrics snapshot. + TMDO_Site_Metrics_Collector::register(); + + // Add 'monthly' cron interval (WP only ships hourly/twicedaily/daily). + add_filter( + 'cron_schedules', + static function ( $schedules ) { + if ( ! isset( $schedules['monthly'] ) ) { + $schedules['monthly'] = array( + 'interval' => 30 * DAY_IN_SECONDS, + 'display' => __( 'Once Monthly', '2meet-data-optimizer' ), + ); + } + return $schedules; + } + ); + + // Admin notice when DB grows > 10% MoM. + if ( is_admin() ) { + add_action( 'admin_notices', array( $this, 'render_health_alert_notice' ) ); + } + + // Zone B/D: Listing stats integration(已搬到 2meet-data-optimizer-hivepress-addon)。 + if ( class_exists( 'TMDO_Listing_Stats' ) ) { + TMDO_Listing_Stats::register_hooks(); + } + } + + /** + * Schedule cron events if not already scheduled. + */ + private function schedule_cron(): void { + if ( ! wp_next_scheduled( 'wpdo_warm_cleanup' ) ) { + wp_schedule_event( time(), 'hourly', 'wpdo_warm_cleanup' ); + } + + // v2.10.3: Post shadow_read verifier — only schedule when post mode + // is shadow_read (auto-cleared in mode_changed listener below). + if ( class_exists( 'TMDO_Post_Shadow_Verifier' ) + && class_exists( 'TMDO_Mode_Manager' ) + && 'shadow_read' === TMDO_Mode_Manager::get( 'post' ) + && ! wp_next_scheduled( TMDO_Post_Shadow_Verifier::CRON_HOOK ) + ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Post_Shadow_Verifier::CRON_HOOK ); + } + + // v2.12.5: Term + Comment shadow verifier — schedule when EITHER term + // or comment mode is shadow_read. + if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) + && class_exists( 'TMDO_Mode_Manager' ) + && ( 'shadow_read' === TMDO_Mode_Manager::get( 'term' ) + || 'shadow_read' === TMDO_Mode_Manager::get( 'comment' ) ) + && ! wp_next_scheduled( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK ) + ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK ); + } + + if ( ! wp_next_scheduled( 'wpdo_archive_sweep' ) ) { + wp_schedule_event( time(), 'daily', 'wpdo_archive_sweep' ); + } + + // Daily errors GC — keep 90 days, prevents wpdo_errors growing unbounded. + if ( ! wp_next_scheduled( 'wpdo_errors_gc' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpdo_errors_gc' ); + } + + // Monthly health snapshot — auto-runs first of each month. + if ( ! wp_next_scheduled( 'wpdo_health_snapshot_monthly' ) ) { + // Schedule at 03:00 on the 1st of next month. + $next = strtotime( 'first day of next month 03:00' ); + wp_schedule_event( $next ?: ( time() + 30 * DAY_IN_SECONDS ), 'monthly', 'wpdo_health_snapshot_monthly' ); + } + + // v2.3.0 M6: daily health probe at 03:30 UTC (avoids hourly warm cleanup). + if ( ! wp_next_scheduled( 'wpdo_daily_health_check' ) ) { + $tomorrow_330 = strtotime( 'tomorrow 03:30 UTC' ); + wp_schedule_event( $tomorrow_330 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_daily_health_check' ); + } + + // v2.3.0 M6: daily snapshot prune at 04:00 UTC. + if ( ! wp_next_scheduled( 'wpdo_snapshot_prune_daily' ) ) { + $tomorrow_400 = strtotime( 'tomorrow 04:00 UTC' ); + wp_schedule_event( $tomorrow_400 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_snapshot_prune_daily' ); + } + + // v2.5.0 M13: FSM Automator daily cron at 04:30 UTC (after prune). + if ( ! wp_next_scheduled( 'wpdo_fsm_automator_run' ) ) { + $tomorrow_430 = strtotime( 'tomorrow 04:30 UTC' ); + wp_schedule_event( $tomorrow_430 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_fsm_automator_run' ); + } + + // v2.6.2: daily site metrics collection at 05:00 UTC. + if ( ! wp_next_scheduled( TMDO_Site_Metrics_Collector::CRON_HOOK ) ) { + $tomorrow_500 = strtotime( 'tomorrow 05:00 UTC' ); + wp_schedule_event( $tomorrow_500 ?: ( time() + DAY_IN_SECONDS ), 'daily', TMDO_Site_Metrics_Collector::CRON_HOOK ); + } + + // Zone B: Flush view counts to postmeta (hourly)(已搬到 hivepress addon)。 + if ( class_exists( 'TMDO_Listing_Stats' ) ) { + TMDO_Listing_Stats::schedule_cron(); + } + } + + /** + * Cron handler: prune wpdo_errors > 90 days. + * + * @return void + */ + public function gc_errors(): void { + TMDO_Logger::purge( 90 ); + } + + /** + * Cron handler: monthly health snapshot via WP_CLI subprocess. + * + * @return void + */ + public function monthly_health_snapshot(): void { + // Only do anything when WP-CLI is invocable in the current request — the + // cron runs via `wp cron event run` typically. Otherwise, dispatch via + // shell. + if ( ! class_exists( 'TMDO_CLI' ) || ! defined( 'WP_CLI' ) || ! WP_CLI ) { + return; + } + $cli = new TMDO_CLI(); + $cli->health_snapshot( array(), array() ); + } + + /** + * Render admin notice when DB has grown > 10% since last snapshot. + * + * Reads `wpdo_health_alert` option set by `health_snapshot` CLI when + * threshold breached. Auto-dismissed after fix or next snapshot. + * + * @return void + */ + public function render_health_alert_notice(): void { + $alert = (string) get_option( 'wpdo_health_alert', '' ); + if ( '' === $alert ) { + return; + } + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + // Allow user to dismiss until next snapshot. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display. + if ( ! empty( $_GET['wpdo_dismiss_health_alert'] ) ) { + $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : ''; + if ( wp_verify_nonce( $nonce, 'wpdo_dismiss_health_alert' ) ) { + delete_option( 'wpdo_health_alert' ); + return; + } + } + $dismiss_url = wp_nonce_url( + add_query_arg( 'wpdo_dismiss_health_alert', '1' ), + 'wpdo_dismiss_health_alert' + ); + ?> +
+

+ WP Data Optimizer: + +   +

+
+ +
+

+ WP Data Optimizer: + ' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '' + ); + ?> +

+
+ $entity_type, + 'group_name' => $group_name, + 'migrated' => $result['migrated'], + 'total' => $result['total'], + 'done' => $result['done'], + 'status' => $result['status'], + ) + ); + } +} diff --git a/includes/class-tmdo-crypto.php b/includes/class-tmdo-crypto.php new file mode 100644 index 0000000..dddf61f --- /dev/null +++ b/includes/class-tmdo-crypto.php @@ -0,0 +1,370 @@ +" (current — AES-256-GCM, AEAD) + * - "enc:v1:" (legacy — AES-256-CBC; read-only) + * + * Backward compatibility: + * - encrypt() always writes v2 GCM + * - decrypt() reads BOTH v1 and v2 (transparent migration) + * - Values without any "enc:" prefix are returned as-is (existing plaintext + * options continue to work until re-saved or migrated explicitly) + * + * The v1→v2 upgrade can be triggered via: + * - WP-CLI: `wp wpdo crypto-migrate` + * - Auto-migration during install/upgrade (idempotent best-effort) + * + * Usage (unchanged from v2.6.4 contract): + * TMDO_Crypto::set_option( 'wpdo_slack_webhook', $url ); // writes v2 + * $url = TMDO_Crypto::get_option( 'wpdo_slack_webhook' ); // reads v1 or v2 + * + * @package WP_Data_Optimizer + * @since 2.6.4 (v1 CBC) + * @since 2.15.0 (v2 GCM, AEAD authentication) + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Symmetric authenticated encryption helper for sensitive wp_options entries. + */ +class TMDO_Crypto { + + /** Ciphertext prefix v2 (AES-256-GCM, current). */ + public const PREFIX_V2 = 'enc:v2:'; + + /** Ciphertext prefix v1 (AES-256-CBC, legacy read-only). */ + public const PREFIX_V1 = 'enc:v1:'; + + /** Cipher suite v2 (AEAD — authenticated, tamper-detectable). */ + private const CIPHER_V2 = 'aes-256-gcm'; + + /** Cipher suite v1 (legacy — no authentication). */ + private const CIPHER_V1 = 'AES-256-CBC'; + + /** GCM IV length (12 bytes is the GCM standard / NIST SP 800-38D recommended). */ + private const IV_LEN_V2 = 12; + + /** CBC IV length (legacy). */ + private const IV_LEN_V1 = 16; + + /** GCM authentication tag length (16 bytes = 128 bits, the strongest standard). */ + private const TAG_LEN = 16; + + /** + * Derive a 32-byte site-specific key from WordPress auth constants. + * + * Stable per site → ciphertext written on this site cannot be decrypted + * elsewhere. Both v1 and v2 use the same derived key (same secret material, + * different cipher) so v1 ciphertext can be read after the v2 upgrade. + * + * Falls back to a sha256 of ABSPATH when constants are not defined + * (unit-test environments). The fallback must be stable per request. + * + * @return string 32 raw bytes. + */ + private static function derived_key(): string { + $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : ''; + $salt .= defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : ABSPATH; + return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 ); + } + + /** + * Encrypt a plaintext string with AES-256-GCM (v2 format). + * + * @param string $plaintext Value to encrypt. + * @return string Encrypted value with "enc:v2:" prefix, or original on failure. + */ + public static function encrypt( string $plaintext ): string { + if ( '' === $plaintext ) { + return ''; + } + if ( ! function_exists( 'openssl_encrypt' ) ) { + return $plaintext; + } + $iv = random_bytes( self::IV_LEN_V2 ); + $tag = ''; + // phpcs:ignore -- $tag is reference output for GCM auth tag. + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER_V2, + self::derived_key(), + OPENSSL_RAW_DATA, + $iv, + $tag, + '', + self::TAG_LEN + ); + if ( false === $ciphertext ) { + return $plaintext; + } + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- intentional binary encoding. + return self::PREFIX_V2 . base64_encode( $iv . $tag . $ciphertext ); + } + + /** + * Decrypt an encrypted value (v2 GCM or v1 CBC). + * + * Dispatches by prefix: + * - "enc:v2:..." → AES-256-GCM (auth-tag verified) + * - "enc:v1:..." → AES-256-CBC (legacy, no auth) + * - other → returned as-is (legacy plaintext) + * + * @param string $stored Stored option value. + * @return string Plaintext, or original value on failure. + */ + public static function decrypt( string $stored ): string { + if ( '' === $stored ) { + return $stored; + } + if ( ! function_exists( 'openssl_decrypt' ) ) { + return $stored; + } + if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { + return self::decrypt_v2( $stored ); + } + if ( str_starts_with( $stored, self::PREFIX_V1 ) ) { + return self::decrypt_v1( $stored ); + } + return $stored; // plaintext (legacy). + } + + /** + * Decrypt v2 GCM blob (private — dispatched by decrypt()). + * + * @param string $stored "enc:v2:..." string. + * @return string Plaintext or original on auth failure / parse error. + */ + private static function decrypt_v2( string $stored ): string { + $encoded = substr( $stored, strlen( self::PREFIX_V2 ) ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data. + $raw = base64_decode( $encoded, true ); + if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V2 + self::TAG_LEN ) { + return $stored; + } + $iv = substr( $raw, 0, self::IV_LEN_V2 ); + $tag = substr( $raw, self::IV_LEN_V2, self::TAG_LEN ); + $ciphertext = substr( $raw, self::IV_LEN_V2 + self::TAG_LEN ); + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER_V2, + self::derived_key(), + OPENSSL_RAW_DATA, + $iv, + $tag + ); + return false === $plaintext ? $stored : $plaintext; + } + + /** + * Decrypt v1 CBC blob (private — backward compatibility path). + * + * NOTE: CBC has no authentication. A successful decrypt does NOT prove the + * ciphertext is intact. Callers should treat v1 plaintext as "trusted as + * much as the surrounding wp_options column was trusted at write time". + * The v1→v2 migration upgrades these values to authenticated GCM. + * + * @param string $stored "enc:v1:..." string. + * @return string Plaintext or original on parse error. + */ + private static function decrypt_v1( string $stored ): string { + $encoded = substr( $stored, strlen( self::PREFIX_V1 ) ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data. + $raw = base64_decode( $encoded, true ); + if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V1 ) { + return $stored; + } + $iv = substr( $raw, 0, self::IV_LEN_V1 ); + $ciphertext = substr( $raw, self::IV_LEN_V1 ); + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER_V1, + self::derived_key(), + OPENSSL_RAW_DATA, + $iv + ); + return false === $plaintext ? $stored : $plaintext; + } + + /** + * Encrypt a value and save it to wp_options. + * + * @param string $option_name Option name. + * @param string $plaintext Value to encrypt and store. + * @param bool $autoload Whether to autoload (default false — secrets should never autoload). + * @return bool Whether the option was updated. + */ + public static function set_option( string $option_name, string $plaintext, bool $autoload = false ): bool { + $encrypted = self::encrypt( $plaintext ); + return update_option( $option_name, $encrypted, $autoload ); + } + + /** + * Read a wp_option and decrypt it. + * + * @param string $option_name Option name. + * @param string $fallback Value returned when option is empty. + * @return string Decrypted plaintext (or legacy plaintext, or fallback). + */ + public static function get_option( string $option_name, string $fallback = '' ): string { + $stored = (string) get_option( $option_name, '' ); + if ( '' === $stored ) { + return $fallback; + } + return self::decrypt( $stored ); + } + + /** + * Whether a stored option is encrypted (v1 or v2). + * + * @param string $option_name Option name. + * @return bool + */ + public static function is_encrypted( string $option_name ): bool { + $stored = get_option( $option_name, '' ); + if ( ! is_string( $stored ) ) { + return false; + } + return str_starts_with( $stored, self::PREFIX_V2 ) + || str_starts_with( $stored, self::PREFIX_V1 ); + } + + /** + * Format version of a stored option ("v2", "v1", "plaintext", or "empty"). + * + * Non-string option values (arrays / objects) are classified as + * "plaintext" since they are not in any encrypted format. This avoids the + * "Array to string conversion" warning when wp_options stores serialized + * arrays (e.g. `wpdo_features`, `wpdo_bridge_modes`). + * + * @param string $option_name Option name. + * @return string One of: "v2", "v1", "plaintext", "empty". + * @since 2.15.0 + */ + public static function format_version( string $option_name ): string { + $stored = get_option( $option_name, '' ); + if ( '' === $stored || null === $stored ) { + return 'empty'; + } + if ( ! is_string( $stored ) ) { + // Arrays, objects, ints, etc. — never encrypted. + return 'plaintext'; + } + if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { + return 'v2'; + } + if ( str_starts_with( $stored, self::PREFIX_V1 ) ) { + return 'v1'; + } + return 'plaintext'; + } + + /** + * Migrate a single option from v1 (CBC) to v2 (GCM). + * + * Idempotent — already-v2 options are skipped. Plaintext options are NOT + * touched (the caller decides whether to encrypt; this method only handles + * format upgrade of already-encrypted values). + * + * @param string $option_name Option name. + * @return string One of: "migrated", "already_v2", "plaintext_skipped", + * "empty", "decrypt_failed", "encrypt_failed", "no_op". + * @since 2.15.0 + */ + public static function migrate_option_v1_to_v2( string $option_name ): string { + $stored = get_option( $option_name, '' ); + if ( '' === $stored || null === $stored ) { + return 'empty'; + } + if ( ! is_string( $stored ) ) { + // Arrays / objects are never v1 ciphertext. + return 'plaintext_skipped'; + } + if ( str_starts_with( $stored, self::PREFIX_V2 ) ) { + return 'already_v2'; + } + if ( ! str_starts_with( $stored, self::PREFIX_V1 ) ) { + return 'plaintext_skipped'; + } + // Decrypt v1. + $plaintext = self::decrypt_v1( $stored ); + if ( $plaintext === $stored ) { + // decrypt_v1() returns original on failure. + return 'decrypt_failed'; + } + // Re-encrypt as v2. + $reencrypted = self::encrypt( $plaintext ); + if ( ! str_starts_with( $reencrypted, self::PREFIX_V2 ) ) { + return 'encrypt_failed'; + } + // Preserve current autoload flag (don't accidentally flip). + $autoload = wp_cache_get( 'notoptions', 'options' ); // not used; kept here as reference for the option API contract. + $ok = update_option( $option_name, $reencrypted, false ); + return $ok ? 'migrated' : 'no_op'; + } + + /** + * Bulk-migrate every wp_option whose name starts with `wpdo_` (or the + * supplied prefix) from v1 CBC to v2 GCM. + * + * Idempotent: already-v2 / plaintext / empty options are skipped without + * error. Returns counts so the caller can log / display progress. + * + * @param string $option_prefix Prefix to scan (default 'wpdo_'). + * @return array{ + * scanned:int, migrated:int, already_v2:int, plaintext:int, empty:int, + * failed:int, errors:array + * } + * @since 2.15.0 + */ + public static function migrate_v1_to_v2( string $option_prefix = 'wpdo_' ): array { + global $wpdb; + $counts = array( + 'scanned' => 0, + 'migrated' => 0, + 'already_v2' => 0, + 'plaintext' => 0, + 'empty' => 0, + 'failed' => 0, + 'errors' => array(), + ); + + $option_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( $option_prefix ) . '%' + ) + ); + + foreach ( (array) $option_names as $option_name ) { + ++$counts['scanned']; + $result = self::migrate_option_v1_to_v2( $option_name ); + switch ( $result ) { + case 'migrated': + ++$counts['migrated']; + break; + case 'already_v2': + ++$counts['already_v2']; + break; + case 'plaintext_skipped': + ++$counts['plaintext']; + break; + case 'empty': + ++$counts['empty']; + break; + default: + ++$counts['failed']; + $counts['errors'][ $option_name ] = $result; + } + } + + return $counts; + } +} diff --git a/includes/class-tmdo-custom-table-registry.php b/includes/class-tmdo-custom-table-registry.php new file mode 100644 index 0000000..d95bdd9 --- /dev/null +++ b/includes/class-tmdo-custom-table-registry.php @@ -0,0 +1,297 @@ +register( '2meet-courses', [ + * 'table_name' => '2mc_courses', // raw, without $wpdb->prefix + * 'primary_key' => 'id', + * 'post_type_link' => null, + * 'doctor_callback' => [ '2meet_Courses', 'doctor_check' ], + * 'benchmark_callback' => [ '2meet_Courses', 'benchmark_run' ], + * ] ); + * } ); + */ +final class TMDO_Custom_Table_Registry { + + /** + * Singleton instance. + * + * @var self|null + */ + private static ?self $instance = null; + + /** + * Registered custom tables, keyed by `provider:table_name`. + * + * @var array + */ + private array $tables = array(); + + /** + * V2.1.2: Secondary index by provider for O(1) `for_provider()` lookup. + * Avoids array_filter scan over the full registry — matters at 100+ tables. + * + * @var array> provider => list of full keys ('provider:table_name'). + */ + private array $by_provider = array(); + + /** + * Whether the registration hook has fired. + * + * @var bool + */ + private bool $registration_done = false; + + /** + * Private constructor — use instance(). + */ + private function __construct() {} + + /** + * Singleton accessor. + */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Reset for tests only. + * + * @internal + */ + public static function reset_for_tests(): void { + self::$instance = null; + } + + /** + * Fire `wpdo_register_custom_tables` action so partner plugins can register. + * + * Called by TMDO_Core::run() and again on plugins_loaded:30 (after all + * partner plugins have had a chance to attach their listeners). Safe to + * call multiple times — `register()` is idempotent on (provider, table_name). + * + * @return void + */ + public function fire_registration(): void { + $this->registration_done = true; + + /** + * Action: wpdo_register_custom_tables + * + * Partner plugins should register their custom tables here. + * + * @param TMDO_Custom_Table_Registry $registry Registry instance. + */ + do_action( 'wpdo_register_custom_tables', $this ); + } + + /** + * Register a custom table from a partner plugin. + * + * @param string $provider Plugin slug (e.g. '2meet-courses'). + * @param array $config Table configuration: + * - table_name (string, required) Raw table name without $wpdb->prefix. + * - primary_key (string, default 'id') Primary key column name. + * - post_type_link (?string) Linked post_type, or null if standalone. + * - doctor_callback (?callable) Returns array of {ok:bool, message:string} for doctor. + * - benchmark_callback (?callable) Returns array of {duration_ms:float, sample_size:int}. + * - expected_columns (array) Column => SQL type for schema drift detection. + * - indexes (array) Index name => column list for index health. + * @return bool True on success, false on validation failure or duplicate. + */ + public function register( string $provider, array $config ): bool { + $config = wp_parse_args( + $config, + array( + 'table_name' => '', + 'primary_key' => 'id', + 'post_type_link' => null, + 'doctor_callback' => null, + 'benchmark_callback' => null, + 'expected_columns' => array(), + 'indexes' => array(), + ) + ); + + if ( '' === $config['table_name'] || '' === $provider ) { + return false; + } + + // Sanitize table name to a safe identifier. + $config['table_name'] = sanitize_key( $config['table_name'] ); + if ( '' === $config['table_name'] ) { + return false; + } + + $config['provider'] = $provider; + + $key = $provider . ':' . $config['table_name']; + if ( isset( $this->tables[ $key ] ) ) { + return false; // Already registered. + } + + $this->tables[ $key ] = $config; + // v2.1.2: maintain secondary index by provider for O(1) lookup. + $this->by_provider[ $provider ][] = $key; + return true; + } + + /** + * Unregister a custom table (test or runtime cleanup). + * + * @param string $provider Plugin slug. + * @param string $table_name Raw table name. + */ + public function unregister( string $provider, string $table_name ): bool { + $key = $provider . ':' . sanitize_key( $table_name ); + if ( ! isset( $this->tables[ $key ] ) ) { + return false; + } + unset( $this->tables[ $key ] ); + // v2.1.2: keep secondary index in sync. + if ( isset( $this->by_provider[ $provider ] ) ) { + $this->by_provider[ $provider ] = array_values( array_diff( $this->by_provider[ $provider ], array( $key ) ) ); + if ( empty( $this->by_provider[ $provider ] ) ) { + unset( $this->by_provider[ $provider ] ); + } + } + return true; + } + + /** + * Get all registered tables. + * + * @return array + */ + public function all(): array { + return $this->tables; + } + + /** + * Get tables for a specific provider. + * + * @param string $provider Plugin slug. + * @return array + */ + public function for_provider( string $provider ): array { + // v2.1.2: O(1) via secondary index instead of O(n) array_filter. + // Drop-in equivalent — same return shape (key=full_key, value=config). + if ( ! isset( $this->by_provider[ $provider ] ) ) { + return array(); + } + $out = array(); + foreach ( $this->by_provider[ $provider ] as $key ) { + if ( isset( $this->tables[ $key ] ) ) { + $out[ $key ] = $this->tables[ $key ]; + } + } + return $out; + } + + /** + * Get tables linked to a specific post_type. + * + * @param string $post_type Post type slug. + * @return array + */ + public function for_post_type( string $post_type ): array { + return array_filter( + $this->tables, + static fn( $cfg ) => isset( $cfg['post_type_link'] ) && $cfg['post_type_link'] === $post_type + ); + } + + /** + * Get all unique provider names. + * + * @return string[] + */ + public function providers(): array { + $set = array(); + foreach ( $this->tables as $cfg ) { + $set[ $cfg['provider'] ] = true; + } + return array_keys( $set ); + } + + /** + * Summary stats for admin dashboard. + * + * @return array{tables_count:int, providers_count:int, with_doctor:int, with_benchmark:int} + */ + public function get_stats(): array { + $with_doctor = 0; + $with_benchmark = 0; + foreach ( $this->tables as $cfg ) { + if ( is_callable( $cfg['doctor_callback'] ?? null ) ) { + ++$with_doctor; + } + if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) { + ++$with_benchmark; + } + } + + return array( + 'tables_count' => count( $this->tables ), + 'providers_count' => count( $this->providers() ), + 'with_doctor' => $with_doctor, + 'with_benchmark' => $with_benchmark, + ); + } + + /** + * Run all registered doctor callbacks and aggregate results. + * + * @return array + */ + public function run_doctor_checks(): array { + $results = array(); + foreach ( $this->tables as $key => $cfg ) { + if ( ! is_callable( $cfg['doctor_callback'] ?? null ) ) { + continue; + } + try { + $result = call_user_func( $cfg['doctor_callback'], $cfg['table_name'] ); + $results[ $key ] = array( + 'provider' => $cfg['provider'], + 'table' => $cfg['table_name'], + 'ok' => (bool) ( $result['ok'] ?? false ), + 'message' => (string) ( $result['message'] ?? '' ), + ); + } catch ( \Throwable $e ) { + $results[ $key ] = array( + 'provider' => $cfg['provider'], + 'table' => $cfg['table_name'], + 'ok' => false, + 'message' => 'doctor_callback threw: ' . $e->getMessage(), + ); + } + } + return $results; + } +} diff --git a/includes/class-tmdo-db.php b/includes/class-tmdo-db.php new file mode 100644 index 0000000..1b2da94 --- /dev/null +++ b/includes/class-tmdo-db.php @@ -0,0 +1,210 @@ +query( self::is_sqlite() ? 'BEGIN IMMEDIATE' : 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static SQL literals; no user input. + } + + /** + * Commit the current transaction. + */ + public static function commit(): void { + global $wpdb; + $wpdb->query( 'COMMIT' ); + } + + /** + * Roll back the current transaction. + */ + public static function rollback(): void { + global $wpdb; + $wpdb->query( 'ROLLBACK' ); + } + + /** + * Get the current WordPress local time as a MySQL datetime string. + */ + public static function now(): string { + return current_time( 'mysql' ); + } + + /** + * Get the full table name with wpdb prefix. + * + * @param string $name Table name without prefix (e.g. 'wpdo_warm'). + * @return string Fully-prefixed, sanitized table name. + */ + public static function table( string $name ): string { + global $wpdb; + return $wpdb->prefix . sanitize_key( $name ); + } + + /** + * Execute an INSERT IGNORE statement (MySQL) or INSERT OR IGNORE (SQLite). + * + * @param string $table Full table name. + * @param array $data Column => value pairs. + * @param array $format Optional wpdb format array ('%s', '%d', etc.). + * @return int|false Number of rows affected, or false on error. + */ + public static function insert_ignore( string $table, array $data, array $format = array() ): int|false { + global $wpdb; + + $columns = array_keys( $data ); + $values = array_values( $data ); + $col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) ); + $placeholder = implode( ', ', $format ?: array_fill( 0, count( $values ), '%s' ) ); + + $keyword = self::is_sqlite() ? 'INSERT OR IGNORE' : 'INSERT IGNORE'; + + $sql = "{$keyword} INTO `{$table}` ({$col_list}) VALUES ({$placeholder})"; + + return $wpdb->query( $wpdb->prepare( $sql, ...$values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Build a JSON_EXTRACT expression compatible with both MySQL and SQLite. + * + * Both MySQL 5.7+ and SQLite 3.38+ support json_extract(). + * + * @param string $column Column name containing JSON data. + * @param string $path JSON path (e.g. '$.social_links'). + * @return string SQL expression. + */ + public static function json_extract( string $column, string $path ): string { + $column = sanitize_key( $column ); + // JSON path must start with '$' and contain only word chars, dots, brackets, and digits. + if ( ! preg_match( '/^\$[\w.\[\]0-9]*$/', $path ) ) { + $path = '$'; + } + return "json_extract(`{$column}`, '{$path}')"; + } + + /** + * Build an UPSERT statement. + * + * MySQL: INSERT ... ON DUPLICATE KEY UPDATE ... + * SQLite: INSERT ... ON CONFLICT(...) DO UPDATE SET ... + * + * @param string $table Full table name. + * @param array $data Column => value pairs to insert. + * @param array $update_columns Columns to update on conflict. + * @param string|string[] $conflict_key Column name (string) or columns (array) + * for ON CONFLICT (SQLite). On MySQL it + * is informational only — ON DUPLICATE + * KEY UPDATE matches any unique key. + * @param array $format Optional wpdb format array. + * @return int|false + */ + public static function upsert( string $table, array $data, array $update_columns, string|array $conflict_key = 'post_id', array $format = array() ): int|false { + global $wpdb; + + $columns = array_keys( $data ); + $values = array_values( $data ); + // Normalise composite vs single conflict key. + $conflict_keys = is_array( $conflict_key ) ? $conflict_key : array( $conflict_key ); + $conflict_keys = array_map( 'sanitize_key', $conflict_keys ); + $conflict_list = implode( + ', ', + array_map( static fn( $k ) => '`' . $k . '`', $conflict_keys ) + ); + + $col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) ); + + // Build per-value placeholders, using NULL literal for null values. + $prepare_values = array(); + $placeholders = array(); + $fmt = $format ?: array_fill( 0, count( $values ), '%s' ); + foreach ( $values as $i => $v ) { + if ( null === $v ) { + $placeholders[] = 'NULL'; + } else { + $placeholders[] = $fmt[ $i ] ?? '%s'; + $prepare_values[] = $v; + } + } + $placeholder = implode( ', ', $placeholders ); + + // ON DUPLICATE KEY UPDATE: null columns use NULL literal, others use VALUES(). + $null_cols = array(); + foreach ( $update_columns as $c ) { + $col_index = array_search( $c, $columns, true ); + if ( false !== $col_index && null === $values[ $col_index ] ) { + $null_cols[] = $c; + } + } + + if ( self::is_mysql() ) { + $updates = implode( + ', ', + array_map( + function ( $c ) use ( $null_cols ) { + $safe = sanitize_key( $c ); + return in_array( $c, $null_cols, true ) + ? "`{$safe}` = NULL" + : "`{$safe}` = VALUES(`{$safe}`)"; + }, + $update_columns + ) + ); + $sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON DUPLICATE KEY UPDATE {$updates}"; + } else { + $updates = implode( + ', ', + array_map( + function ( $c ) use ( $null_cols ) { + $safe = sanitize_key( $c ); + return in_array( $c, $null_cols, true ) + ? "`{$safe}` = NULL" + : "`{$safe}` = excluded.`{$safe}`"; + }, + $update_columns + ) + ); + $sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON CONFLICT({$conflict_list}) DO UPDATE SET {$updates}"; + } + + if ( empty( $prepare_values ) ) { + return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + return $wpdb->query( $wpdb->prepare( $sql, ...$prepare_values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } +} diff --git a/includes/class-tmdo-feature-flags.php b/includes/class-tmdo-feature-flags.php new file mode 100644 index 0000000..6e0a632 --- /dev/null +++ b/includes/class-tmdo-feature-flags.php @@ -0,0 +1,346 @@ +|null + */ + private static ?array $shadow_cache = null; + + /** + * Enable shadow_read_only for a module (only meaningful in the verify state). + * + * @param string $module Module identifier. + * @return bool True on success. + */ + public static function enable_shadow_read( string $module ): bool { + $flags = self::all_shadow(); + $flags[ $module ] = true; + update_option( self::SHADOW_OPTION_KEY, $flags, false ); + self::$shadow_cache = null; + return true; + } + + /** + * Disable shadow_read_only for a module. + * + * @param string $module Module identifier. + * @return bool + */ + public static function disable_shadow_read( string $module ): bool { + $flags = self::all_shadow(); + unset( $flags[ $module ] ); + update_option( self::SHADOW_OPTION_KEY, $flags, false ); + self::$shadow_cache = null; + return true; + } + + /** + * Whether shadow_read_only is currently active for a module. + * + * Returns true only when: + * - Module is in the `verify` state, AND + * - Shadow flag has been explicitly enabled. + * + * @param string $module Module identifier. + * @return bool + */ + public static function is_shadow_read_active( string $module ): bool { + if ( 'verify' !== self::get( $module ) ) { + return false; + } + $flags = self::all_shadow(); + return ! empty( $flags[ $module ] ); + } + + /** + * Return all shadow flags. + * + * @return array + */ + public static function all_shadow(): array { + if ( null !== self::$shadow_cache ) { + return self::$shadow_cache; + } + $saved = get_option( self::SHADOW_OPTION_KEY, array() ); + if ( ! is_array( $saved ) ) { + $saved = array(); + } + self::$shadow_cache = $saved; + return $saved; + } + + /** + * Check if a module is fully complete (reads and writes on custom table only). + * + * @param string $module Module identifier. + * @return bool True if module state is 'complete'. + */ + public static function is_complete( string $module ): bool { + return 'complete' === self::get( $module ); + } + + /** + * Check if dual-write is active for a module. + * + * @param string $module Module identifier. + * @return bool True if module is in a write-active state. + */ + public static function is_write_active( string $module ): bool { + return in_array( self::get( $module ), self::WRITE_ACTIVE_STATES, true ); + } + + /** + * Check if reads should come from the custom/zone table. + * + * @param string $module Module identifier. + * @return bool True if module is in a read-custom state. + */ + public static function is_read_custom( string $module ): bool { + return in_array( self::get( $module ), self::READ_CUSTOM_STATES, true ); + } + + /** + * Check if query interceptors should be active. + * + * @param string $module Module identifier. + * @return bool True if module is in a query-active state. + */ + public static function is_query_active( string $module ): bool { + return in_array( self::get( $module ), self::QUERY_ACTIVE_STATES, true ); + } + + /** + * Reset a module to idle (rollback). + * + * @param string $module Module identifier. + * @return void + */ + public static function reset( string $module ): void { + self::set( $module, 'idle' ); + self::$cache = null; // Invalidate request-level cache. + } + + /** + * Request-level cache for all() to avoid repeated get_option() calls. + * + * @var array|null + */ + private static ?array $cache = null; + + /** + * Return all module states. + * + * @return array + */ + public static function all(): array { + if ( null !== self::$cache ) { + return self::$cache; + } + + $saved = get_option( self::OPTION_KEY, array() ); + if ( ! is_array( $saved ) ) { + $saved = array(); + } + + $all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES ); + $defaults = array_fill_keys( $all_modules, 'idle' ); + + self::$cache = array_merge( $defaults, $saved ); + return self::$cache; + } + + /** + * Return only HPCT-inherited module states. + * + * @return array + */ + public static function hpct_modules(): array { + $all = self::all(); + return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) ); + } + + /** + * Return only zone module states. + * + * @return array + */ + public static function zone_modules(): array { + $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', + }; + } +} diff --git a/includes/class-tmdo-hook-bus-bridge.php b/includes/class-tmdo-hook-bus-bridge.php new file mode 100644 index 0000000..b76fd9b --- /dev/null +++ b/includes/class-tmdo-hook-bus-bridge.php @@ -0,0 +1,232 @@ + + */ + public static function detect_intra_wpdo_conflicts(): array { + global $wp_filter; + + $findings = array(); + $hooks_to_check = array( + 'update_post_metadata', + 'add_post_metadata', + 'delete_post_metadata', + 'get_post_metadata', + ); + + foreach ( $hooks_to_check as $hook ) { + if ( ! isset( $wp_filter[ $hook ] ) ) { + continue; + } + + // Collect TMDO_* callbacks AND identify which are non-whitelisted. + $wpdo_callbacks = array(); + $non_whitelist_callbacks = array(); + $priorities = $wp_filter[ $hook ]->callbacks ?? array(); + + foreach ( $priorities as $priority => $callbacks ) { + foreach ( $callbacks as $cb ) { + $cb = $cb['function'] ?? null; + if ( null === $cb ) { + continue; + } + $class_name = self::callable_class_name( $cb ); + if ( null === $class_name || ! str_starts_with( $class_name, 'TMDO_' ) ) { + continue; + } + $entry = array( + 'class' => $class_name, + 'priority' => (int) $priority, + ); + $wpdo_callbacks[] = $entry; + if ( ! in_array( $class_name, self::COEXIST_WHITELIST, true ) ) { + $non_whitelist_callbacks[] = $entry; + } + } + } + + // Real conflict: any non-whitelisted WPDO callback overlapping with + // other WPDO callbacks on the same hook. + if ( ! empty( $non_whitelist_callbacks ) && count( $wpdo_callbacks ) > 1 ) { + foreach ( $non_whitelist_callbacks as $cb ) { + $findings[] = array( + 'hook' => $hook, + 'priority' => $cb['priority'], + 'callback' => $cb['class'], + ); + } + } + } + + return $findings; + } + + /** + * Extract the class name from a callable, or return null when not class-bound. + * + * @param mixed $cb Any PHP callable. + * @return string|null Fully qualified class name, or null. + */ + private static function callable_class_name( $cb ): ?string { + if ( is_array( $cb ) && isset( $cb[0] ) ) { + $obj = $cb[0]; + if ( is_object( $obj ) ) { + return get_class( $obj ); + } + if ( is_string( $obj ) ) { + return $obj; + } + } + if ( is_string( $cb ) && str_contains( $cb, '::' ) ) { + return strtok( $cb, ':' ); + } + return null; + } +} diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php new file mode 100644 index 0000000..a030b53 --- /dev/null +++ b/includes/class-tmdo-installer.php @@ -0,0 +1,1348 @@ + $batch, + 'offset' => $offset, + 'fields' => 'ids', + ) + ); + foreach ( $site_ids as $blog_id ) { + switch_to_blog( (int) $blog_id ); + try { + self::install(); + } finally { + // v2.14.1: ensure blog context restored even if install() throws. + restore_current_blog(); + } + } + $offset += $batch; + } while ( count( $site_ids ) === $batch ); // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- count() used in do-while condition, loop body does not modify $site_ids, so caching is unnecessary. + } + + /** + * Auto-install tables when a new site is created in Multisite. + * + * @param \WP_Site $site The newly created site. + */ + public static function on_new_site( \WP_Site $site ): void { + if ( ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) ) { + return; + } + + switch_to_blog( (int) $site->blog_id ); + try { + self::install(); + } finally { + // v2.14.1: ensure blog context restored even if install() throws. + restore_current_blog(); + } + } + + /** + * Drop WPDO tables when a Multisite site is being deleted (v2.14.0). + * + * Fires on `wp_uninitialize_site` (the recommended hook for plugins to + * clean up site-scoped data; runs BEFORE WordPress drops the core + * wp_N_* tables). Without this hook, deleting a site leaves up to ~36 + * orphan `wp_N_wpdo_*` tables permanently consuming DB space. + * + * Only runs when the plugin is network-active (per-site activations + * are scoped to that single site, no cross-site cleanup needed). + * + * @param \WP_Site|int $site Site or site ID being deleted. + * @return void + * @since 2.14.0 + */ + public static function on_site_delete( $site ): void { + if ( ! is_multisite() ) { + return; + } + if ( function_exists( 'is_plugin_active_for_network' ) + && ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) + ) { + return; + } + + $blog_id = $site instanceof \WP_Site ? (int) $site->blog_id : (int) $site; + if ( $blog_id < 1 ) { + return; + } + + switch_to_blog( $blog_id ); + try { + $counts = self::drop_all_tables_for_current_blog(); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'multisite_site_delete_cleanup', + array( + 'blog_id' => $blog_id, + 'counts' => $counts, + ) + ); + } + } finally { + restore_current_blog(); + } + } + + /** + * Install tables for the current blog. + * + * Idempotent. Safely re-runs on every activation / version bump. + * v2.0.0: also installs the entity / audit / shadow_diffs / site_metrics + * tables via install_v2_tables() — kept here so plain `wp plugin activate` + * gets a complete v2 schema without needing the explicit V2_Upgrader run. + */ + public static function install(): void { + self::run_dbdelta(); + + if ( TMDO_IS_MYSQL ) { + self::run_mysql_indexes(); + } + + if ( TMDO_IS_SQLITE ) { + TMDO_SQLite_Compat::patch_all(); + } + + // v2.0.0 entity + audit + shadow_diffs + site_metrics + uni_options. + // Idempotent — only creates tables that don't already exist. + self::install_v2_tables(); + + update_option( 'wpdo_db_version', self::SCHEMA_VERSION ); + + // Register wpdo_features with autoload=no so it doesn't inflate every page load. + if ( false === get_option( 'wpdo_features' ) ) { + 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). + * + * Idempotent: guarded by the `wpdo_crypto_migrated_v2` flag option. Re-runs + * are no-ops. Errors are logged but do not block plugin activation — v1 + * blobs remain readable so notifications continue to work. + * + * @return void + */ + private static function maybe_migrate_crypto_v1_to_v2(): void { + if ( get_option( 'wpdo_crypto_migrated_v2' ) ) { + return; + } + if ( ! class_exists( 'TMDO_Crypto' ) ) { + return; + } + $counts = TMDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); + + if ( $counts['migrated'] > 0 || $counts['failed'] > 0 ) { + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( 'crypto_migrate_v1_to_v2', $counts ); + } + } + + // Mark as migrated even if 0 v1 ciphertexts were found, so we don't + // re-scan wp_options on every activation. The flag itself uses the + // option name pattern but isn't an encrypted secret. + update_option( 'wpdo_crypto_migrated_v2', '1', false ); + } + + /** + * Called on plugins_loaded to upgrade when SCHEMA_VERSION changes. + */ + public static function maybe_upgrade(): void { + if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) { + self::install(); + } + } + + // ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ───────────────── + // These tables are installed only when v2.0.0 upgrade runs (PR-7). + // install_v2_tables() is idempotent; safe to call multiple times. + + /** + * Install v2.0.0 tables — entity tables (user/term/comment), audit log, + * shadow_diffs, site_metrics, options manager backing table. + * + * Idempotent. Called from TMDO_V2_Upgrader::upgrade_to_v2() in PR-7. + * + * @internal Public so PR-7 upgrader and integration tests can invoke directly. + * @return void + */ + public static function install_v2_tables(): void { + global $wpdb; + // Allow integration tests to provide a stub dbDelta() without WP being bootstrapped. + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + $charset = $wpdb->get_charset_collate(); + $p = $wpdb->prefix; + + $sqls = array(); + + // ── wpdo_audit (structured op-aware audit log) ──────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_audit ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + entity_type varchar(20) NOT NULL DEFAULT '', + entity_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) NOT NULL DEFAULT '', + op varchar(20) NOT NULL DEFAULT '', + value_before longtext, + value_after longtext, + source varchar(20) NOT NULL DEFAULT '', + trace_id varchar(36) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_entity (entity_type, entity_id), + KEY idx_meta_key (meta_key(191)), + KEY idx_ts (ts), + KEY idx_trace (trace_id) +) {$charset};"; + + // ── wpdo_shadow_diffs (verify-stage divergence log) ─────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_shadow_diffs ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + entity_type varchar(20) NOT NULL DEFAULT '', + entity_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) NOT NULL DEFAULT '', + postmeta_value longtext, + zone_value longtext, + diff_hash varchar(40) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_entity (entity_type, entity_id), + KEY idx_meta_key (meta_key(191)), + KEY idx_diff_hash (diff_hash), + KEY idx_ts (ts) +) {$charset};"; + + // ── wpdo_site_metrics (Part C.3 site-wide EAV health) ───────────── + $sqls[] = "CREATE TABLE {$p}wpdo_site_metrics ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + collected_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + metric_key varchar(100) NOT NULL DEFAULT '', + metric_value bigint(20) NOT NULL DEFAULT 0, + context longtext, + PRIMARY KEY (id), + KEY idx_metric_key (metric_key), + KEY idx_collected_at (collected_at) +) {$charset};"; + + // ── wpdo_uni_options (autoload-optimized options) ───────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_uni_options ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + option_name varchar(191) NOT NULL DEFAULT '', + option_value longtext NOT NULL, + autoload varchar(20) NOT NULL DEFAULT 'no', + provider varchar(50) NOT NULL DEFAULT '', + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_option_name (option_name), + KEY idx_provider (provider) +) {$charset};"; + + // ── wpdo_registry_meta (v2.5.1 fix — engine schema_manager metadata catalog) ─ + // Stores schema hash + field_definitions per (entity_type, group_name). + // Previously referenced by `TMDO_Schema_Manager::store_schema_metadata` / + // `get_stored_schema_hash` via `$wpdb->replace()` but never created → produced + // continuous WordPress database errors on every wp-cli init. Schema derived + // from the columns those callers write. + $sqls[] = "CREATE TABLE {$p}wpdo_registry_meta ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + entity_type varchar(20) NOT NULL DEFAULT '', + group_name varchar(40) NOT NULL DEFAULT '', + schema_hash varchar(64) NOT NULL DEFAULT '', + field_definitions longtext, + updated_at datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (id), + UNIQUE KEY ui_entity_group (entity_type, group_name), + KEY idx_schema_hash (schema_hash) +) {$charset};"; + + // ── wpdo_snapshots (v2.2.0 M1 — backup/restore catalog with hybrid storage) ─ + // Catalog rows for every backup snapshot. Small payloads (≤5MB) live in + // `inline_blob`; larger snapshots are stored as gzipped SQL dumps under + // wp-content/uploads/wpdo-backups/.sql.gz with sha256 verify. + // `trigger` records why the snapshot was created (manual / pre_fsm_transition / + // pre_v2_upgrade / scheduled / pre_uninstall). `scope` JSON declares which + // entities/modules were dumped. `expires_at` powers the daily prune cron. + $sqls[] = "CREATE TABLE {$p}wpdo_snapshots ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + snapshot_id varchar(64) NOT NULL DEFAULT '', + trigger_type varchar(40) NOT NULL DEFAULT 'manual', + scope longtext, + size_bytes bigint(20) unsigned NOT NULL DEFAULT 0, + row_count bigint(20) unsigned NOT NULL DEFAULT 0, + storage varchar(20) NOT NULL DEFAULT 'file', + file_path varchar(500) DEFAULT NULL, + file_sha256 varchar(64) DEFAULT NULL, + inline_blob longblob, + fsm_states longtext, + notes varchar(500) DEFAULT NULL, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + expires_at datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY ui_snapshot_id (snapshot_id), + KEY idx_trigger_created (trigger_type, created_at), + KEY idx_expires (expires_at) +) {$charset};"; + + // ── wpdo_wc_commissions (v2.1.0 WC integration — vendor commission) ─ + // Replaces legacy `hpct_wc_orders` from HPCT era. HPOS-aware via + // wc_get_order() abstraction in TMDO_WC_Orders_Interceptor. + $sqls[] = "CREATE TABLE {$p}wpdo_wc_commissions ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + wc_order_id bigint(20) unsigned NOT NULL DEFAULT 0, + vendor_id bigint(20) unsigned NOT NULL DEFAULT 0, + listing_id bigint(20) unsigned NOT NULL DEFAULT 0, + subtotal decimal(15,4) NOT NULL DEFAULT 0, + commission decimal(15,4) NOT NULL DEFAULT 0, + vendor_payout decimal(15,4) NOT NULL DEFAULT 0, + commission_rate decimal(5,2) NOT NULL DEFAULT 0, + status varchar(40) NOT NULL DEFAULT 'pending', + hpos_enabled tinyint(1) NOT NULL DEFAULT 0, + payout_at datetime DEFAULT NULL, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_order_vendor (wc_order_id, vendor_id), + KEY idx_vendor (vendor_id), + KEY idx_status (status), + KEY idx_payout_at (payout_at) +) {$charset};"; + + // ── wpdo_migration_status (entity migration engine checkpoint) ──────── + // Bug fix: TMDO_Entity_Migration_Engine::migrate_group() reads/writes this + // table at every batch. Missing in original schema → first backfill fails. + $sqls[] = "CREATE TABLE {$p}wpdo_migration_status ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + entity_type varchar(20) NOT NULL DEFAULT '', + group_name varchar(40) NOT NULL DEFAULT '', + last_id bigint(20) unsigned NOT NULL DEFAULT 0, + total_migrated bigint(20) unsigned NOT NULL DEFAULT 0, + status varchar(20) NOT NULL DEFAULT 'idle', + started_at datetime NULL DEFAULT NULL, + completed_at datetime NULL DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY ui_entity_group (entity_type, group_name) +) {$charset};"; + + // ── wpdo_user_points_ledger (append-only points journal) ────────────── + // One row per transaction. idx_user_created is a covering index — + // WHERE user_id=? ORDER BY created_at DESC LIMIT N never touches data pages. + $sqls[] = "CREATE TABLE {$p}wpdo_user_points_ledger ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + delta int(11) NOT NULL DEFAULT 0, + balance_after bigint(20) NOT NULL DEFAULT 0, + reason varchar(60) NOT NULL DEFAULT '', + ref_id bigint(20) DEFAULT NULL, + ref_type varchar(30) DEFAULT NULL, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_user_created (user_id, created_at), + KEY idx_user_reason (user_id, reason), + KEY idx_created (created_at) +) {$charset};"; + + // ── v2.12.4 Phase 4: Term + Comment misc bucket (catch-all flat) ─────── + // Last-resort storage for unregistered term/comment meta keys, so + // wp_termmeta / wp_commentmeta can become DROPpable in v3.0.0. + // Composite PK (entity_id, meta_key) means each (entity, key) pair + // has exactly one canonical row — UPSERT semantics on write. + $sqls[] = "CREATE TABLE {$p}wpdo_term_misc ( + term_id bigint(20) unsigned NOT NULL, + meta_key varchar(191) NOT NULL, + meta_value longtext, + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (term_id, meta_key), + KEY meta_key (meta_key) +) {$charset};"; + + $sqls[] = "CREATE TABLE {$p}wpdo_comment_misc ( + comment_id bigint(20) unsigned NOT NULL, + meta_key varchar(191) NOT NULL, + meta_value longtext, + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (comment_id, meta_key), + KEY meta_key (meta_key) +) {$charset};"; + + foreach ( $sqls as $sql ) { + dbDelta( $sql ); + } + + if ( TMDO_IS_SQLITE ) { + TMDO_SQLite_Compat::patch_table( $p . 'wpdo_audit', self::v2_audit_sqlite_cols() ); + TMDO_SQLite_Compat::patch_table( $p . 'wpdo_shadow_diffs', self::v2_shadow_diffs_sqlite_cols() ); + } + + // Composite indexes for member flat tables — MySQL-only, idempotent. + if ( TMDO_IS_MYSQL ) { + self::install_member_indexes(); + } + } + + /** + * Add composite indexes to wp_wpdo_user_membership after Schema Manager + * creates the flat table from Entity Registry field definitions. + * + * Three indexes enable the key 10M-scale query patterns: + * idx_level_expires → WHERE level='gold' AND expires_at < NOW() + * idx_expires_level → ORDER BY expires_at (scan all expiring this month) + * idx_points_bal → ORDER BY points_balance DESC (leaderboard) + * + * Safe to call multiple times — skips existing indexes. + * + * @return void + */ + private static function install_member_indexes(): void { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_user_membership'; + + // Skip if Schema Manager hasn't created the table yet. + $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return; + } + + $desired = array( + 'idx_level_expires' => 'ADD INDEX `idx_level_expires` (membership_level, membership_expires_at)', + 'idx_expires_level' => 'ADD INDEX `idx_expires_level` (membership_expires_at, membership_level)', + 'idx_points_bal' => 'ADD INDEX `idx_points_bal` (points_balance)', + ); + + foreach ( $desired as $idx_name => $add_sql ) { + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', + $table, + $idx_name + ) + ); + if ( ! $exists ) { + $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are safe string literals with no user input + } + } + } + + /** + * SQLite column metadata for wpdo_audit (used by SQLite_Compat patcher). + * + * @return array + */ + private static function v2_audit_sqlite_cols(): array { + return array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'ts', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + array( + 'name' => 'entity_type', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'entity_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'op', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'trace_id', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + ); + } + + /** + * SQLite column metadata for wpdo_shadow_diffs. + * + * @return array + */ + private static function v2_shadow_diffs_sqlite_cols(): array { + return array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'entity_type', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'entity_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'diff_hash', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + ); + } + + /** + * Verify v2 tables exist. Used by upgrader pre-flight + tests. + * + * @return array Table name => exists. + */ + public static function v2_tables_status(): array { + global $wpdb; + $p = $wpdb->prefix; + $tables = array( + $p . 'wpdo_audit', + $p . 'wpdo_shadow_diffs', + $p . 'wpdo_site_metrics', + $p . 'wpdo_uni_options', + ); + + $status = array(); + foreach ( $tables as $table ) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $status[ $table ] = $exists; + } + return $status; + } + + /** + * 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' ); + } + + // ── v2.14.0: Multisite-aware site cleanup ───────────────────────────────── + + /** + * Drop all WPDO tables and clear options + cron for the *current* blog. + * + * Idempotent. Used by: + * - `uninstall.php` (single-site delete-and-uninstall path) + * - `on_site_delete()` / `on_uninitialize_site()` (multisite per-site cleanup) + * - `uninstall.php` network branch via `switch_to_blog` loop + * + * Does NOT take pre-uninstall snapshot — that's the caller's responsibility + * (snapshot only makes sense at uninstall, not at site-deletion). + * + * Validates each table name against `^[a-zA-Z0-9_]+$` + the `wpdo_` prefix + * to prevent accidental DROP of unrelated tables if registry is poisoned. + * + * @return array{tables_dropped:int,options_deleted:int,crons_cleared:int} + * @since 2.14.0 + */ + public static function drop_all_tables_for_current_blog(): array { + global $wpdb; + + $tables_dropped = 0; + $options_deleted = 0; + $crons_cleared = 0; + + // ── 1) Static system + entity flat tables ───────────────────────────── + $tables = array( + $wpdb->prefix . 'wpdo_migrations', + $wpdb->prefix . 'wpdo_errors', + $wpdb->prefix . 'wpdo_benchmarks', + $wpdb->prefix . 'wpdo_warm', + $wpdb->prefix . 'wpdo_archive', + $wpdb->prefix . 'wpdo_audit', + $wpdb->prefix . 'wpdo_shadow_diffs', + $wpdb->prefix . 'wpdo_site_metrics', + $wpdb->prefix . 'wpdo_registry_meta', + $wpdb->prefix . 'wpdo_uni_options', + $wpdb->prefix . 'wpdo_wc_commissions', + $wpdb->prefix . 'wpdo_snapshots', + $wpdb->prefix . 'wpdo_migration_status', + $wpdb->prefix . 'wpdo_user_points_ledger', + $wpdb->prefix . 'wpdo_user_membership', + $wpdb->prefix . 'wpdo_user_activity', + $wpdb->prefix . 'wpdo_user_profile', + $wpdb->prefix . 'wpdo_user_sso', + $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', + ); + + // ── 2) Dynamic zone + entity flat tables (per-site discovery) ───────── + $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%'; + $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%'; + $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%'; + $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%'; + $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%'; + $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%'; + + if ( class_exists( 'WP_SQLite_Driver' ) ) { + $dynamic = $wpdb->get_col( + $wpdb->prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)", + $hot_like, + $cold_like, + $entity_like, + $post_ent_like, + $term_ent_like, + $comment_ent_like + ) + ); + } else { + $dynamic = $wpdb->get_col( + $wpdb->prepare( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)', + $hot_like, + $cold_like, + $entity_like, + $post_ent_like, + $term_ent_like, + $comment_ent_like + ) + ); + } + + $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) ); + + // ── 3) Validate + filter existing-only + DROP ───────────────────────── + // Pre-check existence so the returned count reflects ACTUAL drops, not + // `DROP IF EXISTS` attempts. `$dynamic` is already pre-filtered (came + // from information_schema), but the static list may contain non-existent + // tables that would inflate the counter. + $prefix = $wpdb->prefix . 'wpdo_'; + $valid_static = array(); + foreach ( $tables as $table ) { + if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { + continue; + } + $valid_static[] = $table; + } + + if ( ! empty( $valid_static ) ) { + // Single information_schema lookup to confirm which actually exist. + $placeholders = implode( ',', array_fill( 0, count( $valid_static ), '%s' ) ); + if ( class_exists( 'WP_SQLite_Driver' ) ) { + $existing = $wpdb->get_col( + $wpdb->prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ...$valid_static + ) + ); + } else { + $existing = $wpdb->get_col( + $wpdb->prepare( + "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ...$valid_static + ) + ); + } + $existing = (array) $existing; + } else { + $existing = array(); + } + + foreach ( $existing as $table ) { + // Re-validate before raw interpolation as a defence-in-depth measure. + if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { + continue; + } + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + ++$tables_dropped; + } + + // ── 4) Clear options ────────────────────────────────────────────────── + $known_options = array( + 'wpdo_db_version', + 'wpdo_features', + 'wpdo_features_shadow', + 'wpdo_hpct_imported', + 'wpdo_hook_bus_enabled', + 'wpdo_v2_features_backup', + 'wpdo_v2_upgrade_status', + 'wpdo_v2_upgrade_error', + 'wpdo_v2_upgraded_at', + 'wpdo_health_alert', + 'wpdo_rl_stats', + 'wpdo_setup_wizard_completed', + 'wpdo_first_run_at', + // Stress test state. + 'wpdo_post_stress_test_state', + 'wpdo_user_stress_test_state', + 'wpdo_term_stress_test_state', + 'wpdo_comment_stress_test_state', + ); + foreach ( $known_options as $opt ) { + if ( delete_option( $opt ) ) { + ++$options_deleted; + } + } + + // 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. + $residual = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( 'wpdo_' ) . '%' + ) + ); + foreach ( (array) $residual as $opt ) { + if ( preg_match( '/^wpdo_[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 ); + ++$crons_cleared; + } + } + + return array( + 'tables_dropped' => $tables_dropped, + 'options_deleted' => $options_deleted, + 'crons_cleared' => $crons_cleared, + ); + } + + // ── Dynamic Zone Table Creation ────────────────────────────────────── + + /** + * Ensure the Zone A (hot) table has all columns declared by Schema_Registry. + * + * V2.1.2 critical fix: when a partner plugin registers new hot-zone fields + * AFTER the initial table creation, those columns never make it to the DB + * → WPDO silently falls back to postmeta → zero advertised speedup. + * + * This method: + * 1. Reads current columns from the existing table (SHOW COLUMNS / pragma) + * 2. Diffs against $expected_columns (from Schema_Registry) + * 3. ALTER TABLE ADD COLUMN for any missing columns (idempotent) + * 4. Returns the list of columns that were added (for logging / doctor) + * + * Safe to call on every page load — diff is fast (~0.5ms), ALTER only fires + * on actual drift. + * + * @param string $post_type Post type slug. + * @param array $expected_columns Schema_Registry's declared columns: [name => sql_type]. + * @return array Names of columns added (empty when no drift). + * + * @since 2.1.2 + */ + public static function ensure_hot_columns( string $post_type, array $expected_columns ): array { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); + + // Skip if table doesn't exist — create_hot_table will handle it. + if ( TMDO_IS_SQLITE ) { + $exists = $wpdb->get_var( + $wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name=%s", $table ) + ); + } else { + $exists = $wpdb->get_var( + $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) + ); + } + if ( ! $exists ) { + return array(); + } + + // Read existing columns. + $existing = array(); + if ( TMDO_IS_SQLITE ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = $wpdb->get_results( "PRAGMA table_info(`{$table}`)", ARRAY_A ); + foreach ( (array) $rows as $row ) { + $existing[ $row['name'] ] = true; + } + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A ); + foreach ( (array) $rows as $row ) { + $existing[ $row['Field'] ] = true; + } + } + + // Diff: which expected columns are missing from the DB? + $added = array(); + foreach ( $expected_columns as $col_name => $col_type ) { + $safe_name = sanitize_key( $col_name ); + if ( isset( $existing[ $safe_name ] ) ) { + continue; + } + + // MySQL/MariaDB ALTER TABLE ADD COLUMN. SQLite supports the same syntax. + // Suppress PHP warnings during the ALTER — concurrent races on cold deploy + // (N php-fpm workers each detecting drift simultaneously) cause the loser + // to error with "Duplicate column name". We tolerate that case (the column + // IS now present) but surface real failures (disk full, permission denied). + $prev_show = $wpdb->show_errors ?? false; + if ( method_exists( $wpdb, 'hide_errors' ) ) { + $wpdb->hide_errors(); + } + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $ok = $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$safe_name}` {$col_type}" ); + if ( $prev_show && method_exists( $wpdb, 'show_errors' ) ) { + $wpdb->show_errors(); + } + + if ( false !== $ok ) { + $added[] = $safe_name; + continue; + } + + // v2.1.3 race tolerance: "Duplicate column name" (MySQL errno 1060) means + // another worker won the race. The column IS present now — confirm and treat + // as success. Real failures (errno != 1060, e.g. 1142 access denied, 1114 + // table full) still get logged as errors with SQLSTATE/errno for ops. + $err = (string) ( $wpdb->last_error ?? '' ); + $is_duplicate = stripos( $err, 'Duplicate column' ) !== false + || stripos( $err, 'duplicate column' ) !== false + || stripos( $err, '1060' ) !== false; // MySQL errno. + if ( $is_duplicate ) { + $added[] = $safe_name; // race winner already added it; we're consistent. + continue; + } + + TMDO_Logger::error( + 'installer', + 'ensure_hot_columns', + "Failed to ALTER TABLE {$table} ADD {$safe_name}: {$err}" + ); + } + + // Re-add covering indexes to pick up any newly indexed columns. + if ( ! empty( $added ) && TMDO_IS_MYSQL ) { + self::add_covering_indexes( $post_type, $expected_columns ); + } + + return $added; + } + + /** + * Create a Zone A (hot) table for a specific post type. + * + * @param string $post_type Post type slug (e.g. 'hp_listing'). + * @param array $columns Column definitions from Schema Registry. Format: [ 'column_name' => 'column_type_sql', ... ]. + * @return void + */ + public static function create_hot_table( string $post_type, array $columns ): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset = $wpdb->get_charset_collate(); + $table_name = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); + + $col_defs = " id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n"; + $col_defs .= " post_id bigint(20) unsigned NOT NULL DEFAULT 0,\n"; + + $index_defs = array(); + $sqlite_cols = array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'post_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + ); + + foreach ( $columns as $col_name => $col_type ) { + $safe_name = sanitize_key( $col_name ); + $col_defs .= " {$safe_name} {$col_type},\n"; + + $base_type = strtolower( strtok( $col_type, '(' ) ); + $sqlite_cols[] = array( + 'name' => $safe_name, + 'type' => $base_type, + 'nullable' => str_contains( strtolower( $col_type ), 'null' ) && ! str_contains( strtolower( $col_type ), 'not null' ), + 'default' => '0', + ); + } + + $col_defs .= " updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',\n"; + $sqlite_cols[] = array( + 'name' => 'updated_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ); + + $indexes = " PRIMARY KEY (id),\n UNIQUE KEY ui_post_id (post_id)"; + foreach ( $index_defs as $idx ) { + $indexes .= ",\n {$idx}"; + } + + $sql = "CREATE TABLE {$table_name} (\n{$col_defs}{$indexes}\n) {$charset};"; + dbDelta( $sql ); + + if ( TMDO_IS_SQLITE ) { + TMDO_SQLite_Compat::patch_table( $table_name, $sqlite_cols ); + } + + // Add covering indexes after table creation (MySQL only). + if ( TMDO_IS_MYSQL ) { + self::add_covering_indexes( $post_type, $columns ); + } + } + + /** + * Add covering indexes to an existing Zone A (hot) table. + * + * Index strategy: + * - DECIMAL/FLOAT columns → single-column idx for range + ORDER BY + * - TINYINT + DECIMAL → compound (flag, sort) for filtered sorts + * - BIGINT *_time + DECIMAL → compound (time, sort) for expiry + price + * - TINYINT matching *_featured + BIGINT *_featured_time → compound (flag, time) + * + * Safe to call multiple times — skips already-existing indexes. + * No-op on SQLite. + * + * @param string $post_type Post type slug. + * @param array $columns Column name => SQL type from Schema Registry. + */ + public static function add_covering_indexes( string $post_type, array $columns ): void { + if ( TMDO_IS_SQLITE ) { + return; + } + + global $wpdb; + $table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type ); + + // Categorise columns by SQL type. + $decimal_cols = array(); // decimal/float/double → good for range + ORDER BY. + $tinyint_cols = array(); // tinyint → boolean flags. + $bigint_time = array(); // bigint with _time suffix → timestamps. + + foreach ( $columns as $col_name => $col_type ) { + $safe = sanitize_key( $col_name ); + $base = strtolower( strtok( $col_type, '( ' ) ); + if ( in_array( $base, array( 'decimal', 'float', 'double' ), true ) ) { + $decimal_cols[] = $safe; + } elseif ( 'tinyint' === $base ) { + $tinyint_cols[] = $safe; + } elseif ( 'bigint' === $base && str_ends_with( $safe, '_time' ) ) { + $bigint_time[] = $safe; + } + } + + // Build desired index map: name => ADD INDEX SQL fragment. + $desired = array(); + + // 1. Single-column index on every decimal column. + foreach ( $decimal_cols as $col ) { + $desired[ "idx_{$col}" ] = "ADD INDEX `idx_{$col}` (`{$col}`)"; + } + + // 2. Compound (tinyint_flag, decimal_sort) — improves "WHERE flag=1 ORDER BY price". + foreach ( $tinyint_cols as $flag ) { + foreach ( $decimal_cols as $sort ) { + $name = "idx_{$flag}_{$sort}"; + $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$sort}`)"; + } + } + + // 3. Compound (bigint_time, decimal_sort) — improves "WHERE exp_time > ? ORDER BY price". + foreach ( $bigint_time as $time_col ) { + foreach ( $decimal_cols as $sort ) { + $name = "idx_{$time_col}_{$sort}"; + $desired[ $name ] = "ADD INDEX `{$name}` (`{$time_col}`, `{$sort}`)"; + } + + // 4. Compound (matching_tinyint_flag, bigint_time) — "WHERE featured=1 ORDER BY featured_time". + // Matches pattern: tinyint column name is prefix of time column (hp_featured → hp_featured_time). + foreach ( $tinyint_cols as $flag ) { + if ( str_starts_with( $time_col, $flag . '_' ) ) { + $name = "idx_{$flag}_{$time_col}"; + $desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$time_col}`)"; + } + } + } + + if ( empty( $desired ) ) { + return; + } + + // Apply only missing indexes. + foreach ( $desired as $idx_name => $add_sql ) { + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', + $table, + $idx_name + ) + ); + if ( ! $exists ) { + $wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are validated via sanitize_key(); add_sql contains no user input. + } + } + } + + /** + * Create a Zone C (cold) table for a specific post type. + * + * @param string $post_type Post type slug (e.g. 'hp_vendor'). + */ + public static function create_cold_table( string $post_type ): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset = $wpdb->get_charset_collate(); + $table_name = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $post_type ); + + $sql = "CREATE TABLE {$table_name} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + data longtext NOT NULL, + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_post_id (post_id) +) {$charset};"; + + dbDelta( $sql ); + + if ( TMDO_IS_SQLITE ) { + TMDO_SQLite_Compat::patch_table( + $table_name, + array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'post_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'data', + 'type' => 'longtext', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'updated_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + ) + ); + } + } + + // ── Layer 1: dbDelta ───────────────────────────────────────────────── + + /** + * Runs dbDelta to create or upgrade all WPDO tables. + * + * @return void + */ + private static function run_dbdelta(): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset = $wpdb->get_charset_collate(); + $p = $wpdb->prefix; + + $sqls = array(); + + // ── wpdo_migrations ────────────────────────────────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_migrations ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + module varchar(50) NOT NULL DEFAULT '', + zone varchar(10) NOT NULL DEFAULT '', + state varchar(20) NOT NULL DEFAULT 'idle', + total_rows bigint(20) unsigned NOT NULL DEFAULT 0, + processed_rows bigint(20) unsigned NOT NULL DEFAULT 0, + last_offset bigint(20) unsigned NOT NULL DEFAULT 0, + error_count int(11) NOT NULL DEFAULT 0, + started_at datetime NULL DEFAULT NULL, + completed_at datetime NULL DEFAULT NULL, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_module (module) +) {$charset};"; + + // ── wpdo_errors ────────────────────────────────────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_errors ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + module varchar(50) NOT NULL DEFAULT '', + zone varchar(10) NOT NULL DEFAULT '', + hook varchar(255) NOT NULL DEFAULT '', + message longtext NOT NULL, + context longtext, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_module (module), + KEY idx_created_at (created_at) +) {$charset};"; + + // ── wpdo_benchmarks ────────────────────────────────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_benchmarks ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + module varchar(50) NOT NULL DEFAULT '', + zone varchar(10) NOT NULL DEFAULT '', + query_type varchar(50) NOT NULL DEFAULT '', + native_ms decimal(10,3) NOT NULL DEFAULT 0, + custom_ms decimal(10,3) NOT NULL DEFAULT 0, + sample_size int(11) NOT NULL DEFAULT 0, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_module (module) +) {$charset};"; + + // ── wpdo_warm (Zone B) ─────────────────────────────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_warm ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) NOT NULL DEFAULT '', + meta_value longtext, + expires_at datetime NULL DEFAULT NULL, + created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_post_meta (post_id, meta_key(191)), + KEY idx_expires_at (expires_at) +) {$charset};"; + + // ── wpdo_archive (Zone D) ──────────────────────────────────────── + $sqls[] = "CREATE TABLE {$p}wpdo_archive ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + post_type varchar(20) NOT NULL DEFAULT '', + meta_key varchar(255) NOT NULL DEFAULT '', + meta_value longtext, + compressed tinyint(1) NOT NULL DEFAULT 0, + archived_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + 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) +) {$charset};"; + + foreach ( $sqls as $sql ) { + dbDelta( $sql ); + } + } + + // ── Layer 2: MySQL-only composite indexes ──────────────────────────── + + /** + * Adds MySQL-only composite indexes if they do not already exist. + * + * @return void + */ + private static function run_mysql_indexes(): void { + global $wpdb; + $p = $wpdb->prefix; + + $indexes = array( + "{$p}wpdo_migrations" => array( + 'ui_module' => "ALTER TABLE `{$p}wpdo_migrations` ADD UNIQUE KEY `ui_module` (module)", + ), + "{$p}wpdo_warm" => array( + 'ui_post_meta' => "ALTER TABLE `{$p}wpdo_warm` ADD UNIQUE KEY `ui_post_meta` (post_id, meta_key(191))", + ), + ); + + foreach ( $indexes as $table => $defs ) { + foreach ( $defs as $key_name => $sql ) { + $exists = $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s', + $table, + $key_name + ) + ); + if ( ! $exists ) { + $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + } + } + } +} diff --git a/includes/class-tmdo-logger.php b/includes/class-tmdo-logger.php new file mode 100644 index 0000000..5f41696 --- /dev/null +++ b/includes/class-tmdo-logger.php @@ -0,0 +1,160 @@ +insert( + $table, + array( + 'module' => sanitize_key( $module ), + 'zone' => sanitize_key( $zone ), + 'hook' => substr( sanitize_text_field( $hook ), 0, 255 ), + 'message' => $message, + 'context' => $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : null, + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + } + + /** + * Return recent errors, optionally filtered by module. + * + * @param string $module Module name (empty = all modules). + * @param int $limit Max rows to return. + * @return array + */ + public static function get_recent( string $module = '', int $limit = 100 ): array { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_errors' ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table(). + if ( $module ) { + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM `{$table}` WHERE module = %s ORDER BY id DESC LIMIT %d", + $module, + $limit + ), + ARRAY_A + ) ?: array(); + } + + return $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ), + ARRAY_A + ) ?: array(); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Delete errors older than a given number of days. + * + * @param int $days Keep errors from the last N days. + * @return int Number of rows deleted. + */ + public static function purge( int $days = 30 ): int { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_errors' ); + + $now = current_time( 'mysql', true ); + + if ( TMDO_IS_SQLITE ) { + $sql = $wpdb->prepare( + "DELETE FROM `{$table}` WHERE created_at < datetime(%s, %s)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $now, + "-{$days} days" + ); + } else { + $sql = $wpdb->prepare( + "DELETE FROM `{$table}` WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $now, + $days + ); + } + + return (int) $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } +} diff --git a/includes/class-tmdo-post-shadow-verifier.php b/includes/class-tmdo-post-shadow-verifier.php new file mode 100644 index 0000000..3961c33 --- /dev/null +++ b/includes/class-tmdo-post-shadow-verifier.php @@ -0,0 +1,437 @@ + 0'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + if ( empty( $keys ) ) { + $msg = 'Keys array cannot be empty'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + global $wpdb; + + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d", + $post_type, + $sample_size + ) + ); + + $sampled = count( $post_ids ); + $matched = 0; + $diffs = 0; + $missing_flat = 0; + $missing_postmeta = 0; + + if ( 0 === $sampled ) { + return array( + 'sampled' => 0, + 'matched' => 0, + 'diffs' => 0, + 'missing_flat' => 0, + 'missing_postmeta' => 0, + 'group' => $group_name, + 'post_type' => $post_type, + ); + } + + foreach ( $post_ids as $post_id ) { + $post_id = (int) $post_id; + foreach ( $keys as $key ) { + $col = self::sanitize_column( $key ); + $flat_val = $wpdb->get_var( + $wpdb->prepare( + "SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1", + $post_id + ) + ); + $pm_val = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", + $post_id, + $key + ) + ); + + $flat_present = null !== $flat_val && '' !== $flat_val; + $pm_present = null !== $pm_val && '' !== $pm_val; + + if ( ! $flat_present && ! $pm_present ) { + // Both empty — neither side has the value, count as match + // (key truly absent for this post). + ++$matched; + continue; + } + if ( ! $flat_present && $pm_present ) { + ++$missing_flat; + self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' ); + continue; + } + if ( $flat_present && ! $pm_present ) { + ++$missing_postmeta; + self::log_diff( $post_id, $key, '(missing)', (string) $flat_val ); + continue; + } + + // Loose equality — flat may have widened types (e.g. tinyint→bigint) + // or full-precision decimals (10,2 → 18,6). + if ( self::values_loose_equal( $pm_val, $flat_val ) ) { + ++$matched; + } else { + ++$diffs; + self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val ); + } + } + } + + return array( + 'sampled' => $sampled, + 'matched' => $matched, + 'diffs' => $diffs, + 'missing_flat' => $missing_flat, + 'missing_postmeta' => $missing_postmeta, + 'group' => $group_name, + 'post_type' => $post_type, + ); + } + + /** + * Cron handler: runs sample_compare for each registered group when post + * mode is shadow_read. No-op otherwise. + * + * @return void + */ + public static function cron_tick(): void { + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return; + } + if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) { + return; + } + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return; + } + + global $wpdb; + + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { + $keys = TMDO_Entity_Registry::get_group_keys( 'post', $group ); + if ( empty( $keys ) ) { + continue; + } + $post_type = self::group_post_type( $group ); + if ( null === $post_type ) { + continue; // wp_core spans all types, skip in cron tick. + } + $flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); + + try { + self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE ); + } catch ( \Throwable $e ) { + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( + 'post_shadow_verifier', + 'cron_tick', + $e->getMessage() + ); + } + } + } + } + + /** + * Recent diff records for entity_type='post'. + * + * @param int $limit Max rows. + * @return array + */ + public static function recent_diffs( int $limit = 100 ): array { + if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) { + return array(); + } + return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' ); + } + + /** + * Aggregate diff stats for entity_type='post' over recent N hours. + * + * @param int $hours Window in hours (default 24). + * @return array{total:int,by_key:array} + */ + public static function diff_stats( int $hours = 24 ): array { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return array( + 'total' => 0, + 'by_key' => array(), + ); + } + + $since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) ); + + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s", + $since + ) + ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s GROUP BY meta_key ORDER BY n DESC", + $since + ), + ARRAY_A + ); + + $by_key = array(); + foreach ( (array) $rows as $row ) { + $by_key[ (string) $row['meta_key'] ] = (int) $row['n']; + } + + return array( + 'total' => $total, + 'by_key' => $by_key, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Sanitize meta_key into a column name (mirrors Schema_Manager rules). + * + * @param string $key Meta key. + * @return string + */ + private static function sanitize_column( string $key ): string { + if ( class_exists( 'TMDO_Schema_Manager' ) ) { + return TMDO_Schema_Manager::sanitize_column_name( $key ); + } + return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key ); + } + + /** + * Map entity group → primary post_type (null = cross-cutting like wp_core). + * + * @param string $group Entity group name. + * @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; + } + } + + /** + * Loose equality for verifier — handles widened types (decimal precision, + * tinyint→bigint, etc.) and serialization-format differences without + * false-positive divergence reports. + * + * Layers (fail-fast on first match): + * 1. Identical strings + * 2. Numeric loose match (handles "50" vs "50.000000") + * 3. v2.10.5: Decoded match for serialized vs JSON values + * (postmeta uses PHP serialize, flat tables use wp_json_encode for + * json-typed fields — same logical data, different storage format) + * + * @param mixed $a Side A value. + * @param mixed $b Side B value. + * @return bool + */ + private static function values_loose_equal( $a, $b ): bool { + // Both null/empty already filtered out by caller. + $as = (string) $a; + $bs = (string) $b; + if ( $as === $bs ) { + return true; + } + // Numeric loose match (handles "50" vs "50.000000"). + if ( is_numeric( $as ) && is_numeric( $bs ) ) { + return (float) $as === (float) $bs; + } + // v2.10.5: try decoded comparison for serialized/JSON values. + $a_decoded = self::decode_value( $as ); + $b_decoded = self::decode_value( $bs ); + if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) { + return true; + } + return false; + } + + /** + * Best-effort decode of a string value: try PHP unserialize (object-safe), + * then JSON. Returns the decoded structure, or null when neither succeeds + * (so the caller can short-circuit instead of false-positive matching + * against a literal "null" string). + * + * Returns null in three cases: + * - Both decoders failed (input is plain non-encoded string) + * - unserialize returned NULL legitimately (input was 'N;') + * - Empty string + * + * The caller distinguishes these by combining with an identical-string + * fast-path that runs first; non-encoded strings hit equality before this + * decoder runs. + * + * @param string $s Raw string value. + * @return mixed|null Decoded value, or null on failure. + */ + private static function decode_value( string $s ) { + if ( '' === $s ) { + return null; + } + // PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'. + if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices. + $v = @unserialize( $s, array( 'allowed_classes' => false ) ); + if ( false !== $v || 'b:0;' === $s ) { + return $v; + } + } + // JSON: arrays/objects start with [ or {. + $first = $s[0] ?? ''; + if ( '[' === $first || '{' === $first ) { + $v = json_decode( $s, true ); + if ( null !== $v ) { + return $v; + } + } + return null; + } + + /** + * Record a divergence via the shared shadow diff logger. + * + * @param int $post_id Post ID. + * @param string $key Meta key. + * @param string $pm_val Postmeta value (or '(missing)'). + * @param string $flat_val Flat value (or '(missing)'). + * @return void + */ + private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void { + if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) { + return; + } + // Use the public record() method if the logger exposes one; otherwise + // fall through silently. v2.5.x exposes compare_and_log() which does + // internal compare; for verifier we already know they diverged so we + // write directly to the table. + global $wpdb; + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return; + } + $wpdb->insert( + $table, + array( + 'entity_type' => 'post', + 'entity_id' => $post_id, + 'meta_key' => $key, + 'postmeta_value' => substr( $pm_val, 0, 1000 ), + 'zone_value' => substr( $flat_val, 0, 1000 ), + 'diff_hash' => md5( $pm_val . '|' . $flat_val ), + 'ts' => gmdate( 'Y-m-d H:i:s' ), + ) + ); + } +} diff --git a/includes/class-tmdo-post-stress-tester.php b/includes/class-tmdo-post-stress-tester.php new file mode 100644 index 0000000..385ada5 --- /dev/null +++ b/includes/class-tmdo-post-stress-tester.php @@ -0,0 +1,1043 @@ +posts/postmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders. + +/** + * Bulk fixture generator for post entity stress tests. + */ +final class TMDO_Post_Stress_Tester { + + /** Prefix for stress test post titles — used for cleanup matching. */ + public const TEST_POST_PREFIX = 'TMDO_STRESS_TEST_'; + + /** Hard cap to prevent runaway create() calls. */ + private const MAX_COUNT = 100000; + + // v2.11.4 state machine constants (mirrors TMDO_User_Stress_Tester). + public const OPT_STATE = 'wpdo_post_stress_test_state'; + public const CRON_HOOK = 'wpdo_post_stress_test_batch'; + public const CANCEL_FLAG = 'wpdo_post_stress_cancel_flag'; + public const DEFAULT_BATCH_SIZE = 200; + public const MAX_BATCH_SIZE = 1000; + public const MIN_BATCH_DELAY_SEC = 1; + public const BATCH_DEADLINE_SEC = 8; + public const MODE_FAST = 'fast'; + public const MODE_REALISTIC = 'realistic'; + + /** + * Per-post_type seed map: meta_key → value generator (closure or static value). + * Static values are sufficient for fixture purposes; randomization belongs in + * the user-side realistic_mode benchmarks (out of scope for v2.9.4). + * + * @var array>|null + */ + private static ?array $seed_map_cache = null; + + // ───────────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────────── + + /** + * Bulk-create N stress test posts of the given post_type, plus their + * canonical postmeta rows (matching the v2.9.1 entity group definitions). + * + * @param string $post_type One of: product, hp_listing, hp_request, hp_vendor, + * attachment, nav_menu_item, post. + * @param int $count Number of posts to insert. Capped at MAX_COUNT. + * @return array{created:int,post_type:string,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When post_type unsupported or $count out of range. + */ + public static function create( string $post_type, int $count ): array { + $seed_map = self::seed_map(); + + if ( ! isset( $seed_map[ $post_type ] ) ) { + throw new InvalidArgumentException( + 'Unsupported post_type for stress test: ' . esc_html( $post_type ) + . '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) ) + ); + } + if ( $count <= 0 ) { + throw new InvalidArgumentException( 'Count must be > 0.' ); + } + if ( $count > self::MAX_COUNT ) { + $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + global $wpdb; + + $now = current_time( 'mysql' ); + $now_gmt = current_time( 'mysql', true ); + $first_id = null; + $last_id = null; + + // Insert posts one-by-one — bulk INSERT would require shared insert_id + // gymnastics for the postmeta foreign-key relationship. For fixture + // volumes (target ≤ 10k), per-row insert is fine and keeps the code + // straightforward. + for ( $i = 0; $i < $count; $i++ ) { + $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false ); + $ok = $wpdb->insert( + $wpdb->posts, + array( + 'post_title' => $title, + 'post_type' => $post_type, + 'post_status' => 'publish', + 'post_date' => $now, + 'post_date_gmt' => $now_gmt, + 'post_modified' => $now, + 'post_modified_gmt' => $now_gmt, + 'post_content' => '', + 'post_excerpt' => '', + 'post_content_filtered' => '', + 'to_ping' => '', + 'pinged' => '', + 'post_name' => sanitize_title( $title ), + 'guid' => '', + ) + ); + if ( ! $ok ) { + continue; + } + $post_id = (int) $wpdb->insert_id; + if ( null === $first_id ) { + $first_id = $post_id; + } + $last_id = $post_id; + + // Seed canonical meta keys for this post_type. + foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + $wpdb->insert( + $wpdb->postmeta, + array( + 'post_id' => $post_id, + 'meta_key' => $meta_key, + 'meta_value' => (string) $value, + ) + ); + } + } + + return array( + 'created' => $count, + 'post_type' => $post_type, + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Realistic-mode counterpart of create() (v2.11.2) — uses wp_insert_post() + * and update_post_meta() instead of direct $wpdb->insert. Exercises the + * full WP filter chain so Hook Bus interception (when post mode is + * dual_write or higher) is naturally triggered. + * + * Use this mode when: + * - Validating production write path (mode=dual_write+ → flat tables auto-fill) + * - Benchmarking realistic insert latency vs fast-path + * - Stress-testing the Hook Bus + Sync_Bridge guard for post entity + * + * @param string $post_type One of: product, hp_listing, hp_request, hp_vendor, + * attachment, nav_menu_item, post. + * @param int $count Number of posts to insert. Capped at MAX_COUNT. + * @return array{created:int,post_type:string,mode:string,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When post_type unsupported or $count out of range. + */ + public static function create_realistic( string $post_type, int $count ): array { + $seed_map = self::seed_map(); + + if ( ! isset( $seed_map[ $post_type ] ) ) { + throw new InvalidArgumentException( + 'Unsupported post_type for stress test: ' . esc_html( $post_type ) + . '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) ) + ); + } + if ( $count <= 0 ) { + throw new InvalidArgumentException( 'Count must be > 0.' ); + } + if ( $count > self::MAX_COUNT ) { + $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $first_id = null; + $last_id = null; + + for ( $i = 0; $i < $count; $i++ ) { + $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false ); + $post_id = wp_insert_post( + array( + 'post_title' => $title, + 'post_type' => $post_type, + 'post_status' => 'publish', + ) + ); + if ( ! $post_id || is_wp_error( $post_id ) ) { + continue; + } + $post_id = (int) $post_id; + if ( null === $first_id ) { + $first_id = $post_id; + } + $last_id = $post_id; + + // Use update_post_meta() so WP fires update_post_metadata filter → + // Hook Bus intercepts when post mode is dual_write or higher. + foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_post_meta( $post_id, $meta_key, $value ); + } + } + + return array( + 'created' => $count, + 'post_type' => $post_type, + 'mode' => 'realistic', + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Count posts matching the stress-test title prefix. + * + * @return int + */ + public static function count_test_posts(): int { + global $wpdb; + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_title LIKE %s", + $wpdb->esc_like( self::TEST_POST_PREFIX ) . '%' + ) + ); + } + + /** + * Delete every stress-test post + its postmeta + any flat-table rows + * still keyed to those post IDs. + * + * @return array{deleted_posts:int,deleted_meta:int,deleted_flat_rows:int} + */ + public static function cleanup(): array { + global $wpdb; + + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE %s", + $wpdb->esc_like( self::TEST_POST_PREFIX ) . '%' + ) + ); + + if ( empty( $post_ids ) ) { + return array( + 'deleted_posts' => 0, + 'deleted_meta' => 0, + 'deleted_flat_rows' => 0, + ); + } + + $id_list = implode( ',', array_map( 'absint', $post_ids ) ); + + // Delete flat rows first (best-effort — tables may not exist yet). + $flat_deleted = 0; + foreach ( self::get_post_flat_tables() as $tbl ) { + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) + ); + if ( ! $exists ) { + continue; + } + $rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE post_id IN ({$id_list})" ); + $flat_deleted += $rows; + } + + // Delete postmeta rows for these post IDs. + $meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list})" ); + + // Finally delete the posts. + $post_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ({$id_list})" ); + + return array( + 'deleted_posts' => $post_deleted, + 'deleted_meta' => $meta_deleted, + 'deleted_flat_rows' => $flat_deleted, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // v2.11.4 — State machine (cron pump + progress polling), mirrors User side + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start an async stress run. + * + * Persists state in `wpdo_post_stress_test_state` and schedules the first + * batch via wp_schedule_single_event(). The first batch does NOT run sync — + * it is pushed by the cron event or by `pump_if_due()` on the next polling + * request. + * + * @param string $post_type One of seed_map() keys. + * @param int $target Total posts to create (1..MAX_COUNT). + * @param string $mode MODE_FAST | MODE_REALISTIC. + * @param int $batch_size Per-batch insert count (1..MAX_BATCH_SIZE). + * @return array{ok:bool,error?:string,state?:array} + */ + public static function start( string $post_type, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array { + $seed_map = self::seed_map(); + if ( ! isset( $seed_map[ $post_type ] ) ) { + return array( + 'ok' => false, + 'error' => 'unsupported_post_type', + ); + } + if ( $target < 1 ) { + return array( + 'ok' => false, + 'error' => 'target must be >= 1', + ); + } + if ( $target > self::MAX_COUNT ) { + return array( + 'ok' => false, + 'error' => 'target too large (max ' . self::MAX_COUNT . ')', + ); + } + if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) { + return array( + 'ok' => false, + 'error' => 'invalid mode', + ); + } + $batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) ); + + $current = self::get_state(); + if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) { + return array( + 'ok' => false, + 'error' => 'already_running', + 'state' => $current, + ); + } + + $state = array( + 'job_id' => uniqid( 'pstress_', true ), + 'status' => 'running', + 'mode' => $mode, + 'post_type' => $post_type, + 'target' => $target, + 'batch_size' => $batch_size, + 'started_at' => time(), + 'processed' => 0, + 'batches_done' => 0, + 'batches_log' => array(), + 'errors' => array(), + 'peak_memory' => 0, + 'completed_at' => null, + 'last_pushed_at' => 0, + 'benchmark' => null, + ); + update_option( self::OPT_STATE, $state, false ); + + // 清掉前次留下的 cancellation flag, avoid 新測試啟動被誤判為已取消. + delete_transient( self::CANCEL_FLAG ); + + wp_clear_scheduled_hook( self::CRON_HOOK ); + wp_schedule_single_event( time(), self::CRON_HOOK ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Cancel a running stress test. Sets cancellation flag for in-flight batch + * to detect on next iteration; cron events are cleared. + * + * @return array{ok:bool,state?:array,message?:string} + */ + public static function cancel(): array { + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'ok' => true, + 'message' => 'no_active_job', + ); + } + + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state = self::get_state(); + $state['status'] = 'cancelled'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Read raw state from option storage. + * + * @return array + */ + public static function get_state(): array { + $state = get_option( self::OPT_STATE, array() ); + return is_array( $state ) ? $state : array(); + } + + /** + * Get progress with computed pct/rate/ETA. + * + * @param bool $pump When true, opportunistically advance one batch if cron + * is overdue. The REST status endpoint passes true so + * admin polling makes progress without an external + * cron worker (dev / low-traffic environments). + * @return array + */ + public static function get_progress( bool $pump = true ): array { + if ( $pump ) { + self::pump_if_due(); + } + + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'status' => 'idle', + 'processed' => 0, + 'target' => 0, + 'pct' => 0, + ); + } + + $processed = (int) ( $state['processed'] ?? 0 ); + $target = (int) ( $state['target'] ?? 0 ); + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? 0 ); + + $now = $ended > 0 ? $ended : time(); + $elapsed = max( 1, $now - $started ); + $rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0; + $eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0; + $pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0; + + return array_merge( + $state, + array( + 'pct' => $pct, + 'rate_per_sec' => $rate, + 'elapsed_sec' => $elapsed, + 'eta_sec' => $eta_sec, + 'test_post_count' => self::count_test_posts(), + ) + ); + } + + /** + * Opportunistic pump. Triggered by status polling. Holds a transient lock + * so concurrent polls don't double-pump. + * + * @return void + */ + public static function pump_if_due(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + $last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 ); + if ( time() - $last_pushed_at < 1 ) { + return; + } + + $lock_key = 'wpdo_post_stress_pump_lock'; + if ( false !== get_transient( $lock_key ) ) { + return; + } + set_transient( $lock_key, 1, 30 ); + + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + + try { + self::run_batch(); + } finally { + delete_transient( $lock_key ); + } + } + + /** + * Cron entrypoint. Run a single batch then either reschedule or finalize. + * + * @return void + */ + public static function run_batch(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + return; + } + + $post_type = (string) ( $state['post_type'] ?? '' ); + $target = (int) ( $state['target'] ?? 0 ); + $processed = (int) ( $state['processed'] ?? 0 ); + $batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE ); + $mode = (string) ( $state['mode'] ?? self::MODE_FAST ); + $remaining = $target - $processed; + if ( $remaining <= 0 ) { + self::finalize( $state ); + return; + } + $this_batch_size = min( $batch_size, $remaining ); + + $batch_started = microtime( true ); + try { + if ( self::MODE_FAST === $mode ) { + $inserted = self::run_batch_fast( $post_type, $this_batch_size ); + } else { + $inserted = self::run_batch_realistic( $post_type, $this_batch_size ); + } + } catch ( \Throwable $e ) { + $state['errors'][] = array( + 'time' => time(), + 'message' => $e->getMessage(), + ); + $state['status'] = 'failed'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'post_stress_test_batch_failed', array( 'message' => $e->getMessage() ) ); + } + return; + } + $batch_elapsed = microtime( true ) - $batch_started; + + // 重讀 state, cancel() 可能在 batch 執行中改了 status. + $latest = self::get_state(); + if ( empty( $latest ) ) { + return; + } + $is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG ); + + $latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted; + $latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1; + $latest['batches_log'][] = array( + 'n' => $inserted, + 'duration_ms' => (int) round( $batch_elapsed * 1000 ), + ); + if ( count( $latest['batches_log'] ) > 200 ) { + $latest['batches_log'] = array_slice( $latest['batches_log'], -200 ); + } + $latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) ); + $latest['last_pushed_at'] = time(); + + if ( $is_cancelled ) { + update_option( self::OPT_STATE, $latest, false ); + return; + } + + update_option( self::OPT_STATE, $latest, false ); + + if ( $latest['processed'] >= $target ) { + self::finalize( $latest ); + return; + } + + wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK ); + } + + /** + * Run one fast-mode batch. Delegates to existing create() to share insert + * logic, then returns the count actually inserted. + * + * @param string $post_type Post type (already validated by start()). + * @param int $count Posts to insert this batch. + * @return int Inserted count. + */ + private static function run_batch_fast( string $post_type, int $count ): int { + $result = self::create( $post_type, $count ); + return (int) ( $result['created'] ?? 0 ); + } + + /** + * Run one realistic-mode batch. Honors BATCH_DEADLINE_SEC and the cancel + * flag — checked between each post insert so cancel takes effect within + * one wp_insert_post() call. + * + * @param string $post_type Post type (already validated by start()). + * @param int $count Posts to insert this batch. + * @return int Inserted count (may be < $count if deadline / cancel hit). + */ + private static function run_batch_realistic( string $post_type, int $count ): int { + $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC; + $inserted = 0; + + $seed_map = self::seed_map(); + if ( ! isset( $seed_map[ $post_type ] ) ) { + return 0; + } + + for ( $i = 0; $i < $count; $i++ ) { + if ( microtime( true ) > $deadline ) { + break; + } + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + break; + } + + $title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false ); + $pid = wp_insert_post( + array( + 'post_title' => $title, + 'post_type' => $post_type, + 'post_status' => 'publish', + ) + ); + if ( ! $pid || is_wp_error( $pid ) ) { + continue; + } + $pid = (int) $pid; + ++$inserted; + + foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_post_meta( $pid, $meta_key, $value ); + } + } + return $inserted; + } + + /** + * Finalize a completed run: clear cron, switch status, run benchmark, save. + * + * @param array $state Current state (passed in to avoid re-reading). + * @return void + */ + private static function finalize( array $state ): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state['status'] = 'benchmarking'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + $report = self::run_benchmark( $state ); + + $state['benchmark'] = $report; + $state['status'] = 'completed'; + update_option( self::OPT_STATE, $state, false ); + } + + /** + * Run the benchmark report on the current dataset. Safe to call externally + * via the /post-stress-test/benchmark REST endpoint to re-measure without + * generating new fixtures. + * + * @param array|null $state Optional state snapshot; defaults to get_state(). + * @return array + */ + public static function run_benchmark( ?array $state = null ): array { + $state = $state ?? self::get_state(); + $post_type = (string) ( $state['post_type'] ?? '' ); + + return array( + 'generated_at' => time(), + 'post_type' => $post_type, + 'write' => self::compute_write_metrics( $state ), + 'db_sizes' => self::measure_db_sizes( $post_type ), + 'query' => $post_type ? self::measure_query_performance( $post_type ) : array(), + ); + } + + /** + * Compute write-side throughput metrics from state. + * + * @param array $state Stress state snapshot. + * @return array + */ + private static function compute_write_metrics( array $state ): array { + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? time() ); + $processed = (int) ( $state['processed'] ?? 0 ); + $elapsed = max( 1, $ended - $started ); + $batches = $state['batches_log'] ?? array(); + + $durations = array_column( $batches, 'duration_ms' ); + $min_ms = ! empty( $durations ) ? min( $durations ) : 0; + $max_ms = ! empty( $durations ) ? max( $durations ) : 0; + $avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0; + + return array( + 'mode' => $state['mode'] ?? '', + 'post_type' => $state['post_type'] ?? '', + 'target' => (int) ( $state['target'] ?? 0 ), + 'processed' => $processed, + 'elapsed_sec' => $elapsed, + 'rate_per_sec' => round( $processed / $elapsed, 2 ), + 'batches_done' => (int) ( $state['batches_done'] ?? 0 ), + 'batch_min_ms' => $min_ms, + 'batch_max_ms' => $max_ms, + 'batch_avg_ms' => $avg_ms, + 'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ), + ); + } + + /** + * Measure DB sizes for the post-side tables relevant to this run: + * wp_posts + wp_postmeta + the flat table that matches $post_type. + * + * @param string $post_type Post type from state. + * @return array + */ + private static function measure_db_sizes( string $post_type ): array { + global $wpdb; + + $tables = array( $wpdb->posts, $wpdb->postmeta ); + $flat = self::flat_table_for_post_type( $post_type ); + if ( $flat && self::table_exists( $flat ) ) { + $tables[] = $flat; + } + + if ( ! self::is_mysql() ) { + return array_map( + static fn( $t ) => array( + 'table' => $t, + 'rows' => self::table_row_count( $t ), + ), + $tables + ); + } + + $placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", + ...$tables + ), + ARRAY_A + ); + + $out = array(); + foreach ( (array) $rows as $r ) { + $dl = (int) $r['dl']; + $il = (int) $r['il']; + $total = $dl + $il; + $out[] = array( + 'table' => $r['t'], + 'rows' => (int) $r['rows_count'], + 'data_mb' => round( $dl / 1048576, 2 ), + 'index_mb' => round( $il / 1048576, 2 ), + 'total_mb' => round( $total / 1048576, 2 ), + 'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0, + ); + } + return $out; + } + + /** + * Measure 3 representative queries: + * - point lookup on the flat table (indexed key, e.g. _stock_status / hp_status) + * - range scan on the flat table (e.g. price >= X) + * - EAV-baseline: same range query expressed against wp_postmeta + * + * The ratio of (3) ÷ (2) is the speedup the stress fixture proves. + * + * @param string $post_type Post type from state. + * @return array + */ + private static function measure_query_performance( string $post_type ): array { + global $wpdb; + + $flat = self::flat_table_for_post_type( $post_type ); + if ( ! $flat || ! self::table_exists( $flat ) ) { + return array( 'note' => 'flat_table_missing' ); + } + + $probes = self::query_probes_for_post_type( $post_type, $flat, $wpdb->postmeta ); + $out = array(); + foreach ( $probes as $key => $sql ) { + $out[ $key ] = self::time_query( $sql ); + } + return $out; + } + + /** + * Build per-post_type representative SQL probes. Three probes per type: + * point / range / eav_baseline. Returned in the same key order so the JS + * report can render them without per-type knowledge. + * + * @param string $post_type Post type slug. + * @param string $flat Flat table for the post type. + * @param string $postmeta $wpdb->postmeta. + * @return array + */ + private static function query_probes_for_post_type( string $post_type, string $flat, string $postmeta ): array { + switch ( $post_type ) { + case 'product': + return array( + 'point_stock_status' => "SELECT post_id FROM `{$flat}` WHERE _stock_status = 'instock' LIMIT 100", + 'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(_price AS DECIMAL(20,2)) > 100 ORDER BY _price LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_stock_status' AND m.meta_value = 'instock' LIMIT 100", + ); + case 'hp_listing': + return array( + 'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100", + 'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_price AS DECIMAL(20,2)) > 100 ORDER BY hp_price LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100", + ); + case 'hp_request': + return array( + 'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100", + 'range_budget_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_budget AS DECIMAL(20,2)) > 100 ORDER BY hp_budget LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100", + ); + case 'hp_vendor': + return array( + 'point_verified' => "SELECT post_id FROM `{$flat}` WHERE hp_verified = '1' LIMIT 100", + 'range_rate_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_hourly_rate AS DECIMAL(20,2)) > 50 ORDER BY hp_hourly_rate LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_verified' AND m.meta_value = '1' LIMIT 100", + ); + case 'attachment': + return array( + 'point_alt_present' => "SELECT post_id FROM `{$flat}` WHERE _wp_attachment_image_alt = 'Stress test image' LIMIT 100", + 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_wp_attachment_image_alt' AND m.meta_value = 'Stress test image' LIMIT 100", + ); + case 'nav_menu_item': + return array( + 'point_type' => "SELECT post_id FROM `{$flat}` WHERE _menu_item_type = 'custom' LIMIT 100", + 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_menu_item_type' AND m.meta_value = 'custom' LIMIT 100", + ); + case 'post': + default: + return array( + 'point_thumbnail' => "SELECT post_id FROM `{$flat}` WHERE _thumbnail_id = '0' LIMIT 100", + 'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100", + 'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_thumbnail_id' AND m.meta_value = '0' LIMIT 100", + ); + } + } + + /** + * Map post_type → its flat table. Returns empty string for unsupported types. + * + * @param string $post_type Post type slug. + * @return string Fully-qualified flat table name or '' if unknown. + */ + private static function flat_table_for_post_type( string $post_type ): string { + 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', + 'attachment' => $prefix . 'attachment', + 'nav_menu_item' => $prefix . 'nav_menu_item', + 'post' => $prefix . 'wp_core', + ); + return $map[ $post_type ] ?? ''; + } + + /** + * Time a callable. + * + * @param callable $cb Callable to invoke once. + * @param int $n Logical operation count for QPS. + * @return array + */ + private static function time_calls( callable $cb, int $n ): array { + $start = microtime( true ); + $cb(); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( + 'n' => $n, + 'total_ms' => round( $elapsed_ms, 2 ), + 'avg_ms' => $n > 0 ? round( $elapsed_ms / $n, 3 ) : 0, + 'qps' => $elapsed_ms > 0 ? round( $n / ( $elapsed_ms / 1000 ), 1 ) : 0, + ); + } + + /** + * Time a SQL query. + * + * @param string $sql Query to execute via $wpdb->get_results(). + * @return array + */ + private static function time_query( string $sql ): array { + global $wpdb; + $start = microtime( true ); + $wpdb->get_results( $sql ); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( + 'duration_ms' => round( $elapsed_ms, 2 ), + ); + } + + /** + * Cheap row-count probe (used for SQLite fallback in measure_db_sizes()). + * + * @param string $table Fully-qualified table name. + * @return int + */ + private static function table_row_count( string $table ): int { + global $wpdb; + if ( ! self::table_exists( $table ) ) { + return 0; + } + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + } + + /** + * Memoized table-exists probe. + * + * @param string $table Fully-qualified table name. + * @return bool + */ + private static function table_exists( string $table ): bool { + global $wpdb; + static $cache = array(); + if ( isset( $cache[ $table ] ) ) { + return $cache[ $table ]; + } + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $cache[ $table ] = ( $found === $table ); + return $cache[ $table ]; + } + + /** + * Detect MySQL vs SQLite (information_schema is unsupported on the latter). + * + * @return bool True when running against MySQL/MariaDB. + */ + private static function is_mysql(): bool { + return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Internals + // ───────────────────────────────────────────────────────────────────────── + + /** + * Lazy-built map of post_type → meta_key → value (or value generator). + * Mirrors the v2.9.1 entity group key definitions, but seeds only a subset + * (5–11 keys) per post_type to keep fixture overhead reasonable. + * + * @return array> + */ + private static function seed_map(): array { + if ( null !== self::$seed_map_cache ) { + return self::$seed_map_cache; + } + + self::$seed_map_cache = array( + 'product' => array( + '_price' => static fn( int $i ) => number_format( 10 + ( $i * 0.5 ), 2, '.', '' ), + '_regular_price' => static fn( int $i ) => number_format( 12 + ( $i * 0.5 ), 2, '.', '' ), + '_stock' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ), + '_stock_status' => 'instock', + '_sku' => static fn( int $i ) => 'STRESS-SKU-' . $i, + ), + 'hp_listing' => array( + 'hp_price' => static fn( int $i ) => number_format( 50 + $i, 2, '.', '' ), + 'hp_status' => 'publish', + 'hp_featured' => '0', + 'hp_verified' => '1', + 'hp_vendor' => '1', + 'hp_view_count' => static fn( int $i ) => (string) $i, + 'hp_expired_time' => static fn() => (string) ( time() + 30 * 86400 ), + ), + 'hp_request' => array( + 'hp_status' => 'publish', + 'hp_user' => '1', + 'hp_budget' => static fn( int $i ) => number_format( 100 + $i * 10, 2, '.', '' ), + 'hp_view_count' => '0', + 'hp_expired_time' => static fn() => (string) ( time() + 14 * 86400 ), + ), + 'hp_vendor' => array( + 'hp_user' => static fn( int $i ) => (string) ( $i + 1 ), + 'hp_verified' => '1', + 'hp_hourly_rate' => static fn( int $i ) => number_format( 50 + $i * 5, 2, '.', '' ), + 'hp_rating_count' => static fn( int $i ) => (string) $i, + 'hp_rating' => '4.5', + ), + 'attachment' => array( + '_wp_attached_file' => static fn( int $i ) => "stress/test-{$i}.jpg", + '_wp_attachment_image_alt' => 'Stress test image', + ), + 'nav_menu_item' => array( + '_menu_item_type' => 'custom', + '_menu_item_object_id' => '0', + '_menu_item_object' => 'custom', + '_menu_item_target' => '', + '_menu_item_url' => static fn( int $i ) => "https://example.com/stress-{$i}", + ), + 'post' => array( + '_thumbnail_id' => '0', + '_edit_last' => '1', + ), + ); + + return self::$seed_map_cache; + } + + /** + * Names of all wp_wpdo_post_* flat tables that cleanup should sweep. + * + * @return string[] + */ + private static function get_post_flat_tables(): array { + global $wpdb; + $prefix = $wpdb->prefix . 'wpdo_post_'; + return array( + $prefix . 'wp_core', + $prefix . 'attachment', + $prefix . 'wc_product', + $prefix . 'hp_listing_core', + $prefix . 'hp_request_core', + $prefix . 'hp_vendor_core', + $prefix . 'nav_menu_item', + ); + } +} diff --git a/includes/class-tmdo-postmeta-cleaner.php b/includes/class-tmdo-postmeta-cleaner.php new file mode 100644 index 0000000..e7e9a16 --- /dev/null +++ b/includes/class-tmdo-postmeta-cleaner.php @@ -0,0 +1,229 @@ + 24h old + * (orphaned from interrupted edit sessions) + * + * Runs before any Entity Bridge migration so subsequent ratio measurements + * reflect real data, not garbage. Pure DB layer — no Hook Bus / Entity Bridge + * coupling so cleanup is safe even when post entity bridge is disabled. + * + * @package WP_Data_Optimizer + * @since 2.9.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Wp_postmeta garbage cleanup (v2.9.0 Phase 0). + */ +class TMDO_Postmeta_Cleaner { + + public const TARGET_TRANSIENTS = 'transients'; + public const TARGET_WP_OLD_DATE = 'wp_old_date'; + public const TARGET_EDIT_LOCKS = 'edit_locks'; + public const TARGET_ALL = 'all'; + + public const VALID_TARGETS = array( + self::TARGET_TRANSIENTS, + self::TARGET_WP_OLD_DATE, + self::TARGET_EDIT_LOCKS, + self::TARGET_ALL, + ); + + /** + * Seconds after which an _edit_lock is considered stale. + * WP refreshes locks on a 15-second heartbeat; 24h is intentionally + * conservative to avoid disturbing any active edit session. + */ + private const EDIT_LOCK_STALE_THRESHOLD = 86400; + + /** + * Count rows that would be cleaned for the given target. + * + * @param string $target One of TARGET_* constants. + * @return array{transients:int, wp_old_date:int, edit_locks:int, total:int} + * @throws InvalidArgumentException When $target is not a valid target. + */ + public static function count_garbage( string $target = self::TARGET_ALL ): array { + self::assert_valid_target( $target ); + + $counts = array( + 'transients' => 0, + 'wp_old_date' => 0, + 'edit_locks' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $counts['transients'] = self::count_transients(); + } + if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) { + $counts['wp_old_date'] = self::count_wp_old_date(); + } + if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) { + $counts['edit_locks'] = self::count_stale_edit_locks(); + } + + $counts['total'] = $counts['transients'] + $counts['wp_old_date'] + $counts['edit_locks']; + return $counts; + } + + /** + * Delete garbage rows for the given target. + * + * @param string $target One of TARGET_* constants. + * @return array{transients:int, wp_old_date:int, edit_locks:int, total:int} + * @throws InvalidArgumentException When $target is not a valid target. + */ + public static function delete_garbage( string $target = self::TARGET_ALL ): array { + self::assert_valid_target( $target ); + + $deleted = array( + 'transients' => 0, + 'wp_old_date' => 0, + 'edit_locks' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $deleted['transients'] = self::delete_transients(); + } + if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) { + $deleted['wp_old_date'] = self::delete_wp_old_date(); + } + if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) { + $deleted['edit_locks'] = self::delete_stale_edit_locks(); + } + + $deleted['total'] = $deleted['transients'] + $deleted['wp_old_date'] + $deleted['edit_locks']; + return $deleted; + } + + /** + * Whether $target selects $bucket (i.e. target=all or target=bucket). + * + * @param string $target Selected target. + * @param string $bucket Bucket constant (TARGET_TRANSIENTS / WP_OLD_DATE / EDIT_LOCKS). + * @return bool + */ + private static function target_includes( string $target, string $bucket ): bool { + return self::TARGET_ALL === $target || $bucket === $target; + } + + /** + * Validate target parameter. + * + * @param string $target Target to validate. + * @return void + * @throws InvalidArgumentException When $target is not in VALID_TARGETS. + */ + private static function assert_valid_target( string $target ): void { + if ( in_array( $target, self::VALID_TARGETS, true ) ) { + return; + } + // Exception messages bubble up to PHP's error handler / WP_CLI; not user output. + $msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) ); + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->postmeta is WP-managed and never user input. PreparedSQL.InterpolatedNotPrepared fires on multi-line $wpdb->prepare() literals where the only interpolation is the trusted table name; user-controlled values use placeholders. + + /** + * Count rows matching transient meta_key patterns in wp_postmeta. + * + * @return int + */ + private static function count_transients(): int { + global $wpdb; + $table = $wpdb->postmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + /** + * Delete rows matching transient meta_key patterns from wp_postmeta. + * + * @return int Affected row count. + */ + private static function delete_transients(): int { + global $wpdb; + $table = $wpdb->postmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + /** + * Count _wp_old_date rows in wp_postmeta. + * + * @return int + */ + private static function count_wp_old_date(): int { + global $wpdb; + $table = $wpdb->postmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_wp_old_date'" + ); + } + + /** + * Delete _wp_old_date rows from wp_postmeta. + * + * @return int Affected row count. + */ + private static function delete_wp_old_date(): int { + global $wpdb; + $table = $wpdb->postmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key = '_wp_old_date'" + ); + } + + /** + * Count stale _edit_lock rows (lock_ts older than 24h) in wp_postmeta. + * + * @return int + */ + private static function count_stale_edit_locks(): int { + global $wpdb; + $table = $wpdb->postmeta; + $cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD; + // _edit_lock format is ":"; SUBSTRING_INDEX extracts the timestamp. + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d", + $cutoff + ) + ); + } + + /** + * Delete stale _edit_lock rows (lock_ts older than 24h) from wp_postmeta. + * + * @return int Affected row count. + */ + private static function delete_stale_edit_locks(): int { + global $wpdb; + $table = $wpdb->postmeta; + $cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD; + return (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d", + $cutoff + ) + ); + } + + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared +} diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php new file mode 100644 index 0000000..e532af2 --- /dev/null +++ b/includes/class-tmdo-rest-api.php @@ -0,0 +1,1608 @@ + WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_listings' ), + 'permission_callback' => '__return_true', + 'args' => $this->listings_args(), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/listings/(?P\d+)', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_listing' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/stats/(?P\d+)', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_stats' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/listings/(?P\d+)/view', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_view' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'id' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + // ── Entity Bridge 端點 (v2.6.6) ─────────────────────────────────────── + + register_rest_route( + self::NAMESPACE, + '/entity-bridge/health', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'entity_bridge_health_all' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/entity-bridge/health/(?P[a-z]+)', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'entity_bridge_health_one' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'type' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/entity-bridge/backfill', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'entity_bridge_start_backfill' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'entity_type' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + 'group_name' => array( + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/entity-bridge/promote', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'entity_bridge_promote' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'entity_type' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/entity-bridge/demote', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'entity_bridge_demote' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'entity_type' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + ), + ) + ); + + // v2.6.7: User stress-test endpoints. + register_rest_route( + self::NAMESPACE, + '/stress-test/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'stress_test_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/stress-test/start', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'stress_test_start' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'target' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000000, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + 'mode' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => false, + ), + 'batch_size' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 2000, + 'sanitize_callback' => 'absint', + 'required' => false, + ), + ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/stress-test/cancel', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'stress_test_cancel' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/stress-test/cleanup', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'stress_test_cleanup' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/stress-test/benchmark', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'stress_test_run_benchmark' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + // v2.11.4: Post stress-test endpoints (mirror of user-side, post_type-scoped). + register_rest_route( + self::NAMESPACE, + '/post-stress-test/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'post_stress_test_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/post-stress-test/start', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_stress_test_start' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'post_type' => array( + 'validate_callback' => fn( $v ) => in_array( + $v, + array( 'product', 'hp_listing', 'hp_request', 'hp_vendor', 'attachment', 'nav_menu_item', 'post' ), + true + ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + 'target' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + 'mode' => array( + 'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => false, + ), + 'batch_size' => array( + 'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, + 'sanitize_callback' => 'absint', + 'required' => false, + ), + ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/post-stress-test/cancel', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_stress_test_cancel' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/post-stress-test/cleanup', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'post_stress_test_cleanup' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/post-stress-test/benchmark', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'post_stress_test_run_benchmark' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + // v2.13.0: Term stress-test endpoints (mirror post side, taxonomy-scoped). + register_rest_route( + self::NAMESPACE, + '/term-stress-test/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'term_stress_test_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/term-stress-test/start', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'term_stress_test_start' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'taxonomy' => array( + 'validate_callback' => static fn( $v ) => is_string( $v ) && '' !== $v && taxonomy_exists( $v ), + 'sanitize_callback' => 'sanitize_key', + 'required' => true, + ), + 'target' => array( + 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + 'mode' => array( + 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => false, + ), + 'batch_size' => array( + 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, + 'sanitize_callback' => 'absint', + 'required' => false, + ), + ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/term-stress-test/cancel', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'term_stress_test_cancel' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/term-stress-test/cleanup', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'term_stress_test_cleanup' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/term-stress-test/benchmark', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'term_stress_test_run_benchmark' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + // v2.13.1: Comment stress-test endpoints (mirror term side, post-scoped). + register_rest_route( + self::NAMESPACE, + '/comment-stress-test/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'comment_stress_test_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/comment-stress-test/start', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'comment_stress_test_start' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'post_id' => array( + 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + 'target' => array( + 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000, + 'sanitize_callback' => 'absint', + 'required' => true, + ), + 'mode' => array( + 'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ), + 'sanitize_callback' => 'sanitize_key', + 'required' => false, + ), + 'batch_size' => array( + 'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000, + 'sanitize_callback' => 'absint', + 'required' => false, + ), + ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/comment-stress-test/cancel', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'comment_stress_test_cancel' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/comment-stress-test/cleanup', + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'comment_stress_test_cleanup' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/comment-stress-test/benchmark', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'comment_stress_test_run_benchmark' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + + // ── Migration Wizard endpoints (v2.8.0) ─────────────────────────── + register_rest_route( + self::NAMESPACE, + '/migration/preflight', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'migration_preflight' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/migration/start', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'migration_start' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + 'args' => array( + 'verify_strict' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), + 'verify_24h' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), + 'auto_backup' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), + 'force_async' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), + 'dry_run' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ), + ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/migration/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'migration_status' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/migration/cancel', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'migration_cancel' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + register_rest_route( + self::NAMESPACE, + '/migration/resume', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'migration_resume' ), + 'permission_callback' => array( $this, 'require_manage_options' ), + ) + ); + } + + // ── Handlers ───────────────────────────────────────────────────────────── + + /** + * GET /wpdo/v1/listings + * + * Query Zone A flat table (if cutover) or fall back to WP_Query. + * Supports pagination and per-column numeric filters. + * + * @param WP_REST_Request $request REST request object. + * @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'; + // 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 ); + $per_page = max( 1, min( $max_per_page, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) ); + $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); + $orderby = $request->get_param( 'orderby' ) ?? 'post_id'; + $order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'DESC' ) ); + $offset = ( $page - 1 ) * $per_page; + + $module = 'hot_' . sanitize_key( $post_type ); + + if ( TMDO_Feature_Flags::is_read_custom( $module ) ) { + return $this->listings_from_zone_a( $post_type, $per_page, $offset, $orderby, $order, $request ); + } + + return $this->listings_from_wp_query( $post_type, $per_page, $page, $request ); + } + + /** + * GET /wpdo/v1/listings/{id} + * + * Returns Zone A fields + Zone C blob merged (or postmeta fallback). + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response REST response. + */ + public function get_listing( WP_REST_Request $request ): WP_REST_Response { + $post_id = (int) $request->get_param( 'id' ); + + // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1). + $err = $this->assert_post_viewable_or_error( $post_id ); + if ( null !== $err ) { + return $err; + } + + $post_type = get_post_type( $post_id ); + + $data = array( + 'id' => $post_id, + 'post_type' => $post_type, + ); + + // Zone A. + $hot_module = 'hot_' . sanitize_key( $post_type ); + if ( TMDO_Feature_Flags::is_read_custom( $hot_module ) ) { + $hot = TMDO_Zone_Hot::get_row( $post_id, $post_type ); + if ( $hot ) { + unset( $hot['post_id'], $hot['updated_at'] ); + $data = array_merge( $data, $hot ); + } + } else { + foreach ( array_keys( TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ) ) as $col ) { + $data[ $col ] = get_post_meta( $post_id, $col, true ); + } + } + + // Zone C. + $cold_module = 'cold_' . sanitize_key( $post_type ); + if ( TMDO_Feature_Flags::is_read_custom( $cold_module ) ) { + $cold = TMDO_Zone_Cold::get_blob( $post_id, $post_type ); + $data = array_merge( $data, $cold ); + } else { + foreach ( TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ) as $key ) { + $data[ $key ] = get_post_meta( $post_id, $key, true ); + } + } + + return new WP_REST_Response( $data, 200 ); + } + + /** + * GET /wpdo/v1/stats/{id} + * + * Returns Zone B view count (with postmeta fallback). + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response REST response. + */ + public function get_stats( WP_REST_Request $request ): WP_REST_Response { + $post_id = (int) $request->get_param( 'id' ); + + // v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1). + $err = $this->assert_post_viewable_or_error( $post_id ); + if ( null !== $err ) { + return $err; + } + + $views = TMDO_Listing_Stats::get_view_count( $post_id ); + + return new WP_REST_Response( + array( + 'post_id' => $post_id, + 'view_count' => (int) $views, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/listings/{id}/view + * + * Increment Zone B view count for a post. + * Requires a valid WP REST nonce (X-WP-Nonce header or _wpnonce query param). + * Rate-limited to one increment per IP per post per hour (transient). + * Returns the new view count. + * + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response REST response. + */ + public function post_view( WP_REST_Request $request ): WP_REST_Response { + // Verify nonce — prevents CSRF from third-party sites. + $nonce = $request->get_header( 'X-WP-Nonce' ) + ?? $request->get_param( '_wpnonce' ) + ?? ''; + + if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { + return new WP_REST_Response( + array( + 'code' => 'rest_forbidden', + 'message' => 'Invalid or missing nonce.', + ), + 403 + ); + } + + $post_id = (int) $request->get_param( 'id' ); + + // v2.13.3: post_status / read_post capability guard (fixes M-LOGIC-1). + // Prevents accumulating view counts on draft / private / trash posts. + $err = $this->assert_post_viewable_or_error( $post_id ); + if ( null !== $err ) { + return $err; + } + + // Cookie-based dedup — browser clients won't re-count on page reload. + $cookie_key = 'wpdo_view_' . $post_id; + $cookie_blocked = ! empty( $_COOKIE[ $cookie_key ] ); + + // IP-based rate limit — one increment per IP per post per hour. + // v2.13.3: behind a reverse proxy REMOTE_ADDR collapses to the proxy IP + // and the rate limit degenerates. Site owners can hook `wpdo_view_client_ip` + // to resolve X-Forwarded-For (or another trusted header) per their topology. + // Default keeps REMOTE_ADDR (no implicit trust of forwarded headers). + $ip_default = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; + $ip = (string) apply_filters( 'wpdo_view_client_ip', $ip_default, $request ); + $rl_key = 'wpdo_view_' . $post_id . '_' . substr( md5( $ip ), 0, 12 ); // Max 43 chars. + $ip_blocked = (bool) get_transient( $rl_key ); + + if ( $cookie_blocked || $ip_blocked ) { + // Track rate-limit hits per post for admin visibility, capped at 100 entries. + $rl_stats = get_option( 'wpdo_rl_stats', array() ); + $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; + 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( + 'code' => 'too_many_requests', + 'message' => 'View already counted for this session.', + ), + 429 + ); + } + + // Mark as counted for this IP/session. + set_transient( $rl_key, 1, HOUR_IN_SECONDS ); + + TMDO_Listing_Stats::increment_view( $post_id ); + $views = TMDO_Listing_Stats::get_view_count( $post_id ); + + $response = new WP_REST_Response( + array( + 'post_id' => $post_id, + 'view_count' => (int) $views, + ), + 200 + ); + // Tell browsers not to re-count on page reload. + $response->header( 'Set-Cookie', "{$cookie_key}=1; Max-Age=3600; Path=/; SameSite=Strict" ); + return $response; + } + + /** + * GET /wpdo/v1/status (requires manage_options) + * + * Returns zone module states and registered field counts. + * + * @param WP_REST_Request $request REST request object. Not used directly. + * @return WP_REST_Response REST response. + */ + public function get_status( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP REST API callback signature. + $registry = TMDO_Schema_Registry::instance(); + $modules = TMDO_Feature_Flags::all(); + $stats = $registry->get_stats(); + + return new WP_REST_Response( + array( + 'version' => TMDO_VERSION, + 'engine' => TMDO_IS_SQLITE ? 'sqlite' : 'mysql', + 'fields' => $stats, + 'modules' => $modules, + 'rate_limit_stats' => get_option( 'wpdo_rl_stats', array() ), + ), + 200 + ); + } + + // ── Permission ──────────────────────────────────────────────────────────── + + /** + * Permission callback that requires plugin admin capability. + * + * Delegates to TMDO_Capability::current_user_can_admin() which additionally + * allows super admins on Multisite (v2.14.0). Method name retained for + * back-compat with the existing 30 REST endpoint registrations. + * + * @return bool True if the current user can manage WP Data Optimizer. + */ + public function require_manage_options(): bool { + return TMDO_Capability::current_user_can_admin(); + } + + /** + * Visibility guard for the four public REST endpoints (v2.13.3 — fixes + * M-AUTH-1 / M-LOGIC-1). + * + * The 4 `__return_true` endpoints (get_listings / get_listing / get_stats / + * post_view) historically only checked post existence via `get_post_type()`. + * That allowed anonymous reads of draft / private / trash post fields + * (Zone A flat columns + Zone C JSON blob), and accumulated view counts + * against unpublished posts. + * + * Rule: a post is viewable to the current request if either + * (a) it is publicly viewable (`is_post_publicly_viewable` — typically + * publish status + non-private + password-protected handled by WP), or + * (b) the current user has `read_post` capability on it. + * + * @param int $post_id Post ID to check. + * @return WP_REST_Response|null Null when allowed; 404 / 403 response otherwise. + */ + private function assert_post_viewable_or_error( int $post_id ): ?WP_REST_Response { + if ( $post_id < 1 || ! get_post_type( $post_id ) ) { + return new WP_REST_Response( + array( + 'code' => 'not_found', + 'message' => 'Post not found.', + ), + 404 + ); + } + + $publicly_viewable = function_exists( 'is_post_publicly_viewable' ) + ? is_post_publicly_viewable( $post_id ) + : ( 'publish' === get_post_status( $post_id ) ); + + if ( $publicly_viewable ) { + return null; + } + + if ( current_user_can( 'read_post', $post_id ) ) { + return null; + } + + return new WP_REST_Response( + array( + 'code' => 'rest_forbidden', + 'message' => 'Post not viewable.', + ), + 403 + ); + } + + // ── Entity Bridge Handlers (v2.6.6) ────────────────────────────────────── + + /** + * GET /wpdo/v1/entity-bridge/health + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function entity_bridge_health_all( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + if ( ! class_exists( 'TMDO_Entity_Health' ) ) { + return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Entity_Health::get_all(), 200 ); + } + + /** + * GET /wpdo/v1/entity-bridge/health/{type} + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function entity_bridge_health_one( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Entity_Health' ) ) { + return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 ); + } + $type = (string) $request->get_param( 'type' ); + return new WP_REST_Response( TMDO_Entity_Health::get_one( $type ), 200 ); + } + + /** + * POST /wpdo/v1/entity-bridge/backfill + * + * Schedules async cron-driven backfill for one (entity_type, group_name) pair. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function entity_bridge_start_backfill( WP_REST_Request $request ): WP_REST_Response { + $entity_type = (string) $request->get_param( 'entity_type' ); + $group_name = (string) $request->get_param( 'group_name' ); + + if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! TMDO_Entity_Registry::get_adapter( $entity_type ) ) { + return new WP_REST_Response( array( 'error' => 'Unknown entity type' ), 400 ); + } + + $groups = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_groups_for_type( $entity_type ) : array(); + if ( ! in_array( $group_name, $groups, true ) ) { + return new WP_REST_Response( array( 'error' => 'Unknown group for this entity type' ), 400 ); + } + + $hook = 'wpdo_entity_backfill_batch'; + $args = array( $entity_type, $group_name ); + + // Clear existing scheduled event for this pair before scheduling fresh. + $existing = wp_next_scheduled( $hook, $args ); + if ( $existing ) { + wp_unschedule_event( $existing, $hook, $args ); + } + + // Also reset the checkpoint so backfill starts from the beginning. + if ( class_exists( 'TMDO_Entity_Migration_Engine' ) ) { + TMDO_Entity_Migration_Engine::reset_checkpoint( $entity_type, $group_name ); + } + + wp_schedule_single_event( time(), $hook, $args ); + + TMDO_Logger::info( + 'entity_backfill_scheduled', + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + 'user_id' => get_current_user_id(), + ) + ); + + return new WP_REST_Response( + array( + 'ok' => true, + 'message' => "Backfill scheduled for {$entity_type}/{$group_name}", + 'entity_type' => $entity_type, + 'group_name' => $group_name, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/entity-bridge/promote + * + * Advance entity mode by one step (disabled→dual_write→shadow_read→aeav_only). + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function entity_bridge_promote( WP_REST_Request $request ): WP_REST_Response { + $entity_type = (string) $request->get_param( 'entity_type' ); + + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 ); + } + + $current = TMDO_Mode_Manager::get( $entity_type ); + $order = TMDO_Mode_Manager::ALL_MODES; + $idx = array_search( $current, $order, true ); + + if ( false === $idx || $idx >= count( $order ) - 1 ) { + return new WP_REST_Response( array( 'error' => "Already at maximum mode: {$current}" ), 400 ); + } + + $next = $order[ $idx + 1 ]; + $result = TMDO_Mode_Manager::set( $entity_type, $next ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 ); + } + + return new WP_REST_Response( + array( + 'ok' => true, + 'entity_type' => $entity_type, + 'from' => $current, + 'to' => $next, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/entity-bridge/demote + * + * Roll back entity mode by one step. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function entity_bridge_demote( WP_REST_Request $request ): WP_REST_Response { + $entity_type = (string) $request->get_param( 'entity_type' ); + + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 ); + } + + $current = TMDO_Mode_Manager::get( $entity_type ); + $order = TMDO_Mode_Manager::ALL_MODES; + $idx = array_search( $current, $order, true ); + + if ( false === $idx || $idx <= 0 ) { + return new WP_REST_Response( array( 'error' => "Already at minimum mode: {$current}" ), 400 ); + } + + $prev = $order[ $idx - 1 ]; + $result = TMDO_Mode_Manager::set( $entity_type, $prev ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 ); + } + + return new WP_REST_Response( + array( + 'ok' => true, + 'entity_type' => $entity_type, + 'from' => $current, + 'to' => $prev, + ), + 200 + ); + } + + // ── v2.6.7: User Stress Test Handlers ──────────────────────────────────── + + /** + * GET /wpdo/v1/stress-test/status + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function stress_test_status( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_User_Stress_Tester::get_progress(), 200 ); + } + + /** + * POST /wpdo/v1/stress-test/start + * + * @param WP_REST_Request $request REST request with target / mode / batch_size. + * @return WP_REST_Response REST response. + */ + public function stress_test_start( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); + } + $target = (int) $request->get_param( 'target' ); + $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); + $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_User_Stress_Tester::DEFAULT_BATCH_SIZE ); + + $result = TMDO_User_Stress_Tester::start( $target, $mode, $batch_size ); + $status = ! empty( $result['ok'] ) ? 200 : 409; + return new WP_REST_Response( $result, $status ); + } + + /** + * POST /wpdo/v1/stress-test/cancel + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_User_Stress_Tester::cancel(), 200 ); + } + + /** + * DELETE /wpdo/v1/stress-test/cleanup + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_User_Stress_Tester::cleanup(), 200 ); + } + + /** + * POST /wpdo/v1/stress-test/benchmark — re-run benchmark on current dataset. + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 ); + } + $report = TMDO_User_Stress_Tester::run_benchmark(); + return new WP_REST_Response( + array( + 'ok' => true, + 'benchmark' => $report, + ), + 200 + ); + } + + // ── v2.11.4: Post Stress Test Handlers ──────────────────────────────────── + + /** + * GET /wpdo/v1/post-stress-test/status + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function post_stress_test_status( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Post_Stress_Tester::get_progress(), 200 ); + } + + /** + * POST /wpdo/v1/post-stress-test/start + * + * @param WP_REST_Request $request REST request with post_type / target / mode / batch_size. + * @return WP_REST_Response REST response. + */ + public function post_stress_test_start( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); + } + $post_type = (string) $request->get_param( 'post_type' ); + $target = (int) $request->get_param( 'target' ); + $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); + $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Post_Stress_Tester::DEFAULT_BATCH_SIZE ); + + $result = TMDO_Post_Stress_Tester::start( $post_type, $target, $mode, $batch_size ); + $status = ! empty( $result['ok'] ) ? 200 : 409; + return new WP_REST_Response( $result, $status ); + } + + /** + * POST /wpdo/v1/post-stress-test/cancel + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function post_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Post_Stress_Tester::cancel(), 200 ); + } + + /** + * DELETE /wpdo/v1/post-stress-test/cleanup + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function post_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); + } + $result = TMDO_Post_Stress_Tester::cleanup(); + // Also wipe the persisted run state so the UI re-renders idle after cleanup. + delete_option( TMDO_Post_Stress_Tester::OPT_STATE ); + delete_transient( TMDO_Post_Stress_Tester::CANCEL_FLAG ); + + return new WP_REST_Response( + array( + 'ok' => true, + 'deleted' => (int) ( $result['deleted_posts'] ?? 0 ), + 'detail' => $result, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/post-stress-test/benchmark — re-run benchmark on current state. + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function post_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 ); + } + $report = TMDO_Post_Stress_Tester::run_benchmark(); + return new WP_REST_Response( + array( + 'ok' => true, + 'benchmark' => $report, + ), + 200 + ); + } + + // ── v2.13.0: Term Stress Test Handlers ──────────────────────────────────── + + /** + * GET /wpdo/v1/term-stress-test/status + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function term_stress_test_status( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Term_Stress_Tester::get_progress(), 200 ); + } + + /** + * POST /wpdo/v1/term-stress-test/start + * + * @param WP_REST_Request $request REST request with taxonomy / target / mode / batch_size. + * @return WP_REST_Response REST response. + */ + public function term_stress_test_start( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); + } + $taxonomy = (string) $request->get_param( 'taxonomy' ); + $target = (int) $request->get_param( 'target' ); + $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); + $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Term_Stress_Tester::DEFAULT_BATCH_SIZE ); + + $result = TMDO_Term_Stress_Tester::start( $taxonomy, $target, $mode, $batch_size ); + $status = ! empty( $result['ok'] ) ? 200 : 409; + return new WP_REST_Response( $result, $status ); + } + + /** + * POST /wpdo/v1/term-stress-test/cancel + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function term_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Term_Stress_Tester::cancel(), 200 ); + } + + /** + * DELETE /wpdo/v1/term-stress-test/cleanup + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function term_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); + } + $result = TMDO_Term_Stress_Tester::cleanup(); + delete_option( TMDO_Term_Stress_Tester::OPT_STATE ); + delete_transient( TMDO_Term_Stress_Tester::CANCEL_FLAG ); + + return new WP_REST_Response( + array( + 'ok' => true, + 'deleted' => (int) ( $result['deleted_terms'] ?? 0 ), + 'detail' => $result, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/term-stress-test/benchmark + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function term_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 ); + } + $report = TMDO_Term_Stress_Tester::run_benchmark(); + return new WP_REST_Response( + array( + 'ok' => true, + 'benchmark' => $report, + ), + 200 + ); + } + + // ── v2.13.1: Comment Stress Test Handlers ───────────────────────────────── + + /** + * GET /wpdo/v1/comment-stress-test/status + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function comment_stress_test_status( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Comment_Stress_Tester::get_progress(), 200 ); + } + + /** + * POST /wpdo/v1/comment-stress-test/start + * + * @param WP_REST_Request $request REST request with post_id / target / mode / batch_size. + * @return WP_REST_Response REST response. + */ + public function comment_stress_test_start( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); + } + $post_id = (int) $request->get_param( 'post_id' ); + $target = (int) $request->get_param( 'target' ); + $mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' ); + $batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE ); + + $result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch_size ); + $status = ! empty( $result['ok'] ) ? 200 : 409; + return new WP_REST_Response( $result, $status ); + } + + /** + * POST /wpdo/v1/comment-stress-test/cancel + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function comment_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); + } + return new WP_REST_Response( TMDO_Comment_Stress_Tester::cancel(), 200 ); + } + + /** + * DELETE /wpdo/v1/comment-stress-test/cleanup + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function comment_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); + } + $result = TMDO_Comment_Stress_Tester::cleanup(); + delete_option( TMDO_Comment_Stress_Tester::OPT_STATE ); + delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG ); + + return new WP_REST_Response( + array( + 'ok' => true, + 'deleted' => (int) ( $result['deleted_comments'] ?? 0 ), + 'detail' => $result, + ), + 200 + ); + } + + /** + * POST /wpdo/v1/comment-stress-test/benchmark + * + * @param WP_REST_Request $request REST request (unused). + * @return WP_REST_Response REST response. + */ + public function comment_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response { + if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) { + return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 ); + } + $report = TMDO_Comment_Stress_Tester::run_benchmark(); + return new WP_REST_Response( + array( + 'ok' => true, + 'benchmark' => $report, + ), + 200 + ); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Query Zone A flat table directly. + * + * @param string $post_type Post type slug. + * @param int $per_page Number of items per page. + * @param int $offset Database offset. + * @param string $orderby Column to order by. + * @param string $order Order direction (ASC or DESC). + * @param WP_REST_Request $request REST request object. + * @return WP_REST_Response REST response. + */ + private function listings_from_zone_a( + string $post_type, + int $per_page, + int $offset, + string $orderby, + string $order, + WP_REST_Request $request + ): WP_REST_Response { + global $wpdb; + + $table = TMDO_Zone_Hot::table( $post_type ); + $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); + + // Build WHERE clauses from numeric filter params. + $where = array(); + $params = array(); + + foreach ( $this->get_filter_params( $request, $columns ) as $filter ) { + $where[] = $filter['sql']; + $params[] = $filter['value']; + } + + $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; + + // Validate orderby against known columns + post_id. + $allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) ); + $orderby_col = in_array( $orderby, $allowed_orderby, true ) ? sanitize_key( $orderby ) : 'post_id'; + $order_dir = 'ASC' === $order ? 'ASC' : 'DESC'; + + // 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}`" ); + } + + // Fetch rows. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d", + ...array_merge( $params, array( $per_page, $offset ) ) + ), + ARRAY_A + ) ?: array(); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + // Rename post_id → id, drop internal columns. + $items = array_map( + function ( array $row ) use ( $post_type ): array { + $item = array( + 'id' => (int) $row['post_id'], + 'post_type' => $post_type, + ); + unset( $row['post_id'], $row['updated_at'] ); + return array_merge( $item, $row ); + }, + $rows + ); + + $response = new WP_REST_Response( $items, 200 ); + $response->header( 'X-WP-Total', (string) $total ); + $response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) ); + + return $response; + } + + /** + * Fall back to WP_Query when zone is not cutover. + * + * @param string $post_type Post type slug. + * @param int $per_page Number of items per page. + * @param int $page Page number. + * @param WP_REST_Request $request REST request object. Not used directly. + * @return WP_REST_Response REST response. + */ + private function listings_from_wp_query( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $request kept for potential future filtering. + string $post_type, + int $per_page, + int $page, + WP_REST_Request $request + ): WP_REST_Response { + $args = array( + 'post_type' => $post_type, + 'posts_per_page' => $per_page, + 'paged' => $page, + 'post_status' => 'publish', + ); + + $query = new WP_Query( $args ); + $items = array(); + + foreach ( $query->posts as $post ) { + $item = array( + 'id' => $post->ID, + 'post_type' => $post->post_type, + ); + $cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); + foreach ( array_keys( $cols ) as $col ) { + $item[ $col ] = get_post_meta( $post->ID, $col, true ); + } + $items[] = $item; + } + + $response = new WP_REST_Response( $items, 200 ); + $response->header( 'X-WP-Total', (string) $query->found_posts ); + $response->header( 'X-WP-TotalPages', (string) $query->max_num_pages ); + + return $response; + } + + /** + * Extract numeric filter params (e.g. hp_price_min => hp_price >= ?) + * from the request, validated against registered hot columns. + * + * @param WP_REST_Request $request REST request object. + * @param array $columns Registered hot columns from Schema Registry. + * @return array[] Each element: ['sql' => string, 'value' => mixed]. + */ + private function get_filter_params( WP_REST_Request $request, array $columns ): array { + $filters = array(); + + foreach ( array_keys( $columns ) as $col ) { + $col = sanitize_key( $col ); + + $min = $request->get_param( $col . '_min' ); + if ( null !== $min && is_numeric( $min ) ) { + $filters[] = array( + 'sql' => "`{$col}` >= %f", + 'value' => (float) $min, + ); + } + + $max = $request->get_param( $col . '_max' ); + if ( null !== $max && is_numeric( $max ) ) { + $filters[] = array( + 'sql' => "`{$col}` <= %f", + 'value' => (float) $max, + ); + } + + $exact = $request->get_param( $col ); + if ( null !== $exact && '' !== $exact ) { + if ( is_numeric( $exact ) ) { + $filters[] = array( + 'sql' => "`{$col}` = %f", + 'value' => (float) $exact, + ); + } else { + $filters[] = array( + 'sql' => "`{$col}` = %s", + 'value' => (string) $exact, + ); + } + } + } + + return $filters; + } + + /** + * Argument definitions for GET /listings. + */ + private function listings_args(): array { + return array( + 'post_type' => array( + 'default' => 'hp_listing', + 'sanitize_callback' => 'sanitize_key', + ), + 'per_page' => array( + 'default' => 20, + 'sanitize_callback' => 'absint', + 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1 && (int) $v <= 100, + ), + 'page' => array( + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1, + ), + 'orderby' => array( + 'default' => 'post_id', + 'sanitize_callback' => 'sanitize_key', + ), + 'order' => array( + 'default' => 'DESC', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => fn( $v ) => in_array( strtoupper( $v ), array( 'ASC', 'DESC' ), true ), + ), + ); + } + + // ── Migration Wizard handlers (v2.8.0) ──────────────────────────────────── + + /** + * GET /wpdo/v1/migration/preflight + * + * Read-only diagnostic — returns current ratio, mode, group residue, and + * the strategy `start()` would pick. + * + * @param WP_REST_Request $request Unused (read-only endpoint takes no params). + * @return WP_REST_Response + */ + public function migration_preflight( WP_REST_Request $request ): WP_REST_Response { + unset( $request ); + return new WP_REST_Response( TMDO_Migration_Orchestrator::preflight(), 200 ); + } + + /** + * POST /wpdo/v1/migration/start + * + * Body params: verify_strict (bool, default true), verify_24h (bool), + * auto_backup (bool, default true), force_async (bool), dry_run (bool). + * + * @param WP_REST_Request $request Body parameters. + * @return WP_REST_Response + */ + public function migration_start( WP_REST_Request $request ): WP_REST_Response { + $options = array( + 'verify_strict' => null === $request->get_param( 'verify_strict' ) ? true : (bool) $request->get_param( 'verify_strict' ), + 'verify_24h' => (bool) $request->get_param( 'verify_24h' ), + 'auto_backup' => null === $request->get_param( 'auto_backup' ) ? true : (bool) $request->get_param( 'auto_backup' ), + 'force_async' => (bool) $request->get_param( 'force_async' ), + 'dry_run' => (bool) $request->get_param( 'dry_run' ), + ); + $result = TMDO_Migration_Orchestrator::start( $options ); + $status = ! empty( $result['ok'] ) ? 200 : ( 'nothing_to_do' === ( $result['reason'] ?? '' ) ? 200 : 409 ); + return new WP_REST_Response( $result, $status ); + } + + /** + * GET /wpdo/v1/migration/status — polling target. + * + * @param WP_REST_Request $request Unused. + * @return WP_REST_Response + */ + public function migration_status( WP_REST_Request $request ): WP_REST_Response { + unset( $request ); + return new WP_REST_Response( TMDO_Migration_Orchestrator::get_status(), 200 ); + } + + /** + * POST /wpdo/v1/migration/cancel + * + * @param WP_REST_Request $request Unused. + * @return WP_REST_Response + */ + public function migration_cancel( WP_REST_Request $request ): WP_REST_Response { + unset( $request ); + $ok = TMDO_Migration_Orchestrator::cancel(); + return new WP_REST_Response( array( 'ok' => $ok ), $ok ? 200 : 409 ); + } + + /** + * POST /wpdo/v1/migration/resume + * + * @param WP_REST_Request $request Unused. + * @return WP_REST_Response + */ + public function migration_resume( WP_REST_Request $request ): WP_REST_Response { + unset( $request ); + $result = TMDO_Migration_Orchestrator::resume(); + return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 ); + } +} diff --git a/includes/class-tmdo-safe-unserialize.php b/includes/class-tmdo-safe-unserialize.php new file mode 100644 index 0000000..9dc0147 --- /dev/null +++ b/includes/class-tmdo-safe-unserialize.php @@ -0,0 +1,91 @@ + false]` so PHP returns + * `__PHP_Incomplete_Class` placeholders without ever invoking magic + * methods on the original class. The placeholders are then walked out of + * the result tree before returning, so they cannot leak into a flat-table + * JSON column or downstream consumer. + * + * @package WP_Data_Optimizer + * @since 2.13.3 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Static helper class — drop-in replacement for `maybe_unserialize()`. + */ +final class TMDO_Safe_Unserialize { + + /** + * Object-injection-safe `maybe_unserialize()` equivalent. + * + * Same input/output contract as `maybe_unserialize()`: + * - Non-string input is returned as-is. + * - Non-serialized strings are returned as-is. + * - Serialized arrays / scalars are unserialized with allowed_classes=false. + * - Any object placeholders in the result are stripped to null. + * + * @param mixed $value Raw value (typically meta_value or option value). + * @return mixed Unserialized array / scalar, or original string if not serialized. + */ + public static function run( $value ) { + if ( ! is_string( $value ) ) { + return $value; + } + + $trimmed = trim( $value ); + + // Cheap inline detector — does not rely on WP's is_serialized() so this + // helper works in CLI / standalone contexts. PHP serialize tokens: + // a (array), O (object), s (string), i (int), d (float), b (bool), + // N; (null), C (custom class — also handled by allowed_classes=false). + if ( 'N;' !== $trimmed ) { + if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) { + return $value; + } + if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b', 'C' ), true ) ) { + return $value; + } + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection. + $result = @unserialize( $trimmed, array( 'allowed_classes' => false ) ); + + // `unserialize()` returns false on parse error. The literal payload + // `b:0;` legitimately deserializes to (bool) false, so distinguish that. + if ( false === $result && 'b:0;' !== $trimmed ) { + return $value; + } + + // Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces + // any object marker with this stub). They must never reach a flat-table + // JSON column or a downstream consumer that might try to access props. + if ( is_object( $result ) ) { + return null; + } + if ( is_array( $result ) ) { + array_walk_recursive( + $result, + static function ( &$v ) { + if ( is_object( $v ) ) { + $v = null; + } + } + ); + } + + return $result; + } +} diff --git a/includes/class-tmdo-schema-registry.php b/includes/class-tmdo-schema-registry.php new file mode 100644 index 0000000..9b1fec5 --- /dev/null +++ b/includes/class-tmdo-schema-registry.php @@ -0,0 +1,287 @@ +register( 'hivepress', [ + * 'post_type' => 'hp_listing', + * 'meta_key' => 'hp_price', + * 'zone' => 'hot', + * 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + * 'column' => 'hp_price', + * 'indexed' => true, + * ]); + */ +class TMDO_Schema_Registry { + + /** + * Singleton instance. + * + * @var self|null + */ + private static ?self $instance = null; + + /** + * Registered field mappings. + * + * Structure: [ 'post_type:meta_key' => field_config, ... ] + * + * @var array + */ + private array $fields = array(); + + /** + * Zone → post_type → columns map for Zone A (hot) table creation. + * + * @var array> + */ + private array $hot_columns = array(); + + /** + * Zone C (cold) fields grouped by post_type. + * + * @var array + */ + private array $cold_fields = array(); + + /** + * Zone B (warm) fields with TTL config. + * + * @var array + */ + private array $warm_fields = array(); + + /** + * Private constructor — use instance() to get the singleton. + */ + private function __construct() {} + + /** + * Get the singleton instance. + */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Register a field mapping. + * + * @param string $provider Provider name (e.g. 'hivepress', 'woocommerce'). + * @param array $config Field configuration: + * - post_type (string) WordPress post type. + * - meta_key (string) The wp_postmeta meta_key. + * - zone (string) hot|warm|cold|archive + * - data_type (string) SQL column type for Zone A (e.g. 'decimal(10,2) NOT NULL DEFAULT 0'). + * - column (string) Column name in zone table (defaults to sanitized meta_key). + * - indexed (bool) Whether to add an index (Zone A only). + * - 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). + */ + public function register( string $provider, array $config ): void { + $config = wp_parse_args( + $config, + array( + 'post_type' => '', + 'entity_type' => '', + 'meta_key' => '', + 'zone' => 'hot', + 'data_type' => 'longtext', + 'column' => '', + 'indexed' => false, + 'ttl' => null, + 'cache_group' => '', + 'cache_ttl' => HOUR_IN_SECONDS, + 'provider' => $provider, + ) + ); + + // v2.1.2 fix: normalize post_type vs entity_type. Some partner plugins + // (e.g. 2meet-liff) register fields against entities other than posts + // (user / term / comment) using `entity_type` instead of `post_type`. + // Without normalization, those fields end up with empty-string post_type, + // polluting `get_hot_post_types()` output. Use entity_type as bucket key. + if ( empty( $config['post_type'] ) && ! empty( $config['entity_type'] ) ) { + $config['post_type'] = sanitize_key( $config['entity_type'] ); + } + + if ( empty( $config['column'] ) ) { + $config['column'] = sanitize_key( $config['meta_key'] ); + } + + $key = $config['post_type'] . ':' . $config['meta_key']; + $config['provider'] = $provider; + $this->fields[ $key ] = $config; + + // Index by zone for efficient lookup. + switch ( $config['zone'] ) { + case 'hot': + $this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type']; + break; + + case 'warm': + $this->warm_fields[ $config['meta_key'] ] = $config; + break; + + case 'cold': + $this->cold_fields[ $config['post_type'] ][] = $config['meta_key']; + break; + } + } + + /** + * Bulk register multiple fields. + * + * @param string $provider Provider name. + * @param array $configs Array of field configs. + */ + public function register_many( string $provider, array $configs ): void { + foreach ( $configs as $config ) { + $this->register( $provider, $config ); + } + } + + /** + * Get the zone configuration for a specific field. + * + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return array|null Field config or null if not registered. + */ + public function get_field( string $post_type, string $meta_key ): ?array { + $key = $post_type . ':' . $meta_key; + return $this->fields[ $key ] ?? null; + } + + /** + * Get all fields for a specific zone. + * + * @param string $zone Zone identifier (hot/warm/cold/archive). + * @return array Array of field configs. + */ + public function get_zone_fields( string $zone ): array { + return array_filter( $this->fields, fn( $f ) => $f['zone'] === $zone ); + } + + /** + * Get all fields for a specific zone and post type. + * + * @param string $zone Zone identifier. + * @param string $post_type Post type. + * @return array + */ + public function get_zone_fields_for_type( string $zone, string $post_type ): array { + return array_filter( + $this->fields, + fn( $f ) => $f['zone'] === $zone && $f['post_type'] === $post_type + ); + } + + /** + * Get hot column definitions for a post type (for table creation). + * + * @param string $post_type Post type. + * @return array Column name => SQL type. + */ + public function get_hot_columns( string $post_type ): array { + return $this->hot_columns[ $post_type ] ?? array(); + } + + /** + * Get all post types that have hot zone fields. + * + * @return string[] + */ + public function get_hot_post_types(): array { + return array_keys( $this->hot_columns ); + } + + /** + * Get all post types that have cold zone fields. + * + * @return string[] + */ + public function get_cold_post_types(): array { + return array_keys( $this->cold_fields ); + } + + /** + * Get cold field meta keys for a post type. + * + * @param string $post_type Post type. + * @return string[] Array of meta keys. + */ + public function get_cold_meta_keys( string $post_type ): array { + return array_unique( $this->cold_fields[ $post_type ] ?? array() ); + } + + /** + * Get warm field configuration. + * + * @param string $meta_key Meta key. + * @return array|null + */ + public function get_warm_field( string $meta_key ): ?array { + return $this->warm_fields[ $meta_key ] ?? null; + } + + /** + * Check which zone a meta_key belongs to for a given post type. + * + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return string|null Zone name or null if not registered. + */ + public function get_field_zone( string $post_type, string $meta_key ): ?string { + $field = $this->get_field( $post_type, $meta_key ); + return $field['zone'] ?? null; + } + + /** + * Get all registered fields (for admin dashboard / analysis). + * + * @return array + */ + public function all(): array { + return $this->fields; + } + + /** + * Get summary statistics for the admin dashboard. + * + * @return array{hot: int, warm: int, cold: int, archive: int, total: int} + */ + public function get_stats(): array { + $stats = array( + 'hot' => 0, + 'warm' => 0, + 'cold' => 0, + 'archive' => 0, + 'total' => 0, + ); + foreach ( $this->fields as $field ) { + if ( isset( $stats[ $field['zone'] ] ) ) { + ++$stats[ $field['zone'] ]; + } + ++$stats['total']; + } + return $stats; + } +} diff --git a/includes/class-tmdo-sqlite-compat.php b/includes/class-tmdo-sqlite-compat.php new file mode 100644 index 0000000..60ebd0f --- /dev/null +++ b/includes/class-tmdo-sqlite-compat.php @@ -0,0 +1,359 @@ +prefix; + + $tables = self::get_system_column_definitions( $p ); + $schema_table = '_wp_sqlite_mysql_information_schema_columns'; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name. + foreach ( $tables as $table_name => $columns ) { + foreach ( $columns as $pos => $col ) { + $wpdb->query( + $wpdb->prepare( + "INSERT OR IGNORE INTO `{$schema_table}` + (table_name, column_name, data_type, is_nullable, column_default, ordinal_position) + VALUES (%s, %s, %s, %s, %s, %d)", + $table_name, + $col['name'], + $col['type'], + $col['nullable'] ? 'YES' : 'NO', + $col['default'], + $pos + 1 + ) + ); + } + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Patch a dynamic zone table (e.g. wpdo_hot_hp_listing) into the SQLite info_schema. + * + * @param string $table_name Full table name with prefix. + * @param array $columns Array of column definitions. + */ + public static function patch_table( string $table_name, array $columns ): void { + if ( ! TMDO_IS_SQLITE ) { + return; + } + + global $wpdb; + $schema_table = '_wp_sqlite_mysql_information_schema_columns'; + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name. + foreach ( $columns as $pos => $col ) { + $wpdb->query( + $wpdb->prepare( + "INSERT OR IGNORE INTO `{$schema_table}` + (table_name, column_name, data_type, is_nullable, column_default, ordinal_position) + VALUES (%s, %s, %s, %s, %s, %d)", + $table_name, + $col['name'], + $col['type'], + $col['nullable'] ? 'YES' : 'NO', + $col['default'], + $pos + 1 + ) + ); + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Column definitions for WPDO system tables (migrations, errors, benchmarks) + * and zone infrastructure tables (warm, archive). + * + * @param string $p Table prefix. + * @return array Table column definitions array. + */ + private static function get_system_column_definitions( string $p ): array { + return array( + "{$p}wpdo_migrations" => array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'module', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'zone', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'state', + 'type' => 'varchar', + 'nullable' => false, + 'default' => 'idle', + ), + array( + 'name' => 'total_rows', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'processed_rows', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'last_offset', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'error_count', + 'type' => 'int', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'started_at', + 'type' => 'datetime', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'completed_at', + 'type' => 'datetime', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'created_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + array( + 'name' => 'updated_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + ), + "{$p}wpdo_errors" => array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'module', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'zone', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'hook', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'message', + 'type' => 'longtext', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'context', + 'type' => 'longtext', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'created_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + ), + "{$p}wpdo_benchmarks" => array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'module', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'zone', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'query_type', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'native_ms', + 'type' => 'decimal', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'custom_ms', + 'type' => 'decimal', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'sample_size', + 'type' => 'int', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'created_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + ), + "{$p}wpdo_warm" => array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'post_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'meta_key', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'meta_value', + 'type' => 'longtext', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'expires_at', + 'type' => 'datetime', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'created_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + ), + "{$p}wpdo_archive" => array( + array( + 'name' => 'id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => null, + ), + array( + 'name' => 'post_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'post_type', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'meta_key', + 'type' => 'varchar', + 'nullable' => false, + 'default' => '', + ), + array( + 'name' => 'meta_value', + 'type' => 'longtext', + 'nullable' => true, + 'default' => null, + ), + array( + 'name' => 'compressed', + 'type' => 'tinyint', + 'nullable' => false, + 'default' => '0', + ), + array( + 'name' => 'archived_at', + 'type' => 'datetime', + 'nullable' => false, + 'default' => '0000-00-00 00:00:00', + ), + array( + 'name' => 'original_meta_id', + 'type' => 'bigint', + 'nullable' => false, + 'default' => '0', + ), + ), + ); + } +} diff --git a/includes/class-tmdo-term-comment-backfill.php b/includes/class-tmdo-term-comment-backfill.php new file mode 100644 index 0000000..1bee04f --- /dev/null +++ b/includes/class-tmdo-term-comment-backfill.php @@ -0,0 +1,224 @@ + + */ + 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 backfill for every registered term + comment group. + * + * @param bool $dry_run When true, only count rows without writing. + * @return array + */ + 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 ); + } + return $results; + } + + /** + * Run backfill for a single group. + * + * @param string $entity_type 'term' or 'comment'. + * @param string $group_name Entity group name. + * @param bool $dry_run When true, only count rows without writing. + * @return array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool, error?:string} + */ + public static function backfill_group( string $entity_type, string $group_name, bool $dry_run = false ): array { + $result = array( + 'group' => $group_name, + 'entity_type' => $entity_type, + 'candidates' => 0, + 'written' => 0, + 'dry_run' => $dry_run, + ); + + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + $result['error'] = 'Entity Registry unavailable'; + return $result; + } + if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) { + $result['error'] = "Unknown group: {$group_name}"; + return $result; + } + $keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name ); + if ( empty( $keys ) ) { + $result['error'] = "No keys registered for {$entity_type}:{$group_name}"; + return $result; + } + + global $wpdb; + + // Resolve source / meta tables + id columns per entity type. + if ( 'term' === $entity_type ) { + $source_table = $wpdb->terms; + $source_id_col = 'term_id'; + $meta_table = $wpdb->termmeta; + $meta_id_col = 'term_id'; + $flat_id_col = 'term_id'; + } elseif ( 'comment' === $entity_type ) { + $source_table = $wpdb->comments; + $source_id_col = 'comment_ID'; + $meta_table = $wpdb->commentmeta; + $meta_id_col = 'comment_id'; + $flat_id_col = 'comment_id'; + } else { + $result['error'] = "Unsupported entity_type: {$entity_type}"; + return $result; + } + + $flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix']; + + // Count candidate entities — those with at least one of the registered keys in wp_*meta. + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $candidate_query = $wpdb->prepare( + "SELECT COUNT(DISTINCT s.`{$source_id_col}`) + FROM `{$source_table}` s + INNER JOIN `{$meta_table}` m ON m.`{$meta_id_col}` = s.`{$source_id_col}` + WHERE m.meta_key IN ({$placeholders})", + ...$keys + ); + $result['candidates'] = (int) $wpdb->get_var( $candidate_query ); + + if ( $dry_run ) { + return $result; + } + if ( 0 === $result['candidates'] ) { + return $result; + } + + // Build SELECT clause: id + one MAX(CASE) per registered key (sanitized to flat column). + $select_parts = array( "s.`{$source_id_col}` AS id" ); + $update_clauses = array(); + $insert_cols = array( "`{$flat_id_col}`" ); + + foreach ( $keys as $key ) { + $col = self::sanitize_column( $key ); + $insert_cols[] = "`{$col}`"; + $select_parts[] = $wpdb->prepare( + "MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS `{$col}`", + $key + ); + $update_clauses[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)"; + } + + $insert_sql = sprintf( + 'INSERT INTO `%s` (%s) + SELECT %s + FROM `%s` s + INNER JOIN `%s` m ON m.`%s` = s.`%s` + WHERE m.meta_key IN (%s) + GROUP BY s.`%s` + ON DUPLICATE KEY UPDATE %s', + $flat_table, + implode( ', ', $insert_cols ), + implode( ",\n ", $select_parts ), + $source_table, + $meta_table, + $meta_id_col, + $source_id_col, + $placeholders, + $source_id_col, + implode( ', ', $update_clauses ) + ); + + $prepared = $wpdb->prepare( $insert_sql, ...$keys ); + $ok = $wpdb->query( $prepared ); + + if ( false === $ok ) { + $result['error'] = $wpdb->last_error ?: 'Backfill query failed'; + return $result; + } + + // MySQL `INSERT ... ON DUPLICATE KEY UPDATE` returns 1 per insert, 2 per + // update — divide by 2 with floor for a meaningful "rows touched" estimate + // only when both sides equal candidates. Easier: just report candidates + // since that's the upper bound and ON DUPLICATE replaces. + $result['written'] = $result['candidates']; + return $result; + } + + /** + * Sanitize meta_key into a column name (mirrors Schema_Manager rules). + * + * @param string $key Meta key. + * @return string + */ + private static function sanitize_column( string $key ): string { + if ( class_exists( 'TMDO_Schema_Manager' ) ) { + return TMDO_Schema_Manager::sanitize_column_name( $key ); + } + return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key ); + } +} diff --git a/includes/class-tmdo-term-comment-shadow-verifier.php b/includes/class-tmdo-term-comment-shadow-verifier.php new file mode 100644 index 0000000..9ec855b --- /dev/null +++ b/includes/class-tmdo-term-comment-shadow-verifier.php @@ -0,0 +1,428 @@ + + */ + 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. + * + * @param string $entity_type 'term' or 'comment'. + * @param string $group_name Entity group name. + * @param string $flat_table Fully-qualified flat table name. + * @param string[] $keys Meta keys to compare. + * @param int $sample_size Number of entities to sample. + * @return array{ + * sampled:int, + * matched:int, + * diffs:int, + * missing_flat:int, + * missing_meta:int, + * group:string, + * entity_type:string, + * } + * @throws InvalidArgumentException When inputs invalid. + */ + public static function sample_compare( + string $entity_type, + string $group_name, + string $flat_table, + array $keys, + int $sample_size = self::DEFAULT_SAMPLE_SIZE + ): array { + if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) { + $msg = 'entity_type must be term or comment'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + if ( $sample_size <= 0 ) { + $msg = 'Sample size must be > 0'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + if ( empty( $keys ) ) { + $msg = 'Keys array cannot be empty'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + global $wpdb; + + // Source table + id column + meta table differ per entity type. + if ( 'term' === $entity_type ) { + $source_table = $wpdb->terms; + $source_id_col = 'term_id'; + $meta_table = $wpdb->termmeta; + $meta_id_col = 'term_id'; + $flat_id_col = 'term_id'; + } else { + $source_table = $wpdb->comments; + $source_id_col = 'comment_ID'; + $meta_table = $wpdb->commentmeta; + $meta_id_col = 'comment_id'; + $flat_id_col = 'comment_id'; + } + + $ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT `{$source_id_col}` FROM `{$source_table}` ORDER BY RAND() LIMIT %d", + $sample_size + ) + ); + + $sampled = count( $ids ); + $matched = 0; + $diffs = 0; + $missing_flat = 0; + $missing_meta = 0; + + if ( 0 === $sampled ) { + return array( + 'sampled' => 0, + 'matched' => 0, + 'diffs' => 0, + 'missing_flat' => 0, + 'missing_meta' => 0, + 'group' => $group_name, + 'entity_type' => $entity_type, + ); + } + + foreach ( $ids as $object_id ) { + $object_id = (int) $object_id; + foreach ( $keys as $key ) { + $col = self::sanitize_column( $key ); + $flat_val = $wpdb->get_var( + $wpdb->prepare( + "SELECT `{$col}` FROM `{$flat_table}` WHERE `{$flat_id_col}` = %d LIMIT 1", + $object_id + ) + ); + $meta_val = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM `{$meta_table}` WHERE `{$meta_id_col}` = %d AND meta_key = %s LIMIT 1", + $object_id, + $key + ) + ); + + $flat_present = null !== $flat_val && '' !== $flat_val; + $meta_present = null !== $meta_val && '' !== $meta_val; + + // In-memory counters only. Hook Bus auto-logs diffs to + // wpdo_shadow_diffs on every read in shadow_read mode (via + // TMDO_Shadow_Diff_Logger::compare_and_log) — this verifier + // is a sample-based snapshot, not the persistent log. + if ( ! $flat_present && ! $meta_present ) { + ++$matched; + continue; + } + if ( ! $flat_present && $meta_present ) { + ++$missing_flat; + continue; + } + if ( $flat_present && ! $meta_present ) { + ++$missing_meta; + continue; + } + + if ( self::values_loose_equal( $meta_val, $flat_val ) ) { + ++$matched; + } else { + ++$diffs; + } + } + } + + return array( + 'sampled' => $sampled, + 'matched' => $matched, + 'diffs' => $diffs, + 'missing_flat' => $missing_flat, + 'missing_meta' => $missing_meta, + 'group' => $group_name, + 'entity_type' => $entity_type, + ); + } + + /** + * Cron handler: runs sample_compare for every group whose entity is in + * shadow_read. No-op when neither term nor comment is shadow_read. + * + * @return void + */ + public static function cron_tick(): void { + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return; + } + $term_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'term' ); + $comment_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'comment' ); + if ( ! $term_shadow && ! $comment_shadow ) { + return; + } + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return; + } + + global $wpdb; + + foreach ( self::GROUP_MAP as $group => $cfg ) { + $entity_type = $cfg['entity_type']; + + // Only run when the entity is in shadow_read mode. + if ( 'term' === $entity_type && ! $term_shadow ) { + continue; + } + if ( 'comment' === $entity_type && ! $comment_shadow ) { + continue; + } + + $keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group ); + if ( empty( $keys ) ) { + continue; + } + $flat_table = $wpdb->prefix . $cfg['table_suffix']; + + try { + self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE ); + } catch ( \Throwable $e ) { + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( + 'term_comment_shadow_verifier', + 'cron_tick', + $e->getMessage() + ); + } + } + } + } + + /** + * Recent diff records for term + comment entity types. + * + * @param int $limit Max rows. + * @return array + */ + public static function recent_diffs( int $limit = 100 ): array { + if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) { + return array(); + } + + // Logger is entity-type-aware; gather both sides. + $term_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'term' ); + $comment_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'comment' ); + + return array_merge( (array) $term_diffs, (array) $comment_diffs ); + } + + /** + * Aggregate diff stats for term+comment over recent N hours. + * + * @param int $hours Window (default 24). + * @return array{total:int,term:int,comment:int,by_key:array} + */ + public static function diff_stats( int $hours = 24 ): array { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return array( + 'total' => 0, + 'term' => 0, + 'comment' => 0, + 'by_key' => array(), + ); + } + + $since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) ); + + $term_total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'term' AND ts >= %s", + $since + ) + ); + $comment_total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'comment' AND ts >= %s", + $since + ) + ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type IN ('term','comment') AND ts >= %s GROUP BY meta_key ORDER BY n DESC", + $since + ), + ARRAY_A + ); + + $by_key = array(); + foreach ( (array) $rows as $row ) { + $by_key[ (string) $row['meta_key'] ] = (int) $row['n']; + } + + return array( + 'total' => $term_total + $comment_total, + 'term' => $term_total, + 'comment' => $comment_total, + 'by_key' => $by_key, + ); + } + + /** + * Run sample_compare for ALL groups (regardless of mode). Used by + * `wp wpdo term-comment-shadow-report` so users can see drift even + * before promoting to shadow_read. + * + * @param int $sample_size Per-group sample size. + * @return array Map of `group_name` → sample_compare() result. + */ + public static function run_all( int $sample_size = self::DEFAULT_SAMPLE_SIZE ): array { + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return array(); + } + + global $wpdb; + $out = array(); + + foreach ( self::GROUP_MAP as $group => $cfg ) { + $entity_type = $cfg['entity_type']; + $keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group ); + if ( empty( $keys ) ) { + continue; + } + $flat_table = $wpdb->prefix . $cfg['table_suffix']; + + try { + $out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size ); + } catch ( \Throwable $e ) { + $out[ $group ] = array( + 'error' => $e->getMessage(), + 'group' => $group, + ); + } + } + + return $out; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Sanitize meta_key into a column name (mirrors Schema_Manager rules). + * + * @param string $key Meta key. + * @return string + */ + private static function sanitize_column( string $key ): string { + if ( class_exists( 'TMDO_Schema_Manager' ) ) { + return TMDO_Schema_Manager::sanitize_column_name( $key ); + } + return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key ); + } + + /** + * Loose equality for verifier — handles type widening and serialization + * format differences. Layers fail-fast: identical → numeric → decoded. + * + * @param mixed $a Side A value. + * @param mixed $b Side B value. + * @return bool + */ + private static function values_loose_equal( $a, $b ): bool { + // 1. Identical strings. + if ( (string) $a === (string) $b ) { + return true; + } + // 2. Numeric loose match. + if ( is_numeric( $a ) && is_numeric( $b ) && (float) $a === (float) $b ) { + return true; + } + // 3. Decoded match (serialized vs JSON). + $decoded_a = self::decode_value( (string) $a ); + $decoded_b = self::decode_value( (string) $b ); + return $decoded_a === $decoded_b; + } + + /** + * Decode a stored value: try unserialize → JSON decode → as-is. + * + * @param string $value Raw stored value. + * @return mixed + */ + private static function decode_value( string $value ) { + // Try unserialize for postmeta-style serialized payloads. + if ( '' !== $value ) { + $first_two = substr( $value, 0, 2 ); + if ( 'a:' === $first_two || 'O:' === $first_two || 's:' === $first_two || 'i:' === $first_two || 'b:' === $first_two || 'd:' === $first_two ) { + $unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors + if ( false !== $unserialized || 'b:0;' === $value ) { + return $unserialized; + } + } + } + // Try JSON for flat-table json-typed columns. + if ( '' !== $value && ( '{' === $value[0] || '[' === $value[0] ) ) { + $json = json_decode( $value, true ); + if ( null !== $json ) { + return $json; + } + } + return $value; + } +} diff --git a/includes/class-tmdo-term-stress-tester.php b/includes/class-tmdo-term-stress-tester.php new file mode 100644 index 0000000..4b6c5d0 --- /dev/null +++ b/includes/class-tmdo-term-stress-tester.php @@ -0,0 +1,880 @@ +terms / wp_term_taxonomy / wp_termmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders. + +/** + * Async bulk fixture generator for term entity stress tests. + */ +final class TMDO_Term_Stress_Tester { + + /** Slug prefix for stress test terms — used for cleanup matching. */ + public const TEST_TERM_PREFIX = 'wpdo-stress-'; + + /** Hard cap to prevent runaway calls. */ + public const MAX_COUNT = 100000; + + // State machine constants (mirror post v2.11.4). + public const OPT_STATE = 'wpdo_term_stress_test_state'; + public const CRON_HOOK = 'wpdo_term_stress_test_batch'; + public const CANCEL_FLAG = 'wpdo_term_stress_cancel_flag'; + public const DEFAULT_BATCH_SIZE = 200; + public const MAX_BATCH_SIZE = 1000; + public const MIN_BATCH_DELAY_SEC = 1; + public const BATCH_DEADLINE_SEC = 8; + public const MODE_FAST = 'fast'; + public const MODE_REALISTIC = 'realistic'; + + /** + * Per-key seed map (single hp_taxonomy group). All keys go to + * `wp_wpdo_term_hp_taxonomy` flat table when term mode is dual_write+. + * + * @var array|null + */ + private static ?array $seed_map_cache = null; + + // ───────────────────────────────────────────────────────────────────────── + // Public API — sync helpers (also used internally by state machine batches) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Bulk-create N stress test terms in the given taxonomy via direct SQL + * (Fast mode — bypasses WP filter chain). Use for fixture loading where + * Hook Bus routing is not the goal. + * + * @param string $taxonomy WP taxonomy slug (e.g., listing_category, category). + * @param int $count Number of terms to insert. Capped at MAX_COUNT. + * @return array{created:int,taxonomy:string,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When inputs invalid. + */ + public static function create( string $taxonomy, int $count ): array { + self::validate_inputs( $taxonomy, $count ); + + global $wpdb; + + $first_id = null; + $last_id = null; + $created = 0; + + $seed_map = self::seed_map(); + + for ( $i = 0; $i < $count; $i++ ) { + $suffix = wp_generate_password( 8, false ); + $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; + $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); + + $ok = $wpdb->insert( + $wpdb->terms, + array( + 'name' => $name, + 'slug' => $slug, + 'term_group' => 0, + ) + ); + if ( ! $ok ) { + continue; + } + $term_id = (int) $wpdb->insert_id; + + $ok2 = $wpdb->insert( + $wpdb->prefix . 'term_taxonomy', + array( + 'term_id' => $term_id, + 'taxonomy' => $taxonomy, + 'description' => '', + 'parent' => 0, + 'count' => 0, + ) + ); + if ( ! $ok2 ) { + $wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) ); + continue; + } + + if ( null === $first_id ) { + $first_id = $term_id; + } + $last_id = $term_id; + ++$created; + + // Direct INSERT to wp_termmeta (bypassing Hook Bus). For aeav_only + // mode validation, use create_realistic() instead. + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + $wpdb->insert( + $wpdb->termmeta, + array( + 'term_id' => $term_id, + 'meta_key' => $meta_key, + 'meta_value' => (string) $value, + ) + ); + } + } + + return array( + 'created' => $created, + 'taxonomy' => $taxonomy, + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Realistic-mode counterpart — uses wp_insert_term() + update_term_meta() + * so Hook Bus / entity registry / mode_manager all engage on the write + * path. Use this for validating mode=dual_write+ behavior. + * + * @param string $taxonomy WP taxonomy slug. + * @param int $count Number of terms to insert. + * @return array{created:int,taxonomy:string,mode:string,first_id:int|null,last_id:int|null} + * @throws InvalidArgumentException When inputs invalid. + */ + public static function create_realistic( string $taxonomy, int $count ): array { + self::validate_inputs( $taxonomy, $count ); + + $first_id = null; + $last_id = null; + $created = 0; + $seed_map = self::seed_map(); + + for ( $i = 0; $i < $count; $i++ ) { + $suffix = wp_generate_password( 8, false ); + $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; + $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); + + $result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) ); + if ( is_wp_error( $result ) ) { + continue; + } + $term_id = (int) ( $result['term_id'] ?? 0 ); + if ( 0 === $term_id ) { + continue; + } + if ( null === $first_id ) { + $first_id = $term_id; + } + $last_id = $term_id; + ++$created; + + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_term_meta( $term_id, $meta_key, $value ); + } + } + + return array( + 'created' => $created, + 'taxonomy' => $taxonomy, + 'mode' => 'realistic', + 'first_id' => $first_id, + 'last_id' => $last_id, + ); + } + + /** + * Count terms whose slug begins with TEST_TERM_PREFIX. + * + * @return int + */ + public static function count_test_terms(): int { + global $wpdb; + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->terms} WHERE slug LIKE %s", + $wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%' + ) + ); + } + + /** + * Delete every stress-test term + its term_taxonomy + termmeta rows + flat + * table rows in wpdo_term_*. + * + * @return array{deleted_terms:int,deleted_meta:int,deleted_flat:int} + */ + public static function cleanup(): array { + global $wpdb; + + // Cancel any in-flight job + clear pump lock. + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $term_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT term_id FROM {$wpdb->terms} WHERE slug LIKE %s", + $wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%' + ) + ); + + if ( empty( $term_ids ) ) { + return array( + 'deleted_terms' => 0, + 'deleted_meta' => 0, + 'deleted_flat' => 0, + ); + } + + $id_list = implode( ',', array_map( 'absint', $term_ids ) ); + $flat_deleted = 0; + + // Cascade flat tables first (best-effort — tables may not exist yet). + foreach ( self::get_term_flat_tables() as $tbl ) { + $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) ); + if ( ! $exists ) { + continue; + } + $rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE term_id IN ({$id_list})" ); + $flat_deleted += $rows; + } + + // Delete termmeta. + $meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE term_id IN ({$id_list})" ); + + // Delete term_taxonomy. + $wpdb->query( "DELETE FROM {$wpdb->prefix}term_taxonomy WHERE term_id IN ({$id_list})" ); + + // Delete terms. + $term_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->terms} WHERE term_id IN ({$id_list})" ); + + // Reset state. + delete_option( self::OPT_STATE ); + delete_transient( self::CANCEL_FLAG ); + + // Clean WP term caches per term_id (clean_term_cache works on arrays). + clean_term_cache( array_map( 'absint', $term_ids ) ); + + return array( + 'deleted_terms' => $term_deleted, + 'deleted_meta' => $meta_deleted, + 'deleted_flat' => $flat_deleted, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // State machine + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start an async stress run. Persists state, schedules the first batch + * via wp_schedule_single_event(). First batch is pushed by cron or by + * pump_if_due() on next polling request. + * + * @param string $taxonomy Taxonomy slug. + * @param int $target Total terms to create. + * @param string $mode MODE_FAST | MODE_REALISTIC. + * @param int $batch_size Per-batch insert count. + * @return array{ok:bool,error?:string,state?:array} + */ + public static function start( string $taxonomy, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array { + if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) { + return array( + 'ok' => false, + 'error' => 'unknown_taxonomy: ' . $taxonomy, + ); + } + if ( $target < 1 ) { + return array( + 'ok' => false, + 'error' => 'target must be >= 1', + ); + } + if ( $target > self::MAX_COUNT ) { + return array( + 'ok' => false, + 'error' => 'target too large (max ' . self::MAX_COUNT . ')', + ); + } + if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) { + return array( + 'ok' => false, + 'error' => 'invalid mode', + ); + } + $batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) ); + + $current = self::get_state(); + if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) { + return array( + 'ok' => false, + 'error' => 'already_running', + 'state' => $current, + ); + } + + $state = array( + 'job_id' => uniqid( 'tstress_', true ), + 'status' => 'running', + 'mode' => $mode, + 'taxonomy' => $taxonomy, + 'target' => $target, + 'batch_size' => $batch_size, + 'started_at' => time(), + 'processed' => 0, + 'batches_done' => 0, + 'batches_log' => array(), + 'errors' => array(), + 'peak_memory' => 0, + 'completed_at' => null, + 'last_pushed_at' => 0, + 'benchmark' => null, + ); + update_option( self::OPT_STATE, $state, false ); + + delete_transient( self::CANCEL_FLAG ); + + wp_clear_scheduled_hook( self::CRON_HOOK ); + wp_schedule_single_event( time(), self::CRON_HOOK ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Cancel a running stress test. + * + * @return array{ok:bool,state?:array,message?:string} + */ + public static function cancel(): array { + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'ok' => true, + 'message' => 'no_active_job', + ); + } + + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state = self::get_state(); + $state['status'] = 'cancelled'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * Read raw state. + * + * @return array + */ + public static function get_state(): array { + $state = get_option( self::OPT_STATE, array() ); + return is_array( $state ) ? $state : array(); + } + + /** + * Get progress with computed pct/rate/ETA. Opportunistically pumps + * if cron is overdue. + * + * @param bool $pump When true, pump_if_due() runs. + * @return array + */ + public static function get_progress( bool $pump = true ): array { + if ( $pump ) { + self::pump_if_due(); + } + + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'status' => 'idle', + 'processed' => 0, + 'target' => 0, + 'pct' => 0, + ); + } + + $processed = (int) ( $state['processed'] ?? 0 ); + $target = (int) ( $state['target'] ?? 0 ); + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? 0 ); + + $now = $ended > 0 ? $ended : time(); + $elapsed = max( 1, $now - $started ); + $rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0; + $eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0; + $pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0; + + return array_merge( + $state, + array( + 'pct' => $pct, + 'rate_per_sec' => $rate, + 'elapsed_sec' => $elapsed, + 'eta_sec' => $eta_sec, + 'test_term_count' => self::count_test_terms(), + ) + ); + } + + /** + * Opportunistic batch pump (transient-locked). + * + * @return void + */ + public static function pump_if_due(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + $last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 ); + if ( time() - $last_pushed_at < 1 ) { + return; + } + + $lock_key = 'wpdo_term_stress_pump_lock'; + if ( false !== get_transient( $lock_key ) ) { + return; + } + set_transient( $lock_key, 1, 30 ); + + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + + try { + self::run_batch(); + } finally { + delete_transient( $lock_key ); + } + } + + /** + * Cron entrypoint. Runs one batch, reschedules or finalizes. + * + * @return void + */ + public static function run_batch(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + return; + } + + $taxonomy = (string) ( $state['taxonomy'] ?? '' ); + $target = (int) ( $state['target'] ?? 0 ); + $processed = (int) ( $state['processed'] ?? 0 ); + $batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE ); + $mode = (string) ( $state['mode'] ?? self::MODE_FAST ); + $remaining = $target - $processed; + if ( $remaining <= 0 ) { + self::finalize( $state ); + return; + } + $this_batch_size = min( $batch_size, $remaining ); + + $batch_started = microtime( true ); + try { + if ( self::MODE_FAST === $mode ) { + $inserted = self::run_batch_fast( $taxonomy, $this_batch_size ); + } else { + $inserted = self::run_batch_realistic( $taxonomy, $this_batch_size ); + } + } catch ( \Throwable $e ) { + $state['errors'][] = array( + 'time' => time(), + 'message' => $e->getMessage(), + ); + $state['status'] = 'failed'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'term_stress_test_batch_failed', array( 'message' => $e->getMessage() ) ); + } + return; + } + $batch_elapsed = microtime( true ) - $batch_started; + + // Re-read state — cancel() may have modified status mid-batch. + $latest = self::get_state(); + if ( empty( $latest ) ) { + return; + } + $is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG ); + + $latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted; + $latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1; + $latest['batches_log'][] = array( + 'n' => $inserted, + 'duration_ms' => (int) round( $batch_elapsed * 1000 ), + ); + if ( count( $latest['batches_log'] ) > 200 ) { + $latest['batches_log'] = array_slice( $latest['batches_log'], -200 ); + } + $latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) ); + $latest['last_pushed_at'] = time(); + + if ( $is_cancelled ) { + update_option( self::OPT_STATE, $latest, false ); + return; + } + + update_option( self::OPT_STATE, $latest, false ); + + if ( $latest['processed'] >= $target ) { + self::finalize( $latest ); + return; + } + + wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK ); + } + + /** + * Run one fast-mode batch. + * + * @param string $taxonomy Taxonomy slug. + * @param int $count Batch size. + * @return int Inserted count. + */ + private static function run_batch_fast( string $taxonomy, int $count ): int { + $result = self::create( $taxonomy, $count ); + return (int) ( $result['created'] ?? 0 ); + } + + /** + * Run one realistic-mode batch with deadline + cancel check per term. + * + * @param string $taxonomy Taxonomy slug. + * @param int $count Batch size. + * @return int Inserted count. + */ + private static function run_batch_realistic( string $taxonomy, int $count ): int { + $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC; + $inserted = 0; + $seed_map = self::seed_map(); + + for ( $i = 0; $i < $count; $i++ ) { + if ( microtime( true ) > $deadline ) { + break; + } + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + break; + } + + $suffix = wp_generate_password( 8, false ); + $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; + $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); + + $result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) ); + if ( is_wp_error( $result ) ) { + continue; + } + $term_id = (int) ( $result['term_id'] ?? 0 ); + if ( 0 === $term_id ) { + continue; + } + ++$inserted; + + foreach ( $seed_map as $meta_key => $value_spec ) { + $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; + update_term_meta( $term_id, $meta_key, $value ); + } + } + return $inserted; + } + + /** + * Finalize: clear cron, run benchmark, persist completed state. + * + * @param array $state Pre-finalize state. + * @return void + */ + private static function finalize( array $state ): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state['status'] = 'benchmarking'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + $report = self::run_benchmark( $state ); + + $state['benchmark'] = $report; + $state['status'] = 'completed'; + update_option( self::OPT_STATE, $state, false ); + } + + /** + * Run benchmark on the current dataset. + * + * @param array|null $state Optional state snapshot. + * @return array + */ + public static function run_benchmark( ?array $state = null ): array { + $state = $state ?? self::get_state(); + $taxonomy = (string) ( $state['taxonomy'] ?? '' ); + + return array( + 'generated_at' => time(), + 'taxonomy' => $taxonomy, + 'write' => self::compute_write_metrics( $state ), + 'db_sizes' => self::measure_db_sizes(), + 'query' => self::measure_query_performance( $taxonomy ), + ); + } + + /** + * Write throughput metrics from state. + * + * @param array $state State snapshot. + * @return array + */ + private static function compute_write_metrics( array $state ): array { + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? time() ); + $processed = (int) ( $state['processed'] ?? 0 ); + $elapsed = max( 1, $ended - $started ); + $batches = $state['batches_log'] ?? array(); + + $durations = array_column( $batches, 'duration_ms' ); + $min_ms = ! empty( $durations ) ? min( $durations ) : 0; + $max_ms = ! empty( $durations ) ? max( $durations ) : 0; + $avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0; + + return array( + 'mode' => $state['mode'] ?? '', + 'taxonomy' => $state['taxonomy'] ?? '', + 'target' => (int) ( $state['target'] ?? 0 ), + 'processed' => $processed, + 'elapsed_sec' => $elapsed, + 'rate_per_sec' => round( $processed / $elapsed, 2 ), + 'batches_done' => (int) ( $state['batches_done'] ?? 0 ), + 'batch_min_ms' => $min_ms, + 'batch_max_ms' => $max_ms, + 'batch_avg_ms' => $avg_ms, + 'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ), + ); + } + + /** + * Measure DB sizes for term-related tables. + * + * @return array + */ + private static function measure_db_sizes(): array { + global $wpdb; + + $tables = array( + $wpdb->terms, + $wpdb->prefix . 'term_taxonomy', + $wpdb->termmeta, + ); + foreach ( self::get_term_flat_tables() as $tbl ) { + if ( self::table_exists( $tbl ) ) { + $tables[] = $tbl; + } + } + + if ( ! self::is_mysql() ) { + return array_map( + static fn( $t ) => array( + 'table' => $t, + 'rows' => self::table_row_count( $t ), + ), + $tables + ); + } + + $placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", + ...$tables + ), + ARRAY_A + ); + + $out = array(); + foreach ( (array) $rows as $r ) { + $dl = (int) $r['dl']; + $il = (int) $r['il']; + $total = $dl + $il; + $out[] = array( + 'table' => $r['t'], + 'rows' => (int) $r['rows_count'], + 'data_mb' => round( $dl / 1048576, 2 ), + 'index_mb' => round( $il / 1048576, 2 ), + 'total_mb' => round( $total / 1048576, 2 ), + 'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0, + ); + } + return $out; + } + + /** + * Measure 3 representative queries for term entity: + * - point lookup on hp_default flat + * - range scan on hp_sort_order flat + * - EAV baseline on wp_termmeta + * + * @param string $taxonomy Taxonomy slug. + * @return array + */ + private static function measure_query_performance( string $taxonomy ): array { + global $wpdb; + $flat = $wpdb->prefix . 'wpdo_term_hp_taxonomy'; + if ( ! self::table_exists( $flat ) ) { + return array( 'note' => 'flat_table_missing' ); + } + + return array( + 'point_default' => self::time_query( + "SELECT term_id FROM `{$flat}` WHERE hp_default = '1' LIMIT 100" + ), + 'range_sort_top' => self::time_query( + "SELECT term_id FROM `{$flat}` WHERE hp_sort_order > 0 ORDER BY hp_sort_order DESC LIMIT 100" + ), + 'eav_baseline' => self::time_query( + "SELECT term_id FROM {$wpdb->termmeta} WHERE meta_key = 'hp_default' AND meta_value = '1' LIMIT 100" + ), + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Validate inputs for create() / create_realistic(). + * + * @param string $taxonomy Taxonomy slug. + * @param int $count Count to insert. + * @return void + * @throws InvalidArgumentException When invalid. + */ + private static function validate_inputs( string $taxonomy, int $count ): void { + if ( '' === $taxonomy ) { + throw new InvalidArgumentException( 'Taxonomy required.' ); + } + if ( $count <= 0 ) { + throw new InvalidArgumentException( 'Count must be > 0.' ); + } + if ( $count > self::MAX_COUNT ) { + $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').'; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + } + + /** + * Lazy-built seed map for hp_taxonomy group. + * + * @return array + */ + private static function seed_map(): array { + if ( null !== self::$seed_map_cache ) { + return self::$seed_map_cache; + } + + self::$seed_map_cache = array( + 'hp_sort_order' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ), + 'hp_default' => static fn( int $i ) => 0 === $i % 50 ? '1' : '0', + 'hp_icon' => static fn( int $i ) => 'fa-icon-' . ( $i % 10 ), + ); + return self::$seed_map_cache; + } + + /** + * Names of all wp_wpdo_term_* flat tables that cleanup should sweep. + * + * @return string[] + */ + private static function get_term_flat_tables(): array { + global $wpdb; + $prefix = $wpdb->prefix . 'wpdo_term_'; + return array( + $prefix . 'hp_taxonomy', + $prefix . 'misc', + ); + } + + /** + * Memoized table-exists probe. + * + * @param string $table Table name. + * @return bool + */ + private static function table_exists( string $table ): bool { + global $wpdb; + static $cache = array(); + if ( isset( $cache[ $table ] ) ) { + return $cache[ $table ]; + } + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $cache[ $table ] = ( $found === $table ); + return $cache[ $table ]; + } + + /** + * Cheap row count helper (SQLite fallback). + * + * @param string $table Table name. + * @return int + */ + private static function table_row_count( string $table ): int { + global $wpdb; + if ( ! self::table_exists( $table ) ) { + return 0; + } + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + } + + /** + * Time a SQL query. + * + * @param string $sql Query. + * @return array{duration_ms:float} + */ + private static function time_query( string $sql ): array { + global $wpdb; + $start = microtime( true ); + $wpdb->get_results( $sql ); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( 'duration_ms' => round( $elapsed_ms, 2 ) ); + } + + /** + * Detect MySQL vs SQLite. + * + * @return bool + */ + private static function is_mysql(): bool { + return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) ); + } +} diff --git a/includes/class-tmdo-termmeta-cleaner.php b/includes/class-tmdo-termmeta-cleaner.php new file mode 100644 index 0000000..1f6aa90 --- /dev/null +++ b/includes/class-tmdo-termmeta-cleaner.php @@ -0,0 +1,213 @@ + 0, + 'demo_data' => 0, + 'transients' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) { + $counts['wxr_import'] = self::count_wxr_import(); + } + if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) { + $counts['demo_data'] = self::count_demo_data(); + } + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $counts['transients'] = self::count_transients(); + } + + $counts['total'] = $counts['wxr_import'] + $counts['demo_data'] + $counts['transients']; + return $counts; + } + + /** + * Delete garbage rows for the given target. + * + * @param string $target One of TARGET_* constants. + * @return array{wxr_import:int, demo_data:int, transients:int, total:int} + * @throws InvalidArgumentException When $target is not a valid target. + */ + public static function delete_garbage( string $target = self::TARGET_ALL ): array { + self::assert_valid_target( $target ); + + $deleted = array( + 'wxr_import' => 0, + 'demo_data' => 0, + 'transients' => 0, + 'total' => 0, + ); + + if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) { + $deleted['wxr_import'] = self::delete_wxr_import(); + } + if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) { + $deleted['demo_data'] = self::delete_demo_data(); + } + if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) { + $deleted['transients'] = self::delete_transients(); + } + + $deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data'] + $deleted['transients']; + return $deleted; + } + + /** + * Whether $target selects $bucket (i.e. target=all or target=bucket). + * + * @param string $target Selected target. + * @param string $bucket Bucket constant. + * @return bool + */ + private static function target_includes( string $target, string $bucket ): bool { + return self::TARGET_ALL === $target || $bucket === $target; + } + + /** + * Validate target parameter. + * + * @param string $target Target to validate. + * @return void + * @throws InvalidArgumentException When $target is not in VALID_TARGETS. + */ + private static function assert_valid_target( string $target ): void { + if ( in_array( $target, self::VALID_TARGETS, true ) ) { + return; + } + $msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) ); + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->termmeta is WP-managed; meta_key patterns are static class constants. + + /** + * Count `_wxr_import_*` rows in wp_termmeta. + * + * @return int + */ + private static function count_wxr_import(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'" + ); + } + + /** + * Delete `_wxr_import_*` rows from wp_termmeta. + * + * @return int Affected row count. + */ + private static function delete_wxr_import(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'" + ); + } + + /** + * Count `_2meet_demo_*` rows in wp_termmeta. + * + * @return int + */ + private static function count_demo_data(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'" + ); + } + + /** + * Delete `_2meet_demo_*` rows from wp_termmeta. + * + * @return int Affected row count. + */ + private static function delete_demo_data(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'" + ); + } + + /** + * Count rows matching transient meta_key patterns in wp_termmeta. + * + * @return int + */ + private static function count_transients(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + /** + * Delete rows matching transient meta_key patterns from wp_termmeta. + * + * @return int Affected row count. + */ + private static function delete_transients(): int { + global $wpdb; + $table = $wpdb->termmeta; + return (int) $wpdb->query( + "DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'" + ); + } + + // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared +} diff --git a/includes/class-tmdo-user-stress-tester.php b/includes/class-tmdo-user-stress-tester.php new file mode 100644 index 0000000..c9f0156 --- /dev/null +++ b/includes/class-tmdo-user-stress-tester.php @@ -0,0 +1,1417 @@ + false, + 'error' => 'target_count must be >= 1', + ); + } + if ( $target_count > 1000000 ) { + return array( + 'ok' => false, + 'error' => 'target_count too large (max 1,000,000)', + ); + } + if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) { + return array( + 'ok' => false, + 'error' => 'invalid mode', + ); + } + $batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) ); + + $current = self::get_state(); + if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) { + return array( + 'ok' => false, + 'error' => 'already_running', + 'state' => $current, + ); + } + + $state = array( + 'job_id' => uniqid( 'stress_', true ), + 'status' => 'running', + 'mode' => $mode, + 'target' => $target_count, + 'batch_size' => $batch_size, + 'started_at' => time(), + 'processed' => 0, + 'last_user_id' => 0, + 'batches_done' => 0, + 'batches_log' => array(), + 'errors' => array(), + 'peak_memory' => 0, + 'completed_at' => null, + 'benchmark' => null, + ); + update_option( self::OPT_STATE, $state, false ); + + // 清掉前次留下的 cancellation flag(避免新測試一啟動就被誤判為已取消) + delete_transient( self::CANCEL_FLAG ); + + wp_clear_scheduled_hook( self::CRON_HOOK ); + wp_schedule_single_event( time(), self::CRON_HOOK ); + + // 注意:不在這裡同步執行 run_batch()。 + // 若 batch_size 大或 mode=realistic,run_batch() 可能跑數十秒到數分鐘, + // PHP-FPM / nginx 會在 60s 時 504 Gateway Timeout。 + // 第一個 batch 由前端 polling 進來時的 pump_if_due() 推進,每個 batch 有 wall-clock 限制。 + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * 取消執行中的測試。 + * + * 設 cancellation transient flag,in-flight 的 run_batch() 與 run_batch_realistic() + * 會在每個 user 迭代之前檢查並提早 break;run_batch() 結尾的 update_option 會 + * 重讀 state 確認 status 仍是 'running' 才覆寫,避免 race condition 把 cancelled + * 重新覆蓋為 running。 + */ + public static function cancel(): array { + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'ok' => true, + 'message' => 'no_active_job', + ); + } + + // 設 flag 給 in-flight batch 看到,提早 break + set_transient( self::CANCEL_FLAG, 1, 600 ); + + wp_clear_scheduled_hook( self::CRON_HOOK ); + + // 重讀 state(避免覆寫 in-flight batch 已寫入的 progress) + $state = self::get_state(); + $state['status'] = 'cancelled'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + return array( + 'ok' => true, + 'state' => $state, + ); + } + + /** + * 取得目前進度狀態。 + */ + public static function get_state(): array { + $state = get_option( self::OPT_STATE, array() ); + return is_array( $state ) ? $state : array(); + } + + /** + * 取得進度(含計算的速率與 ETA)。 + * + * 副作用:若狀態為 running 且 wp-cron 沒按時觸發(dev 環境常見),主動同步推進一個 batch, + * 確保 admin polling 看得到進度,不依賴外部 cron worker。 + * + * @param bool $pump 是否在偵測到延誤時主動推進。預設 true,REST status endpoint 用。 + */ + public static function get_progress( bool $pump = true ): array { + if ( $pump ) { + self::pump_if_due(); + } + + $state = self::get_state(); + if ( empty( $state ) ) { + return array( + 'status' => 'idle', + 'processed' => 0, + 'target' => 0, + 'pct' => 0, + ); + } + + $processed = (int) ( $state['processed'] ?? 0 ); + $target = (int) ( $state['target'] ?? 0 ); + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? 0 ); + + $now = $ended > 0 ? $ended : time(); + $elapsed = max( 1, $now - $started ); + $rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0; + $eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0; + $pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0; + + return array_merge( + $state, + array( + 'pct' => $pct, + 'rate_per_sec' => $rate, + 'elapsed_sec' => $elapsed, + 'eta_sec' => $eta_sec, + 'test_user_count' => self::count_test_users(), + ) + ); + } + + /** + * 檢查是否需要主動推進一個 batch。 + * + * 觸發條件: + * - state.status === 'running' + * - 距離上次 batch 已超過 N 秒(避免 polling 太密集連續跑) + * - 沒有其他 request 在執行(用 transient lock 防併發) + * + * 之所以這樣設計:dev / 低流量環境的 wp-cron 可能不會準時跑(CLI 沒 web request、 + * spawn_cron 是 async fire-and-forget)。讓 polling 自帶推進可確保進度條會動。 + */ + public static function pump_if_due(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + // 至少間隔 1 秒推一次,避免極端密集 polling 把 DB 壓垮 + $last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 ); + if ( time() - $last_pushed_at < 1 ) { + return; + } + + // Transient lock 防多個 polling request 同時執行(30s TTL,確保即使崩潰也會自動釋放) + $lock_key = 'wpdo_stress_pump_lock'; + if ( false !== get_transient( $lock_key ) ) { + return; + } + set_transient( $lock_key, 1, 30 ); + + // 確保 PHP 有足夠時間跑滿 batch deadline;不超過 nginx 60s timeout + if ( function_exists( 'set_time_limit' ) ) { + @set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); + } + + try { + self::run_batch(); + } finally { + delete_transient( $lock_key ); + } + } + + /** + * Cron 觸發點:執行一個批次。完成則啟動 benchmark;未完成 reschedule。 + */ + public static function run_batch(): void { + $state = self::get_state(); + if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) { + return; + } + + // 開頭就檢查 cancellation flag — 即使 cron 已排到,看到 flag 立即放棄 + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + return; + } + + $target = (int) $state['target']; + $processed = (int) $state['processed']; + $batch_size = (int) $state['batch_size']; + $mode = (string) $state['mode']; + $remaining = $target - $processed; + if ( $remaining <= 0 ) { + self::finalize( $state ); + return; + } + $this_batch_size = min( $batch_size, $remaining ); + + $batch_started = microtime( true ); + try { + if ( self::MODE_FAST === $mode ) { + $inserted = self::run_batch_fast( $this_batch_size ); + } else { + $inserted = self::run_batch_realistic( $this_batch_size ); + } + } catch ( \Throwable $e ) { + $state['errors'][] = array( + 'time' => time(), + 'message' => $e->getMessage(), + ); + $state['status'] = 'failed'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::error( 'stress_test_batch_failed', array( 'message' => $e->getMessage() ) ); + } + return; + } + $batch_elapsed = microtime( true ) - $batch_started; + + // 重讀 state — 中間可能被 cancel() 改成 'cancelled',不能用本地快照覆寫 + $latest = self::get_state(); + if ( empty( $latest ) ) { + return; // state 已被 cleanup() 刪光,不再寫入 + } + $is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG ); + + // 累加 progress 到「最新」state(保留 cancel 寫入的 status) + $latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted; + $latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1; + $latest['batches_log'][] = array( + 'n' => $inserted, + 'duration_ms' => (int) round( $batch_elapsed * 1000 ), + ); + if ( count( $latest['batches_log'] ) > 200 ) { + $latest['batches_log'] = array_slice( $latest['batches_log'], -200 ); + } + $latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) ); + $latest['last_pushed_at'] = time(); + + // 若已被 cancel:保留 'cancelled' 狀態,僅累加 progress 給 UI 顯示「實際已寫入幾筆」 + // 不重新排 cron、不 finalize benchmark + if ( $is_cancelled ) { + update_option( self::OPT_STATE, $latest, false ); + return; + } + + update_option( self::OPT_STATE, $latest, false ); + + if ( $latest['processed'] >= $target ) { + self::finalize( $latest ); + return; + } + + // 排程下一個批次 + wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK ); + } + + /** + * 清除所有 test_* 使用者及其 flat table 資料。 + * + * 也設 cancellation flag,確保 in-flight batch 看到立即中止,避免 cleanup 後 + * 又被新建一筆 user 進來。 + */ + public static function cleanup(): array { + global $wpdb; + + // 通知 in-flight batch 立即停(最壞情況等 ~3 秒讓當前 user 完成) + set_transient( self::CANCEL_FLAG, 1, 600 ); + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $user_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE %s", + $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%' + ) + ); + + if ( empty( $user_ids ) ) { + return array( + 'ok' => true, + 'deleted' => 0, + ); + } + + $count = count( $user_ids ); + $id_list = implode( ',', array_map( 'absint', $user_ids ) ); + + // 先刪 flat tables + $flat_tables = self::get_user_flat_tables(); + foreach ( $flat_tables as $tbl ) { + $wpdb->query( "DELETE FROM `{$tbl}` WHERE user_id IN ({$id_list})" ); + } + + // 刪 usermeta + users + $wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE user_id IN ({$id_list})" ); + $wpdb->query( "DELETE FROM {$wpdb->users} WHERE ID IN ({$id_list})" ); + + // 重置 state + cancel flag(後者確保下一次啟動不會被誤判為 cancelled) + delete_option( self::OPT_STATE ); + delete_transient( self::CANCEL_FLAG ); + + // 清空 cache(每個 user 各自清,clean_user_cache 不接受 array) + foreach ( $user_ids as $uid ) { + clean_user_cache( (int) $uid ); + } + + return array( + 'ok' => true, + 'deleted' => $count, + ); + } + + /** + * 取得目前 test_* 使用者數量。 + */ + public static function count_test_users(): int { + global $wpdb; + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->users} WHERE user_login LIKE %s", + $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%' + ) + ); + } + + // ───────────────────────────────────────────────────────── + // 批次寫入:Fast Mode + // ───────────────────────────────────────────────────────── + + private static function run_batch_fast( int $count ): int { + global $wpdb; + + // 一次計算密碼 hash(重複用同一個 hash,若密碼相同 phpass 會比對通過嗎? + // 注意:phpass 每次 hash_password 會產生不同 salt,所以這裡用同一 hash 對所有 user 是 OK 的 + // 因為 wp_check_password 只是用儲存的 hash 校驗輸入密碼,相同密碼+相同 hash 會通過 + static $cached_hash = null; + if ( null === $cached_hash ) { + $cached_hash = wp_hash_password( self::TEST_PASSWORD ); + } + + $registered_at = current_time( 'mysql', true ); + + // 先取得目前最大 test 序號(避免衝突) + $next_seq = self::next_test_user_seq(); + + // 準備 wp_users bulk INSERT + $user_rows = array(); + $user_data = array(); + for ( $i = 0; $i < $count; $i++ ) { + $seq = $next_seq + $i; + $login = self::TEST_USER_PREFIX . $seq; + $email = $login . '@' . self::TEST_EMAIL_DOMAIN; + $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ]; + $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ]; + $display = $first . ' ' . $last . ' #' . $seq; + + $user_rows[] = $wpdb->prepare( + '(%s,%s,%s,%s,%s,%s,%s,%d)', + $login, + $cached_hash, + $login, + $email, + '', + $registered_at, + $display, + 0 + ); + $user_data[ $seq ] = array( + 'login' => $login, + 'email' => $email, + 'first' => $first, + 'last' => $last, + 'display' => $display, + ); + } + + $sql = "INSERT INTO {$wpdb->users} + (user_login, user_pass, user_nicename, user_email, user_url, user_registered, display_name, user_status) + VALUES " . implode( ',', $user_rows ); + $wpdb->query( $sql ); + + // 取出剛 INSERT 的 user IDs(用 user_login 對應) + $placeholders = implode( ',', array_fill( 0, count( $user_data ), '%s' ) ); + $logins = array_map( fn( $d ) => $d['login'], $user_data ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ID, user_login FROM {$wpdb->users} WHERE user_login IN ({$placeholders})", + ...$logins + ), + ARRAY_A + ); + $login_to_id = array(); + foreach ( (array) $rows as $row ) { + $login_to_id[ $row['user_login'] ] = (int) $row['ID']; + } + + // 從 user_login 反查回 seq + $seq_to_id = array(); + foreach ( $user_data as $seq => $data ) { + if ( isset( $login_to_id[ $data['login'] ] ) ) { + $seq_to_id[ $seq ] = $login_to_id[ $data['login'] ]; + } + } + + if ( empty( $seq_to_id ) ) { + return 0; + } + + // usermeta:必填的 caps + nickname + first_name + last_name + self::insert_usermeta_bulk( $seq_to_id, $user_data ); + + // flat tables — existing v2.5.x groups + self::insert_flat_hot( $seq_to_id ); + self::insert_flat_membership( $seq_to_id ); + self::insert_flat_activity( $seq_to_id ); + self::insert_flat_profile( $seq_to_id, $user_data ); + self::insert_flat_sso( $seq_to_id ); + self::insert_flat_cold( $seq_to_id ); + self::insert_points_ledger( $seq_to_id ); + + // v2.7.0+ groups: legacy WP/WC/HP keys absorbed into entity bridge + self::insert_flat_core_profile( $seq_to_id, $user_data ); + self::insert_flat_social( $seq_to_id ); + self::insert_flat_commerce( $seq_to_id, $user_data ); + self::insert_flat_hp_user( $seq_to_id ); + + return count( $seq_to_id ); + } + + private static function next_test_user_seq(): int { + global $wpdb; + $max = $wpdb->get_var( + $wpdb->prepare( + "SELECT MAX(CAST(SUBSTRING(user_login, %d) AS UNSIGNED)) FROM {$wpdb->users} WHERE user_login LIKE %s", + strlen( self::TEST_USER_PREFIX ) + 1, + $wpdb->esc_like( self::TEST_USER_PREFIX ) . '%' + ) + ); + return ( null === $max ) ? 1 : ( (int) $max ) + 1; + } + + /** + * Bulk-insert the WP-core-required usermeta rows for a batch of test users. + * + * v2.8.3: drops `nickname` / `first_name` / `last_name` rows — those keys + * are managed by the v2.7.0 `core_profile` entity group and written directly + * into `wp_wpdo_user_core_profile` by `insert_flat_core_profile()`. Writing + * them to `wp_usermeta` here was a direct-SQL bypass of Hook Bus aeav_only + * short-circuit logic and inflated the wp_users:wp_usermeta ratio by 3 rows + * per test user (1:5 instead of the expected 1:2). Only `wp_capabilities` + * and `wp_user_level` are kept because WP core itself writes them on every + * `wp_insert_user()` and they are NOT registered as entity fields. + * + * @param array $seq_to_id Map seq → user_id from the bulk wp_users insert. + * @param array $user_data Per-seq generated user data (unused in v2.8.3). + */ + private static function insert_usermeta_bulk( array $seq_to_id, array $user_data ): void { + global $wpdb; + unset( $user_data ); // v2.8.3: name fields no longer written here. + $caps_value = serialize( array( 'subscriber' => true ) ); + + $rows = array(); + foreach ( $seq_to_id as $uid ) { + $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'capabilities', $caps_value ); + $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'user_level', '0' ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO {$wpdb->usermeta} (user_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_hot( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_hot"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $money = mt_rand( 0, 1000000 ) / 100; + $orders = mt_rand( 0, 50 ); + $last = time() - mt_rand( 0, 365 * DAY_IN_SECONDS ); + $rows[] = $wpdb->prepare( '(%d,%f,%d,%d)', $uid, $money, $orders, $last ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, _money_spent, _order_count, _last_order) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_membership( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_membership"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $level = self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ]; + $points = mt_rand( 0, 50000 ); + $expires = gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS ); + $activated = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS ); + $source = self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ]; + $label = ucfirst( $level ) . ' Tier'; + $rows[] = $wpdb->prepare( '(%d,%s,%d,%s,%s,%s,%s)', $uid, $level, $points, $expires, $activated, $source, $label ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, membership_level, points_balance, membership_expires_at, membership_activated_at, tier_source, custom_tier_label) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_activity( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_activity"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $login_count = mt_rand( 1, 500 ); + $last_active = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) ); + $last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) ); + $last_order = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 90 * DAY_IN_SECONDS ) ); + $sessions = mt_rand( 0, 200 ); + $flags = mt_rand( 0, 7 ); + $rows[] = $wpdb->prepare( '(%d,%d,%s,%s,%s,%d,%d)', $uid, $login_count, $last_active, $last_login, $last_order, $sessions, $flags ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, login_count, last_active_at, last_login_at, last_order_at, session_count, account_flags) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_profile( array $seq_to_id, array $user_data ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_profile"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $d = $user_data[ $seq ]; + $num_specs = ( $seq % 3 ) + 1; + $specs_array = array_slice( self::SPECIALTIES_POOL, $seq % 7, $num_specs ); + $specialties = wp_json_encode( $specs_array ); + $bio_url = 'https://example.com/' . $d['login']; + $avatar_url = 'https://example.com/avatars/' . $d['login'] . '.jpg'; + $display_custom = $d['display']; + $locale = self::LOCALES[ $seq % count( self::LOCALES ) ]; + $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s)', $uid, $specialties, $bio_url, $avatar_url, $display_custom, $locale ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, specialties, bio_url, avatar_url, display_name_custom, locale) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_sso( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_sso"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $hub_id = 'hub_' . wp_generate_password( 16, false ); + $picture = 'https://example.com/sso/' . $uid . '.jpg'; + $token_hash = hash( 'sha256', 'token_' . $uid ); + $refresh_enc = 'enc:v1:' . base64_encode( random_bytes( 32 ) ); + $expires = gmdate( 'Y-m-d H:i:s', time() + 3600 ); + $last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) ); + $count = mt_rand( 1, 100 ); + $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s,%s,%d)', $uid, $hub_id, $picture, $token_hash, $refresh_enc, $expires, $last_login, $count ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, hub_global_user_id, picture_url, last_id_token_hash, refresh_token_enc, token_expires_at, sso_last_login_at, sso_login_count) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_flat_cold( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_cold"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $picture = 'https://cdn.example.com/' . $uid . '.png'; + $tok = 'tok_legacy_' . wp_generate_password( 32, false ); + $ref = 'ref_legacy_' . wp_generate_password( 32, false ); + $rows[] = $wpdb->prepare( '(%d,%s,%s,%s)', $uid, $picture, $tok, $ref ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, _tmso_picture_url, _tmso_last_id_token, _tmso_refresh_token) VALUES " . implode( ',', $rows ) ); + } + } + + /** + * v2.7.0 core_profile — nickname/first_name/last_name/description. + * + * @since 2.8.2 + */ + private static function insert_flat_core_profile( array $seq_to_id, array $user_data ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_core_profile"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $d = $user_data[ $seq ]; + $nickname = $d['login']; + $first = $d['first']; + $last = $d['last']; + $description = self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq; + $rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s)', $uid, $nickname, $first, $last, $description ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, nickname, first_name, last_name, description) VALUES " . implode( ',', $rows ) ); + } + } + + /** + * v2.7.0 social — 15 social profile URLs (textarea per H2 fix). + * + * @since 2.8.2 + */ + private static function insert_flat_social( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_social"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $cols = array_merge( array( 'user_id' ), self::SOCIAL_KEYS ); + $col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) ); + + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $values = array( $uid ); + $formats = array( '%d' ); + foreach ( self::SOCIAL_KEYS as $i => $key ) { + // Populate ~3 of the 15 social URLs per user — realistic for vendor profiles. + if ( ( $seq + $i ) % 5 === 0 ) { + $values[] = sprintf( 'https://%s.example.com/%s', $key, $uid ); + $formats[] = '%s'; + } else { + $values[] = null; + $formats[] = '%s'; + } + } + $rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) ); + } + } + + /** + * v2.7.0 commerce — WooCommerce billing + shipping address fields. + * + * @since 2.8.2 + */ + private static function insert_flat_commerce( array $seq_to_id, array $user_data ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_commerce"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + + $all_keys = array_merge( self::BILLING_KEYS, self::SHIPPING_KEYS ); + $cols = array_merge( array( 'user_id' ), $all_keys ); + $col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) ); + + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $d = $user_data[ $seq ]; + $first = $d['first']; + $last = $d['last']; + $country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ]; + $state = self::STATE_POOL[ $seq % count( self::STATE_POOL ) ]; + $city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ]; + $company = 'Test Co. #' . $seq; + $addr1 = sprintf( '%d %s St.', ( $seq * 13 ) % 9999 + 1, $city ); + $addr2 = ( $seq % 3 === 0 ) ? sprintf( 'Apt %d', $seq % 100 ) : ''; + $post = sprintf( '%05d', ( $seq * 7 ) % 99999 ); + $email = $d['login'] . '@' . self::TEST_EMAIL_DOMAIN; + $phone = sprintf( '+886-%d-%07d', mt_rand( 2, 9 ), mt_rand( 1000000, 9999999 ) ); + + $values = array( $uid ); + $formats = array( '%d' ); + + // billing block + $billing_values = array( + 'billing_first_name' => $first, + 'billing_last_name' => $last, + 'billing_company' => $company, + 'billing_address_1' => $addr1, + 'billing_address_2' => $addr2, + 'billing_city' => $city, + 'billing_state' => $state, + 'billing_postcode' => $post, + 'billing_country' => $country, + 'billing_email' => $email, + 'billing_phone' => $phone, + ); + foreach ( self::BILLING_KEYS as $k ) { + $values[] = $billing_values[ $k ]; + $formats[] = '%s'; + } + + // shipping block — 60% of users have shipping = billing, rest different + $same_address = ( $seq % 5 ) < 3; + foreach ( self::SHIPPING_KEYS as $k ) { + $billing_equiv = str_replace( 'shipping_', 'billing_', $k ); + if ( isset( $billing_values[ $billing_equiv ] ) && $same_address ) { + $values[] = $billing_values[ $billing_equiv ]; + } else { + $values[] = $billing_values[ $billing_equiv ] ?? ''; + } + $formats[] = '%s'; + } + + $rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) ); + } + } + + /** + * v2.7.0 hp_user — HivePress favorites (json array) + avatar attachment. + * + * @since 2.8.2 + */ + private static function insert_flat_hp_user( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_hp_user"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $rows = array(); + foreach ( $seq_to_id as $seq => $uid ) { + $num_favs = $seq % 5; // 0..4 favorites + $favs = array(); + for ( $i = 0; $i < $num_favs; $i++ ) { + $favs[] = 1700 + ( ( $seq + $i ) % 60 ); // listing IDs 1700-1759 + } + $favs_json = wp_json_encode( $favs ); + $hp_image = ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : ''; + $rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $favs_json, $hp_image ); + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, hp_favorited_listings, hp_image) VALUES " . implode( ',', $rows ) ); + } + } + + private static function insert_points_ledger( array $seq_to_id ): void { + global $wpdb; + $tbl = "{$wpdb->prefix}wpdo_user_points_ledger"; + if ( ! self::table_exists( $tbl ) ) { + return; + } + $reasons = array( 'signup_bonus', 'order_reward', 'referral', 'promo', 'manual_adjust' ); + $rows = array(); + $created = current_time( 'mysql', true ); + foreach ( $seq_to_id as $seq => $uid ) { + $num_entries = ( $seq % 3 ) + 1; // 1-3 筆 + $balance = 0; + for ( $i = 0; $i < $num_entries; $i++ ) { + $delta = mt_rand( -200, 1000 ); + $balance += $delta; + $reason = $reasons[ ( $seq + $i ) % count( $reasons ) ]; + $rows[] = $wpdb->prepare( + '(%d,%d,%d,%s,%d,%s,%s)', + $uid, + $delta, + $balance, + $reason, + 0, + '', + $created + ); + } + } + if ( ! empty( $rows ) ) { + $wpdb->query( "INSERT INTO `{$tbl}` (user_id, delta, balance_after, reason, ref_id, ref_type, created_at) VALUES " . implode( ',', $rows ) ); + } + } + + // ───────────────────────────────────────────────────────── + // 批次寫入:Realistic Mode(走 wp_insert_user + update_user_meta) + // ───────────────────────────────────────────────────────── + + private static function run_batch_realistic( int $count ): int { + // Realistic mode:走 wp_insert_user + update_user_meta,每個 user 1-3 秒。 + // 加 wall-clock deadline,避免 PHP-FPM / nginx 504 Gateway Timeout(預設 60 秒)。 + $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC; + $next_seq = self::next_test_user_seq(); + $inserted = 0; + for ( $i = 0; $i < $count; $i++ ) { + if ( microtime( true ) > $deadline ) { + break; // 超時:把已完成的回傳,剩餘讓下一個 pump/cron 接手 + } + // 使用者點 cancel:每個 user 開始前檢查 — realistic mode 每 user ~3s, + // 最壞情況 cancel 後 3 秒內即可中止 + if ( false !== get_transient( self::CANCEL_FLAG ) ) { + break; + } + + $seq = $next_seq + $i; + $login = self::TEST_USER_PREFIX . $seq; + $email = $login . '@' . self::TEST_EMAIL_DOMAIN; + $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ]; + $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ]; + + $uid = wp_insert_user( + array( + 'user_login' => $login, + 'user_pass' => self::TEST_PASSWORD, + 'user_email' => $email, + 'first_name' => $first, + 'last_name' => $last, + 'display_name' => $first . ' ' . $last . ' #' . $seq, + 'role' => 'subscriber', + ) + ); + if ( is_wp_error( $uid ) ) { + continue; + } + ++$inserted; + + // 走 update_user_meta,讓 Hook Bus 自然攔截到 flat tables + $meta = self::generate_realistic_meta( $seq ); + foreach ( $meta as $key => $value ) { + update_user_meta( $uid, $key, $value ); + } + } + return $inserted; + } + + private static function generate_realistic_meta( int $seq ): array { + $first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ]; + $last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ]; + $login = self::TEST_USER_PREFIX . $seq; + $country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ]; + $city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ]; + $num_favs = $seq % 5; + $favs = array(); + for ( $i = 0; $i < $num_favs; $i++ ) { + $favs[] = 1700 + ( ( $seq + $i ) % 60 ); + } + + return array( + // hot + '_money_spent' => mt_rand( 0, 1000000 ) / 100, + '_order_count' => mt_rand( 0, 50 ), + '_last_order' => time() - mt_rand( 0, 365 * DAY_IN_SECONDS ), + // membership + 'membership_level' => self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ], + 'points_balance' => mt_rand( 0, 50000 ), + 'membership_expires_at' => gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS ), + 'membership_activated_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS ), + 'tier_source' => self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ], + // activity + 'login_count' => mt_rand( 1, 500 ), + 'last_active_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) ), + 'last_login_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) ), + 'session_count' => mt_rand( 0, 200 ), + // profile + 'specialties' => array_slice( self::SPECIALTIES_POOL, $seq % 7, ( $seq % 3 ) + 1 ), + 'bio_url' => 'https://example.com/test' . $seq, + 'locale' => self::LOCALES[ $seq % count( self::LOCALES ) ], + // sso + 'hub_global_user_id' => 'hub_' . wp_generate_password( 16, false ), + 'sso_login_count' => mt_rand( 1, 100 ), + // v2.7.0 core_profile (note: nickname/first_name/last_name are also + // set via wp_insert_user, but description is unique to this group) + 'description' => self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq, + // v2.7.0 social — populate 3 of 15 to mimic real vendor profile usage + 'facebook' => sprintf( 'https://facebook.com/%s', $login ), + 'instagram' => sprintf( 'https://instagram.com/%s', $login ), + 'youtube' => sprintf( 'https://youtube.com/@%s', $login ), + // v2.7.0 commerce (subset; 5 representative billing fields) + 'billing_first_name' => $first, + 'billing_last_name' => $last, + 'billing_email' => $login . '@' . self::TEST_EMAIL_DOMAIN, + 'billing_country' => $country, + 'billing_city' => $city, + // v2.7.0 hp_user + 'hp_favorited_listings' => $favs, + 'hp_image' => ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : '', + ); + } + + // ───────────────────────────────────────────────────────── + // Benchmark + // ───────────────────────────────────────────────────────── + + private static function finalize( array $state ): void { + // 清掉殘留的 cron event(避免完成後仍有過期 event) + wp_clear_scheduled_hook( self::CRON_HOOK ); + + $state['status'] = 'benchmarking'; + $state['completed_at'] = time(); + update_option( self::OPT_STATE, $state, false ); + + $report = self::run_benchmark( $state ); + + $state['benchmark'] = $report; + $state['status'] = 'completed'; + update_option( self::OPT_STATE, $state, false ); + } + + /** + * 執行 benchmark:寫入指標 + DB 容量 + 查詢效能。 + */ + public static function run_benchmark( ?array $state = null ): array { + global $wpdb; + $state = $state ?? self::get_state(); + + // 1. 寫入指標 + $write_metrics = self::compute_write_metrics( $state ); + + // 2. DB 容量 + $db_sizes = self::measure_db_sizes(); + + // 3. 查詢效能 + $query_perf = self::measure_query_performance(); + + return array( + 'generated_at' => time(), + 'write' => $write_metrics, + 'db_sizes' => $db_sizes, + 'query' => $query_perf, + ); + } + + private static function compute_write_metrics( array $state ): array { + $started = (int) ( $state['started_at'] ?? 0 ); + $ended = (int) ( $state['completed_at'] ?? time() ); + $processed = (int) ( $state['processed'] ?? 0 ); + $elapsed = max( 1, $ended - $started ); + $batches = $state['batches_log'] ?? array(); + + $durations = array_column( $batches, 'duration_ms' ); + $min_ms = ! empty( $durations ) ? min( $durations ) : 0; + $max_ms = ! empty( $durations ) ? max( $durations ) : 0; + $avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0; + + return array( + 'mode' => $state['mode'] ?? '', + 'target' => (int) ( $state['target'] ?? 0 ), + 'processed' => $processed, + 'elapsed_sec' => $elapsed, + 'rate_per_sec' => round( $processed / $elapsed, 2 ), + 'batches_done' => (int) ( $state['batches_done'] ?? 0 ), + 'batch_min_ms' => $min_ms, + 'batch_max_ms' => $max_ms, + 'batch_avg_ms' => $avg_ms, + 'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ), + ); + } + + private static function measure_db_sizes(): array { + global $wpdb; + $tables = array_merge( array( $wpdb->users, $wpdb->usermeta ), self::get_user_flat_tables() ); + $placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) ); + // MySQL only — SQLite 不支援 information_schema.TABLES,跳過 + if ( ! self::is_mysql() ) { + return array_map( + fn( $t ) => array( + 'table' => $t, + 'rows' => self::table_row_count( $t ), + 'size_mb' => null, + ), + $tables + ); + } + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", + ...$tables + ), + ARRAY_A + ); + + $out = array(); + foreach ( $rows as $r ) { + $out[] = array( + 'table' => $r['t'], + 'rows' => (int) $r['rows_count'], + 'data_mb' => round( $r['dl'] / 1048576, 2 ), + 'index_mb' => round( $r['il'] / 1048576, 2 ), + 'total_mb' => round( ( $r['dl'] + $r['il'] ) / 1048576, 2 ), + 'avg_bytes' => $r['rows_count'] > 0 ? (int) ( ( $r['dl'] + $r['il'] ) / $r['rows_count'] ) : 0, + ); + } + return $out; + } + + private static function measure_query_performance(): array { + global $wpdb; + + $sample_ids = $wpdb->get_col( + "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE '" . esc_sql( self::TEST_USER_PREFIX ) . "%' ORDER BY ID DESC LIMIT 100" + ); + $sample_ids = array_map( 'intval', $sample_ids ); + + if ( empty( $sample_ids ) ) { + return array( 'note' => 'no_test_users_found' ); + } + + $results = array(); + + // Q1: 讀單一 meta(透過 Hook Bus / get_user_meta 攔截路徑),走 flat table + $results['get_field_membership_level'] = self::time_calls( + function () use ( $sample_ids ) { + foreach ( $sample_ids as $id ) { + get_user_meta( $id, 'membership_level', true ); + } + }, + count( $sample_ids ) + ); + + // Q2: 讀多個 meta key(模擬整 entity 讀取,覆蓋多個 group) + $multi_keys = array( + 'membership_level', // membership group + 'points_balance', // membership + 'login_count', // activity + 'last_active_at', // activity + '_money_spent', // hot + 'specialties', // profile + 'locale', // profile + ); + $results['get_entity_full'] = self::time_calls( + function () use ( $sample_ids, $multi_keys ) { + foreach ( $sample_ids as $id ) { + foreach ( $multi_keys as $key ) { + get_user_meta( $id, $key, true ); + } + } + }, + count( $sample_ids ) + ); + + // Q3: 索引範圍查詢 — gold 等級且 points > 5000 的用戶總數 + $tbl = $wpdb->prefix . 'wpdo_user_membership'; + if ( self::table_exists( $tbl ) ) { + $results['range_gold_high_points'] = self::time_query( + "SELECT COUNT(*) FROM `{$tbl}` WHERE membership_level = 'gold' AND points_balance > 5000" + ); + } + + // Q4: 排序查詢 — last_active_at DESC LIMIT 100 + $tbl = $wpdb->prefix . 'wpdo_user_activity'; + if ( self::table_exists( $tbl ) ) { + $results['sort_recent_active_100'] = self::time_query( + "SELECT user_id, last_active_at FROM `{$tbl}` ORDER BY last_active_at DESC LIMIT 100" + ); + } + + // Q5: JOIN — top 100 most active gold members + $mtbl = $wpdb->prefix . 'wpdo_user_membership'; + $atbl = $wpdb->prefix . 'wpdo_user_activity'; + if ( self::table_exists( $mtbl ) && self::table_exists( $atbl ) ) { + $results['join_top_gold_active'] = self::time_query( + "SELECT m.user_id, m.points_balance, a.login_count + FROM `{$mtbl}` m + JOIN `{$atbl}` a ON a.user_id = m.user_id + WHERE m.membership_level = 'gold' + ORDER BY a.last_active_at DESC LIMIT 100" + ); + } + + // Q6 baseline:原生 EAV 等價查詢(usermeta 範圍查詢) + $results['eav_range_baseline'] = self::time_query( + "SELECT COUNT(*) FROM {$wpdb->usermeta} m1 + JOIN {$wpdb->usermeta} m2 ON m1.user_id = m2.user_id + WHERE m1.meta_key = 'membership_level' AND m1.meta_value = 'gold' + AND m2.meta_key = 'points_balance' AND CAST(m2.meta_value AS UNSIGNED) > 5000" + ); + + // v2.8.2 — exercise v2.7.0 groups too: + + // Q7: billing_email indexed lookup (commerce group, the only searchable + // commerce field; representative WC customer-search workload) + $ctbl = $wpdb->prefix . 'wpdo_user_commerce'; + if ( self::table_exists( $ctbl ) ) { + $sample_email = self::TEST_USER_PREFIX . ( $sample_ids[0] ?? 1 ) . '@' . self::TEST_EMAIL_DOMAIN; + $results['commerce_email_lookup'] = self::time_query( + $wpdb->prepare( + "SELECT user_id FROM `{$ctbl}` WHERE billing_email = %s LIMIT 1", + $sample_email + ) + ); + } + + // Q8: core_profile fulltext-ish search (display_name lookup pattern) + $cptbl = $wpdb->prefix . 'wpdo_user_core_profile'; + if ( self::table_exists( $cptbl ) ) { + $results['core_profile_first_name_scan'] = self::time_query( + "SELECT user_id, first_name FROM `{$cptbl}` WHERE first_name = 'Alice' LIMIT 100" + ); + } + + // Q9: hp_user JSON read (favorites count distribution — N+1 pattern through Hook Bus) + $hpttbl = $wpdb->prefix . 'wpdo_user_hp_user'; + if ( self::table_exists( $hpttbl ) ) { + $results['hp_favorites_full_scan'] = self::time_query( + "SELECT user_id, hp_favorited_listings FROM `{$hpttbl}` LIMIT 100" + ); + } + + // Q10: simulate a realistic "render a vendor profile" — read 1 user across + // ALL 9 groups via Hook Bus (=10 get_user_meta calls hitting flat tables) + if ( ! empty( $sample_ids ) ) { + $probe_keys = array( + 'membership_level', + 'points_balance', // membership + 'last_active_at', + 'login_count', // activity + 'specialties', + 'locale', // profile + 'hub_global_user_id', + 'sso_login_count', // sso + 'nickname', + 'first_name', + 'last_name', + 'description', // core_profile + 'facebook', + 'instagram', + 'youtube', // social + 'billing_email', + 'billing_country', + 'billing_first_name', // commerce + 'hp_favorited_listings', + 'hp_image', // hp_user + ); + $results['hook_bus_full_profile_render'] = self::time_calls( + function () use ( $sample_ids, $probe_keys ) { + $first_id = $sample_ids[0]; + foreach ( $probe_keys as $key ) { + get_user_meta( $first_id, $key, true ); + } + }, + count( $probe_keys ) + ); + } + + return $results; + } + + private static function time_calls( callable $cb, int $n ): array { + $start = microtime( true ); + $cb(); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( + 'n' => $n, + 'total_ms' => round( $elapsed_ms, 2 ), + 'avg_ms' => $n > 0 ? round( $elapsed_ms / $n, 3 ) : 0, + 'qps' => $elapsed_ms > 0 ? round( $n / ( $elapsed_ms / 1000 ), 1 ) : 0, + ); + } + + private static function time_query( string $sql ): array { + global $wpdb; + $start = microtime( true ); + $wpdb->get_results( $sql ); + $elapsed_ms = ( microtime( true ) - $start ) * 1000; + return array( + 'duration_ms' => round( $elapsed_ms, 2 ), + ); + } + + // ───────────────────────────────────────────────────────── + // 輔助 + // ───────────────────────────────────────────────────────── + + private static function get_user_flat_tables(): array { + global $wpdb; + return array_filter( + array_map( + fn( $name ) => $wpdb->prefix . 'wpdo_user_' . $name, + array( + // v2.5.x groups + 'hot', + 'cold', + 'membership', + 'activity', + 'profile', + 'sso', + 'points_ledger', + // v2.7.0 groups + 'core_profile', + 'social', + 'commerce', + 'hp_user', + // v2.8.4 group + 'admin_prefs', + ) + ), + array( __CLASS__, 'table_exists' ) + ); + } + + private static function table_exists( string $table ): bool { + global $wpdb; + static $cache = array(); + if ( isset( $cache[ $table ] ) ) { + return $cache[ $table ]; + } + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $cache[ $table ] = ( $found === $table ); + return $cache[ $table ]; + } + + private static function table_row_count( string $table ): int { + global $wpdb; + return self::table_exists( $table ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ) : 0; + } + + private static function is_mysql(): bool { + return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) ); + } +} diff --git a/includes/class-tmdo-v2-upgrader.php b/includes/class-tmdo-v2-upgrader.php new file mode 100644 index 0000000..09ffe99 --- /dev/null +++ b/includes/class-tmdo-v2-upgrader.php @@ -0,0 +1,301 @@ + bool. + * + * @param array $opts Optional overrides (mostly for tests). + * @return array + */ + public static function pre_flight_check( array $opts = array() ): array { + global $wpdb; + + $wp_version = $opts['wp_version'] ?? ( defined( 'ABSPATH' ) ? ( get_bloginfo( 'version' ) ?: '6.0' ) : '6.0' ); + $mysql_version = $opts['mysql_version'] ?? self::detect_mysql_version(); + $disk_path = $opts['disk_path'] ?? sys_get_temp_dir(); + + $checks = array( + 'php_version' => version_compare( PHP_VERSION, self::MIN_PHP_VERSION, '>=' ), + 'wp_version' => version_compare( $wp_version, self::MIN_WP_VERSION, '>=' ), + 'mysql_version' => version_compare( $mysql_version, self::MIN_MYSQL_VERSION, '>=' ), + 'free_disk_mb' => self::detect_free_disk_mb( $disk_path ) >= self::MIN_FREE_DISK_MB, + 'features_writable' => self::is_features_option_writable(), + 'no_active_migration' => self::no_active_migration(), + ); + + return $checks; + } + + /** + * Run the atomic upgrade. Returns true on success, false on failure. + * + * @return bool + * @throws \RuntimeException When UAE importer fails (caught internally and rolled back). + */ + public static function upgrade_to_v2(): bool { + // 1. Pre-flight check. + $checks = self::pre_flight_check(); + if ( in_array( false, $checks, true ) ) { + update_option( + 'wpdo_v2_upgrade_error', + 'Pre-flight check failed: ' . wp_json_encode( $checks ), + false + ); + update_option( 'wpdo_v2_upgrade_status', 'preflight_failed', false ); + return false; + } + + update_option( 'wpdo_v2_upgrade_started_at', time(), false ); + update_option( 'wpdo_v2_upgrade_status', 'in_progress', false ); + + $features_backup = get_option( 'wpdo_features', array() ); + update_option( 'wpdo_v2_features_backup', $features_backup, false ); + + try { + // 4. Install v2 tables. + TMDO_Installer::install_v2_tables(); + + // 5. UAE import (when applicable). + if ( self::detect_uae_data() ) { + $import_ok = self::run_uae_import(); + if ( ! $import_ok ) { + throw new \RuntimeException( 'UAE importer failed' ); + } + } + + // 6. Migrate feature flags to include entity modules. + self::migrate_feature_flags_v2(); + + // 7. Bump db_version. + update_option( 'wpdo_db_version', self::TARGET_VERSION, false ); + update_option( 'wpdo_v2_upgrade_status', 'complete', false ); + update_option( 'wpdo_v2_upgraded_at', time(), false ); + + // 8. Safely deactivate UAE plugin. + self::safely_deactivate_uae_plugin(); + + // 9. Schedule plugin directory removal. + self::schedule_uae_dir_removal(); + + // 10. Fire hook for downstream consumers. + do_action( 'wpdo_v2_upgraded', '1.3.38', self::TARGET_VERSION ); + + return true; + + } catch ( \Throwable $e ) { + // Rollback path: restore features option, leave new tables in place + // (idempotent retry on next attempt). + update_option( 'wpdo_features', $features_backup ); + update_option( 'wpdo_v2_upgrade_error', $e->getMessage(), false ); + update_option( 'wpdo_v2_upgrade_status', 'failed', false ); + return false; + } + } + + /** + * Roll back from a partially-completed v2 upgrade. + * + * @param bool $keep_data When true, retain wpdo_uni_* tables (for retry). + * @return bool + */ + public static function rollback_v2( bool $keep_data = true ): bool { + $backup = get_option( 'wpdo_v2_features_backup' ); + if ( false !== $backup ) { + update_option( 'wpdo_features', $backup ); + } + update_option( 'wpdo_db_version', '1.0.0', false ); + update_option( 'wpdo_v2_upgrade_status', 'rolled_back', false ); + + if ( ! $keep_data ) { + global $wpdb; + foreach ( array( 'wpdo_audit', 'wpdo_shadow_diffs', 'wpdo_site_metrics', 'wpdo_uni_options' ) as $t ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $t from hardcoded array, $wpdb->prefix sanitized by core. + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}{$t}`" ); + } + } + + return true; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** + * Detect whether wp_uae_* tables exist (S3 scenario). + */ + public static function detect_uae_data(): bool { + global $wpdb; + $count = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s', + $wpdb->prefix . 'uae_%' + ) + ); + return $count > 0; + } + + /** + * Run the UAE → WPDO data importer. Stub for v2.0.0 — full implementation + * lives in includes/migration/class-tmdo-uae-importer.php (PR-7 follow-up). + * + * @return bool + */ + public static function run_uae_import(): bool { + if ( class_exists( 'TMDO_UAE_Importer' ) ) { + return TMDO_UAE_Importer::run( + array( + 'auto' => true, + 'keep_source' => true, + ) + ); + } + // No UAE importer yet — return true so dev10 (no UAE data) progresses. + return true; + } + + /** + * Migrate the feature_flags option to include entity modules. + */ + public static function migrate_feature_flags_v2(): void { + $flags = get_option( 'wpdo_features', array() ); + if ( ! is_array( $flags ) ) { + $flags = array(); + } + foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) { + if ( ! isset( $flags[ $module ] ) ) { + $flags[ $module ] = 'idle'; + } + } + update_option( 'wpdo_features', $flags ); + } + + /** + * Detect installed MySQL / MariaDB version via @@version. + */ + public static function detect_mysql_version(): string { + global $wpdb; + try { + $v = (string) $wpdb->get_var( 'SELECT VERSION()' ); + // Strip trailing -MariaDB or similar tags for version_compare. + if ( preg_match( '/^([\d.]+)/', $v, $m ) ) { + return $m[1]; + } + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: fall through to safe default. + // Fall through to safe default. + } + return '5.7'; + } + + /** + * Detect free disk space in MB at the given path. + * + * @param string $path Filesystem path (typically sys_get_temp_dir()). + * @return int Free disk space in MB, or 0 when unreadable. + */ + public static function detect_free_disk_mb( string $path ): int { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- @ suppresses E_WARNING when path is on a stat-fail filesystem; explicit false-check follows. + $free = @disk_free_space( $path ); + if ( false === $free ) { + return 0; + } + return (int) ( $free / 1024 / 1024 ); + } + + /** + * Verify wpdo_features option is writable (autoload=no row exists or absent). + */ + private static function is_features_option_writable(): bool { + $probe_key = '_wpdo_v2_writability_probe_' . wp_generate_password( 12, false ); + $ok = update_option( $probe_key, time(), false ); + delete_option( $probe_key ); + return $ok; + } + + /** + * Verify no migration is currently in-flight (zero rows in 'in_progress' state). + */ + private static function no_active_migration(): bool { + global $wpdb; + try { + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_migrations` WHERE state = %s", + 'in_progress' + ) + ); + return 0 === $count; + } catch ( \Throwable $e ) { + // Table may not exist on truly fresh installs — treat as "no active migration". + return true; + } + } + + /** + * Quietly deactivate wp-universal-anti-eav plugin if it is currently active. + */ + private static function safely_deactivate_uae_plugin(): void { + if ( ! function_exists( 'deactivate_plugins' ) || ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( ! function_exists( 'is_plugin_active' ) ) { + return; + } + $slug = 'wp-universal-anti-eav/wp-universal-anti-eav.php'; + if ( is_plugin_active( $slug ) ) { + deactivate_plugins( $slug, true ); + } + } + + /** + * Schedule a one-shot cron event to remove the UAE plugin directory. + * + * Filesystem permission failures fall back to admin notice for manual removal. + */ + private static function schedule_uae_dir_removal(): void { + if ( ! function_exists( 'wp_schedule_single_event' ) ) { + return; + } + if ( ! wp_next_scheduled( 'wpdo_remove_uae_plugin_dir' ) ) { + wp_schedule_single_event( time() + 5, 'wpdo_remove_uae_plugin_dir' ); + } + } +} diff --git a/includes/class-tmdo-zone-classifier.php b/includes/class-tmdo-zone-classifier.php new file mode 100644 index 0000000..f6cbc32 --- /dev/null +++ b/includes/class-tmdo-zone-classifier.php @@ -0,0 +1,396 @@ +get_results( + $wpdb->prepare( + "SELECT pm.meta_key, COUNT(*) as row_count + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s + GROUP BY pm.meta_key + ORDER BY row_count DESC + LIMIT 200", + $post_type + ), + ARRAY_A + ); + + if ( empty( $keys ) ) { + return array(); + } + + $suggestions = array(); + $registry = TMDO_Schema_Registry::instance(); + + foreach ( $keys as $key_info ) { + $meta_key = $key_info['meta_key']; + $row_count = (int) $key_info['row_count']; + + // Skip WordPress internal keys. + if ( self::is_wp_internal( $meta_key ) ) { + continue; + } + + // Check if already registered. + $existing = $registry->get_field( $post_type, $meta_key ); + $already_assigned = $existing ? $existing['zone'] : null; + + // Collect signals. + $signals = self::collect_signals( $meta_key, $post_type, $row_count, $sample_size ); + + // Score each zone. + $scores = self::score_zones( $signals ); + + // Pick the best zone. + arsort( $scores ); + $best_zone = array_key_first( $scores ); + $confidence = $scores[ $best_zone ]; + + $suggestions[] = array( + 'meta_key' => $meta_key, + 'row_count' => $row_count, + 'suggested_zone' => $best_zone, + 'confidence' => round( $confidence, 2 ), + 'already_assigned' => $already_assigned, + 'scores' => $scores, + 'reasons' => self::build_reasons( $signals, $best_zone ), + ); + } + + // Sort by confidence descending. + usort( $suggestions, fn( $a, $b ) => $b['confidence'] <=> $a['confidence'] ); + + set_transient( $transient_key, $suggestions, HOUR_IN_SECONDS ); + + return $suggestions; + } + + /** + * Quick summary: count of meta_keys per suggested zone. + * + * @param string $post_type Post type to analyze. + * @return array{hot: int, warm: int, cold: int, archive: int, already_assigned: int} + */ + public static function summary( string $post_type ): array { + $suggestions = self::analyze( $post_type ); + $summary = array( + 'hot' => 0, + 'warm' => 0, + 'cold' => 0, + 'archive' => 0, + 'already_assigned' => 0, + ); + + foreach ( $suggestions as $s ) { + if ( $s['already_assigned'] ) { + ++$summary['already_assigned']; + } else { + ++$summary[ $s['suggested_zone'] ]; + } + } + + return $summary; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Collect analytical signals for a meta_key. + * + * @param string $meta_key Meta key to analyze. + * @param string $post_type Post type context. + * @param int $row_count Total rows for this meta key. + * @param int $sample_size Number of rows sampled. + * @return array Signal data for zone scoring. + */ + private static function collect_signals( string $meta_key, string $post_type, int $row_count, int $sample_size ): array { + global $wpdb; + + $signals = array( + 'meta_key' => $meta_key, + 'row_count' => $row_count, + 'avg_length' => 0, + 'max_length' => 0, + 'distinct_values' => 0, + 'numeric_ratio' => 0.0, + 'trash_ratio' => 0.0, + 'is_serialized' => false, + 'is_json' => false, + 'prefix' => '', + ); + + // Prefix detection. + if ( str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ) ) { + $signals['prefix'] = 'hivepress'; + } elseif ( str_starts_with( $meta_key, '_transient_' ) || str_starts_with( $meta_key, '_site_transient_' ) ) { + $signals['prefix'] = 'transient'; + } elseif ( str_starts_with( $meta_key, '_' ) ) { + $signals['prefix'] = 'internal'; + } + + // Sample values for analysis. + $samples = $wpdb->get_col( + $wpdb->prepare( + "SELECT pm.meta_value + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key = %s + LIMIT %d", + $post_type, + $meta_key, + $sample_size + ) + ); + + if ( ! empty( $samples ) ) { + $lengths = array_map( 'strlen', $samples ); + $signals['avg_length'] = (int) ( array_sum( $lengths ) / count( $lengths ) ); + $signals['max_length'] = max( $lengths ); + + $numeric_count = 0; + foreach ( $samples as $val ) { + if ( is_numeric( $val ) ) { + ++$numeric_count; + } + } + $signals['numeric_ratio'] = $numeric_count / count( $samples ); + + // Check serialized/JSON. + $first = $samples[0] ?? ''; + $signals['is_serialized'] = is_serialized( $first ); + $signals['is_json'] = ( str_starts_with( $first, '{' ) || str_starts_with( $first, '[' ) ) + && null !== json_decode( $first ); + } + + // Distinct value count. + $signals['distinct_values'] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT pm.meta_value) + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key = %s", + $post_type, + $meta_key + ) + ); + + // Trash ratio. + if ( $row_count > 0 ) { + $trash_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key = %s AND p.post_status = 'trash'", + $post_type, + $meta_key + ) + ); + $signals['trash_ratio'] = $trash_count / $row_count; + } + + return $signals; + } + + /** + * Score each zone based on collected signals. + * + * @param array $signals Signal data from collect_signals(). + * @return array{hot: float, warm: float, cold: float, archive: float} Zone scores. + */ + private static function score_zones( array $signals ): array { + $scores = array( + 'hot' => 0.0, + 'warm' => 0.0, + 'cold' => 0.0, + 'archive' => 0.0, + ); + + // --- Hot signals --- + // Short, numeric values are great for indexing. + if ( $signals['avg_length'] < 50 ) { + $scores['hot'] += 0.3; + } + if ( $signals['numeric_ratio'] > 0.8 ) { + $scores['hot'] += 0.3; + } + // Low cardinality = enum/flag = good for filtering. + if ( $signals['distinct_values'] > 0 && $signals['distinct_values'] <= 20 ) { + $scores['hot'] += 0.2; + } + // HivePress prefix = likely a search field. + if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] < 100 ) { + $scores['hot'] += 0.2; + } + + // --- Warm signals --- + // Transient prefix is a clear warm signal. + if ( 'transient' === $signals['prefix'] ) { + $scores['warm'] += 0.8; + } + // Internal prefix + short values. + if ( 'internal' === $signals['prefix'] && $signals['avg_length'] < 100 ) { + $scores['warm'] += 0.2; + } + + // --- Cold signals --- + // Long text/JSON blobs are cold candidates. + if ( $signals['avg_length'] > 200 ) { + $scores['cold'] += 0.4; + } + if ( $signals['is_json'] || $signals['is_serialized'] ) { + $scores['cold'] += 0.3; + } + // High cardinality + long values = display/profile data. + if ( $signals['distinct_values'] > 50 && $signals['avg_length'] > 100 ) { + $scores['cold'] += 0.2; + } + // HivePress prefix + long values = description field. + if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] > 100 ) { + $scores['cold'] += 0.2; + } + + // --- Archive signals --- + // High trash ratio = archive candidate. + if ( $signals['trash_ratio'] > 0.5 ) { + $scores['archive'] += 0.6; + } elseif ( $signals['trash_ratio'] > 0.2 ) { + $scores['archive'] += 0.3; + } + + // Normalize: ensure at least one zone has a score. + $max = max( $scores ); + if ( 0.0 === $max ) { + // Default to cold for unknown patterns. + $scores['cold'] = 0.1; + } + + return $scores; + } + + /** + * Build human-readable reasons for the zone suggestion. + * + * @param array $signals Signal data from collect_signals(). + * @param string $zone Suggested zone name. + * @return array Array of human-readable reason strings. + */ + private static function build_reasons( array $signals, string $zone ): array { + $reasons = array(); + + switch ( $zone ) { + case 'hot': + if ( $signals['avg_length'] < 50 ) { + $reasons[] = sprintf( 'Short values (avg %d chars) — efficient for indexing', $signals['avg_length'] ); + } + if ( $signals['numeric_ratio'] > 0.8 ) { + $reasons[] = sprintf( '%.0f%% numeric values — ideal for range queries', $signals['numeric_ratio'] * 100 ); + } + if ( $signals['distinct_values'] <= 20 ) { + $reasons[] = sprintf( 'Low cardinality (%d distinct values) — good for filtering', $signals['distinct_values'] ); + } + break; + + case 'warm': + if ( 'transient' === $signals['prefix'] ) { + $reasons[] = 'Transient prefix detected — ephemeral data with natural TTL'; + } + break; + + case 'cold': + if ( $signals['avg_length'] > 200 ) { + $reasons[] = sprintf( 'Long values (avg %d chars) — display/profile data', $signals['avg_length'] ); + } + if ( $signals['is_json'] ) { + $reasons[] = 'JSON structure detected — good for blob storage'; + } + if ( $signals['is_serialized'] ) { + $reasons[] = 'Serialized data — good for blob storage'; + } + break; + + case 'archive': + if ( $signals['trash_ratio'] > 0.2 ) { + $reasons[] = sprintf( '%.0f%% of entries belong to trashed posts', $signals['trash_ratio'] * 100 ); + } + break; + } + + if ( empty( $reasons ) ) { + $reasons[] = 'Default classification based on overall signal pattern'; + } + + return $reasons; + } + + /** + * Check if a meta_key is a WordPress internal key that should be skipped. + * + * @param string $meta_key Meta key to check. + * @return bool True if the key is a WordPress internal key. + */ + private static function is_wp_internal( string $meta_key ): bool { + $skip = array( + '_edit_lock', + '_edit_last', + '_wp_page_template', + '_wp_old_slug', + '_wp_trash_meta_time', + '_wp_trash_meta_status', + '_wp_desired_post_slug', + '_thumbnail_id', + '_encloseme', + '_pingme', + ); + + return in_array( $meta_key, $skip, true ); + } +} diff --git a/includes/diagnostic/class-tmdo-health-cron.php b/includes/diagnostic/class-tmdo-health-cron.php new file mode 100644 index 0000000..bed04f3 --- /dev/null +++ b/includes/diagnostic/class-tmdo-health-cron.php @@ -0,0 +1,288 @@ + $results, + 'critical_count' => $critical_count, + 'recommended_count' => $recommended_count, + 'conflicts' => $conflict, + 'shadow_diffs' => $shadow, + 'autoload_bytes' => $autoload, + 'ran_at' => gmdate( 'Y-m-d H:i:s' ), + 'duration_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ), + ); + + // 1. Persist last-run snapshot (autoload=no, lightweight). + update_option( self::OPTION_LAST_RUN, $summary, false ); + + // 2. Set / clear alert flag. + if ( $critical_count > 0 ) { + $first_critical = self::first_critical_test( $results ); + update_option( + self::OPTION_ALERT, + array( + 'level' => 'critical', + 'count' => $critical_count, + 'first' => $first_critical, + 'ran_at' => $summary['ran_at'], + ), + false + ); + } else { + delete_option( self::OPTION_ALERT ); + } + + // v2.5.0 M16: refresh module suggestions cache (autoload=no). + $module_suggestions_count = 0; + if ( class_exists( 'TMDO_Module_Detector' ) ) { + try { + $detected = TMDO_Module_Detector::detect_all( true ); + foreach ( $detected as $r ) { + if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) ) { + ++$module_suggestions_count; + } + } + } 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 + } + } + + // 3. Audit log entry. + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'health_check_daily', + array( + 'critical' => $critical_count, + 'recommended' => $recommended_count, + 'duration_ms' => $summary['duration_ms'], + 'autoload_kb' => (int) round( $autoload / 1024 ), + 'conflicts' => (int) ( $conflict['total'] ?? 0 ), + 'module_suggestions_count' => $module_suggestions_count, + ) + ); + } + + // 4. Fire action for downstream subscribers (v2.4.0 email notifier). + if ( $critical_count > 0 ) { + do_action( 'wpdo/health_alert_critical', $summary ); + } else { + do_action( 'wpdo/health_check_passed', $summary ); + } + + return array( + 'ok' => true, + 'summary' => $summary, + 'critical_count' => $critical_count, + 'recommended_count' => $recommended_count, + 'ts' => $summary['ran_at'], + ); + } + + /** + * Read most recent run (for SOP runbook + Doctor tab streak counter). + * + * @return array|null + */ + public static function get_last_run(): ?array { + $v = get_option( self::OPTION_LAST_RUN ); + return is_array( $v ) ? $v : null; + } + + /** + * Compute consecutive green days from audit log (best-effort for SOP UI). + * + * @return int + */ + public static function consecutive_green_days(): int { + $last = self::get_last_run(); + if ( null === $last ) { + return 0; + } + // If today's run is critical, streak = 0. + if ( ( $last['critical_count'] ?? 0 ) > 0 ) { + return 0; + } + // Otherwise approximate via TMDO_Logger — count distinct days with health_check_daily and 0 critical. + // Conservative best-effort: just check today's run is green = 1 day. + return 1; + } + + // ─── private helpers ──────────────────────────────────────────────── + + /** + * Run all 7 Site Health tests directly (without the WP Site Health UI loop). + * + * @return array Test slug → result array. + */ + private static function run_site_health_tests(): array { + $out = array(); + if ( ! class_exists( 'TMDO_Site_Health' ) ) { + return $out; + } + $tests = array( + 'wpdo_schema_drift' => 'check_schema_drift', + 'wpdo_error_budget' => 'check_error_budget', + 'wpdo_hook_conflicts' => 'check_hook_conflicts', + 'wpdo_autoload_bloat' => 'check_autoload_bloat', + 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', + 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', + 'wpdo_missing_snapshot' => 'check_missing_snapshot', + ); + foreach ( $tests as $slug => $cb ) { + try { + $result = call_user_func( array( 'TMDO_Site_Health', $cb ) ); + if ( is_array( $result ) ) { + $out[ $slug ] = array( + 'status' => (string) ( $result['status'] ?? 'good' ), + 'severity' => (string) ( $result['severity'] ?? 'good' ), + 'label' => (string) ( $result['label'] ?? $slug ), + 'description' => wp_strip_all_tags( (string) ( $result['description'] ?? '' ) ), + ); + } + } catch ( Throwable $e ) { + $out[ $slug ] = array( + 'status' => 'critical', + 'severity' => 'critical', + 'label' => $slug, + 'description' => 'test threw: ' . $e->getMessage(), + ); + } + } + return $out; + } + + /** + * Return a conflict summary from TMDO_Conflict_Monitor. + * + * @return array {total:int, hook_overlap:int, uaepg_overlap:int} + */ + private static function summarize_conflicts(): array { + if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) { + return array( + 'total' => 0, + 'hook_overlap' => 0, + 'uaepg_overlap' => 0, + ); + } + $summary = TMDO_Conflict_Monitor::get_summary(); + return array( + 'total' => (int) ( $summary['total'] ?? 0 ), + 'hook_overlap' => (int) ( $summary['hook_overlap'] ?? 0 ), + 'uaepg_overlap' => (int) ( $summary['uaepg_overlap'] ?? 0 ), + ); + } + + /** + * Per-module shadow_diffs ratio (only for modules currently in `verify`). + * Reads wp_wpdo_shadow_diffs and bucket-counts by entity_type. + * + * @return array + */ + private static function summarize_shadow_diffs(): array { + global $wpdb; + $out = array(); + $table = $wpdb->prefix . 'wpdo_shadow_diffs'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + return $out; + } + $rows = $wpdb->get_results( "SELECT entity_type, COUNT(*) AS cnt FROM `{$table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR) GROUP BY entity_type", ARRAY_A ); // phpcs:ignore WordPress.DB + if ( is_array( $rows ) ) { + foreach ( $rows as $r ) { + $out[ (string) $r['entity_type'] ] = array( + 'diffs_24h' => (int) $r['cnt'], + ); + } + } + return $out; + } + + /** + * Return the total byte size of autoloaded options. + * + * @return int Bytes in autoloaded options. + */ + private static function measure_autoload_size(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB + } + + /** + * Return the slug of the first critical test result, or null if none. + * + * @param array $results Site Health test results map. + * @return string|null Slug of first critical test, or null. + */ + private static function first_critical_test( array $results ): ?string { + foreach ( $results as $slug => $r ) { + if ( 'critical' === ( $r['status'] ?? '' ) ) { + return $slug; + } + } + return null; + } +} diff --git a/includes/diagnostic/class-tmdo-monthly-summary.php b/includes/diagnostic/class-tmdo-monthly-summary.php new file mode 100644 index 0000000..b54d9f4 --- /dev/null +++ b/includes/diagnostic/class-tmdo-monthly-summary.php @@ -0,0 +1,283 @@ + gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ), + 'period_end' => gmdate( 'Y-m-d H:i:s' ), + 'health' => self::aggregate_health(), + 'fsm_trajectory' => self::aggregate_fsm(), + 'snapshots' => self::aggregate_snapshots(), + 'zone_growth' => self::aggregate_zone_growth(), + 'autoload_size' => self::measure_autoload(), + 'duration_ms' => 0, + 'generated_at' => gmdate( 'Y-m-d H:i:s' ), + ); + $summary['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 ); + + update_option( self::OPTION_LATEST, $summary, false ); + + // v2.5.0 M14: archive into history (max 12 entries). + $history = (array) get_option( 'wpdo_monthly_summary_history', array() ); + array_unshift( $history, $summary ); + $history = array_slice( $history, 0, 12 ); + update_option( 'wpdo_monthly_summary_history', $history, false ); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'monthly_summary', + array( + 'health_success' => (int) ( $summary['health']['success'] ?? 0 ), + 'health_critical' => (int) ( $summary['health']['critical'] ?? 0 ), + 'fsm_promotions' => count( $summary['fsm_trajectory']['promotions'] ?? array() ), + 'fsm_rollbacks' => count( $summary['fsm_trajectory']['rollbacks'] ?? array() ), + 'snapshots_total' => (int) ( $summary['snapshots']['total'] ?? 0 ), + 'autoload_kb' => (int) round( ( $summary['autoload_size'] ?? 0 ) / 1024 ), + ) + ); + } + + do_action( 'wpdo/monthly_summary_generated', $summary ); + return $summary; + } + + /** + * Read the latest summary (from wp_options). + * + * @return array|null + */ + public static function get_latest(): ?array { + $v = get_option( self::OPTION_LATEST ); + return is_array( $v ) ? $v : null; + } + + // ─── private ──────────────────────────────────────────────────── + + /** + * Aggregate health-check audit log entries from last 30 days. + * + * @return array {success:int, recommended:int, critical:int, total:int} + */ + private static function aggregate_health(): array { + global $wpdb; + $out = array( + 'success' => 0, + 'recommended' => 0, + 'critical' => 0, + 'total' => 0, + ); + + // wp_wpdo_audit may not exist on early v1.x installs; check first. + $audit = $wpdb->prefix . 'wpdo_audit'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $audit + ) + ); + if ( 0 === $exists ) { + return $out; + } + // Health check audit rows have op='health_check_daily'. + $rows = (array) $wpdb->get_results( "SELECT * FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", ARRAY_A ); // phpcs:ignore WordPress.DB + foreach ( $rows as $r ) { + $ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : null; + if ( ! is_array( $ctx ) ) { + continue; + } + $crit = (int) ( $ctx['critical'] ?? 0 ); + $rec = (int) ( $ctx['recommended'] ?? 0 ); + ++$out['total']; + if ( $crit > 0 ) { + ++$out['critical']; + } elseif ( $rec > 0 ) { + ++$out['recommended']; + } else { + ++$out['success']; + } + } + return $out; + } + + /** + * Best-effort FSM trajectory: which modules changed state in the last 30 + * days. Reads `wpdo_fsm_state_entered` + current state. + * + * @return array {promotions:array, rollbacks:array, stationary:array} + */ + private static function aggregate_fsm(): array { + $out = array( + 'promotions' => array(), + 'rollbacks' => array(), + 'stationary' => array(), + ); + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return $out; + } + $entered = (array) get_option( 'wpdo_fsm_state_entered', array() ); + $now = time(); + foreach ( TMDO_Feature_Flags::all() as $module => $state ) { + $ts = isset( $entered[ $module ]['entered_at'] ) ? strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ) : 0; + $age_days = $ts > 0 ? (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) : null; + + if ( $ts > 0 && ( $now - $ts ) <= ( 30 * DAY_IN_SECONDS ) ) { + // Recently changed — bucket by direction. + if ( 'idle' === $state ) { + $out['rollbacks'][ $module ] = array( + 'state' => $state, + 'days_in_state' => $age_days, + ); + } else { + $out['promotions'][ $module ] = array( + 'state' => $state, + 'days_in_state' => $age_days, + ); + } + } else { + $out['stationary'][ $module ] = array( + 'state' => $state, + 'days_in_state' => $age_days, + ); + } + } + return $out; + } + + /** + * Aggregate snapshot retention stats from the snapshots table. + * + * @return array {total:int, oldest:string|null, newest:string|null, total_bytes:int} + */ + private static function aggregate_snapshots(): array { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_snapshots'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + return array( + 'total' => 0, + 'oldest' => null, + 'newest' => null, + 'total_bytes' => 0, + ); + } + $row = $wpdb->get_row( "SELECT COUNT(*) AS c, MIN(created_at) AS oldest, MAX(created_at) AS newest, COALESCE(SUM(size_bytes),0) AS bytes FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB + return array( + 'total' => (int) ( $row['c'] ?? 0 ), + 'oldest' => isset( $row['oldest'] ) ? (string) $row['oldest'] : null, + 'newest' => isset( $row['newest'] ) ? (string) $row['newest'] : null, + 'total_bytes' => (int) ( $row['bytes'] ?? 0 ), + ); + } + + /** + * Per-zone row count snapshot. Compares to previous month's value when + * available (delta_rows positive = growth). + * + * @return array + */ + private static function aggregate_zone_growth(): array { + // Use historical site_metrics when available — avoids live COUNT(*) on large tables + // and provides a 30-day delta (current − oldest snapshot). + if ( class_exists( 'TMDO_Site_Metrics_Collector' ) ) { + $latest = TMDO_Site_Metrics_Collector::get_latest_snapshot(); + if ( ! empty( $latest ) ) { + // Get oldest snapshot within 30 days for delta calculation. + $keys_of_interest = array( 'eav.postmeta_rows', 'flat.hot_rows', 'flat.cold_rows', 'flat.warm_rows', 'custom_tables.total_rows' ); + $out = array(); + foreach ( $keys_of_interest as $mk ) { + if ( ! isset( $latest[ $mk ] ) ) { + continue; + } + $history = TMDO_Site_Metrics_Collector::get_history( $mk, 30 ); + $oldest = ! empty( $history ) ? (int) $history[0]['value'] : null; + $current = (int) $latest[ $mk ]; + $out[ $mk ] = array( + 'rows' => $current, + 'delta_rows' => null !== $oldest ? $current - $oldest : null, + ); + } + return $out; + } + } + + // Fallback: live COUNT(*) for warm + archive tables (pre-v2.6.2 installs). + global $wpdb; + $out = array(); + $keys = array( 'wpdo_warm', 'wpdo_archive' ); + foreach ( $keys as $slug ) { + $table = $wpdb->prefix . $slug; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + continue; + } + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB + $out[ $slug ] = array( + 'rows' => $count, + 'delta_rows' => null, + ); + } + return $out; + } + + /** + * Return the total byte size of autoloaded options. + * + * @return int Bytes in autoloaded options. + */ + private static function measure_autoload(): int { + global $wpdb; + return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB + } +} diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php new file mode 100644 index 0000000..c11abfc --- /dev/null +++ b/includes/diagnostic/class-tmdo-site-health.php @@ -0,0 +1,491 @@ + 'check_schema_drift', + 'wpdo_error_budget' => 'check_error_budget', + 'wpdo_hook_conflicts' => 'check_hook_conflicts', + 'wpdo_autoload_bloat' => 'check_autoload_bloat', + 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', + 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', + 'wpdo_missing_snapshot' => 'check_missing_snapshot', + ); + + /** + * Hook into Site Health. + * + * @return void + */ + public static function register(): void { + add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) ); + } + + /** + * Register the 7 tests with WP Site Health. + * + * @param array $tests Existing tests. + * @return array + */ + public static function register_tests( array $tests ): array { + foreach ( self::TESTS as $key => $cb_suffix ) { + $tests['direct'][ $key ] = array( + 'label' => self::label_for( $key ), + 'test' => array( __CLASS__, $cb_suffix ), + ); + } + return $tests; + } + + /** + * Human-readable label for each test. + * + * @param string $key Test slug. + * @return string + */ + private static function label_for( string $key ): string { + $map = array( + 'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ), + 'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ), + 'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ), + 'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ), + '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' ), + ); + return $map[ $key ] ?? $key; + } + + // ─── Test 1: Schema drift ───────────────────────────────────────────── + + /** + * Verify all expected v2 tables exist. + * + * @return array Site Health result. + */ + public static function check_schema_drift(): array { + $result = self::cached( + 'wpdo_sh_schema_drift', + static function () { + if ( ! class_exists( 'TMDO_Installer' ) ) { + return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) ); + } + if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) { + return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) ); + } + $status = TMDO_Installer::v2_tables_status(); + $missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) ); + if ( empty( $missing ) ) { + return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) ); + } + return self::fail( + __( 'WPDO v2 tables missing', '2meet-data-optimizer' ), + sprintf( + /* translators: %s: comma-separated list of missing table names */ + __( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ), + implode( ', ', $missing ) + ), + 'critical' + ); + } + ); + return self::wrap( 'wpdo_schema_drift', $result ); + } + + // ─── Test 2: Error budget (last 7 days) ────────────────────────────── + + /** + * Count errors in wp_wpdo_errors over the last 7 days. + * + * @return array Site Health result. + */ + public static function check_error_budget(): array { + $result = self::cached( + 'wpdo_sh_error_budget', + static function () { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_errors'; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + return self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) ); + } + $threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 ); + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB + if ( $count <= $threshold ) { + return self::pass( + sprintf( + /* translators: %d: error count */ + __( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ), + $count + ) + ); + } + return self::fail( + __( 'WPDO error budget exceeded', '2meet-data-optimizer' ), + sprintf( + /* translators: 1: error count, 2: threshold */ + __( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ), + $count, + $threshold + ), + 'recommended' + ); + } + ); + return self::wrap( 'wpdo_error_budget', $result ); + } + + // ─── Test 3: Hook conflicts ────────────────────────────────────────── + + /** + * Check for interceptor or hook conflicts via TMDO_Conflict_Monitor. + * + * @return array Site Health result. + */ + public static function check_hook_conflicts(): array { + $result = self::cached( + 'wpdo_sh_hook_conflicts', + static function () { + if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) { + return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) ); + } + $summary = TMDO_Conflict_Monitor::get_summary(); + $total = (int) ( $summary['total'] ?? 0 ); + if ( 0 === $total ) { + return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) ); + } + return self::fail( + __( 'WPDO hook conflicts detected', '2meet-data-optimizer' ), + sprintf( + /* translators: %d: number of conflicts */ + __( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ), + $total + ), + 'recommended' + ); + } + ); + return self::wrap( 'wpdo_hook_conflicts', $result ); + } + + // ─── Test 4: Autoload bloat ────────────────────────────────────────── + + /** + * Check total autoloaded options size against a configurable threshold. + * + * @return array Site Health result. + */ + public static function check_autoload_bloat(): array { + $result = self::cached( + 'wpdo_sh_autoload_bloat', + static function () { + global $wpdb; + $threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 ); + $bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB + $mb = $bytes / 1024 / 1024; + if ( $mb < $threshold_mb ) { + return self::pass( + sprintf( + /* translators: 1: actual size in MB */ + __( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ), + $mb, + $threshold_mb + ) + ); + } + return self::fail( + __( 'Autoload size large', '2meet-data-optimizer' ), + sprintf( + /* translators: 1: actual size in MB, 2: threshold in MB */ + __( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ), + $mb, + $threshold_mb + ), + 'recommended' + ); + } + ); + return self::wrap( 'wpdo_autoload_bloat', $result ); + } + + // ─── Test 5: Postmeta explosion ────────────────────────────────────── + + /** + * Check whether wp_postmeta row count exceeds the explosion threshold. + * + * @return array Site Health result. + */ + public static function check_postmeta_explosion(): array { + $result = self::cached( + 'wpdo_sh_postmeta_explosion', + static function () { + global $wpdb; + $threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 ); + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB + if ( $count < $threshold ) { + return self::pass( + sprintf( + /* translators: %s: row count */ + __( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ), + number_format_i18n( $count ) + ) + ); + } + // Also check if any zone module is active. + $any_active = false; + if ( class_exists( 'TMDO_Feature_Flags' ) ) { + foreach ( TMDO_Feature_Flags::all() as $state ) { + if ( 'idle' !== $state ) { + $any_active = true; + break; + } + } + } + $severity = $any_active ? 'recommended' : 'critical'; + return self::fail( + __( 'wp_postmeta is large', '2meet-data-optimizer' ), + sprintf( + /* translators: %s: row count */ + __( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ), + number_format_i18n( $count ) + ), + $severity + ); + } + ); + return self::wrap( 'wpdo_postmeta_explosion', $result ); + } + + // ─── Test 6: Orphan zone rows ──────────────────────────────────────── + + /** + * Detect zone table rows that belong to modules currently in idle state. + * + * @return array Site Health result. + */ + public static function check_orphan_zone_rows(): array { + $result = self::cached( + 'wpdo_sh_orphan_zone', + static function () { + global $wpdb; + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) ); + } + $idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) ); + $orphans = array(); + foreach ( $idle_modules as $module ) { + // Best-effort: zone tables for hot_*/cold_* are dynamically named. + $candidates = array( + $wpdb->prefix . 'wpdo_warm', + $wpdb->prefix . 'wpdo_archive', + ); + foreach ( $candidates as $table ) { + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $table + ) + ); + if ( 0 === $exists ) { + continue; + } + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB + if ( $count > 0 ) { + $orphans[ $table ] = $count; + } + } + } + if ( empty( $orphans ) ) { + return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) ); + } + $lines = array(); + foreach ( $orphans as $t => $n ) { + $lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) ); + } + return self::fail( + __( 'Orphan zone rows detected', '2meet-data-optimizer' ), + sprintf( + /* translators: %s: list of tables and row counts */ + __( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ), + implode( ', ', $lines ) + ), + 'recommended' + ); + } + ); + return self::wrap( 'wpdo_orphan_zone_rows', $result ); + } + + // ─── Test 7: Missing snapshot ──────────────────────────────────────── + + /** + * Verify that a recent snapshot exists when modules are in risk states. + * + * @return array Site Health result. + */ + public static function check_missing_snapshot(): array { + $result = self::cached( + 'wpdo_sh_missing_snapshot', + static function () { + global $wpdb; + if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) { + return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) ); + } + // Risk only applies to modules in cutover/cleanup/complete (data is in custom tables). + $risk_modules = array_keys( + array_filter( + TMDO_Feature_Flags::all(), + static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true ) + ) + ); + if ( empty( $risk_modules ) ) { + return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) ); + } + $snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG; + $exists = (int) $wpdb->get_var( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $snap_table + ) + ); + if ( 0 === $exists ) { + return self::fail( + __( 'Snapshot table missing', '2meet-data-optimizer' ), + __( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ), + 'critical' + ); + } + $days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 ); + $recent = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input) + $days + ) + ); + if ( $recent > 0 ) { + return self::pass( + sprintf( + /* translators: 1: count, 2: days */ + __( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ), + $recent, + $days + ) + ); + } + return self::fail( + __( 'No recent WPDO snapshot', '2meet-data-optimizer' ), + sprintf( + /* translators: 1: comma-separated module names, 2: days */ + __( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ), + implode( ', ', $risk_modules ), + $days + ), + 'critical' + ); + } + ); + return self::wrap( 'wpdo_missing_snapshot', $result ); + } + + // ─── helpers ────────────────────────────────────────────────────────── + + /** + * Wrap a raw check result with required Site Health envelope fields. + * + * @param string $key Test slug. + * @param array $result Raw result with keys label, status, description, severity. + * @return array + */ + private static function wrap( string $key, array $result ): array { + $result['test'] = $key; + $result['badge'] = array( + 'label' => 'WPDO', + 'color' => 'blue', + ); + return $result; + } + + /** + * Cache a callable's return value via transient. + * + * @param string $key Transient key. + * @param callable $producer Callable returning result array. + * @return array + */ + private static function cached( string $key, callable $producer ): array { + $cached = get_transient( $key ); + if ( is_array( $cached ) ) { + return $cached; + } + $result = $producer(); + set_transient( $key, $result, self::CACHE_TTL ); + return $result; + } + + /** + * Build a "pass" result envelope. + * + * @param string $description Body text. + * @return array + */ + private static function pass( string $description ): array { + return array( + 'label' => __( 'WPDO check passed', '2meet-data-optimizer' ), + 'status' => 'good', + 'description' => '

' . esc_html( $description ) . '

', + 'severity' => 'good', + 'actions' => '', + ); + } + + /** + * Build a fail / warning result envelope. + * + * @param string $label Test heading. + * @param string $description Body. + * @param string $severity 'critical' | 'recommended'. + * @return array + */ + private static function fail( string $label, string $description, string $severity ): array { + return array( + 'label' => $label, + 'status' => 'critical' === $severity ? 'critical' : 'recommended', + 'description' => '

' . esc_html( $description ) . '

', + 'severity' => $severity, + 'actions' => '

' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '

', + ); + } +} diff --git a/includes/diagnostic/class-tmdo-site-metrics-collector.php b/includes/diagnostic/class-tmdo-site-metrics-collector.php new file mode 100644 index 0000000..f7fb0ae --- /dev/null +++ b/includes/diagnostic/class-tmdo-site-metrics-collector.php @@ -0,0 +1,236 @@ + Map of metric_key => metric_value collected. + */ + public static function collect( bool $dry_run = false ): array { + global $wpdb; + + $now = TMDO_DB::now(); + $metrics = array(); + + // ── EAV row counts ──────────────────────────────────────────────── + $eav_tables = array( + 'eav.postmeta_rows' => $wpdb->postmeta, + 'eav.usermeta_rows' => $wpdb->usermeta, + 'eav.termmeta_rows' => $wpdb->termmeta, + 'eav.commentmeta_rows' => $wpdb->commentmeta, + ); + foreach ( $eav_tables as $key => $table ) { + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB + $metrics[ $key ] = $count; + } + + // ── Flat-table row counts (hot + cold dynamic tables, warm) ─────── + $hot_rows = 0; + $cold_rows = 0; + + if ( class_exists( 'TMDO_Schema_Registry' ) ) { + $registry = TMDO_Schema_Registry::instance(); + foreach ( $registry->get_hot_post_types() as $pt ) { + $ht = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt ); + $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ht ) ); // phpcs:ignore WordPress.DB + if ( $exists ) { + $hot_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ht}`" ); // phpcs:ignore WordPress.DB + } + } + foreach ( $registry->get_cold_post_types() as $pt ) { + $ct = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $pt ); + $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ct ) ); // phpcs:ignore WordPress.DB + if ( $exists ) { + $cold_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ct}`" ); // phpcs:ignore WordPress.DB + } + } + } + + $warm_table = $wpdb->prefix . 'wpdo_warm'; + $metrics['flat.hot_rows'] = $hot_rows; + $metrics['flat.cold_rows'] = $cold_rows; + $metrics['flat.warm_rows'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$warm_table}`" ); // phpcs:ignore WordPress.DB + + // ── Custom table row counts ─────────────────────────────────────── + $custom_rows = 0; + $custom_count = 0; + if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { + foreach ( TMDO_Custom_Table_Registry::instance()->all() as $cfg ) { + $tbl = $wpdb->prefix . $cfg['table_name']; + $exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tbl ) ); // phpcs:ignore WordPress.DB + if ( $exists ) { + $custom_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$tbl}`" ); // phpcs:ignore WordPress.DB + ++$custom_count; + } + } + } + $metrics['custom_tables.total_rows'] = $custom_rows; + $metrics['custom_tables.table_count'] = $custom_count; + + // ── Error / shadow-diff activity (last 24 h) ───────────────────── + $errors_table = $wpdb->prefix . 'wpdo_errors'; + $metrics['errors.last_24h'] = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$errors_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" // phpcs:ignore WordPress.DB + ); + + $shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs'; + $shadow_exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $shadow_table ) ); // phpcs:ignore WordPress.DB + $metrics['shadow_diffs.last_24h'] = $shadow_exists + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$shadow_table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" ) // phpcs:ignore WordPress.DB + : 0; + + if ( $dry_run ) { + return $metrics; + } + + // ── Persist each metric to wpdo_site_metrics ───────────────────── + $dest = TMDO_DB::table( 'wpdo_site_metrics' ); + foreach ( $metrics as $key => $value ) { + $wpdb->insert( + $dest, + array( + 'collected_at' => $now, + 'metric_key' => $key, + 'metric_value' => $value, + 'context' => null, + ), + array( '%s', '%s', '%d', '%s' ) + ); + } + + // ── Prune old rows beyond retention window ──────────────────────── + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::RETENTION_DAYS . ' days' ) ); + $wpdb->query( $wpdb->prepare( "DELETE FROM {$dest} WHERE collected_at < %s", $cutoff ) ); // phpcs:ignore WordPress.DB + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'site_metrics_collected', + array( + 'metric_count' => count( $metrics ), + 'postmeta_rows' => $metrics['eav.postmeta_rows'] ?? 0, + 'hot_rows' => $metrics['flat.hot_rows'] ?? 0, + 'custom_rows' => $metrics['custom_tables.total_rows'] ?? 0, + ) + ); + } + + return $metrics; + } + + /** + * Get the most recent snapshot (latest collected_at timestamp). + * + * @return array metric_key => metric_value, or empty on miss. + */ + public static function get_latest_snapshot(): array { + global $wpdb; + $dest = TMDO_DB::table( 'wpdo_site_metrics' ); + + $latest_ts = $wpdb->get_var( "SELECT MAX(collected_at) FROM `{$dest}`" ); // phpcs:ignore WordPress.DB + if ( ! $latest_ts ) { + return array(); + } + + $rows = (array) $wpdb->get_results( + $wpdb->prepare( + "SELECT metric_key, metric_value FROM `{$dest}` WHERE collected_at = %s", // phpcs:ignore WordPress.DB + $latest_ts + ), + ARRAY_A + ); + + $out = array(); + foreach ( $rows as $r ) { + $out[ (string) $r['metric_key'] ] = (int) $r['metric_value']; + } + return $out; + } + + /** + * Get daily metric history for a single key over the past N days. + * + * @param string $metric_key Metric key (e.g. 'eav.postmeta_rows'). + * @param int $days Number of days of history to return (default 30). + * @return array Oldest-first. + */ + public static function get_history( string $metric_key, int $days = 30 ): array { + global $wpdb; + $dest = TMDO_DB::table( 'wpdo_site_metrics' ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $dest is a validated table name from TMDO_DB::table(). + $rows = (array) $wpdb->get_results( + $wpdb->prepare( + "SELECT collected_at, metric_value AS value + FROM `{$dest}` + WHERE metric_key = %s + AND collected_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY) + ORDER BY collected_at ASC", + $metric_key, + $days + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + return array_map( + static fn( $r ) => array( + 'collected_at' => (string) $r['collected_at'], + 'value' => (int) $r['value'], + ), + $rows + ); + } +} diff --git a/includes/engine/class-tmdo-audit-logger.php b/includes/engine/class-tmdo-audit-logger.php new file mode 100644 index 0000000..f589e7f --- /dev/null +++ b/includes/engine/class-tmdo-audit-logger.php @@ -0,0 +1,322 @@ +prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX; + } + + public static function init(): void { + add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 ); + add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 ); + } + + /** + * 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。 + * + * @param string $entity_type post|user|term|comment + * @param int $entity_id + * @param string $meta_key + * @param mixed $meta_value 寫入後的新值 + * @param mixed $result perform_upsert 回傳值 + * @param string $op 'add' | 'update' + * @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入) + */ + public static function on_write( + string $entity_type, + int $entity_id, + string $meta_key, + $meta_value, + $result, + string $op, + $before_value = null + ): void { + if ( $result === false ) { + return; // 寫入失敗不記 + } + + $field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key ); + if ( ! $field_def ) { + return; // 非 UAE 欄位 + } + + self::write_row( + array( + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'group_name' => (string) ( $field_def['group'] ?? '' ), + 'meta_key' => $meta_key, + 'action' => 'write', + 'op' => $op, // 'add' | 'update' + 'value_before' => self::stringify( $before_value ), + 'value_after' => self::stringify( $meta_value ), + ) + ); + } + + /** + * 刪除後訂閱。 + * + * @param string $entity_type + * @param int $entity_id + * @param string $meta_key + * @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值) + * @param mixed $result + * @param bool $delete_all + * @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+) + */ + public static function on_delete( + string $entity_type, + int $entity_id, + string $meta_key, + $meta_value, + $result, + bool $delete_all, + $before_value = null + ): void { + if ( $result === false ) { + return; + } + + $field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key ); + if ( ! $field_def ) { + return; + } + + self::write_row( + array( + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'group_name' => (string) ( $field_def['group'] ?? '' ), + 'meta_key' => $meta_key, + 'action' => 'delete', + 'op' => $delete_all ? 'delete_all' : 'delete', + 'value_before' => self::stringify( $before_value ), + 'value_after' => null, + ) + ); + } + + private static function write_row( array $row ): void { + global $wpdb; + + $source = apply_filters( + 'wpdo_audit_source', + defined( 'WP_CLI' ) && WP_CLI + ? self::SOURCE_CLI + : ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL ) + ); + + // wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷, + // 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。 + $wpdb->insert( + self::table_name(), + array_merge( + $row, + array( + 'ts' => current_time( 'mysql', true ), + 'user_id' => get_current_user_id(), + 'source' => (string) $source, + 'trace_id' => TMDO_Logger::trace_id(), + ) + ) + ); + + // 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim, + // 避免每次寫都查 count。 + if ( wp_rand( 1, 100 ) === 1 ) { + self::maybe_prune(); + } + } + + /** + * 依 MAX_ROWS 與 retention_days 裁剪 audit 表。 + * + * 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。 + * 回傳總共刪除的 row 數(供 CLI 呈現)。 + * + * @since 1.3.2 + */ + public static function maybe_prune(): int { + global $wpdb; + $table = self::table_name(); + $total = 0; + + $retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS ); + if ( $retention_days > 0 ) { + $cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS ); + $deleted = (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` WHERE ts < %s", + $cutoff + ) + ); + $total += $deleted; + + if ( $deleted > 0 ) { + TMDO_Logger::info( + 'audit_prune_expired', + array( + 'deleted' => $deleted, + 'cutoff' => $cutoff, + 'days' => $retention_days, + ) + ); + } + } + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + if ( $count > self::MAX_ROWS ) { + $to_delete = $count - self::MAX_ROWS; + $deleted = (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d", + $to_delete + ) + ); + $total += $deleted; + + TMDO_Logger::warning( + 'audit_prune_overflow', + array( + 'deleted' => $deleted, + 'count_before' => $count, + 'max_rows' => self::MAX_ROWS, + ) + ); + } + + return $total; + } + + private static function stringify( $value ): ?string { + if ( $value === null ) { + return null; + } + if ( is_scalar( $value ) ) { + return (string) $value; + } + return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + // ───────────────────────────────────────────────────────── + // Schema + // ───────────────────────────────────────────────────────── + + public static function install_table(): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $table = self::table_name(); + $charset = $wpdb->get_charset_collate(); + + // `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。 + // v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete), + // 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。 + $sql = "CREATE TABLE {$table} ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `ts` DATETIME NOT NULL, + `user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `entity_type` VARCHAR(20) NOT NULL, + `entity_id` BIGINT UNSIGNED NOT NULL, + `group_name` VARCHAR(64) NOT NULL, + `meta_key` VARCHAR(255) NOT NULL, + `action` VARCHAR(10) NOT NULL, + `op` VARCHAR(10) NOT NULL DEFAULT 'update', + `value_before` LONGTEXT NULL, + `value_after` LONGTEXT NULL, + `source` VARCHAR(20) NULL, + `trace_id` CHAR(36) NULL, + PRIMARY KEY (`id`), + KEY `idx_entity` (`entity_type`, `entity_id`), + KEY `idx_ts` (`ts`), + KEY `idx_user` (`user_id`, `ts`), + KEY `idx_op` (`op`, `ts`) + ) {$charset};"; + + dbDelta( $sql ); + } + + public static function drop_table(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() ); + } + + // ───────────────────────────────────────────────────────── + // Read API(供 admin / CLI 查詢) + // ───────────────────────────────────────────────────────── + + /** + * 取得最近 N 筆 audit 紀錄。 + * + * @return array> + */ + public static function recent( int $limit = 100, ?string $entity_type = null ): array { + global $wpdb; + $table = self::table_name(); + + if ( $entity_type ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d", + $entity_type, + $limit + ), + ARRAY_A + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", + $limit + ), + ARRAY_A + ); + } + + return is_array( $rows ) ? $rows : array(); + } + + public static function count(): int { + global $wpdb; + return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' ); + } +} diff --git a/includes/engine/class-tmdo-auto-promoter.php b/includes/engine/class-tmdo-auto-promoter.php new file mode 100644 index 0000000..c3a23e8 --- /dev/null +++ b/includes/engine/class-tmdo-auto-promoter.php @@ -0,0 +1,134 @@ + entity_type → 結果('promoted'|'not_ready'|'no_diff_yet'|'disabled_globally') + */ + public static function check_all(): array { + $results = array(); + + if ( ! (bool) get_option( self::OPT_ENABLED, false ) ) { + foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) { + $results[ $type ] = 'disabled_globally'; + } + return $results; + } + + $min_days = (int) get_option( self::OPT_MIN_DAYS, self::DEFAULT_MIN_DAYS ); + $entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() ); + + foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) { + $results[ $type ] = self::check_entity( $type, $min_days, $entered_all ); + } + + return $results; + } + + /** + * 評估單一 entity 是否可升級。 + * + * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed' + */ + public static function check_entity( string $type, int $min_days, array $entered_all ): string { + $current = TMDO_Mode_Manager::get( $type ); + if ( $current !== TMDO_Mode_Manager::MODE_SHADOW_READ ) { + return 'not_shadow_read'; + } + + $entered_at = isset( $entered_all[ $type ] ) ? (int) $entered_all[ $type ] : 0; + if ( $entered_at === 0 || ( time() - $entered_at ) < $min_days * DAY_IN_SECONDS ) { + return 'not_ready'; + } + + $diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity(); + $count = (int) ( $diff_counts[ $type ] ?? 0 ); + if ( $count > 0 ) { + return 'has_diff'; + } + + // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間) + $should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count ); + if ( ! $should ) { + return 'vetoed'; + } + + $result = TMDO_Mode_Manager::set( $type, TMDO_Mode_Manager::MODE_AEAV_ONLY ); + if ( is_wp_error( $result ) ) { + TMDO_Logger::error( + 'auto_promote_failed', + array( + 'entity_type' => $type, + 'error' => $result->get_error_message(), + ) + ); + return 'error'; + } + + TMDO_Logger::info( + 'auto_promote_success', + array( + 'entity_type' => $type, + 'entered_at' => gmdate( 'c', $entered_at ), + 'days_elapsed' => (int) ( ( time() - $entered_at ) / DAY_IN_SECONDS ), + 'min_days' => $min_days, + ) + ); + + do_action( 'wpdo_auto_promoted', $type, $entered_at, $min_days ); + + return 'promoted'; + } +} diff --git a/includes/engine/class-tmdo-cache-orchestrator.php b/includes/engine/class-tmdo-cache-orchestrator.php new file mode 100644 index 0000000..65da265 --- /dev/null +++ b/includes/engine/class-tmdo-cache-orchestrator.php @@ -0,0 +1,205 @@ + L1 記憶體快取 */ + private static array $l1_cache = array(); + + /** @var int L1 快取計數上限(防記憶體爆炸) */ + private const L1_MAX_ITEMS = 1000; + + /** @var int L2 TTL 秒數 */ + private const L2_TTL = HOUR_IN_SECONDS; + + public static function init(): void { + // 註冊 L1 清理(避免長命令列任務記憶體膨脹) + add_action( 'shutdown', array( self::class, 'clear_l1' ) ); + } + + /** + * 產生快取 key + */ + private static function make_key( string $type, int $id, string $group ): string { + return "{$type}:{$id}:{$group}"; + } + + // ───────────────────────────────────────────────────────── + // 讀取 + // ───────────────────────────────────────────────────────── + + public static function get_row( string $type, int $id, string $group ) { + $key = self::make_key( $type, $id, $group ); + + // L1 + if ( array_key_exists( $key, self::$l1_cache ) ) { + return self::$l1_cache[ $key ]; + } + + // L2 + $cached = wp_cache_get( $key, TMDO_CACHE_GROUP ); + if ( $cached !== false ) { + self::set_l1( $key, $cached ); + return $cached; + } + + return false; + } + + // ───────────────────────────────────────────────────────── + // 寫入 + // ───────────────────────────────────────────────────────── + + public static function set_row( string $type, int $id, string $group, array $data ): void { + $key = self::make_key( $type, $id, $group ); + + self::set_l1( $key, $data ); + wp_cache_set( $key, $data, TMDO_CACHE_GROUP, self::L2_TTL ); + } + + private static function set_l1( string $key, $data ): void { + // 防 L1 無限膨脹 + if ( count( self::$l1_cache ) >= self::L1_MAX_ITEMS ) { + // 簡易 FIFO:砍掉最舊的 20% + $keep = (int) ( self::L1_MAX_ITEMS * 0.8 ); + self::$l1_cache = array_slice( self::$l1_cache, - $keep, null, true ); + } + self::$l1_cache[ $key ] = $data; + } + + // ───────────────────────────────────────────────────────── + // 失效 + // ───────────────────────────────────────────────────────── + + public static function invalidate( string $type, int $id, string $group ): void { + $key = self::make_key( $type, $id, $group ); + unset( self::$l1_cache[ $key ] ); + wp_cache_delete( $key, TMDO_CACHE_GROUP ); + } + + public static function flush_entity( string $type, ?int $id = null ): void { + // 若沒給 id → flush 整個 entity type(L1 中所有該 type 的 key) + if ( $id === null ) { + $prefix = 'uae:' . $type . ':'; + foreach ( array_keys( self::$l1_cache ) as $key ) { + if ( strpos( $key, $prefix ) === 0 ) { + unset( self::$l1_cache[ $key ] ); + } + } + // Object cache 沒法做 prefix flush,用版本號 bump + wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP ); + return; + } + + $groups = TMDO_Entity_Registry::get_groups_for_type( $type ); + foreach ( $groups as $group ) { + self::invalidate( $type, $id, $group ); + } + } + + public static function flush_all(): void { + self::$l1_cache = array(); + // WP Object Cache 無跨群組 flush,改用版本號方式 + wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP ); + } + + public static function clear_l1(): void { + self::$l1_cache = array(); + } + + // ───────────────────────────────────────────────────────── + // 批次預熱(核心效能優化:解決清單頁 N+1) + // ───────────────────────────────────────────────────────── + + /** + * 批次預載一組 entity IDs 的資料至快取 + * 用於清單頁渲染前呼叫,避免每個 item 都去查 DB + * + * @param string $type 實體類型 + * @param array $ids entity ID 陣列 + * @param string $group 欄位群組 + */ + public static function warm_batch( string $type, array $ids, string $group ): void { + + if ( empty( $ids ) ) { + return; + } + + // 找出未快取的 IDs + $uncached_ids = array_filter( + $ids, + function ( $id ) use ( $type, $group ) { + return self::get_row( $type, (int) $id, $group ) === false; + } + ); + + if ( empty( $uncached_ids ) ) { + return; + } + + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return; + } + + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + $id_col = $adapter->get_entity_id_column(); + + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + return; + } + + $ids_in = implode( ',', array_map( 'intval', $uncached_ids ) ); + + $rows = $wpdb->get_results( + "SELECT * FROM `{$table}` WHERE `{$id_col}` IN ({$ids_in})", + ARRAY_A + ); + + if ( ! is_array( $rows ) ) { + return; + } + + // 建立 id → row 索引 + $indexed = array(); + foreach ( $rows as $row ) { + $indexed[ (int) $row[ $id_col ] ] = $row; + } + + // 填入快取(未找到者存空陣列避免重查) + foreach ( $uncached_ids as $id ) { + $id_int = (int) $id; + self::set_row( $type, $id_int, $group, $indexed[ $id_int ] ?? array() ); + } + } + + // ───────────────────────────────────────────────────────── + // 統計(供後台顯示) + // ───────────────────────────────────────────────────────── + + public static function get_l1_stats(): array { + return array( + 'count' => count( self::$l1_cache ), + 'max' => self::L1_MAX_ITEMS, + 'usage_pct' => self::L1_MAX_ITEMS > 0 + ? round( count( self::$l1_cache ) / self::L1_MAX_ITEMS * 100, 1 ) + : 0, + ); + } +} diff --git a/includes/engine/class-tmdo-conflict-detector.php b/includes/engine/class-tmdo-conflict-detector.php new file mode 100644 index 0000000..74a70ec --- /dev/null +++ b/includes/engine/class-tmdo-conflict-detector.php @@ -0,0 +1,148 @@ +|null + */ + private static ?array $cache = null; + + public static function init(): void { + // init:25 — register_default_fields 於 init:20 觸發 wpdo_register_fields, + // UAEPG 亦在 init:20 觸發 uaepg_register_fields;此時 priority 25 可確保雙邊皆完成登錄。 + add_action( 'init', array( self::class, 'scan' ), 25 ); + + if ( is_admin() ) { + add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) ); + } + } + + /** + * 掃描並 cache 衝突清單。 + * + * @return array + */ + public static function scan(): array { + if ( self::$cache !== null ) { + return self::$cache; + } + + if ( ! class_exists( 'UAEPG_Registry' ) ) { + return self::$cache = array(); + } + + $conflicts = array(); + + foreach ( TMDO_Entity_Registry::get_all_fields() as $entity_type => $fields_by_key ) { + foreach ( $fields_by_key as $meta_key => $wpdo_field ) { + if ( ! UAEPG_Registry::is_managed( $entity_type, $meta_key ) ) { + continue; + } + + $uaepg_field = UAEPG_Registry::get_field( $entity_type, $meta_key ); + + $conflicts[] = array( + 'entity_type' => $entity_type, + 'meta_key' => $meta_key, + 'wpdo_group' => $wpdo_field['group'] ?? '(unknown)', + 'uaepg_group' => $uaepg_field['group'] ?? '(unknown)', + ); + } + } + + if ( $conflicts ) { + TMDO_Logger::error( + 'field_registration_conflict', + array( + 'count' => count( $conflicts ), + 'fields' => $conflicts, + ) + ); + } + + return self::$cache = $conflicts; + } + + /** + * 取得衝突清單(若尚未掃描,觸發掃描)。 + */ + public static function get_conflicts(): array { + return self::$cache ?? self::scan(); + } + + /** + * 測試用:強制清除 cache,下次 scan() 會重跑。 + * + * @internal + */ + public static function reset_cache(): void { + self::$cache = null; + } + + /** + * 若有衝突則輸出管理後台紅色通知。 + */ + public static function maybe_render_admin_notice(): void { + $conflicts = self::get_conflicts(); + if ( ! $conflicts ) { + return; + } + + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } + + $count = count( $conflicts ); + $lines = array(); + foreach ( array_slice( $conflicts, 0, 5 ) as $c ) { + $lines[] = sprintf( + '%s / %s(UAE 群組「%s」 ↔ UAEPG 群組「%s」)', + esc_html( $c['entity_type'] ), + esc_html( $c['meta_key'] ), + esc_html( $c['wpdo_group'] ), + esc_html( $c['uaepg_group'] ) + ); + } + $extra = $count > 5 ? sprintf( __( '… 另有 %d 個欄位未顯示', 'uae' ), $count - 5 ) : ''; + + printf( + '

%s

  • %s
%s

%s wp uae conflicts

', + esc_html( + sprintf( + /* translators: %d: conflict count */ + __( 'UAE × UAEPG 欄位衝突偵測:發現 %d 個同 meta_key 被雙方 Registry 重複登錄 — UAEPG 會搶先短路,UAE 寫入將被靜默跳過', 'uae' ), + $count + ) + ), + // $lines elements were each individually esc_html()'d above (lines 121-124). + // implode joins escaped strings with literal HTML markers — safe. + implode( '
  • ', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above. + $extra ? '

    ' . esc_html( $extra ) . '

    ' : '', + esc_html__( '查看完整清單:', 'uae' ) + ); + } +} diff --git a/includes/engine/class-tmdo-entity-health.php b/includes/engine/class-tmdo-entity-health.php new file mode 100644 index 0000000..0a466cc --- /dev/null +++ b/includes/engine/class-tmdo-entity-health.php @@ -0,0 +1,329 @@ + + */ + public static function get_all(): array { + $result = array(); + foreach ( self::MANAGED_TYPES as $type ) { + $result[ $type ] = self::get_one( $type ); + } + return $result; + } + + /** + * 取得單一 entity 的健康快照。 + */ + public static function get_one( string $entity_type ): array { + $mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( $entity_type ) : 'disabled'; + $entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() ); + $entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0; + $mode_days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0; + + $groups = self::get_groups_health( $entity_type ); + + $shadow_diffs = 0; + if ( class_exists( 'TMDO_Shadow_Diff_Logger' ) ) { + $diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity(); + $shadow_diffs = (int) ( $diff_counts[ $entity_type ] ?? 0 ); + } + + $auto_promote = self::get_auto_promote_status( $entity_type, $mode, $entered_at, $shadow_diffs ); + $backfill_active = self::is_backfill_active( $entity_type ); + $native_counts = self::get_native_counts( $entity_type ); + + return array( + 'entity_type' => $entity_type, + 'mode' => $mode, + 'entered_at' => $entered_at, + 'mode_days' => $mode_days, + 'groups' => $groups, + 'shadow_diffs' => $shadow_diffs, + 'auto_promote' => $auto_promote, + 'backfill_active' => $backfill_active, + 'native_entity_count' => $native_counts['entity_count'], + 'native_meta_count' => $native_counts['meta_count'], + 'native_entity_table' => $native_counts['entity_table'], + 'native_meta_table' => $native_counts['meta_table'], + 'recommendation' => self::get_recommendation( $entity_type, $mode, $groups, $shadow_diffs ), + 'next_mode' => self::get_next_mode( $mode ), + 'prev_mode' => self::get_prev_mode( $mode ), + ); + } + + /** + * 取得 native EAV 表的總筆數(entity 本體 + meta)。 + */ + private static function get_native_counts( string $entity_type ): array { + global $wpdb; + + $adapter = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_adapter( $entity_type ) : null; + + if ( ! $adapter ) { + return array( + 'entity_table' => '', + 'meta_table' => '', + 'entity_count' => 0, + 'meta_count' => 0, + ); + } + + $meta_table = $adapter->get_native_meta_table(); + $entity_table = self::get_native_entity_table( $entity_type ); + + $entity_count = $entity_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$entity_table}`" ) : 0; + $meta_count = $meta_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$meta_table}`" ) : 0; + + return array( + 'entity_table' => $entity_table, + 'meta_table' => $meta_table, + 'entity_count' => $entity_count, + 'meta_count' => $meta_count, + ); + } + + /** + * 取得 entity 本體資料表名稱(非 meta 表)。 + */ + private static function get_native_entity_table( string $entity_type ): string { + global $wpdb; + $map = array( + 'user' => $wpdb->users, + 'term' => $wpdb->terms, + 'comment' => $wpdb->comments, + 'post' => $wpdb->posts, + ); + return $map[ $entity_type ] ?? ''; + } + + // ───────────────────────────────────────────────────────── + // 群組覆蓋率 + // ───────────────────────────────────────────────────────── + + private static function get_groups_health( string $entity_type ): array { + if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Schema_Manager' ) ) { + return array(); + } + + global $wpdb; + + $groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type ); + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + $result = array(); + + foreach ( $groups as $group_name ) { + $fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + $table_exists = TMDO_Schema_Manager::table_exists( $table ); + + $flat_rows = 0; + $eav_rows = 0; + + if ( $table_exists && $adapter ) { + $flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + + $managed_keys = array_column( $fields, 'key' ); + if ( ! empty( $managed_keys ) ) { + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $phs = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); + + $eav_rows = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$meta_table}` WHERE `meta_key` IN ({$phs})", + ...$managed_keys + ) + ); + } + } + + $coverage_pct = 0.0; + if ( $eav_rows > 0 ) { + $coverage_pct = round( min( $flat_rows / $eav_rows, 1.0 ) * 100, 1 ); + } elseif ( $flat_rows > 0 ) { + $coverage_pct = 100.0; + } + + $migration_info = self::get_migration_status( $entity_type, $group_name ); + + $result[] = array( + 'name' => $group_name, + 'fields_count' => count( $fields ), + 'table' => $table, + 'table_exists' => $table_exists, + 'flat_rows' => $flat_rows, + 'eav_rows' => $eav_rows, + 'coverage_pct' => $coverage_pct, + 'migration_status' => $migration_info['status'], + 'last_id' => $migration_info['last_id'], + 'total_migrated' => $migration_info['total_migrated'], + 'started_at' => $migration_info['started_at'], + 'completed_at' => $migration_info['completed_at'], + ); + } + + return $result; + } + + private static function get_migration_status( string $entity_type, string $group_name ): array { + global $wpdb; + $table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status'; + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT status, last_id, total_migrated, started_at, completed_at FROM `{$table}` WHERE entity_type = %s AND group_name = %s", + $entity_type, + $group_name + ), + ARRAY_A + ); + + return array( + 'status' => $row['status'] ?? 'not_started', + 'last_id' => (int) ( $row['last_id'] ?? 0 ), + 'total_migrated' => (int) ( $row['total_migrated'] ?? 0 ), + 'started_at' => $row['started_at'] ?? null, + 'completed_at' => $row['completed_at'] ?? null, + ); + } + + // ───────────────────────────────────────────────────────── + // Auto-promote 資格評估 + // ───────────────────────────────────────────────────────── + + private static function get_auto_promote_status( + string $entity_type, + string $mode, + int $entered_at, + int $shadow_diffs + ): array { + $enabled = (bool) get_option( TMDO_Auto_Promoter::OPT_ENABLED, false ); + $min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS ); + $days_elapsed = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0; + + if ( $mode !== TMDO_Mode_Manager::MODE_SHADOW_READ ) { + return array( + 'enabled' => $enabled, + 'min_days' => $min_days, + 'eligible' => false, + 'reason' => 'not_shadow_read', + ); + } + + if ( $days_elapsed < $min_days ) { + return array( + 'enabled' => $enabled, + 'min_days' => $min_days, + 'days_elapsed' => $days_elapsed, + 'eligible' => false, + 'reason' => "need_{$min_days}_days", + ); + } + + if ( $shadow_diffs > 0 ) { + return array( + 'enabled' => $enabled, + 'min_days' => $min_days, + 'days_elapsed' => $days_elapsed, + 'eligible' => false, + 'reason' => "has_{$shadow_diffs}_diffs", + ); + } + + return array( + 'enabled' => $enabled, + 'min_days' => $min_days, + 'days_elapsed' => $days_elapsed, + 'eligible' => true, + 'reason' => 'ready', + ); + } + + // ───────────────────────────────────────────────────────── + // 建議與模式轉換 + // ───────────────────────────────────────────────────────── + + private static function get_recommendation( + string $entity_type, + string $mode, + array $groups, + int $shadow_diffs + ): string { + switch ( $mode ) { + case TMDO_Mode_Manager::MODE_DISABLED: + return '啟用 Hook Bus 並切換到 dual_write,讓系統開始對 flat table 雙寫。'; + + case TMDO_Mode_Manager::MODE_DUAL_WRITE: + foreach ( $groups as $g ) { + if ( $g['coverage_pct'] < 99.0 ) { + return '執行 Backfill 遷移歷史資料,待所有群組覆蓋率達到 100% 後再升級到 shadow_read。'; + } + } + return '所有群組覆蓋率已達 100%,可以升級到 shadow_read 進行驗證期觀察。'; + + case TMDO_Mode_Manager::MODE_SHADOW_READ: + if ( $shadow_diffs > 0 ) { + return "發現 {$shadow_diffs} 筆 shadow diff,請先調查差異原因(Settings → Shadow Diffs)後再考慮升級。"; + } + $min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS ); + $entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() ); + $entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0; + $days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0; + if ( $days < $min_days ) { + return "繼續觀察中(已 {$days}/{$min_days} 天)。無 diff 且穩定後可升級到 aeav_only。"; + } + return '已達穩定期,可以升級到 aeav_only(最終生產模式)。'; + + case TMDO_Mode_Manager::MODE_AEAV_ONLY: + return '遷移完成。所有讀寫均走 flat table,效能最佳。'; + + default: + return ''; + } + } + + private static function get_next_mode( string $mode ): ?string { + $idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true ); + if ( $idx === false || $idx >= count( TMDO_Mode_Manager::ALL_MODES ) - 1 ) { + return null; + } + return TMDO_Mode_Manager::ALL_MODES[ $idx + 1 ]; + } + + private static function get_prev_mode( string $mode ): ?string { + $idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true ); + if ( $idx === false || $idx <= 0 ) { + return null; + } + return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ]; + } + + private static function is_backfill_active( string $entity_type ): bool { + return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) ); + } +} diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php new file mode 100644 index 0000000..c07c495 --- /dev/null +++ b/includes/engine/class-tmdo-entity-migration-engine.php @@ -0,0 +1,799 @@ + self::DEFAULT_BATCH_SIZE, + 'sleep_ms' => self::DEFAULT_SLEEP_MS, + 'resume' => true, + 'dry_run' => false, + ); + $options = wp_parse_args( $options, $defaults ); + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return self::error_result( "Adapter not found: {$entity_type}" ); + } + + $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + if ( empty( $group_fields ) ) { + return self::error_result( "Group not registered: {$entity_type}/{$group_name}" ); + } + + $managed_keys = array_column( $group_fields, 'key' ); + if ( empty( $managed_keys ) ) { + return self::error_result( 'No keys to migrate' ); + } + + $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + if ( ! TMDO_Schema_Manager::table_exists( $target_table ) && ! $options['dry_run'] ) { + return self::error_result( "Target table does not exist: {$target_table}" ); + } + + // 斷點 + $last_id = $options['resume'] + ? self::get_checkpoint( $entity_type, $group_name ) + : 0; + + // 標記開始 + if ( ! $options['dry_run'] ) { + self::mark_migration_started( $entity_type, $group_name ); + } + + $stats = array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'migrated' => 0, + 'errors' => 0, + 'skipped' => 0, + 'elapsed_sec' => 0, + 'dry_run' => $options['dry_run'], + ); + + $start_time = microtime( true ); + + global $wpdb; + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $field_map = array_column( $group_fields, null, 'key' ); + + // 建立 IN 子句佔位符 + $keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); + + do { + // Cursor-based 分頁 + $query = $wpdb->prepare( + "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` + WHERE `meta_key` IN ({$keys_placeholders}) + AND `{$id_col}` > %d + ORDER BY `{$id_col}` ASC + LIMIT %d", + ...array_merge( $managed_keys, array( $last_id, $options['batch_size'] ) ) + ); + + $entity_ids = $wpdb->get_col( $query ); + + if ( empty( $entity_ids ) ) { + break; + } + + foreach ( $entity_ids as $entity_id ) { + $entity_id = (int) $entity_id; + + try { + $migrated = self::migrate_single_entity( + $entity_type, + $group_name, + $entity_id, + $managed_keys, + $field_map, + $adapter, + $options['dry_run'] + ); + + if ( $migrated ) { + ++$stats['migrated']; + } else { + ++$stats['skipped']; + } + } catch ( \Throwable $e ) { + ++$stats['errors']; + error_log( + sprintf( + '[UAE Migration] Error %s/%s ID=%d: %s', + $entity_type, + $group_name, + $entity_id, + $e->getMessage() + ) + ); + } + + $last_id = $entity_id; + } + + // 更新斷點 + if ( ! $options['dry_run'] ) { + self::update_checkpoint( $entity_type, $group_name, $last_id, $stats['migrated'] ); + } + + // 釋放記憶體 + $wpdb->flush(); + + // 延遲(降低 DB 負載) + if ( $options['sleep_ms'] > 0 ) { + usleep( $options['sleep_ms'] * 1000 ); + } + } while ( count( $entity_ids ) === $options['batch_size'] ); + + // 標記完成 + if ( ! $options['dry_run'] ) { + self::mark_migration_completed( $entity_type, $group_name ); + } + + $stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 ); + + return $stats; + } + + /** + * 遷移單一 entity 的所有 meta + */ + private static function migrate_single_entity( + string $entity_type, + string $group_name, + int $entity_id, + array $managed_keys, + array $field_map, + TMDO_Entity_Adapter_Interface $adapter, + bool $dry_run + ): bool { + global $wpdb; + + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + + // 取出此 entity 所有相關的 meta + $placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); + + $metas = $wpdb->get_results( + $wpdb->prepare( + "SELECT `meta_key`, `meta_value` FROM `{$meta_table}` + WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})", + ...array_merge( array( $entity_id ), $managed_keys ) + ), + ARRAY_A + ); + + if ( empty( $metas ) ) { + return false; + } + + // 組裝 row 資料 + $data = array( $id_col => $entity_id ); + $formats = array( '%d' ); + + foreach ( $metas as $meta ) { + $key = $meta['meta_key']; + $value = $meta['meta_value']; + + if ( ! isset( $field_map[ $key ] ) ) { + continue; + } + + $field_def = $field_map[ $key ]; + + // 安全反序列化 — wp_usermeta 是使用者可寫表,攻擊者可植入序列化物件 + // 觸發 __wakeup/__destruct gadget chain。allowed_classes=false 阻擋。 + $value = self::safe_unserialize( $value ); + + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + + $data[ $col ] = TMDO_Type_Caster::to_db( $value, $field_def ); + $formats[] = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ); + } + + if ( count( $data ) <= 1 ) { + return false; + } + + if ( $dry_run ) { + return true; + } + + $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + + // REPLACE 作 Upsert + return $wpdb->replace( $target_table, $data, $formats ) !== false; + } + + // ───────────────────────────────────────────────────────── + // 單批次非同步遷移(WP Cron 用) + // ───────────────────────────────────────────────────────── + + /** + * 執行一個批次遷移並回傳進度(由 WP Cron 驅動,自動重排直到完成)。 + * + * @param string $entity_type + * @param string $group_name + * @param int $batch_size + * @return array{done:bool,migrated:int,last_id:int,total:int,status:string,error?:string} + */ + public static function migrate_group_batch( + string $entity_type, + string $group_name, + int $batch_size = self::DEFAULT_BATCH_SIZE + ): array { + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return array( + 'done' => true, + 'migrated' => 0, + 'last_id' => 0, + 'total' => 0, + 'status' => 'error', + 'error' => "Adapter not found: {$entity_type}", + ); + } + + $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + if ( empty( $group_fields ) ) { + return array( + 'done' => true, + 'migrated' => 0, + 'last_id' => 0, + 'total' => 0, + 'status' => 'error', + 'error' => "Group not registered: {$entity_type}/{$group_name}", + ); + } + + $managed_keys = array_column( $group_fields, 'key' ); + $target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + + if ( ! TMDO_Schema_Manager::table_exists( $target_table ) ) { + return array( + 'done' => true, + 'migrated' => 0, + 'last_id' => 0, + 'total' => 0, + 'status' => 'error', + 'error' => "Target table not found: {$target_table}", + ); + } + + global $wpdb; + $status_table = self::get_status_table(); + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT last_id, total_migrated FROM `{$status_table}` WHERE entity_type = %s AND group_name = %s", + $entity_type, + $group_name + ), + ARRAY_A + ); + + $last_id = (int) ( $row['last_id'] ?? 0 ); + $prev_total = (int) ( $row['total_migrated'] ?? 0 ); + + self::mark_migration_started( $entity_type, $group_name ); + + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $field_map = array_column( $group_fields, null, 'key' ); + $keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); + + $entity_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` + WHERE `meta_key` IN ({$keys_placeholders}) + AND `{$id_col}` > %d + ORDER BY `{$id_col}` ASC + LIMIT %d", + ...array_merge( $managed_keys, array( $last_id, $batch_size ) ) + ) + ); + + $batch_migrated = 0; + foreach ( $entity_ids as $entity_id ) { + $entity_id = (int) $entity_id; + try { + if ( self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, false ) ) { + ++$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() ) ); + } + $last_id = $entity_id; + } + + $total = $prev_total + $batch_migrated; + $done = count( $entity_ids ) < $batch_size; + + if ( $done ) { + self::mark_migration_completed( $entity_type, $group_name ); + } else { + self::update_checkpoint( $entity_type, $group_name, $last_id, $total ); + } + + $wpdb->flush(); + + return array( + 'done' => $done, + 'migrated' => $batch_migrated, + 'last_id' => $last_id, + 'total' => $total, + 'status' => $done ? 'completed' : 'running', + ); + } + + // ───────────────────────────────────────────────────────── + // 斷點管理 + // ───────────────────────────────────────────────────────── + + private static function get_status_table(): string { + global $wpdb; + return $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status'; + } + + public static function get_checkpoint( string $entity_type, string $group_name ): int { + global $wpdb; + $table = self::get_status_table(); + + $last_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT last_id FROM `{$table}` WHERE entity_type = %s AND group_name = %s", + $entity_type, + $group_name + ) + ); + + return (int) ( $last_id ?? 0 ); + } + + private static function update_checkpoint( string $entity_type, string $group_name, int $last_id, int $total ): void { + global $wpdb; + $table = self::get_status_table(); + + $wpdb->replace( + $table, + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + 'last_id' => $last_id, + 'total_migrated' => $total, + 'status' => 'running', + ), + array( '%s', '%s', '%d', '%d', '%s' ) + ); + } + + private static function mark_migration_started( string $entity_type, string $group_name ): void { + global $wpdb; + $table = self::get_status_table(); + + $existing = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM `{$table}` WHERE entity_type = %s AND group_name = %s", + $entity_type, + $group_name + ) + ); + + if ( $existing ) { + $wpdb->update( + $table, + array( + 'status' => 'running', + 'started_at' => current_time( 'mysql' ), + ), + array( 'id' => $existing ), + array( '%s', '%s' ), + array( '%d' ) + ); + } else { + $wpdb->insert( + $table, + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + 'status' => 'running', + 'started_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s' ) + ); + } + } + + private static function mark_migration_completed( string $entity_type, string $group_name ): void { + global $wpdb; + $table = self::get_status_table(); + + $wpdb->update( + $table, + array( + 'status' => 'completed', + 'completed_at' => current_time( 'mysql' ), + ), + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + ), + array( '%s', '%s' ), + array( '%s', '%s' ) + ); + } + + public static function reset_checkpoint( string $entity_type, string $group_name ): void { + global $wpdb; + $wpdb->delete( + self::get_status_table(), + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + ), + array( '%s', '%s' ) + ); + } + + // ───────────────────────────────────────────────────────── + // 驗證 + // ───────────────────────────────────────────────────────── + + /** + * 驗證遷移資料完整性(抽樣比對) + */ + public static function verify( + string $entity_type, + string $group_name, + int $sample_size = 100 + ): array { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return array( 'error' => 'Adapter not found' ); + } + + $wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + + if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) { + return array( 'error' => "UAE table not found: {$wpdo_table}" ); + } + + // 隨機抽樣 UAE 表中的 IDs + $sample_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT `{$id_col}` FROM `{$wpdo_table}` ORDER BY RAND() LIMIT %d", + $sample_size + ) + ); + + if ( empty( $sample_ids ) ) { + return array( + 'sampled' => 0, + 'match' => 0, + 'mismatch' => 0, + ); + } + + $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + $field_map = array_column( $group_fields, null, 'key' ); + $managed_keys = array_column( $group_fields, 'key' ); + + $match = 0; + $mismatch = 0; + $details = array(); + + foreach ( $sample_ids as $entity_id ) { + $entity_id = (int) $entity_id; + + // 取 UAE 資料 + $wpdo_row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` = %d", + $entity_id + ), + ARRAY_A + ); + + // 取原始 meta + $placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) ); + $raw_metas = $wpdb->get_results( + $wpdb->prepare( + "SELECT `meta_key`, `meta_value` FROM `{$meta_table}` + WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})", + ...array_merge( array( $entity_id ), $managed_keys ) + ), + ARRAY_A + ); + + $is_match = true; + + foreach ( $raw_metas as $raw ) { + $key = $raw['meta_key']; + if ( ! isset( $field_map[ $key ] ) ) { + continue; + } + + $raw_value = self::safe_unserialize( $raw['meta_value'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + $field_def = $field_map[ $key ]; + $wpdo_value = TMDO_Type_Caster::from_db( $wpdo_row[ $col ] ?? null, $field_def ); + + // v2.1.6: type-aware comparison. The previous `(string)` cast falsely + // flagged numeric-precision differences (`"5678.90" !== "5678.9"`) + // even though both are numerically equal. Aligned with + // TMDO_Shadow_Diff_Logger::values_equal() semantics. + if ( ! self::loose_equal( $raw_value, $wpdo_value, $field_def['type'] ?? 'text' ) ) { + $is_match = false; + $details[] = array( + 'entity_id' => $entity_id, + 'key' => $key, + 'raw' => $raw_value, + 'uae' => $wpdo_value, + ); + break; + } + } + + $is_match ? $match++ : $mismatch++; + } + + return array( + 'sampled' => count( $sample_ids ), + 'match' => $match, + 'mismatch' => $mismatch, + 'match_rate_pct' => count( $sample_ids ) > 0 + ? round( $match / count( $sample_ids ) * 100, 2 ) + : 0, + 'mismatch_details' => array_slice( $details, 0, 10 ), + ); + } + + // ───────────────────────────────────────────────────────── + // 回溯(UAE → wp_*meta 反向遷移) + // ───────────────────────────────────────────────────────── + + /** + * 反向遷移:將 UAE 表資料寫回 wp_*meta + * 用於解除安裝前的資料保留 + */ + public static function rollback_group( + string $entity_type, + string $group_name, + array $options = array() + ): array { + $defaults = array( + 'batch_size' => 500, + 'sleep_ms' => 50, + ); + $options = wp_parse_args( $options, $defaults ); + + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return self::error_result( "Adapter not found: {$entity_type}" ); + } + + $wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name ); + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + + $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + $field_map = array_column( $group_fields, null, 'key' ); + + $stats = array( + 'written' => 0, + 'errors' => 0, + ); + $last_id = 0; + + do { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d", + $last_id, + $options['batch_size'] + ), + ARRAY_A + ); + + if ( empty( $rows ) ) { + break; + } + + foreach ( $rows as $row ) { + $entity_id = (int) $row[ $id_col ]; + + foreach ( $field_map as $key => $field_def ) { + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + $value = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def ); + + if ( $value === null ) { + continue; + } + + // 直接寫 wp_*meta,繞過 UAE 攔截(因為我們正在反向遷移) + $wpdb->replace( + $meta_table, + array( + $id_col => $entity_id, + 'meta_key' => $key, + 'meta_value' => maybe_serialize( $value ), + ), + array( '%d', '%s', '%s' ) + ); + ++$stats['written']; + } + + $last_id = $entity_id; + } + + $wpdb->flush(); + usleep( $options['sleep_ms'] * 1000 ); + + } while ( count( $rows ) === $options['batch_size'] ); + + return $stats; + } + + private static function error_result( string $message ): array { + return array( + 'error' => $message, + 'migrated' => 0, + 'errors' => 1, + ); + } + + /** + * Object-safe replacement for `maybe_unserialize()`. + * + * `wp_usermeta` / `wp_postmeta` rows can contain attacker-planted serialized + * objects. `maybe_unserialize()` calls `unserialize()` with default options, + * which materializes objects and triggers `__wakeup`/`__destruct` gadgets. + * During backfill the migration engine runs in admin context, so any gadget + * chain in vendor/ becomes RCE. + * + * This wrapper passes `allowed_classes => false` so PHP returns + * `__PHP_Incomplete_Class` instances without ever invoking magic methods on + * the original class. We then convert those to null so they cannot leak + * into a flat-table column. + * + * @param mixed $value Raw meta_value from native EAV table. + * @return mixed Unserialized array/scalar, or original string if not serialized. + */ + private static function safe_unserialize( $value ) { + if ( ! is_string( $value ) ) { + return $value; + } + + $trimmed = trim( $value ); + + // Cheap inline detector — does not rely on WP's is_serialized() so this + // function works in CLI / standalone migration contexts. Mirrors the + // shape checks WP does: type-tag at offset 0, ':' at offset 1, plausible + // terminator. PHP serialize tokens are: a (array), O (object), + // s (string), i (int), d (float), b (bool), N; (null). + if ( 'N;' !== $trimmed ) { + if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) { + return $value; + } + if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b' ), true ) ) { + return $value; + } + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- explicit allowed_classes=false hardens against object injection. + $result = @unserialize( $trimmed, array( 'allowed_classes' => false ) ); + + if ( false === $result && 'b:0;' !== $trimmed ) { + return $value; + } + + // Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces + // any object marker with this stub). They must never reach a flat-table + // JSON column or a downstream consumer. + if ( is_object( $result ) ) { + return null; + } + if ( is_array( $result ) ) { + array_walk_recursive( + $result, + static function ( &$v ) { + if ( is_object( $v ) ) { + $v = null; + } + } + ); + } + + return $result; + } + + /** + * Type-aware loose equality check for verify() sample comparison. + * + * Mirrors TMDO_Shadow_Diff_Logger::values_equal() so the two divergence + * detection paths (cron verify + live shadow_compare) agree on what + * constitutes a real mismatch vs a representation difference. + * + * @param mixed $eav Native wp_*meta value (after maybe_unserialize). + * @param mixed $flat Value from the WPDO flat table (after type cast). + * @param string $type Field type from registry (text/integer/decimal/...). + * @return bool + */ + private static function loose_equal( $eav, $flat, string $type ): bool { + if ( $eav === $flat ) { + return true; + } + if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) { + return is_numeric( $eav ) && is_numeric( $flat ) && (float) $eav === (float) $flat; + } + if ( 'boolean' === $type ) { + return (bool) $eav === (bool) $flat; + } + if ( 'json' === $type || is_array( $eav ) || is_array( $flat ) ) { + return wp_json_encode( $eav ) === wp_json_encode( $flat ); + } + return (string) $eav === (string) $flat; + } + + /** + * 取得所有遷移狀態 + */ + public static function get_all_statuses(): array { + global $wpdb; + $table = self::get_status_table(); + + return $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY entity_type, group_name", ARRAY_A ) ?: array(); + } +} diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php new file mode 100644 index 0000000..94496da --- /dev/null +++ b/includes/engine/class-tmdo-entity-registry.php @@ -0,0 +1,226 @@ + 實體類型 → 適配器實例 */ + private static array $adapters = array(); + + /** @var array> 實體類型 → 群組名 → 欄位定義陣列 */ + private static array $groups = array(); + + /** @var array> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/ + private static array $field_index = array(); + + /** @var array 待建表的 Schema 清單 */ + private static array $pending_schemas = array(); + + /** 合法的欄位型別 */ + public const VALID_TYPES = array( + 'text', + 'textarea', + 'integer', + 'decimal', + 'boolean', + 'date', + 'datetime', + 'timestamp', + 'json', + 'enum', + 'binary', + ); + + /** 合法的實體類型 */ + public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' ); + + public static function init(): void { + self::$adapters = array(); + self::$groups = array(); + self::$field_index = array(); + self::$pending_schemas = array(); + } + + // ───────────────────────────────────────────────────────── + // 適配器管理 + // ───────────────────────────────────────────────────────── + + public static function register_adapter( string $entity_type, TMDO_Entity_Adapter_Interface $adapter ): void { + if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) { + return; + } + self::$adapters[ $entity_type ] = $adapter; + } + + public static function get_adapter( string $entity_type ): ?TMDO_Entity_Adapter_Interface { + return self::$adapters[ $entity_type ] ?? null; + } + + public static function get_all_adapters(): array { + return self::$adapters; + } + + // ───────────────────────────────────────────────────────── + // 欄位群組登錄 + // ───────────────────────────────────────────────────────── + + /** + * 登錄一組 UAE 管理的欄位 + * + * @param string $entity_type post|user|term|comment + * @param string $group_name 群組名稱(會成為表名的一部分) + * @param array $fields 欄位定義陣列,每項需有 key、type,選填: + * - required (bool) + * - default (mixed) + * - searchable (bool) 加索引 + * - fulltext (bool) 全文索引(僅 text/textarea) + * - unique (bool) 唯一索引 + * - options (array) enum 選項 + * - label (string) 顯示名稱 + * + * @return bool + */ + public static function register_group( string $entity_type, string $group_name, array $fields ): bool { + + if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) { + return false; + } + + if ( ! isset( self::$adapters[ $entity_type ] ) ) { + return false; + } + + // 防重複登錄 + if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) { + return false; + } + + // 驗證並正規化欄位定義 + $normalized = array(); + + foreach ( $fields as $field ) { + if ( empty( $field['key'] ) || empty( $field['type'] ) ) { + continue; + } + + if ( ! in_array( $field['type'], self::VALID_TYPES, true ) ) { + continue; + } + + $normalized_field = wp_parse_args( + $field, + array( + 'key' => '', + 'type' => 'text', + 'required' => false, + 'default' => null, + 'searchable' => false, + 'fulltext' => false, + 'unique' => false, + 'options' => array(), + 'label' => '', + ) + ); + + $normalized_field['group'] = $group_name; + $normalized_field['entity_type'] = $entity_type; + + $normalized[] = $normalized_field; + + // 建立索引以供快速查詢 + self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + } + + if ( empty( $normalized ) ) { + return false; + } + + self::$groups[ $entity_type ][ $group_name ] = $normalized; + + // 加入待建表佇列 + self::$pending_schemas[] = array( + 'type' => $entity_type, + 'group' => $group_name, + 'fields' => $normalized, + ); + + return true; + } + + // ───────────────────────────────────────────────────────── + // 查詢 API + // ───────────────────────────────────────────────────────── + + /** + * 依 meta_key 查詢欄位定義 + * + * @return array|null 欄位定義,或 null(非 UAE 管理欄位) + */ + public static function get_field( string $entity_type, string $meta_key ): ?array { + return self::$field_index[ $entity_type ][ $meta_key ] ?? null; + } + + /** + * 取得實體類型下所有群組名稱 + * + * @return array + */ + public static function get_groups_for_type( string $entity_type ): array { + return array_keys( self::$groups[ $entity_type ] ?? array() ); + } + + /** + * 取得特定群組的完整欄位定義 + */ + public static function get_group_fields( string $entity_type, string $group_name ): array { + return self::$groups[ $entity_type ][ $group_name ] ?? array(); + } + + /** + * 取得一個群組中所有 meta_key + * + * @return array + */ + public static function get_group_keys( string $entity_type, string $group_name ): array { + $fields = self::get_group_fields( $entity_type, $group_name ); + return array_column( $fields, 'key' ); + } + + /** + * 取得所有已登錄的欄位(跨實體、跨群組) + */ + public static function get_all_fields(): array { + return self::$field_index; + } + + /** + * 取得待建表清單(供 Schema_Manager 處理) + */ + public static function get_pending_schemas(): array { + return self::$pending_schemas; + } + + public static function clear_pending_schemas(): void { + self::$pending_schemas = array(); + } + + /** + * 判斷某個 meta_key 是否由 UAE 管理 + */ + public static function is_managed( string $entity_type, string $meta_key ): bool { + return isset( self::$field_index[ $entity_type ][ $meta_key ] ); + } +} diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php new file mode 100644 index 0000000..8638542 --- /dev/null +++ b/includes/engine/class-tmdo-hook-bus.php @@ -0,0 +1,658 @@ + $adapter ) { + self::register_hooks_for_type( $type, $adapter ); + } + } + + private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void { + + // ── 寫入攔截 ────────────────────────────────────────── + add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 ); + add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 ); + + // ── 讀取攔截 ────────────────────────────────────────── + add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 ); + + // ── 刪除攔截 ────────────────────────────────────────── + add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 ); + + // ── 實體刪除時自動清理 ─────────────────────────────── + add_action( + $adapter->get_delete_hook(), + function ( $entity_id ) use ( $type, $adapter ) { + self::cleanup_entity( $type, (int) $entity_id ); + }, + 10, + 1 + ); + } + + // ───────────────────────────────────────────────────────── + // 寫入:update_{type}_metadata filter + // ───────────────────────────────────────────────────────── + + /** + * 短路 WordPress 原生 update_metadata() 流程 + * + * @param null|bool $check 若回傳 null 則 WP 繼續原生流程 + * @param int $object_id + * @param string $meta_key + * @param mixed $meta_value + * @param mixed $prev_value + */ + 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 ( ! $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 + // aeav_only : 寫 flat,return true 短路 WP(不寫 EAV) + if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { + return $check; // disabled + } + + // Route decision (v1.2.0):讓 UAEPG 等外掛正式訂閱 routing,不必搶 priority 5。 + $route = self::decide_route( 'update', $type, (int) $object_id, $meta_key, $meta_value ); + if ( $route === 'pg' ) { + // 讓其他 listener(例如 UAEPG)接手;原生 EAV 也放行。 + return null; + } + if ( $route === 'skip' ) { + // 不寫 flat、不寫 EAV,但告訴 WP 已處理。 + return true; + } + + // 截取 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' ); + + $flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def ); + + 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 ( ! $field_def ) { + return $check; + } + + if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { + return $check; + } + + $route = self::decide_route( 'add', $type, (int) $object_id, $meta_key, $meta_value ); + if ( $route === 'pg' ) { + return null; + } + if ( $route === 'skip' ) { + return true; + } + + // 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' ); + + $flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def ); + + 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; + } + + /** + * 如 filter `wpdo_capture_before_value` 回 true 才讀 flat table 的 before value, + * 否則直接回 null — 讓沒在用 audit / 其他 listener 的站台省一次 DB read。 + * + * filter 參數:(bool $default_true, string $type, string $meta_key, string $op) + * $op ∈ { 'add', 'update', 'delete' } + * + * 使用範例(關閉 audit 的站台): + * add_filter( 'wpdo_capture_before_value', '__return_false' ); + * + * @since 1.3.2 + */ + private static function maybe_read_before_value( + string $type, + int $entity_id, + string $meta_key, + array $field_def, + string $op + ) { + $capture = apply_filters( + 'wpdo_capture_before_value', + true, + $type, + $meta_key, + $op + ); + + if ( ! $capture ) { + return null; + } + + return self::read_flat_value( $type, $entity_id, $meta_key, $field_def ); + } + + /** + * 讀取 flat table 中當前值(before value,供 audit / after_write listener 使用)。 + * + * 此方法**不經 cache 加熱**,直接查 DB,以避免快取污染與遞迴。表不存在回 null。 + * + * @since 1.3.1 + */ + private static function read_flat_value( + string $type, + int $entity_id, + string $meta_key, + array $field_def + ) { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + + $group = $field_def['group'] ?? ''; + if ( $group === '' ) { + return null; + } + + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + return null; + } + + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + $id_col = $adapter->get_entity_id_column(); + + $raw = $wpdb->get_var( + $wpdb->prepare( + "SELECT `{$col}` FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1", + $entity_id + ) + ); + + return $raw === null ? null : TMDO_Type_Caster::from_db( $raw, $field_def ); + } + + /** + * 讓外部 listener(例如 UAEPG)透過 `wpdo_route_decision` filter 指定路由。 + * + * 回傳值: + * 'flat' (預設) — UAE 寫入 MySQL flat table + * 'pg' — 放行,由其他 listener 接手;UAE 不寫 flat,原生 EAV 依 mode 決定 + * 'skip' — 都不寫(用於軟刪除之類特殊情境),但告訴 WP 已處理 + * + * 其他非預期值會被 fallback 到 'flat' 以維持安全預設。 + * + * @since 1.2.0 + */ + private static function decide_route( + string $op, + string $type, + int $object_id, + string $meta_key, + $meta_value + ): string { + $route = apply_filters( + 'wpdo_route_decision', + 'flat', + $type, + $object_id, + $meta_key, + $meta_value, + $op + ); + + if ( in_array( $route, array( 'flat', 'pg', 'skip' ), true ) ) { + return $route; + } + + TMDO_Logger::warning( + 'wpdo_route_decision_invalid_return', + array( + 'returned' => is_scalar( $route ) ? (string) $route : gettype( $route ), + 'op' => $op, + 'type' => $type, + 'key' => $meta_key, + ) + ); + return 'flat'; + } + + /** + * 執行 Upsert 操作 + */ + private static function perform_upsert( + string $type, + int $entity_id, + string $meta_key, + $meta_value, + array $field_def + ) { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + + $group = $field_def['group']; + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + $id_col = $adapter->get_entity_id_column(); + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + + // 表不存在則讓 WP 走原生流程(降級處理) + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + return null; + } + + // 型別轉換 + $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; + self::$internal_ops[ $lock_key ] = true; + + try { + // 檢查列是否存在 + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM `{$table}` WHERE `{$id_col}` = %d", + $entity_id + ) + ); + + if ( $exists ) { + // UPDATE + $result = $wpdb->update( + $table, + array( $col => $db_value ), + array( $id_col => $entity_id ), + array( $format ), + array( '%d' ) + ); + } else { + // INSERT + $result = $wpdb->insert( + $table, + array( + $id_col => $entity_id, + $col => $db_value, + ), + array( '%d', $format ) + ); + } + + // 清除快取 + TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group ); + + // 短路回傳 true(WP 認為寫入成功) + return $result !== false; + } finally { + unset( self::$internal_ops[ $lock_key ] ); + } + } + + // ───────────────────────────────────────────────────────── + // 讀取:get_{type}_metadata filter + // ───────────────────────────────────────────────────────── + + /** + * 攔截 get_metadata() 呼叫 + * + * @param null|mixed $check 若回傳 null 則 WP 繼續原生流程 + * @param int $object_id + * @param string $meta_key 空字串表示取所有 meta + * @param bool $single + * @param string $meta_type 5.5+ 額外參數 + */ + public static function intercept_get( $check, $object_id, $meta_key, $single, $meta_type = '' ) { + + $type = $meta_type ?: self::resolve_type_from_current_filter(); + if ( ! $type ) { + return $check; + } + + // 空 key:WP 要求所有 meta,UAE 不攔截(維持相容性) + if ( $meta_key === '' ) { + return $check; + } + + $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + if ( ! $field_def ) { + return $check; + } + + // ── Mode-aware dispatch ────────────────────────────── + // disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV + // dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check + // shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff + // aeav_only : 讀取走 UAE flat,不讀 EAV + if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) { + return $check; // disabled / dual_write + } + + $group = $field_def['group']; + $row = self::get_or_load_row( $type, (int) $object_id, $group ); + + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + $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( + 'shadow_compare_exception', + array( + 'error' => $e->getMessage(), + ) + ); + } + } + + // 值為空 → 回 WP 原生慣例 + if ( empty( $row ) || $value === null || $value === '' ) { + return $single ? '' : array(); + } + + // WP 的慣例:get_metadata() 即使 $single=true 也回傳陣列包裝 + return $single ? array( $value ) : array( $value ); + } + + /** + * 取得(或載入)完整列資料,並快取 + */ + private static function get_or_load_row( string $type, int $entity_id, string $group ): array { + + // L1 快取 + $cached = TMDO_Cache_Orchestrator::get_row( $type, $entity_id, $group ); + if ( is_array( $cached ) ) { + return $cached; + } + + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return array(); + } + + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + $id_col = $adapter->get_entity_id_column(); + + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + return array(); + } + + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1", + $entity_id + ), + ARRAY_A + ); + + $row = $row ?: array(); + + TMDO_Cache_Orchestrator::set_row( $type, $entity_id, $group, $row ); + + return $row; + } + + // ───────────────────────────────────────────────────────── + // 刪除:delete_{type}_metadata filter + // ───────────────────────────────────────────────────────── + + public static function intercept_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) { + + $type = self::resolve_type_from_current_filter(); + if ( ! $type ) { + return $check; + } + + $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + if ( ! $field_def ) { + return $check; + } + + // Mode-aware:disabled 完全不攔截 + if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { + return $check; + } + + $route = self::decide_route( 'delete', $type, (int) $object_id, $meta_key, $meta_value ); + if ( $route === 'pg' ) { + return null; + } + if ( $route === 'skip' ) { + return true; + } + + // 截取 before value(v1.3.1) + $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' ); + + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + $group = $field_def['group']; + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + $id_col = $adapter->get_entity_id_column(); + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + return $check; + } + + // UAE 的邏輯:刪除 meta = 設該欄位為 NULL + // 因為一個 entity 只對應一列,完整刪除列會丟失其他欄位 + $default = $field_def['default'] ?? null; + + if ( $delete_all ) { + // Safety cap: refuse mass-null if affected row count exceeds threshold. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + + if ( $row_count > 500 ) { + TMDO_Logger::warning( + 'intercept_delete_mass_blocked', + array( + 'table' => $table, + 'col' => $col, + 'rows' => $row_count, + ) + ); + return false; + } + + TMDO_Logger::info( + 'intercept_delete_all', + array( + 'table' => $table, + 'col' => $col, + 'rows' => $row_count, + ) + ); + + // 刪除所有 entity 的該欄位 + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE `{$table}` SET `{$col}` = %s", + $default + ) + ); + } else { + $result = $wpdb->update( + $table, + array( $col => $default ), + array( $id_col => $object_id ), + array( TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ) ), + array( '%d' ) + ); + + TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group ); + } + + 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; + } + + // ───────────────────────────────────────────────────────── + // 實體刪除清理 + // ───────────────────────────────────────────────────────── + + public static function cleanup_entity( string $type, int $entity_id ): void { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return; + } + + $id_col = $adapter->get_entity_id_column(); + $groups = TMDO_Entity_Registry::get_groups_for_type( $type ); + + foreach ( $groups as $group ) { + $table = TMDO_Schema_Manager::get_table_name( $type, $group ); + if ( TMDO_Schema_Manager::table_exists( $table ) ) { + $wpdb->delete( $table, array( $id_col => $entity_id ), array( '%d' ) ); + } + } + + TMDO_Cache_Orchestrator::flush_entity( $type, $entity_id ); + } + + // ───────────────────────────────────────────────────────── + // 工具方法 + // ───────────────────────────────────────────────────────── + + /** + * 從當前 filter 名稱推斷實體類型 + * 例:update_user_metadata → user + */ + private static function resolve_type_from_current_filter(): ?string { + $current = current_filter(); + + if ( preg_match( '/^(?:add|get|update|delete)_(post|user|term|comment)_metadata$/', $current, $matches ) ) { + return $matches[1]; + } + + return null; + } + + /** + * 直接讀取(繞過 WP filter 系統) + * 供 wpdo_get_meta() 便利函式使用,性能更好 + * + * @param string $type + * @param int $entity_id + * @param string $key 若為空字串則回傳整列 + * @return mixed + */ + public static function direct_read( string $type, int $entity_id, string $key = '' ) { + + if ( $key === '' ) { + // 回傳所有群組的所有欄位 + $result = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) { + $row = 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 ); + } + } + return $result; + } + + $field_def = TMDO_Entity_Registry::get_field( $type, $key ); + if ( ! $field_def ) { + return null; + } + + $row = self::get_or_load_row( $type, $entity_id, $field_def['group'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + return TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def ); + } +} diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php new file mode 100644 index 0000000..090415b --- /dev/null +++ b/includes/engine/class-tmdo-mode-manager.php @@ -0,0 +1,380 @@ + */ + private const OPT_KEY = 'wpdo_bridge_modes'; + + /** + * Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。 + * Shape: array + */ + public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at'; + + /** Per-request memoization(避免每次 filter 都查 DB option) */ + 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) + */ + 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, + ); + } + + /** + * 取得所有 entity 的 mode + * + * @return array + */ + public static function all(): array { + if ( self::$cache !== null ) { + return self::$cache; + } + + $stored = get_option( self::OPT_KEY, array() ); + $modes = self::defaults(); + + if ( is_array( $stored ) ) { + foreach ( $stored as $type => $mode ) { + if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { + $modes[ $type ] = $mode; + } + } + } + + self::$cache = $modes; + return $modes; + } + + /** + * 取得單一 entity type 的 mode + */ + public static function get( string $entity_type ): string { + $all = self::all(); + return $all[ $entity_type ] ?? self::MODE_DISABLED; + } + + /** + * 設定單一 entity type 的 mode(包含轉換安全檢查) + * + * @return true|\WP_Error + */ + public static function set( string $entity_type, string $new_mode ) { + if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { + return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" ); + } + if ( ! self::is_valid_mode( $new_mode ) ) { + return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" ); + } + + $current = self::get( $entity_type ); + if ( $current === $new_mode ) { + return true; // no-op + } + + // 驗證轉換安全性 + if ( ! self::is_safe_transition( $current, $new_mode ) ) { + return new \WP_Error( + 'unsafe_transition', + sprintf( + __( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', 'uae' ), + $current, + $new_mode + ) + ); + } + + $all = self::all(); + $all[ $entity_type ] = $new_mode; + + update_option( self::OPT_KEY, $all, false ); + self::$cache = $all; + + // 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。 + $entered = get_option( self::OPT_ENTERED_AT, array() ); + if ( ! is_array( $entered ) ) { + $entered = array(); + } + $entered[ $entity_type ] = time(); + update_option( self::OPT_ENTERED_AT, $entered, false ); + + TMDO_Logger::info( + 'bridge_mode_changed', + array( + 'entity_type' => $entity_type, + 'from' => $current, + 'to' => $new_mode, + 'user_id' => get_current_user_id(), + ) + ); + + // 模式變更時清除所有相關快取 + TMDO_Cache_Orchestrator::flush_entity( $entity_type ); + + do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current ); + + return true; + } + + /** + * 緊急 kill-switch:全部 entity 瞬間切到 disabled + * + * 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled, + * 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV), + * 只有 aeav_only 切到 disabled 才有資料遺失風險。 + * + * 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的, + * 但至少系統不會壞。 + * + * @return int 改變狀態的 entity 數量 + */ + public static function emergency_disable_all(): int { + $all = self::all(); + $changed = 0; + + foreach ( $all as $type => $mode ) { + if ( $mode !== self::MODE_DISABLED ) { + $all[ $type ] = self::MODE_DISABLED; + ++$changed; + + TMDO_Logger::warning( + 'bridge_emergency_disable', + array( + 'entity_type' => $type, + 'previous_mode' => $mode, + 'user_id' => get_current_user_id(), + 'is_aeav_only' => $mode === self::MODE_AEAV_ONLY, + 'warning' => $mode === self::MODE_AEAV_ONLY + ? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale' + : 'Normal emergency fallback', + ) + ); + } + } + + if ( $changed > 0 ) { + update_option( self::OPT_KEY, $all, false ); + self::$cache = $all; + TMDO_Cache_Orchestrator::flush_all(); + do_action( 'wpdo_bridge_emergency_disabled', $changed ); + } + + return $changed; + } + + /** + * 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查) + * + * @return array 逐 entity 的結果 + */ + public static function set_all( string $mode ): array { + $results = array(); + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) { + $results[ $type ] = self::set( $type, $mode ); + } + return $results; + } + + /** + * Helper: 目前 mode 是否會寫入 flat table? + */ + public static function writes_to_flat( string $entity_type ): bool { + return in_array( + self::get( $entity_type ), + array( + self::MODE_DUAL_WRITE, + self::MODE_SHADOW_READ, + self::MODE_AEAV_ONLY, + ), + true + ); + } + + /** + * Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入? + */ + public static function writes_to_eav( string $entity_type ): bool { + return in_array( + self::get( $entity_type ), + array( + self::MODE_DISABLED, + self::MODE_DUAL_WRITE, + self::MODE_SHADOW_READ, + ), + true + ); + } + + /** + * Helper: 目前 mode 是否從 flat table 讀取? + */ + public static function reads_from_flat( string $entity_type ): bool { + return in_array( + self::get( $entity_type ), + array( + self::MODE_SHADOW_READ, + self::MODE_AEAV_ONLY, + ), + true + ); + } + + /** + * Helper: 是否要做 shadow 比對? + */ + public static function does_shadow_compare( string $entity_type ): bool { + return self::get( $entity_type ) === self::MODE_SHADOW_READ; + } + + // ───────────────────────────────────────────────────────── + // 驗證 + // ───────────────────────────────────────────────────────── + + public static function is_valid_mode( $mode ): bool { + return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true ); + } + + /** + * 轉換是否安全? + * + * 規則: + * - 相鄰的階梯移動:允許(正向或反向) + * - 跨階梯降級:允許(後面的 fallback,EAV 還在) + * - 跨階梯升級:禁止(下游資料可能還沒跟上) + * + * 階梯順序(index 越大 = 越「靠 UAE」): + * 0: disabled + * 1: dual_write + * 2: shadow_read + * 3: aeav_only + * + * 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳, + * 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。 + * + * 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。 + */ + public static function is_safe_transition( string $from, string $to ): bool { + $order = array( + self::MODE_DISABLED => 0, + self::MODE_DUAL_WRITE => 1, + self::MODE_SHADOW_READ => 2, + self::MODE_AEAV_ONLY => 3, + ); + + if ( ! isset( $order[ $from ], $order[ $to ] ) ) { + return false; + } + + $diff = $order[ $to ] - $order[ $from ]; + + // 升級:只允許 +1 + if ( $diff > 0 ) { + return $diff === 1; + } + + // 降級:一律允許(這是 fallback,資料都還在) + return true; + } + + // ───────────────────────────────────────────────────────── + // 內部 + // ───────────────────────────────────────────────────────── + + /** + * 測試用:清除 memoization + */ + public static function reset_cache(): void { + self::$cache = null; + } + + /** + * Human-readable label + */ + public static function label( string $mode ): string { + switch ( $mode ) { + case self::MODE_DISABLED: + return __( '停用(原生 EAV)', 'uae' ); + case self::MODE_DUAL_WRITE: + return __( '雙寫', 'uae' ); + case self::MODE_SHADOW_READ: + return __( '影子讀取(驗證中)', 'uae' ); + case self::MODE_AEAV_ONLY: + return __( '僅 UAE(生產模式)', 'uae' ); + default: + return $mode; + } + } + + /** + * Mode 的說明文字(給 admin UI 用) + */ + public static function description( string $mode ): string { + switch ( $mode ) { + case self::MODE_DISABLED: + return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', 'uae' ); + case self::MODE_DUAL_WRITE: + return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', 'uae' ); + case self::MODE_SHADOW_READ: + return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', 'uae' ); + case self::MODE_AEAV_ONLY: + return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', 'uae' ); + default: + return ''; + } + } +} diff --git a/includes/engine/class-tmdo-query-compiler.php b/includes/engine/class-tmdo-query-compiler.php new file mode 100644 index 0000000..492f1ef --- /dev/null +++ b/includes/engine/class-tmdo-query-compiler.php @@ -0,0 +1,322 @@ +', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + ); + + /** + * 注入 wpdo_meta_query 至 WP_Query(post 實體) + * + * @param WP_Query $query + * @param array $wpdo_meta_query 結構同 meta_query + */ + public static function inject_into_wp_query( WP_Query $query, array $wpdo_meta_query ): void { + + $post_type = $query->get( 'post_type' ); + if ( is_array( $post_type ) ) { + $post_type = $post_type[0] ?? 'post'; + } + if ( empty( $post_type ) || $post_type === 'any' ) { + $post_type = 'post'; + } + + // 編譯 JOIN 與 WHERE + $compiled = self::compile( 'post', $wpdo_meta_query ); + + if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) { + return; + } + + // 注入 posts_clauses filter + add_filter( + 'posts_clauses', + function ( $clauses ) use ( $compiled ) { + global $wpdb; + + if ( ! empty( $compiled['joins'] ) ) { + $clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] ); + } + + if ( ! empty( $compiled['where'] ) ) { + $clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')'; + } + + return $clauses; + }, + 10, + 1 + ); + + // wpdo_orderby 支援 + $wpdo_orderby = $query->get( 'wpdo_orderby' ); + if ( $wpdo_orderby ) { + $order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' ); + if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) { + $order_dir = 'DESC'; + } + + $wpdo_col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby ); + + // 找出該欄位所在的群組 + $field_def = TMDO_Entity_Registry::get_field( 'post', $wpdo_orderby ); + if ( $field_def ) { + $alias = 'wpdo_' . $field_def['group']; + add_filter( + 'posts_orderby', + function ( $orderby ) use ( $alias, $wpdo_col, $order_dir ) { + return "`{$alias}`.`{$wpdo_col}` {$order_dir}"; + }, + 10, + 1 + ); + } + } + } + + /** + * 注入至 WP_User_Query + */ + public static function inject_into_user_query( WP_User_Query $query, array $wpdo_meta_query ): void { + + $compiled = self::compile( 'user', $wpdo_meta_query ); + + if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) { + return; + } + + global $wpdb; + + // 透過 reflection 存取 WP_User_Query 的 query_vars 來注入 + $query_orderby = &$query->query_orderby; + $query_where = &$query->query_where; + $query_from = &$query->query_from; + + if ( ! empty( $compiled['joins'] ) ) { + $query_from .= ' ' . implode( ' ', $compiled['joins'] ); + } + + if ( ! empty( $compiled['where'] ) ) { + $query_where .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')'; + } + + // wpdo_orderby + $wpdo_orderby = $query->get( 'wpdo_orderby' ); + if ( $wpdo_orderby ) { + $field_def = TMDO_Entity_Registry::get_field( 'user', $wpdo_orderby ); + if ( $field_def ) { + $order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' ); + if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) { + $order_dir = 'DESC'; + } + $alias = 'wpdo_' . $field_def['group']; + $col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby ); + $query_orderby = "ORDER BY `{$alias}`.`{$col}` {$order_dir}"; + } + } + } + + /** + * 注入至 get_terms() 的 clauses + */ + public static function inject_into_terms_clauses( array $clauses, array $wpdo_meta_query ): array { + + $compiled = self::compile( 'term', $wpdo_meta_query ); + + if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) { + return $clauses; + } + + if ( ! empty( $compiled['joins'] ) ) { + $clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] ); + } + + if ( ! empty( $compiled['where'] ) ) { + $clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')'; + } + + return $clauses; + } + + /** + * 核心編譯邏輯 + * + * @param string $entity_type + * @param array $meta_query 結構範例: + * [ + * 'relation' => 'AND', + * [ 'key' => 'price', 'value' => [100, 500], 'compare' => 'BETWEEN' ], + * [ 'key' => 'stock', 'value' => 'instock' ], + * ] + * @return array{joins: array, where: array} + */ + public static function compile( string $entity_type, array $meta_query ): array { + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return array( + 'joins' => array(), + 'where' => array(), + ); + } + + $relation = strtoupper( $meta_query['relation'] ?? 'AND' ); + if ( ! in_array( $relation, array( 'AND', 'OR' ), true ) ) { + $relation = 'AND'; + } + unset( $meta_query['relation'] ); + + $needed_groups = array(); // group → true + $where_clauses = array(); + + foreach ( $meta_query as $clause ) { + if ( ! is_array( $clause ) || empty( $clause['key'] ) ) { + continue; + } + + $field_def = TMDO_Entity_Registry::get_field( $entity_type, $clause['key'] ); + if ( ! $field_def ) { + // 非 UAE 管理的欄位,跳過(讓原生 meta_query 接手) + continue; + } + + $group = $field_def['group']; + $needed_groups[ $group ] = true; + + $compare = strtoupper( $clause['compare'] ?? '=' ); + if ( ! in_array( $compare, self::VALID_OPERATORS, true ) ) { + $compare = '='; + } + + $alias = 'wpdo_' . $group; + $col = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ); + $value = $clause['value'] ?? null; + + $where_clauses[] = self::build_comparison( $alias, $col, $compare, $value, $field_def ); + } + + // 建立 JOIN + $joins = array(); + $prim_table = $adapter->get_primary_table(); + $prim_id = $adapter->get_primary_id_column(); + $id_col = $adapter->get_entity_id_column(); + + foreach ( array_keys( $needed_groups ) as $group ) { + $wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group ); + + if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) { + continue; + } + + $alias = 'wpdo_' . $group; + $joins[] = "LEFT JOIN `{$wpdo_table}` `{$alias}` ON `{$prim_table}`.`{$prim_id}` = `{$alias}`.`{$id_col}`"; + } + + // 組合 where,依 relation 連接 + $where_sql = array(); + if ( ! empty( $where_clauses ) ) { + $where_sql[] = implode( " {$relation} ", $where_clauses ); + } + + return array( + 'joins' => $joins, + 'where' => $where_sql, + ); + } + + /** + * 產生單一比較子句 + */ + private static function build_comparison( + string $alias, + string $col, + string $compare, + $value, + array $field_def + ): string { + global $wpdb; + + $col_ref = "`{$alias}`.`{$col}`"; + + switch ( $compare ) { + case 'EXISTS': + return "{$col_ref} IS NOT NULL"; + + case 'NOT EXISTS': + return "{$col_ref} IS NULL"; + + case 'BETWEEN': + case 'NOT BETWEEN': + if ( ! is_array( $value ) || count( $value ) !== 2 ) { + return '1=1'; + } + $v1 = self::escape_value( $value[0], $field_def ); + $v2 = self::escape_value( $value[1], $field_def ); + return "{$col_ref} {$compare} {$v1} AND {$v2}"; + + case 'IN': + case 'NOT IN': + if ( ! is_array( $value ) ) { + $value = array( $value ); + } + if ( empty( $value ) ) { + return $compare === 'IN' ? '1=0' : '1=1'; + } + $escaped = array_map( fn( $v ) => self::escape_value( $v, $field_def ), $value ); + return "{$col_ref} {$compare} (" . implode( ',', $escaped ) . ')'; + + case 'LIKE': + case 'NOT LIKE': + $like_val = '%' . $wpdb->esc_like( (string) $value ) . '%'; + return "{$col_ref} {$compare} '" . esc_sql( $like_val ) . "'"; + + default: + $escaped = self::escape_value( $value, $field_def ); + return "{$col_ref} {$compare} {$escaped}"; + } + } + + /** + * 型別安全地轉義值 + */ + private static function escape_value( $value, array $field_def ): string { + $type = $field_def['type'] ?? 'text'; + + return match ( $type ) { + 'integer', 'boolean' => (string) (int) $value, + 'decimal' => (string) (float) $value, + default => "'" . esc_sql( (string) $value ) . "'", + }; + } +} diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php new file mode 100644 index 0000000..45df82d --- /dev/null +++ b/includes/engine/class-tmdo-schema-manager.php @@ -0,0 +1,341 @@ + 'VARCHAR(255)', + 'textarea' => 'TEXT', + 'integer' => 'BIGINT(20)', + 'decimal' => 'DECIMAL(18,6)', + 'boolean' => 'TINYINT(1)', + 'date' => 'DATE', + 'datetime' => 'DATETIME', + 'timestamp' => 'TIMESTAMP', + 'json' => 'LONGTEXT', // MySQL 5.7.8+ 可用 JSON,為相容性用 LONGTEXT + 'enum' => 'VARCHAR(100)', // 在 PHP 層驗證 + 'binary' => 'LONGBLOB', + ); + + /** + * 取得完整資料表名稱 + */ + public static function get_table_name( string $entity_type, string $group_name ): string { + global $wpdb; + return $wpdb->prefix . TMDO_TABLE_PREFIX . sanitize_key( $entity_type ) . '_' . sanitize_key( $group_name ); + } + + /** + * 批次處理所有待建表 + */ + public static function process_pending_migrations(): void { + $pending = TMDO_Entity_Registry::get_pending_schemas(); + + foreach ( $pending as $schema ) { + self::create_or_upgrade_table( + $schema['type'], + $schema['group'], + $schema['fields'] + ); + } + + TMDO_Entity_Registry::clear_pending_schemas(); + } + + /** + * 建立或升級資料表 + */ + public static function create_or_upgrade_table( + string $entity_type, + 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 ) { + return true; + } + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return false; + } + + $table = self::get_table_name( $entity_type, $group_name ); + $charset = $wpdb->get_charset_collate(); + $id_col = $adapter->get_entity_id_column(); + + // 建立基礎欄位(每張表都有) + $sql_columns = array( + '`id` BIGINT(20) NOT NULL AUTO_INCREMENT', + "`{$id_col}` BIGINT(20) NOT NULL", + '`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', + '`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + ); + + $sql_indexes = array( + 'PRIMARY KEY (`id`)', + "UNIQUE KEY `uk_entity` (`{$id_col}`)", + 'KEY `idx_created` (`created_at`)', + ); + + // 處理動態欄位 + foreach ( $field_definitions as $field ) { + $col_name = self::sanitize_column_name( $field['key'] ); + $col_type = self::$type_map[ $field['type'] ] ?? 'VARCHAR(255)'; + + $null_clause = ! empty( $field['required'] ) ? 'NOT NULL' : 'DEFAULT NULL'; + $default = self::build_default_clause( $field ); + + // 組合欄位 DDL + $col_ddl = "`{$col_name}` {$col_type} {$null_clause}"; + if ( $default !== '' ) { + $col_ddl .= " {$default}"; + } + + $sql_columns[] = $col_ddl; + + // 索引策略 + if ( ! empty( $field['unique'] ) ) { + $sql_indexes[] = "UNIQUE KEY `uk_{$col_name}` (`{$col_name}`)"; + } elseif ( ! empty( $field['searchable'] ) ) { + // 不同型別決定索引長度 + if ( in_array( $field['type'], array( 'text', 'textarea' ), true ) ) { + // 文字欄位使用前綴索引避免過長 + $sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`(100))"; + } else { + $sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`)"; + } + } + + if ( ! empty( $field['fulltext'] ) && in_array( $field['type'], array( 'text', 'textarea' ), true ) ) { + $sql_indexes[] = "FULLTEXT KEY `ft_{$col_name}` (`{$col_name}`)"; + } + } + + $columns_sql = implode( ",\n ", $sql_columns ); + $indexes_sql = implode( ",\n ", $sql_indexes ); + + $sql = "CREATE TABLE `{$table}` (\n {$columns_sql},\n {$indexes_sql}\n) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + // dbDelta 自動處理建表/ALTER TABLE + $dbdelta_result = dbDelta( $sql ); + + // 記錄 Schema 版本與定義 + self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); + + /** + * Action: 表建立/升級完成 + */ + do_action( 'wpdo_schema_updated', $entity_type, $group_name, $table, $dbdelta_result ); + + return true; + } + + /** + * 計算 Schema Hash(用於偵測欄位變動) + */ + public static function calculate_schema_hash( array $fields ): string { + // 正規化:僅保留影響 Schema 的屬性 + $normalized = array_map( + function ( $f ) { + return array( + 'key' => $f['key'] ?? '', + 'type' => $f['type'] ?? '', + 'required' => ! empty( $f['required'] ), + 'default' => $f['default'] ?? null, + 'searchable' => ! empty( $f['searchable'] ), + 'fulltext' => ! empty( $f['fulltext'] ), + 'unique' => ! empty( $f['unique'] ), + ); + }, + $fields + ); + + // 依 key 排序以確保 hash 穩定 + usort( $normalized, fn( $a, $b ) => strcmp( $a['key'], $b['key'] ) ); + + $json = wp_json_encode( $normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + + return hash( 'sha256', $json ); + } + + /** + * 欄位名稱清理(防止 SQL 注入) + */ + public static function sanitize_column_name( string $key ): string { + // 移除所有非 alphanumeric/底線 + $clean = preg_replace( '/[^a-zA-Z0-9_]/', '', $key ); + + // 若以數字開頭,前綴 f_ + if ( $clean !== '' && preg_match( '/^\d/', $clean ) ) { + $clean = 'f_' . $clean; + } + + // MySQL 欄位名長度限制 64 字元 + return substr( $clean, 0, 60 ); + } + + /** + * 建立 DEFAULT 子句 + */ + private static function build_default_clause( array $field ): string { + if ( ! isset( $field['default'] ) || $field['default'] === null ) { + return ''; + } + + $default = $field['default']; + + switch ( $field['type'] ) { + case 'integer': + case 'boolean': + return 'DEFAULT ' . (int) $default; + + case 'decimal': + return 'DEFAULT ' . (float) $default; + + case 'date': + case 'datetime': + case 'timestamp': + if ( strtoupper( (string) $default ) === 'CURRENT_TIMESTAMP' ) { + return 'DEFAULT CURRENT_TIMESTAMP'; + } + return "DEFAULT '" . esc_sql( (string) $default ) . "'"; + + case 'json': + case 'text': + case 'textarea': + case 'enum': + default: + return "DEFAULT '" . esc_sql( (string) $default ) . "'"; + } + } + + /** + * 儲存 Schema metadata 到 wpdo_registry_meta 表 + */ + private static function store_schema_metadata( + string $entity_type, + string $group_name, + string $schema_hash, + array $field_definitions + ): void { + global $wpdb; + + $table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta'; + + $wpdb->replace( + $table, + array( + 'entity_type' => $entity_type, + 'group_name' => $group_name, + 'schema_hash' => $schema_hash, + 'field_definitions' => wp_json_encode( $field_definitions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), + ), + array( '%s', '%s', '%s', '%s' ) + ); + } + + /** + * 取得已儲存的 Schema hash + */ + public static function get_stored_schema_hash( string $entity_type, string $group_name ): string { + global $wpdb; + + $table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta'; + + $hash = $wpdb->get_var( + $wpdb->prepare( + "SELECT schema_hash FROM `{$table}` WHERE entity_type = %s AND group_name = %s", + $entity_type, + $group_name + ) + ); + + return $hash ?: ''; + } + + /** + * 檢查表是否存在 + */ + public static function table_exists( string $table_name ): bool { + global $wpdb; + $result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); + return $result === $table_name; + } + + /** + * 丟棄表(謹慎使用,僅用於解除安裝) + */ + public static function drop_table( string $entity_type, string $group_name ): bool { + global $wpdb; + $table = self::get_table_name( $entity_type, $group_name ); + return (bool) $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + + /** + * 取得所有 UAE 建立的表清單 + */ + public static function list_all_uae_tables(): array { + global $wpdb; + $prefix = $wpdb->prefix . TMDO_TABLE_PREFIX; + $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $prefix . '%' ) ); + return $tables ?: array(); + } + + /** + * 取得資料表統計資訊(大小、列數) + */ + public static function get_table_stats( string $table_name ): array { + global $wpdb; + + $info = $wpdb->get_row( + $wpdb->prepare( + 'SELECT TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s', + DB_NAME, + $table_name + ), + ARRAY_A + ); + + if ( ! $info ) { + return array( + 'rows' => 0, + 'size_mb' => 0, + 'index_mb' => 0, + ); + } + + return array( + 'rows' => (int) $info['TABLE_ROWS'], + 'size_mb' => round( $info['DATA_LENGTH'] / 1024 / 1024, 2 ), + 'index_mb' => round( $info['INDEX_LENGTH'] / 1024 / 1024, 2 ), + ); + } +} diff --git a/includes/engine/class-tmdo-shadow-diff-logger.php b/includes/engine/class-tmdo-shadow-diff-logger.php new file mode 100644 index 0000000..92b5fee --- /dev/null +++ b/includes/engine/class-tmdo-shadow-diff-logger.php @@ -0,0 +1,407 @@ +prefix . 'wpdo_shadow_diffs'; + } + + /** + * 比對並記錄差異 + * + * @param string $entity_type + * @param int $entity_id + * @param string $meta_key + * @param mixed $wpdo_value 從 UAE flat table 取得的值(已型別轉換) + * @param array $field_def field definition(含 type) + */ + public static function compare_and_log( + string $entity_type, + int $entity_id, + string $meta_key, + $wpdo_value, + array $field_def + ): void { + // 直接從 native meta 表取(繞過 UAE filter 避免無限迴圈) + $eav_raw = self::get_eav_value_raw( $entity_type, $entity_id, $meta_key ); + + // 兩邊都無 → 一致 + if ( $eav_raw === null && ( $wpdo_value === null || $wpdo_value === '' ) ) { + return; + } + + $type = $field_def['type'] ?? 'text'; + $eav_val = self::cast_eav( $eav_raw, $type ); + + // 比對 + if ( self::values_equal( $eav_val, $wpdo_value, $type ) ) { + return; // 一致 + } + + // 不一致 → 檢查 rate limit + if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) { + return; + } + + // 寫入 diff log + self::record( + $entity_type, + $entity_id, + $meta_key, + self::stringify_for_log( $eav_val ), + self::stringify_for_log( $wpdo_value ), + $type + ); + + // Trim 舊 rows + self::maybe_trim(); + } + + /** + * 從 wp_*meta 直接取值(繞過 UAE filter) + */ + private static function get_eav_value_raw( string $entity_type, int $entity_id, string $meta_key ): ?string { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return null; + } + + $table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + + // 直接 SQL 繞過 get_metadata 系列 filter + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s LIMIT 1", + $entity_id, + $meta_key + ) + ); + + return $value === null ? null : (string) $value; + } + + /** + * 把 EAV 原始字串轉為該型別的值(用來比對) + */ + private static function cast_eav( ?string $raw, string $type ) { + if ( $raw === null ) { + return null; + } + // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). + // Mirrors WP's maybe_unserialize but with allowed_classes=false. + $raw = TMDO_Safe_Unserialize::run( $raw ); + + switch ( $type ) { + case 'integer': + return is_numeric( $raw ) ? (int) $raw : null; + case 'decimal': + return is_numeric( $raw ) ? (float) $raw : null; + case 'boolean': + return (bool) $raw; + case 'json': + if ( is_array( $raw ) || is_object( $raw ) ) { + return $raw; + } + $decoded = json_decode( (string) $raw, true ); + return $decoded !== null ? $decoded : $raw; + default: + return $raw; + } + } + + /** + * 兩值是否一致(依型別做合適比對) + */ + private static function values_equal( $a, $b, string $type ): bool { + if ( $a === $b ) { + return true; + } + + // JSON / array 比對:用 canonical JSON + if ( $type === 'json' || is_array( $a ) || is_array( $b ) ) { + return wp_json_encode( $a ) === wp_json_encode( $b ); + } + + // Numeric:比數值 + if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) { + return is_numeric( $a ) && is_numeric( $b ) + ? (float) $a === (float) $b + : $a === $b; + } + + // Boolean:寬鬆 + if ( $type === 'boolean' ) { + return (bool) $a === (bool) $b; + } + + // Default:鬆散字串比較(EAV 字串 vs casted) + return (string) $a === (string) $b; + } + + /** + * Rate limit 判斷(同 entity+key 在 300 秒內只記一次) + */ + private static function is_rate_limited( string $entity_type, int $entity_id, string $meta_key ): bool { + $key = 'wpdo_sd_rl_' . md5( "{$entity_type}:{$entity_id}:{$meta_key}" ); + if ( get_transient( $key ) !== false ) { + return true; + } + set_transient( $key, 1, self::RATELIMIT_WINDOW ); + return false; + } + + /** + * 寫入 diff row + * + * v2.1.6 fix: aligned to actual `wpdo_shadow_diffs` schema installed by + * TMDO_Installer. The columns are: ts / entity_type / entity_id / meta_key / + * postmeta_value / zone_value / diff_hash. Logger code previously assumed + * eav_value / wpdo_value / created_at / field_type — schema-vs-code drift + * that silently no-op'd every diff INSERT (wpdb returns 0, no exception). + * + * `field_type` is consumed only as input to diff_hash so the same + * (entity_type, entity_id, meta_key) tuple records distinct rows when the + * field's interpreted type changes (rare; mostly a defence-in-depth bucket). + */ + private static function record( + string $entity_type, + int $entity_id, + string $meta_key, + string $eav_value, + string $wpdo_value, + string $field_type + ): void { + global $wpdb; + $table = self::table_name(); + + $diff_hash = sha1( $entity_type . '|' . $meta_key . '|' . $field_type . '|' . $eav_value . '|' . $wpdo_value ); + + $wpdb->insert( + $table, + array( + 'ts' => current_time( 'mysql', true ), + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'meta_key' => $meta_key, + 'postmeta_value' => $eav_value, + 'zone_value' => $wpdo_value, + 'diff_hash' => $diff_hash, + ), + array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) + ); + + TMDO_Logger::warning( + 'shadow_read_diff', + array( + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'meta_key' => $meta_key, + ) + ); + } + + /** + * 轉為 log 字串 + */ + private static function stringify_for_log( $value ): string { + if ( is_scalar( $value ) || $value === null ) { + return (string) $value; + } + return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + /** + * 超過 MAX_ROWS 時 trim 最舊的(chance 1% 才執行,不用每次都跑) + */ + private static function maybe_trim(): void { + if ( wp_rand( 1, 100 ) !== 1 ) { + return; + } + + global $wpdb; + $table = self::table_name(); + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + if ( $count <= self::MAX_ROWS ) { + return; + } + + $to_delete = $count - self::MAX_ROWS; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d", + $to_delete + ) + ); + } + + // ───────────────────────────────────────────────────────── + // Read API (for admin UI + CLI) + // ───────────────────────────────────────────────────────── + + /** + * 取得最近 N 筆 diffs + */ + public static function recent( int $limit = 100, ?string $entity_type = null ): array { + global $wpdb; + $table = self::table_name(); + + if ( $entity_type ) { + $sql = $wpdb->prepare( + "SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d", + $entity_type, + $limit + ); + } else { + $sql = $wpdb->prepare( + "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", + $limit + ); + } + + $rows = $wpdb->get_results( $sql, ARRAY_A ); + return is_array( $rows ) ? $rows : array(); + } + + /** + * 統計:按 entity / meta_key 聚合 + */ + public static function stats_by_key( int $limit = 20 ): array { + global $wpdb; + $table = self::table_name(); + + // v2.1.6: column is `ts`, not `created_at` (matches installer schema). + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT entity_type, meta_key, COUNT(*) AS c, MAX(ts) AS last_seen + FROM `{$table}` + GROUP BY entity_type, meta_key + ORDER BY c DESC LIMIT %d", + $limit + ), + ARRAY_A + ); + + return is_array( $rows ) ? $rows : array(); + } + + /** + * 全部 diff 總數 + */ + public static function total_count(): int { + global $wpdb; + return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' ); + } + + /** + * 按 entity type 統計 + */ + public static function count_by_entity(): array { + global $wpdb; + $rows = $wpdb->get_results( + 'SELECT entity_type, COUNT(*) AS c FROM `' . self::table_name() . '` GROUP BY entity_type', + ARRAY_A + ); + $result = array(); + foreach ( $rows ?: array() as $r ) { + $result[ $r['entity_type'] ] = (int) $r['c']; + } + return $result; + } + + /** + * 清空所有 diffs(admin confirmed) + */ + public static function clear_all(): int { + global $wpdb; + $table = self::table_name(); + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + $wpdb->query( "TRUNCATE TABLE `{$table}`" ); + TMDO_Logger::info( + 'shadow_diffs_cleared', + array( + 'count' => $count, + 'user_id' => get_current_user_id(), + ) + ); + return $count; + } + + /** + * 清除特定 entity 的 diffs + */ + public static function clear_entity( string $entity_type ): int { + global $wpdb; + return (int) $wpdb->query( + $wpdb->prepare( + 'DELETE FROM `' . self::table_name() . '` WHERE entity_type = %s', + $entity_type + ) + ); + } + + // ───────────────────────────────────────────────────────── + // Schema + // ───────────────────────────────────────────────────────── + + public static function install_table(): void { + global $wpdb; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $table = self::table_name(); + $charset = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + entity_type VARCHAR(20) NOT NULL, + entity_id BIGINT UNSIGNED NOT NULL, + meta_key VARCHAR(255) NOT NULL, + field_type VARCHAR(20) NOT NULL DEFAULT 'text', + eav_value LONGTEXT NULL, + wpdo_value LONGTEXT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_entity (entity_type, entity_id), + KEY idx_key (entity_type, meta_key(191)), + KEY idx_created (created_at) + ) {$charset};"; + + dbDelta( $sql ); + } + + public static function drop_table(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() ); + } +} diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php new file mode 100644 index 0000000..9a1cf6e --- /dev/null +++ b/includes/engine/class-tmdo-type-caster.php @@ -0,0 +1,165 @@ + '%d', + 'decimal' => '%f', + default => '%s', + }; + } + + // ───────────────────────────────────────────────────────── + // 工具方法 + // ───────────────────────────────────────────────────────── + + private static function to_bool( $value ): bool { + if ( is_bool( $value ) ) { + return $value; + } + if ( is_numeric( $value ) ) { + return (int) $value !== 0; + } + $str = strtolower( trim( (string) $value ) ); + return in_array( $str, array( 'true', 'yes', 'y', '1', 'on' ), true ); + } + + private static function format_date( $value, string $format ): ?string { + if ( $value === '' || $value === null ) { + return null; + } + + // 接受 Unix timestamp + if ( is_numeric( $value ) ) { + return gmdate( $format, (int) $value ); + } + + $ts = strtotime( (string) $value ); + if ( $ts === false ) { + return null; + } + + return gmdate( $format, $ts ); + } +} diff --git a/includes/export/class-tmdo-csv-writer.php b/includes/export/class-tmdo-csv-writer.php new file mode 100644 index 0000000..912e2c0 --- /dev/null +++ b/includes/export/class-tmdo-csv-writer.php @@ -0,0 +1,83 @@ +get_charset_collate(); + $table = $wpdb->prefix . self::TABLE; + + $sql = "CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + entity_type varchar(20) NOT NULL DEFAULT '', + entity_id bigint(20) unsigned NOT NULL DEFAULT 0, + counter_key varchar(100) NOT NULL DEFAULT '', + counter_value bigint(20) NOT NULL DEFAULT 0, + updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key), + KEY idx_lookup (entity_type, counter_key, counter_value), + KEY idx_entity (entity_type, entity_id) +) {$charset};"; + dbDelta( $sql ); + } + + /** + * Drop the demo table — idempotent. + * + * @return void + */ + public static function drop_table(): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + + /** + * Set a counter for any entity. Writes to BOTH native meta AND demo table + * when feature flag is in a write-active state. Otherwise writes native + * only (idle path — full backward compatibility). + * + * @param string $entity_type One of: post, user, term, comment. + * @param int $entity_id Entity ID. + * @param string $counter_key Counter slug (e.g. 'points', 'view_count'). + * @param int $value New value. + * @return bool + */ + public static function set( string $entity_type, int $entity_id, string $counter_key, int $value ): bool { + if ( ! self::is_valid_entity( $entity_type ) ) { + return false; + } + + // Always write the native meta first (durability anchor). + TMDO_API::set_entity( $entity_type, $entity_id, $counter_key, $value ); + + // Conditional dual-write to demo table. + if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) { + self::write_to_table( $entity_type, $entity_id, $counter_key, $value ); + } + + return true; + } + + /** + * Read a counter value. Source depends on feature flag state: + * - read_custom (cutover/cleanup/complete) → demo table + * - otherwise → native meta (fallback) + * + * @param string $entity_type One of: post, user, term, comment. + * @param int $entity_id Entity ID. + * @param string $counter_key Counter slug. + * @return int + */ + public static function get( string $entity_type, int $entity_id, string $counter_key ): int { + if ( ! self::is_valid_entity( $entity_type ) ) { + return 0; + } + + if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) { + $row = self::read_from_table( $entity_type, $entity_id, $counter_key ); + if ( null !== $row ) { + return (int) $row; + } + // Fallback to native if zone row missing — graceful degradation. + } + + return (int) TMDO_API::get_entity( $entity_type, $entity_id, $counter_key ); + } + + /** + * Top-N entities by counter value — the killer query that postmeta CANNOT + * do efficiently (requires full scan + filesort). Demonstrates the value of + * the entity adapter pattern. + * + * @param string $entity_type One of: post, user, term, comment. + * @param string $counter_key Counter slug. + * @param int $limit Max rows. + * @return array + */ + public static function top_n( string $entity_type, string $counter_key, int $limit = 10 ): array { + global $wpdb; + $table = $wpdb->prefix . self::TABLE; + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from constant. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT entity_id, counter_value FROM `{$table}` WHERE entity_type = %s AND counter_key = %s ORDER BY counter_value DESC LIMIT %d", + $entity_type, + $counter_key, + $limit + ), + ARRAY_A + ); + + return array_map( + static fn( array $r ) => array( + 'entity_id' => (int) $r['entity_id'], + 'counter_value' => (int) $r['counter_value'], + ), + $rows ?: array() + ); + } + + // ── Internals ────────────────────────────────────────────────────────── + + /** + * @param string $entity_type Entity type. + */ + private static function is_valid_entity( string $entity_type ): bool { + return in_array( $entity_type, array( 'post', 'user', 'term', 'comment' ), true ); + } + + /** + * Write a single counter value via UPSERT (1 round-trip). + * + * @param string $entity_type One of: post, user, term, comment. + * @param int $entity_id Entity ID. + * @param string $counter_key Counter slug. + * @param int $value New value. + * @return void + */ + private static function write_to_table( string $entity_type, int $entity_id, string $counter_key, int $value ): void { + global $wpdb; + TMDO_DB::upsert( + $wpdb->prefix . self::TABLE, + array( + 'entity_type' => $entity_type, + 'entity_id' => $entity_id, + 'counter_key' => $counter_key, + 'counter_value' => $value, + 'updated_at' => current_time( 'mysql' ), + ), + array( 'counter_value', 'updated_at' ), + array( 'entity_type', 'entity_id', 'counter_key' ), + array( '%s', '%d', '%s', '%d', '%s' ) + ); + } + + /** + * Read a single counter value from the demo table. + * + * @param string $entity_type Entity type. + * @param int $entity_id Entity ID. + * @param string $counter_key Counter slug. + * @return int|null Null when row absent. + */ + private static function read_from_table( string $entity_type, int $entity_id, string $counter_key ): ?int { + global $wpdb; + $table = $wpdb->prefix . self::TABLE; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from constant. + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT counter_value FROM `{$table}` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s LIMIT 1", + $entity_type, + $entity_id, + $counter_key + ) + ); + return null === $value ? null : (int) $value; + } +} diff --git a/includes/integrations/class-tmdo-member-fields.php b/includes/integrations/class-tmdo-member-fields.php new file mode 100644 index 0000000..47cc468 --- /dev/null +++ b/includes/integrations/class-tmdo-member-fields.php @@ -0,0 +1,498 @@ + 'membership_level', + 'type' => 'enum', + 'searchable' => true, + 'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ), + 'label' => 'Membership tier level', + ), + array( + 'key' => 'points_balance', + 'type' => 'integer', + 'searchable' => true, + 'default' => 0, + 'label' => 'Current points balance', + ), + array( + 'key' => 'membership_expires_at', + 'type' => 'datetime', + 'searchable' => true, + 'label' => 'Membership expiry datetime', + ), + array( + 'key' => 'membership_activated_at', + 'type' => 'datetime', + 'label' => 'Membership activation datetime', + ), + array( + 'key' => 'tier_source', + 'type' => 'text', + 'label' => 'Tier source: manual / wc_subscription / admin_set', + ), + array( + 'key' => 'custom_tier_label', + 'type' => 'text', + 'label' => 'Custom tier display label (when level=custom)', + ), + ) + ); + } + + /** + * Login counters and last-active timestamps — separated to avoid lock + * contention with membership reads during high-traffic periods. + * + * @return void + */ + private static function register_activity_group(): void { + TMDO_Entity_Registry::register_group( + 'user', + 'activity', + array( + array( + 'key' => 'login_count', + 'type' => 'integer', + 'searchable' => true, + 'default' => 0, + 'label' => 'Cumulative login count', + ), + array( + 'key' => 'last_active_at', + 'type' => 'datetime', + 'searchable' => true, + 'label' => 'Last activity datetime', + ), + array( + 'key' => 'last_login_at', + 'type' => 'datetime', + 'label' => 'Last login datetime', + ), + array( + 'key' => 'last_order_at', + 'type' => 'datetime', + 'label' => 'Last order datetime', + ), + array( + 'key' => 'session_count', + 'type' => 'integer', + 'default' => 0, + 'label' => 'Total session count', + ), + array( + 'key' => 'account_flags', + 'type' => 'integer', + 'default' => 0, + 'label' => 'Bitmask: 1=email_verified 2=phone_verified 4=kyc 8=social_signup', + ), + ) + ); + } + + /** + * Display fields, specialties, avatar — written infrequently. + * + * @return void + */ + private static function register_profile_group(): void { + TMDO_Entity_Registry::register_group( + 'user', + 'profile', + array( + array( + 'key' => 'specialties', + 'type' => 'json', + 'label' => 'Professional specialties (JSON array)', + ), + array( + 'key' => 'bio_url', + 'type' => 'text', + 'label' => 'Bio or portfolio URL', + ), + array( + 'key' => 'avatar_url', + 'type' => 'text', + 'label' => 'Avatar image URL', + ), + array( + 'key' => 'display_name_custom', + 'type' => 'text', + 'searchable' => true, + 'fulltext' => true, + 'label' => 'Custom display name (fulltext searchable)', + ), + array( + 'key' => 'locale', + 'type' => 'text', + 'label' => 'User locale (e.g. zh_TW)', + ), + ) + ); + } + + /** + * 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', + ), + ) + ); + } + + /** + * WP core user fields stored as multi-row EAV in wp_usermeta. + * + * Absorbing these here lets the Hook Bus short-circuit get_user_meta() / + * update_user_meta() for the keys WP itself uses for display_name resolution + * and the WP profile UI. + * + * @return void + */ + private static function register_core_profile_group(): void { + TMDO_Entity_Registry::register_group( + 'user', + 'core_profile', + array( + array( + 'key' => 'nickname', + 'type' => 'text', + 'searchable' => true, + 'label' => 'WP nickname', + ), + array( + 'key' => 'first_name', + 'type' => 'text', + 'searchable' => true, + 'label' => 'WP first name', + ), + array( + 'key' => 'last_name', + 'type' => 'text', + 'searchable' => true, + 'label' => 'WP last name', + ), + array( + 'key' => 'description', + 'type' => 'textarea', + 'label' => 'WP user bio', + ), + ) + ); + } + + /** + * Social profile URLs (HivePress vendor-profile + WP user-contact-methods). + * + * 15 keys × 8 vendor users ≈ 120 EAV rows in this dataset. + * + * @return void + */ + private static function register_social_group(): void { + $social_keys = array( + 'facebook', + 'twitter', + 'instagram', + 'youtube', + 'tiktok', + 'linkedin', + 'vimeo', + 'vkontakte', + 'mastodon', + 'medium', + 'wordpress', + 'odnoklassniki', + 'pinterest', + 'dribbble', + 'github', + ); + + // Social URLs typed as `textarea` (TEXT) — VARCHAR(255) silently truncates + // long share URLs (utm params, deep paths) under WP's default non-strict + // SQL mode. TEXT (64KB) covers all realistic URL lengths. + $fields = array(); + foreach ( $social_keys as $key ) { + $fields[] = array( + 'key' => $key, + 'type' => 'textarea', + 'label' => ucfirst( $key ) . ' profile URL', + ); + } + + TMDO_Entity_Registry::register_group( 'user', 'social', $fields ); + } + + /** + * WooCommerce billing & shipping address fields. + * + * Billing_email is searchable for guest-checkout customer lookups. + * + * @return void + */ + private static function register_commerce_group(): void { + $address_keys = array( + 'first_name', + 'last_name', + 'company', + 'address_1', + 'address_2', + 'city', + 'state', + 'postcode', + 'country', + ); + + $fields = array(); + foreach ( $address_keys as $key ) { + $fields[] = array( + 'key' => 'billing_' . $key, + 'type' => 'text', + 'label' => 'WC billing ' . str_replace( '_', ' ', $key ), + ); + $fields[] = array( + 'key' => 'shipping_' . $key, + 'type' => 'text', + 'label' => 'WC shipping ' . str_replace( '_', ' ', $key ), + ); + } + + // Email + phone are billing-only. + $fields[] = array( + 'key' => 'billing_email', + 'type' => 'text', + 'searchable' => true, + 'label' => 'WC billing email', + ); + $fields[] = array( + 'key' => 'billing_phone', + 'type' => 'text', + 'label' => 'WC billing phone', + ); + $fields[] = array( + 'key' => 'shipping_phone', + 'type' => 'text', + 'label' => 'WC shipping phone', + ); + + 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 + * lands at ratio 1:9 (7 admin prefs + wp_capabilities + wp_user_level). + * Once registered, Hook Bus intercepts `update_user_meta()` calls from + * `wp_insert_user()` and routes them to `wp_wpdo_user_admin_prefs` flat + * table → fresh user ratio drops to 1:2. + * + * Values are stored as text because WP itself stores 'true'/'false' + * strings (not booleans), 'fresh' / 'classic' (admin_color enum strings), + * and integer-as-string for `use_ssl`. Preserving WP's textual storage + * shape ensures downstream code (e.g. theme switchers reading + * `admin_color`) sees the exact same value as before. + * + * @since 2.8.4 + * @return void + */ + private static function register_admin_prefs_group(): void { + TMDO_Entity_Registry::register_group( + 'user', + 'admin_prefs', + array( + array( + 'key' => 'rich_editing', + 'type' => 'text', + 'label' => 'Visual editor enabled (true/false string)', + ), + array( + 'key' => 'syntax_highlighting', + 'type' => 'text', + 'label' => 'Code editor syntax highlighting (true/false string)', + ), + array( + 'key' => 'comment_shortcuts', + 'type' => 'text', + 'label' => 'Comment moderation keyboard shortcuts (true/false string)', + ), + array( + 'key' => 'admin_color', + 'type' => 'text', + 'label' => 'Admin colour scheme (fresh/classic/etc)', + ), + array( + 'key' => 'use_ssl', + 'type' => 'text', + 'label' => 'Force SSL on admin (0/1 as string)', + ), + array( + 'key' => 'show_admin_bar_front', + 'type' => 'text', + 'label' => 'Show admin bar on front-end (true/false string)', + ), + array( + 'key' => 'dismissed_wp_pointers', + 'type' => 'textarea', + 'label' => 'Comma-separated dismissed pointer IDs', + ), + ) + ); + } +} diff --git a/includes/integrations/class-tmdo-points-manager.php b/includes/integrations/class-tmdo-points-manager.php new file mode 100644 index 0000000..d625378 --- /dev/null +++ b/includes/integrations/class-tmdo-points-manager.php @@ -0,0 +1,256 @@ + 0). + * @param string $reason Short reason code (≤60 chars). + * @param int $ref_id Optional reference ID (order_id, post_id, …). + * @param string $ref_type Optional reference type ('order', 'post', 'manual', …). + * @return array{ok:bool, balance:int, ledger_id:int, error?:string} + */ + public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array { + if ( $delta <= 0 ) { + return array( + 'ok' => false, + 'error' => 'credit delta must be positive', + ); + } + return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false ); + } + + /** + * Debit points from a user (negative delta applied internally). + * + * @param int $user_id WordPress user ID. + * @param int $delta Points to deduct (positive number; stored as negative). + * @param string $reason Short reason code (≤60 chars). + * @param int $ref_id Optional reference ID. + * @param string $ref_type Optional reference type. + * @param bool $allow_overdraft When true, debit proceeds even if balance < delta. + * @return array{ok:bool, balance:int, ledger_id:int, error?:string} + */ + public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array { + if ( $delta <= 0 ) { + return array( + 'ok' => false, + 'error' => 'debit delta must be positive', + ); + } + return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft ); + } + + /** + * Return current points balance for a user. + * + * Reads directly from the flat table, bypassing usermeta EAV. + * + * @param int $user_id WordPress user ID. + * @return int Balance (0 when user has no membership row). + */ + public static function get_balance( int $user_id ): int { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_user_membership'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $balance = $wpdb->get_var( + $wpdb->prepare( + "SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_id + ) + ); + + return (int) ( $balance ?? 0 ); + } + + /** + * Retrieve ledger entries for a user, newest-first. + * + * The idx_user_created covering index makes this O(log N) regardless of total rows. + * + * @param int $user_id WordPress user ID. + * @param int $limit Max rows to return (default 20). + * @param int $offset Row offset for pagination (default 0). + * @return array + */ + public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array { + global $wpdb; + $table = $wpdb->prefix . 'wpdo_user_points_ledger'; + + $limit = max( 1, min( 500, $limit ) ); + $offset = max( 0, $offset ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_id, + $limit, + $offset + ), + ARRAY_A + ); + + return $rows ?: array(); + } + + // ── Internal ───────────────────────────────────────────────────────────── + + /** + * Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger. + * + * All balance mutations (credit and debit) route through this single method. + * TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise + * InnoDB ignores the lock hint and the serialisation guarantee is lost. + * + * @param int $user_id WordPress user ID. + * @param int $delta Signed delta (positive = credit, negative = debit). + * @param string $reason Reason code stored in ledger. + * @param int $ref_id Reference ID (0 = none). + * @param string $ref_type Reference type ('' = none). + * @param bool $allow_overdraft Skip balance-floor check when true. + * @return array{ok:bool, balance:int, ledger_id:int, error?:string} + */ + private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array { + global $wpdb; + $mem_table = $wpdb->prefix . 'wpdo_user_membership'; + $ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger'; + + // Truncate reason to column width to avoid silent DB truncation. + $reason = substr( $reason, 0, 60 ); + $ref_type = substr( $ref_type, 0, 30 ); + + TMDO_DB::begin(); + + try { + // Lock the membership row for this user so concurrent writes wait. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $current_balance = $wpdb->get_var( + $wpdb->prepare( + "SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_id + ) + ); + $current_balance = (int) ( $current_balance ?? 0 ); + + $new_balance = $current_balance + $delta; + + // Reject negative-result debits unless overdraft is explicitly allowed. + if ( ! $allow_overdraft && $new_balance < 0 ) { + TMDO_DB::rollback(); + return array( + 'ok' => false, + 'error' => 'insufficient_balance', + ); + } + + // Upsert with relative increment — prevents concurrent first-credit race. + // SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous + // first-credits both read balance=0. Using VALUES(points_balance) here means + // InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and + // applies a relative +delta instead of overwriting with an absolute value. + $upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_id, + $delta + ) + ); + + if ( false === $upserted ) { + TMDO_DB::rollback(); + TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" ); + return array( + 'ok' => false, + 'error' => 'db_error', + ); + } + + // Re-read actual balance so ledger and return value are correct even when + // ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $new_balance = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_id + ) + ); + + // Append ledger row. + $ledger_data = array( + 'user_id' => $user_id, + 'delta' => $delta, + 'balance_after' => $new_balance, + 'reason' => $reason, + 'created_at' => current_time( 'mysql' ), + ); + $ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' ); + + if ( $ref_id ) { + $ledger_data['ref_id'] = $ref_id; + $ledger_fmt[] = '%d'; + } + if ( '' !== $ref_type ) { + $ledger_data['ref_type'] = $ref_type; + $ledger_fmt[] = '%s'; + } + + $inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt ); + + if ( false === $inserted ) { + TMDO_DB::rollback(); + TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" ); + return array( + 'ok' => false, + 'error' => 'db_error', + ); + } + + $ledger_id = (int) $wpdb->insert_id; + + TMDO_DB::commit(); + + // Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before). + do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance ); + + return array( + 'ok' => true, + 'balance' => $new_balance, + 'ledger_id' => $ledger_id, + ); + + } catch ( \Throwable $e ) { + TMDO_DB::rollback(); + TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() ); + return array( + 'ok' => false, + 'error' => 'exception', + ); + } + } +} diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php new file mode 100644 index 0000000..6aead41 --- /dev/null +++ b/includes/integrations/class-tmdo-post-fields.php @@ -0,0 +1,487 @@ + '_thumbnail_id', + 'type' => 'integer', + 'searchable' => true, + 'label' => 'Featured image attachment ID', + ), + array( + 'key' => '_wp_page_template', + 'type' => 'text', + 'label' => 'Page template slug', + ), + array( + 'key' => '_edit_last', + 'type' => 'integer', + 'searchable' => true, + 'label' => 'Last editor user ID', + ), + ) + ); + } + + /** + * Attachment-specific meta keys (post_type=attachment). + * + * @return void + */ + private static function register_attachment_group(): void { + TMDO_Entity_Registry::register_group( + 'post', + 'attachment', + array( + array( + 'key' => '_wp_attached_file', + 'type' => 'text', + 'searchable' => true, + 'label' => 'Relative path of the attached file', + ), + array( + 'key' => '_wp_attachment_metadata', + 'type' => 'json', + 'label' => 'Image dimensions / EXIF / sizes array', + ), + array( + 'key' => '_wp_attachment_image_alt', + 'type' => 'textarea', + 'label' => 'Alt text for accessibility', + ), + array( + 'key' => '_wp_attachment_caption', + 'type' => 'textarea', + 'label' => 'Attachment caption', + ), + ) + ); + } + + /** + * 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). + * + * @return void + */ + private static function register_nav_menu_item_group(): void { + TMDO_Entity_Registry::register_group( + 'post', + 'nav_menu_item', + array( + array( + 'key' => '_menu_item_type', + 'type' => 'enum', + 'options' => array( 'post_type', 'taxonomy', 'custom', 'post_type_archive' ), + 'label' => 'Menu item linking type', + ), + array( + 'key' => '_menu_item_menu_item_parent', + 'type' => 'integer', + 'label' => 'Parent menu item ID', + ), + array( + 'key' => '_menu_item_object_id', + 'type' => 'integer', + 'searchable' => true, + 'label' => 'Linked object ID', + ), + array( + 'key' => '_menu_item_object', + 'type' => 'text', + 'label' => 'Linked object slug (post_type/taxonomy)', + ), + array( + 'key' => '_menu_item_target', + 'type' => 'text', + 'label' => 'Link target attribute', + ), + array( + 'key' => '_menu_item_classes', + 'type' => 'json', + 'label' => 'CSS classes array', + ), + array( + 'key' => '_menu_item_xfn', + 'type' => 'text', + 'label' => 'XFN relationship', + ), + array( + 'key' => '_menu_item_url', + 'type' => 'textarea', + 'label' => 'Custom URL (for type=custom)', + ), + ) + ); + } +} diff --git a/includes/integrations/class-tmdo-term-comment-garbage-filter.php b/includes/integrations/class-tmdo-term-comment-garbage-filter.php new file mode 100644 index 0000000..859b0a6 --- /dev/null +++ b/includes/integrations/class-tmdo-term-comment-garbage-filter.php @@ -0,0 +1,229 @@ += DAY_IN_SECONDS ) { + update_option( self::OPT_DROPPED_COUNT, 1, false ); + update_option( self::OPT_DROPPED_RESET_AT, $now, false ); + return; + } + + $count = (int) get_option( self::OPT_DROPPED_COUNT, 0 ); + update_option( self::OPT_DROPPED_COUNT, $count + 1, false ); + } + + /** + * Get the current 24h rolling drop counter (for admin status panel). + * + * @return int + */ + public static function get_drop_count_24h(): int { + $reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 ); + if ( 0 === $reset_at || ( time() - $reset_at ) >= DAY_IN_SECONDS ) { + return 0; + } + return (int) get_option( self::OPT_DROPPED_COUNT, 0 ); + } +} diff --git a/includes/integrations/class-tmdo-term-comment-misc-bucket.php b/includes/integrations/class-tmdo-term-comment-misc-bucket.php new file mode 100644 index 0000000..c3116bc --- /dev/null +++ b/includes/integrations/class-tmdo-term-comment-misc-bucket.php @@ -0,0 +1,412 @@ +prefix . 'wpdo_term_misc'; + } + + /** + * Comment-side fully-qualified misc table name. + * + * @return string + */ + public static function comment_table(): string { + global $wpdb; + return $wpdb->prefix . 'wpdo_comment_misc'; + } + + // ── Term metadata callbacks ─────────────────────────────────────────────── + + /** + * Filter callback: get_term_metadata. + * + * @param mixed $pre Filter accumulator. + * @param int $object_id Term ID. + * @param string $meta_key Meta key being read. + * @param bool $single Whether single value was requested. + * @return mixed + */ + public static function on_term_read( $pre, $object_id, $meta_key, $single ) { + unset( $single ); + // Only handle when no earlier filter has resolved this read. + if ( null !== $pre ) { + return $pre; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $pre; + } + $value = self::read( self::term_table(), 'term_id', (int) $object_id, $meta_key ); + if ( null === $value ) { + return $pre; + } + return array( $value ); + } + + /** + * Filter callback: add_term_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Term ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value. + * @param bool $unique Unique flag (unused). + * @return mixed + */ + public static function on_term_add( $check, $object_id, $meta_key, $meta_value, $unique ) { + unset( $unique ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: update_term_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Term ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value. + * @param mixed $prev_value Previous value (unused). + * @return mixed + */ + public static function on_term_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { + unset( $prev_value ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: delete_term_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Term ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value-scoped delete (unused). + * @param bool $delete_all Delete-all flag (unused). + * @return mixed + */ + public static function on_term_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) { + unset( $meta_value, $delete_all ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::delete_row( self::term_table(), 'term_id', (int) $object_id, $meta_key ); + return true; + } + + // ── Comment metadata callbacks ──────────────────────────────────────────── + + /** + * Filter callback: get_comment_metadata. + * + * @param mixed $pre Filter accumulator. + * @param int $object_id Comment ID. + * @param string $meta_key Meta key. + * @param bool $single Single flag (unused). + * @return mixed + */ + public static function on_comment_read( $pre, $object_id, $meta_key, $single ) { + unset( $single ); + if ( null !== $pre ) { + return $pre; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $pre; + } + $value = self::read( self::comment_table(), 'comment_id', (int) $object_id, $meta_key ); + if ( null === $value ) { + return $pre; + } + return array( $value ); + } + + /** + * Filter callback: add_comment_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Comment ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value. + * @param bool $unique Unique flag (unused). + * @return mixed + */ + public static function on_comment_add( $check, $object_id, $meta_key, $meta_value, $unique ) { + unset( $unique ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: update_comment_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Comment ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value. + * @param mixed $prev_value Previous value (unused). + * @return mixed + */ + public static function on_comment_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { + unset( $prev_value ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value ); + return true; + } + + /** + * Filter callback: delete_comment_metadata. + * + * @param mixed $check Filter accumulator. + * @param int $object_id Comment ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value-scoped (unused). + * @param bool $delete_all Delete-all flag (unused). + * @return mixed + */ + public static function on_comment_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) { + unset( $meta_value, $delete_all ); + if ( null !== $check ) { + return $check; + } + if ( ! is_string( $meta_key ) || '' === $meta_key ) { + return $check; + } + self::delete_row( self::comment_table(), 'comment_id', (int) $object_id, $meta_key ); + return true; + } + + // ── Internal storage helpers ────────────────────────────────────────────── + + /** + * Read a value from a misc bucket table. + * + * Returns the raw stored value (string), or null when no row exists. + * Caller must wrap in array for the get_*_metadata filter contract. + * + * @param string $table Fully-qualified table name. + * @param string $id_column 'term_id' or 'comment_id'. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @return string|null + */ + private static function read( string $table, string $id_column, int $object_id, string $meta_key ): ?string { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $object_id, + $meta_key + ) + ); + // phpcs:enable + return ( null === $value ) ? null : (string) $value; + } + + /** + * Write (INSERT or UPDATE) a value to a misc bucket table. + * + * Uses ON DUPLICATE KEY UPDATE — the (id, meta_key) primary key + * means each (entity, meta_key) pair has a single canonical row. + * + * @param string $table Fully-qualified table name. + * @param string $id_column 'term_id' or 'comment_id'. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value to store. Non-scalar types are serialized. + * @return void + */ + private static function write( string $table, string $id_column, int $object_id, string $meta_key, $meta_value ): void { + global $wpdb; + + // Match WP convention: serialize arrays/objects, scalar values stored as-is. + $serialized = is_scalar( $meta_value ) || null === $meta_value + ? (string) $meta_value + : maybe_serialize( $meta_value ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( + $wpdb->prepare( + "INSERT INTO `{$table}` (`{$id_column}`, meta_key, meta_value) VALUES (%d, %s, %s) + ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)", + $object_id, + $meta_key, + $serialized + ) + ); + // phpcs:enable + } + + /** + * Delete the row matching (object_id, meta_key) from a misc bucket table. + * + * @param string $table Fully-qualified table name. + * @param string $id_column 'term_id' or 'comment_id'. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @return void + */ + private static function delete_row( string $table, string $id_column, int $object_id, string $meta_key ): void { + global $wpdb; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $object_id, + $meta_key + ) + ); + // phpcs:enable + } + + /** + * Count rows in a misc bucket table (for admin status panel). + * + * @param string $entity_type 'term' or 'comment'. + * @return int + */ + public static function count_rows( string $entity_type ): int { + global $wpdb; + $table = 'comment' === $entity_type ? self::comment_table() : self::term_table(); + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $exists = (bool) $wpdb->get_var( + $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) + ); + if ( ! $exists ) { + return 0; + } + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + // phpcs:enable + } +} diff --git a/includes/interceptors/class-tmdo-interceptor-base.php b/includes/interceptors/class-tmdo-interceptor-base.php new file mode 100644 index 0000000..c8b5810 --- /dev/null +++ b/includes/interceptors/class-tmdo-interceptor-base.php @@ -0,0 +1,195 @@ +module ); + } + + /** + * Return true when dual-write is active (writes go to both native + custom). + * + * @return bool True if module is in a write-active state. + */ + protected function is_write_active(): bool { + return TMDO_Feature_Flags::is_write_active( $this->module ); + } + + /** + * Return true when the module should intercept reads or writes. + * + * @return bool True if module is enabled or write-active. + */ + protected function is_active(): bool { + return $this->is_enabled() || $this->is_write_active(); + } + + /** + * Execute a custom-table callable safely. + * + * If $custom throws, log and return $native_fallback(). + * After 3 consecutive errors in a request, disable the module. + * + * @param callable $custom Custom table read/write callable. + * @param callable $native_fallback Original WordPress callable. + * @param string $hook Hook name for logging context. + * @return mixed + * @throws \RuntimeException When the custom callable returns a WP_Error. + */ + protected function intercept( callable $custom, callable $native_fallback, string $hook = '' ): mixed { + if ( ! $this->is_enabled() ) { + return $native_fallback(); + } + + try { + $result = $custom(); + + if ( is_wp_error( $result ) ) { + throw new \RuntimeException( $result->get_error_message() ); + } + + return $result; + + } catch ( \Throwable $e ) { + $this->handle_error( + $hook ?: 'intercept', + $e->getMessage(), + array( + 'exception' => get_class( $e ), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ) + ); + + return $native_fallback(); + } + } + + /** + * Dual-write: call native first, then sync to custom table. + * Custom failure is non-fatal. + * + * @param callable $native Original write callable (always executed). + * @param callable $custom Custom table write callable. + * @param string $hook Hook name for logging. + * @return mixed Return value of $native. + */ + protected function dual_write( callable $native, callable $custom, string $hook = '' ): mixed { + $result = $native(); + + if ( $this->is_write_active() || $this->is_enabled() ) { + try { + $custom( $result ); + } catch ( \Throwable $e ) { + $this->handle_error( + $hook ?: 'dual_write', + $e->getMessage(), + array( + 'exception' => get_class( $e ), + ) + ); + } + } + + return $result; + } + + /** + * Register all hooks. Called by TMDO_Core. + * + * @return void + */ + abstract public function register_hooks(): void; + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Per-request consecutive error counter, keyed by module. + * + * @var array + */ + private static array $error_counts = array(); + + /** + * Handles an interceptor error and auto-disables the module after 3 consecutive errors. + * + * @param string $hook Hook name for logging context. + * @param string $message Error message. + * @param array $context Additional context data. + * @return void + */ + private function handle_error( string $hook, string $message, array $context = array() ): void { + TMDO_Logger::error( $this->module, $hook, $message, $context ); + + self::$error_counts[ $this->module ] = ( self::$error_counts[ $this->module ] ?? 0 ) + 1; + + if ( self::$error_counts[ $this->module ] >= 3 ) { + TMDO_Feature_Flags::reset( $this->module ); + TMDO_Logger::error( $this->module, $hook, 'Module auto-disabled after 3 consecutive errors.' ); + + if ( $this->email_on_error ) { + $this->notify_admin( $message ); + $this->email_on_error = false; + } + } + } + + /** + * Sends an admin notification email when a module is auto-disabled. + * + * @param string $message Error message to include in the notification. + * @return void + */ + private function notify_admin( string $message ): void { + $admin_email = get_option( 'admin_email' ); + if ( ! $admin_email ) { + return; + } + + wp_mail( + $admin_email, + sprintf( '[WPDO] Module "%s" auto-disabled', $this->module ), + sprintf( + "The WPDO module \"%s\" has been automatically disabled due to repeated errors.\n\nLast error: %s\n\nPlease review the error log at Tools > WP Data Optimizer > Logs.", + $this->module, + $message + ) + ); + } +} diff --git a/includes/interceptors/class-tmdo-sync-bridge.php b/includes/interceptors/class-tmdo-sync-bridge.php new file mode 100644 index 0000000..94c39b4 --- /dev/null +++ b/includes/interceptors/class-tmdo-sync-bridge.php @@ -0,0 +1,400 @@ + field|false). + * + * @var array + */ + private static array $field_cache = array(); + + /** + * Register all metadata hooks. + */ + public function register_hooks(): void { + add_filter( 'get_post_metadata', array( $this, 'intercept_get' ), 10, 5 ); + add_filter( 'update_post_metadata', array( $this, 'intercept_update' ), 10, 5 ); + add_filter( 'add_post_metadata', array( $this, 'intercept_add' ), 10, 5 ); + add_action( 'deleted_post_meta', array( $this, 'intercept_delete' ), 10, 4 ); + add_action( 'before_delete_post', array( $this, 'cleanup_post' ), 10, 1 ); + } + + /** + * Intercept get_post_meta — read from zone table when module is in read-custom state. + * + * @param mixed $value Existing filtered value (null by default). + * @param int $post_id Post ID. + * @param string $meta_key Meta key (empty = get all). + * @param bool $single Whether to return single value. + * @param string $meta_type Meta type (always 'post'). + * @return mixed + */ + public function intercept_get( $value, int $post_id, string $meta_key, bool $single, string $meta_type = 'post' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by get_post_metadata filter signature. + if ( self::$bypassing || empty( $meta_key ) || $post_id <= 0 ) { + return $value; + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return $value; + } + + $field = $this->get_field_cached( $post_type, $meta_key ); + if ( ! $field ) { + return $value; + } + + $module = $this->get_zone_module( $field['zone'], $post_type ); + if ( ! TMDO_Feature_Flags::is_read_custom( $module ) ) { + return $value; + } + + try { + $zone_value = $this->read_from_zone( $field, $post_id, $post_type, $meta_key ); + + if ( null === $zone_value ) { + return $value; + } + + // phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- This is an explanatory comment, not commented-out code. + // Wrap in array: WP unwraps $check[0] for $single=true, casts (array)$check for $single=false. + return array( $zone_value ); + + } catch ( \Throwable $e ) { + TMDO_Logger::error( $module, 'get_post_metadata', $e->getMessage() ); + return $value; + } + } + + /** + * Intercept update_post_meta — dual-write to zone table. + * + * @param null|bool $check Whether to short-circuit (null = proceed). + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous value. + * @return null|bool + */ + public function intercept_update( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + if ( self::$bypassing || $post_id <= 0 ) { + return $check; + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return $check; + } + + // v2.9.2 Entity Bridge guard: when post mode is dual_write or higher + // AND the key is registered in the new Entity Registry, the unified + // Hook Bus is the source of truth — Sync_Bridge must not also write + // to the legacy zone table to avoid duplicate flat writes. + if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + return $check; + } + + $field = $this->get_field_cached( $post_type, $meta_key ); + if ( ! $field ) { + return $check; + } + + $module = $this->get_zone_module( $field['zone'], $post_type ); + if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + return $check; + } + + // Write to zone table (non-fatal on failure). + try { + $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $module, 'update_post_metadata', $e->getMessage() ); + } + + // Return null — let WordPress proceed with native postmeta write. + // In cleanup/complete states, we could skip native write, but for safety + // we always allow it during zone migration lifecycle. + return $check; + } + + /** + * Whether the given post meta_key is now owned by the Entity Bridge, + * meaning Sync_Bridge should defer to the unified Hook Bus and skip its + * zone write to avoid duplicate flat writes. + * + * Returns true only when ALL of: + * - TMDO_Mode_Manager and TMDO_Entity_Registry classes exist + * - post mode is dual_write or higher (writes_to_flat returns true) + * - the key is registered for entity_type=post + * + * Default post mode is `disabled`, so this returns false in all + * environments that have not opted in to Entity Bridge — keeping the + * legacy Sync_Bridge → zone path unchanged. + * + * @param string $meta_key Meta key being written. + * @return bool + * @since 2.9.2 + */ + private static function is_owned_by_entity_bridge( string $meta_key ): bool { + if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) ) { + return false; + } + if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) ) { + return false; + } + return null !== TMDO_Entity_Registry::get_field( 'post', $meta_key ); + } + + /** + * Intercept add_post_meta — dual-write to zone table. + * + * @param mixed $check Whether to short-circuit. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $unique Whether the meta key should be unique. Not used directly. + * @return mixed Filtered check value. + */ + public function intercept_add( $check, int $post_id, string $meta_key, $meta_value, $unique ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by add_post_metadata filter signature. + if ( self::$bypassing || $post_id <= 0 ) { + return $check; + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return $check; + } + + // v2.9.2 Entity Bridge guard — see is_owned_by_entity_bridge() docblock. + if ( self::is_owned_by_entity_bridge( $meta_key ) ) { + return $check; + } + + $field = $this->get_field_cached( $post_type, $meta_key ); + if ( ! $field ) { + return $check; + } + + $module = $this->get_zone_module( $field['zone'], $post_type ); + if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + return $check; + } + + try { + $this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $module, 'add_post_metadata', $e->getMessage() ); + } + + return $check; + } + + /** + * After a postmeta is deleted, remove from zone table too. + * + * Hooked to 'deleted_post_meta' (fires after native delete completes). + * + * @param int[] $meta_ids Array of deleted meta IDs. + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. Not used directly. + * @return void + */ + public function intercept_delete( $meta_ids, int $post_id, string $meta_key, $meta_value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by deleted_post_meta action signature. + if ( self::$bypassing || $post_id <= 0 ) { + return; + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return; + } + + $field = $this->get_field_cached( $post_type, $meta_key ); + if ( ! $field ) { + return; + } + + $module = $this->get_zone_module( $field['zone'], $post_type ); + if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) { + return; + } + + try { + $this->delete_from_zone( $field, $post_id, $post_type, $meta_key ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( $module, 'deleted_post_meta', $e->getMessage() ); + } + } + + /** + * When a post is permanently deleted, clean up all zone data. + * + * @param int $post_id Post ID being deleted. + * @return void + */ + public function cleanup_post( int $post_id ): void { + $post_type = get_post_type( $post_id ); + if ( ! $post_type ) { + return; + } + + $registry = TMDO_Schema_Registry::instance(); + + // Clean Hot zone. + if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { + try { + TMDO_Zone_Hot::delete( $post_id, $post_type ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'hot_' . $post_type, 'before_delete_post', $e->getMessage() ); + } + } + + // Clean Cold zone. + if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { + try { + TMDO_Zone_Cold::delete( $post_id, $post_type ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'cold_' . $post_type, 'before_delete_post', $e->getMessage() ); + } + } + + // Clean Warm zone. + try { + TMDO_Zone_Warm::delete_all( $post_id ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'warm', 'before_delete_post', $e->getMessage() ); + } + + // Clean Archive zone. + try { + TMDO_Zone_Archive::delete( $post_id ); + } catch ( \Throwable $e ) { + TMDO_Logger::error( 'archive', 'before_delete_post', $e->getMessage() ); + } + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Get a registered field with a request-level static cache. + * + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return array|null Field definition, or null if not registered. + */ + private function get_field_cached( string $post_type, string $meta_key ): ?array { + $cache_key = $post_type . ':' . $meta_key; + if ( ! array_key_exists( $cache_key, self::$field_cache ) ) { + self::$field_cache[ $cache_key ] = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key ); + } + return self::$field_cache[ $cache_key ]; + } + + /** + * Derive module name from zone + post_type for feature flag lookups. + * + * @param string $zone Zone identifier (hot, cold, warm, archive). + * @param string $post_type Post type. + * @return string Module name. + */ + private function get_zone_module( string $zone, string $post_type ): string { + return match ( $zone ) { + 'hot' => 'hot_' . sanitize_key( $post_type ), + 'cold' => 'cold_' . sanitize_key( $post_type ), + 'warm' => 'warm', + 'archive' => 'archive', + default => 'unknown', + }; + } + + /** + * Read a value from the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return mixed Value from zone or null. + */ + private function read_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): mixed { + return match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ), + 'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ), + 'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ), + default => null, + }; + } + + /** + * Write a value to the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @param mixed $value Value to write. + * @return void + */ + private function write_to_zone( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void { + match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ), + 'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ), + 'warm' => TMDO_Zone_Warm::set( + $post_id, + $meta_key, + is_string( $value ) ? $value : wp_json_encode( $value ), + $field['ttl'] ?? null + ), + default => null, + }; + } + + /** + * Delete a value from the appropriate zone. + * + * @param array $field Field definition from Schema Registry. + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return void + */ + private function delete_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): void { + match ( $field['zone'] ) { + 'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ), + 'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ), + 'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ), + default => null, + }; + } +} diff --git a/includes/migration/class-tmdo-archive-migration.php b/includes/migration/class-tmdo-archive-migration.php new file mode 100644 index 0000000..0c68878 --- /dev/null +++ b/includes/migration/class-tmdo-archive-migration.php @@ -0,0 +1,168 @@ +days = $days; + $this->compress = $compress; + } + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + public function get_module(): string { + return 'archive'; + } + + /** + * Returns the zone identifier. + * + * @return string Zone name. + */ + public function get_zone(): string { + return 'archive'; + } + + /** + * Returns the total number of archive-eligible postmeta rows. + * + * @return int Total row count. + */ + protected function count_source(): int { + global $wpdb; + + $cutoff = $this->get_cutoff(); + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_status = 'trash' + AND p.post_modified_gmt < %s", + $cutoff + ) + ); + } + + /** + * Migrates one batch of eligible postmeta rows to the archive table. + * + * @param int $offset Starting row offset. + * @return int Number of rows processed. + */ + protected function migrate_batch( int $offset ): int { + global $wpdb; + + $cutoff = $this->get_cutoff(); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_status = 'trash' + AND p.post_modified_gmt < %s + ORDER BY pm.meta_id ASC + LIMIT %d OFFSET %d", + $cutoff, + self::BATCH_SIZE, + $offset + ), + ARRAY_A + ); + + if ( empty( $rows ) ) { + return 0; + } + + $entries = array(); + foreach ( $rows as $row ) { + $entries[] = array( + 'post_id' => $row['post_id'], + 'post_type' => $row['post_type'], + 'meta_key' => $row['meta_key'], + 'meta_value' => $row['meta_value'], + 'meta_id' => $row['meta_id'], + ); + } + + TMDO_Zone_Archive::archive_batch( $entries, $this->compress ); + + return count( $rows ); + } + + /** + * Verifies that archive table has rows when eligible source rows exist. + * + * @return bool True if verification passes. + */ + public function verify_counts(): bool { + global $wpdb; + + $source = $this->count_source(); + $table = TMDO_Zone_Archive::table(); + $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Archive::table() + + // Archive may have more rows than current source (trashed posts may have + // been permanently deleted after archival). So we just check target > 0 + // when source is 0, or target >= some threshold. + if ( 0 === $source ) { + return true; + } + + return $target >= $source; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Returns the cutoff datetime string for archival eligibility. + * + * @return string MySQL datetime string. + */ + private function get_cutoff(): string { + return gmdate( 'Y-m-d H:i:s', time() - ( $this->days * DAY_IN_SECONDS ) ); + } +} diff --git a/includes/migration/class-tmdo-cold-migration.php b/includes/migration/class-tmdo-cold-migration.php new file mode 100644 index 0000000..7512ae6 --- /dev/null +++ b/includes/migration/class-tmdo-cold-migration.php @@ -0,0 +1,166 @@ +post_type = $post_type; + } + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + public function get_module(): string { + return 'cold_' . sanitize_key( $this->post_type ); + } + + /** + * Returns the zone identifier. + * + * @return string Zone name. + */ + public function get_zone(): string { + return 'cold'; + } + + /** + * Returns the total number of posts to migrate. + * + * @return int Total post count. + */ + protected function count_source(): int { + global $wpdb; + + $meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type ); + if ( empty( $meta_keys ) ) { + return 0; + } + + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + $args = array_merge( array( $this->post_type ), $meta_keys ); + + // Count distinct posts that have at least one cold field. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties. + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT pm.post_id) + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})", + ...$args + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } + + /** + * Migrates one batch of posts to the cold table. + * + * @param int $offset Starting post offset. + * @return int Number of posts processed. + */ + protected function migrate_batch( int $offset ): int { + global $wpdb; + + $meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type ); + if ( empty( $meta_keys ) ) { + return 0; + } + + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + + // Get batch of distinct post IDs. + $args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties. + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT pm.post_id + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders}) + ORDER BY pm.post_id ASC + LIMIT %d OFFSET %d", + ...$args + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + // Ensure cold table exists before any write (even if this batch is empty). + TMDO_Zone_Cold::ensure_table( $this->post_type ); + + if ( empty( $post_ids ) ) { + return 0; + } + + // Fetch all cold meta for these posts. + $id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $meta_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_key, meta_value + FROM {$wpdb->postmeta} + WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})", + ...array_merge( array_map( 'intval', $post_ids ), $meta_keys ) + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + // Group by post_id. + $grouped = array(); + foreach ( $meta_rows ?: array() as $row ) { + $grouped[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value']; + } + + // Write each post's cold data as a JSON blob. + foreach ( $grouped as $pid => $data ) { + TMDO_Zone_Cold::set_many( (int) $pid, $this->post_type, $data ); + } + + return count( $post_ids ); + } + + /** + * Verifies that the cold table row count is at least as large as the source. + * + * @return bool True if verification passes. + */ + public function verify_counts(): bool { + global $wpdb; + + $source = $this->count_source(); + $table = TMDO_Zone_Cold::table( $this->post_type ); + $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Cold::table() + + return $target >= $source; + } +} diff --git a/includes/migration/class-tmdo-hot-migration.php b/includes/migration/class-tmdo-hot-migration.php new file mode 100644 index 0000000..dc4ec0e --- /dev/null +++ b/includes/migration/class-tmdo-hot-migration.php @@ -0,0 +1,191 @@ +post_type = $post_type; + } + + /** + * Returns the module identifier. + * + * @return string Module name. + */ + public function get_module(): string { + return 'hot_' . sanitize_key( $this->post_type ); + } + + /** + * Returns the zone identifier. + * + * @return string Zone name. + */ + public function get_zone(): string { + return 'hot'; + } + + /** + * Returns the total number of posts to migrate. + * + * @return int Total post count. + */ + protected function count_source(): int { + global $wpdb; + + $meta_keys = $this->get_meta_keys(); + if ( empty( $meta_keys ) ) { + return 0; + } + + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + $args = array_merge( array( $this->post_type ), $meta_keys ); + + // Count distinct posts that have at least one hot field. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders built from array_fill with %s; $wpdb->postmeta/$wpdb->posts are core properties. + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT pm.post_id) + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})", + ...$args + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } + + /** + * Migrates one batch of posts to the hot table. + * + * @param int $offset Starting post offset. + * @return int Number of posts processed. + */ + protected function migrate_batch( int $offset ): int { + global $wpdb; + + $meta_keys = $this->get_meta_keys(); + if ( empty( $meta_keys ) ) { + return 0; + } + + $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $this->post_type ); + $key_map = $this->build_key_to_column_map(); + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + + // Get batch of distinct post IDs. + $args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders built from array_fill; $wpdb->postmeta/$wpdb->posts are core properties. + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT pm.post_id + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders}) + ORDER BY pm.post_id ASC + LIMIT %d OFFSET %d", + ...$args + ) + ); + + if ( empty( $post_ids ) ) { + return 0; + } + + // Ensure hot table exists. + TMDO_Zone_Hot::ensure_table( $this->post_type ); + + // For each post, gather all hot meta and upsert. + $id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); + + $meta_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_key, meta_value + FROM {$wpdb->postmeta} + WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})", + ...array_merge( array_map( 'intval', $post_ids ), $meta_keys ) + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + // Group by post_id. + $grouped = array(); + foreach ( $meta_rows ?: array() as $row ) { + $col = $key_map[ $row['meta_key'] ] ?? null; + if ( $col ) { + $grouped[ $row['post_id'] ][ $col ] = $row['meta_value']; + } + } + + // Upsert each post's hot data. + foreach ( $grouped as $pid => $data ) { + TMDO_Zone_Hot::set_many( (int) $pid, $this->post_type, $data ); + } + + return count( $post_ids ); + } + + /** + * Verifies that the hot table row count is at least as large as the source. + * + * @return bool True if verification passes. + */ + public function verify_counts(): bool { + global $wpdb; + + $source = $this->count_source(); + $table = TMDO_Zone_Hot::table( $this->post_type ); + $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Hot::table() + + return $target >= $source; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Get all registered hot meta_keys for this post type. + */ + private function get_meta_keys(): array { + $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type ); + return array_column( $fields, 'meta_key' ); + } + + /** + * Build meta_key → column_name map. + */ + private function build_key_to_column_map(): array { + $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type ); + $map = array(); + foreach ( $fields as $field ) { + $map[ $field['meta_key'] ] = $field['column']; + } + return $map; + } +} diff --git a/includes/migration/class-tmdo-migration-base.php b/includes/migration/class-tmdo-migration-base.php new file mode 100644 index 0000000..6b5c80e --- /dev/null +++ b/includes/migration/class-tmdo-migration-base.php @@ -0,0 +1,287 @@ += native count + * + * Migration record is tracked in wpdo_migrations table. + * Batch size: 500 rows. Timeout: 28 seconds per run. + */ +abstract class TMDO_Migration_Base { + + protected const BATCH_SIZE = 500; + protected const TIMEOUT = 28; // Seconds. + + /** + * Returns the module identifier for this migration. + * + * @return string Module name. + */ + abstract public function get_module(): string; + + /** + * Zone identifier. Empty string for HPCT-inherited modules. + */ + public function get_zone(): string { + return ''; + } + + /** + * Returns the total number of source rows to migrate. + * + * @return int Total row count. + */ + abstract protected function count_source(): int; + + /** + * Migrate one batch of rows. + * + * @param int $offset Starting row offset. + * @return int Number of rows processed in this batch. + */ + abstract protected function migrate_batch( int $offset ): int; + + /** + * Verify that the custom table count is >= native count. + */ + abstract public function verify_counts(): bool; + + // ── Migration record helpers ────────────────────────────────────────── + + /** + * Retrieves the current migration record from the database. + * + * @return array|null Migration record array, or null if not found. + */ + public function get_record(): ?array { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM `{$table}` WHERE module = %s", $this->get_module() ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table(). + ARRAY_A + ); + + return $row ?: null; + } + + /** + * Initialize or reset the migration record. + */ + public function init_record(): void { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + $now = TMDO_DB::now(); + $total = $this->count_source(); + + $existing = $this->get_record(); + + if ( $existing ) { + $wpdb->update( + $table, + array( + 'state' => 'backfill', + 'zone' => $this->get_zone(), + 'total_rows' => $total, + 'processed_rows' => 0, + 'last_offset' => 0, + 'error_count' => 0, + 'started_at' => $now, + 'completed_at' => null, + 'updated_at' => $now, + ), + array( 'module' => $this->get_module() ), + array( '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ), + array( '%s' ) + ); + } else { + $wpdb->insert( + $table, + array( + 'module' => $this->get_module(), + 'zone' => $this->get_zone(), + 'state' => 'backfill', + 'total_rows' => $total, + 'processed_rows' => 0, + 'last_offset' => 0, + 'error_count' => 0, + 'started_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ) + ); + } + } + + /** + * Resume an existing migration (does NOT reset processed_rows). + */ + public function resume_record(): void { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + + $wpdb->update( + $table, + array( + 'state' => 'backfill', + 'updated_at' => TMDO_DB::now(), + ), + array( 'module' => $this->get_module() ), + array( '%s', '%s' ), + array( '%s' ) + ); + } + + /** + * Update progress after each batch. + * + * @param int $processed Number of rows processed in this batch. + * @param int $last_offset Last row offset processed. + * @return void + */ + public function update_progress( int $processed, int $last_offset ): void { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table(). + $wpdb->query( + $wpdb->prepare( + "UPDATE `{$table}` SET + processed_rows = processed_rows + %d, + last_offset = %d, + updated_at = %s + WHERE module = %s", + $processed, + $last_offset, + TMDO_DB::now(), + $this->get_module() + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Mark migration as completed (state → verify). + */ + public function mark_complete(): void { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + $now = TMDO_DB::now(); + + $wpdb->update( + $table, + array( + 'state' => 'verify', + 'completed_at' => $now, + 'updated_at' => $now, + ), + array( 'module' => $this->get_module() ), + array( '%s', '%s', '%s' ), + array( '%s' ) + ); + } + + /** + * Increment error count. + */ + public function increment_errors(): void { + global $wpdb; + $table = TMDO_DB::table( 'wpdo_migrations' ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table(). + $wpdb->query( + $wpdb->prepare( + "UPDATE `{$table}` SET error_count = error_count + 1, updated_at = %s WHERE module = %s", + TMDO_DB::now(), + $this->get_module() + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Run the migration in batches. Stops after ~28 seconds. + * + * @param bool $resume If true, resumes from last_offset. + * @param callable|null $progress_callback Called after each batch with (processed, total). + * @return bool True if migration completed, false if timed out (resume needed). + */ + public function run( bool $resume = false, ?callable $progress_callback = null ): bool { + // Set feature flag to dual_write before starting. + TMDO_Feature_Flags::set( $this->get_module(), 'dual_write' ); + + if ( $resume ) { + $record = $this->get_record(); + $offset = (int) ( $record['last_offset'] ?? 0 ); + $this->resume_record(); + } else { + $offset = 0; + $this->init_record(); + } + + // Advance to backfill state. + TMDO_Feature_Flags::set( $this->get_module(), 'backfill' ); + + $start = microtime( true ); + + do { + try { + $batch_count = $this->migrate_batch( $offset ); + } catch ( \Throwable $e ) { + $this->increment_errors(); + TMDO_Logger::error( + $this->get_module(), + 'migrate_batch', + $e->getMessage(), + array( + 'offset' => $offset, + 'zone' => $this->get_zone(), + ) + ); + $batch_count = 0; + + // Check if too many errors. + $record = $this->get_record(); + if ( $record && (int) $record['error_count'] >= 10 ) { + TMDO_Feature_Flags::reset( $this->get_module() ); + return false; + } + } + + if ( $batch_count > 0 ) { + $this->update_progress( $batch_count, $offset + $batch_count ); + $offset += $batch_count; + } + + if ( $progress_callback ) { + $record = $this->get_record(); + $progress_callback( (int) $record['processed_rows'], (int) $record['total_rows'] ); + } + + if ( ( microtime( true ) - $start ) > self::TIMEOUT ) { + return false; // Timed out — next run will resume. + } + } while ( $batch_count >= self::BATCH_SIZE ); + + // Backfill done → move to verify. + $this->mark_complete(); + TMDO_Feature_Flags::set( $this->get_module(), 'verify' ); + + return true; + } +} diff --git a/includes/migration/class-tmdo-migration-engine.php b/includes/migration/class-tmdo-migration-engine.php new file mode 100644 index 0000000..8e91104 --- /dev/null +++ b/includes/migration/class-tmdo-migration-engine.php @@ -0,0 +1,329 @@ + array( 'dual_write' ), + 'dual_write' => array( 'backfill', 'idle' ), + 'backfill' => array( 'verify', 'dual_write', 'idle' ), + 'verify' => array( 'cutover', 'dual_write', 'idle' ), + 'cutover' => array( 'cleanup', 'idle' ), + 'cleanup' => array( 'complete', 'idle' ), + 'complete' => array( 'idle' ), + ); + + /** + * Registry of migration class instances, keyed by module name. + * + * @var array + */ + private static array $migrations = array(); + + /** + * Register a migration class for a module. + * + * @param string $module Module identifier. + * @param TMDO_Migration_Base $migration Migration instance. + * @return void + */ + public static function register( string $module, TMDO_Migration_Base $migration ): void { + self::$migrations[ $module ] = $migration; + } + + /** + * Get the migration instance for a module. + * + * @param string $module Module identifier. + * @return TMDO_Migration_Base|null Migration instance, or null if not found. + */ + public static function get_migration( string $module ): ?TMDO_Migration_Base { + return self::$migrations[ $module ] ?? null; + } + + /** + * Get all registered migration instances. + * + * @return array + */ + public static function all(): array { + return self::$migrations; + } + + // ── State transitions ───────────────────────────────────────────────── + + /** + * Check if a state transition is valid. + * + * @param string $module Module identifier. + * @param string $target_state Target state to transition to. + * @return bool True if the transition is allowed. + */ + public static function can_transition( string $module, string $target_state ): bool { + $current = TMDO_Feature_Flags::get( $module ); + + // Rollback to idle is always allowed. + if ( 'idle' === $target_state ) { + return true; + } + + $allowed = self::TRANSITIONS[ $current ] ?? array(); + return in_array( $target_state, $allowed, true ); + } + + /** + * Transition a module to a new state. + * + * @param string $module Module identifier. + * @param string $target_state Target state to transition to. + * @return bool True on success, false if transition is invalid. + */ + public static function transition( string $module, string $target_state ): bool { + if ( ! self::can_transition( $module, $target_state ) ) { + return false; + } + + return TMDO_Feature_Flags::set( $module, $target_state ); + } + + // ── High-level operations ───────────────────────────────────────────── + + /** + * Start or resume migration for a module. + * + * Flow: idle → dual_write → backfill (run batches) + * + * @param string $module Module name. + * @param callable|null $progress_callback Called after each batch. + * @return array{status: string, message: string} + */ + public static function migrate( string $module, ?callable $progress_callback = null ): array { + $migration = self::get_migration( $module ); + if ( ! $migration ) { + return array( + 'status' => 'error', + 'message' => "No migration registered for module: {$module}", + ); + } + + $current = TMDO_Feature_Flags::get( $module ); + + // Already in backfill — resume. + if ( 'backfill' === $current ) { + $completed = $migration->run( true, $progress_callback ); + return array( + 'status' => $completed ? 'verify' : 'backfill', + 'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.', + ); + } + + // Already past backfill. + if ( in_array( $current, array( 'verify', 'cutover', 'cleanup', 'complete' ), true ) ) { + return array( + 'status' => $current, + 'message' => "Module is already in state: {$current}.", + ); + } + + // Start fresh: idle or dual_write → backfill. + if ( ! self::transition( $module, 'dual_write' ) && 'dual_write' !== $current ) { + return array( + 'status' => 'error', + 'message' => "Cannot start migration from state: {$current}", + ); + } + + $completed = $migration->run( false, $progress_callback ); + + return array( + 'status' => $completed ? 'verify' : 'backfill', + 'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.', + ); + } + + /** + * Verify data consistency for a module. + * + * @param string $module Module identifier. + * @return array{status: string, message: string, verified: bool} Verification result. + */ + public static function verify( string $module ): array { + $migration = self::get_migration( $module ); + if ( ! $migration ) { + return array( + 'status' => 'error', + 'message' => "No migration registered for module: {$module}", + 'verified' => false, + ); + } + + $current = TMDO_Feature_Flags::get( $module ); + if ( 'verify' !== $current ) { + return array( + 'status' => 'error', + 'message' => "Module must be in 'verify' state. Current: {$current}", + 'verified' => false, + ); + } + + $ok = $migration->verify_counts(); + + if ( ! $ok ) { + // Verification failed — allow retry via dual_write → backfill. + self::transition( $module, 'dual_write' ); + return array( + 'status' => 'dual_write', + 'message' => 'Verification failed. Rolled back to dual_write for retry.', + 'verified' => false, + ); + } + + return array( + 'status' => 'verify', + 'message' => 'Verification passed. Ready for cutover.', + 'verified' => true, + ); + } + + /** + * Cutover: switch reads to the custom table. + * + * @param string $module Module identifier. + * @return array{status: string, message: string} Operation result. + */ + public static function cutover( string $module ): array { + $current = TMDO_Feature_Flags::get( $module ); + + if ( 'verify' !== $current ) { + return array( + 'status' => 'error', + 'message' => "Module must be in 'verify' state. Current: {$current}", + ); + } + + self::transition( $module, 'cutover' ); + + return array( + 'status' => 'cutover', + 'message' => 'Cutover complete. Reads now come from the custom table. Run cleanup when ready.', + ); + } + + /** + * Rollback: return to idle state from any state. + * + * @param string $module Module identifier. + * @return array{status: string, message: string} Operation result. + */ + public static function rollback( string $module ): array { + $current = TMDO_Feature_Flags::get( $module ); + + if ( 'idle' === $current ) { + return array( + 'status' => 'idle', + 'message' => 'Module is already idle.', + ); + } + + TMDO_Feature_Flags::reset( $module ); + + return array( + 'status' => 'idle', + 'message' => "Module rolled back from '{$current}' to idle.", + ); + } + + /** + * Cleanup: stop writing to native postmeta. + * + * @param string $module Module identifier. + * @return array{status: string, message: string} Operation result. + */ + public static function cleanup( string $module ): array { + $current = TMDO_Feature_Flags::get( $module ); + + if ( 'cutover' !== $current ) { + return array( + 'status' => 'error', + 'message' => "Module must be in 'cutover' state. Current: {$current}", + ); + } + + self::transition( $module, 'cleanup' ); + + return array( + 'status' => 'cleanup', + 'message' => 'Cleanup started. Native postmeta writes are stopped. Run enable to complete.', + ); + } + + /** + * Enable: mark migration fully complete. + * + * @param string $module Module identifier. + * @return array{status: string, message: string} Operation result. + */ + public static function enable( string $module ): array { + $current = TMDO_Feature_Flags::get( $module ); + + if ( 'cleanup' !== $current ) { + return array( + 'status' => 'error', + 'message' => "Module must be in 'cleanup' state. Current: {$current}", + ); + } + + self::transition( $module, 'complete' ); + + return array( + 'status' => 'complete', + 'message' => 'Module fully enabled. All reads and writes use the custom table.', + ); + } + + /** + * Get comprehensive status for a module. + * + * @param string $module Module identifier. + * @return array{module: string, state: string, zone: string, record: ?array} Module status. + */ + public static function status( string $module ): array { + $migration = self::get_migration( $module ); + $record = $migration ? $migration->get_record() : null; + + return array( + 'module' => $module, + 'state' => TMDO_Feature_Flags::get( $module ), + 'zone' => $migration ? $migration->get_zone() : '', + 'record' => $record, + ); + } +} diff --git a/includes/migration/class-tmdo-migration-orchestrator.php b/includes/migration/class-tmdo-migration-orchestrator.php new file mode 100644 index 0000000..4172f44 --- /dev/null +++ b/includes/migration/class-tmdo-migration-orchestrator.php @@ -0,0 +1,1107 @@ +v2 legacy meta orchestration +/** + * One-click User Entity Migration Orchestrator. + * + * Drives the full demote → backfill → verify → promote → cleanup flow as a + * single state machine. Designed for both small (sync, <30s) and large + * (async via cron, minutes-to-hours) sites with auto-detection. + * + * Fast mode: bulk SQL pivot (INSERT...SELECT...GROUP BY) for text-only groups + * is 10-50× faster than per-row PHP loops. Per-row safe_unserialize fallback + * applies only to groups containing `json` fields (currently only `hp_user`). + * + * @package WP_Data_Optimizer + * @since 2.8.0 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable Squiz.Commenting.FunctionComment,Squiz.Commenting.VariableComment,Squiz.Commenting.ClassComment,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- Internal state-machine helpers and Throwable messages — not user output. SQL composed with sanitize_column_name() + $wpdb->prepare() values. + +/** + * One-click User Migration Orchestrator — drives the demote → backfill → + * verify → promote → cleanup state machine for the user entity. + * + * @since 2.8.0 + */ +final class TMDO_Migration_Orchestrator { + + const OPT_JOB = 'wpdo_migration_job'; + const OPT_LOCK = 'wpdo_migration_lock'; + const TRANS_PROGRESS = 'wpdo_migration_progress'; + const TRANS_NEEDS = 'wpdo_migration_needs_attention'; + const CRON_HOOK = 'wpdo_migration_tick'; + const BACKUP_DIR_REL = 'wpdo-backups'; + const MAX_LOG_LINES = 50; + const SYNC_THRESHOLD_SEC = 30; + const SYNC_DEADLINE_SEC = 110; + const VERIFY_SAMPLE_MIN = 500; + const VERIFY_SAMPLE_RATIO = 0.10; + const LOCK_TTL_SEC = 1800; + const NEEDS_TTL_SEC = 300; + const ENTITY_TYPE = 'user'; + + /** + * Phase order. Each phase is idempotent — re-entering returns ok if + * the desired state is already achieved. + */ + private const PHASES = array( + 'diagnose', + 'backup', + 'demote', + 'install_schema', + 'backfill_bulk', + 'backfill_unserialize', + 'promote_shadow', + 'verify_sample', + 'promote_aeav', + 'cleanup', + 'completed', + ); + + // ───────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────── + + /** + * Read-only inspection — returns what `start()` would do. + * + * @return array{managed_keys:array,eav_rows:int,users:int,usermeta:int,ratio:float,mode:string,groups:array,estimated_strategy:string,estimated_sec:float} + */ + public static function preflight(): array { + global $wpdb; + + $managed_keys = self::get_managed_keys(); + $eav_rows = self::count_eav_residue( $managed_keys ); + $users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); + $usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" ); + $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + + $groups = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ); + $has_json = self::group_has_json_field( $group ); + $residue = $keys ? self::count_eav_residue( $keys ) : 0; + $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group ); + $flat_rows = TMDO_Schema_Manager::table_exists( $flat_table ) + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) + : 0; + $groups[ $group ] = array( + 'keys' => $keys, + 'residue' => $residue, + 'flat_rows' => $flat_rows, + 'has_json' => $has_json, + 'strategy' => $has_json ? 'row_by_row' : 'bulk_pivot', + ); + } + + // Heuristic: 5ms per bulk row, 80ms per per-row PHP entity. + $bulk_rows = 0; + $row_rows = 0; + foreach ( $groups as $g ) { + if ( $g['has_json'] ) { + $row_rows += $g['residue']; + } else { + $bulk_rows += $g['residue']; + } + } + $estimated_sec = ( $bulk_rows * 0.005 ) + ( $row_rows * 0.080 ) + 2.0; + $strategy = $estimated_sec <= self::SYNC_THRESHOLD_SEC ? 'sync' : 'async'; + + return array( + 'managed_keys' => $managed_keys, + 'eav_rows' => $eav_rows, + 'users' => $users, + 'usermeta' => $usermeta, + 'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0, + 'mode' => $mode, + 'groups' => $groups, + 'estimated_strategy' => $strategy, + 'estimated_sec' => round( $estimated_sec, 1 ), + ); + } + + /** + * Cached attention summary for dashboard widget + tab-nav red dot. + * + * Hits a single SELECT against wp_usermeta + an in-process ratio + * calculation; cached for NEEDS_TTL_SEC (5 min) so dashboard widgets + * stay snappy. Bust the cache after migrations or mode changes via + * `bust_attention_cache()`. + * + * @return array{needs:bool,eav_rows:int,groups_with_residue:int,ratio:float,mode:string,job_state:string} + */ + public static function needs_attention(): array { + $cached = get_transient( self::TRANS_NEEDS ); + if ( is_array( $cached ) ) { + return $cached; + } + + global $wpdb; + $managed_keys = self::get_managed_keys(); + $eav_rows = self::count_eav_residue( $managed_keys ); + + $groups_with_residue = 0; + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ); + if ( $keys && self::count_eav_residue( $keys ) > 0 ) { + ++$groups_with_residue; + } + } + + $users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); + $usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" ); + $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + + $job_state = 'idle'; + $job = get_option( self::OPT_JOB ); + if ( is_array( $job ) && ! empty( $job['state'] ) ) { + $job_state = (string) $job['state']; + } + + $result = array( + 'needs' => $eav_rows > 0, + 'eav_rows' => $eav_rows, + 'groups_with_residue' => $groups_with_residue, + 'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0, + 'mode' => $mode, + 'job_state' => $job_state, + ); + + set_transient( self::TRANS_NEEDS, $result, self::NEEDS_TTL_SEC ); + return $result; + } + + /** + * Bust the attention cache — call after migrations, mode changes, + * group registrations, etc. + */ + public static function bust_attention_cache(): void { + delete_transient( self::TRANS_NEEDS ); + } + + /** + * Begin a migration job. If small, runs to completion in this request. + * Otherwise schedules cron-driven ticks and returns immediately. + * + * @param array{verify_strict?:bool,verify_24h?:bool,auto_backup?:bool,force_async?:bool,dry_run?:bool} $options + * @return array{ok:bool,job_id?:string,strategy?:string,error?:string,reason?:string} + */ + public static function start( array $options = array() ): array { + if ( ! self::acquire_lock() ) { + return array( + 'ok' => false, + 'error' => __( '另一個 migration job 正在執行;請先取消或等候完成。', '2meet-data-optimizer' ), + ); + } + + $preflight = self::preflight(); + + // Idempotency: nothing to do. + if ( $preflight['eav_rows'] === 0 && TMDO_Mode_Manager::MODE_AEAV_ONLY === $preflight['mode'] ) { + self::release_lock(); + self::bust_attention_cache(); + return array( + 'ok' => false, + 'reason' => 'nothing_to_do', + 'error' => sprintf( + /* translators: %s: ratio. */ + __( '所有 entity group 已 aeav_only / 0 EAV 殘留。當前 ratio %s。', '2meet-data-optimizer' ), + (string) $preflight['ratio'] + ), + ); + } + + $options = wp_parse_args( + $options, + array( + 'verify_strict' => true, + 'verify_24h' => false, + 'auto_backup' => true, + 'force_async' => false, + 'dry_run' => false, + ) + ); + + $job = array( + 'job_id' => 'mig_' . wp_generate_password( 12, false ), + 'started_at' => time(), + 'updated_at' => time(), + 'phase' => 'diagnose', + 'phase_index' => 0, + 'phase_progress' => 0, + 'overall_progress' => 0, + 'log' => array(), + 'options' => $options, + 'metrics' => array( + 'mode_start' => $preflight['mode'], + 'ratio_start' => $preflight['ratio'], + 'ratio_now' => $preflight['ratio'], + 'eav_rows_start' => $preflight['eav_rows'], + 'eav_rows_now' => $preflight['eav_rows'], + 'users' => $preflight['users'], + ), + 'strategy' => $options['force_async'] ? 'async' : $preflight['estimated_strategy'], + 'state' => 'running', + 'errors' => array(), + 'backup_path' => '', + ); + + self::log( + $job, + sprintf( + 'Job started — strategy=%s, residue=%d rows across %d groups, mode=%s', + $job['strategy'], + $preflight['eav_rows'], + count( array_filter( $preflight['groups'], fn( $g ) => $g['residue'] > 0 ) ), + $preflight['mode'] + ) + ); + + self::persist_job( $job ); + + if ( 'sync' === $job['strategy'] ) { + self::run_sync_loop( $job ); + } else { + self::schedule_next_tick(); + } + + return array( + 'ok' => true, + 'job_id' => $job['job_id'], + 'strategy' => $job['strategy'], + ); + } + + /** + * Advance one phase. Used by cron and by sync inline loop. + * + * @return array{done:bool,phase:string,error?:string} + * @throws \RuntimeException When a phase callback throws or returns non-ok status; caught internally and converted to an `error` array entry. + */ + public static function tick(): array { + $job = get_option( self::OPT_JOB ); + if ( ! is_array( $job ) || empty( $job['job_id'] ) ) { + return array( + 'done' => true, + 'phase' => 'idle', + 'error' => 'No active job', + ); + } + if ( 'running' !== ( $job['state'] ?? '' ) ) { + return array( + 'done' => true, + 'phase' => $job['phase'] ?? 'unknown', + ); + } + + $current = $job['phase']; + $method = 'phase_' . $current; + + try { + if ( ! method_exists( __CLASS__, $method ) ) { + throw new \RuntimeException( "Unknown phase: {$current}" ); + } + + $result = self::{$method}( $job ); + + if ( 'in_progress' === ( $result['status'] ?? '' ) ) { + self::persist_job( $job ); + if ( 'async' === $job['strategy'] ) { + self::schedule_next_tick(); + } + return array( + 'done' => false, + 'phase' => $current, + ); + } + + if ( 'ok' !== ( $result['status'] ?? '' ) ) { + throw new \RuntimeException( $result['message'] ?? 'Phase returned non-ok status' ); + } + + $next = self::next_phase( $current ); + self::log( $job, sprintf( '✓ %s (%.2fs)', $current, microtime( true ) - ( $result['_started_at'] ?? microtime( true ) ) ) ); + $job['phase'] = $next; + $job['phase_index'] = array_search( $next, self::PHASES, true ); + $job['phase_progress'] = 0; + $job['overall_progress'] = (int) round( ( $job['phase_index'] / ( count( self::PHASES ) - 1 ) ) * 100 ); + $job['updated_at'] = time(); + + if ( 'completed' === $next ) { + $job['state'] = 'completed'; + $job['completed_at'] = time(); + $job['overall_progress'] = 100; + self::log( + $job, + sprintf( + '✅ Migration complete — ratio %s → %s (-%.0f%%)', + $job['metrics']['ratio_start'], + $job['metrics']['ratio_now'], + ( $job['metrics']['ratio_start'] - $job['metrics']['ratio_now'] ) / max( $job['metrics']['ratio_start'], 0.01 ) * 100 + ) + ); + self::persist_job( $job ); + self::release_lock(); + self::bust_attention_cache(); + return array( + 'done' => true, + 'phase' => 'completed', + ); + } + + self::persist_job( $job ); + + if ( 'async' === $job['strategy'] ) { + self::schedule_next_tick(); + } + + return array( + 'done' => false, + 'phase' => $next, + ); + + } catch ( \Throwable $e ) { + self::handle_failure( $job, $e ); + return array( + 'done' => true, + 'phase' => 'failed', + 'error' => $e->getMessage(), + ); + } + } + + /** + * Inspection — read-only snapshot for the polling UI. + */ + public static function get_status(): array { + $cached = get_transient( self::TRANS_PROGRESS ); + if ( is_array( $cached ) ) { + return $cached; + } + $job = get_option( self::OPT_JOB ); + if ( ! is_array( $job ) ) { + return array( 'state' => 'idle' ); + } + return self::project_status( $job ); + } + + public static function cancel(): bool { + $job = get_option( self::OPT_JOB ); + if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) { + return false; + } + // Auto-rollback to safe state (dual_write) before clearing. + try { + $current_mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current_mode ) { + TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ ); + TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE ); + } elseif ( TMDO_Mode_Manager::MODE_SHADOW_READ === $current_mode ) { + TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE ); + } + } catch ( \Throwable $e ) { + // Logged but non-fatal. + TMDO_Logger::warning( 'migration_cancel_rollback_failed', array( 'error' => $e->getMessage() ) ); + } + + $job['state'] = 'cancelled'; + $job['cancelled_at'] = time(); + self::log( $job, '⚠ Cancelled by operator — rolled back to dual_write.' ); + self::persist_job( $job ); + self::release_lock(); + self::bust_attention_cache(); + wp_clear_scheduled_hook( self::CRON_HOOK ); + return true; + } + + public static function resume(): array { + $job = get_option( self::OPT_JOB ); + if ( ! is_array( $job ) ) { + return array( + 'ok' => false, + 'error' => 'No job to resume', + ); + } + if ( ! in_array( $job['state'] ?? '', array( 'failed', 'paused' ), true ) ) { + return array( + 'ok' => false, + 'error' => 'Job is not in a resumable state', + ); + } + $job['state'] = 'running'; + $job['updated_at'] = time(); + self::log( $job, '↻ Resumed by operator.' ); + self::persist_job( $job ); + self::acquire_lock(); + + if ( 'sync' === $job['strategy'] ) { + self::run_sync_loop( $job ); + } else { + self::schedule_next_tick(); + } + return array( 'ok' => true ); + } + + // ───────────────────────────────────────────────────────────────────── + // Sync inline loop — for sites where total work fits within + // SYNC_DEADLINE_SEC. Heartbeats progress via transient on every phase. + // ───────────────────────────────────────────────────────────────────── + + private static function run_sync_loop( array $job ): void { + set_time_limit( self::SYNC_DEADLINE_SEC + 10 ); + $deadline = microtime( true ) + self::SYNC_DEADLINE_SEC; + + while ( microtime( true ) < $deadline ) { + $result = self::tick(); + if ( $result['done'] ) { + return; + } + // Tiny pause to let DB breathe and avoid 100% CPU pegs. + usleep( 5000 ); + $job = get_option( self::OPT_JOB ); + if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) { + return; + } + } + + // Deadline reached but not done — convert to async. + $job = get_option( self::OPT_JOB ); + $job['strategy'] = 'async'; + self::log( $job, '⏱ Sync deadline reached — switching to async (cron-driven) for remaining phases.' ); + self::persist_job( $job ); + self::schedule_next_tick(); + } + + // ───────────────────────────────────────────────────────────────────── + // Phases — each returns ['status' => 'ok'|'in_progress'|'error', 'message' => string] + // ───────────────────────────────────────────────────────────────────── + + private static function phase_diagnose( array &$job ): array { + $preflight = self::preflight(); + $job['metrics']['eav_rows_now'] = $preflight['eav_rows']; + $job['metrics']['ratio_now'] = $preflight['ratio']; + + self::log( + $job, + sprintf( + 'Diagnose: %d EAV residue rows, %d users, ratio %s, mode %s', + $preflight['eav_rows'], + $preflight['users'], + (string) $preflight['ratio'], + $preflight['mode'] + ) + ); + + return array( + 'status' => 'ok', + 'message' => 'Diagnose complete', + ); + } + + private static function phase_backup( array &$job ): array { + if ( empty( $job['options']['auto_backup'] ) ) { + self::log( $job, '↪ Backup skipped (auto_backup=false)' ); + return array( 'status' => 'ok' ); + } + + $upload_dir = wp_upload_dir(); + $backup_dir = trailingslashit( $upload_dir['basedir'] ) . self::BACKUP_DIR_REL; + + if ( ! wp_mkdir_p( $backup_dir ) ) { + throw new \RuntimeException( "Cannot create backup directory: {$backup_dir}" ); + } + + // HTTP-level deny for Apache/IIS. Note: nginx silently ignores .htaccess — + // operators on nginx must add a `location ~ /wp-content/uploads/wpdo-backups/ + // { deny all; }` block (logged in admin notice). + $htaccess = $backup_dir . '/.htaccess'; + if ( ! file_exists( $htaccess ) ) { + file_put_contents( $htaccess, "Require all denied\n" ); + @chmod( $htaccess, 0644 ); + } + $webconfig = $backup_dir . '/web.config'; + if ( ! file_exists( $webconfig ) ) { + file_put_contents( + $webconfig, + "\n\n" + ); + @chmod( $webconfig, 0644 ); + } + $index = $backup_dir . '/index.php'; + if ( ! file_exists( $index ) ) { + file_put_contents( $index, "get_results( + "SELECT umeta_id, user_id, meta_key, meta_value FROM {$wpdb->usermeta} ORDER BY umeta_id ASC", + ARRAY_A + ); + + $fp = fopen( $backup_path, 'wb' ); + if ( ! $fp ) { + throw new \RuntimeException( "Cannot open backup file for writing: {$backup_path}" ); + } + // Owner-only read/write — backup contains user PII (emails, billing + // addresses, OAuth tokens, session blobs). Default umask leaks to + // other system users on shared hosts. + @chmod( $backup_path, 0600 ); + + fwrite( $fp, "-- WPDO migration backup of {$wpdb->usermeta}\n" ); + fwrite( $fp, '-- Job: ' . $job['job_id'] . "\n" ); + fwrite( $fp, '-- Generated: ' . gmdate( 'c' ) . "\n" ); + fwrite( $fp, "-- Restore: mysql ... < this_file.sql\n\n" ); + fwrite( $fp, "/*!40101 SET NAMES utf8mb4 */;\n" ); + fwrite( $fp, "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n" ); + fwrite( $fp, "SET FOREIGN_KEY_CHECKS=0;\n" ); + fwrite( $fp, "LOCK TABLES `{$wpdb->usermeta}` WRITE;\n" ); + + $batch = array(); + $count = 0; + foreach ( $rows as $row ) { + // UNHEX-encode meta_value: avoids ALL escape edge cases (binary, + // embedded NULs, non-UTF8, sql_mode mismatches at restore time). + // meta_key is %-bound through esc_sql which is sufficient for an + // ASCII-only key universe. + $batch[] = sprintf( + "(%d,%d,'%s',UNHEX('%s'))", + (int) $row['umeta_id'], + (int) $row['user_id'], + esc_sql( (string) $row['meta_key'] ), + bin2hex( (string) ( $row['meta_value'] ?? '' ) ) + ); + ++$count; + if ( count( $batch ) >= 500 ) { + fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" ); + $batch = array(); + } + } + if ( $batch ) { + fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" ); + } + fwrite( $fp, "UNLOCK TABLES;\n" ); + fwrite( $fp, "SET FOREIGN_KEY_CHECKS=1;\n" ); + fwrite( $fp, "/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n" ); + fclose( $fp ); + + $job['backup_path'] = $backup_path; + self::log( + $job, + sprintf( + 'Backup written: %s (%d rows, %s, chmod 0600)', + $filename, + $count, + size_format( filesize( $backup_path ) ) + ) + ); + + // Best-effort web-server detection — log nginx warning so operator + // can add the manual location block (htaccess/web.config don't apply). + $server = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['SERVER_SOFTWARE'] ) ) ) : ''; + if ( str_contains( $server, 'nginx' ) ) { + self::log( $job, '⚠ nginx detected: add `location ~ /wp-content/uploads/wpdo-backups/ { deny all; }` to your nginx config — .htaccess does not apply.' ); + } + + return array( 'status' => 'ok' ); + } + + private static function phase_demote( array &$job ): array { + $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $current ) { + self::log( $job, "↪ Demote skipped (mode is {$current}, not aeav_only)" ); + return array( 'status' => 'ok' ); + } + + // Two-step demotion: aeav_only → shadow_read → dual_write. + // (Safe transition: demotion always allowed.) + $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ ); + if ( is_wp_error( $r ) ) { + throw new \RuntimeException( 'Demote step 1 failed: ' . $r->get_error_message() ); + } + $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE ); + if ( is_wp_error( $r ) ) { + throw new \RuntimeException( 'Demote step 2 failed: ' . $r->get_error_message() ); + } + + self::log( $job, 'Mode: aeav_only → dual_write (reads now go to EAV)' ); + return array( 'status' => 'ok' ); + } + + private static function phase_install_schema( array &$job ): array { + do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class ); + TMDO_Schema_Manager::process_pending_migrations(); + + // Verify all 9 expected tables exist. + $missing = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + $missing[] = $group; + } + } + if ( $missing ) { + throw new \RuntimeException( 'Schema migration failed; missing tables: ' . implode( ',', $missing ) ); + } + + $count = count( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) ); + self::log( $job, "Schema migration ok ({$count} flat tables verified)" ); + return array( 'status' => 'ok' ); + } + + private static function phase_backfill_bulk( array &$job ): array { + if ( ! empty( $job['options']['dry_run'] ) ) { + self::log( $job, '↪ Backfill bulk skipped (dry_run)' ); + return array( 'status' => 'ok' ); + } + + $total_groups = 0; + $total_rows = 0; + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + if ( self::group_has_json_field( $group ) ) { + continue; // handled in phase_backfill_unserialize + } + $rows = self::execute_bulk_pivot( $group ); + $total_rows += $rows; + ++$total_groups; + self::log( $job, sprintf( ' • bulk pivot %s: %d row(s)', $group, $rows ) ); + } + self::log( $job, sprintf( 'Bulk backfill: %d groups, %d rows total', $total_groups, $total_rows ) ); + + // Update live ratio. + $preflight = self::preflight(); + $job['metrics']['eav_rows_now'] = $preflight['eav_rows']; + $job['metrics']['ratio_now'] = $preflight['ratio']; + + return array( 'status' => 'ok' ); + } + + private static function phase_backfill_unserialize( array &$job ): array { + if ( ! empty( $job['options']['dry_run'] ) ) { + self::log( $job, '↪ Backfill unserialize skipped (dry_run)' ); + return array( 'status' => 'ok' ); + } + + $total_rows = 0; + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + if ( ! self::group_has_json_field( $group ) ) { + continue; + } + $result = TMDO_Entity_Migration_Engine::migrate_group( + self::ENTITY_TYPE, + $group, + array( 'sleep_ms' => 0 ) + ); + if ( ! empty( $result['error'] ) ) { + throw new \RuntimeException( "Row-by-row backfill {$group} failed: " . $result['error'] ); + } + if ( ( $result['errors'] ?? 0 ) > 0 && 0 === ( $result['migrated'] ?? 0 ) ) { + throw new \RuntimeException( "All rows failed during {$group} backfill — see error_log" ); + } + $total_rows += (int) ( $result['migrated'] ?? 0 ); + self::log( $job, sprintf( ' • row-by-row %s: %d row(s)', $group, $result['migrated'] ?? 0 ) ); + } + self::log( $job, sprintf( 'Row-by-row backfill: %d rows total', $total_rows ) ); + return array( 'status' => 'ok' ); + } + + private static function phase_promote_shadow( array &$job ): array { + $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_DUAL_WRITE === $current ) { + $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ ); + if ( is_wp_error( $r ) ) { + throw new \RuntimeException( 'Promote to shadow_read failed: ' . $r->get_error_message() ); + } + self::log( $job, 'Mode: dual_write → shadow_read (verifying flat reads)' ); + } else { + self::log( $job, "↪ Already at {$current}; skip promote_shadow" ); + } + return array( 'status' => 'ok' ); + } + + private static function phase_verify_sample( array &$job ): array { + global $wpdb; + + if ( ! empty( $job['options']['verify_24h'] ) ) { + // Operator opted into the 24-hour shadow-read window — pause here. + $elapsed = time() - ( $job['phase_started_at'] ?? time() ); + if ( ! isset( $job['phase_started_at'] ) ) { + $job['phase_started_at'] = time(); + self::log( $job, '⏸ 24h shadow_read window started — wizard will resume after window elapses.' ); + return array( 'status' => 'in_progress' ); + } + if ( $elapsed < DAY_IN_SECONDS ) { + return array( 'status' => 'in_progress' ); + } + self::log( $job, '⏳ 24h shadow_read window complete; running sample compare' ); + } + + $strict = ! empty( $job['options']['verify_strict'] ); + $users_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" ); + $sample_size = $strict + ? (int) max( self::VERIFY_SAMPLE_MIN, ceil( $users_total * self::VERIFY_SAMPLE_RATIO ) ) + : 100; + $sample_size = min( $sample_size, $users_total ); + $sample_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT ID FROM {$wpdb->users} ORDER BY RAND() LIMIT %d", + $sample_size + ) + ); + + $diffs = 0; + $compared = 0; + $managed_keys = self::get_managed_keys(); + + foreach ( $sample_ids as $uid ) { + foreach ( $managed_keys as $key ) { + $eav = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s LIMIT 1", + $uid, + $key + ) + ); + + // Skip: no EAV source-of-truth to verify against (key already cleaned + // or never existed). The wizard's invariant only flags as DIFF the + // case where EAV has data but flat doesn't match it — that is the + // only real "backfill error". + if ( null === $eav || '' === $eav ) { + continue; + } + + ++$compared; + $flat = TMDO_Hook_Bus::direct_read( self::ENTITY_TYPE, (int) $uid, $key ); + + if ( ! self::values_loose_equal( $flat, $eav ) ) { + ++$diffs; + if ( $diffs <= 3 ) { + self::log( + $job, + sprintf( + ' ! diff uid=%d key=%s flat=%s eav=%s', + $uid, + $key, + is_scalar( $flat ) ? (string) $flat : gettype( $flat ), + is_scalar( $eav ) ? (string) $eav : gettype( $eav ) + ) + ); + } + } + } + } + + self::log( + $job, + sprintf( + 'Verify: sampled %d users × %d keys = %d compares, %d diff(s)', + count( $sample_ids ), + count( $managed_keys ), + $compared, + $diffs + ) + ); + + if ( $diffs > 0 ) { + throw new \RuntimeException( + sprintf( + 'Verification found %d divergence(s) across %d compares — aborting before destructive cleanup.', + $diffs, + $compared + ) + ); + } + + return array( 'status' => 'ok' ); + } + + private static function phase_promote_aeav( array &$job ): array { + $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) { + self::log( $job, '↪ Already aeav_only' ); + return array( 'status' => 'ok' ); + } + $r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_AEAV_ONLY ); + if ( is_wp_error( $r ) ) { + throw new \RuntimeException( 'Promote to aeav_only failed: ' . $r->get_error_message() ); + } + self::log( $job, 'Mode: shadow_read → aeav_only (cutover complete)' ); + return array( 'status' => 'ok' ); + } + + private static function phase_cleanup( array &$job ): array { + if ( ! empty( $job['options']['dry_run'] ) ) { + self::log( $job, '↪ Cleanup skipped (dry_run)' ); + return array( 'status' => 'ok' ); + } + + // Hard guard: must be in aeav_only AND backup must exist (if requested). + $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { + throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" ); + } + if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) { + throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' ); + } + + global $wpdb; + $keys = self::get_managed_keys(); + if ( empty( $keys ) ) { + self::log( $job, '↪ No managed keys to clean' ); + return array( 'status' => 'ok' ); + } + + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $deleted = (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})", + ...$keys + ) + ); + + // Refresh metrics. + $preflight = self::preflight(); + $job['metrics']['eav_rows_now'] = $preflight['eav_rows']; + $job['metrics']['ratio_now'] = $preflight['ratio']; + + self::log( $job, sprintf( 'Cleanup: deleted %d EAV row(s); ratio now %s', $deleted, (string) $preflight['ratio'] ) ); + return array( 'status' => 'ok' ); + } + + // ───────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────── + + /** Returns all meta_keys registered as entity fields for `user`. */ + private static function get_managed_keys(): array { + $keys = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) ); + } + return array_values( array_unique( $keys ) ); + } + + private static function group_has_json_field( string $group ): bool { + foreach ( TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group ) as $field ) { + if ( 'json' === ( $field['type'] ?? '' ) ) { + return true; + } + } + return false; + } + + private static function count_eav_residue( array $keys = array() ): int { + global $wpdb; + if ( empty( $keys ) ) { + $keys = self::get_managed_keys(); + } + if ( empty( $keys ) ) { + return 0; + } + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})", + ...$keys + ) + ); + } + + /** + * Single-statement bulk pivot for a text-only group. + * + * @param string $group Entity group name. + * @return int Affected rows. + * @throws \RuntimeException If $wpdb->query() fails. + */ + private static function execute_bulk_pivot( string $group ): int { + global $wpdb; + + $fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group ); + if ( empty( $fields ) ) { + return 0; + } + + $adapter = TMDO_Entity_Registry::get_adapter( self::ENTITY_TYPE ); + $id_col = $adapter->get_entity_id_column(); + $table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group ); + + $select_cases = array(); + $update_cols = array(); + $col_names = array(); + $keys = array(); + foreach ( $fields as $f ) { + $key = $f['key']; + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + $keys[] = $key; + $col_names[] = "`{$col}`"; + // MAX(CASE WHEN meta_key='...' THEN meta_value END) + $select_cases[] = $wpdb->prepare( + "MAX(CASE WHEN um.meta_key = %s THEN um.meta_value END) AS `{$col}`", + $key + ); + $update_cols[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)"; + } + + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + + $sql = sprintf( + 'INSERT INTO `%s` (`%s`, %s) + SELECT um.user_id, %s + FROM `%s` um + WHERE um.meta_key IN (%s) + GROUP BY um.user_id + ON DUPLICATE KEY UPDATE %s', + $table, + $id_col, + implode( ', ', $col_names ), + implode( ', ', $select_cases ), + $wpdb->usermeta, + $placeholders, + implode( ', ', $update_cols ) + ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- pivot SQL composed from registry-validated identifiers + prepared values inside CASE/IN. + $result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) ); + + if ( false === $result ) { + throw new \RuntimeException( "Bulk pivot failed for group {$group}: " . $wpdb->last_error ); + } + return (int) $result; + } + + private static function values_loose_equal( $a, $b ): bool { + if ( null === $a && null === $b ) { + return true; + } + if ( null === $a || null === $b ) { + $other = null === $a ? $b : $a; + return '' === $other || array() === $other || 0 === $other || '0' === $other; + } + if ( is_array( $a ) || is_array( $b ) ) { + return wp_json_encode( $a ) === wp_json_encode( $b ); + } + return (string) $a === (string) $b; + } + + private static function next_phase( string $current ): string { + $idx = array_search( $current, self::PHASES, true ); + if ( false === $idx || $idx + 1 >= count( self::PHASES ) ) { + return 'completed'; + } + return self::PHASES[ $idx + 1 ]; + } + + private static function log( array &$job, string $message ): void { + $line = '[' . gmdate( 'H:i:s' ) . '] ' . $message; + $job['log'][] = $line; + if ( count( $job['log'] ) > self::MAX_LOG_LINES ) { + $job['log'] = array_slice( $job['log'], -self::MAX_LOG_LINES ); + } + $job['updated_at'] = time(); + } + + private static function persist_job( array $job ): void { + update_option( self::OPT_JOB, $job, false ); + set_transient( self::TRANS_PROGRESS, self::project_status( $job ), 60 ); + } + + /** Public-safe projection of job state for the polling UI. */ + private static function project_status( array $job ): array { + return array( + 'job_id' => $job['job_id'] ?? '', + 'state' => $job['state'] ?? 'idle', + 'phase' => $job['phase'] ?? 'idle', + 'phase_index' => $job['phase_index'] ?? 0, + 'phase_total' => count( self::PHASES ) - 1, + 'overall_progress' => $job['overall_progress'] ?? 0, + 'log' => $job['log'] ?? array(), + 'metrics' => $job['metrics'] ?? array(), + 'strategy' => $job['strategy'] ?? 'sync', + 'started_at' => $job['started_at'] ?? 0, + 'updated_at' => $job['updated_at'] ?? 0, + 'completed_at' => $job['completed_at'] ?? null, + 'cancelled_at' => $job['cancelled_at'] ?? null, + 'errors' => $job['errors'] ?? array(), + 'backup_path' => isset( $job['backup_path'] ) ? basename( $job['backup_path'] ) : '', + ); + } + + private static function acquire_lock(): bool { + if ( false === add_option( self::OPT_LOCK, time(), '', false ) ) { + $existing = (int) get_option( self::OPT_LOCK, 0 ); + if ( $existing > 0 && time() - $existing > self::LOCK_TTL_SEC ) { + delete_option( self::OPT_LOCK ); + return add_option( self::OPT_LOCK, time(), '', false ); + } + return false; + } + return true; + } + + private static function release_lock(): void { + delete_option( self::OPT_LOCK ); + } + + private static function schedule_next_tick(): void { + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_single_event( time() + 1, self::CRON_HOOK ); + } + } + + private static function handle_failure( array &$job, \Throwable $e ): void { + $job['state'] = 'failed'; + $job['errors'][] = array( + 'phase' => $job['phase'] ?? 'unknown', + 'message' => $e->getMessage(), + 'at' => time(), + ); + self::log( $job, sprintf( '✗ %s failed: %s', $job['phase'] ?? '?', $e->getMessage() ) ); + + // Auto-rollback: only if mode is currently aeav_only AND we haven't reached cleanup yet. + try { + $current = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + $phase = $job['phase'] ?? ''; + $pre_cleanup = ! in_array( $phase, array( 'cleanup', 'completed' ), true ); + if ( $pre_cleanup && TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) { + TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ ); + TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE ); + self::log( $job, '↩ Auto-rolled back to dual_write (reads safe)' ); + } + } catch ( \Throwable $inner ) { + TMDO_Logger::warning( 'migration_rollback_failed', array( 'error' => $inner->getMessage() ) ); + } + + TMDO_Logger::warning( + 'migration_phase_failed', + array( + 'phase' => $job['phase'] ?? '?', + 'job' => $job['job_id'] ?? '?', + 'error' => $e->getMessage(), + ) + ); + + self::persist_job( $job ); + self::release_lock(); + self::bust_attention_cache(); + } + + /** + * Cron callback — wires CRON_HOOK to tick(). + */ + public static function cron_tick(): void { + self::tick(); + } +} diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php new file mode 100644 index 0000000..a957de3 --- /dev/null +++ b/includes/migration/class-tmdo-post-migration.php @@ -0,0 +1,628 @@ +v2 legacy post meta migration +/** + * TMDO_Post_Migration — Post entity migration core (v2.9.3). + * + * Independent of the user-side TMDO_Migration_Orchestrator. The user + * orchestrator is intentionally frozen (1105 lines, hardcoded ENTITY_TYPE='user' + * via `self::ENTITY_TYPE` const) — this class implements the equivalent + * post-side flow without touching any user code path. + * + * Phase coverage (compared to user 10-phase orchestrator): + * diagnose ✓ implemented + * backup ✗ skipped — v2.9.0 postmeta-cleanup CLI handles garbage; + * full wp_postmeta backup deferred to v2.9.4 (admin tab + * + DB hook) since wp_postmeta tends to be very large + * (29k+ rows on dev10) and requires streaming approach + * demote ✗ not applicable — post mode starts at 'disabled', no + * aeav_only state to demote from + * install_schema ✗ already done in v2.9.1 by Schema_Manager auto-create + * backfill_bulk ✓ implemented (per-group, by post_type filter) + * backfill_unserialize ✗ deferred to v2.9.4 (json groups: attachment + + * nav_menu_item have only ~200 rows on dev10) + * promote_shadow ✓ implemented (set_mode dual_write → shadow_read) + * verify_sample ✓ implemented (sample-and-compare) + * promote_aeav ✓ implemented (set_mode → aeav_only) + * cleanup ✓ implemented (DELETE managed wp_postmeta keys) + * + * 🔒 v2.9.x frozen contract: this class must NEVER touch wp_usermeta, + * wp_users, or any wp_wpdo_user_* table. All operations target wp_posts / + * wp_postmeta / wp_wpdo_post_*. + * + * @package WP_Data_Optimizer + * @since 2.9.3 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal migration: $flat_table goes through Schema_Manager::sanitize_column_name + TMDO_Entity_Registry; $columns_sql/$cases_sql/$update_sql composed from the same sanitized sources; user-controlled values use prepare() placeholders. Multi-statement IN clauses with array_fill('%s') trigger false positives. + +/** + * Post entity migration core. Static API mirrors TMDO_Migration_Orchestrator + * for predictability, but each method is post-only. + */ +final class TMDO_Post_Migration { + + private const ENTITY_TYPE = 'post'; + + /** + * Read-only inspection — what's the current post EAV state? + * + * @return array{ + * posts:int, + * postmeta:int, + * ratio:float, + * mode:string, + * groups:array + * } + */ + public static function diagnose(): array { + global $wpdb; + + $posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" ); + $postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" ); + $ratio = $posts_count > 0 ? round( $postmeta_count / $posts_count, 2 ) : 0.0; + + $groups = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ); + $post_type = self::group_post_type( $group ); + $eav_rows = $keys ? self::count_eav_residue( $keys, $post_type ) : 0; + + $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group ); + $flat_rows = TMDO_Schema_Manager::table_exists( $flat_table ) + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + : 0; + + $groups[ $group ] = array( + 'keys' => $keys, + 'eav_rows' => $eav_rows, + 'flat_rows' => $flat_rows, + 'post_type' => $post_type, + ); + } + + return array( + 'posts' => $posts_count, + 'postmeta' => $postmeta_count, + 'ratio' => $ratio, + 'mode' => TMDO_Mode_Manager::get( self::ENTITY_TYPE ), + 'groups' => $groups, + ); + } + + /** + * 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. + * + * @param string $group_name Entity group name (e.g. 'wc_product'). + * @return array{migrated:int,group:string,post_type:string|null} + * @throws InvalidArgumentException When group is not registered. + * @throws RuntimeException When the pivot SQL fails. + */ + public static function backfill_group( string $group_name ): array { + global $wpdb; + + $fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group_name ); + if ( empty( $fields ) ) { + throw new InvalidArgumentException( + 'Unknown post entity group: ' . esc_html( $group_name ) + ); + } + + $post_type = self::group_post_type( $group_name ); + $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name ); + + // Introspect target table columns so we only pivot fields that actually + // exist in the flat schema. Defends against partial schema environments + // (e.g. v2.9.3 deployed before Schema_Manager auto-create has run, or + // custom installs that intentionally pruned columns). + $existing_cols = self::get_existing_columns( $flat_table ); + if ( empty( $existing_cols ) ) { + throw new RuntimeException( + 'Flat table missing or has no columns: ' . esc_html( $flat_table ) + ); + } + + // 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. + } + $key = esc_sql( (string) $field['key'] ); + $columns[] = "`{$col}`"; + $cases[] = "MAX(CASE WHEN meta_key = '{$key}' THEN meta_value END) AS `{$col}`"; + $update_parts[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)"; + } + + if ( empty( $columns ) ) { + return array( + 'migrated' => 0, + 'group' => $group_name, + 'post_type' => $post_type, + ); + } + + $columns_sql = implode( ', ', $columns ); + $cases_sql = implode( ', ', $cases ); + $update_sql = implode( ', ', $update_parts ); + $post_type_filter = $post_type ? $wpdb->prepare( 'AND p.post_type = %s', $post_type ) : ''; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared + $rows = $wpdb->query( + "INSERT INTO `{$flat_table}` (post_id, {$columns_sql}) + SELECT pm.post_id, {$cases_sql} + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE 1=1 {$post_type_filter} + GROUP BY pm.post_id + ON DUPLICATE KEY UPDATE {$update_sql}" + ); + + if ( false === $rows ) { + throw new RuntimeException( + 'backfill_group SQL failed: ' . esc_html( (string) $wpdb->last_error ) + ); + } + + // MySQL ON DUPLICATE KEY UPDATE counts changes as 2 per affected row; + // $rows = 2 * matched if pure update; for our migrate use case we just + // want to confirm the operation completed. Re-count flat table rows + // limited to $post_type for a clean migrated count. + $count_sql = $post_type + ? $wpdb->prepare( + "SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s", + $post_type + ) + : "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $migrated = (int) $wpdb->get_var( $count_sql ); + + return array( + 'migrated' => $migrated, + 'group' => $group_name, + 'post_type' => $post_type, + ); + } + + /** + * Promote post mode along the safe transition path. + * + * Path: disabled → dual_write → shadow_read → aeav_only. + * Must be invoked separately for each step (caller decides timing). + * + * @param string $target_mode One of TMDO_Mode_Manager::MODE_* constants. + * @return true|WP_Error + */ + public static function set_mode( string $target_mode ) { + return TMDO_Mode_Manager::set( self::ENTITY_TYPE, $target_mode ); + } + + /** + * Cleanup: DELETE managed keys from wp_postmeta. Only callable when + * post mode is aeav_only — otherwise EAV is still authoritative source. + * + * @return array{deleted:int} + * @throws RuntimeException When mode != aeav_only. + */ + public static function cleanup(): array { + $mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { + throw new RuntimeException( + 'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only' + ); + } + + global $wpdb; + $keys = self::get_managed_keys(); + if ( empty( $keys ) ) { + return array( 'deleted' => 0 ); + } + + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $deleted = (int) $wpdb->query( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", + ...$keys + ) + ); + + return array( 'deleted' => $deleted ); + } + + /** + * Row-by-row backfill for groups containing json-typed fields (v2.10.4). + * + * Bulk SQL pivot in backfill_group() can't handle json/serialized values + * because the conversion serialize() → wp_json_encode() requires PHP-level + * processing. This method delegates to TMDO_Entity_Migration_Engine which + * already handles safe_unserialize and json encoding row-by-row. + * + * Idempotent — uses checkpoint cursor; safe to re-run. + * + * @param string $group_name Entity group name (e.g. 'attachment', 'nav_menu_item'). + * @param array $options Optional: batch_size (default 500), sleep_ms (50), + * resume (true), dry_run (false). + * @return array Engine result tuple including migrated/errors/skipped. + */ + public static function backfill_group_json( string $group_name, array $options = array() ): array { + if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) { + return array( 'error' => 'TMDO_Entity_Migration_Engine class not available' ); + } + return TMDO_Entity_Migration_Engine::migrate_group( + self::ENTITY_TYPE, + $group_name, + $options + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Legacy zone-table cutover (v2.9.5) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Non-destructive copy of a legacy `wpdo_hot_` zone table + * into the new `wp_wpdo_post_` flat table. + * + * Copies the intersection of columns (by name), excluding `id` and + * `updated_at` so the flat table manages those itself. Uses ON DUPLICATE + * KEY UPDATE so the operation is idempotent — re-running is safe. + * + * The legacy table is left UNTOUCHED — this is critical for v3.0.0 rollback + * safety. The legacy table is only DROP'd at v3.0.0 release after several + * release cycles of the new flat table being authoritative. + * + * @param string $post_type Post type the legacy table targets. + * @param string $hot_table Fully-qualified legacy table name. + * @param string $flat_table Fully-qualified target flat table name. + * @return array{copied:int,common_columns:string[],post_type:string} + * @throws RuntimeException When tables missing or copy SQL fails. + */ + public static function copy_legacy_hot_table( + string $post_type, + string $hot_table, + string $flat_table + ): array { + global $wpdb; + + $hot_cols = self::get_existing_columns( $hot_table ); + $flat_cols = self::get_existing_columns( $flat_table ); + + if ( empty( $hot_cols ) ) { + throw new RuntimeException( + 'Legacy hot table missing or empty: ' . esc_html( $hot_table ) + ); + } + if ( empty( $flat_cols ) ) { + throw new RuntimeException( + 'Target flat table missing: ' . esc_html( $flat_table ) + ); + } + + // Intersect columns by name, excluding ones the flat table manages itself. + $skip = array( 'id', 'updated_at', 'created_at' ); + $common = array(); + foreach ( $hot_cols as $name => $_ ) { + if ( in_array( $name, $skip, true ) ) { + continue; + } + if ( ! isset( $flat_cols[ $name ] ) ) { + continue; + } + $common[] = $name; + } + + // post_id is the unique key — must always be present. + if ( ! in_array( 'post_id', $common, true ) ) { + throw new RuntimeException( + 'Cannot copy: post_id column not present in both tables' + ); + } + + $cols_quoted = '`' . implode( '`, `', $common ) . '`'; + $update_parts = array(); + foreach ( $common as $col ) { + if ( 'post_id' === $col ) { + continue; + } + $update_parts[] = "`{$col}` = VALUES(`{$col}`)"; + } + $update_sql = implode( ', ', $update_parts ); + + $rows = $wpdb->query( + "INSERT INTO `{$flat_table}` ({$cols_quoted}) + SELECT {$cols_quoted} FROM `{$hot_table}` + ON DUPLICATE KEY UPDATE {$update_sql}" + ); + + if ( false === $rows ) { + throw new RuntimeException( + 'copy_legacy_hot_table SQL failed: ' . esc_html( (string) $wpdb->last_error ) + ); + } + + // MySQL counts UPSERT modified rows differently from inserted rows; + // re-count flat table by post_id intersection for clean number. + $copied = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$flat_table}` f + INNER JOIN `{$hot_table}` h ON h.post_id = f.post_id" + ); + + return array( + 'copied' => $copied, + 'common_columns' => $common, + 'post_type' => $post_type, + ); + } + + /** + * Verify legacy cutover by row count + sampled value comparison. + * + * Note: only checks row count + post_id presence. Per-column value + * comparison would require knowing both tables' column types and + * applying lossy/lossless conversion rules — out of scope for v2.9.5 + * (the COPY operation itself uses INSERT...SELECT which preserves + * values byte-for-byte where types match). + * + * @param string $hot_table Legacy hot table name. + * @param string $flat_table Target flat table name. + * @return array{hot_rows:int,flat_rows:int,mismatched_rows:int,ok:bool} + */ + public static function verify_legacy_cutover( string $hot_table, string $flat_table ): array { + global $wpdb; + + $hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" ); + $flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ); + + // Find post_ids in hot but not in flat (i.e. failed copies). + $mismatched = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `{$hot_table}` h + LEFT JOIN `{$flat_table}` f ON f.post_id = h.post_id + WHERE f.post_id IS NULL" + ); + + return array( + 'hot_rows' => $hot_rows, + 'flat_rows' => $flat_rows, + 'mismatched_rows' => $mismatched, + 'ok' => 0 === $mismatched && $flat_rows >= $hot_rows, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Query Router benchmark (v2.10.2) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Compare query latency between wp_postmeta JOIN path and flat-table path + * for a single meta_key/value/compare combination. + * + * Both queries are functionally equivalent but use different storage: + * postmeta path → INNER JOIN wp_postmeta (the legacy WP behavior) + * flat path → INNER JOIN wp_wpdo_post_ (the v2.10.1 router target) + * + * Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better. + * + * @param string $post_type WP post_type to filter by. + * @param string $meta_key Meta key to query. + * @param string $compare Comparison operator (=, !=, <, <=, >, >=, LIKE). + * @param string $value Value to compare against. + * @param string $flat_table Fully qualified flat table name (must contain $meta_key column). + * @param int $samples Number of times to run each query (default 50). + * @return array{ + * samples:int, + * postmeta_avg_ms:float, + * flat_avg_ms:float, + * speedup:float, + * postmeta_rows:int, + * flat_rows:int, + * meta_key:string, + * post_type:string, + * } + * @throws InvalidArgumentException When $samples <= 0 or compare invalid. + * @throws RuntimeException When flat table doesn't exist. + */ + public static function benchmark_query( + string $post_type, + string $meta_key, + string $compare, + string $value, + string $flat_table, + int $samples = 50 + ): array { + if ( $samples <= 0 ) { + $msg = 'Samples must be > 0, got ' . $samples; + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $allowed_compare = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'LIKE' ); + if ( ! in_array( strtoupper( $compare ), $allowed_compare, true ) ) { + $msg = 'Invalid compare: ' . $compare . '. Allowed: ' . implode( ', ', $allowed_compare ); + throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // Verify flat table exists. + $existing = self::get_existing_columns( $flat_table ); + if ( empty( $existing ) ) { + throw new RuntimeException( + 'Benchmark target flat table missing: ' . esc_html( $flat_table ) + ); + } + + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + if ( ! isset( $existing[ $col ] ) ) { + throw new RuntimeException( + 'Flat table does not have column for ' . esc_html( $meta_key ) + ); + } + + global $wpdb; + + // Build both query templates (parameterized via prepare in the loop). + $pm_sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID + WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value {$compare} %s", + $post_type, + $meta_key, + $value + ); + + $flat_sql = $wpdb->prepare( + "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p + INNER JOIN `{$flat_table}` f ON f.post_id = p.ID + WHERE p.post_type = %s AND f.`{$col}` {$compare} %s", + $post_type, + $value + ); + + // Warm up MySQL query cache so the first run isn't penalized. + $wpdb->get_var( $pm_sql ); + $wpdb->get_var( $flat_sql ); + + $pm_total = 0.0; + $flat_total = 0.0; + $pm_rows = 0; + $flat_rows = 0; + + for ( $i = 0; $i < $samples; $i++ ) { + $start = microtime( true ); + $pm_rows = (int) $wpdb->get_var( $pm_sql ); + $pm_total += microtime( true ) - $start; + + $start = microtime( true ); + $flat_rows = (int) $wpdb->get_var( $flat_sql ); + $flat_total += microtime( true ) - $start; + } + + $pm_avg_ms = ( $pm_total / $samples ) * 1000; + $flat_avg_ms = ( $flat_total / $samples ) * 1000; + $speedup = $flat_avg_ms > 0 ? ( $pm_avg_ms / $flat_avg_ms ) : 0.0; + + return array( + 'samples' => $samples, + 'postmeta_avg_ms' => round( $pm_avg_ms, 3 ), + 'flat_avg_ms' => round( $flat_avg_ms, 3 ), + 'speedup' => round( $speedup, 2 ), + 'postmeta_rows' => $pm_rows, + 'flat_rows' => $flat_rows, + 'meta_key' => $meta_key, + 'post_type' => $post_type, + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * All meta_keys registered for any post entity group. + * + * @return string[] + */ + public static function get_managed_keys(): array { + $keys = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) { + $keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) ); + } + return array_values( array_unique( $keys ) ); + } + + /** + * Map group name to the post_type it targets. The mapping is canonical + * to v2.9.1 group definitions in TMDO_Post_Fields. + * + * Returns null for cross-post_type groups (e.g. 'wp_core'). + * + * @param string $group Entity group name. + * @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; + } + } + + /** + * Map of column_name → true for every column that exists in $table. + * Returns empty array if table missing. + * + * @param string $table Fully qualified table name. + * @return array + */ + private static function get_existing_columns( string $table ): array { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A ); + if ( empty( $rows ) ) { + return array(); + } + $cols = array(); + foreach ( $rows as $r ) { + $cols[ (string) ( $r['Field'] ?? '' ) ] = true; + } + return $cols; + } + + /** + * Count wp_postmeta rows matching $keys, optionally filtered by post_type. + * + * @param string[] $keys Meta keys to match. + * @param string|null $post_type Optional post_type filter. + * @return int + */ + private static function count_eav_residue( array $keys, ?string $post_type = null ): int { + global $wpdb; + if ( empty( $keys ) ) { + return 0; + } + $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + if ( $post_type ) { + return (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + "SELECT COUNT(*) FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})", + $post_type, + ...$keys + ) + ); + } + return (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", + ...$keys + ) + ); + } +} diff --git a/includes/migration/class-tmdo-warm-migration.php b/includes/migration/class-tmdo-warm-migration.php new file mode 100644 index 0000000..ef3e100 --- /dev/null +++ b/includes/migration/class-tmdo-warm-migration.php @@ -0,0 +1,140 @@ +get_meta_keys(); + if ( empty( $meta_keys ) ) { + return 0; + } + + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property. + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", // phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Warm migration: postmeta → Zone B path. + ...$meta_keys + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + } + + /** + * Migrates one batch of warm meta rows. + * + * @param int $offset Starting row offset. + * @return int Number of rows processed. + */ + protected function migrate_batch( int $offset ): int { + global $wpdb; + + $meta_keys = $this->get_meta_keys(); + if ( empty( $meta_keys ) ) { + return 0; + } + + $registry = TMDO_Schema_Registry::instance(); + $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); + + $args = array_merge( $meta_keys, array( self::BATCH_SIZE, $offset ) ); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property. + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, meta_key, meta_value + FROM {$wpdb->postmeta} + WHERE meta_key IN ({$placeholders}) + ORDER BY meta_id ASC + LIMIT %d OFFSET %d", + ...$args + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + + if ( empty( $rows ) ) { + return 0; + } + + foreach ( $rows as $row ) { + $field = $registry->get_warm_field( $row['meta_key'] ); + $ttl = $field['ttl'] ?? null; + + TMDO_Zone_Warm::set( + (int) $row['post_id'], + $row['meta_key'], + $row['meta_value'], + $ttl + ); + } + + return count( $rows ); + } + + /** + * Verifies that the warm table row count is at least as large as the source. + * + * @return bool True if verification passes. + */ + public function verify_counts(): bool { + global $wpdb; + + $source = $this->count_source(); + $table = TMDO_Zone_Warm::table(); + $target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Warm::table() + + return $target >= $source; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Get all registered warm meta_keys. + * + * @return array List of meta key strings. + */ + private function get_meta_keys(): array { + $fields = TMDO_Schema_Registry::instance()->get_zone_fields( 'warm' ); + return array_column( $fields, 'meta_key' ); + } +} diff --git a/includes/notifications/abstract-class-tmdo-notifier.php b/includes/notifications/abstract-class-tmdo-notifier.php new file mode 100644 index 0000000..48bd929 --- /dev/null +++ b/includes/notifications/abstract-class-tmdo-notifier.php @@ -0,0 +1,249 @@ + $fingerprint, + 'critical' => (int) ( $summary['critical_count'] ?? 0 ), + ) + ); + } + } + return $sent; + } + + // ─── settings accessors(optional override)──────────────────────── + + /** + * Whether this notification channel is enabled. + * + * @return bool + */ + public static function is_enabled(): bool { + return '1' === (string) get_option( static::option_key( 'enabled' ), '0' ); + } + + /** + * Throttle window in hours (clamped 1..168). + * + * @return int + */ + public static function throttle_hours(): int { + $v = (int) get_option( static::option_key( 'throttle_hours' ), self::DEFAULT_THROTTLE_HOURS ); + return max( 1, min( 168, $v ) ); + } + + /** + * Severity subscription filter. + * + * @return string 'critical_only' or 'critical_and_recommended' + */ + public static function severity_filter(): string { + $v = (string) get_option( static::option_key( 'severity' ), 'critical_only' ); + return in_array( $v, array( 'critical_only', 'critical_and_recommended' ), true ) ? $v : 'critical_only'; + } + + /** + * Build channel-specific context (e.g. webhook URL or recipient address). + * Return null to skip send (e.g. invalid email or empty webhook). + * + * @return array|null + */ + public static function build_context(): ?array { + return array(); + } + + // ─── shared subject / body builders ─────────────────────────────── + + /** + * Build alert subject line. + * + * @param array $summary Summary array from Health_Cron::run(). + * @return string + */ + public static function build_subject( array $summary ): string { + $site = (string) get_option( 'blogname', 'WordPress' ); + $crit = (int) ( $summary['critical_count'] ?? 0 ); + $first = ''; + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( 'critical' === ( $t['status'] ?? '' ) ) { + $first = $slug; + break; + } + } + return sprintf( '[%s] WPDO 警告:%d 項 critical (%s)', $site, $crit, $first ); + } + + /** + * Generic plain-text body. Subclasses can override to add channel-specific + * formatting (Slack mrkdwn, Discord markdown, Telegram MarkdownV2, etc.). + * + * @param array $summary Summary. + * @return string + */ + public static function build_body( array $summary ): string { + $lines = array(); + $lines[] = sprintf( '站台:%s', home_url( '/' ) ); + $lines[] = sprintf( '檢查時間:%s UTC', (string) ( $summary['ran_at'] ?? '?' ) ); + $lines[] = sprintf( + '結果:%d critical / %d recommended', + (int) ( $summary['critical_count'] ?? 0 ), + (int) ( $summary['recommended_count'] ?? 0 ) + ); + $lines[] = ''; + $lines[] = '失敗的檢查:'; + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) { + $lines[] = sprintf( + ' [%s] %s — %s', + strtoupper( (string) ( $t['status'] ?? '' ) ), + $slug, + (string) ( $t['description'] ?? '' ) + ); + } + } + $lines[] = ''; + $lines[] = '查看詳情:' . admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' ); + return implode( "\n", $lines ); + } + + // ─── private helpers ───────────────────────────────────────────── + + /** + * Per-channel option key (e.g. wpdo_email_enabled, wpdo_slack_webhook). + * + * @param string $field 'enabled' / 'throttle_hours' / 'severity' / etc. + * @return string + */ + protected static function option_key( string $field ): string { + return sprintf( 'wpdo_%s_%s', static::channel_id(), $field ); + } + + /** + * Stable fingerprint from the alert content for deduplication. + * + * @param array $summary Summary array from Health_Cron::run(). + * @return string md5 hash. + */ + protected static function fingerprint( array $summary ): string { + $relevant = array( + 'critical_count' => (int) ( $summary['critical_count'] ?? 0 ), + 'first_critical' => null, + ); + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( 'critical' === ( $t['status'] ?? '' ) ) { + $relevant['first_critical'] = $slug; + break; + } + } + return md5( wp_json_encode( $relevant ) ); + } + + /** + * Check whether an alert with this fingerprint was recently sent. + * + * @param string $fingerprint Alert fingerprint (md5). + * @return bool + */ + protected static function is_throttled( string $fingerprint ): bool { + $key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint ); + return false !== get_transient( $key ); + } + + /** + * Record that an alert was sent (sets throttle transient). + * + * @param string $fingerprint Alert fingerprint (md5). + * @return void + */ + protected static function mark_sent( string $fingerprint ): void { + $key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint ); + set_transient( $key, time(), static::throttle_hours() * HOUR_IN_SECONDS ); + } +} diff --git a/includes/notifications/class-tmdo-discord-notifier.php b/includes/notifications/class-tmdo-discord-notifier.php new file mode 100644 index 0000000..3c6432a --- /dev/null +++ b/includes/notifications/class-tmdo-discord-notifier.php @@ -0,0 +1,76 @@ + $url ); + } + + /** + * Send alert via Discord webhook. + * + * @param string $subject Alert subject / title. + * @param string $body Alert body text. + * @param array $context Channel context (webhook_url). + * @return bool True on HTTP 2xx response. + */ + public static function actually_send( string $subject, string $body, array $context ): bool { + $url = (string) ( $context['webhook_url'] ?? '' ); + if ( '' === $url ) { + return false; + } + // Discord max content length = 2000 chars. + $content = "**{$subject}**\n```\n" . substr( $body, 0, 1800 ) . "\n```"; + $payload = wp_json_encode( array( 'content' => $content ) ); + $resp = wp_remote_post( + $url, + array( + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => $payload, + 'timeout' => 5, + 'blocking' => true, + ) + ); + if ( is_wp_error( $resp ) ) { + return false; + } + $code = (int) wp_remote_retrieve_response_code( $resp ); + // Discord returns 204 on success. + return $code >= 200 && $code < 300; + } +} diff --git a/includes/notifications/class-tmdo-email-notifier.php b/includes/notifications/class-tmdo-email-notifier.php new file mode 100644 index 0000000..cfb333e --- /dev/null +++ b/includes/notifications/class-tmdo-email-notifier.php @@ -0,0 +1,238 @@ + 0). Sends a plain-text email to the configured + * recipient(s) with a 24h throttle key (per-alert-fingerprint) so admins + * don't get spammed. + * + * Default OFF — admin must explicitly enable via Settings tab. + * + * @package WP_Data_Optimizer + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Email notifier — stateless static API. + */ +class TMDO_Email_Notifier { + + public const OPT_ENABLED = 'wpdo_email_alerts_enabled'; + public const OPT_RECIPIENT = 'wpdo_alert_email'; + public const OPT_THROTTLE_HRS = 'wpdo_alert_throttle_hours'; + + /** Default throttle window. */ + public const DEFAULT_THROTTLE_HOURS = 24; + + /** + * Register subscriber. + * + * @return void + */ + public static function register(): void { + add_action( 'wpdo/health_alert_critical', array( __CLASS__, 'maybe_send' ), 10, 1 ); + } + + /** + * Decide + send. + * + * @param array $summary Health summary from Health_Cron::run(). + * @return bool true on send, false on skip / fail. + */ + public static function maybe_send( array $summary ): bool { + if ( ! self::is_enabled() ) { + return false; + } + $recipient = self::recipient(); + if ( ! is_email( $recipient ) ) { + return false; + } + $fingerprint = self::fingerprint( $summary ); + if ( self::is_throttled( $fingerprint ) ) { + return false; + } + $subject = self::build_subject( $summary ); + $body = self::build_body( $summary ); + $sent = wp_mail( $recipient, $subject, $body ); + if ( $sent ) { + self::mark_sent( $fingerprint ); + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( + 'email_alert_sent', + array( + 'fingerprint' => $fingerprint, + 'critical' => (int) ( $summary['critical_count'] ?? 0 ), + 'recipient' => $recipient, + ) + ); + } + } + return (bool) $sent; + } + + // ─── settings accessors ───────────────────────────────────────────── + + /** + * Whether email alerts are enabled. + * + * @return bool + */ + public static function is_enabled(): bool { + return '1' === (string) get_option( self::OPT_ENABLED, '0' ); + } + + /** + * Alert recipient email address (falls back to admin_email). + * + * @return string + */ + public static function recipient(): string { + $v = (string) get_option( self::OPT_RECIPIENT, '' ); + if ( '' === $v ) { + $v = (string) get_option( 'admin_email', '' ); + } + return $v; + } + + /** + * Throttle window in hours (clamped 1..168). + * + * @return int + */ + public static function throttle_hours(): int { + $v = (int) get_option( self::OPT_THROTTLE_HRS, self::DEFAULT_THROTTLE_HOURS ); + return max( 1, min( 168, $v ) ); // Clamp 1h..1week. + } + + // ─── private helpers ────────────────────────────────────────────── + + /** + * Stable fingerprint from the alert content (so identical incidents + * dedupe within the throttle window). + * + * @param array $summary Summary array. + * @return string md5 hash. + */ + private static function fingerprint( array $summary ): string { + $relevant = array( + 'critical_count' => (int) ( $summary['critical_count'] ?? 0 ), + 'first_critical' => null, + ); + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( 'critical' === ( $t['status'] ?? '' ) ) { + $relevant['first_critical'] = $slug; + break; + } + } + return md5( wp_json_encode( $relevant ) ); + } + + /** + * Check whether an alert with this fingerprint was recently sent. + * + * @param string $fingerprint Alert fingerprint (md5). + * @return bool + */ + private static function is_throttled( string $fingerprint ): bool { + $key = 'wpdo_alert_sent_' . $fingerprint; + return false !== get_transient( $key ); + } + + /** + * Record that an alert was sent (sets throttle transient). + * + * @param string $fingerprint Alert fingerprint (md5). + * @return void + */ + private static function mark_sent( string $fingerprint ): void { + $key = 'wpdo_alert_sent_' . $fingerprint; + set_transient( $key, time(), self::throttle_hours() * HOUR_IN_SECONDS ); + } + + /** + * Build subject. Site name + critical count + first slug. + * + * @param array $summary Summary array. + * @return string + */ + private static function build_subject( array $summary ): string { + $site = (string) get_option( 'blogname', 'WordPress' ); + $crit = (int) ( $summary['critical_count'] ?? 0 ); + $first = ''; + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( 'critical' === ( $t['status'] ?? '' ) ) { + $first = $slug; + break; + } + } + return sprintf( + /* translators: 1: site name, 2: critical count, 3: first critical test slug */ + __( '[%1$s] WPDO 警告:%2$d 項 critical (%3$s)', '2meet-data-optimizer' ), + $site, + $crit, + $first + ); + } + + /** + * Build plain-text body. + * + * @param array $summary Summary array. + * @return string + */ + private static function build_body( array $summary ): string { + $site_url = home_url( '/' ); + $health_url = admin_url( 'site-health.php' ); + $wpdo_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' ); + $lines = array(); + $lines[] = __( 'WP Data Optimizer 自動健康檢查發現 critical 警告。', '2meet-data-optimizer' ); + $lines[] = ''; + $lines[] = sprintf( '站台:%s', $site_url ); + $lines[] = sprintf( + /* translators: %s: timestamp */ + __( '檢查時間:%s UTC', '2meet-data-optimizer' ), + (string) ( $summary['ran_at'] ?? '?' ) + ); + $lines[] = sprintf( + /* translators: 1: critical count, 2: recommended count */ + __( '結果:%1$d critical / %2$d recommended', '2meet-data-optimizer' ), + (int) ( $summary['critical_count'] ?? 0 ), + (int) ( $summary['recommended_count'] ?? 0 ) + ); + $lines[] = ''; + $lines[] = __( '失敗的檢查:', '2meet-data-optimizer' ); + foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) { + if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) { + $lines[] = sprintf( + ' [%s] %s — %s', + strtoupper( (string) $t['status'] ), + $slug, + (string) ( $t['description'] ?? '' ) + ); + } + } + $lines[] = ''; + $lines[] = __( '建議行動:', '2meet-data-optimizer' ); + $lines[] = ' · ' . sprintf( + /* translators: %s: WPDO Doctor admin URL */ + __( '立即查看 WPDO Doctor:%s', '2meet-data-optimizer' ), + $wpdo_url + ); + $lines[] = ' · ' . sprintf( + /* translators: %s: WP Site Health admin URL */ + __( '或 WP Site Health:%s', '2meet-data-optimizer' ), + $health_url + ); + $lines[] = ''; + $lines[] = sprintf( + /* translators: %d: hours */ + __( '註:相同警告在 %d 小時內不會重發;前往設定可調整 throttle / 收件人 / 關閉。', '2meet-data-optimizer' ), + self::throttle_hours() + ); + return implode( "\n", $lines ); + } +} diff --git a/includes/notifications/class-tmdo-slack-notifier.php b/includes/notifications/class-tmdo-slack-notifier.php new file mode 100644 index 0000000..9304af8 --- /dev/null +++ b/includes/notifications/class-tmdo-slack-notifier.php @@ -0,0 +1,78 @@ + $url ); + } + + /** + * Send alert via Slack incoming webhook. + * + * @param string $subject Alert subject / title. + * @param string $body Alert body text. + * @param array $context Channel context (webhook_url). + * @return bool True on HTTP 2xx response. + */ + public static function actually_send( string $subject, string $body, array $context ): bool { + $url = (string) ( $context['webhook_url'] ?? '' ); + if ( '' === $url ) { + return false; + } + // Slack mrkdwn — bold subject + plaintext body in code block for readability. + $payload = wp_json_encode( + array( + 'text' => "*{$subject}*\n```\n{$body}\n```", + ) + ); + $resp = wp_remote_post( + $url, + array( + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => $payload, + 'timeout' => 5, + 'blocking' => true, + ) + ); + if ( is_wp_error( $resp ) ) { + return false; + } + $code = (int) wp_remote_retrieve_response_code( $resp ); + return $code >= 200 && $code < 300; + } +} diff --git a/includes/notifications/class-tmdo-telegram-notifier.php b/includes/notifications/class-tmdo-telegram-notifier.php new file mode 100644 index 0000000..cfb9e80 --- /dev/null +++ b/includes/notifications/class-tmdo-telegram-notifier.php @@ -0,0 +1,95 @@ + $token, + 'chat_id' => $chat, + ); + } + + /** + * Send alert via Telegram Bot API. + * + * @param string $subject Alert subject / title. + * @param string $body Alert body text. + * @param array $context Channel context (token, chat_id). + * @return bool True on HTTP 2xx response. + */ + public static function actually_send( string $subject, string $body, array $context ): bool { + $token = (string) ( $context['token'] ?? '' ); + $chat = (string) ( $context['chat_id'] ?? '' ); + if ( '' === $token || '' === $chat ) { + return false; + } + // Telegram MarkdownV2 has many escaped chars; use plain text mode for safety. + $text = "🚨 {$subject}\n\n{$body}"; + // Telegram message limit = 4096 chars. + $text = substr( $text, 0, 4000 ); + + $url = sprintf( 'https://api.telegram.org/bot%s/sendMessage', rawurlencode( $token ) ); + $resp = wp_remote_post( + $url, + array( + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( + array( + 'chat_id' => $chat, + 'text' => $text, + ) + ), + 'timeout' => 5, + 'blocking' => true, + ) + ); + if ( is_wp_error( $resp ) ) { + return false; + } + $code = (int) wp_remote_retrieve_response_code( $resp ); + return $code >= 200 && $code < 300; + } +} diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php new file mode 100644 index 0000000..4e9ccfb --- /dev/null +++ b/includes/query/class-tmdo-post-query-router.php @@ -0,0 +1,320 @@ +set( 'wpdo_post_clauses', $routed ) + * 2. posts_join — LEFT JOIN wp_wpdo_post_ per routed post_type + * 3. posts_where — append WHERE conditions targeting flat columns + * 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication + * + * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes + * only entity_type='post'. + * + * @package WP_Data_Optimizer + * @since 2.10.1 + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal query rewriter: table names go through Schema_Manager::sanitize_column_name, column names go through esc_sql, user-controlled values use $wpdb->prepare() in build_condition(). + +/** + * Rewrites WP_Query meta_query clauses to JOIN against post entity flat tables. + */ +class TMDO_Post_Query_Router { + + /** Query var key for capturing routed clauses (consumed by posts_join/where). */ + private const QUERY_VAR = 'wpdo_post_clauses'; + + /** + * Map post_type → entity group (mirror of TMDO_Post_Migration::group_post_type + * but inverted; a post_type may map to multiple groups eventually, currently + * we route to the most-specific *_core group plus the cross-cutting wp_core + * group when the key is in wp_core). + * + * @var array + */ + private const POST_TYPE_GROUP_MAP = array( + 'product' => 'wc_product', + 'hp_listing' => 'hp_listing_core', + 'hp_request' => 'hp_request_core', + 'hp_vendor' => 'hp_vendor_core', + 'attachment' => 'attachment', + 'nav_menu_item' => 'nav_menu_item', + ); + + /** + * Register all WP_Query hooks. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); + add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); + add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); + add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); + } + + /** + * Strip Entity-Registry-managed meta_query keys; store routed clauses. + * + * @param \WP_Query $query Current WP_Query. + * @return void + */ + public function pre_get_posts( \WP_Query $query ): void { + // Mode gate: only run when post mode is reads_from_flat. + if ( ! self::is_router_active() ) { + return; + } + + // is_admin guard (matches legacy TMDO_Query_Router behavior). + if ( is_admin() && ! wp_doing_ajax() ) { + return; + } + + $post_types = (array) $query->get( 'post_type' ); + if ( empty( $post_types ) ) { + return; + } + + $raw_meta_query = (array) $query->get( 'meta_query' ); + if ( empty( $raw_meta_query ) ) { + return; + } + + $routed = array(); // Keyed by post_type. + $remaining = array(); + + foreach ( $raw_meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + $matched = false; + foreach ( $post_types as $pt ) { + $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] ); + if ( ! $field ) { + continue; + } + // Confirm the field's group is appropriate for this post_type: + // either cross-cutting wp_core (any) or the post_type-specific group. + $group = $field['group'] ?? ''; + $expected_grp = self::POST_TYPE_GROUP_MAP[ $pt ] ?? null; + $is_appropriate = ( 'wp_core' === $group ) || ( null !== $expected_grp && $expected_grp === $group ); + if ( ! $is_appropriate ) { + continue; + } + + $column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ); + + $routed[ $pt ][] = array( + 'group' => $group, + 'column' => $column, + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ), + 'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ), + ); + $matched = true; + break; + } + + if ( ! $matched ) { + $remaining[ $k ] = $clause; + } + } + + if ( empty( $routed ) ) { + return; + } + + // Preserve relation if surviving clauses still have it. + if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { + $remaining['relation'] = $raw_meta_query['relation']; + } + + $query->set( 'meta_query', $remaining ); + $query->set( self::QUERY_VAR, $routed ); + } + + /** + * LEFT JOIN flat tables for each routed post_type. + * + * @param string|null $join Current JOIN SQL. + * @param \WP_Query $query Current WP_Query. + * @return string Modified JOIN SQL. + */ + public function posts_join( ?string $join, \WP_Query $query ): string { + $join = (string) ( $join ?? '' ); + $routed = $query->get( self::QUERY_VAR ); + if ( empty( $routed ) || ! is_array( $routed ) ) { + return $join; + } + + global $wpdb; + + foreach ( $routed as $post_type => $clauses ) { + // Each routed clause carries its group; use the first clause's group + // (all clauses for a given post_type currently target one group). + $group = $clauses[0]['group'] ?? ''; + if ( '' === $group ) { + continue; + } + $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); + $alias = self::table_alias( $post_type, $group ); + + // Don't double-join. + if ( false !== strpos( $join, "`{$alias}`" ) ) { + continue; + } + + $join .= " LEFT JOIN `{$table}` AS `{$alias}` " + . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; + } + + return $join; + } + + /** + * Append WHERE conditions for routed clauses. + * + * @param string|null $where Current WHERE SQL. + * @param \WP_Query $query Current WP_Query. + * @return string Modified WHERE SQL. + */ + public function posts_where( ?string $where, \WP_Query $query ): string { + $where = (string) ( $where ?? '' ); + $routed = $query->get( self::QUERY_VAR ); + if ( empty( $routed ) || ! is_array( $routed ) ) { + return $where; + } + + global $wpdb; + + foreach ( $routed as $post_type => $clauses ) { + foreach ( $clauses as $clause ) { + $alias = self::table_alias( $post_type, $clause['group'] ); + $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`'; + $compare = self::sanitize_compare( $clause['compare'] ); + $value = $clause['value']; + + $where .= self::build_condition( $col, $compare, $value ); + } + } + + return $where; + } + + /** + * Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows. + * + * @param string|null $groupby Current GROUP BY SQL. + * @param \WP_Query $query Current WP_Query. + * @return string Modified GROUP BY SQL. + */ + public function posts_groupby( ?string $groupby, \WP_Query $query ): string { + $groupby = (string) ( $groupby ?? '' ); + $routed = $query->get( self::QUERY_VAR ); + if ( empty( $routed ) || ! is_array( $routed ) ) { + return $groupby; + } + + global $wpdb; + if ( '' === trim( $groupby ) ) { + $groupby = "`{$wpdb->posts}`.`ID`"; + } + + return $groupby; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Whether the router is mode-active (post mode is shadow_read or aeav_only). + * + * @return bool + */ + public static function is_router_active(): bool { + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return false; + } + return TMDO_Mode_Manager::reads_from_flat( 'post' ); + } + + /** + * Generate a deterministic JOIN alias for a (post_type, group) pair. + * + * @param string $post_type Post type slug. + * @param string $group Entity group name. + * @return string SQL alias. + */ + private static function table_alias( string $post_type, string $group ): string { + return 'wpdoflat_' . sanitize_key( $post_type ) . '_' . sanitize_key( $group ); + } + + /** + * Allow-list of comparison operators. + * + * @param string $compare Raw operator from clause. + * @return string Sanitized operator (defaults to '='). + */ + private static function sanitize_compare( string $compare ): string { + $allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); + $compare = strtoupper( trim( $compare ) ); + return in_array( $compare, $allowed, true ) ? $compare : '='; + } + + /** + * Build a single WHERE condition fragment (always prepended with " AND "). + * + * @param string $col_sql Quoted column reference. + * @param string $compare Sanitized comparison operator. + * @param mixed $value Raw value (scalar or array). + * @return string + */ + private static function build_condition( string $col_sql, string $compare, $value ): string { + global $wpdb; + + if ( in_array( $compare, array( 'IN', 'NOT IN' ), true ) ) { + $values = (array) $value; + if ( empty( $values ) ) { + return ''; + } + $placeholders = implode( ',', array_fill( 0, count( $values ), '%s' ) ); + return ' AND ' . $col_sql . ' ' . $compare . ' (' . $wpdb->prepare( $placeholders, ...$values ) . ')'; + } + + if ( in_array( $compare, array( 'BETWEEN', 'NOT BETWEEN' ), true ) ) { + $values = (array) $value; + if ( count( $values ) < 2 ) { + return ''; + } + return ' AND ' . $col_sql . ' ' . $compare . ' ' + . $wpdb->prepare( '%s AND %s', $values[0], $values[1] ); + } + + if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { + $op = 'EXISTS' === $compare ? 'IS NOT NULL' : 'IS NULL'; + return ' AND ' . $col_sql . ' ' . $op; + } + + return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value ); + } +} diff --git a/includes/query/class-tmdo-query-interceptor-base.php b/includes/query/class-tmdo-query-interceptor-base.php new file mode 100644 index 0000000..d1d9aff --- /dev/null +++ b/includes/query/class-tmdo-query-interceptor-base.php @@ -0,0 +1,358 @@ + 'column_name' ] for flat-column tables + * + * Ported from HPCT_Query_Interceptor_Base with WPDO enhancements: + * - Uses TMDO_Feature_Flags (7-state) for query-active check + * - Uses TMDO_DB::table() for table name resolution + * - Query var prefix: wpdo_qi_ (avoids collision with HPCT) + */ +abstract class TMDO_Query_Interceptor_Base { + + /** + * Returns the module identifier for feature flag lookups. + * + * @return string Module name. + */ + abstract protected function get_module(): string; + + /** + * Returns the post types this interceptor handles. + * + * @return string[] Array of post type slugs. + */ + abstract protected function get_post_types(): array; + + /** + * Custom table name without $wpdb->prefix. + * Passed through TMDO_DB::table() at runtime. + */ + abstract protected function get_table(): string; + + /** + * Returns the column name used to join to wp_posts.ID. + * + * @return string Join column name, default 'post_id'. + */ + protected function get_join_column(): string { + return 'post_id'; + } + + /** + * Meta_key to custom table column map (flat-column tables only). + * Return empty array for KV tables (override posts_where instead). + * + * @return array Map of meta_key to column name. + */ + abstract protected function get_meta_key_map(): array; + + // ── Hook registration ───────────────────────────────────────────────── + + /** + * Registers all WordPress query hooks. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); + add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); + add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); + add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); + } + + // ── Hook handlers ───────────────────────────────────────────────────── + + /** + * Intercepts pre_get_posts to rewrite meta_query conditions for our custom tables. + * + * @param \WP_Query $query The WP_Query object. + * @return void + */ + public function pre_get_posts( \WP_Query $query ): void { + if ( ! $this->should_intercept( $query ) ) { + return; + } + + $raw_meta_query = (array) $query->get( 'meta_query' ); + if ( empty( $raw_meta_query ) ) { + return; + } + + $key_map = $this->get_meta_key_map(); + $our_clauses = array(); + $remaining = array(); + + foreach ( $raw_meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + if ( isset( $key_map[ $clause['key'] ] ) ) { + $our_clauses[] = array( + 'column' => $key_map[ $clause['key'] ], + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), + 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), + ); + } else { + $remaining[ $k ] = $clause; + } + } + + if ( empty( $our_clauses ) ) { + return; + } + + if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { + $remaining['relation'] = $raw_meta_query['relation']; + } + + $query->set( 'meta_query', $remaining ); + $query->set( $this->query_var(), $our_clauses ); + } + + /** + * Appends a LEFT JOIN to the custom table when this interceptor has active clauses. + * + * @param string $join Current JOIN SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified JOIN SQL. + */ + public function posts_join( ?string $join, \WP_Query $query ): string { + $join = (string) ( $join ?? '' ); + if ( ! $this->has_clauses( $query ) ) { + return $join; + } + + global $wpdb; + $alias = $this->table_alias(); + + if ( false === strpos( $join, "`{$alias}`" ) ) { + $table = TMDO_DB::table( $this->get_table() ); + $join_col = esc_sql( $this->get_join_column() ); + $join .= " LEFT JOIN `{$table}` AS `{$alias}`" + . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`)"; + } + + return $join; + } + + /** + * Appends WHERE conditions for the custom table columns. + * + * @param string $where Current WHERE SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified WHERE SQL. + */ + public function posts_where( ?string $where, \WP_Query $query ): string { + $where = (string) ( $where ?? '' ); + $clauses = $this->get_clauses( $query ); + if ( empty( $clauses ) ) { + return $where; + } + + $alias = $this->table_alias(); + + foreach ( $clauses as $clause ) { + $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`'; + $compare = $this->sanitize_compare( $clause['compare'] ); + $type = $clause['type']; + $value = $clause['value']; + + $where .= $this->build_condition( $col, $compare, $type, $value ); + } + + return $where; + } + + /** + * Ensures GROUP BY is set to prevent duplicates from LEFT JOINs. + * + * @param string $groupby Current GROUP BY SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified GROUP BY SQL. + */ + public function posts_groupby( ?string $groupby, \WP_Query $query ): string { + $groupby = (string) ( $groupby ?? '' ); + if ( ! $this->has_clauses( $query ) ) { + return $groupby; + } + + global $wpdb; + if ( '' === trim( $groupby ) ) { + $groupby = "`{$wpdb->posts}`.`ID`"; + } + + return $groupby; + } + + // ── Internal helpers ────────────────────────────────────────────────── + + /** + * Determines whether this interceptor should handle the given query. + * + * @param \WP_Query $query The WP_Query object. + * @return bool True if the query should be intercepted. + */ + protected function should_intercept( \WP_Query $query ): bool { + if ( ! TMDO_Feature_Flags::is_query_active( $this->get_module() ) ) { + return false; + } + + $post_types = (array) $query->get( 'post_type' ); + + return ! empty( array_intersect( $post_types, $this->get_post_types() ) ); + } + + /** + * Returns the query var key used to store intercepted clauses. + * + * @return string Query var name. + */ + protected function query_var(): string { + return 'wpdo_qi_' . $this->get_module(); + } + + /** + * Returns the SQL alias for the custom table in JOIN clauses. + * + * @return string Table alias. + */ + protected function table_alias(): string { + return 'wpdo_' . $this->get_module(); + } + + /** + * Checks whether the query has any intercepted clauses stored. + * + * @param \WP_Query $query The WP_Query object. + * @return bool True if there are stored clauses. + */ + protected function has_clauses( \WP_Query $query ): bool { + $clauses = $query->get( $this->query_var() ); + return ! empty( $clauses ); + } + + /** + * Retrieves stored intercepted clauses from the query. + * + * @param \WP_Query $query The WP_Query object. + * @return array[] Array of clause definitions. + */ + protected function get_clauses( \WP_Query $query ): array { + $val = $query->get( $this->query_var() ); + return is_array( $val ) ? $val : array(); + } + + /** + * Build a single WHERE condition string. + * All values go through $wpdb->prepare(). + * + * @param string $col Column reference (escaped). + * @param string $compare Comparison operator. + * @param string $type Meta type for placeholder selection. + * @param mixed $value Value(s) to compare against. + * @return string SQL WHERE condition fragment. + */ + protected function build_condition( string $col, string $compare, string $type, $value ): string { + global $wpdb; + + $ph = $this->placeholder( $type ); + + switch ( $compare ) { + case 'IN': + case 'NOT IN': + $vals = array_values( (array) $value ); + if ( empty( $vals ) ) { + return ' AND 1=0'; + } + $phs = implode( ',', array_fill( 0, count( $vals ), $ph ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals ); + + case 'BETWEEN': + case 'NOT BETWEEN': + $vals = array_values( (array) $value ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. + return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] ); + + case 'EXISTS': + return " AND {$col} IS NOT NULL"; + + case 'NOT EXISTS': + return " AND {$col} IS NULL"; + + default: + // =, !=, >, >=, <, <=, LIKE, NOT LIKE + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. + return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value ); + } + } + + /** + * Returns the SQL placeholder string for a given meta type. + * + * @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR). + * @return string SQL placeholder (%d or %s). + */ + protected function placeholder( string $type ): string { + $int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' ); + return in_array( $type, $int_types, true ) ? '%d' : '%s'; + } + + /** + * Sanitizes a comparison operator against an allowed list. + * + * @param string $compare Comparison operator string. + * @return string Sanitized comparison operator, defaults to '='. + */ + protected function sanitize_compare( string $compare ): string { + $allowed = array( + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + ); + return in_array( $compare, $allowed, true ) ? $compare : '='; + } +} diff --git a/includes/query/class-tmdo-query-router.php b/includes/query/class-tmdo-query-router.php new file mode 100644 index 0000000..e775e00 --- /dev/null +++ b/includes/query/class-tmdo-query-router.php @@ -0,0 +1,292 @@ +get( 'post_type' ); + if ( empty( $post_types ) ) { + return; + } + + $raw_meta_query = (array) $query->get( 'meta_query' ); + if ( empty( $raw_meta_query ) ) { + return; + } + + $registry = TMDO_Schema_Registry::instance(); + $hot_clauses = array(); // Keyed by post_type. + $remaining = array(); + + foreach ( $raw_meta_query as $k => $clause ) { + if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + $remaining[ $k ] = $clause; + continue; + } + + $matched = false; + foreach ( $post_types as $pt ) { + $field = $registry->get_field( $pt, $clause['key'] ); + if ( $field && 'hot' === $field['zone'] ) { + // Check module is query-active. + $module = 'hot_' . sanitize_key( $pt ); + if ( ! TMDO_Feature_Flags::is_query_active( $module ) ) { + continue; + } + + $hot_clauses[ $pt ][] = array( + 'column' => $field['column'], + 'value' => $clause['value'] ?? '', + 'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ), + 'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ), + ); + $matched = true; + break; + } + } + + if ( ! $matched ) { + $remaining[ $k ] = $clause; + } + } + + if ( empty( $hot_clauses ) ) { + return; + } + + // Preserve relation if there are remaining clauses. + if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { + $remaining['relation'] = $raw_meta_query['relation']; + } + + $query->set( 'meta_query', $remaining ); + $query->set( self::QUERY_VAR, $hot_clauses ); + } + + /** + * LEFT JOIN hot tables for each post type with extracted clauses. + * + * @param string $join Current JOIN SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified JOIN SQL. + */ + public function posts_join( ?string $join, \WP_Query $query ): string { + $join = (string) ( $join ?? '' ); + $hot_clauses = $query->get( self::QUERY_VAR ); + if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) { + return $join; + } + + global $wpdb; + + foreach ( $hot_clauses as $post_type => $clauses ) { + $alias = $this->table_alias( $post_type ); + + if ( false !== strpos( $join, "`{$alias}`" ) ) { + continue; + } + + $table = TMDO_Zone_Hot::table( $post_type ); + $join .= " LEFT JOIN `{$table}` AS `{$alias}`" + . " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; + } + + return $join; + } + + /** + * Append WHERE conditions for hot table columns. + * + * @param string $where Current WHERE SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified WHERE SQL. + */ + public function posts_where( ?string $where, \WP_Query $query ): string { + $where = (string) ( $where ?? '' ); + $hot_clauses = $query->get( self::QUERY_VAR ); + if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) { + return $where; + } + + foreach ( $hot_clauses as $post_type => $clauses ) { + $alias = $this->table_alias( $post_type ); + + foreach ( $clauses as $clause ) { + $col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`'; + $compare = $this->sanitize_compare( $clause['compare'] ); + $type = $clause['type']; + $value = $clause['value']; + + $where .= $this->build_condition( $col, $compare, $type, $value ); + } + } + + return $where; + } + + /** + * Ensure GROUP BY to prevent duplicate rows from JOINs. + * + * @param string $groupby Current GROUP BY SQL. + * @param \WP_Query $query The WP_Query object. + * @return string Modified GROUP BY SQL. + */ + public function posts_groupby( ?string $groupby, \WP_Query $query ): string { + $groupby = (string) ( $groupby ?? '' ); + $hot_clauses = $query->get( self::QUERY_VAR ); + if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) { + return $groupby; + } + + global $wpdb; + if ( '' === trim( $groupby ) ) { + $groupby = "`{$wpdb->posts}`.`ID`"; + } + + return $groupby; + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Generate a unique alias for the hot table JOIN. + * + * @param string $post_type Post type slug. + * @return string SQL table alias. + */ + private function table_alias( string $post_type ): string { + return 'wpdo_hot_' . sanitize_key( $post_type ); + } + + /** + * Build a single WHERE condition with prepared values. + * + * @param string $col Column reference (escaped). + * @param string $compare Comparison operator. + * @param string $type Meta type for placeholder selection. + * @param mixed $value Value(s) to compare against. + * @return string SQL WHERE condition fragment. + */ + private function build_condition( string $col, string $compare, string $type, $value ): string { + global $wpdb; + + $ph = $this->placeholder( $type ); + + switch ( $compare ) { + case 'IN': + case 'NOT IN': + $vals = array_values( (array) $value ); + if ( empty( $vals ) ) { + return ' AND 1=0'; + } + $phs = implode( ',', array_fill( 0, count( $vals ), $ph ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals ); + + case 'BETWEEN': + case 'NOT BETWEEN': + $vals = array_values( (array) $value ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. + return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] ); + + case 'EXISTS': + return " AND {$col} IS NOT NULL"; + + case 'NOT EXISTS': + return " AND {$col} IS NULL"; + + default: + // =, !=, >, >=, <, <=, LIKE, NOT LIKE + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string. + return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value ); + } + } + + /** + * Returns the SQL placeholder string for a given meta type. + * + * @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR). + * @return string SQL placeholder (%d or %s). + */ + private function placeholder( string $type ): string { + $int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' ); + return in_array( $type, $int_types, true ) ? '%d' : '%s'; + } + + /** + * Sanitizes a comparison operator against an allowed list. + * + * @param string $compare Comparison operator string. + * @return string Sanitized comparison operator, defaults to '='. + */ + private function sanitize_compare( string $compare ): string { + $allowed = array( + '=', + '!=', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'BETWEEN', + 'NOT BETWEEN', + 'EXISTS', + 'NOT EXISTS', + ); + return in_array( $compare, $allowed, true ) ? $compare : '='; + } +} diff --git a/includes/safety/class-tmdo-fsm-guard.php b/includes/safety/class-tmdo-fsm-guard.php new file mode 100644 index 0000000..6e9e000 --- /dev/null +++ b/includes/safety/class-tmdo-fsm-guard.php @@ -0,0 +1,215 @@ + array( 'dual_write' ), + 'dual_write' => array( 'backfill', 'verify' ), + 'backfill' => array( 'verify' ), + 'verify' => array( 'cutover' ), + 'cutover' => array( 'cleanup' ), + 'cleanup' => array( 'complete' ), + 'complete' => array(), // terminal — only rewind to idle. + ); + + /** + * Transitions that destroy data on completion (write-only-custom or purge). + * These trigger an automatic pre-transition snapshot. + */ + public const DESTRUCTIVE_TRANSITIONS = array( + 'cutover' => array( 'cleanup' ), // cleanup purges wp_*meta. + 'cleanup' => array( 'complete' ), // complete = no fallback to wp_*meta. + ); + + /** + * "Active" states (writes hit custom tables). Reverting these to idle + * potentially loses data and warrants a snapshot. + */ + public const ACTIVE_STATES = array( + 'dual_write', + 'backfill', + 'verify', + 'cutover', + 'cleanup', + 'complete', + ); + + /** + * Check whether a transition is allowed. Returns true on allow, + * WP_Error on block. + * + * @param string $module Module name (must be a known module). + * @param string $from Current state. + * @param string $to Target state. + * @return true|WP_Error + */ + public static function can_transition( string $module, string $from, string $to ) { + // Allow filter-based bypass (CLI --force, integration tests, manual override). + $bypass = (bool) apply_filters( 'wpdo/fsm_guard/bypass', false, $module, $from, $to ); + if ( $bypass ) { + return true; + } + + // No-op transition. + if ( $from === $to ) { + return true; + } + + // Backward to idle is always allowed (emergency rewind). + if ( 'idle' === $to ) { + return true; + } + + // Validate against forward graph. + $allowed = self::FORWARD_GRAPH[ $from ] ?? array(); + if ( ! in_array( $to, $allowed, true ) ) { + return new WP_Error( + 'wpdo_fsm_invalid_transition', + sprintf( + /* translators: 1: module, 2: from-state, 3: to-state, 4: list of allowed states */ + __( 'Cannot transition module %1$s from %2$s to %3$s. Allowed forward states from %2$s: %4$s. Pass --force or set the wpdo/fsm_guard/bypass filter to override.', '2meet-data-optimizer' ), + $module, + $from, + $to, + empty( $allowed ) ? __( '(none — terminal state)', '2meet-data-optimizer' ) : implode( ', ', $allowed ) + ), + array( + 'module' => $module, + 'from' => $from, + 'to' => $to, + 'allowed' => $allowed, + ) + ); + } + + return true; + } + + /** + * Decide whether a transition is destructive (warrants auto-snapshot). + * + * @param string $from Current state. + * @param string $to Target state. + * @return bool + */ + public static function is_destructive( string $from, string $to ): bool { + // Cleanup/complete transitions purge data. + if ( isset( self::DESTRUCTIVE_TRANSITIONS[ $from ] ) + && in_array( $to, self::DESTRUCTIVE_TRANSITIONS[ $from ], true ) ) { + return true; + } + // Reverting an active module to idle abandons custom-table data. + if ( 'idle' === $to && in_array( $from, self::ACTIVE_STATES, true ) ) { + return true; + } + return false; + } + + /** + * Take an auto-snapshot for the pending FSM transition. + * Called from Feature_Flags::set() before update_option. + * + * @param string $module Module name. + * @param string $from Current state. + * @param string $to Target state. + * @return string|null Snapshot ID on success; null on skip/failure. + */ + public static function maybe_snapshot( string $module, string $from, string $to ): ?string { + if ( ! self::is_destructive( $from, $to ) ) { + return null; + } + // Check if Snapshot_Manager is loaded — defensive (in case unit tests skip include). + if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) { + return null; + } + // Allow filter to suppress (e.g. CLI passed --skip-snapshot for emergency rewind). + $skip = (bool) apply_filters( 'wpdo/fsm_guard/skip_snapshot', false, $module, $from, $to ); + if ( $skip ) { + return null; + } + $result = TMDO_Snapshot_Manager::create( + 'pre_fsm_transition', + array(), + array( + 'notes' => sprintf( '[%s] %s → %s', $module, $from, $to ), + 'retention_days' => 90, // Keep FSM-rewind snapshots longer. + ) + ); + return ! empty( $result['ok'] ) ? (string) $result['snapshot_id'] : null; + } + + /** + * Convenience: check + snapshot + record entry time. Called from + * Feature_Flags::set(). Returns WP_Error to block, true to allow. + * + * @param string $module Module identifier. + * @param string $to Target state. + * @return true|WP_Error + */ + public static function before_transition( string $module, string $to ) { + $from = TMDO_Feature_Flags::get( $module ); + $check = self::can_transition( $module, $from, $to ); + if ( is_wp_error( $check ) ) { + return $check; + } + self::maybe_snapshot( $module, $from, $to ); + return true; + } + + /** + * Record state entry time after a successful transition. Stored in + * wp_options key `wpdo_fsm_state_entered` for verify-gate / wash-period + * checks (v2.3.0 M5 will consume this). + * + * @param string $module Module identifier. + * @param string $to New state. + * @return void + */ + public static function record_entry( string $module, string $to ): void { + $key = 'wpdo_fsm_state_entered'; + $data = get_option( $key, array() ); + if ( ! is_array( $data ) ) { + $data = array(); + } + $data[ $module ] = array( + 'state' => $to, + 'entered_at' => current_time( 'mysql', true ), + ); + update_option( $key, $data, false ); + } + + /** + * Read the entry timestamp for a module's current state. + * + * @param string $module Module identifier. + * @return string|null UTC mysql datetime, or null when unrecorded. + */ + public static function get_entry_time( string $module ): ?string { + $data = get_option( 'wpdo_fsm_state_entered', array() ); + return is_array( $data ) && isset( $data[ $module ]['entered_at'] ) ? (string) $data[ $module ]['entered_at'] : null; + } +} diff --git a/includes/snapshots/class-tmdo-snapshot-manager.php b/includes/snapshots/class-tmdo-snapshot-manager.php new file mode 100644 index 0000000..3daa377 --- /dev/null +++ b/includes/snapshots/class-tmdo-snapshot-manager.php @@ -0,0 +1,431 @@ + false, + 'error' => 'invalid_trigger', + ); + } + if ( ! self::ensure_backup_dir() ) { + return array( + 'ok' => false, + 'error' => 'backup_dir_unwritable', + ); + } + + $snapshot_id = self::generate_id(); + $writer = new TMDO_Snapshot_Writer( $snapshot_id, self::resolve_scope( $scope ), $opts ); + + try { + $result = $writer->write(); + } catch ( Throwable $e ) { + TMDO_Logger::error( + 'snapshots', + 'create', + $e->getMessage(), + array( + 'snapshot_id' => $snapshot_id, + 'trigger' => $trigger, + ), + '' + ); + return array( + 'ok' => false, + 'error' => 'writer_failed', + 'message' => $e->getMessage(), + ); + } + + $retention_days = (int) ( $opts['retention_days'] ?? self::DEFAULT_RETENTION_DAYS ); + $expires_at = $retention_days > 0 + ? gmdate( 'Y-m-d H:i:s', time() + $retention_days * DAY_IN_SECONDS ) + : null; + + $row = array( + 'snapshot_id' => $snapshot_id, + 'trigger_type' => $trigger, + 'scope' => wp_json_encode( $writer->get_scope() ), + 'size_bytes' => (int) $result['size_bytes'], + 'row_count' => (int) $result['row_count'], + 'storage' => (string) $result['storage'], + 'file_path' => $result['file_path'] ?? null, + 'file_sha256' => $result['sha256'] ?? null, + 'inline_blob' => $result['inline_blob'] ?? null, + 'fsm_states' => wp_json_encode( self::capture_fsm_states() ), + 'notes' => isset( $opts['notes'] ) ? (string) $opts['notes'] : null, + 'created_at' => current_time( 'mysql', true ), + 'expires_at' => $expires_at, + ); + + $inserted = $wpdb->insert( $wpdb->prefix . self::TABLE_SLUG, $row ); // phpcs:ignore WordPress.DB + if ( false === $inserted ) { + // Insert failed — clean up the written file to avoid orphan. + if ( ! empty( $result['file_path'] ) && file_exists( $result['file_path'] ) ) { + @unlink( $result['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + return array( + 'ok' => false, + 'error' => 'db_insert_failed', + 'message' => $wpdb->last_error, + ); + } + + TMDO_Logger::info( + 'snapshot_created', + array( + 'snapshot_id' => $snapshot_id, + 'trigger' => $trigger, + 'size_bytes' => $result['size_bytes'], + 'row_count' => $result['row_count'], + 'storage' => $result['storage'], + ) + ); + + do_action( 'wpdo_after_snapshot_create', $snapshot_id, $trigger, $row ); + + return array( + 'ok' => true, + 'snapshot_id' => $snapshot_id, + 'size_bytes' => (int) $result['size_bytes'], + 'row_count' => (int) $result['row_count'], + 'storage' => (string) $result['storage'], + ); + } + + /** + * Restore a snapshot. Default dry-run; pass false to actually replay. + * + * @param string $snapshot_id Snapshot ULID. + * @param bool $dry_run When true, return preview without applying. + * @return array {ok:bool, preview?:array, restored?:array, error?:string} + */ + public static function restore( string $snapshot_id, bool $dry_run = true ): array { + $row = self::get( $snapshot_id ); + if ( null === $row ) { + return array( + 'ok' => false, + 'error' => 'not_found', + ); + } + try { + $reader = new TMDO_Snapshot_Reader( $row ); + } catch ( Throwable $e ) { + return array( + 'ok' => false, + 'error' => 'reader_init_failed', + 'message' => $e->getMessage(), + ); + } + try { + if ( $dry_run ) { + $preview = $reader->preview(); + return array( + 'ok' => true, + 'preview' => $preview, + ); + } + $applied = $reader->apply(); + TMDO_Logger::warning( + 'snapshot_restored', + array( + 'snapshot_id' => $snapshot_id, + 'tables' => $applied['tables'] ?? array(), + 'rows' => $applied['rows_restored'] ?? 0, + ) + ); + do_action( 'wpdo_after_snapshot_restore', $snapshot_id, $applied ); + return array( + 'ok' => true, + 'restored' => $applied, + ); + } catch ( Throwable $e ) { + TMDO_Logger::error( 'snapshots', 'restore', $e->getMessage(), array( 'snapshot_id' => $snapshot_id ), '' ); + return array( + 'ok' => false, + 'error' => 'restore_failed', + 'message' => $e->getMessage(), + ); + } + } + + /** + * Verify integrity (sha256, file existence/readability). + * + * @param string $snapshot_id Snapshot ULID. + * @return array {ok:bool, sha256_ok?:bool, size_match?:bool, error?:string} + */ + public static function verify( string $snapshot_id ): array { + $row = self::get( $snapshot_id ); + if ( null === $row ) { + return array( + 'ok' => false, + 'error' => 'not_found', + ); + } + try { + $reader = new TMDO_Snapshot_Reader( $row ); + return $reader->verify(); + } catch ( Throwable $e ) { + return array( + 'ok' => false, + 'error' => 'verify_failed', + 'message' => $e->getMessage(), + ); + } + } + + /** + * List recent snapshots (catalog rows only — no payload). + * + * @param int $limit Max rows. + * @param string|null $trigger_filter Only this trigger_type if set. + * @return array> + */ + public static function list_recent( int $limit = 50, ?string $trigger_filter = null ): array { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_SLUG; + $limit = max( 1, min( 1000, $limit ) ); + if ( null !== $trigger_filter ) { + $rows = $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` WHERE trigger_type = %s ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() + $trigger_filter, + $limit + ), + ARRAY_A + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() + $limit + ), + ARRAY_A + ); + } + return is_array( $rows ) ? $rows : array(); + } + + /** + * Prune expired or oldest-first snapshots up to a size cap. + * + * @param int $older_than_days Default retention window. + * @param int $size_cap_bytes When backup dir exceeds this, evict oldest. + * @return array {pruned:int, freed_bytes:int, errors:array} + */ + public static function prune( int $older_than_days = self::DEFAULT_RETENTION_DAYS, int $size_cap_bytes = self::DEFAULT_SIZE_CAP_BYTES ): array { + return TMDO_Snapshot_Pruner::prune( $older_than_days, $size_cap_bytes ); + } + + /** + * Read a single catalog row by snapshot_id (includes inline_blob). + * + * @param string $snapshot_id Snapshot ULID. + * @return array|null + */ + public static function get( string $snapshot_id ): ?array { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_SLUG; + $row = $wpdb->get_row( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT * FROM `{$table}` WHERE snapshot_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() + $snapshot_id + ), + ARRAY_A + ); + return is_array( $row ) ? $row : null; + } + + /** + * Delete one snapshot (catalog row + file). Idempotent. + * + * @param string $snapshot_id Snapshot ULID. + * @return bool true on row+file deletion (or row absent), false on DB error. + */ + public static function delete( string $snapshot_id ): bool { + global $wpdb; + $row = self::get( $snapshot_id ); + if ( null === $row ) { + return true; + } + if ( ! empty( $row['file_path'] ) && file_exists( $row['file_path'] ) ) { + @unlink( $row['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + $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 ) ); + return false !== $deleted; + } + + /** + * Resolve absolute path to wp-content/uploads/wpdo-backups/. + * + * Multisite (v2.14.0): `wp_upload_dir()` automatically returns the + * current site's uploads basedir — i.e. `/wp-content/uploads/sites/N/` + * for sub-sites and `/wp-content/uploads/` for the main site. So the + * backup directory is naturally per-site isolated; no extra handling + * needed. When a site is deleted via Network → Sites → Delete, the + * entire `sites/N/` tree is removed by WP, taking the snapshots with it. + * + * @return string + */ + public static function backup_dir(): string { + $uploads = wp_upload_dir(); + $base = $uploads['basedir'] ?? WP_CONTENT_DIR . '/uploads'; + return trailingslashit( $base ) . self::BACKUP_DIR_NAME; + } + + /** + * Ensure wp-content/uploads/wpdo-backups/ exists with access-denial guards. + * + * Apache: .htaccess "Deny from all" + modern "Require all denied". + * Nginx: nginx does not read .htaccess; add a location block to site config: + * location ~* /wpdo-backups/ { deny all; } + * The README-NGINX.txt placed here documents this for server admins. + * + * @return bool + */ + public static function ensure_backup_dir(): bool { + $dir = self::backup_dir(); + if ( ! file_exists( $dir ) ) { + if ( ! wp_mkdir_p( $dir ) ) { + return false; + } + } + $base = trailingslashit( $dir ); + + // Apache — "Deny from all" (Apache 2.2) + "Require all denied" (Apache 2.4). + $htaccess = $base . '.htaccess'; + if ( ! file_exists( $htaccess ) ) { + file_put_contents( + $htaccess, + "\n Require all denied\n\n\n Order allow,deny\n Deny from all\n\n" + ); + } + + // Empty index.php — prevents directory listing on Apache without Options -Indexes. + $index = $base . 'index.php'; + if ( ! file_exists( $index ) ) { + file_put_contents( $index, " isset( $scope['entities'] ) && is_array( $scope['entities'] ) ? array_values( array_map( 'strval', $scope['entities'] ) ) : array(), + 'modules' => isset( $scope['modules'] ) && is_array( $scope['modules'] ) ? array_values( array_map( 'strval', $scope['modules'] ) ) : array(), + 'tables' => isset( $scope['tables'] ) && is_array( $scope['tables'] ) ? array_values( array_map( 'strval', $scope['tables'] ) ) : array(), + ); + } + + /** + * Capture per-module FSM states at snapshot creation time (for context + * during restore — admin can decide whether to also rewind FSM). + * + * @return array + */ + private static function capture_fsm_states(): array { + if ( ! class_exists( 'TMDO_Feature_Flags' ) ) { + return array(); + } + $out = array(); + $modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES ); + foreach ( $modules as $m ) { + $out[ $m ] = TMDO_Feature_Flags::get( $m ); + } + return $out; + } +} diff --git a/includes/snapshots/class-tmdo-snapshot-pruner.php b/includes/snapshots/class-tmdo-snapshot-pruner.php new file mode 100644 index 0000000..da2f8a0 --- /dev/null +++ b/includes/snapshots/class-tmdo-snapshot-pruner.php @@ -0,0 +1,198 @@ +prefix . TMDO_Snapshot_Manager::TABLE_SLUG; + + $ttl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB + "SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < UTC_TIMESTAMP()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() + ARRAY_A + ); + $result = self::evict_rows( is_array( $ttl_rows ) ? $ttl_rows : array(), 'ttl' ); + + if ( $size_cap_bytes > 0 ) { + $current_size = self::current_dir_size(); + if ( $current_size > $size_cap_bytes ) { + $over = $current_size - $size_cap_bytes; + $candidates = $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE trigger_type NOT IN ('" . implode( "','", array_map( 'esc_sql', self::PROTECTED_TRIGGERS ) ) . "') ORDER BY created_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.QuotedDynamicPlaceholderGeneration -- dynamic IN clause with trusted table name and string-literal enum values + 100 + ), + ARRAY_A + ); + $cap_result = self::evict_to_recover( is_array( $candidates ) ? $candidates : array(), $over ); + $result['sizecap_pruned'] = $cap_result['pruned']; + $result['freed_bytes'] += $cap_result['freed_bytes']; + $result['pruned'] += $cap_result['pruned']; + $result['errors'] = array_merge( $result['errors'], $cap_result['errors'] ); + } else { + $result['sizecap_pruned'] = 0; + } + } else { + $result['sizecap_pruned'] = 0; + } + + TMDO_Logger::info( + 'snapshot_prune', + array( + 'older_than_days' => $older_than_days, + 'size_cap_bytes' => $size_cap_bytes, + 'pruned' => $result['pruned'], + 'freed_bytes' => $result['freed_bytes'], + 'ttl_pruned' => $result['ttl_pruned'] ?? 0, + 'sizecap_pruned' => $result['sizecap_pruned'], + ) + ); + + return $result; + } + + /** + * Cron handler — called from class-tmdo-core.php via the wpdo_daily_health_check + * subroutine (v2.3.0) or its own scheduled hook. + * + * @return void + */ + public static function cron_run(): void { + self::prune( TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, TMDO_Snapshot_Manager::DEFAULT_SIZE_CAP_BYTES ); + } + + // ─── private ────────────────────────────────────────────────────────── + + /** + * Delete rows + their files. Returns prune accounting. + * + * @param array $rows Rows to evict. + * @param string $phase Tag for logs. + * @return array {pruned:int,freed_bytes:int,ttl_pruned?:int,errors:array} + */ + private static function evict_rows( array $rows, string $phase ): array { + global $wpdb; + $table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG; + $pruned = 0; + $freed = 0; + $errors = array(); + foreach ( $rows as $row ) { + $path = (string) ( $row['file_path'] ?? '' ); + if ( '' !== $path && file_exists( $path ) ) { + $ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + if ( ! $ok ) { + $errors[] = "unlink failed: {$path}"; + } + } + $deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB + if ( false === $deleted ) { + $errors[] = 'db delete failed: ' . $row['snapshot_id']; + continue; + } + ++$pruned; + $freed += (int) $row['size_bytes']; + } + $out = array( + 'pruned' => $pruned, + 'freed_bytes' => $freed, + 'errors' => $errors, + ); + if ( 'ttl' === $phase ) { + $out['ttl_pruned'] = $pruned; + } + return $out; + } + + /** + * Evict from candidates until we've freed `$target_bytes` (or run out). + * + * @param array $candidates Sorted oldest-first. + * @param int $target_bytes Bytes to free. + * @return array {pruned:int,freed_bytes:int,errors:array} + */ + private static function evict_to_recover( array $candidates, int $target_bytes ): array { + global $wpdb; + $table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG; + $pruned = 0; + $freed = 0; + $errors = array(); + foreach ( $candidates as $row ) { + if ( $freed >= $target_bytes ) { + break; + } + $path = (string) ( $row['file_path'] ?? '' ); + if ( '' !== $path && file_exists( $path ) ) { + $ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + if ( ! $ok ) { + $errors[] = "unlink failed: {$path}"; + } + } + $deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB + if ( false === $deleted ) { + $errors[] = 'db delete failed: ' . $row['snapshot_id']; + continue; + } + ++$pruned; + $freed += (int) $row['size_bytes']; + } + return array( + 'pruned' => $pruned, + 'freed_bytes' => $freed, + 'errors' => $errors, + ); + } + + /** + * Sum all backup directory file sizes. + * + * @return int Bytes. + */ + private static function current_dir_size(): int { + $dir = TMDO_Snapshot_Manager::backup_dir(); + if ( ! is_dir( $dir ) ) { + return 0; + } + $total = 0; + $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) ); + foreach ( $it as $file ) { + if ( $file->isFile() ) { + $total += $file->getSize(); + } + } + return $total; + } +} diff --git a/includes/snapshots/class-tmdo-snapshot-reader.php b/includes/snapshots/class-tmdo-snapshot-reader.php new file mode 100644 index 0000000..ec871f2 --- /dev/null +++ b/includes/snapshots/class-tmdo-snapshot-reader.php @@ -0,0 +1,266 @@ + WHERE 1=1 before re-inserting (clean slate per table). + * Caller is responsible for FSM rewind / cache flush around this call. + * + * @package WP_Data_Optimizer + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Snapshot reader: integrity check + apply. + */ +class TMDO_Snapshot_Reader { + + /** + * Catalog row from wp_wpdo_snapshots. + * + * @var array + */ + private array $row; + + /** + * Constructor. + * + * @param array $row Catalog row (must contain snapshot_id, storage, file_path, file_sha256, inline_blob, size_bytes). + * @throws InvalidArgumentException When row is missing required keys. + */ + public function __construct( array $row ) { + foreach ( array( 'snapshot_id', 'storage', 'size_bytes' ) as $required ) { + if ( ! array_key_exists( $required, $row ) ) { + throw new InvalidArgumentException( "snapshot row missing key: {$required}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + } + $this->row = $row; + } + + /** + * Verify sha256 + readability. + * + * @return array {ok:bool, sha256_ok:bool, size_match:bool, error?:string} + */ + public function verify(): array { + $storage = (string) $this->row['storage']; + $expected_size = (int) $this->row['size_bytes']; + $expected_sha = (string) ( $this->row['file_sha256'] ?? '' ); + + if ( 'inline' === $storage ) { + $blob = (string) ( $this->row['inline_blob'] ?? '' ); + $actual_size = strlen( $blob ); + // inline blobs aren't sha-checked by default — just check size. + return array( + 'ok' => $actual_size === $expected_size, + 'sha256_ok' => true, + 'size_match' => $actual_size === $expected_size, + 'storage' => 'inline', + ); + } + + $path = (string) ( $this->row['file_path'] ?? '' ); + if ( '' === $path || ! file_exists( $path ) ) { + return array( + 'ok' => false, + 'error' => 'file_missing', + 'storage' => 'file', + ); + } + if ( ! is_readable( $path ) ) { + return array( + 'ok' => false, + 'error' => 'file_unreadable', + 'storage' => 'file', + ); + } + $actual_size = (int) filesize( $path ); + $actual_sha = hash_file( 'sha256', $path ); + return array( + 'ok' => ( $actual_size === $expected_size ) && ( $actual_sha === $expected_sha ), + 'sha256_ok' => ( $actual_sha === $expected_sha ), + 'size_match' => ( $actual_size === $expected_size ), + 'storage' => 'file', + ); + } + + /** + * Parse SQL stream and return a preview of tables + row counts (no apply). + * + * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int} + * @throws RuntimeException When payload cannot be loaded. + */ + public function preview(): array { + $sql = $this->load_sql(); + return $this->parse_summary( $sql ); + } + + /** + * Load + apply (DELETE + INSERT per table). + * + * For file-storage snapshots, sha256 is verified before execution. + * Only tables whose names pass is_safe_name() (prefix-prefixed, alphanum+_) + * are touched — arbitrary table names in the SQL file cannot be applied. + * + * @return array {tables:array, rows_restored:int, statements_run:int} + * @throws RuntimeException When payload cannot be loaded or integrity check fails. + */ + public function apply(): array { + global $wpdb; + + // Verify sha256 / size for file-storage snapshots before executing any SQL. + if ( 'inline' !== (string) ( $this->row['storage'] ?? 'file' ) ) { + $check = $this->verify(); + if ( ! $check['ok'] ) { + $reason = $check['error'] ?? ( $check['sha256_ok'] ? 'size_mismatch' : 'sha256_mismatch' ); + throw new RuntimeException( 'snapshot integrity check failed: ' . $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + } + + $sql = $this->load_sql(); + $summary = $this->parse_summary( $sql ); + + // Wipe target tables before inserting (clean slate). + foreach ( array_keys( $summary['tables'] ) as $table ) { + if ( $this->is_safe_name( $table ) ) { + $wpdb->query( "DELETE FROM `{$table}`" ); // phpcs:ignore WordPress.DB + } + } + + $statements_run = 0; + $rows_restored = 0; + // Naive split on `;\n` — INSERT statements never contain unescaped semicolon-newline. + $lines = preg_split( '/;\s*\n/', $sql ); + if ( ! is_array( $lines ) ) { + throw new RuntimeException( 'sql parse failed' ); + } + foreach ( $lines as $stmt ) { + $stmt = trim( $stmt ); + if ( '' === $stmt || str_starts_with( $stmt, '--' ) ) { + continue; + } + if ( ! preg_match( '/^(INSERT|SET)\b/i', $stmt ) ) { + continue; + } + $result = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB + if ( false === $result ) { + throw new RuntimeException( 'restore stmt failed: ' . substr( $stmt, 0, 80 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + if ( stripos( $stmt, 'INSERT' ) === 0 ) { + $rows_restored += (int) $result; + } + ++$statements_run; + } + + return array( + 'tables' => array_keys( $summary['tables'] ), + 'rows_restored' => $rows_restored, + 'statements_run' => $statements_run, + ); + } + + // ─── private ────────────────────────────────────────────────────────── + + /** + * Load + ungzip the snapshot SQL payload. + * + * @return string Raw SQL. + * @throws RuntimeException When payload is missing or unreadable. + */ + private function load_sql(): string { + $storage = (string) $this->row['storage']; + if ( 'inline' === $storage ) { + $blob = (string) ( $this->row['inline_blob'] ?? '' ); + if ( '' === $blob ) { + throw new RuntimeException( 'inline_blob is empty' ); + } + return $this->maybe_gunzip( $blob ); + } + $path = (string) ( $this->row['file_path'] ?? '' ); + if ( '' === $path || ! file_exists( $path ) ) { + throw new RuntimeException( 'snapshot file missing: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + if ( str_ends_with( $path, '.gz' ) ) { + $content = ''; + $handle = gzopen( $path, 'rb' ); + if ( false === $handle ) { + throw new RuntimeException( 'gzopen failed: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + while ( ! gzeof( $handle ) ) { + $content .= gzread( $handle, 65536 ); + } + gzclose( $handle ); + return $content; + } + return (string) file_get_contents( $path ); + } + + /** + * Decompress if first 2 bytes are gzip magic. + * + * @param string $blob Possibly-gzipped data. + * @return string Decompressed. + */ + private function maybe_gunzip( string $blob ): string { + if ( strlen( $blob ) >= 2 && "\x1f\x8b" === substr( $blob, 0, 2 ) ) { + $out = @gzdecode( $blob ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + return false === $out ? $blob : $out; + } + return $blob; + } + + /** + * Walk SQL and count INSERT rows per table. + * + * @param string $sql Raw SQL. + * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int} + */ + private function parse_summary( string $sql ): array { + $tables = array(); + $total = 0; + $statements = 0; + preg_match_all( '/INSERT INTO `([^`]+)` \([^)]*\) VALUES (.+?);\s*\n/s', $sql, $matches, PREG_SET_ORDER ); + foreach ( $matches as $m ) { + $table = $m[1]; + $body = $m[2]; + // Count `(` at the start of value tuples — substring_count of `,(` + 1. + $rows = substr_count( $body, "),\n (" ) + 1; + $tables[ $table ] = ( $tables[ $table ] ?? 0 ) + $rows; + $total += $rows; + ++$statements; + } + return array( + 'tables' => $tables, + 'total_rows' => $total, + 'statements' => $statements, + 'sql_bytes' => strlen( $sql ), + ); + } + + /** + * Match safe table names: alphanumeric + underscore, must start with $wpdb->prefix. + * + * Mirrors TMDO_Snapshot_Writer::is_safe_name() so writer and reader apply + * the same gate — an attacker-crafted SQL file cannot target tables outside + * this site's WordPress prefix. + * + * @param string $name Table name. + * @return bool + */ + private function is_safe_name( string $name ): bool { + global $wpdb; + return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name ) + && strpos( $name, $wpdb->prefix ) === 0; + } +} diff --git a/includes/snapshots/class-tmdo-snapshot-writer.php b/includes/snapshots/class-tmdo-snapshot-writer.php new file mode 100644 index 0000000..c7e6144 --- /dev/null +++ b/includes/snapshots/class-tmdo-snapshot-writer.php @@ -0,0 +1,331 @@ + + */ + private array $opts; + + /** + * Constructor. + * + * @param string $snapshot_id Snapshot ULID. + * @param array $scope Resolved scope. + * @param array $opts {gzip:bool, inline_threshold_bytes:int, max_tables:int}. + */ + public function __construct( string $snapshot_id, array $scope, array $opts = array() ) { + $this->snapshot_id = $snapshot_id; + $this->scope = $scope; + $this->opts = $opts; + } + + /** + * Run the dump. + * + * @return array {storage,file_path,sha256,size_bytes,row_count,inline_blob} + * @throws RuntimeException When no tables resolved or filesystem unwritable. + */ + public function write(): array { + $tables = $this->resolve_tables(); + if ( empty( $tables ) ) { + throw new RuntimeException( 'snapshot scope produced 0 tables' ); + } + + $gzip = (bool) ( $this->opts['gzip'] ?? true ); + $inline_th = (int) ( $this->opts['inline_threshold_bytes'] ?? TMDO_Snapshot_Manager::INLINE_THRESHOLD_BYTES ); + $dir = TMDO_Snapshot_Manager::backup_dir(); + $ext = $gzip ? '.sql.gz' : '.sql'; + $path = trailingslashit( $dir ) . $this->snapshot_id . $ext; + + $handle = $gzip ? gzopen( $path, 'wb6' ) : fopen( $path, 'wb' ); + if ( false === $handle ) { + throw new RuntimeException( 'cannot open ' . $path . ' for write' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output + } + $write = static function ( string $chunk ) use ( $handle, $gzip ): void { + if ( $gzip ) { + gzwrite( $handle, $chunk ); + } else { + fwrite( $handle, $chunk ); + } + }; + + $row_count = 0; + $header = $this->dump_header( $tables ); + $write( $header ); + + global $wpdb; + foreach ( $tables as $table ) { + $row_count += $this->dump_table( $table, $write ); + } + $write( "\n-- end of dump\n" ); + + if ( $gzip ) { + gzclose( $handle ); + } else { + fclose( $handle ); + } + + $size = (int) filesize( $path ); + $sha = hash_file( 'sha256', $path ); + + // Decide inline vs file storage. + if ( $size <= $inline_th ) { + $blob = file_get_contents( $path ); + @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + return array( + 'storage' => 'inline', + 'file_path' => null, + 'sha256' => $sha, + 'size_bytes' => $size, + 'row_count' => $row_count, + 'inline_blob' => $blob, + ); + } + + return array( + 'storage' => 'file', + 'file_path' => $path, + 'sha256' => $sha, + 'size_bytes' => $size, + 'row_count' => $row_count, + 'inline_blob' => null, + ); + } + + /** + * Returns canonical scope (after manager resolution). + * + * @return array + */ + public function get_scope(): array { + return $this->scope; + } + + // ─── private ────────────────────────────────────────────────────────── + + /** + * Resolve which tables to dump. + * + * Priority: + * 1. Explicit `scope.tables` if provided. + * 2. `scope.entities` → wp_postmeta / wp_usermeta / etc. + * 3. Empty scope → all WPDO-owned tables (zone + system) + wp_postmeta if classifier active. + * + * @return array Fully-prefixed table names. + */ + private function resolve_tables(): array { + global $wpdb; + if ( ! empty( $this->scope['tables'] ) ) { + return array_values( array_filter( array_map( 'strval', $this->scope['tables'] ), array( $this, 'is_safe_name' ) ) ); + } + + $max = (int) ( $this->opts['max_tables'] ?? 200 ); + $out = array(); + + // Always include WPDO system tables. + $wpdo_likes = array( + $wpdb->prefix . 'wpdo_%', + ); + foreach ( $wpdo_likes as $like ) { + if ( class_exists( 'WP_SQLite_Driver' ) ) { + $rows = $wpdb->get_col( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE %s", + $like + ) + ); + } else { + $rows = $wpdb->get_col( + $wpdb->prepare( // phpcs:ignore WordPress.DB + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s', + $like + ) + ); + } + if ( is_array( $rows ) ) { + foreach ( $rows as $t ) { + if ( $this->is_safe_name( (string) $t ) ) { + $out[ $t ] = true; + } + } + } + } + + // Skip wp_wpdo_snapshots itself — we don't want a dump of a dump. + unset( $out[ $wpdb->prefix . 'wpdo_snapshots' ] ); + + // If `entities` includes 'post', also dump wp_postmeta (mainly used for + // pre_v2_upgrade trigger so we have the pre-migration meta). + if ( ! empty( $this->scope['entities'] ) ) { + $entity_to_table = array( + 'post' => $wpdb->postmeta, + 'user' => $wpdb->usermeta, + 'term' => $wpdb->termmeta ?? ( $wpdb->prefix . 'termmeta' ), + 'comment' => $wpdb->commentmeta, + ); + foreach ( $this->scope['entities'] as $entity ) { + if ( isset( $entity_to_table[ $entity ] ) ) { + $t = $entity_to_table[ $entity ]; + if ( $this->is_safe_name( $t ) ) { + $out[ $t ] = true; + } + } + } + } + + $tables = array_keys( $out ); + if ( count( $tables ) > $max ) { + $tables = array_slice( $tables, 0, $max ); + } + sort( $tables ); + return $tables; + } + + /** + * Validate a table name (alphanumeric + underscore only, must start with $wpdb->prefix). + * + * @param string $name Table name. + * @return bool + */ + private function is_safe_name( string $name ): bool { + global $wpdb; + return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name ) + && strpos( $name, $wpdb->prefix ) === 0; + } + + /** + * Dump header (timestamp + table list). + * + * @param array $tables Tables that will be dumped. + * @return string + */ + private function dump_header( array $tables ): string { + $out = "-- WPDO snapshot {$this->snapshot_id}\n"; + $out .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n"; + $out .= '-- Tables: ' . count( $tables ) . "\n"; + $out .= "-- Format: SQL INSERT statements (one per row, batched by table)\n\n"; + $out .= "SET NAMES utf8mb4;\n"; + $out .= "SET FOREIGN_KEY_CHECKS=0;\n\n"; + return $out; + } + + /** + * Stream-dump one table. + * + * @param string $table Fully-prefixed table name. + * @param callable $write Stream writer. + * @return int rows dumped. + */ + private function dump_table( string $table, callable $write ): int { + global $wpdb; + + $write( "-- Table {$table}\n" ); + $count_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB + if ( 0 === $count_total ) { + $write( "-- (empty)\n\n" ); + return 0; + } + + $columns = $wpdb->get_col( "DESC `{$table}`" ); // phpcs:ignore WordPress.DB + if ( ! is_array( $columns ) || empty( $columns ) ) { + return 0; + } + $col_list = '`' . implode( '`,`', $columns ) . '`'; + + $offset = 0; + $rows_dumped = 0; + while ( $offset < $count_total ) { + $chunk = $wpdb->get_results( + $wpdb->prepare( // phpcs:ignore WordPress.DB + "SELECT * FROM `{$table}` LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table() + self::CHUNK_SIZE, + $offset + ), + ARRAY_A + ); + if ( ! is_array( $chunk ) || empty( $chunk ) ) { + break; + } + $values = array(); + foreach ( $chunk as $row ) { + $row_vals = array(); + foreach ( $columns as $col ) { + $row_vals[] = $this->escape_sql_value( $row[ $col ] ?? null ); + } + $values[] = '(' . implode( ',', $row_vals ) . ')'; + } + $write( "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n ", $values ) . ";\n" ); + $rows_dumped += count( $chunk ); + $offset += self::CHUNK_SIZE; + } + $write( "\n" ); + return $rows_dumped; + } + + /** + * Escape a single value for SQL INSERT. + * + * @param mixed $v Value. + * @return string SQL-quoted literal. + */ + private function escape_sql_value( $v ): string { + global $wpdb; + if ( null === $v ) { + return 'NULL'; + } + if ( is_bool( $v ) ) { + return $v ? '1' : '0'; + } + if ( is_int( $v ) || is_float( $v ) ) { + return (string) $v; + } + // String / longtext / blob — wrap with hex notation if non-utf8 bytes. + $s = (string) $v; + // Use mb_check_encoding when available; fall back to a heuristic. + $is_utf8 = function_exists( 'mb_check_encoding' ) ? mb_check_encoding( $s, 'UTF-8' ) : ( @iconv( 'UTF-8', 'UTF-8//IGNORE', $s ) === $s ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + if ( ! $is_utf8 ) { + return '0x' . bin2hex( $s ); + } + return "'" . esc_sql( $s ) . "'"; + } +} diff --git a/includes/trait-tmdo-anti-eav-aware.php b/includes/trait-tmdo-anti-eav-aware.php new file mode 100644 index 0000000..3726499 --- /dev/null +++ b/includes/trait-tmdo-anti-eav-aware.php @@ -0,0 +1,211 @@ +register_field( $registry, [...] )` + * for each post-meta key the plugin manages. + * + * @param TMDO_Schema_Registry $registry The shared field registry. + */ + public function on_register_fields( TMDO_Schema_Registry $registry ): void { + // Default no-op. Override in subclass. + } + + /** + * Register custom tables owned by this plugin. + * + * Override in subclass and call `$this->register_custom_table( $r, [...] )` + * for each table. + * + * @param TMDO_Custom_Table_Registry $r The shared custom-table registry. + */ + public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void { + // Default no-op. Override in subclass. + } + + /** + * Register entity field groups (user / term / comment / post metadata). + * + * Override in subclass and call `$this->register_entity_fields( $type, $group, [...] )` + * for each logical field group the plugin contributes. + */ + public function on_register_entity_fields(): void { + // Default no-op. Override in subclass. + } + + // ── Sugar wrappers around WPDO public API ──────────────────────────────── + + /** + * Register a single zone field via TMDO_Schema_Registry. + * + * @param TMDO_Schema_Registry $registry Provided by the `wpdo_register_fields` hook. + * @param array $field_def Field definition (post_type, meta_key, zone, ...). + */ + protected function register_field( TMDO_Schema_Registry $registry, array $field_def ): void { + $registry->register( $this->plugin_slug(), $field_def ); + } + + /** + * Register a custom table via TMDO_Custom_Table_Registry. + * + * @param TMDO_Custom_Table_Registry $r Provided by the `wpdo_register_custom_tables` hook. + * @param array $table_def Table definition (table_name, primary_key, expected_columns, indexes, ...). + */ + protected function register_custom_table( TMDO_Custom_Table_Registry $r, array $table_def ): void { + $r->register( $this->plugin_slug(), $table_def ); + } + + /** + * Register an entity field group via TMDO_Entity_Registry. + * + * @param string $entity_type 'post' | 'user' | 'term' | 'comment'. + * @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience'). + * @param array> $fields Field definitions. + */ + protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool { + if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { + return false; + } + return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields ); + } + + // ── Read/write convenience wrappers ────────────────────────────────────── + + /** + * Read an entity field via the TMDO_API facade. + * + * @param string $entity_type 'post' | 'user' | 'term' | 'comment'. + * @param int $entity_id Entity primary id. + * @param string $key Meta key. + * @param bool $single Return single value (default true). + */ + protected function get_field( string $entity_type, int $entity_id, string $key, bool $single = true ): mixed { + return TMDO_API::get_entity( $entity_type, $entity_id, $key, $single ); + } + + /** + * Write an entity field via the TMDO_API facade. + * + * @param string $entity_type 'post' | 'user' | 'term' | 'comment'. + * @param int $entity_id Entity primary id. + * @param string $key Meta key. + * @param mixed $value Meta value. + * + * @return bool|int False on failure, otherwise the meta id (add) or true (update). + */ + protected function set_field( string $entity_type, int $entity_id, string $key, mixed $value ): bool|int { + return TMDO_API::set_entity( $entity_type, $entity_id, $key, $value ); + } + + /** + * Subscribe to write events on a specific meta key. + * + * Convenience wrapper around `add_action('wpdo_after_write', ...)` that + * filters callbacks to the plugin's own keys via prefix match. + * + * @param string $key_prefix Meta-key prefix to react on (e.g. 'tmos_vendor_'). + * @param callable $callback `function(string $entity_type, int $entity_id, string $key, mixed $value, bool $ok, string $op, mixed $before)`. + * @param int $priority WordPress action priority (default 10). + */ + protected function on_after_write( string $key_prefix, callable $callback, int $priority = 10 ): void { + add_action( + 'wpdo_after_write', + static function ( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before ) use ( $key_prefix, $callback ): void { + if ( is_string( $meta_key ) && str_starts_with( $meta_key, $key_prefix ) ) { + $callback( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before ); + } + }, + $priority, + 7 + ); + } + + // ── Backward-compat (legacy 2meet-* mu-plugin trait surface) ───────────── + // The pre-v2.17.0 mu-plugin trait stub exposed two abstract static methods + // (`register_wpdo_fields`, `register_custom_tables`). Older partner plugins + // (e.g. 2meet-inquiries) implemented those static methods. The promoted + // trait keeps the names with non-abstract no-op defaults so existing + // classes continue to work without modification. + + /** + * Legacy entry point for plugins that registered fields via a static method + * before v2.17.0. New partner plugins should override `on_register_fields()` + * (instance method) instead. + * + * @deprecated 2.17.0 Use instance method `on_register_fields()` + `bind_anti_eav_hooks()` instead. + */ + public static function register_wpdo_fields(): void { + // No-op default. Legacy classes overrode this and called the registry directly. + } + + /** + * Legacy entry point for plugins that registered custom tables via a static + * method before v2.17.0. + * + * @deprecated 2.17.0 Use instance method `on_register_custom_tables()` + `bind_anti_eav_hooks()` instead. + */ + public static function register_custom_tables(): void { + // No-op default. + } +} + +} // end if ( ! trait_exists ) diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php new file mode 100644 index 0000000..4293a55 --- /dev/null +++ b/includes/zones/class-tmdo-zone-archive.php @@ -0,0 +1,271 @@ +insert( + $table, + array( + 'post_id' => $post_id, + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'meta_value' => $meta_value, + 'compressed' => $compressed, + 'archived_at' => TMDO_DB::now(), + 'original_meta_id' => $meta_id, + ), + array( '%d', '%s', '%s', '%s', '%d', '%s', '%d' ) + ); + } + + /** + * Archive multiple postmeta entries in batch. + * + * @param array $entries Array of [post_id, post_type, meta_key, meta_value, meta_id]. + * @param bool $compress Whether to compress values. + * @return void + * @throws \Throwable When a batch insert fails and the transaction is rolled back. + */ + public static function archive_batch( array $entries, bool $compress = false ): void { + TMDO_DB::begin(); + try { + foreach ( $entries as $entry ) { + self::archive( + (int) $entry['post_id'], + $entry['post_type'], + $entry['meta_key'], + $entry['meta_value'], + (int) ( $entry['meta_id'] ?? 0 ), + $compress + ); + } + TMDO_DB::commit(); + } catch ( \Throwable $e ) { + TMDO_DB::rollback(); + throw $e; + } + } + + /** + * Retrieve archived values for a post. + * + * @param int $post_id Post ID. + * @param string|null $meta_key Optional specific meta_key filter. + * @return array Array of [meta_key, meta_value, archived_at, compressed]. + */ + public static function get( int $post_id, ?string $meta_key = null ): array { + global $wpdb; + $table = self::table(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Archive::table() via TMDO_DB::table(). + if ( $meta_key ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d AND meta_key = %s ORDER BY archived_at DESC", + $post_id, + $meta_key + ), + ARRAY_A + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d ORDER BY archived_at DESC", + $post_id + ), + ARRAY_A + ); + } + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + // Decompress where needed. + // Note: must iterate $rows directly (not $rows ?: []) to allow &$row to modify the original array. + foreach ( $rows as &$row ) { + if ( (int) $row['compressed'] && function_exists( 'gzdecode' ) ) { + $decoded = base64_decode( $row['meta_value'] ); + if ( false !== $decoded ) { + $decompressed = gzdecode( $decoded ); + if ( false !== $decompressed ) { + $row['meta_value'] = $decompressed; + } + } + } + unset( $row['compressed'] ); + } + + return $rows ?: array(); + } + + /** + * Restore archived entries back to wp_postmeta. + * + * @param int $post_id Post ID. + * @param string|null $meta_key Optional meta_key filter (null = restore all). + * @return int Number of entries restored. + */ + public static function restore( int $post_id, ?string $meta_key = null ): int { + $entries = self::get( $post_id, $meta_key ); + $count = 0; + + foreach ( $entries as $entry ) { + update_post_meta( $post_id, $entry['meta_key'], $entry['meta_value'] ); + ++$count; + } + + // Delete restored entries from archive. + if ( $count > 0 ) { + global $wpdb; + $table = self::table(); + + if ( $meta_key ) { + $wpdb->delete( + $table, + array( + 'post_id' => $post_id, + 'meta_key' => $meta_key, + ), + array( '%d', '%s' ) + ); + } else { + $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); + } + } + + return $count; + } + + /** + * Delete all archived entries for a post. + * + * @param int $post_id Post ID. + * @return void + */ + public static function delete( int $post_id ): void { + global $wpdb; + $wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) ); + } + + /** + * Sweep: archive stale postmeta entries by age. + * + * Finds postmeta for trashed/deleted posts older than $days and archives them. + * + * @param int $days Minimum age in days. + * @param bool $compress Whether to compress. + * @param int $limit Maximum rows per sweep. + * @return int Number of entries archived. + */ + public static function sweep( int $days = 90, bool $compress = true, int $limit = 500 ): int { + global $wpdb; + + $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_status = 'trash' + AND p.post_modified_gmt < %s + LIMIT %d", + $cutoff, + $limit + ), + ARRAY_A + ); + + if ( ! $rows ) { + return 0; + } + + $entries = array(); + foreach ( $rows as $row ) { + $entries[] = array( + 'post_id' => $row['post_id'], + 'post_type' => $row['post_type'], + 'meta_key' => $row['meta_key'], + 'meta_value' => $row['meta_value'], + 'meta_id' => $row['meta_id'], + ); + } + + self::archive_batch( $entries, $compress ); + + return count( $entries ); + } + + /** + * Get archive statistics. + * + * @return array{total_rows: int, compressed_rows: int, post_types: array} + */ + public static function stats(): array { + global $wpdb; + $table = self::table(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table() + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); + $compressed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE compressed = 1" ); + + $types = $wpdb->get_results( + "SELECT post_type, COUNT(*) as cnt FROM `{$table}` GROUP BY post_type ORDER BY cnt DESC", + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + return array( + 'total_rows' => $total, + 'compressed_rows' => $compressed, + 'post_types' => $types ?: array(), + ); + } +} diff --git a/includes/zones/class-tmdo-zone-cold.php b/includes/zones/class-tmdo-zone-cold.php new file mode 100644 index 0000000..6b997a3 --- /dev/null +++ b/includes/zones/class-tmdo-zone-cold.php @@ -0,0 +1,262 @@ +get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table ) + ); + } else { + $exists = $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) + ); + } + + if ( ! $exists ) { + TMDO_Installer::create_cold_table( $post_type ); + } + } + + /** + * Get a single cold meta value for a post. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @return mixed|null + */ + public static function get( int $post_id, string $post_type, string $meta_key ): mixed { + $data = self::get_blob( $post_id, $post_type ); + return $data[ $meta_key ] ?? null; + } + + /** + * Get the full JSON blob for a post, with Object Cache layer. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @return array Decoded JSON data (meta_key => value). + */ + public static function get_blob( int $post_id, string $post_type ): array { + $group = self::cache_group( $post_type ); + $cache_key = "cold_{$post_id}"; + + $cached = wp_cache_get( $cache_key, $group ); + if ( false !== $cached ) { + return is_array( $cached ) ? $cached : array(); + } + + global $wpdb; + $table = self::table( $post_type ); + + $json = $wpdb->get_var( + $wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table(). + ); + + $data = $json ? json_decode( $json, true ) : array(); + if ( ! is_array( $data ) ) { + $data = array(); + } + + // Get cache TTL from registry — 粒度化策略:取所有 cold fields 的最小 TTL, + // 確保任何短 TTL 欄位(如 IG token:v2.0.4 起預期 < 5 min)能在保護期內失效, + // 而非被某個長 TTL 欄位拖拽到 1h 才更新。原本的「取第一個」策略會視註冊順序 + // 而異,無法保證安全。 + // 同時提供 `wpdo_cold_cache_ttl` filter 讓 partner integration 進一步覆蓋. + $fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type ); + $ttls = array(); + foreach ( $fields as $field ) { + if ( ! empty( $field['cache_ttl'] ) ) { + $ttls[] = (int) $field['cache_ttl']; + } + } + $ttl = empty( $ttls ) ? HOUR_IN_SECONDS : min( $ttls ); + + /** + * Filter the effective cache TTL for a cold zone post type. + * + * @since 2.0.5 + * @param int $ttl Computed TTL (min of all registered fields, or HOUR_IN_SECONDS default). + * @param string $post_type Post type being cached. + * @param int $post_id Post ID (entity being read). + * @param array $fields Fields registry config for this post_type. + */ + $ttl = (int) apply_filters( 'wpdo_cold_cache_ttl', $ttl, $post_type, $post_id, $fields ); + + wp_cache_set( $cache_key, $data, $group, $ttl ); + + return $data; + } + + /** + * Set a single cold meta value for a post. + * Merges into the existing JSON blob. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $meta_key Meta key. + * @param mixed $value Value to store. + */ + public static function set( int $post_id, string $post_type, string $meta_key, mixed $value ): void { + $data = self::get_blob_raw( $post_id, $post_type ); + $data[ $meta_key ] = $value; + self::save_blob( $post_id, $post_type, $data ); + } + + /** + * Set multiple cold meta values at once. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param array $values meta_key => value pairs. + */ + public static function set_many( int $post_id, string $post_type, array $values ): void { + $data = self::get_blob_raw( $post_id, $post_type ); + $data = array_merge( $data, $values ); + self::save_blob( $post_id, $post_type, $data ); + } + + /** + * Remove a key from the cold blob. + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @param string $meta_key Meta key to remove. + * @return void + */ + public static function remove( int $post_id, string $post_type, string $meta_key ): void { + $data = self::get_blob_raw( $post_id, $post_type ); + unset( $data[ $meta_key ] ); + self::save_blob( $post_id, $post_type, $data ); + } + + /** + * Delete the entire cold row for a post. + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @return void + */ + 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 ) ); + } + + // ── Private helpers ─────────────────────────────────────────────────── + + /** + * Get raw blob from DB (no cache). + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @return array Decoded JSON data array. + */ + private static function get_blob_raw( int $post_id, string $post_type ): array { + global $wpdb; + $table = self::table( $post_type ); + + $json = $wpdb->get_var( + $wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table(). + ); + + $data = $json ? json_decode( $json, true ) : array(); + return is_array( $data ) ? $data : array(); + } + + /** + * Save the JSON blob and invalidate cache. + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @param array $data Key-value pairs to store as JSON. + * @return void + */ + private static function save_blob( int $post_id, string $post_type, array $data ): void { + global $wpdb; + $table = self::table( $post_type ); + $now = TMDO_DB::now(); + $json = wp_json_encode( $data ); + + $existing = $wpdb->get_var( + $wpdb->prepare( "SELECT id FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table(). + ); + + if ( $existing ) { + $wpdb->update( + $table, + array( + 'data' => $json, + 'updated_at' => $now, + ), + array( 'post_id' => $post_id ), + array( '%s', '%s' ), + array( '%d' ) + ); + } else { + $wpdb->insert( + $table, + array( + 'post_id' => $post_id, + 'data' => $json, + 'updated_at' => $now, + ), + array( '%d', '%s', '%s' ) + ); + } + + // Invalidate object cache. + wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); + } +} diff --git a/includes/zones/class-tmdo-zone-hot.php b/includes/zones/class-tmdo-zone-hot.php new file mode 100644 index 0000000..2657959 --- /dev/null +++ b/includes/zones/class-tmdo-zone-hot.php @@ -0,0 +1,212 @@ +get_hot_columns( $post_type ); + if ( empty( $columns ) ) { + return; + } + + global $wpdb; + $table = self::table( $post_type ); + + // Quick existence check. + if ( TMDO_IS_SQLITE ) { + $exists = $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table ) + ); + } else { + $exists = $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table ) + ); + } + + if ( ! $exists ) { + TMDO_Installer::create_hot_table( $post_type, $columns ); + return; + } + + // v2.1.2 critical fix: ensure existing tables have all columns declared + // by Schema_Registry. Partner plugins registering new hot fields after + // the initial migration would otherwise silently fall back to postmeta. + // + // v2.1.3 optimization: schema fingerprint stored in option. When the hash + // of declared columns matches the stored hash, skip SHOW COLUMNS entirely. + // Only diff + ALTER fires when fingerprint actually changed (drift detected). + // This avoids per-request SHOW COLUMNS overhead on stable production sites. + static $checked = array(); + if ( isset( $checked[ $post_type ] ) ) { + return; + } + + $fingerprint = self::compute_fingerprint( $columns ); + $opt_key = 'wpdo_hot_fp_' . $post_type; + $stored_fp = (string) get_option( $opt_key, '' ); + + if ( $stored_fp === $fingerprint ) { + // Schema unchanged since last successful ensure → no need to SHOW COLUMNS. + $checked[ $post_type ] = true; + return; + } + + // Fingerprint mismatch (or first run) → run diff + ALTER, then store new hash. + TMDO_Installer::ensure_hot_columns( $post_type, $columns ); + update_option( $opt_key, $fingerprint, false ); + $checked[ $post_type ] = true; + } + + /** + * Stable hash of declared column definitions. Used to short-circuit SHOW COLUMNS + * when Schema_Registry hasn't changed since the last successful ensure. + * + * @param array $columns column_name => sql_type map. + * @return string Short SHA-1 prefix (10 chars — collision-safe at our scale). + * + * @since 2.1.3 + */ + private static function compute_fingerprint( array $columns ): string { + ksort( $columns ); + return substr( sha1( wp_json_encode( $columns ) ?: '' ), 0, 10 ); + } + + /** + * Read a single field value from the hot table. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $column Column name in hot table. + * @return mixed|null Value or null if not found. + */ + public static function get( int $post_id, string $post_type, string $column ): mixed { + global $wpdb; + $table = self::table( $post_type ); + $column = sanitize_key( $column ); + + return $wpdb->get_var( + $wpdb->prepare( + "SELECT `{$column}` FROM `{$table}` WHERE post_id = %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column from sanitize_key(); table from TMDO_DB::table(). + $post_id + ) + ); + } + + /** + * Read all hot fields for a post as an associative array. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @return array|null Column => value pairs, or null. + */ + public static function get_row( int $post_id, string $post_type ): ?array { + global $wpdb; + $table = self::table( $post_type ); + + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Hot::table() via TMDO_DB::table(). + ARRAY_A + ); + + return $row ?: null; + } + + /** + * Upsert a single field into the hot table. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param string $column Column name. + * @param mixed $value Value to set. + */ + public static function set( int $post_id, string $post_type, string $column, mixed $value ): void { + $table = self::table( $post_type ); + $column = sanitize_key( $column ); + $now = TMDO_DB::now(); + + TMDO_DB::upsert( + $table, + array( + 'post_id' => $post_id, + $column => $value, + 'updated_at' => $now, + ), + array( $column, 'updated_at' ), + 'post_id' + ); + } + + /** + * Upsert multiple fields at once for a post. + * + * @param int $post_id Post ID. + * @param string $post_type Post type. + * @param array $data Column => value pairs. + */ + public static function set_many( int $post_id, string $post_type, array $data ): void { + $table = self::table( $post_type ); + $now = TMDO_DB::now(); + + $data['post_id'] = $post_id; + $data['updated_at'] = $now; + $update_cols = array_diff( array_keys( $data ), array( 'post_id' ) ); + + TMDO_DB::upsert( $table, $data, $update_cols, 'post_id' ); + } + + /** + * Delete a post's row from the hot table. + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @return void + */ + 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' ) ); + } + + /** + * Get all post types that have hot zone tables registered. + * + * @return string[] + */ + public static function get_post_types(): array { + return TMDO_Schema_Registry::instance()->get_hot_post_types(); + } +} diff --git a/includes/zones/class-tmdo-zone-warm.php b/includes/zones/class-tmdo-zone-warm.php new file mode 100644 index 0000000..2599f1c --- /dev/null +++ b/includes/zones/class-tmdo-zone-warm.php @@ -0,0 +1,193 @@ +get_var( + $wpdb->prepare( + "SELECT meta_value FROM `{$table}` + WHERE post_id = %d AND meta_key = %s + AND (expires_at IS NULL OR expires_at > %s) + LIMIT 1", + $post_id, + $meta_key, + $now + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + return $val; + } + + /** + * Read all warm values for a post. + * + * @param int $post_id Post ID. + * @return array meta_key => meta_value pairs. + */ + public static function get_all( int $post_id ): array { + global $wpdb; + $table = self::table(); + $now = TMDO_DB::now(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table(). + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_key, meta_value FROM `{$table}` + WHERE post_id = %d AND (expires_at IS NULL OR expires_at > %s)", + $post_id, + $now + ), + ARRAY_A + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + $result = array(); + foreach ( $rows ?: array() as $row ) { + $result[ $row['meta_key'] ] = $row['meta_value']; + } + + return $result; + } + + /** + * Set a value in the warm zone with optional TTL. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param string $value Value to store. + * @param int|null $ttl TTL in seconds. Null = no expiry. + */ + public static function set( int $post_id, string $meta_key, string $value, ?int $ttl = null ): void { + global $wpdb; + $table = self::table(); + $now = TMDO_DB::now(); + + $expires_at = null; + if ( $ttl && $ttl > 0 ) { + $expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl ); + } + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table(). + $existing = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM `{$table}` WHERE post_id = %d AND meta_key = %s LIMIT 1", + $post_id, + $meta_key + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $existing ) { + $update_data = array( 'meta_value' => $value ); + $update_format = array( '%s' ); + if ( null !== $expires_at ) { + $update_data['expires_at'] = $expires_at; + $update_format[] = '%s'; + } + $wpdb->update( $table, $update_data, array( 'id' => (int) $existing ), $update_format, array( '%d' ) ); + } else { + $wpdb->insert( + $table, + array( + 'post_id' => $post_id, + 'meta_key' => $meta_key, + 'meta_value' => $value, + 'expires_at' => $expires_at, + 'created_at' => $now, + ), + array( '%d', '%s', '%s', $expires_at ? '%s' : null, '%s' ) + ); + } + } + + /** + * Delete a specific key from the warm zone. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key to delete. + * @return void + */ + public static function delete( int $post_id, string $meta_key ): void { + global $wpdb; + $wpdb->delete( + self::table(), + array( + 'post_id' => $post_id, + 'meta_key' => $meta_key, + ), + array( '%d', '%s' ) + ); + } + + /** + * Delete all warm entries for a post. + * + * @param int $post_id Post ID. + * @return void + */ + public static function delete_all( int $post_id ): void { + global $wpdb; + $wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) ); + } + + /** + * Purge all expired entries (called by cron). + * + * @return int Number of rows deleted. + */ + public static function purge_expired(): int { + global $wpdb; + $table = self::table(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table(). + return (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < %s", + TMDO_DB::now() + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/languages/2meet-data-optimizer-zh_TW.mo b/languages/2meet-data-optimizer-zh_TW.mo new file mode 100644 index 0000000000000000000000000000000000000000..7e8c670ae301e8edb5d94f40f330a41f32d707d3 GIT binary patch literal 7067 zcmeH~d2H0j6~`xSlBQ0Yv`KmpUu(QR8f=3P4ub(>@IeU9;!|qXQmKhlDODAzQq`n8^ZTt-l`5?iwST1jzTb@3 ziy=)Fsh7maZ$H1ekN4)y^KWmxwu2!(1Gy9OhmSG#5;%DsevlHMVC+-idGH4C0{Ch0 zmzr;bHzNNoxDR|^mv^G^=aD}F8sH2_?G}ELv6WyR_!%$&Qu#}oP2hKs-v{E4{RKZa zf$xL2fj4HU^1DGAe=$hoJPOh{8+CrG<_|P$z}r#Z0aCj`koGYK-V9y<8^K?LH1GG& zC=NacQvN;gdhi-7W)pY=NbCA8e(nOluk(8}_kq;DAG{SjrOT%^f1&x7rU18~-MiqU z;CdW7&D)~6A6$(55s=pZI*6w1Ef9Z9;D_e>v*zDGs=o%CTLs<>eimE@QacYw^%Xi_ z1=4)Gz@=clt{(^QNB#mx{oVyvfPdBX`M0Qk4}(;{7Nl`YK{}Ub!7qTjbbSZ74f$S> z+Wj5GAG-<4p#FD)RDLf=<+(clh_2tD^KOvr!2{B|t91EGAnh}*^G9@k1RS`Av7dsZ zk05@?o~uC=u|1kSAkBXo>;lj0@{O2`?9B_3J?{cZ5AT3v&wm8T4)2GNQTZy(Vvy|F z4bnQ#fHeLDh@sifb^bRX-9H#{H`1gPQ9=G-c0%)Gh?lK7Od{Tfs); z`#_p^$>)?kF9#|AW021Kb#N2-CP?dAj7{DLJ^)hw+nN<1wXXtc-Me&ot7fm}NzE~k z?D;1k+4BMz0nNKr(+4g_J`B?O4}dh^Ns#nD2GV>#(|kkM{|Y2~ejCKr*j-;#?UsSm zf2GbBgEU_WxD4E)>svsw=N`}*A6$X_8@m4bFR6aFfmFW$q;c;9$)58-OwCGly$>XN zt^}#wn;@D0%iDGn!3{@bRS1z)YM(I#{5; z2?dbN5FDeEw%`}8)JddgN^nVzE;#7eH25{hY$846K(2+54N@wE(0Q(dWJ5|Jc%s-6 z$YYQvAa_G3!6mVc&KF~=!ABsu5W45HiO!hPw;)gnTMi+;&8CI;SPyv|@^uL5oKgwo zVaQ4d-8)LS@{SJIf}5xSiS8gJH-zk(Yz0qZI(_Z z#+d9q&5Hb0;R;^pK_M$Lt3s}wX3*eXPsGE`%7`!E+v$n;%pl)xhWKVPmzo7kS92^n1$CuDFyJs*Y}_V>X*#a#;Zv@exsX>L6aQDT@xK%qQrsJZz*J>ZQY%%DL zSqj~AGp4^~tWE4WbaAp;OpMz@qaQE;7tWIAjgvZDojPztjVuTHtaG*M+iHr+x+e9l z7D#(>HYi{ z;~BCo(R@~R?anNbr)!f_&2s-?F+kP&9RjSbeJRpy(P@nn&kcw*%+0*jW|MkU_#XIvASz{+H%#D zqIC~!fvbgLvbttE;ntB=XB_4Q?}aAI0am`9D^d&A;|W285_h&(y~Nt)b1OQ2WA)!SRQQC zxa4Lh6GJq+qbdw751*HNyG3*og;wKvF?~*J$T5D|8%<7~op+2hDzqnmwBJ5njZA}V z?XicWqO~(QT`$MmuyBlF5A4QTnA&^la2xE4DU*@McRQJuL!##}6Fmpz#58SoR)tI) zdd=>OYhR%5nf7v`|0FGb-!XeI&eY9;4W>HxGBMh1)%InYM5Z;85}d1(so7YkY;1xt zDz$36fyaPd_tvEPMy&pE=TM!IGxdYgfwxlyYAQ^ zqg#J;uay|4FPW%Ib@lUwUTzK5+hcWPn8;-A(dq4I!qEX!*Iq4^$#^55yUB8Ib8@Pe z>9fK0kR363*hYu!Drs!{4Vw@f2uv&&Lm zpd-QLP`{&89)YXj@|BnA(zcE$+k_s|8t1C3Gu==^4z_ELp}M5vBlcjwx*BL=ot;2+ zs=5i=l|x5GBH{FN4wI=f#Z!qlVD50}z9}Ov3;a=x&BHEEbR`Nc5gUEdTNvs zRR-23yE{d5G~F2Yh(?;LcD7=lZIGQ^PQ_3?JS@#+iPyjcO6e zMAUA0RYp&+(vV5-m}{-q^^_3`BYG)-|C{$N@x1J%*?G&do-``WP{g$%T<-I_9;+%3 zyWD00pZ}s{m6o`kG@kd-U@kmxMgh;y&40j^yVSKb*PWYN;QTGgU4f6R^`3CVS+gu+H-^(J?b zYsFkUTJd%xaPlU{H`K16bKjaV5O4{ug+V^)#vKmm03@_ zo-l$2!a>?@q2E*GHF%-vHBhm}3}Dg%_&XOxl`b7s8lfx%tz3+@!V!O@$W>igF>?wJ z2%8H{MNf}?rZ3girp!XUL?Yg)-UBCcrROv=XjK+!H=GrtF>&U!euSL+ho|V`WbBHO z>&3}N3aH@r@YLfCn>c#eQ%<*ceq450!CWYLY22#=IC8C5gVzraUaiKH$%!#-4w)Gp zyUNZ6>(pV29uGEM7QRBq$|*ysh*dW%$?g8%3~4i$_#cmHGbhgoYA^AVS7TcG_w=L2w3#RT?}%#u E0pTyFm;e9( literal 0 HcmV?d00001 diff --git a/languages/2meet-data-optimizer-zh_TW.po b/languages/2meet-data-optimizer-zh_TW.po new file mode 100644 index 0000000..2febb17 --- /dev/null +++ b/languages/2meet-data-optimizer-zh_TW.po @@ -0,0 +1,387 @@ +# WP Data Optimizer — 繁體中文 (zh_TW) +# Copyright (C) 2026 2meet.io +# This file is distributed under the same license as the WP Data Optimizer plugin. +# +msgid "" +msgstr "" +"Project-Id-Version: WP Data Optimizer 1.3.14\n" +"Report-Msgid-Bugs-To: https://2meet.io\n" +"POT-Creation-Date: 2026-03-30T00:00:00+08:00\n" +"PO-Revision-Date: 2026-03-30T00:00:00+08:00\n" +"Last-Translator: 2meet.io \n" +"Language-Team: Traditional Chinese \n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Claude Code\n" +"X-Domain: wp-data-optimizer\n" + +#. Plugin URI of the plugin. +msgid "https://2meet.io" +msgstr "https://2meet.io" + +#. Description of the plugin. +msgid "Four-zone data optimization for WordPress — migrates wp_postmeta to dedicated custom tables (Hot/Warm/Cold/Archive). Fully replaces HP Custom Tables." +msgstr "WordPress 四象限資料最佳化外掛 — 將 wp_postmeta 遷移至專屬自訂資料表(Hot/Warm/Cold/Archive),完整取代 HP Custom Tables。" + +#. Author of the plugin. +msgid "2meet.io" +msgstr "2meet.io" + +#. Plugin Name of the plugin. +#: admin/class-wpdo-admin.php:36 +#: admin/class-wpdo-admin.php:37 +#: admin/class-wpdo-admin.php:101 +msgid "WP Data Optimizer" +msgstr "WP 資料最佳化器" + +#: admin/class-wpdo-admin.php:82 +msgid "儀表板" +msgstr "儀表板" + +#: admin/class-wpdo-admin.php:83 +msgid "Zone 配置" +msgstr "Zone 配置" + +#: admin/class-wpdo-admin.php:84 +#: admin/class-wpdo-admin.php:473 +msgid "遷移狀態" +msgstr "遷移狀態" + +#: admin/class-wpdo-admin.php:85 +msgid "自動分類" +msgstr "自動分類" + +#: admin/class-wpdo-admin.php:86 +msgid "日誌" +msgstr "日誌" + +#: admin/class-wpdo-admin.php:96 +msgid "HPCT 匯入" +msgstr "HPCT 匯入" + +#: admin/class-wpdo-admin.php:193 +msgid "系統概覽" +msgstr "系統概覽" + +#: admin/class-wpdo-admin.php:197 +msgid "資料庫引擎" +msgstr "資料庫引擎" + +#: admin/class-wpdo-admin.php:201 +msgid "外掛版本" +msgstr "外掛版本" + +#: admin/class-wpdo-admin.php:205 +msgid "DB Schema 版本" +msgstr "DB Schema 版本" + +#: admin/class-wpdo-admin.php:209 +msgid "HivePress" +msgstr "HivePress" + +#: admin/class-wpdo-admin.php:213 +msgid "HP Custom Tables" +msgstr "HP Custom Tables" + +#: admin/class-wpdo-admin.php:227 +msgid "Object Cache" +msgstr "物件快取" + +#: admin/class-wpdo-admin.php:240 +msgid "Zone 欄位統計" +msgstr "Zone 欄位統計" + +#: admin/class-wpdo-admin.php:243 +msgid "欄位數" +msgstr "欄位數" + +#: admin/class-wpdo-admin.php:250 +msgid "總計" +msgstr "總計" + +#: admin/class-wpdo-admin.php:257 +#: admin/class-wpdo-admin.php:262 +#: admin/class-wpdo-admin.php:276 +msgid "模組" +msgstr "模組" + +#: admin/class-wpdo-admin.php:262 +#: admin/class-wpdo-admin.php:276 +msgid "狀態" +msgstr "狀態" + +#: admin/class-wpdo-admin.php:291 +msgid "Zone 即時狀態" +msgstr "Zone 即時狀態" + +#: admin/class-wpdo-admin.php:295 +msgid "即時概覽" +msgstr "即時概覽" + +#: admin/class-wpdo-admin.php:299 +msgid "有效條目" +msgstr "有效條目" + +#: admin/class-wpdo-admin.php:303 +msgid "24h 內到期" +msgstr "24 小時內到期" + +#: admin/class-wpdo-admin.php:310 +msgid "Top 10 瀏覽數 (Warm)" +msgstr "Top 10 瀏覽數(暖區)" + +#: admin/class-wpdo-admin.php:315 +msgid "瀏覽數" +msgstr "瀏覽數" + +#: admin/class-wpdo-admin.php:328 +msgid "目前無暖區瀏覽數據。" +msgstr "目前無暖區瀏覽數據。" + +#: admin/class-wpdo-admin.php:333 +msgid "歸檔統計" +msgstr "歸檔統計" + +#: admin/class-wpdo-admin.php:337 +msgid "總歸檔筆數" +msgstr "總歸檔筆數" + +#: admin/class-wpdo-admin.php:341 +msgid "已壓縮" +msgstr "已壓縮" + +#: admin/class-wpdo-admin.php:356 +msgid "按 Post Type" +msgstr "按文章類型" + +#: admin/class-wpdo-admin.php:361 +msgid "筆數" +msgstr "筆數" + +#: admin/class-wpdo-admin.php:374 +msgid "目前無歸檔資料。" +msgstr "目前無歸檔資料。" + +#: admin/class-wpdo-admin.php:391 +msgid "Zone 欄位映射" +msgstr "Zone 欄位映射" + +#: admin/class-wpdo-admin.php:392 +msgid "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。" +msgstr "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。" + +#: admin/class-wpdo-admin.php:395 +msgid "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。" +msgstr "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。" + +#: admin/class-wpdo-admin.php:434 +msgid "Object Cache 管理" +msgstr "物件快取管理" + +#: admin/class-wpdo-admin.php:435 +msgid "清除指定 Post Type 的 Zone C Object Cache 群組。" +msgstr "清除指定文章類型的 Zone C 物件快取群組。" + +#: admin/class-wpdo-admin.php:439 +msgid "Post Type" +msgstr "文章類型" + +#: admin/class-wpdo-admin.php:440 +msgid "操作" +msgstr "操作" + +#: admin/class-wpdo-admin.php:450 +msgid "Flush Cache" +msgstr "清除快取" + +#: admin/class-wpdo-admin.php:474 +msgid "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate " +msgstr "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate " + +#: admin/class-wpdo-admin.php:477 +msgid "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。" +msgstr "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。" + +#: admin/class-wpdo-admin.php:484 +msgid "模組狀態" +msgstr "模組狀態" + +#: admin/class-wpdo-admin.php:485 +msgid "進度" +msgstr "進度" + +#: admin/class-wpdo-admin.php:486 +msgid "錯誤數" +msgstr "錯誤數" + +#: admin/class-wpdo-admin.php:487 +msgid "開始時間" +msgstr "開始時間" + +#: admin/class-wpdo-admin.php:488 +msgid "完成時間" +msgstr "完成時間" + +#: admin/class-wpdo-admin.php:517 +msgid "CLI 指令參考" +msgstr "CLI 指令參考" + +#: admin/class-wpdo-admin.php:520 +msgid "開始或繼續遷移" +msgstr "開始或繼續遷移" + +#: admin/class-wpdo-admin.php:521 +msgid "驗證資料一致性" +msgstr "驗證資料一致性" + +#: admin/class-wpdo-admin.php:522 +msgid "切換讀取來源到自訂表" +msgstr "切換讀取來源到自訂資料表" + +#: admin/class-wpdo-admin.php:523 +msgid "回滾到 postmeta" +msgstr "回滾至 postmeta" + +#: admin/class-wpdo-admin.php:524 +msgid "標記模組為完成" +msgstr "標記模組為完成" + +#: admin/class-wpdo-admin.php:550 +msgid "Zone 自動分類器" +msgstr "Zone 自動分類器" + +#: admin/class-wpdo-admin.php:551 +msgid "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。" +msgstr "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。" + +#: admin/class-wpdo-admin.php:556 +msgid "選擇 Post Type:" +msgstr "選擇文章類型:" + +#: admin/class-wpdo-admin.php:558 +msgid "— 選擇 —" +msgstr "— 請選擇 —" + +#: admin/class-wpdo-admin.php:563 +msgid "分析" +msgstr "分析" + +#: admin/class-wpdo-admin.php:573 +msgid "此 post type 沒有可分析的 postmeta 欄位。" +msgstr "此文章類型沒有可分析的 postmeta 欄位。" + +#: admin/class-wpdo-admin.php:582 +msgid "分類摘要" +msgstr "分類摘要" + +#: admin/class-wpdo-admin.php:585 +#: admin/class-wpdo-admin.php:586 +#: admin/class-wpdo-admin.php:587 +#: admin/class-wpdo-admin.php:588 +msgid "建議欄位" +msgstr "建議欄位" + +#: admin/class-wpdo-admin.php:589 +msgid "已分配" +msgstr "已分配" + +#: admin/class-wpdo-admin.php:589 +msgid "欄位" +msgstr "欄位" + +#: admin/class-wpdo-admin.php:599 +msgid "資料筆數" +msgstr "資料筆數" + +#: admin/class-wpdo-admin.php:600 +msgid "建議 Zone" +msgstr "建議 Zone" + +#: admin/class-wpdo-admin.php:601 +msgid "信心度" +msgstr "信心度" + +#: admin/class-wpdo-admin.php:602 +msgid "目前分配" +msgstr "目前分配" + +#: admin/class-wpdo-admin.php:603 +msgid "原因" +msgstr "原因" + +#. translators: %1$d: number of deleted logs, %2$d: number of days +#: admin/class-wpdo-admin.php:641 +msgid "已清除 %d 筆超過 %d 天的日誌。" +msgstr "已清除 %d 筆超過 %d 天的日誌。" + +#: admin/class-wpdo-admin.php:647 +msgid "錯誤日誌" +msgstr "錯誤日誌" + +#: admin/class-wpdo-admin.php:651 +msgid "清除超過" +msgstr "清除超過" + +#: admin/class-wpdo-admin.php:653 +msgid "天的日誌" +msgstr "天的日誌" + +#: admin/class-wpdo-admin.php:655 +msgid "清除" +msgstr "清除" + +#: admin/class-wpdo-admin.php:659 +msgid "沒有錯誤記錄。系統運作正常。" +msgstr "沒有錯誤記錄。系統運作正常。" + +#: admin/class-wpdo-admin.php:699 +msgid "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。" +msgstr "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。" + +#: admin/class-wpdo-admin.php:707 +msgid "HP Custom Tables 匯入" +msgstr "HP Custom Tables 匯入" + +#: admin/class-wpdo-admin.php:711 +msgid "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。" +msgstr "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。" + +#: admin/class-wpdo-admin.php:714 +msgid "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。" +msgstr "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。" + +#: admin/class-wpdo-admin.php:721 +msgid "匯入預覽" +msgstr "匯入預覽" + +#: admin/class-wpdo-admin.php:725 +msgid "HPCT 模組" +msgstr "HPCT 模組" + +#: admin/class-wpdo-admin.php:726 +msgid "HPCT 狀態" +msgstr "HPCT 狀態" + +#: admin/class-wpdo-admin.php:727 +msgid "WPDO 狀態" +msgstr "WPDO 狀態" + +#: admin/class-wpdo-admin.php:743 +msgid "執行匯入" +msgstr "執行匯入" + +#: admin/class-wpdo-admin.php:748 +msgid "未偵測到 HP Custom Tables 外掛,或已完成匯入。" +msgstr "未偵測到 HP Custom Tables 外掛,或已完成匯入。" + +#. translators: %s: link to the HPCT import page +#: includes/class-wpdo-core.php:257 +msgid "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。" +msgstr "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。" + +#: includes/class-wpdo-core.php:258 +msgid "HPCT 匯入頁面" +msgstr "HPCT 匯入頁面" diff --git a/languages/2meet-data-optimizer.pot b/languages/2meet-data-optimizer.pot new file mode 100644 index 0000000..e3e7b89 --- /dev/null +++ b/languages/2meet-data-optimizer.pot @@ -0,0 +1,541 @@ +# Copyright (C) 2026 2meet.io +# This file is distributed under the same license as the WP Data Optimizer plugin. +msgid "" +msgstr "" +"Project-Id-Version: WP Data Optimizer 1.3.34\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-data-optimizer\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2026-04-12T06:53:18+00:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"X-Generator: WP-CLI 2.12.0\n" +"X-Domain: wp-data-optimizer\n" + +#. Plugin Name of the plugin +#: wp-data-optimizer.php +#: admin/class-wpdo-admin.php:42 +#: admin/class-wpdo-admin.php:43 +#: admin/class-wpdo-admin.php:141 +msgid "WP Data Optimizer" +msgstr "" + +#. Plugin URI of the plugin +#: wp-data-optimizer.php +msgid "https://2meet.io" +msgstr "" + +#. Description of the plugin +#: wp-data-optimizer.php +msgid "Four-zone data optimization for WordPress — migrates wp_postmeta to dedicated custom tables (Hot/Warm/Cold/Archive). Fully replaces HP Custom Tables." +msgstr "" + +#. Author of the plugin +#: wp-data-optimizer.php +msgid "2meet.io" +msgstr "" + +#: admin/class-wpdo-admin.php:121 +msgid "儀表板" +msgstr "" + +#: admin/class-wpdo-admin.php:122 +msgid "Zone 配置" +msgstr "" + +#: admin/class-wpdo-admin.php:123 +#: admin/class-wpdo-admin.php:583 +msgid "遷移狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:124 +msgid "自動分類" +msgstr "" + +#: admin/class-wpdo-admin.php:125 +msgid "日誌" +msgstr "" + +#: admin/class-wpdo-admin.php:126 +#: admin/class-wpdo-admin.php:806 +msgid "REST API" +msgstr "" + +#: admin/class-wpdo-admin.php:136 +msgid "HPCT 匯入" +msgstr "" + +#: admin/class-wpdo-admin.php:245 +msgid "系統概覽" +msgstr "" + +#: admin/class-wpdo-admin.php:249 +msgid "資料庫引擎" +msgstr "" + +#: admin/class-wpdo-admin.php:253 +msgid "外掛版本" +msgstr "" + +#: admin/class-wpdo-admin.php:257 +msgid "DB Schema 版本" +msgstr "" + +#: admin/class-wpdo-admin.php:261 +msgid "HivePress" +msgstr "" + +#: admin/class-wpdo-admin.php:265 +msgid "HP Custom Tables" +msgstr "" + +#: admin/class-wpdo-admin.php:279 +msgid "Object Cache" +msgstr "" + +#: admin/class-wpdo-admin.php:292 +msgid "Zone 欄位統計" +msgstr "" + +#: admin/class-wpdo-admin.php:295 +msgid "欄位數" +msgstr "" + +#: admin/class-wpdo-admin.php:302 +msgid "總計" +msgstr "" + +#: admin/class-wpdo-admin.php:309 +msgid "模組狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:314 +#: admin/class-wpdo-admin.php:328 +#: admin/class-wpdo-admin.php:592 +msgid "模組" +msgstr "" + +#: admin/class-wpdo-admin.php:314 +#: admin/class-wpdo-admin.php:328 +#: admin/class-wpdo-admin.php:594 +msgid "狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:343 +msgid "Zone 即時狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:347 +msgid "即時概覽" +msgstr "" + +#: admin/class-wpdo-admin.php:351 +msgid "有效條目" +msgstr "" + +#: admin/class-wpdo-admin.php:355 +msgid "24h 內到期" +msgstr "" + +#: admin/class-wpdo-admin.php:362 +msgid "Top 10 瀏覽數 (Warm)" +msgstr "" + +#: admin/class-wpdo-admin.php:367 +msgid "瀏覽數" +msgstr "" + +#: admin/class-wpdo-admin.php:380 +msgid "目前無暖區瀏覽數據。" +msgstr "" + +#: admin/class-wpdo-admin.php:385 +msgid "歸檔統計" +msgstr "" + +#: admin/class-wpdo-admin.php:389 +msgid "總歸檔筆數" +msgstr "" + +#: admin/class-wpdo-admin.php:393 +msgid "已壓縮" +msgstr "" + +#: admin/class-wpdo-admin.php:408 +msgid "按 Post Type" +msgstr "" + +#: admin/class-wpdo-admin.php:413 +msgid "筆數" +msgstr "" + +#: admin/class-wpdo-admin.php:426 +msgid "目前無歸檔資料。" +msgstr "" + +#: admin/class-wpdo-admin.php:434 +msgid "REST API 速率限制統計" +msgstr "" + +#: admin/class-wpdo-admin.php:436 +msgid "POST /view 端點因 IP 或 Cookie 重複計數而被拒絕的次數(HTTP 429)。" +msgstr "" + +#: admin/class-wpdo-admin.php:452 +msgid "重置統計" +msgstr "" + +#: admin/class-wpdo-admin.php:458 +msgid "總 429 事件" +msgstr "" + +#: admin/class-wpdo-admin.php:462 +msgid "受影響的 Post 數" +msgstr "" + +#: admin/class-wpdo-admin.php:469 +msgid "Top 10 被限速 Post" +msgstr "" + +#: admin/class-wpdo-admin.php:474 +msgid "429 次數" +msgstr "" + +#: admin/class-wpdo-admin.php:487 +msgid "目前無速率限制事件記錄。" +msgstr "" + +#: admin/class-wpdo-admin.php:501 +msgid "Zone 欄位映射" +msgstr "" + +#: admin/class-wpdo-admin.php:502 +msgid "以下是所有已註冊的 postmeta 欄位及其 Zone 分配。可透過 wpdo_register_fields action 或 HivePress 整合自動註冊。" +msgstr "" + +#: admin/class-wpdo-admin.php:505 +msgid "尚無已註冊的欄位映射。啟用 HivePress 或手動註冊欄位後即會顯示。" +msgstr "" + +#: admin/class-wpdo-admin.php:544 +msgid "Object Cache 管理" +msgstr "" + +#: admin/class-wpdo-admin.php:545 +msgid "清除指定 Post Type 的 Zone C Object Cache 群組。" +msgstr "" + +#: admin/class-wpdo-admin.php:549 +msgid "Post Type" +msgstr "" + +#: admin/class-wpdo-admin.php:550 +msgid "操作" +msgstr "" + +#: admin/class-wpdo-admin.php:560 +msgid "Flush Cache" +msgstr "" + +#: admin/class-wpdo-admin.php:584 +msgid "管理各模組的資料遷移狀態。使用 WP-CLI 執行遷移操作:wp wpdo migrate " +msgstr "" + +#: admin/class-wpdo-admin.php:587 +msgid "尚無遷移記錄。使用 wp wpdo migrate 指令開始遷移。" +msgstr "" + +#: admin/class-wpdo-admin.php:595 +msgid "進度" +msgstr "" + +#: admin/class-wpdo-admin.php:596 +msgid "錯誤數" +msgstr "" + +#: admin/class-wpdo-admin.php:597 +msgid "開始時間" +msgstr "" + +#: admin/class-wpdo-admin.php:598 +msgid "完成時間" +msgstr "" + +#: admin/class-wpdo-admin.php:627 +msgid "CLI 指令參考" +msgstr "" + +#: admin/class-wpdo-admin.php:630 +msgid "開始或繼續遷移" +msgstr "" + +#: admin/class-wpdo-admin.php:631 +msgid "驗證資料一致性" +msgstr "" + +#: admin/class-wpdo-admin.php:632 +msgid "切換讀取來源到自訂表" +msgstr "" + +#: admin/class-wpdo-admin.php:633 +msgid "回滾到 postmeta" +msgstr "" + +#: admin/class-wpdo-admin.php:634 +msgid "標記模組為完成" +msgstr "" + +#: admin/class-wpdo-admin.php:660 +msgid "Zone 自動分類器" +msgstr "" + +#: admin/class-wpdo-admin.php:661 +msgid "分析 wp_postmeta 中的欄位,根據值的特徵自動建議最佳 Zone 分配。" +msgstr "" + +#: admin/class-wpdo-admin.php:666 +msgid "選擇 Post Type:" +msgstr "" + +#: admin/class-wpdo-admin.php:668 +msgid "— 選擇 —" +msgstr "" + +#: admin/class-wpdo-admin.php:673 +msgid "分析" +msgstr "" + +#: admin/class-wpdo-admin.php:683 +msgid "此 post type 沒有可分析的 postmeta 欄位。" +msgstr "" + +#: admin/class-wpdo-admin.php:692 +msgid "分類摘要" +msgstr "" + +#: admin/class-wpdo-admin.php:695 +#: admin/class-wpdo-admin.php:696 +#: admin/class-wpdo-admin.php:697 +#: admin/class-wpdo-admin.php:698 +msgid "建議欄位" +msgstr "" + +#: admin/class-wpdo-admin.php:699 +msgid "已分配" +msgstr "" + +#: admin/class-wpdo-admin.php:699 +msgid "欄位" +msgstr "" + +#: admin/class-wpdo-admin.php:709 +msgid "資料筆數" +msgstr "" + +#: admin/class-wpdo-admin.php:710 +msgid "建議 Zone" +msgstr "" + +#: admin/class-wpdo-admin.php:711 +msgid "信心度" +msgstr "" + +#: admin/class-wpdo-admin.php:712 +msgid "目前分配" +msgstr "" + +#: admin/class-wpdo-admin.php:713 +msgid "原因" +msgstr "" + +#. translators: 1: number of deleted log entries, 2: number of days. +#: admin/class-wpdo-admin.php:752 +#, php-format +msgid "已清除 %1$d 筆超過 %2$d 天的日誌。" +msgstr "" + +#: admin/class-wpdo-admin.php:758 +msgid "錯誤日誌" +msgstr "" + +#: admin/class-wpdo-admin.php:762 +msgid "清除超過" +msgstr "" + +#: admin/class-wpdo-admin.php:764 +msgid "天的日誌" +msgstr "" + +#: admin/class-wpdo-admin.php:766 +msgid "清除" +msgstr "" + +#: admin/class-wpdo-admin.php:770 +msgid "沒有錯誤記錄。系統運作正常。" +msgstr "" + +#: admin/class-wpdo-admin.php:807 +msgid "以下端點讓前端直接查詢 Zone 資料,取代 WP_Query / postmeta。" +msgstr "" + +#: admin/class-wpdo-admin.php:814 +msgid "增加 Zone B 瀏覽計數。需要 WP REST nonce(X-WP-Nonce header)。warm zone cutover 前自動 fallback postmeta hp_view_count。" +msgstr "" + +#: admin/class-wpdo-admin.php:816 +#: admin/class-wpdo-admin.php:846 +#: admin/class-wpdo-admin.php:854 +msgid "Post ID(路徑參數)" +msgstr "" + +#: admin/class-wpdo-admin.php:817 +msgid "WP REST nonce(wp_create_nonce(\"wp_rest\"))" +msgstr "" + +#: admin/class-wpdo-admin.php:825 +msgid "查詢 Zone A 扁平欄位(搜尋/篩選),支援分頁與排序。" +msgstr "" + +#: admin/class-wpdo-admin.php:827 +msgid "post type(預設 hp_listing)" +msgstr "" + +#: admin/class-wpdo-admin.php:828 +msgid "每頁筆數 1–100(預設 20)" +msgstr "" + +#: admin/class-wpdo-admin.php:829 +msgid "頁碼(預設 1)" +msgstr "" + +#: admin/class-wpdo-admin.php:830 +msgid "排序欄位(預設 post_id)" +msgstr "" + +#: admin/class-wpdo-admin.php:831 +msgid "ASC 或 DESC(預設 DESC)" +msgstr "" + +#: admin/class-wpdo-admin.php:832 +msgid "數值篩選下限,例如 hp_price_min=1000" +msgstr "" + +#: admin/class-wpdo-admin.php:833 +msgid "數值篩選上限,例如 hp_price_max=5000" +msgstr "" + +#: admin/class-wpdo-admin.php:834 +msgid "精確值篩選,例如 hp_featured=1" +msgstr "" + +#: admin/class-wpdo-admin.php:837 +msgid "符合條件的總筆數" +msgstr "" + +#: admin/class-wpdo-admin.php:838 +msgid "總頁數" +msgstr "" + +#: admin/class-wpdo-admin.php:845 +msgid "單筆 listing:Zone A(熱區欄位)+ Zone C(JSON blob)合併回傳。Zone 未啟用時自動 fallback postmeta。" +msgstr "" + +#: admin/class-wpdo-admin.php:853 +msgid "Zone B 瀏覽計數(warm zone),warm 未啟用時 fallback hp_view_count postmeta。" +msgstr "" + +#: admin/class-wpdo-admin.php:861 +msgid "版本、引擎、欄位統計、模組狀態。需要 manage_options 權限(帶 WP Nonce)。" +msgstr "" + +#: admin/class-wpdo-admin.php:878 +msgid "參數" +msgstr "" + +#: admin/class-wpdo-admin.php:879 +msgid "說明" +msgstr "" + +#: admin/class-wpdo-admin.php:892 +msgid "回應 Headers:" +msgstr "" + +#: admin/class-wpdo-admin.php:901 +msgid "JavaScript SDK" +msgstr "" + +#: admin/class-wpdo-admin.php:903 +msgid "在主題或外掛中引入 SDK,即可使用 " +msgstr "" + +#: admin/class-wpdo-admin.php:905 +msgid " 類別操作所有端點,支援分頁生成器(async generator)。" +msgstr "" + +#: admin/class-wpdo-admin.php:907 +msgid "方式一:wp_enqueue_script(推薦)" +msgstr "" + +#: admin/class-wpdo-admin.php:928 +msgid "方式二:直接使用(免 localize)" +msgstr "" + +#: admin/class-wpdo-admin.php:959 +msgid "即時測試" +msgstr "" + +#: admin/class-wpdo-admin.php:960 +msgid "在瀏覽器 Console 輸入(需已載入 SDK):" +msgstr "" + +#: admin/class-wpdo-admin.php:982 +msgid "HPCT 匯入成功!建議停用 HP Custom Tables 外掛。" +msgstr "" + +#: admin/class-wpdo-admin.php:990 +msgid "HP Custom Tables 匯入" +msgstr "" + +#: admin/class-wpdo-admin.php:994 +msgid "HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。" +msgstr "" + +#: admin/class-wpdo-admin.php:997 +msgid "偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。" +msgstr "" + +#: admin/class-wpdo-admin.php:1004 +msgid "匯入預覽" +msgstr "" + +#: admin/class-wpdo-admin.php:1008 +msgid "HPCT 模組" +msgstr "" + +#: admin/class-wpdo-admin.php:1009 +msgid "HPCT 狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:1010 +msgid "WPDO 狀態" +msgstr "" + +#: admin/class-wpdo-admin.php:1026 +msgid "執行匯入" +msgstr "" + +#: admin/class-wpdo-admin.php:1031 +msgid "未偵測到 HP Custom Tables 外掛,或已完成匯入。" +msgstr "" + +#. translators: %s: URL to import page +#: includes/class-wpdo-core.php:272 +#, php-format +msgid "HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。" +msgstr "" + +#: includes/class-wpdo-core.php:273 +msgid "HPCT 匯入頁面" +msgstr "" diff --git a/modules/options/class-tmdo-options-manager.php b/modules/options/class-tmdo-options-manager.php new file mode 100644 index 0000000..c00f9d6 --- /dev/null +++ b/modules/options/class-tmdo-options-manager.php @@ -0,0 +1,285 @@ +get_row( + " + SELECT + COUNT(*) as cnt, + SUM(LENGTH(option_value)) as total_bytes + FROM {$wpdb->options} + WHERE autoload = 'yes' + ", + ARRAY_A + ); + + // 最大的 50 個 autoload 選項 + $largest = $wpdb->get_results( + " + SELECT + option_name, + LENGTH(option_value) AS size_bytes, + autoload + FROM {$wpdb->options} + WHERE autoload = 'yes' + ORDER BY size_bytes DESC + LIMIT 50 + ", + ARRAY_A + ); + + // 可 defer 的候選項 + $deferrable_candidates = array(); + foreach ( $largest as $row ) { + if ( (int) $row['size_bytes'] > self::SINGLE_OPTION_SIZE_THRESHOLD || + in_array( $row['option_name'], self::KNOWN_DEFERRABLE_OPTIONS, true ) + ) { + $deferrable_candidates[] = $row; + } + } + + return array( + 'total_count' => (int) $total['cnt'], + 'total_bytes' => (int) $total['total_bytes'], + 'total_mb' => round( $total['total_bytes'] / 1024 / 1024, 2 ), + 'warning' => (int) $total['total_bytes'] > self::AUTOLOAD_WARNING_THRESHOLD_MB * 1024 * 1024, + 'largest' => $largest, + 'deferrable' => $deferrable_candidates, + 'estimated_save_mb' => round( + array_sum( array_column( $deferrable_candidates, 'size_bytes' ) ) / 1024 / 1024, + 2 + ), + ); + } + + /** + * 執行 autoload 最佳化 + * + * @param array $options_to_defer 要設為 autoload=no 的選項名稱陣列 + * 若為空,使用內建安全清單 + * @param bool $dry_run + */ + public static function optimize_autoload( array $options_to_defer = array(), bool $dry_run = false ): array { + global $wpdb; + + if ( empty( $options_to_defer ) ) { + $options_to_defer = self::KNOWN_DEFERRABLE_OPTIONS; + } + + $result = array( + 'dry_run' => $dry_run, + 'processed' => 0, + 'saved_bytes' => 0, + 'details' => array(), + ); + + foreach ( $options_to_defer as $option_name ) { + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT option_id, LENGTH(option_value) AS size_bytes, autoload + FROM {$wpdb->options} + WHERE option_name = %s", + $option_name + ), + ARRAY_A + ); + + if ( ! $row ) { + continue; + } + + if ( $row['autoload'] === 'no' ) { + continue; + } + + if ( ! $dry_run ) { + $wpdb->update( + $wpdb->options, + array( 'autoload' => 'no' ), + array( 'option_name' => $option_name ), + array( '%s' ), + array( '%s' ) + ); + + // 清除該選項的快取(下次讀取時會重建) + wp_cache_delete( $option_name, 'options' ); + wp_cache_delete( 'alloptions', 'options' ); + } + + ++$result['processed']; + $result['saved_bytes'] += (int) $row['size_bytes']; + $result['details'][] = array( + 'option_name' => $option_name, + 'saved_bytes' => (int) $row['size_bytes'], + ); + } + + $result['saved_mb'] = round( $result['saved_bytes'] / 1024 / 1024, 2 ); + + return $result; + } + + // ───────────────────────────────────────────────────────── + // 選項群組重導向 + // ───────────────────────────────────────────────────────── + + /** + * 將一組選項重導向至專屬設定表 + * + * @param string $group_name 設定群組名 + * @param array $option_keys 要管理的 option_name 清單 + */ + public static function register_settings_group( string $group_name, array $option_keys ): void { + global $wpdb; + + $table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name ); + + // 建立設定專屬表 + $charset = $wpdb->get_charset_collate(); + $sql = "CREATE TABLE {$table} ( + id INT NOT NULL AUTO_INCREMENT, + setting_key VARCHAR(191) NOT NULL, + setting_value LONGTEXT, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_key (setting_key) + ) {$charset};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + // 註冊為管理中選項 + foreach ( $option_keys as $key ) { + self::$redirected_options[ $key ] = array( + 'group' => $group_name, + 'table' => $table, + ); + + // 攔截讀取 + add_filter( + "pre_option_{$key}", + function ( $value ) use ( $key, $table ) { + return self::read_setting( $table, $key, $value ); + }, + 10, + 1 + ); + + // 攔截寫入:寫入 UAE 表,並回傳 $old_value 使 WP 跳過寫 wp_options + add_filter( + "pre_update_option_{$key}", + function ( $value, $old_value ) use ( $key, $table ) { + self::write_setting( $table, $key, $value ); + // 回傳 $old_value 會讓 update_option() 判定「值未變動」進而跳過 wp_options 寫入 + return $old_value; + }, + 10, + 2 + ); + } + } + + private static function read_setting( string $table, string $key, $default ) { + global $wpdb; + + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT setting_value FROM `{$table}` WHERE setting_key = %s", + $key + ) + ); + + if ( $value === null ) { + return $default; + } + + // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). + $decoded = TMDO_Safe_Unserialize::run( $value ); + return $decoded; + } + + private static function write_setting( string $table, string $key, $value ): void { + global $wpdb; + + $wpdb->replace( + $table, + array( + 'setting_key' => $key, + 'setting_value' => maybe_serialize( $value ), + ), + array( '%s', '%s' ) + ); + } + + public static function get_redirected_options(): array { + return self::$redirected_options; + } +} diff --git a/modules/options/index.php b/modules/options/index.php new file mode 100644 index 0000000..7f3b1a3 --- /dev/null +++ b/modules/options/index.php @@ -0,0 +1,10 @@ + + + + + tests/integration + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3c8e9ae --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + tests/unit + + + + + includes + + + vendor + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..25dbbeb --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,651 @@ + $c !== $cb ) + ); + } + return true; + } +} +if ( ! function_exists( 'remove_action' ) ) { + function remove_action( string $hook, $cb, int $p = 10 ): bool { + return remove_filter( $hook, $cb, $p ); + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) { + $value = $cb( $value, ...$args ); + } + return $value; + } +} +if ( ! function_exists( 'do_action' ) ) { + function do_action( string $hook, ...$args ): void { + foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) { + $cb( ...$args ); + } + } +} +if ( ! function_exists( 'wp_next_scheduled' ) ) { + function wp_next_scheduled( string $hook ): int|false { return false; } +} +if ( ! function_exists( 'wp_schedule_event' ) ) { + function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } +} +if ( ! function_exists( 'wp_schedule_single_event' ) ) { + function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } +} +if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { + function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } +} +if ( ! function_exists( 'is_admin' ) ) { + function is_admin(): bool { return false; } +} +if ( ! function_exists( 'is_singular' ) ) { + function is_singular( $t = '' ): bool { return false; } +} +if ( ! function_exists( 'get_the_ID' ) ) { + function get_the_ID(): int|false { return false; } +} +if ( ! function_exists( 'wp_doing_ajax' ) ) { + function wp_doing_ajax(): bool { return false; } +} +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } +} +if ( ! function_exists( '__' ) ) { + function __( string $text, string $domain = 'default' ): string { return $text; } +} +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); } +} +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( string $text, string $domain = 'default' ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); } +} +if ( ! function_exists( 'esc_html_e' ) ) { + function esc_html_e( string $text, string $domain = 'default' ): void { echo htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); } +} +if ( ! function_exists( 'esc_attr' ) ) { + function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); } +} +if ( ! function_exists( 'esc_url' ) ) { + function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; } +} +if ( ! function_exists( 'esc_sql' ) ) { + function esc_sql( $s ): string { + $s = is_string( $s ) ? $s : (string) $s; + return addslashes( $s ); + } +} +if ( ! function_exists( '_doing_it_wrong' ) ) { + function _doing_it_wrong( string $fn, string $msg, string $ver ): void {} +} +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } +} +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } +} +if ( ! function_exists( 'delete_option' ) ) { + function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } +} +if ( ! function_exists( 'get_post_meta' ) ) { + function get_post_meta( int $post_id, string $key = '', bool $single = false ) { + return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] ); + } +} +if ( ! function_exists( 'update_post_meta' ) ) { + function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool { + $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_user_meta' ) ) { + function get_user_meta( int $uid, string $key = '', bool $single = false ) { + return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); + } +} +if ( ! function_exists( 'update_user_meta' ) ) { + function update_user_meta( int $uid, string $key, $value, $prev = '' ): int|bool { + $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_term_meta' ) ) { + function get_term_meta( int $tid, string $key = '', bool $single = false ) { + return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] ); + } +} +if ( ! function_exists( 'update_term_meta' ) ) { + function update_term_meta( int $tid, string $key, $value, $prev = '' ): int|bool { + $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_comment_meta' ) ) { + function get_comment_meta( int $cid, string $key = '', bool $single = false ) { + return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); + } +} +if ( ! function_exists( 'update_comment_meta' ) ) { + function update_comment_meta( int $cid, string $key, $value, $prev = '' ): int|bool { + $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_post_type' ) ) { + function get_post_type( $post_id ) { + return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; + } +} +if ( ! function_exists( 'get_post_status' ) ) { + function get_post_status( $post_id ) { + return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; + } +} +if ( ! function_exists( 'is_post_publicly_viewable' ) ) { + function is_post_publicly_viewable( $post_id ): bool { + if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) { + return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ]; + } + return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' ); + } +} +if ( ! function_exists( 'wp_die' ) ) { + function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } +} +if ( ! function_exists( 'wp_verify_nonce' ) ) { + function wp_verify_nonce( $nonce, string $action = '' ) { + return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false; + } +} +if ( ! function_exists( 'wp_create_nonce' ) ) { + function wp_create_nonce( string $action = '' ): string { + $nonce = 'test_nonce_' . md5( $action ); + $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1; + return $nonce; + } +} +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $cap, ...$args ): bool { + if ( ! empty( $args ) ) { + $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); + if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { + return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; + } + } + return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false; + } +} +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); } +} +if ( ! function_exists( 'is_multisite' ) ) { + function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); } +} +if ( ! function_exists( 'is_super_admin' ) ) { + function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); } +} +if ( ! function_exists( 'switch_to_blog' ) ) { + function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; } +} +if ( ! function_exists( 'restore_current_blog' ) ) { + function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; } +} +if ( ! function_exists( 'get_sites' ) ) { + function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; } +} +if ( ! function_exists( 'is_plugin_active_for_network' ) ) { + function is_plugin_active_for_network( string $plugin ): bool { + return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false ); + } +} +if ( ! function_exists( 'plugin_basename' ) ) { + function plugin_basename( string $file ): string { + return basename( dirname( $file ) ) . '/' . basename( $file ); + } +} +if ( ! function_exists( 'wp_generate_password' ) ) { + function wp_generate_password( int $len = 12, bool $special = true ): string { + return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len ); + } +} +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); } +} +if ( ! function_exists( 'is_serialized' ) ) { + function is_serialized( $data ): bool { + return is_string( $data ) && strlen( $data ) >= 4 + && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true ) + && str_ends_with( $data, ';' ); + } +} +if ( ! function_exists( 'maybe_serialize' ) ) { + function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; } +} +if ( ! function_exists( 'maybe_unserialize' ) ) { + function maybe_unserialize( $value ) { + if ( ! is_string( $value ) ) { return $value; } + $u = @unserialize( $value ); + return ( false !== $u || 'b:0;' === $value ) ? $u : $value; + } +} +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( string $key ) { return $GLOBALS['_wp_options'][ '_transient_' . $key ] ?? false; } +} +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( string $key, $value, int $expiry = 0 ): bool { + $GLOBALS['_wp_options'][ '_transient_' . $key ] = $value; + return true; + } +} +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_options'][ '_transient_' . $key ] ); return true; } +} +if ( ! function_exists( 'wp_rand' ) ) { + function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); } +} +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } +} + +// Object cache simulation. +$GLOBALS['_wp_cache'] = []; +if ( ! function_exists( 'wp_cache_get' ) ) { + function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } +} +if ( ! function_exists( 'wp_cache_set' ) ) { + function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { + $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'wp_cache_delete' ) ) { + function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } +} + +// ── Stub classes ─────────────────────────────────────────────────────────── + +if ( ! class_exists( 'WP_Query' ) ) { + class WP_Query { + public array $posts = []; + public int $found_posts = 0; + public int $max_num_pages = 0; + private array $args = []; + public function __construct( array $args = [] ) { $this->args = $args; } + public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; } + public function set( string $key, $value ): void { $this->args[ $key ] = $value; } + } +} +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $code; + private string $message; + private array $data; + public function __construct( string $code = '', string $message = '', $data = [] ) { + $this->code = $code; + $this->message = $message; + $this->data = is_array( $data ) ? $data : []; + } + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} +if ( ! class_exists( 'WP_REST_Request' ) ) { + class WP_REST_Request { + private array $params = []; + private array $headers = []; + public function __construct( string $method = 'GET', string $route = '' ) {} + public function get_param( string $key ) { return $this->params[ $key ] ?? null; } + public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; } + public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; } + public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; } + } +} +if ( ! class_exists( 'WP_REST_Response' ) ) { + class WP_REST_Response { + private $data; + private int $status; + private array $headers = []; + public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; } + public function get_data() { return $this->data; } + public function get_status(): int { return $this->status; } + public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; } + public function get_headers(): array { return $this->headers; } + } +} +if ( ! class_exists( 'WP_REST_Server' ) ) { + class WP_REST_Server { + const READABLE = 'GET'; + const CREATABLE = 'POST'; + const EDITABLE = 'POST, PUT, PATCH'; + const DELETABLE = 'DELETE'; + } +} +if ( ! function_exists( 'register_rest_route' ) ) { + function register_rest_route( string $ns, string $route, array $args ): bool { return true; } +} + +// 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 {} + public static function add_command( string $name, $class ): void {} + } +} + +// ── Load plugin classes (TMDO_ prefix, dependency order) ────────────────── + +require_once TMDO_PATH . 'includes/class-tmdo-capability.php'; +require_once TMDO_PATH . 'includes/class-tmdo-crypto.php'; +require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php'; +require_once TMDO_PATH . 'includes/class-tmdo-db.php'; +require_once TMDO_PATH . 'includes/class-tmdo-logger.php'; +require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php'; +require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php'; +require_once TMDO_PATH . 'includes/class-tmdo-installer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php'; +require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php'; +require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php'; +require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php'; +require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php'; +require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php'; + +// Interceptors. +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php'; +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; + +// Zones. +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php'; + +// Query. +require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php'; + +// Migration. +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php'; + +// Integrations. +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php'; + +// Cache + Classifier. +require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php'; + +// Public API. +require_once TMDO_PATH . 'includes/class-tmdo-api.php'; + +// v2 Upgrader. +require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php'; + +// Snapshot system. +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php'; + +// FSM safety guard. +require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php'; + +// Diagnostic — do NOT load site-health here; its DB checks would fail with +// the stub $wpdb and trigger false schema_drift criticals in HealthCronTest. +// TMDO_Site_Health loads lazily if needed by integration tests. +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-health-cron.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-monthly-summary.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-metrics-collector.php'; + +// Notifiers. +require_once TMDO_PATH . 'includes/notifications/abstract-class-tmdo-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-email-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-slack-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-discord-notifier.php'; +require_once TMDO_PATH . 'includes/notifications/class-tmdo-telegram-notifier.php'; + +// FSM advisor. +require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-advisor.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-rules.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-module-detector.php'; +require_once TMDO_PATH . 'includes/advisor/class-tmdo-fsm-automator.php'; + +// Export. +require_once TMDO_PATH . 'includes/export/class-tmdo-csv-writer.php'; + +// REST API. +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-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'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php'; +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/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'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.php'; + +// Migration Orchestrator (needs Entity_Registry). +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; + +// Options Manager. +require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; + +// Stress testers + shadow verifiers. +require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php'; + +// Core. +require_once TMDO_PATH . 'includes/class-tmdo-core.php'; + +// CLI. +require_once TMDO_PATH . 'cli/class-tmdo-cli.php'; +require_once TMDO_PATH . 'cli/class-tmdo-cli-v2.php'; +require_once TMDO_PATH . 'cli/class-tmdo-cli-member.php'; +require_once TMDO_PATH . 'cli/class-tmdo-cli-post.php'; +require_once TMDO_PATH . 'cli/class-tmdo-cli-term-comment.php'; + +// Back-compat aliases (WPDO_* → TMDO_*) — must be last. +require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php'; + +// Global FSM Guard bypass for all unit tests except FSMGuardTest itself. +// FSMGuardTest::setUp() clears _wp_filter_callbacks to restore guard behavior. +if ( ! function_exists( '__return_true' ) ) { + function __return_true(): bool { return true; } +} +add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + +// ── AddOn stubs (classes moved to optional AddOns) ───────────────────────── + +// TMDO_Listing_Stats → 2meet-data-optimizer-hivepress-addon. +// Tests that hit REST endpoints using listing stats receive stub responses. +if ( ! class_exists( 'TMDO_Listing_Stats' ) ) { + class TMDO_Listing_Stats { + public static function register(): void {} + public static function get_view_count( int $post_id ): int { return 0; } + public static function increment_view( int $post_id, string $ip = '' ): int { return 0; } + public static function is_rate_limited( int $post_id, string $ip ): bool { return false; } + } + class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' ); +} diff --git a/tests/integration/BenchmarkIntegrationTest.php b/tests/integration/BenchmarkIntegrationTest.php new file mode 100644 index 0000000..8231fa1 --- /dev/null +++ b/tests/integration/BenchmarkIntegrationTest.php @@ -0,0 +1,297 @@ +prefix . 'wpdo_hot_bench'; + self::$warm_table = $wpdb->prefix . 'wpdo_warm_bench'; + self::$cold_table = $wpdb->prefix . 'wpdo_cold_bench'; + + // Zone A table. + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` ( + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + bench_val DECIMAL(10,2) DEFAULT NULL, + updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (post_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Zone B table (KV schema). + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + meta_key VARCHAR(255) NOT NULL DEFAULT '', + meta_value LONGTEXT DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY post_meta (post_id, meta_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Zone C table. + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$cold_table . "` ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + data LONGTEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_post_id (post_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$cold_table . "`" ); + + // Print timing summary. + fwrite( STDOUT, "\n\n ── Benchmark Results (N=" . self::N . " per zone) ──────────────────────\n" ); + foreach ( self::$report as $label => $ms ) { + fwrite( STDOUT, sprintf( " %-40s %7.1f ms\n", $label, $ms ) ); + } + fwrite( STDOUT, " ──────────────────────────────────────────────────────\n\n" ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE `" . self::$hot_table . "`" ); + $wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" ); + $wpdb->query( "TRUNCATE TABLE `" . self::$cold_table . "`" ); + $GLOBALS['_wp_cache'] = []; + } + + // ── Zone A (Hot) ───────────────────────────────────────────────────────── + + public function test_zone_a_bulk_write_performance(): void { + global $wpdb; + $now = gmdate( 'Y-m-d H:i:s' ); + $start = microtime( true ); + + for ( $i = 1; $i <= self::N; $i++ ) { + $wpdb->query( + $wpdb->prepare( + "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) + VALUES (%d, %f, %s) + ON DUPLICATE KEY UPDATE bench_val = VALUES(bench_val), updated_at = VALUES(updated_at)", + $i, + $i * 10.0, + $now + ) + ); + } + + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone A: ' . self::N . ' UPSERT writes'] = $ms; + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$hot_table . "`" ); + $this->assertSame( self::N, $count, 'Zone A: all rows written' ); + } + + public function test_zone_a_bulk_read_performance(): void { + global $wpdb; + + // Seed data. + $now = gmdate( 'Y-m-d H:i:s' ); + for ( $i = 1; $i <= self::N; $i++ ) { + $wpdb->query( $wpdb->prepare( + "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)", + $i, $i * 10.0, $now + ) ); + } + + // Benchmark individual point-reads. + $start = microtime( true ); + $values = []; + for ( $i = 1; $i <= self::N; $i++ ) { + $values[] = $wpdb->get_var( + $wpdb->prepare( "SELECT bench_val FROM `" . self::$hot_table . "` WHERE post_id = %d", $i ) + ); + } + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone A: ' . self::N . ' point reads'] = $ms; + + $this->assertCount( self::N, $values, 'Zone A: all rows readable' ); + $this->assertSame( '10.00', $values[0] ); // post_id=1 → 1*10=10 + } + + public function test_zone_a_filtered_query_performance(): void { + global $wpdb; + + $now = gmdate( 'Y-m-d H:i:s' ); + for ( $i = 1; $i <= self::N; $i++ ) { + $wpdb->query( $wpdb->prepare( + "INSERT INTO `" . self::$hot_table . "` (post_id, bench_val, updated_at) VALUES (%d, %f, %s)", + $i, $i * 10.0, $now + ) ); + } + + // Filtered query: bench_val >= 1000 (100 rows). + $start = microtime( true ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, bench_val FROM `" . self::$hot_table . "` WHERE bench_val >= %f ORDER BY bench_val ASC", + 1000.0 + ), + ARRAY_A + ); + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone A: filtered query (half dataset)'] = $ms; + + $this->assertCount( 101, $rows, 'Zone A: filter returns correct row count' ); + $this->assertSame( '1000.00', $rows[0]['bench_val'] ); + } + + // ── Zone B (Warm) ───────────────────────────────────────────────────────── + + public function test_zone_b_bulk_write_performance(): void { + global $wpdb; + $now = gmdate( 'Y-m-d H:i:s' ); + $start = microtime( true ); + + for ( $i = 1; $i <= self::N; $i++ ) { + $wpdb->query( + $wpdb->prepare( + "INSERT INTO `" . self::$warm_table . "` + (post_id, meta_key, meta_value, created_at) + VALUES (%d, %s, %s, %s) + ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)", + $i, 'bench_views', (string) $i, $now + ) + ); + } + + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone B: ' . self::N . ' KV writes'] = $ms; + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$warm_table . "`" ); + $this->assertSame( self::N, $count, 'Zone B: all KV rows written' ); + } + + public function test_zone_b_bulk_read_performance(): void { + global $wpdb; + + $now = gmdate( 'Y-m-d H:i:s' ); + for ( $i = 1; $i <= self::N; $i++ ) { + $wpdb->query( $wpdb->prepare( + "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, created_at) VALUES (%d, %s, %s, %s)", + $i, 'bench_views', (string) $i, $now + ) ); + } + + $start = microtime( true ); + $values = []; + for ( $i = 1; $i <= self::N; $i++ ) { + $values[] = $wpdb->get_var( $wpdb->prepare( + "SELECT meta_value FROM `" . self::$warm_table . "` WHERE post_id = %d AND meta_key = %s", + $i, 'bench_views' + ) ); + } + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone B: ' . self::N . ' KV reads'] = $ms; + + $this->assertCount( self::N, $values, 'Zone B: all KV rows readable' ); + $this->assertSame( '1', $values[0] ); // post_id=1 → value=1 + } + + // ── Zone C (Cold) ───────────────────────────────────────────────────────── + + public function test_zone_c_bulk_write_performance(): void { + global $wpdb; + $now = gmdate( 'Y-m-d H:i:s' ); + $start = microtime( true ); + + for ( $i = 1; $i <= self::N; $i++ ) { + $json = wp_json_encode( [ + 'hp_description' => 'Benchmark listing description number ' . $i, + 'hp_website' => 'https://listing' . $i . '.example.com', + 'hp_facebook' => 'https://facebook.com/listing' . $i, + ] ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at) + VALUES (%d, %s, %s) + ON DUPLICATE KEY UPDATE data = VALUES(data), updated_at = VALUES(updated_at)", + $i, $json, $now + ) + ); + } + + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone C: ' . self::N . ' JSON blob writes'] = $ms; + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$cold_table . "`" ); + $this->assertSame( self::N, $count, 'Zone C: all JSON rows written' ); + } + + public function test_zone_c_bulk_read_performance(): void { + global $wpdb; + + $now = gmdate( 'Y-m-d H:i:s' ); + for ( $i = 1; $i <= self::N; $i++ ) { + $json = wp_json_encode( [ + 'hp_description' => 'Description ' . $i, + 'hp_website' => 'https://listing' . $i . '.example.com', + ] ); + $wpdb->query( $wpdb->prepare( + "INSERT INTO `" . self::$cold_table . "` (post_id, data, updated_at) VALUES (%d, %s, %s)", + $i, $json, $now + ) ); + } + + $start = microtime( true ); + $decoded = 0; + for ( $i = 1; $i <= self::N; $i++ ) { + $json = $wpdb->get_var( $wpdb->prepare( + "SELECT data FROM `" . self::$cold_table . "` WHERE post_id = %d", + $i + ) ); + $data = json_decode( (string) $json, true ); + if ( is_array( $data ) && isset( $data['hp_description'] ) ) { + $decoded++; + } + } + $ms = ( microtime( true ) - $start ) * 1000; + self::$report['Zone C: ' . self::N . ' JSON blob reads'] = $ms; + + $this->assertSame( self::N, $decoded, 'Zone C: all JSON blobs readable' ); + } +} diff --git a/tests/integration/CommentStressTesterTest.php b/tests/integration/CommentStressTesterTest.php new file mode 100644 index 0000000..3e608ec --- /dev/null +++ b/tests/integration/CommentStressTesterTest.php @@ -0,0 +1,322 @@ +posts = self::POSTS; + $wpdb->comments = self::COMMENTS; + $wpdb->commentmeta = self::COMMENTMETA; + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title text NOT NULL DEFAULT "", + post_status varchar(20) NOT NULL DEFAULT "publish", + post_type varchar(20) NOT NULL DEFAULT "post", + comment_count bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (ID) + ) DEFAULT CHARACTER SET utf8mb4' ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENTS . '` ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0, + comment_author tinytext NOT NULL, + comment_author_email varchar(100) NOT NULL DEFAULT "", + comment_author_url varchar(200) NOT NULL DEFAULT "", + comment_author_IP varchar(100) NOT NULL DEFAULT "", + comment_date datetime NOT NULL DEFAULT "1970-01-01 00:00:00", + comment_date_gmt datetime NOT NULL DEFAULT "1970-01-01 00:00:00", + comment_content text NOT NULL, + comment_karma int(11) NOT NULL DEFAULT 0, + comment_approved varchar(20) NOT NULL DEFAULT "1", + comment_agent varchar(255) NOT NULL DEFAULT "", + comment_type varchar(20) NOT NULL DEFAULT "comment", + comment_parent bigint(20) unsigned NOT NULL DEFAULT 0, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (comment_ID), + KEY comment_author_email (comment_author_email(10)), + KEY comment_post_ID (comment_post_ID) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY comment_id (comment_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + + // Seed a single fixture post to satisfy post_exists() checks. + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->insert( self::POSTS, array( + 'post_title' => 'WPDO Comment Stress Fixture Post', + 'post_status' => 'publish', + 'post_type' => 'post', + ) ); + self::$test_post_id = (int) $wpdb->insert_id; + } + + public static function tearDownAfterClass(): void { + global $wpdb; + foreach ( array( self::COMMENTS, self::COMMENTMETA ) as $tbl ) { + $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' ); + } + // Don't drop wp_itest_posts — shared fixture across test classes. + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' ); + // Reset state per test so each starts idle. + unset( $GLOBALS['_wp_options'][ WPDO_Comment_Stress_Tester::OPT_STATE ] ); + unset( $GLOBALS['_wp_transients'][ WPDO_Comment_Stress_Tester::CANCEL_FLAG ] ); + unset( $GLOBALS['_wp_transients']['wpdo_comment_stress_pump_lock'] ); + } + + // ── create() (fast-path direct SQL) ────────────────────────────────────── + + public function test_create_inserts_comments_for_post(): void { + $result = WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 ); + + $this->assertSame( 5, $result['created'] ); + $this->assertSame( self::$test_post_id, $result['post_id'] ); + $this->assertNotNull( $result['first_id'] ); + $this->assertNotNull( $result['last_id'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ); + $this->assertSame( 5, $count ); + } + + public function test_create_uses_stress_email_domain(): void { + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 ); + + global $wpdb; + $prefix_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `" . self::COMMENTS . "` WHERE comment_author_email LIKE %s", + '%@' . WPDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN + ) + ); + $this->assertSame( 3, $prefix_count ); + } + + public function test_create_seeds_commentmeta_keys(): void { + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 ); + + global $wpdb; + $total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ); + // 3 comments × 1 key (hp_rating) = 3. + $this->assertSame( 3, $total_meta ); + } + + public function test_create_rejects_bad_post_id(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Comment_Stress_Tester::create( 0, 3 ); + } + + public function test_create_rejects_zero_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 0 ); + } + + public function test_create_rejects_excessive_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 100001 ); + } + + // ── count_test_comments() ──────────────────────────────────────────────── + + public function test_count_test_comments_returns_zero_for_empty(): void { + $this->assertSame( 0, WPDO_Comment_Stress_Tester::count_test_comments() ); + } + + public function test_count_test_comments_counts_only_stress_emails(): void { + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 4 ); + + global $wpdb; + $wpdb->insert( self::COMMENTS, array( + 'comment_post_ID' => self::$test_post_id, + 'comment_author' => 'Real', + 'comment_author_email' => 'real@example.com', + 'comment_content' => 'Real comment', + 'comment_approved' => '1', + ) ); + + $this->assertSame( 4, WPDO_Comment_Stress_Tester::count_test_comments() ); + } + + // ── cleanup() ───────────────────────────────────────────────────────────── + + public function test_cleanup_removes_test_comments_and_cascade(): void { + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 5 ); + $this->assertSame( 5, WPDO_Comment_Stress_Tester::count_test_comments() ); + + $result = WPDO_Comment_Stress_Tester::cleanup(); + $this->assertSame( 5, $result['deleted_comments'] ); + + global $wpdb; + $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ) ); + $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ) ); + } + + public function test_cleanup_preserves_non_stress_comments(): void { + global $wpdb; + $wpdb->insert( self::COMMENTS, array( + 'comment_post_ID' => self::$test_post_id, + 'comment_author' => 'Real', + 'comment_author_email' => 'real@example.com', + 'comment_content' => 'Real comment', + 'comment_approved' => '1', + ) ); + WPDO_Comment_Stress_Tester::create( self::$test_post_id, 3 ); + + $result = WPDO_Comment_Stress_Tester::cleanup(); + $this->assertSame( 3, $result['deleted_comments'] ); + + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTS . '`' ); + $this->assertSame( 1, $remaining ); + } + + public function test_cleanup_idempotent_on_empty(): void { + $first = WPDO_Comment_Stress_Tester::cleanup(); + $second = WPDO_Comment_Stress_Tester::cleanup(); + $this->assertSame( 0, $first['deleted_comments'] ); + $this->assertSame( 0, $second['deleted_comments'] ); + } + + // ── State machine ──────────────────────────────────────────────────────── + + public function test_get_state_returns_empty_when_idle(): void { + $this->assertSame( array(), WPDO_Comment_Stress_Tester::get_state() ); + } + + public function test_get_progress_returns_idle_when_no_state(): void { + $progress = WPDO_Comment_Stress_Tester::get_progress( false ); + $this->assertSame( 'idle', $progress['status'] ); + } + + public function test_start_persists_state_with_running_status(): void { + $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 ); + + $this->assertTrue( $result['ok'], 'start should succeed' ); + $state = $result['state']; + $this->assertSame( 'running', $state['status'] ); + $this->assertSame( self::$test_post_id, $state['post_id'] ); + $this->assertSame( 'fast', $state['mode'] ); + $this->assertSame( 10, $state['target'] ); + $this->assertSame( 5, $state['batch_size'] ); + } + + public function test_start_rejects_unknown_post(): void { + $result = WPDO_Comment_Stress_Tester::start( 999999, 10 ); + $this->assertFalse( $result['ok'] ); + $this->assertStringContainsString( 'unknown_post', $result['error'] ); + } + + public function test_start_rejects_invalid_mode(): void { + $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'turbo' ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'invalid mode', $result['error'] ); + } + + public function test_start_rejects_concurrent_run(): void { + WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10 ); + $result = WPDO_Comment_Stress_Tester::start( self::$test_post_id, 5 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'already_running', $result['error'] ); + } + + public function test_run_batch_advances_processed_count(): void { + WPDO_Comment_Stress_Tester::start( self::$test_post_id, 6, 'fast', 3 ); + + WPDO_Comment_Stress_Tester::run_batch(); + $progress = WPDO_Comment_Stress_Tester::get_progress( false ); + $this->assertSame( 3, $progress['processed'] ); + $this->assertSame( 1, $progress['batches_done'] ); + $this->assertSame( 'running', $progress['status'] ); + + WPDO_Comment_Stress_Tester::run_batch(); + $progress = WPDO_Comment_Stress_Tester::get_progress( false ); + $this->assertSame( 6, $progress['processed'] ); + $this->assertSame( 'completed', $progress['status'] ); + } + + public function test_cancel_marks_state_as_cancelled(): void { + WPDO_Comment_Stress_Tester::start( self::$test_post_id, 100, 'fast', 50 ); + + $result = WPDO_Comment_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'cancelled', $result['state']['status'] ); + + // In-flight batch run after cancel must NOT bump status back to running. + WPDO_Comment_Stress_Tester::run_batch(); + $state = WPDO_Comment_Stress_Tester::get_state(); + $this->assertSame( 'cancelled', $state['status'] ); + } + + public function test_cancel_returns_no_active_job_when_idle(): void { + $result = WPDO_Comment_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'no_active_job', $result['message'] ?? '' ); + } + + public function test_get_progress_includes_pct_and_eta_keys(): void { + WPDO_Comment_Stress_Tester::start( self::$test_post_id, 10, 'fast', 5 ); + WPDO_Comment_Stress_Tester::run_batch(); + + $progress = WPDO_Comment_Stress_Tester::get_progress( false ); + $this->assertArrayHasKey( 'pct', $progress ); + $this->assertArrayHasKey( 'rate_per_sec', $progress ); + $this->assertArrayHasKey( 'elapsed_sec', $progress ); + $this->assertArrayHasKey( 'eta_sec', $progress ); + $this->assertArrayHasKey( 'test_comment_count', $progress ); + $this->assertSame( 50.0, $progress['pct'] ); + } + + public function test_run_benchmark_returns_structured_payload(): void { + WPDO_Comment_Stress_Tester::start( self::$test_post_id, 4, 'fast', 4 ); + WPDO_Comment_Stress_Tester::run_batch(); + + $state = WPDO_Comment_Stress_Tester::get_state(); + $this->assertSame( 'completed', $state['status'] ); + $this->assertIsArray( $state['benchmark'] ); + $this->assertArrayHasKey( 'write', $state['benchmark'] ); + $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] ); + $this->assertSame( self::$test_post_id, $state['benchmark']['post_id'] ); + } +} diff --git a/tests/integration/CommentmetaCleanerIntegrationTest.php b/tests/integration/CommentmetaCleanerIntegrationTest.php new file mode 100644 index 0000000..5df4ef4 --- /dev/null +++ b/tests/integration/CommentmetaCleanerIntegrationTest.php @@ -0,0 +1,169 @@ +commentmeta = self::COMMENTMETA; + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENTMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY comment_id (comment_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTMETA . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' ); + } + + private function seed( array $rows ): void { + global $wpdb; + foreach ( $rows as $row ) { + $wpdb->insert( self::COMMENTMETA, $row ); + } + } + + // ── count_garbage ──────────────────────────────────────────────────────── + + public function test_count_garbage_returns_zero_for_empty_table(): void { + $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 0, $counts['wxr_import'] ); + $this->assertSame( 0, $counts['demo_data'] ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 0, $counts['orphan_post_meta'] ); + $this->assertSame( 0, $counts['total'] ); + } + + public function test_count_garbage_counts_wxr_import(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'comment_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ), + array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + ) ); + + $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'wxr_import' ); + $this->assertSame( 2, $counts['wxr_import'] ); + $this->assertSame( 2, $counts['total'] ); + } + + public function test_count_garbage_counts_orphan_post_meta(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ), + array( 'comment_id' => 1, 'meta_key' => '_hp_status', 'meta_value' => 'publish' ), + array( 'comment_id' => 2, 'meta_key' => '_thumbnail_id', 'meta_value' => '50' ), + array( 'comment_id' => 3, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + array( 'comment_id' => 4, 'meta_key' => 'note_group', 'meta_value' => 'foo' ), + ) ); + + $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'orphan_post_meta' ); + $this->assertSame( 3, $counts['orphan_post_meta'] ); + $this->assertSame( 3, $counts['total'] ); + } + + public function test_count_garbage_all_unions_four_buckets(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ), + array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + ) ); + + $counts = WPDO_Commentmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 1, $counts['wxr_import'] ); + $this->assertSame( 1, $counts['demo_data'] ); + $this->assertSame( 1, $counts['transients'] ); + $this->assertSame( 1, $counts['orphan_post_meta'] ); + $this->assertSame( 4, $counts['total'] ); + } + + // ── delete_garbage ──────────────────────────────────────────────────────── + + public function test_delete_garbage_removes_targeted_rows_only(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'comment_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'comment_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + array( 'comment_id' => 4, 'meta_key' => '_hp_price', 'meta_value' => '99' ), + array( 'comment_id' => 5, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + array( 'comment_id' => 6, 'meta_key' => 'note_group', 'meta_value' => 'foo' ), + ) ); + + $deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 1, $deleted['wxr_import'] ); + $this->assertSame( 1, $deleted['demo_data'] ); + $this->assertSame( 1, $deleted['transients'] ); + $this->assertSame( 1, $deleted['orphan_post_meta'] ); + $this->assertSame( 4, $deleted['total'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENTMETA . '`' ); + $this->assertSame( 2, $remaining, 'hp_rating + note_group must survive' ); + } + + public function test_delete_garbage_orphan_post_meta_specific(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => '_hp_price', 'meta_value' => '99' ), + array( 'comment_id' => 2, 'meta_key' => '_hp_featured', 'meta_value' => '1' ), + array( 'comment_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => '111:1' ), + array( 'comment_id' => 4, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + array( 'comment_id' => 5, 'meta_key' => 'note_group', 'meta_value' => 'foo' ), + ) ); + + $deleted = WPDO_Commentmeta_Cleaner::delete_garbage( 'orphan_post_meta' ); + $this->assertSame( 3, $deleted['orphan_post_meta'] ); + $this->assertSame( 3, $deleted['total'] ); + + global $wpdb; + $keys = $wpdb->get_col( 'SELECT meta_key FROM `' . self::COMMENTMETA . '` ORDER BY meta_key' ); + $this->assertSame( array( 'hp_rating', 'note_group' ), $keys ); + } + + public function test_delete_garbage_idempotent_on_clean_table(): void { + $this->seed( array( + array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => '5' ), + ) ); + + $first = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' ); + $second = WPDO_Commentmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 0, $first['total'] ); + $this->assertSame( 0, $second['total'] ); + } + + public function test_invalid_target_throws(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Commentmeta_Cleaner::count_garbage( 'bogus' ); + } +} diff --git a/tests/integration/CryptoMigrationTest.php b/tests/integration/CryptoMigrationTest.php new file mode 100644 index 0000000..4504581 --- /dev/null +++ b/tests/integration/CryptoMigrationTest.php @@ -0,0 +1,159 @@ +prefix = self::TEST_PREFIX; + $wpdb->options = self::TEST_PREFIX . 'options'; + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` ( + option_id bigint(20) 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) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // Define WP auth constants for stable key derivation. + if ( ! defined( 'AUTH_KEY' ) ) { + define( 'AUTH_KEY', 'integration_auth_key_long_enough_xxxxxxxxxxxxxxxxxxxxxx' ); + } + if ( ! defined( 'SECURE_AUTH_SALT' ) ) { + define( 'SECURE_AUTH_SALT', 'integration_secure_auth_salt_long_xxxxxxxxxxxxxxxxxxxx' ); + } + } + + protected function setUp(): void { + global $wpdb; + // Clear all wpdo_* options before each test for isolation. + $wpdb->query( "DELETE FROM `" . self::TEST_PREFIX . "options` WHERE option_name LIKE 'wpdo_%' OR option_name LIKE 'unrelated_%'" ); + } + + public function test_migrate_mixed_format_inputs(): void { + // Set up: 2 v1 blobs, 1 v2 blob, 1 plaintext, 1 unrelated (non-wpdo). + $plain1 = 'https://hooks.slack.com/services/legacy1'; + $plain2 = 'https://discord.com/api/webhooks/legacy2'; + $this->insert_v1_option( 'wpdo_legacy_slack', $plain1 ); + $this->insert_v1_option( 'wpdo_legacy_discord', $plain2 ); + + // Already v2. + $this->set_option_raw( 'wpdo_already_v2', WPDO_Crypto::encrypt( 'already encrypted' ) ); + + // Plaintext. + $this->set_option_raw( 'wpdo_plaintext_secret', 'just text' ); + + // Unrelated prefix — must NOT be touched. + $this->set_option_raw( 'unrelated_secret', 'should be ignored' ); + + $counts = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); + + // Scanned 4 wpdo_* options (unrelated_ excluded). + $this->assertSame( 4, $counts['scanned'] ); + $this->assertSame( 2, $counts['migrated'] ); + $this->assertSame( 1, $counts['already_v2'] ); + $this->assertSame( 1, $counts['plaintext'] ); + $this->assertSame( 0, $counts['failed'] ); + + // Verify v1 blobs were upgraded to v2 and decrypt correctly. + $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_slack' ) ); + $this->assertSame( 'v2', WPDO_Crypto::format_version( 'wpdo_legacy_discord' ) ); + $this->assertSame( $plain1, WPDO_Crypto::get_option( 'wpdo_legacy_slack' ) ); + $this->assertSame( $plain2, WPDO_Crypto::get_option( 'wpdo_legacy_discord' ) ); + + // Plaintext untouched. + $this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'wpdo_plaintext_secret' ) ); + + // Unrelated option untouched. + global $wpdb; + $unrelated_value = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM `" . self::TEST_PREFIX . "options` WHERE option_name = %s", + 'unrelated_secret' + ) + ); + $this->assertSame( 'should be ignored', $unrelated_value ); + } + + public function test_migrate_idempotent_second_run_is_noop(): void { + $plain = 'a value'; + $this->insert_v1_option( 'wpdo_test_idempotent', $plain ); + + $first = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); + $second = WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); + + // First run migrates 1, second run sees it as already_v2. + $this->assertSame( 1, $first['migrated'] ); + $this->assertSame( 0, $second['migrated'] ); + $this->assertSame( 1, $second['already_v2'] ); + + // Value still decrypts correctly after both runs. + $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_test_idempotent' ) ); + } + + public function test_migrate_empty_set(): void { + $counts = WPDO_Crypto::migrate_v1_to_v2( 'nonexistent_prefix_' ); + + $this->assertSame( 0, $counts['scanned'] ); + $this->assertSame( 0, $counts['migrated'] ); + $this->assertSame( 0, $counts['failed'] ); + } + + public function test_migrate_preserves_value_semantics(): void { + // Realistic test: write a webhook-shaped string that includes URL chars + // + special padding to make sure no encoding artifacts surface. + $plain = 'https://hooks.slack.com/services/T01/B02/=+&%/special?chars=true'; + $this->insert_v1_option( 'wpdo_realistic_webhook', $plain ); + + WPDO_Crypto::migrate_v1_to_v2( 'wpdo_' ); + + $this->assertSame( $plain, WPDO_Crypto::get_option( 'wpdo_realistic_webhook' ) ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** + * Insert an option containing a hand-crafted v1 (CBC) ciphertext. + */ + private function insert_v1_option( string $name, string $plaintext ): void { + $key = substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', AUTH_KEY . SECURE_AUTH_SALT, true ), 0, 32 ); + $iv = random_bytes( 16 ); + $ct = openssl_encrypt( $plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); + $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct ); + $this->set_option_raw( $name, $blob ); + } + + /** + * Write a raw option value directly (bypasses WPDO_Crypto::set_option). + */ + private function set_option_raw( string $name, string $value ): void { + global $wpdb; + $wpdb->query( + $wpdb->prepare( + 'REPLACE INTO `' . self::TEST_PREFIX . 'options` (option_name, option_value, autoload) VALUES (%s, %s, %s)', + $name, + $value, + 'no' + ) + ); + $GLOBALS['_wp_options'][ $name ] = $value; + } +} diff --git a/tests/integration/DemoEntityCounterTest.php b/tests/integration/DemoEntityCounterTest.php new file mode 100644 index 0000000..7715ab5 --- /dev/null +++ b/tests/integration/DemoEntityCounterTest.php @@ -0,0 +1,268 @@ +query( "TRUNCATE TABLE `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "`" ); + + // Reset native usermeta global stubs (when running under integration env). + $GLOBALS['_wp_usermeta'] = array(); + } + + // ── Schema ───────────────────────────────────────────────────────────── + + public function test_install_table_creates_with_composite_unique(): void { + global $wpdb; + $table = $wpdb->prefix . WPDO_Demo_Entity_Counter::TABLE; + + // Index check: ui_entity_counter must be UNIQUE on (entity_type, entity_id, counter_key). + $rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY INDEX_NAME, SEQ_IN_INDEX', + $table + ), + ARRAY_A + ); + + $ui_cols = array(); + foreach ( $rows as $r ) { + if ( 'ui_entity_counter' === $r['INDEX_NAME'] && '0' === (string) $r['NON_UNIQUE'] ) { + $ui_cols[] = $r['COLUMN_NAME']; + } + } + $this->assertSame( array( 'entity_type', 'entity_id', 'counter_key' ), $ui_cols ); + } + + // ── set() / get() — idle state (native fallback only) ────────────────── + + public function test_set_writes_to_native_meta_in_idle_state(): void { + WPDO_Demo_Entity_Counter::set( 'user', 100, 'points', 50 ); + $this->assertSame( 50, (int) get_user_meta( 100, 'points', true ) ); + } + + public function test_get_reads_native_in_idle_state(): void { + update_user_meta( 200, 'points', 75 ); + $this->assertSame( 75, WPDO_Demo_Entity_Counter::get( 'user', 200, 'points' ) ); + } + + public function test_idle_state_does_not_dual_write(): void { + WPDO_Demo_Entity_Counter::set( 'user', 300, 'points', 99 ); + + global $wpdb; + $count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d", + 'user', + 300 + ) + ); + $this->assertSame( 0, $count, 'idle state must NOT dual-write to demo table' ); + } + + // ── set() — dual_write state ──────────────────────────────────────────── + + public function test_dual_write_state_writes_to_both(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + + WPDO_Demo_Entity_Counter::set( 'user', 400, 'points', 123 ); + + global $wpdb; + $zone_value = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s", + 'user', + 400, + 'points' + ) + ); + $this->assertSame( 123, $zone_value, 'dual_write must populate the zone table' ); + $this->assertSame( 123, (int) get_user_meta( 400, 'points', true ), 'dual_write must also keep native meta' ); + } + + public function test_upsert_uses_single_round_trip(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + + // Two rapid writes to the same key — should produce exactly 1 row, not 2. + WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 10 ); + WPDO_Demo_Entity_Counter::set( 'user', 500, 'points', 25 ); + + global $wpdb; + $rows = $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d", + 'user', + 500 + ) + ); + $this->assertSame( '1', (string) $rows, 'composite UNIQUE must collapse to 1 row' ); + + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s", + 'user', + 500, + 'points' + ) + ); + $this->assertSame( '25', (string) $value, 'second write must overwrite via UPSERT' ); + } + + // ── get() — cutover state (read from zone) ───────────────────────────── + + public function test_cutover_state_reads_from_zone_table(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + WPDO_Demo_Entity_Counter::set( 'user', 600, 'points', 999 ); + + // Switch to cutover — reads now come from zone. + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' ); + + // Tamper with native meta to prove zone table is the source of truth. + update_user_meta( 600, 'points', 0 ); + + $this->assertSame( 999, WPDO_Demo_Entity_Counter::get( 'user', 600, 'points' ) ); + } + + public function test_cutover_falls_back_to_native_when_zone_row_missing(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'cutover' ); + // No dual_write history — zone table is empty for this entity. + update_user_meta( 700, 'points', 42 ); + + $this->assertSame( 42, WPDO_Demo_Entity_Counter::get( 'user', 700, 'points' ), 'graceful fallback when zone row absent' ); + } + + // ── Cross-entity coverage ────────────────────────────────────────────── + + public function test_term_entity_works(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + WPDO_Demo_Entity_Counter::set( 'term', 800, 'usage_count', 17 ); + + global $wpdb; + $value = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s", + 'term', + 800, + 'usage_count' + ) + ); + $this->assertSame( 17, $value ); + } + + public function test_comment_entity_works(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + WPDO_Demo_Entity_Counter::set( 'comment', 900, 'helpful_count', 8 ); + + global $wpdb; + $value = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT counter_value FROM `{$wpdb->prefix}" . WPDO_Demo_Entity_Counter::TABLE . "` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s", + 'comment', + 900, + 'helpful_count' + ) + ); + $this->assertSame( 8, $value ); + } + + public function test_invalid_entity_type_returns_false(): void { + $this->assertFalse( WPDO_Demo_Entity_Counter::set( 'bogus', 1, 'k', 1 ) ); + $this->assertSame( 0, WPDO_Demo_Entity_Counter::get( 'bogus', 1, 'k' ) ); + } + + // ── Top-N query (killer use case postmeta can't do efficiently) ─────── + + public function test_top_n_query_returns_sorted_results(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + + // Seed 5 users with varying point counts. + WPDO_Demo_Entity_Counter::set( 'user', 1001, 'points', 100 ); + WPDO_Demo_Entity_Counter::set( 'user', 1002, 'points', 500 ); + WPDO_Demo_Entity_Counter::set( 'user', 1003, 'points', 200 ); + WPDO_Demo_Entity_Counter::set( 'user', 1004, 'points', 800 ); + WPDO_Demo_Entity_Counter::set( 'user', 1005, 'points', 350 ); + + $top3 = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 3 ); + + $this->assertCount( 3, $top3 ); + // Sorted DESC: 1004(800) > 1002(500) > 1005(350) > 1003(200) > 1001(100) + $this->assertSame( 1004, $top3[0]['entity_id'] ); + $this->assertSame( 800, $top3[0]['counter_value'] ); + $this->assertSame( 1002, $top3[1]['entity_id'] ); + $this->assertSame( 500, $top3[1]['counter_value'] ); + $this->assertSame( 1005, $top3[2]['entity_id'] ); + $this->assertSame( 350, $top3[2]['counter_value'] ); + } + + public function test_top_n_filters_by_entity_type(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + WPDO_Demo_Entity_Counter::set( 'user', 2001, 'points', 999 ); + WPDO_Demo_Entity_Counter::set( 'term', 2001, 'usage_count', 999 ); // same id, different type. + + $users = WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 ); + $terms = WPDO_Demo_Entity_Counter::top_n( 'term', 'usage_count', 10 ); + + $this->assertCount( 1, $users ); + $this->assertCount( 1, $terms ); + $this->assertSame( 2001, $users[0]['entity_id'] ); + $this->assertSame( 2001, $terms[0]['entity_id'] ); + } + + // ── Mini benchmark — proves zone table beats postmeta on top-N ──────── + + public function test_benchmark_top_n_zone_vs_postmeta_simulated(): void { + WPDO_Feature_Flags::set( WPDO_Demo_Entity_Counter::MODULE, 'dual_write' ); + + // Seed 100 users with random point values. + for ( $i = 3001; $i <= 3100; $i++ ) { + WPDO_Demo_Entity_Counter::set( 'user', $i, 'points', wp_rand( 0, 10000 ) ); + } + + // Time the zone-table top-10 query. + $t1 = microtime( true ); + for ( $i = 0; $i < 100; $i++ ) { + WPDO_Demo_Entity_Counter::top_n( 'user', 'points', 10 ); + } + $zone_ms = ( microtime( true ) - $t1 ) * 1000; + + // We expect 100 zone reads under 200ms total (well under "1 LEFT JOIN per request"). + $this->assertLessThan( + 500, + $zone_ms, + "100 top-N reads from zone table took {$zone_ms}ms — exceeded 500ms ceiling" + ); + + // Print for visibility (PHPUnit captures to test output, no assertion impact). + fwrite( STDOUT, "\n Demo benchmark: 100x top-10 in {$zone_ms}ms (avg " . round( $zone_ms / 100, 2 ) . "ms/call)\n" ); + } +} diff --git a/tests/integration/InstallerCleanupTest.php b/tests/integration/InstallerCleanupTest.php new file mode 100644 index 0000000..b95224b --- /dev/null +++ b/tests/integration/InstallerCleanupTest.php @@ -0,0 +1,178 @@ +prefix` so changing the prefix scopes drops to our tables only. + private const TEST_PREFIX = 'wp_clnup_'; + + public static function setUpBeforeClass(): void { + global $wpdb; + + if ( ! class_exists( 'WPDO_Installer' ) ) { + require_once WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php'; + } + + $wpdb->prefix = self::TEST_PREFIX; + $wpdb->options = self::TEST_PREFIX . 'options'; + + // Ensure options table exists (needed for delete_option fallback path). + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::TEST_PREFIX . 'options` ( + option_id bigint(20) 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) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + // Drop the dedicated options table + any residual prefix tables. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TEST_PREFIX . 'options`' ); + foreach ( array( + self::TEST_PREFIX . 'wpdo_archive', + self::TEST_PREFIX . 'wpdo_warm', + self::TEST_PREFIX . 'wpdo_errors', + self::TEST_PREFIX . 'wpdo_hot_test_type', + self::TEST_PREFIX . 'wpdo_user_profile', + self::TEST_PREFIX . 'wpdo_post_attachment', + self::TEST_PREFIX . 'wpdo_term_hp_taxonomy', + self::TEST_PREFIX . 'wpdo_comment_hp_review', + self::TEST_PREFIX . 'unrelated_table', + ) as $tbl ) { + $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' ); + } + // Restore the shared integration test prefix so any teardown elsewhere + // that depends on `$wpdb->prefix === 'wp_itest_'` still works. + $wpdb->prefix = 'wp_itest_'; + } + + protected function setUp(): void { + global $wpdb; + // Reset options table state. + $wpdb->query( 'TRUNCATE TABLE `' . self::TEST_PREFIX . 'options`' ); + $GLOBALS['_wp_options'] = array(); + } + + public function test_drops_static_tables(): void { + global $wpdb; + + // Create a few WPDO-prefixed tables that should be dropped. + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_archive` (id INT)' ); + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_warm` (id INT)' ); + + $counts = WPDO_Installer::drop_all_tables_for_current_blog(); + + $this->assertGreaterThanOrEqual( 2, $counts['tables_dropped'] ); + + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + self::TEST_PREFIX . 'wpdo_archive' + ) + ); + $this->assertSame( 0, $exists, 'wpdo_archive should be dropped' ); + } + + public function test_drops_dynamic_zone_tables(): void { + global $wpdb; + + // Dynamic hot/cold zone tables should be discovered via LIKE pattern. + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_hot_test_type` (id INT)' ); + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_user_profile` (id INT)' ); + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_post_attachment` (id INT)' ); + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_term_hp_taxonomy` (id INT)' ); + $wpdb->query( 'CREATE TABLE `' . self::TEST_PREFIX . 'wpdo_comment_hp_review` (id INT)' ); + + WPDO_Installer::drop_all_tables_for_current_blog(); + + foreach ( array( + 'wpdo_hot_test_type', + 'wpdo_user_profile', + 'wpdo_post_attachment', + 'wpdo_term_hp_taxonomy', + 'wpdo_comment_hp_review', + ) as $tbl_suffix ) { + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + self::TEST_PREFIX . $tbl_suffix + ) + ); + $this->assertSame( 0, $exists, $tbl_suffix . ' should be dropped' ); + } + } + + public function test_does_not_drop_unrelated_tables(): void { + global $wpdb; + // Defensive: a table named like wpdo_X should be dropped, but a table + // with a non-wpdo prefix MUST NEVER be dropped even if name pattern + // would match. + $unrelated = self::TEST_PREFIX . 'unrelated_table'; + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . $unrelated . '` (id INT)' ); + + WPDO_Installer::drop_all_tables_for_current_blog(); + + $exists = (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $unrelated + ) + ); + $this->assertSame( 1, $exists, 'Non-wpdo table must not be dropped' ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . $unrelated . '`' ); + } + + public function test_returns_counts_structure(): void { + $counts = WPDO_Installer::drop_all_tables_for_current_blog(); + + $this->assertIsArray( $counts ); + $this->assertArrayHasKey( 'tables_dropped', $counts ); + $this->assertArrayHasKey( 'options_deleted', $counts ); + $this->assertArrayHasKey( 'crons_cleared', $counts ); + } + + public function test_idempotent_on_empty_state(): void { + // Run twice — second run should be a no-op for tables (we already + // dropped them all in the first run). Options may not be 0 because + // other test classes share the same wp_itest_options table and may + // continually re-create wpdo_* rows; just verify that running cleanup + // twice in a row does not throw. + WPDO_Installer::drop_all_tables_for_current_blog(); + $second = WPDO_Installer::drop_all_tables_for_current_blog(); + + $this->assertSame( 0, $second['tables_dropped'] ); + $this->assertIsInt( $second['options_deleted'] ); + $this->assertIsInt( $second['crons_cleared'] ); + } + + public function test_drops_known_options(): void { + // Stub `delete_option` does not interact with DB layer in our test stubs; + // it modifies `$GLOBALS['_wp_options']`. Verify counts work via the + // known-options list. + $GLOBALS['_wp_options']['wpdo_db_version'] = '2.14.0'; + $GLOBALS['_wp_options']['wpdo_features'] = array(); + $GLOBALS['_wp_options']['wpdo_health_alert'] = '1'; + + $counts = WPDO_Installer::drop_all_tables_for_current_blog(); + + // Note: the actual count depends on $wpdb->options interaction in + // the residual sweep. Just verify the structure works without errors. + $this->assertIsInt( $counts['options_deleted'] ); + } +} diff --git a/tests/integration/InstallerV2Test.php b/tests/integration/InstallerV2Test.php new file mode 100644 index 0000000..689597c --- /dev/null +++ b/tests/integration/InstallerV2Test.php @@ -0,0 +1,118 @@ +prefix; + $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_audit`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_shadow_diffs`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_site_metrics`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$p}wpdo_uni_options`" ); + } + + public function test_v2_tables_initially_absent(): void { + $status = WPDO_Installer::v2_tables_status(); + foreach ( $status as $table => $exists ) { + $this->assertFalse( $exists, "Expected {$table} to NOT exist initially" ); + } + } + + public function test_install_v2_tables_creates_all_four(): void { + WPDO_Installer::install_v2_tables(); + + $status = WPDO_Installer::v2_tables_status(); + foreach ( $status as $table => $exists ) { + $this->assertTrue( $exists, "Expected {$table} to exist after install_v2_tables()" ); + } + } + + public function test_install_v2_tables_is_idempotent(): void { + WPDO_Installer::install_v2_tables(); + WPDO_Installer::install_v2_tables(); // Second call must not error. + WPDO_Installer::install_v2_tables(); // Third for good measure. + + $status = WPDO_Installer::v2_tables_status(); + $this->assertCount( 4, $status ); + $this->assertTrue( array_reduce( $status, static fn( $carry, $v ) => $carry && $v, true ) ); + } + + public function test_audit_table_has_required_columns(): void { + global $wpdb; + $p = $wpdb->prefix; + $cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_audit`" ); + + // PR-2 spec: op, value_before, value_after, source, trace_id are required. + foreach ( array( 'op', 'value_before', 'value_after', 'source', 'trace_id' ) as $required ) { + $this->assertContains( $required, $cols, "wpdo_audit missing column {$required}" ); + } + } + + public function test_shadow_diffs_table_has_required_columns(): void { + global $wpdb; + $p = $wpdb->prefix; + $cols = $wpdb->get_col( "SHOW COLUMNS FROM `{$p}wpdo_shadow_diffs`" ); + + foreach ( array( 'entity_type', 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'diff_hash' ) as $required ) { + $this->assertContains( $required, $cols, "wpdo_shadow_diffs missing column {$required}" ); + } + } + + public function test_uni_options_has_unique_index_on_option_name(): void { + global $wpdb; + $p = $wpdb->prefix; + $indexes = $wpdb->get_results( + $wpdb->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $p . 'wpdo_uni_options' + ), + ARRAY_A + ); + + $found_unique = false; + foreach ( $indexes as $idx ) { + if ( 'option_name' === $idx['COLUMN_NAME'] && '0' === (string) $idx['NON_UNIQUE'] ) { + $found_unique = true; + break; + } + } + $this->assertTrue( $found_unique, 'Expected UNIQUE index on wpdo_uni_options.option_name' ); + } + + public function test_audit_table_indexes_for_query_performance(): void { + global $wpdb; + $p = $wpdb->prefix; + $indexes = $wpdb->get_col( + $wpdb->prepare( + 'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', + $p . 'wpdo_audit' + ) + ); + + // Performance-critical indexes per Part F.3 schema spec. + foreach ( array( 'idx_entity', 'idx_meta_key', 'idx_ts', 'idx_trace' ) as $required ) { + $this->assertContains( $required, $indexes, "wpdo_audit missing index {$required}" ); + } + } + + 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. + } +} diff --git a/tests/integration/MemberFields/MemberBackfillIntegrationTest.php b/tests/integration/MemberFields/MemberBackfillIntegrationTest.php new file mode 100644 index 0000000..2910917 --- /dev/null +++ b/tests/integration/MemberFields/MemberBackfillIntegrationTest.php @@ -0,0 +1,226 @@ +query( "DROP TABLE IF EXISTS `{$t}`" ); + } + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::STATUS_TABLE . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::USERMETA . '`' ); + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + public function test_migrates_membership_level_and_points(): void { + $this->seed_usermeta( array( + array( 'user_id' => 1, 'meta_key' => 'membership_level', 'meta_value' => 'gold' ), + array( 'user_id' => 1, 'meta_key' => 'points_balance', 'meta_value' => '500' ), + array( 'user_id' => 2, 'meta_key' => 'membership_level', 'meta_value' => 'silver' ), + array( 'user_id' => 2, 'meta_key' => 'points_balance', 'meta_value' => '200' ), + ) ); + + $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) ); + + $this->assertSame( 2, $result['migrated'], 'Expected 2 migrated rows' ); + $this->assertSame( 0, $result['errors'] ); + + global $wpdb; + $row1 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 1", ARRAY_A ); + $this->assertNotNull( $row1 ); + $this->assertSame( 'gold', $row1['membership_level'] ); + $this->assertSame( '500', $row1['points_balance'] ); + + $row2 = $wpdb->get_row( "SELECT * FROM `" . self::MEM_TABLE . "` WHERE user_id = 2", ARRAY_A ); + $this->assertNotNull( $row2 ); + $this->assertSame( 'silver', $row2['membership_level'] ); + $this->assertSame( '200', $row2['points_balance'] ); + } + + public function test_skips_users_with_no_managed_keys(): void { + $this->seed_usermeta( array( + array( 'user_id' => 3, 'meta_key' => 'some_other_meta', 'meta_value' => 'value' ), + ) ); + + $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) ); + + $this->assertSame( 0, $result['migrated'] ); + $this->assertSame( 0, $result['errors'] ); + } + + public function test_dry_run_does_not_write_to_flat_table(): void { + $this->seed_usermeta( array( + array( 'user_id' => 4, 'meta_key' => 'membership_level', 'meta_value' => 'platinum' ), + ) ); + + $result = WPDO_Entity_Migration_Engine::migrate_group( + 'user', 'membership', + array( 'sleep_ms' => 0, 'dry_run' => true ) + ); + + $this->assertTrue( $result['dry_run'] ); + $this->assertSame( 1, $result['migrated'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" ); + $this->assertSame( 0, $count, 'Dry run must not write to flat table' ); + } + + public function test_row_count_matches_seeded_users(): void { + $this->seed_usermeta( array( + array( 'user_id' => 10, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ), + array( 'user_id' => 11, 'meta_key' => 'membership_level', 'meta_value' => 'bronze' ), + array( 'user_id' => 12, 'meta_key' => 'points_balance', 'meta_value' => '50' ), + ) ); + + $result = WPDO_Entity_Migration_Engine::migrate_group( 'user', 'membership', array( 'sleep_ms' => 0 ) ); + + // user_id 10, 11 have membership_level; user_id 12 has points_balance. + $this->assertSame( 3, $result['migrated'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::MEM_TABLE . "`" ); + $this->assertSame( 3, $count ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function seed_usermeta( array $rows ): void { + global $wpdb; + foreach ( $rows as $row ) { + $wpdb->insert( self::USERMETA, $row ); + } + } + + private static function load_engine_classes(): void { + $base = WPDO_PLUGIN_DIR; + + $files = 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/adapters/class-tmdo-adapter-user.php', + 'includes/engine/class-tmdo-entity-migration-engine.php', + ); + + foreach ( $files as $file ) { + if ( ! class_exists( self::class_for_file( $file ) ) ) { + require_once $base . $file; + } + } + } + + private static function class_for_file( string $file ): string { + $map = array( + 'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface', + 'class-tmdo-type-caster.php' => 'WPDO_Type_Caster', + 'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager', + 'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry', + 'class-tmdo-adapter-user.php' => 'WPDO_Adapter_User', + 'class-tmdo-entity-migration-engine.php' => 'WPDO_Entity_Migration_Engine', + ); + return $map[ basename( $file ) ] ?? ''; + } + + private static function create_tables(): void { + global $wpdb; + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::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 DEFAULT NULL, + PRIMARY KEY (`umeta_id`), + KEY `user_id` (`user_id`), + KEY `meta_key` (`meta_key`(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` ( + `user_id` bigint(20) NOT NULL, + `membership_level` varchar(100) DEFAULT NULL, + `points_balance` bigint(20) DEFAULT 0, + `expires_at` datetime DEFAULT NULL, + `activated_at` datetime DEFAULT NULL, + `tier_source` varchar(255) DEFAULT NULL, + `custom_tier` varchar(255) DEFAULT NULL, + PRIMARY KEY (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::STATUS_TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `entity_type` varchar(50) NOT NULL, + `group_name` varchar(50) NOT NULL, + `last_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `total_migrated` bigint(20) 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 DEFAULT CHARSET=utf8mb4' + ); + } + + private static function register_user_entity(): void { + if ( ! class_exists( 'WPDO_Entity_Registry' ) ) { + return; + } + + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + + WPDO_Entity_Registry::register_group( + 'user', + 'membership', + array( + array( + 'key' => 'membership_level', + 'type' => 'enum', + 'searchable' => true, + 'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ), + ), + array( 'key' => 'points_balance', 'type' => 'integer', 'searchable' => true, 'default' => 0 ), + array( 'key' => 'expires_at', 'type' => 'datetime', 'searchable' => true ), + array( 'key' => 'activated_at', 'type' => 'datetime' ), + array( 'key' => 'tier_source', 'type' => 'text' ), + array( 'key' => 'custom_tier', 'type' => 'text' ), + ) + ); + } +} diff --git a/tests/integration/MigrationOrchestratorTest.php b/tests/integration/MigrationOrchestratorTest.php new file mode 100644 index 0000000..d58e5b0 --- /dev/null +++ b/tests/integration/MigrationOrchestratorTest.php @@ -0,0 +1,219 @@ +query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + + $wpdb->query( + 'CREATE TABLE `' . self::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 DEFAULT NULL, + PRIMARY KEY (`umeta_id`), + KEY `meta_key` (`meta_key`(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) NOT NULL, + `nickname` varchar(255) DEFAULT NULL, + `first_name` varchar(255) DEFAULT NULL, + `last_name` varchar(255) DEFAULT NULL, + `description` text 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_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::USERMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + } + + public function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE `' . self::USERMETA . '`' ); + $wpdb->query( 'TRUNCATE `' . self::FLAT . '`' ); + } + + // ── Tests ──────────────────────────────────────────────────────────────── + + public function test_bulk_pivot_produces_one_row_per_user(): void { + $this->seed_eav( array( + array( 'user_id' => 10, 'meta_key' => 'first_name', 'meta_value' => 'Alice' ), + array( 'user_id' => 10, 'meta_key' => 'last_name', 'meta_value' => 'Adams' ), + array( 'user_id' => 10, 'meta_key' => 'nickname', 'meta_value' => 'al' ), + array( 'user_id' => 11, 'meta_key' => 'first_name', 'meta_value' => 'Bob' ), + array( 'user_id' => 11, 'meta_key' => 'description', 'meta_value' => 'engineer' ), + ) ); + + $affected = $this->run_pivot(); + // MySQL returns 2*N for INSERT...ON DUPLICATE on conflict, N for new inserts. + // Two new users → both INSERTs → affected_rows == 2. + $this->assertSame( 2, $affected ); + + global $wpdb; + $row10 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=10', ARRAY_A ); + $row11 = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=11', ARRAY_A ); + + $this->assertSame( 'Alice', $row10['first_name'] ); + $this->assertSame( 'Adams', $row10['last_name'] ); + $this->assertSame( 'al', $row10['nickname'] ); + $this->assertNull( $row10['description'] ); + + $this->assertSame( 'Bob', $row11['first_name'] ); + $this->assertSame( 'engineer', $row11['description'] ); + $this->assertNull( $row11['last_name'] ); + } + + public function test_bulk_pivot_idempotent_re_run_preserves_data(): void { + $this->seed_eav( array( + array( 'user_id' => 20, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ), + ) ); + + $this->run_pivot(); + $this->run_pivot(); // Second run must not lose or double-count data. + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' ); + $this->assertSame( 1, $count, 'Re-run should not duplicate user_id row' ); + + $first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=20' ); + $this->assertSame( 'Carol', $first ); + } + + public function test_bulk_pivot_coalesce_preserves_existing_when_new_eav_subset(): void { + // Round 1: full data. + $this->seed_eav( array( + array( 'user_id' => 30, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ), + array( 'user_id' => 30, 'meta_key' => 'last_name', 'meta_value' => 'Diaz' ), + ) ); + $this->run_pivot(); + + // Round 2: only first_name remains in EAV (last_name was cleaned). + global $wpdb; + $wpdb->query( "DELETE FROM `" . self::USERMETA . "` WHERE meta_key='last_name'" ); + $this->run_pivot(); + + $row = $wpdb->get_row( 'SELECT * FROM `' . self::FLAT . '` WHERE user_id=30', ARRAY_A ); + // COALESCE(VALUES(last_name), last_name) → keeps 'Diaz' even though new VALUES is NULL. + $this->assertSame( 'Dora', $row['first_name'] ); + $this->assertSame( 'Diaz', $row['last_name'], 'COALESCE should preserve previously-migrated value when EAV is now empty' ); + } + + public function test_bulk_pivot_handles_empty_eav_gracefully(): void { + $affected = $this->run_pivot(); + $this->assertSame( 0, $affected ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' ); + $this->assertSame( 0, $count ); + } + + public function test_bulk_pivot_uses_max_for_duplicate_meta_keys(): void { + // HivePress occasionally writes duplicate meta_value rows for the same key. + $this->seed_eav( array( + array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'older_value' ), + array( 'user_id' => 40, 'meta_key' => 'first_name', 'meta_value' => 'newer_value' ), + ) ); + $this->run_pivot(); + + global $wpdb; + $first = $wpdb->get_var( 'SELECT first_name FROM `' . self::FLAT . '` WHERE user_id=40' ); + // MAX() picks lexicographically larger; for our purpose this just guarantees + // deterministic behavior — no NULL, no error. + $this->assertNotNull( $first ); + $this->assertContains( $first, array( 'older_value', 'newer_value' ) ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private function seed_eav( array $rows ): void { + global $wpdb; + foreach ( $rows as $row ) { + $wpdb->insert( self::USERMETA, $row ); + } + } + + /** + * Local mirror of WPDO_Migration_Orchestrator::execute_bulk_pivot() against + * isolated test tables. Builds the same SQL form but pointing at our test + * usermeta and flat tables (the orchestrator targets $wpdb->usermeta). + */ + private function run_pivot(): int { + global $wpdb; + + $keys = array( 'nickname', 'first_name', 'last_name', 'description' ); + $cols = $keys; + $ph = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $cases = array(); + $updates = array(); + foreach ( $cols as $col ) { + $cases[] = "MAX(CASE WHEN um.meta_key = '{$col}' THEN um.meta_value END) AS `{$col}`"; + $updates[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)"; + } + + $sql = sprintf( + 'INSERT INTO `%s` (`user_id`, %s) + SELECT um.user_id, %s + FROM `%s` um + WHERE um.meta_key IN (%s) + GROUP BY um.user_id + ON DUPLICATE KEY UPDATE %s', + self::FLAT, + implode( ', ', array_map( fn( $c ) => "`{$c}`", $cols ) ), + implode( ', ', $cases ), + self::USERMETA, + $ph, + implode( ', ', $updates ) + ); + + $result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) ); + return false === $result ? 0 : (int) $result; + } +} diff --git a/tests/integration/PointsAtomicIntegrationTest.php b/tests/integration/PointsAtomicIntegrationTest.php new file mode 100644 index 0000000..796a9c4 --- /dev/null +++ b/tests/integration/PointsAtomicIntegrationTest.php @@ -0,0 +1,261 @@ +query( + 'CREATE TABLE IF NOT EXISTS `' . self::MEM_TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) unsigned NOT NULL, + `points_balance` bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::LEDGER_TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) unsigned NOT NULL, + `delta` int(11) NOT NULL, + `balance_after` bigint(20) NOT NULL, + `reason` varchar(60) NOT NULL DEFAULT \'\', + `ref_id` bigint(20) DEFAULT NULL, + `ref_type` varchar(30) DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_created` (`user_id`,`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( + 'CREATE TABLE IF NOT EXISTS `' . self::ERRORS_TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `severity` varchar(10) NOT NULL DEFAULT \'error\', + `component` varchar(60) NOT NULL DEFAULT \'\', + `context` varchar(60) NOT NULL DEFAULT \'\', + `message` text NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::MEM_TABLE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::LEDGER_TABLE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::ERRORS_TABLE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::MEM_TABLE . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::LEDGER_TABLE . '`' ); + } + + // ── credit() happy path ───────────────────────────────────────────────── + + public function test_credit_creates_membership_row(): void { + $result = WPDO_Points_Manager::credit( 1, 100, 'signup_bonus' ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( 100, $result['balance'] ); + $this->assertGreaterThan( 0, $result['ledger_id'] ); + } + + public function test_credit_accumulates_balance(): void { + WPDO_Points_Manager::credit( 2, 200, 'first' ); + $result = WPDO_Points_Manager::credit( 2, 300, 'second' ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( 500, $result['balance'] ); + } + + public function test_credit_writes_ledger_row(): void { + global $wpdb; + WPDO_Points_Manager::credit( 3, 50, 'test_reason' ); + + $row = $wpdb->get_row( + "SELECT * FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 3", + ARRAY_A + ); + $this->assertNotNull( $row ); + $this->assertSame( '50', $row['delta'] ); + $this->assertSame( '50', $row['balance_after'] ); + $this->assertSame( 'test_reason', $row['reason'] ); + } + + public function test_get_balance_reflects_credits(): void { + WPDO_Points_Manager::credit( 4, 75, 'top_up' ); + + $balance = WPDO_Points_Manager::get_balance( 4 ); + $this->assertSame( 75, $balance ); + } + + // ── debit() happy path ────────────────────────────────────────────────── + + public function test_debit_after_credit_reduces_balance(): void { + WPDO_Points_Manager::credit( 5, 300, 'load' ); + $result = WPDO_Points_Manager::debit( 5, 100, 'purchase' ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( 200, $result['balance'] ); + } + + public function test_debit_writes_negative_delta_to_ledger(): void { + global $wpdb; + WPDO_Points_Manager::credit( 6, 200, 'load' ); + WPDO_Points_Manager::debit( 6, 50, 'spend' ); + + $rows = $wpdb->get_results( + "SELECT delta, balance_after FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 6 ORDER BY id", + ARRAY_A + ); + $this->assertCount( 2, $rows ); + $this->assertSame( '200', $rows[0]['delta'] ); // credit row. + $this->assertSame( '-50', $rows[1]['delta'] ); + $this->assertSame( '150', $rows[1]['balance_after'] ); + } + + // ── debit() insufficient balance ───────────────────────────────────────── + + public function test_debit_fails_when_insufficient(): void { + WPDO_Points_Manager::credit( 7, 50, 'load' ); + $result = WPDO_Points_Manager::debit( 7, 100, 'purchase' ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'insufficient_balance', $result['error'] ); + } + + public function test_debit_failure_does_not_write_ledger(): void { + global $wpdb; + WPDO_Points_Manager::credit( 8, 30, 'load' ); + WPDO_Points_Manager::debit( 8, 100, 'purchase' ); // should fail. + + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 8 AND delta < 0" + ); + $this->assertSame( 0, $count ); + } + + public function test_debit_failure_preserves_balance(): void { + WPDO_Points_Manager::credit( 9, 40, 'load' ); + WPDO_Points_Manager::debit( 9, 200, 'purchase' ); // fails. + + $balance = WPDO_Points_Manager::get_balance( 9 ); + $this->assertSame( 40, $balance ); + } + + // ── sequential double-spend scenario ──────────────────────────────────── + + /** + * Simulate the classic double-spend race: + * Balance = 100. Two requests each try to debit 80. + * With FOR UPDATE serialisation: first succeeds → balance = 20, + * second then reads balance = 20 and correctly rejects (insufficient). + */ + public function test_sequential_debit_only_first_succeeds(): void { + WPDO_Points_Manager::credit( 10, 100, 'load' ); + + $first = WPDO_Points_Manager::debit( 10, 80, 'spend_1' ); + $second = WPDO_Points_Manager::debit( 10, 80, 'spend_2' ); + + $this->assertTrue( $first['ok'], 'First debit should succeed' ); + $this->assertSame( 20, $first['balance'] ); + + $this->assertFalse( $second['ok'], 'Second debit should fail (insufficient)' ); + $this->assertSame( 'insufficient_balance', $second['error'] ); + + $this->assertSame( 20, WPDO_Points_Manager::get_balance( 10 ) ); + } + + public function test_sequential_debits_leave_correct_ledger_count(): void { + global $wpdb; + WPDO_Points_Manager::credit( 11, 200, 'load' ); + WPDO_Points_Manager::debit( 11, 150, 'spend_1' ); // succeeds: balance=50. + WPDO_Points_Manager::debit( 11, 150, 'spend_2' ); // fails: insufficient. + + $debit_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 11 AND delta < 0" + ); + $this->assertSame( 1, $debit_count, 'Only one successful debit should be in ledger' ); + } + + // ── allow_overdraft ────────────────────────────────────────────────────── + + public function test_overdraft_debit_goes_negative(): void { + WPDO_Points_Manager::credit( 12, 50, 'load' ); + $result = WPDO_Points_Manager::debit( 12, 200, 'force', 0, '', true ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( -150, $result['balance'] ); + } + + // ── ref_id / ref_type ──────────────────────────────────────────────────── + + public function test_credit_with_ref_id_and_type(): void { + global $wpdb; + WPDO_Points_Manager::credit( 13, 100, 'order_reward', 9999, 'order' ); + + $row = $wpdb->get_row( + "SELECT ref_id, ref_type FROM `" . self::LEDGER_TABLE . "` WHERE user_id = 13", + ARRAY_A + ); + $this->assertSame( '9999', $row['ref_id'] ); + $this->assertSame( 'order', $row['ref_type'] ); + } + + // ── get_ledger() ───────────────────────────────────────────────────────── + + public function test_get_ledger_returns_entries_newest_first(): void { + WPDO_Points_Manager::credit( 14, 100, 'a' ); + WPDO_Points_Manager::credit( 14, 200, 'b' ); + + $ledger = WPDO_Points_Manager::get_ledger( 14 ); + + $this->assertCount( 2, $ledger ); + // Newest-first: second credit (delta=200) should be first. + $this->assertSame( '200', $ledger[0]['delta'] ); + $this->assertSame( '100', $ledger[1]['delta'] ); + } + + public function test_get_ledger_respects_limit(): void { + for ( $i = 1; $i <= 5; $i++ ) { + WPDO_Points_Manager::credit( 15, 10, "entry_{$i}" ); + } + + $ledger = WPDO_Points_Manager::get_ledger( 15, 3 ); + $this->assertCount( 3, $ledger ); + } +} diff --git a/tests/integration/PostBackfillJsonTest.php b/tests/integration/PostBackfillJsonTest.php new file mode 100644 index 0000000..4a6bf39 --- /dev/null +++ b/tests/integration/PostBackfillJsonTest.php @@ -0,0 +1,224 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_type varchar(20) NOT NULL DEFAULT \'post\', + PRIMARY KEY (ID), + KEY post_type (post_type) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + _wp_attached_file varchar(255) DEFAULT NULL, + _wp_attachment_metadata longtext DEFAULT NULL, + _wp_attachment_image_alt longtext DEFAULT NULL, + _wp_attachment_caption 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_post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // migration_status table needed by Entity_Migration_Engine + // (schema mirrors MemberBackfillIntegrationTest fixture). + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' ); + $wpdb->query( + 'CREATE TABLE `wp_itest_wpdo_migration_status` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + entity_type varchar(50) NOT NULL, + group_name varchar(50) NOT NULL, + last_id bigint(20) unsigned NOT NULL DEFAULT 0, + total_migrated bigint(20) 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) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Post_Fields::register_entity_fields(); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_migration_status`' ); + WPDO_Entity_Registry::init(); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); + $wpdb->query( 'TRUNCATE TABLE `wp_itest_wpdo_migration_status`' ); + WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' ); + } + + private function seed_attachment( int $post_id, array $metadata, string $alt = '' ): void { + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'attachment' ) ); + // Real WP serializes _wp_attachment_metadata via PHP serialize(). + $wpdb->insert( self::POSTMETA, array( + 'post_id' => $post_id, + 'meta_key' => '_wp_attachment_metadata', + 'meta_value' => serialize( $metadata ), + ) ); + if ( '' !== $alt ) { + $wpdb->insert( self::POSTMETA, array( + 'post_id' => $post_id, + 'meta_key' => '_wp_attachment_image_alt', + 'meta_value' => $alt, + ) ); + } + } + + // ── backfill_group_json() ──────────────────────────────────────────────── + + public function test_unserializes_attachment_metadata_to_json(): void { + $this->seed_attachment( 1, array( + 'width' => 800, + 'height' => 600, + 'file' => '2026/04/test.jpg', + 'sizes' => array( + 'thumbnail' => array( 'width' => 150, 'height' => 150 ), + ), + ), 'Stress test alt' ); + + $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); + + $this->assertGreaterThan( 0, $result['migrated'] ?? 0 ); + $this->assertSame( 0, $result['errors'] ?? -1 ); + + global $wpdb; + $row = $wpdb->get_row( + 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', + ARRAY_A + ); + + $this->assertNotNull( $row ); + + // metadata value should now be JSON. + $decoded = json_decode( (string) $row['_wp_attachment_metadata'], true ); + $this->assertIsArray( $decoded ); + $this->assertSame( 800, $decoded['width'] ); + $this->assertSame( 'thumbnail', array_keys( $decoded['sizes'] )[0] ); + + // Non-json field still passes through. + $this->assertSame( 'Stress test alt', $row['_wp_attachment_image_alt'] ); + } + + public function test_idempotent_re_run(): void { + $this->seed_attachment( 1, array( 'width' => 100 ) ); + + WPDO_Post_Migration::backfill_group_json( 'attachment' ); + // reset checkpoint so the engine reprocesses the same row. + WPDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'attachment' ); + $result2 = WPDO_Post_Migration::backfill_group_json( 'attachment' ); + + $this->assertSame( 0, $result2['errors'] ?? -1 ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' ); + $this->assertSame( 1, $count, 'No duplicate row after re-run.' ); + } + + public function test_handles_already_serialized_string_safely(): void { + // Attacker-style: write an object signature into _wp_attachment_metadata + // (this is what safe_unserialize defends against). + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => 99, 'post_type' => 'attachment' ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 99, + 'meta_key' => '_wp_attachment_metadata', + 'meta_value' => 'O:8:"stdClass":0:{}', // Object string — should be NULL'd + ) ); + + $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); + + // Engine should NOT throw — safe_unserialize converts object to NULL. + $this->assertSame( 0, $result['errors'] ?? -1 ); + } + + public function test_handles_empty_postmeta_gracefully(): void { + $result = WPDO_Post_Migration::backfill_group_json( 'attachment' ); + + $this->assertSame( 0, $result['migrated'] ?? -1 ); + $this->assertSame( 0, $result['errors'] ?? -1 ); + } + + public function test_returns_error_for_unknown_group(): void { + $result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' ); + + // Engine returns error_result with 'error' key set. + $this->assertNotEmpty( $result['error'] ?? null ); + } +} diff --git a/tests/integration/PostBenchmarkTest.php b/tests/integration/PostBenchmarkTest.php new file mode 100644 index 0000000..98b13ab --- /dev/null +++ b/tests/integration/PostBenchmarkTest.php @@ -0,0 +1,159 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_type varchar(20) NOT NULL DEFAULT \'post\', + post_status varchar(20) NOT NULL DEFAULT \'publish\', + PRIMARY KEY (ID), + KEY post_type (post_type) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + _price decimal(18,6) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_post_id (post_id), + KEY idx__price (_price) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // Seed 50 products with _price = 10..59 in both tables. + for ( $i = 1; $i <= 50; $i++ ) { + $wpdb->insert( self::POSTS, array( 'ID' => $i, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $price = (string) ( 10 + $i ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => $i, 'meta_key' => '_price', 'meta_value' => $price ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => $i, '_price' => $price ) ); + } + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + } + + // ── benchmark_query() ──────────────────────────────────────────────────── + + public function test_benchmark_returns_expected_shape(): void { + $result = WPDO_Post_Migration::benchmark_query( + 'product', + '_price', + '>=', + '30', + self::FLAT, + 5 + ); + + $this->assertArrayHasKey( 'samples', $result ); + $this->assertArrayHasKey( 'postmeta_avg_ms', $result ); + $this->assertArrayHasKey( 'flat_avg_ms', $result ); + $this->assertArrayHasKey( 'speedup', $result ); + $this->assertArrayHasKey( 'postmeta_rows', $result ); + $this->assertArrayHasKey( 'flat_rows', $result ); + + $this->assertSame( 5, $result['samples'] ); + $this->assertGreaterThan( 0.0, $result['postmeta_avg_ms'] ); + $this->assertGreaterThan( 0.0, $result['flat_avg_ms'] ); + } + + public function test_benchmark_finds_same_rows_via_both_paths(): void { + // _price >= 30 should match products 20..50 (i.e. 31 rows). + $result = WPDO_Post_Migration::benchmark_query( + 'product', + '_price', + '>=', + '30', + self::FLAT, + 3 + ); + + // Both paths must return the same count — verifies router correctness. + $this->assertSame( $result['postmeta_rows'], $result['flat_rows'] ); + $this->assertGreaterThan( 0, $result['postmeta_rows'] ); + } + + public function test_benchmark_speedup_is_positive_number(): void { + $result = WPDO_Post_Migration::benchmark_query( + 'product', + '_price', + '=', + '25', + self::FLAT, + 3 + ); + + $this->assertIsFloat( $result['speedup'] ); + $this->assertGreaterThan( 0.0, $result['speedup'] ); + } + + public function test_benchmark_rejects_zero_samples(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Migration::benchmark_query( 'product', '_price', '=', '25', self::FLAT, 0 ); + } + + public function test_benchmark_rejects_invalid_compare(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Migration::benchmark_query( 'product', '_price', 'BOGUS', '25', self::FLAT, 3 ); + } + + public function test_benchmark_throws_when_flat_table_missing(): void { + $this->expectException( RuntimeException::class ); + WPDO_Post_Migration::benchmark_query( + 'product', + '_price', + '=', + '25', + 'wp_itest_nonexistent_flat', + 3 + ); + } +} diff --git a/tests/integration/PostEntityLifecycleTest.php b/tests/integration/PostEntityLifecycleTest.php new file mode 100644 index 0000000..345e89c --- /dev/null +++ b/tests/integration/PostEntityLifecycleTest.php @@ -0,0 +1,260 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title text NOT NULL, + post_type varchar(20) NOT NULL DEFAULT \'post\', + post_status varchar(20) NOT NULL DEFAULT \'publish\', + post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_author bigint(20) unsigned NOT NULL DEFAULT 0, + post_content longtext NOT NULL, + post_excerpt text NOT NULL, + comment_status varchar(20) NOT NULL DEFAULT \'open\', + ping_status varchar(20) NOT NULL DEFAULT \'open\', + post_password varchar(255) NOT NULL DEFAULT \'\', + post_name varchar(200) NOT NULL DEFAULT \'\', + to_ping text NOT NULL, + pinged text NOT NULL, + post_content_filtered longtext NOT NULL, + post_parent bigint(20) unsigned NOT NULL DEFAULT 0, + guid varchar(255) NOT NULL DEFAULT \'\', + menu_order int(11) NOT NULL DEFAULT 0, + post_mime_type varchar(100) NOT NULL DEFAULT \'\', + comment_count bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (ID), + KEY post_type (post_type), + KEY post_title (post_title(64)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + + // wc_product flat target with all 19 columns. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + _price decimal(18,6) DEFAULT NULL, + _regular_price decimal(18,6) DEFAULT NULL, + _sale_price decimal(18,6) DEFAULT NULL, + _stock bigint(20) DEFAULT NULL, + _stock_status varchar(100) DEFAULT NULL, + _sku varchar(255) DEFAULT NULL, + _manage_stock varchar(100) DEFAULT NULL, + _backorders varchar(100) DEFAULT NULL, + _sold_individually varchar(100) DEFAULT NULL, + _virtual varchar(100) DEFAULT NULL, + _downloadable varchar(100) DEFAULT NULL, + _tax_class varchar(255) DEFAULT NULL, + _tax_status varchar(100) DEFAULT NULL, + _download_limit bigint(20) DEFAULT NULL, + _download_expiry bigint(20) DEFAULT NULL, + _product_version varchar(255) DEFAULT NULL, + _wc_average_rating decimal(18,6) DEFAULT NULL, + _wc_review_count bigint(20) DEFAULT NULL, + total_sales bigint(20) 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_post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // Register post adapter + groups. + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Post_Fields::register_entity_fields(); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + + // Reset Mode_Manager + Entity_Registry to avoid leaking state. + $ref = new ReflectionClass( WPDO_Mode_Manager::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + WPDO_Entity_Registry::init(); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); + } + + private static function class_for( string $rel ): ?string { + $map = array( + 'interface-entity-adapter.php' => 'WPDO_Entity_Adapter_Interface', + 'class-tmdo-entity-registry.php' => 'WPDO_Entity_Registry', + 'class-tmdo-mode-manager.php' => 'WPDO_Mode_Manager', + 'class-tmdo-schema-manager.php' => 'WPDO_Schema_Manager', + 'class-tmdo-adapter-post.php' => 'WPDO_Adapter_Post', + 'class-tmdo-post-fields.php' => 'WPDO_Post_Fields', + 'class-tmdo-post-migration.php' => 'WPDO_Post_Migration', + 'class-tmdo-postmeta-cleaner.php' => 'WPDO_Postmeta_Cleaner', + 'class-tmdo-post-stress-tester.php' => 'WPDO_Post_Stress_Tester', + ); + foreach ( $map as $needle => $cls ) { + if ( str_contains( $rel, $needle ) ) { + return $cls; + } + } + return null; + } + + // ── End-to-end lifecycle ────────────────────────────────────────────────── + + /** + * Full lifecycle: stress create → cleanup garbage → backfill → diagnose + * → stress cleanup → final ratio assertion. + * + * This test is the contract net for v2.9.x phases working together. + */ + public function test_full_post_entity_lifecycle(): void { + global $wpdb; + + // Phase 1 (v2.9.4): seed 10 product posts via stress tester. + $create_result = WPDO_Post_Stress_Tester::create( 'product', 10 ); + $this->assertSame( 10, $create_result['created'] ); + $this->assertSame( 10, WPDO_Post_Stress_Tester::count_test_posts() ); + + // Add some garbage to validate v2.9.0 cleanup phase. + for ( $i = 0; $i < 5; $i++ ) { + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 1, + 'meta_key' => '_transient_test_' . $i, + 'meta_value' => 'x', + ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 1, + 'meta_key' => '_wp_old_date', + 'meta_value' => '2024-01-01', + ) ); + } + + // Phase 2 (v2.9.0): cleanup garbage. + $garbage_before = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 5, $garbage_before['transients'] ); + $this->assertSame( 5, $garbage_before['wp_old_date'] ); + + $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 10, $deleted['total'] ); + + $garbage_after = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 0, $garbage_after['total'], 'After delete, all garbage gone.' ); + + // Phase 3 (v2.9.3): diagnose post entity state. + $diag1 = WPDO_Post_Migration::diagnose(); + $this->assertSame( 10, $diag1['posts'] ); + $this->assertSame( 'disabled', $diag1['mode'] ); + $this->assertGreaterThan( 0, $diag1['groups']['wc_product']['eav_rows'], 'wc_product seeded keys present.' ); + $this->assertSame( 0, $diag1['groups']['wc_product']['flat_rows'], 'flat empty before backfill.' ); + + // Phase 4 (v2.9.3): backfill wc_product from postmeta to flat. + $backfill = WPDO_Post_Migration::backfill_group( 'wc_product' ); + $this->assertSame( 10, $backfill['migrated'], '10 products backfilled to flat.' ); + + // Phase 5: re-diagnose; flat_rows must equal post count for wc_product. + $diag2 = WPDO_Post_Migration::diagnose(); + $this->assertSame( 10, $diag2['groups']['wc_product']['flat_rows'] ); + + // Phase 6 (v2.9.4): stress cleanup removes everything. + $cleanup = WPDO_Post_Stress_Tester::cleanup(); + $this->assertSame( 10, $cleanup['deleted_posts'] ); + $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() ); + + // Final state: empty everywhere. + $diag3 = WPDO_Post_Migration::diagnose(); + $this->assertSame( 0, $diag3['posts'] ); + $this->assertSame( 0, $diag3['groups']['wc_product']['eav_rows'] ); + // Note: flat rows survive stress cleanup (ON DELETE CASCADE not configured + // in test fixture); production v2.9.4 cleanup() also sweeps flat tables. + $this->assertGreaterThanOrEqual( 0, $diag3['groups']['wc_product']['flat_rows'] ); + } + + /** + * Verifies that v2.9.0 cleanup + v2.9.3 backfill have zero overlap: + * cleanup keys (_transient_*, _wp_old_date, stale _edit_lock) must not + * collide with any v2.9.1 entity group's managed keys. + */ + public function test_cleanup_keys_never_overlap_managed_group_keys(): void { + $managed = WPDO_Post_Migration::get_managed_keys(); + + foreach ( $managed as $key ) { + $this->assertStringStartsNotWith( '_transient_', $key ); + $this->assertStringStartsNotWith( '_transient_timeout_', $key ); + $this->assertNotSame( '_wp_old_date', $key ); + $this->assertNotSame( '_edit_lock', $key ); + } + } +} diff --git a/tests/integration/PostLegacyCutoverTest.php b/tests/integration/PostLegacyCutoverTest.php new file mode 100644 index 0000000..d3d765a --- /dev/null +++ b/tests/integration/PostLegacyCutoverTest.php @@ -0,0 +1,239 @@ +` table + * to the new `wp_wpdo_post_` flat table: + * + * - copies common columns by name intersection + * - skips auto_increment id + updated_at columns (let flat manage them) + * - idempotent (re-run doesn't duplicate; ON DUPLICATE KEY UPDATE) + * - leaves the legacy table untouched (safety net for v3.0.0 DROP) + * - verify_legacy_cutover() reports row count + sample mismatches + */ +class PostLegacyCutoverTest extends TestCase { + + private const HOT_TABLE = 'wp_itest_wpdo_hot_hp_listing'; + private const FLAT_TABLE = 'wp_itest_wpdo_post_hp_listing_core'; + + public static function setUpBeforeClass(): void { + global $wpdb; + + if ( ! class_exists( 'WPDO_Post_Migration' ) ) { + require_once WPDO_PLUGIN_DIR . 'includes/migration/class-tmdo-post-migration.php'; + } + + // Legacy hot table — narrower schema, hp_booking_enabled is hot-only. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::HOT_TABLE . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL DEFAULT 0, + hp_price decimal(10,2) NOT NULL DEFAULT 0.00, + hp_featured tinyint(1) NOT NULL DEFAULT 0, + hp_verified tinyint(1) NOT NULL DEFAULT 0, + hp_expired_time bigint(20) NOT NULL DEFAULT 0, + hp_featured_time bigint(20) NOT NULL DEFAULT 0, + updated_at datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + hp_booking_enabled tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // Flat target — wider schema, includes hp_status/hp_vendor not in hot. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT_TABLE . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + hp_price decimal(18,6) DEFAULT NULL, + hp_status varchar(100) DEFAULT NULL, + hp_featured bigint(20) DEFAULT NULL, + hp_verified bigint(20) DEFAULT NULL, + hp_vendor bigint(20) DEFAULT NULL, + hp_expired_time bigint(20) DEFAULT NULL, + hp_featured_time bigint(20) DEFAULT NULL, + hp_view_count bigint(20) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::HOT_TABLE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT_TABLE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::HOT_TABLE . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT_TABLE . '`' ); + } + + private function seed_hot( int $post_id, array $cols ): void { + global $wpdb; + $wpdb->insert( self::HOT_TABLE, array_merge( array( 'post_id' => $post_id ), $cols ) ); + } + + // ── copy_legacy_hot_table() ────────────────────────────────────────────── + + public function test_copy_legacy_hot_table_copies_all_rows(): void { + // Seed 5 rows. + for ( $i = 1; $i <= 5; $i++ ) { + $this->seed_hot( 100 + $i, array( + 'hp_price' => 50.00 + $i, + 'hp_featured' => $i % 2, + 'hp_verified' => 1, + 'hp_expired_time' => 9999999 + $i, + 'hp_featured_time' => 0, + ) ); + } + + $result = WPDO_Post_Migration::copy_legacy_hot_table( + 'hp_listing', + self::HOT_TABLE, + self::FLAT_TABLE + ); + + $this->assertSame( 5, $result['copied'] ); + + global $wpdb; + $flat_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' ); + $this->assertSame( 5, $flat_count ); + + // Verify a row's data round-trips. + $row = $wpdb->get_row( 'SELECT hp_price, hp_featured, hp_verified, hp_expired_time FROM `' . self::FLAT_TABLE . '` WHERE post_id = 103', ARRAY_A ); + $this->assertSame( '53.000000', $row['hp_price'] ); + $this->assertSame( '1', $row['hp_featured'] ); // 3 % 2 = 1 + $this->assertSame( '1', $row['hp_verified'] ); + $this->assertSame( '10000002', $row['hp_expired_time'] ); + } + + public function test_copy_skips_id_and_updated_at_columns(): void { + $this->seed_hot( 200, array( + 'hp_price' => 99.99, + 'hp_featured' => 0, + 'hp_verified' => 1, + 'hp_expired_time' => 0, + 'hp_featured_time' => 0, + ) ); + + WPDO_Post_Migration::copy_legacy_hot_table( + 'hp_listing', + self::HOT_TABLE, + self::FLAT_TABLE + ); + + global $wpdb; + // Flat row's id should be auto-assigned (not the hot row's id). + // Flat row's updated_at should be CURRENT_TIMESTAMP (not 0000-00-00). + $row = $wpdb->get_row( 'SELECT id, updated_at FROM `' . self::FLAT_TABLE . '` WHERE post_id = 200', ARRAY_A ); + $this->assertNotEmpty( $row['updated_at'] ); + $this->assertNotEquals( '0000-00-00 00:00:00', $row['updated_at'] ); + } + + public function test_copy_legacy_hot_table_is_idempotent(): void { + $this->seed_hot( 300, array( + 'hp_price' => 10.00, + 'hp_featured' => 0, + 'hp_verified' => 1, + 'hp_expired_time' => 0, + 'hp_featured_time' => 0, + ) ); + + WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE ); + WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '`' ); + $this->assertSame( 1, $count, 'Re-running copy is idempotent — UPSERT, no duplicate.' ); + } + + public function test_copy_does_not_modify_legacy_hot_table(): void { + $this->seed_hot( 400, array( + 'hp_price' => 25.00, + 'hp_featured' => 0, + 'hp_verified' => 1, + 'hp_expired_time' => 0, + 'hp_featured_time' => 0, + ) ); + + global $wpdb; + $before = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A ); + + WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE ); + + $after = $wpdb->get_results( 'SELECT * FROM `' . self::HOT_TABLE . '` ORDER BY id', ARRAY_A ); + $this->assertEquals( $before, $after, 'Legacy hot table must remain untouched (safety net for v3.0.0).' ); + } + + public function test_copy_returns_zero_when_hot_table_empty(): void { + $result = WPDO_Post_Migration::copy_legacy_hot_table( + 'hp_listing', + self::HOT_TABLE, + self::FLAT_TABLE + ); + $this->assertSame( 0, $result['copied'] ); + } + + public function test_copy_throws_when_hot_table_missing(): void { + $this->expectException( RuntimeException::class ); + WPDO_Post_Migration::copy_legacy_hot_table( + 'hp_listing', + 'wp_itest_nonexistent_hot', + self::FLAT_TABLE + ); + } + + // ── verify_legacy_cutover() ────────────────────────────────────────────── + + public function test_verify_reports_match_when_counts_equal(): void { + for ( $i = 1; $i <= 3; $i++ ) { + $this->seed_hot( 500 + $i, array( + 'hp_price' => $i * 10, + 'hp_featured' => 0, + 'hp_verified' => 1, + 'hp_expired_time' => 0, + 'hp_featured_time' => 0, + ) ); + } + + WPDO_Post_Migration::copy_legacy_hot_table( 'hp_listing', self::HOT_TABLE, self::FLAT_TABLE ); + + $verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE ); + + $this->assertSame( 3, $verify['hot_rows'] ); + $this->assertSame( 3, $verify['flat_rows'] ); + $this->assertSame( 0, $verify['mismatched_rows'] ); + $this->assertTrue( $verify['ok'] ); + } + + public function test_verify_reports_mismatch_when_flat_lags_hot(): void { + // Seed hot with 3 rows. + for ( $i = 1; $i <= 3; $i++ ) { + $this->seed_hot( 600 + $i, array( + 'hp_price' => $i * 10, + 'hp_featured' => 0, + 'hp_verified' => 1, + 'hp_expired_time' => 0, + 'hp_featured_time' => 0, + ) ); + } + // Don't run copy — flat stays empty. + + $verify = WPDO_Post_Migration::verify_legacy_cutover( self::HOT_TABLE, self::FLAT_TABLE ); + + $this->assertSame( 3, $verify['hot_rows'] ); + $this->assertSame( 0, $verify['flat_rows'] ); + $this->assertFalse( $verify['ok'] ); + } +} diff --git a/tests/integration/PostMigrationTest.php b/tests/integration/PostMigrationTest.php new file mode 100644 index 0000000..f10edef --- /dev/null +++ b/tests/integration/PostMigrationTest.php @@ -0,0 +1,240 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_type varchar(20) NOT NULL DEFAULT \'post\', + PRIMARY KEY (ID), + KEY post_type (post_type) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // wp_postmeta — same schema as production WP. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + + // Flat target for wc_product group (subset of full schema for test). + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + _price decimal(18,6) DEFAULT NULL, + _regular_price decimal(18,6) DEFAULT NULL, + _stock bigint(20) DEFAULT NULL, + _stock_status varchar(100) DEFAULT NULL, + _sku 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_post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + // Reset Entity Registry + register post adapter + post fields. + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Post_Fields::register_entity_fields(); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + + // Reset Mode_Manager + Entity_Registry to avoid polluting later tests. + $ref = new ReflectionClass( WPDO_Mode_Manager::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + WPDO_Entity_Registry::init(); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); + } + + private function seed_post( int $id, string $post_type ): void { + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => $id, 'post_type' => $post_type ) ); + } + + private function seed_meta( int $post_id, string $key, string $value ): void { + global $wpdb; + $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => $value ) ); + } + + // ── diagnose() ──────────────────────────────────────────────────────────── + + public function test_diagnose_reports_posts_postmeta_ratio(): void { + // 5 posts, 12 postmeta rows → ratio 2.4 + for ( $i = 1; $i <= 5; $i++ ) { + $this->seed_post( $i, 'post' ); + } + for ( $i = 0; $i < 12; $i++ ) { + $this->seed_meta( ( $i % 5 ) + 1, 'random_key', 'v' ); + } + + $result = WPDO_Post_Migration::diagnose(); + + $this->assertSame( 5, $result['posts'] ); + $this->assertSame( 12, $result['postmeta'] ); + $this->assertSame( 2.4, $result['ratio'] ); + } + + public function test_diagnose_reports_eav_rows_per_managed_group(): void { + $this->seed_post( 1, 'product' ); + $this->seed_meta( 1, '_price', '99.99' ); + $this->seed_meta( 1, '_stock', '5' ); + $this->seed_meta( 1, 'unrelated_key', 'x' ); // not in any group + + $result = WPDO_Post_Migration::diagnose(); + + $this->assertArrayHasKey( 'groups', $result ); + $this->assertArrayHasKey( 'wc_product', $result['groups'] ); + $this->assertSame( + 2, + $result['groups']['wc_product']['eav_rows'], + 'wc_product group has 2 EAV rows: _price + _stock (unrelated_key excluded).' + ); + } + + public function test_diagnose_reports_zero_eav_for_unused_group(): void { + $this->seed_post( 1, 'post' ); + $this->seed_meta( 1, 'random_key', 'v' ); + + $result = WPDO_Post_Migration::diagnose(); + + // nav_menu_item group has no postmeta seeded. + $this->assertSame( 0, $result['groups']['nav_menu_item']['eav_rows'] ); + } + + public function test_diagnose_reports_post_mode(): void { + $result = WPDO_Post_Migration::diagnose(); + + $this->assertArrayHasKey( 'mode', $result ); + // Default post mode is 'disabled' per Mode_Manager defaults(). + $this->assertSame( 'disabled', $result['mode'] ); + } + + // ── backfill_group() — bulk SQL pivot ───────────────────────────────────── + + public function test_backfill_group_pivots_wc_product_keys(): void { + $this->seed_post( 1, 'product' ); + $this->seed_meta( 1, '_price', '99.99' ); + $this->seed_meta( 1, '_regular_price', '120.00' ); + $this->seed_meta( 1, '_stock', '5' ); + $this->seed_meta( 1, '_stock_status', 'instock' ); + $this->seed_meta( 1, '_sku', 'SKU-001' ); + + $this->seed_post( 2, 'product' ); + $this->seed_meta( 2, '_price', '49.50' ); + $this->seed_meta( 2, '_stock_status', 'outofstock' ); + + $result = WPDO_Post_Migration::backfill_group( 'wc_product' ); + + $this->assertSame( 2, $result['migrated'], 'Two posts produce two flat rows.' ); + + global $wpdb; + $row1 = $wpdb->get_row( 'SELECT _price, _stock, _sku FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A ); + $this->assertSame( '99.990000', $row1['_price'], 'Price decimal stored with full precision.' ); + $this->assertSame( '5', $row1['_stock'] ); + $this->assertSame( 'SKU-001', $row1['_sku'] ); + + $row2 = $wpdb->get_row( 'SELECT _price, _stock_status, _sku FROM `' . self::FLAT . '` WHERE post_id = 2', ARRAY_A ); + $this->assertSame( '49.500000', $row2['_price'] ); + $this->assertSame( 'outofstock', $row2['_stock_status'] ); + $this->assertNull( $row2['_sku'], 'Unset key remains NULL in flat row.' ); + } + + public function test_backfill_group_is_idempotent(): void { + $this->seed_post( 1, 'product' ); + $this->seed_meta( 1, '_price', '50.00' ); + + WPDO_Post_Migration::backfill_group( 'wc_product' ); + $result_second = WPDO_Post_Migration::backfill_group( 'wc_product' ); + + $this->assertSame( 1, $result_second['migrated'], 'Re-running backfill is idempotent (UPSERT).' ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT . '`' ); + $this->assertSame( 1, $count, 'No duplicate rows after re-run.' ); + } + + public function test_backfill_group_skips_posts_of_wrong_type(): void { + // _price on a non-product post should NOT migrate to wc_product flat. + $this->seed_post( 99, 'post' ); + $this->seed_meta( 99, '_price', '100.00' ); + + $result = WPDO_Post_Migration::backfill_group( 'wc_product' ); + + $this->assertSame( 0, $result['migrated'], 'Posts of wrong type are excluded by post_type filter.' ); + } + + public function test_backfill_group_rejects_invalid_group(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Migration::backfill_group( 'bogus_group' ); + } +} diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php new file mode 100644 index 0000000..762c2ef --- /dev/null +++ b/tests/integration/PostQueryRouterTest.php @@ -0,0 +1,245 @@ +getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + WPDO_Entity_Registry::init(); + } + + private function set_post_mode( string $mode ): void { + $ref = new ReflectionClass( WPDO_Mode_Manager::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array( + 'post' => $mode, + 'user' => 'aeav_only', + 'term' => 'dual_write', + 'comment' => 'dual_write', + ) ); + } + + private function make_query( array $vars ): WP_Query { + $q = new WP_Query(); + foreach ( $vars as $k => $v ) { + $q->set( $k, $v ); + } + return $q; + } + + // ── pre_get_posts gate (mode-aware) ─────────────────────────────────────── + + public function test_pass_through_when_mode_disabled(): void { + $this->set_post_mode( 'disabled' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + ), + ) ); + $original = $query->get( 'meta_query' ); + + $router->pre_get_posts( $query ); + + $this->assertSame( + $original, + $query->get( 'meta_query' ), + 'mode=disabled: meta_query must be untouched.' + ); + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_pass_through_when_mode_dual_write(): void { + $this->set_post_mode( 'dual_write' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + ), + ) ); + $original = $query->get( 'meta_query' ); + + $router->pre_get_posts( $query ); + + $this->assertSame( + $original, + $query->get( 'meta_query' ), + 'mode=dual_write: wp_postmeta is still source-of-truth, no rewrite.' + ); + } + + // ── pre_get_posts rewrite (mode=aeav_only) ─────────────────────────────── + + public function test_rewrites_meta_query_when_mode_aeav_only(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + ), + ) ); + + $router->pre_get_posts( $query ); + + // Original meta_query stripped of registered keys. + $remaining = $query->get( 'meta_query' ); + $this->assertEmpty( + $remaining, + 'aeav_only: registered keys removed from meta_query.' + ); + + // Routed clauses captured under wpdo_post_clauses query var. + $routed = $query->get( 'wpdo_post_clauses' ); + $this->assertIsArray( $routed ); + $this->assertNotEmpty( $routed ); + } + + public function test_keeps_unmanaged_keys_in_meta_query(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => 'unmanaged_attr', 'value' => 'x' ), + ), + ) ); + + $router->pre_get_posts( $query ); + + $remaining = $query->get( 'meta_query' ); + $this->assertCount( 1, $remaining ); + // Original index preserved (k=1 since k=0 was the routed _price clause). + $first_clause = reset( $remaining ); + $this->assertSame( + 'unmanaged_attr', + $first_clause['key'], + 'Unmanaged key remains in meta_query (Hook Bus pass-through).' + ); + } + + public function test_skips_admin_requests(): void { + $this->set_post_mode( 'aeav_only' ); + + $prev_admin = $GLOBALS['_wp_is_admin'] ?? false; + $GLOBALS['_wp_is_admin'] = true; + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + ), + ) ); + $original = $query->get( 'meta_query' ); + + $router->pre_get_posts( $query ); + + $this->assertSame( + $original, + $query->get( 'meta_query' ), + 'is_admin requests should not be rewritten.' + ); + + $GLOBALS['_wp_is_admin'] = $prev_admin; + } + + // ── posts_join / posts_where (SQL emission) ────────────────────────────── + + public function test_posts_join_emits_left_join_for_each_routed_post_type(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50' ), + ), + 'wpdo_post_clauses' => array(), + ) ); + + $router->pre_get_posts( $query ); + $join = $router->posts_join( '', $query ); + + $this->assertStringContainsString( 'LEFT JOIN', $join ); + $this->assertStringContainsString( 'wpdo_post_wc_product', $join ); + } + + public function test_posts_where_appends_condition_for_routed_clause(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '99', 'compare' => '=' ), + ), + ) ); + + $router->pre_get_posts( $query ); + $where = $router->posts_where( '', $query ); + + $this->assertStringContainsString( '`_price`', $where ); + $this->assertStringContainsString( "'99'", $where ); + } + + public function test_pass_through_when_no_meta_query(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( 'post_type' => 'product' ) ); + + $router->pre_get_posts( $query ); + + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) ); + } +} diff --git a/tests/integration/PostShadowVerifierTest.php b/tests/integration/PostShadowVerifierTest.php new file mode 100644 index 0000000..8767b10 --- /dev/null +++ b/tests/integration/PostShadowVerifierTest.php @@ -0,0 +1,330 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_type varchar(20) NOT NULL DEFAULT \'post\', + post_status varchar(20) NOT NULL DEFAULT \'publish\', + PRIMARY KEY (ID), + KEY post_type (post_type) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::FLAT . '` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + _price decimal(18,6) DEFAULT NULL, + _stock_status varchar(100) DEFAULT NULL, + _sku longtext DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_post_id (post_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Post_Fields::register_entity_fields(); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::FLAT . '`' ); + WPDO_Entity_Registry::init(); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::FLAT . '`' ); + } + + private function seed_consistent( int $post_id, string $price, string $stock ): void { + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $price ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_stock_status', 'meta_value' => $stock ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $price, '_stock_status' => $stock ) ); + } + + private function seed_diverged( int $post_id, string $pm_price, string $flat_price ): void { + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => $post_id, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => $post_id, 'meta_key' => '_price', 'meta_value' => $pm_price ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => $post_id, '_price' => $flat_price ) ); + } + + // ── sample_compare() ───────────────────────────────────────────────────── + + public function test_returns_expected_shape(): void { + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price', '_stock_status' ), + 5 + ); + + $this->assertArrayHasKey( 'sampled', $result ); + $this->assertArrayHasKey( 'matched', $result ); + $this->assertArrayHasKey( 'diffs', $result ); + $this->assertArrayHasKey( 'missing_flat', $result ); + $this->assertArrayHasKey( 'missing_postmeta', $result ); + } + + public function test_all_match_when_data_is_consistent(): void { + for ( $i = 1; $i <= 5; $i++ ) { + $this->seed_consistent( $i, '50.00', 'instock' ); + } + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 5 + ); + + $this->assertSame( 5, $result['sampled'] ); + $this->assertSame( 5, $result['matched'] ); + $this->assertSame( 0, $result['diffs'] ); + $this->assertSame( 0, $result['missing_flat'] ); + } + + public function test_detects_divergence_between_flat_and_postmeta(): void { + // Two diverged: pm has 50, flat has 60. + $this->seed_diverged( 1, '50', '60' ); + $this->seed_diverged( 2, '99', '88' ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 5 + ); + + $this->assertSame( 2, $result['sampled'] ); + $this->assertSame( 0, $result['matched'] ); + $this->assertSame( 2, $result['diffs'] ); + } + + public function test_counts_missing_flat_when_flat_row_absent(): void { + // Post + postmeta exist, but no flat row. + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => 100, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 100, 'meta_key' => '_price', 'meta_value' => '99' ) ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 5 + ); + + $this->assertSame( 1, $result['sampled'] ); + $this->assertSame( 1, $result['missing_flat'] ); + } + + public function test_counts_missing_postmeta_when_pm_absent(): void { + // Post + flat exist, but no postmeta. + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => 200, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => 200, '_price' => '50' ) ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 5 + ); + + $this->assertSame( 1, $result['sampled'] ); + $this->assertSame( 1, $result['missing_postmeta'] ); + } + + public function test_returns_zero_when_no_posts_of_type(): void { + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 5 + ); + + $this->assertSame( 0, $result['sampled'] ); + $this->assertSame( 0, $result['matched'] ); + } + + public function test_caps_sample_at_available_post_count(): void { + // 3 posts, ask for 10 samples → only 3 sampled. + $this->seed_consistent( 1, '10', 'instock' ); + $this->seed_consistent( 2, '20', 'instock' ); + $this->seed_consistent( 3, '30', 'instock' ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_price' ), + 10 + ); + + $this->assertSame( 3, $result['sampled'] ); + $this->assertSame( 3, $result['matched'] ); + } + + public function test_rejects_zero_sample_size(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array( '_price' ), 0 ); + } + + public function test_rejects_empty_keys(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Shadow_Verifier::sample_compare( 'product', 'wc_product', self::FLAT, array(), 5 ); + } + + // ── v2.10.5: serialize vs JSON loose equality ──────────────────────────── + + public function test_treats_serialized_array_equal_to_json_array(): void { + // pm side has serialized array; flat side has JSON for the same data. + // post_id=10, key=_stock_status (we reuse this column to inject test values). + // Use a dedicated key for clarity by re-purposing the keys array. + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => 10, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 10, + 'meta_key' => '_sku', + 'meta_value' => serialize( array( 'a', 'b', 'c' ) ), + ) ); + $wpdb->insert( self::FLAT, array( + 'post_id' => 10, + '_sku' => wp_json_encode( array( 'a', 'b', 'c' ) ), + ) ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_sku' ), + 5 + ); + + $this->assertSame( + 1, + $result['matched'], + 'serialized array vs JSON-encoded same array should match.' + ); + $this->assertSame( 0, $result['diffs'], 'No false-positive diff.' ); + } + + public function test_treats_serialized_assoc_equal_to_json_assoc(): void { + global $wpdb; + $assoc = array( 'width' => 100, 'height' => 200 ); + $wpdb->insert( self::POSTS, array( 'ID' => 11, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 11, + 'meta_key' => '_sku', + 'meta_value' => serialize( $assoc ), + ) ); + $wpdb->insert( self::FLAT, array( + 'post_id' => 11, + '_sku' => wp_json_encode( $assoc ), + ) ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_sku' ), + 5 + ); + + $this->assertSame( 1, $result['matched'] ); + $this->assertSame( 0, $result['diffs'] ); + } + + public function test_genuine_diff_still_detected_after_loose_equal_widening(): void { + // Real divergence — must still be flagged even with loosened compare. + global $wpdb; + $wpdb->insert( self::POSTS, array( 'ID' => 12, 'post_type' => 'product', 'post_status' => 'publish' ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 12, + 'meta_key' => '_sku', + 'meta_value' => serialize( array( 1, 2, 3 ) ), + ) ); + $wpdb->insert( self::FLAT, array( + 'post_id' => 12, + '_sku' => wp_json_encode( array( 9, 9, 9 ) ), + ) ); + + $result = WPDO_Post_Shadow_Verifier::sample_compare( + 'product', + 'wc_product', + self::FLAT, + array( '_sku' ), + 5 + ); + + $this->assertSame( 0, $result['matched'] ); + $this->assertSame( 1, $result['diffs'] ); + } +} diff --git a/tests/integration/PostStressTesterTest.php b/tests/integration/PostStressTesterTest.php new file mode 100644 index 0000000..79deb65 --- /dev/null +++ b/tests/integration/PostStressTesterTest.php @@ -0,0 +1,476 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::POSTS . '` ( + ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title text NOT NULL, + post_type varchar(20) NOT NULL DEFAULT \'post\', + post_status varchar(20) NOT NULL DEFAULT \'publish\', + post_date datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_date_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_modified_gmt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + post_author bigint(20) unsigned NOT NULL DEFAULT 0, + post_content longtext NOT NULL, + post_excerpt text NOT NULL, + comment_status varchar(20) NOT NULL DEFAULT \'open\', + ping_status varchar(20) NOT NULL DEFAULT \'open\', + post_password varchar(255) NOT NULL DEFAULT \'\', + post_name varchar(200) NOT NULL DEFAULT \'\', + to_ping text NOT NULL, + pinged text NOT NULL, + post_content_filtered longtext NOT NULL, + post_parent bigint(20) unsigned NOT NULL DEFAULT 0, + guid varchar(255) NOT NULL DEFAULT \'\', + menu_order int(11) NOT NULL DEFAULT 0, + post_mime_type varchar(100) NOT NULL DEFAULT \'\', + comment_count bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (ID), + KEY post_type (post_type), + KEY post_title (post_title(64)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTS . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + } + + // ── create() ────────────────────────────────────────────────────────────── + + public function test_create_inserts_requested_count_of_products(): void { + $result = WPDO_Post_Stress_Tester::create( 'product', 5 ); + + $this->assertSame( 5, $result['created'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'" + ); + $this->assertSame( 5, $count ); + } + + public function test_create_uses_stress_test_prefix_in_post_title(): void { + WPDO_Post_Stress_Tester::create( 'product', 3 ); + + global $wpdb; + $prefix_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s", + WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%' + ) + ); + $this->assertSame( 3, $prefix_count ); + } + + public function test_create_seeds_postmeta_for_each_post(): void { + WPDO_Post_Stress_Tester::create( 'product', 2 ); + + global $wpdb; + // Each test product should have at least the 5 critical wc_product keys. + $meta_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" + ); + $this->assertGreaterThanOrEqual( 10, $meta_count, 'At least 5 keys × 2 posts = 10 rows.' ); + + // Verify _price was set on every test product. + $price_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'" + ); + $this->assertSame( 2, $price_count ); + } + + public function test_create_supports_hp_listing_post_type(): void { + $result = WPDO_Post_Stress_Tester::create( 'hp_listing', 4 ); + $this->assertSame( 4, $result['created'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'hp_listing'" + ); + $this->assertSame( 4, $count ); + + // hp_listing seeds hp_price, not _price. + $hp_price_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'" + ); + $this->assertSame( 4, $hp_price_count ); + } + + public function test_create_rejects_unsupported_post_type(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Stress_Tester::create( 'bogus_type', 3 ); + } + + public function test_create_rejects_zero_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Stress_Tester::create( 'product', 0 ); + } + + public function test_create_rejects_excessive_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Stress_Tester::create( 'product', 100001 ); + } + + // ── count_test_posts() ──────────────────────────────────────────────────── + + public function test_count_test_posts_returns_zero_for_empty(): void { + $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() ); + } + + public function test_count_test_posts_counts_only_stress_prefix(): void { + // Seed 2 stress posts + 1 real post. + WPDO_Post_Stress_Tester::create( 'product', 2 ); + global $wpdb; + $wpdb->insert( self::POSTS, array( + 'ID' => 9999, + 'post_title' => 'Real product not from stress', + 'post_type' => 'product', + 'post_content' => '', + 'post_excerpt' => '', + 'post_content_filtered' => '', + 'to_ping' => '', + 'pinged' => '', + ) ); + + $this->assertSame( 2, WPDO_Post_Stress_Tester::count_test_posts() ); + } + + // ── cleanup() ───────────────────────────────────────────────────────────── + + public function test_cleanup_removes_all_stress_posts_and_their_meta(): void { + WPDO_Post_Stress_Tester::create( 'product', 5 ); + + // Verify pre-state. + $this->assertSame( 5, WPDO_Post_Stress_Tester::count_test_posts() ); + + $result = WPDO_Post_Stress_Tester::cleanup(); + $this->assertSame( 5, $result['deleted_posts'] ); + + global $wpdb; + $post_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" ); + $meta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" ); + $this->assertSame( 0, $post_count ); + $this->assertSame( 0, $meta_count, 'cleanup() must cascade delete postmeta.' ); + } + + public function test_cleanup_preserves_non_stress_posts(): void { + // Real post with prefix-collision-immune title. + global $wpdb; + $wpdb->insert( self::POSTS, array( + 'ID' => 9999, + 'post_title' => 'Real product not from stress', + 'post_type' => 'product', + 'post_content' => '', + 'post_excerpt' => '', + 'post_content_filtered' => '', + 'to_ping' => '', + 'pinged' => '', + ) ); + $wpdb->insert( self::POSTMETA, array( + 'post_id' => 9999, + 'meta_key' => '_price', + 'meta_value' => '50.00', + ) ); + + WPDO_Post_Stress_Tester::create( 'product', 3 ); + + $result = WPDO_Post_Stress_Tester::cleanup(); + $this->assertSame( 3, $result['deleted_posts'] ); + + // Real post + its meta survive. + $remaining_posts = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTS . "`" ); + $remaining_meta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::POSTMETA . "`" ); + $this->assertSame( 1, $remaining_posts ); + $this->assertSame( 1, $remaining_meta ); + } + + public function test_cleanup_idempotent_on_empty_state(): void { + $result1 = WPDO_Post_Stress_Tester::cleanup(); + $result2 = WPDO_Post_Stress_Tester::cleanup(); + + $this->assertSame( 0, $result1['deleted_posts'] ); + $this->assertSame( 0, $result2['deleted_posts'] ); + } + + // ── v2.11.2: create_realistic() — uses wp_insert_post + update_post_meta ─ + + public function test_create_realistic_uses_wp_insert_post(): void { + $result = WPDO_Post_Stress_Tester::create_realistic( 'product', 3 ); + + $this->assertSame( 3, $result['created'] ); + $this->assertSame( 'realistic', $result['mode'] ?? '' ); + + global $wpdb; + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_type = 'product'" + ); + $this->assertSame( 3, $count ); + } + + public function test_create_realistic_writes_postmeta_via_update_post_meta(): void { + $result = WPDO_Post_Stress_Tester::create_realistic( 'product', 2 ); + + $this->assertSame( 2, $result['created'] ); + + global $wpdb; + $price_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = '_price'" + ); + $this->assertSame( 2, $price_count, 'Each realistic product seeds _price.' ); + } + + public function test_create_realistic_uses_stress_test_prefix(): void { + WPDO_Post_Stress_Tester::create_realistic( 'product', 2 ); + + global $wpdb; + $prefixed = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `" . self::POSTS . "` WHERE post_title LIKE %s", + WPDO_Post_Stress_Tester::TEST_POST_PREFIX . '%' + ) + ); + $this->assertSame( 2, $prefixed ); + } + + public function test_create_realistic_supports_hp_listing(): void { + $result = WPDO_Post_Stress_Tester::create_realistic( 'hp_listing', 4 ); + + $this->assertSame( 4, $result['created'] ); + + global $wpdb; + $hp_price_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM `" . self::POSTMETA . "` WHERE meta_key = 'hp_price'" + ); + $this->assertSame( 4, $hp_price_count ); + } + + public function test_create_realistic_rejects_unsupported_post_type(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Stress_Tester::create_realistic( 'bogus_type', 2 ); + } + + public function test_create_realistic_rejects_invalid_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Stress_Tester::create_realistic( 'product', 0 ); + } + + public function test_cleanup_removes_realistic_created_posts(): void { + WPDO_Post_Stress_Tester::create_realistic( 'product', 3 ); + $this->assertSame( 3, WPDO_Post_Stress_Tester::count_test_posts() ); + + $cleanup = WPDO_Post_Stress_Tester::cleanup(); + $this->assertSame( 3, $cleanup['deleted_posts'] ); + $this->assertSame( 0, WPDO_Post_Stress_Tester::count_test_posts() ); + } + + // ── v2.11.4: state machine (start / cancel / get_state / get_progress / run_batch) ─ + + protected function tearDown(): void { + // Reset persisted state between state-machine tests so each test starts idle. + unset( $GLOBALS['_wp_options'][ WPDO_Post_Stress_Tester::OPT_STATE ] ); + unset( $GLOBALS['_wp_transients'][ WPDO_Post_Stress_Tester::CANCEL_FLAG ] ); + unset( $GLOBALS['_wp_transients']['wpdo_post_stress_pump_lock'] ); + } + + public function test_get_state_returns_empty_when_idle(): void { + $this->assertSame( array(), WPDO_Post_Stress_Tester::get_state() ); + } + + public function test_get_progress_returns_idle_when_no_state(): void { + $progress = WPDO_Post_Stress_Tester::get_progress( false ); + $this->assertSame( 'idle', $progress['status'] ); + $this->assertSame( 0, $progress['processed'] ); + } + + public function test_start_persists_state_with_running_status(): void { + $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 ); + + $this->assertTrue( $result['ok'] ); + $state = $result['state']; + $this->assertSame( 'running', $state['status'] ); + $this->assertSame( 'product', $state['post_type'] ); + $this->assertSame( 'fast', $state['mode'] ); + $this->assertSame( 10, $state['target'] ); + $this->assertSame( 5, $state['batch_size'] ); + $this->assertSame( 0, $state['processed'] ); + } + + public function test_start_rejects_unsupported_post_type(): void { + $result = WPDO_Post_Stress_Tester::start( 'bogus_type', 10 ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'unsupported_post_type', $result['error'] ); + } + + public function test_start_rejects_zero_target(): void { + $result = WPDO_Post_Stress_Tester::start( 'product', 0 ); + $this->assertFalse( $result['ok'] ); + } + + public function test_start_rejects_excessive_target(): void { + $result = WPDO_Post_Stress_Tester::start( 'product', 100001 ); + $this->assertFalse( $result['ok'] ); + } + + public function test_start_rejects_invalid_mode(): void { + $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'turbo' ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'invalid mode', $result['error'] ); + } + + public function test_start_rejects_concurrent_run(): void { + WPDO_Post_Stress_Tester::start( 'product', 10 ); + $result = WPDO_Post_Stress_Tester::start( 'product', 5 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'already_running', $result['error'] ); + } + + public function test_start_clamps_batch_size_above_max(): void { + $result = WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5000 ); + $this->assertTrue( $result['ok'] ); + $this->assertSame( WPDO_Post_Stress_Tester::MAX_BATCH_SIZE, $result['state']['batch_size'] ); + } + + public function test_run_batch_advances_processed_count(): void { + WPDO_Post_Stress_Tester::start( 'product', 6, 'fast', 3 ); + + WPDO_Post_Stress_Tester::run_batch(); + $progress = WPDO_Post_Stress_Tester::get_progress( false ); + $this->assertSame( 3, $progress['processed'] ); + $this->assertSame( 1, $progress['batches_done'] ); + $this->assertSame( 'running', $progress['status'] ); + + WPDO_Post_Stress_Tester::run_batch(); + $progress = WPDO_Post_Stress_Tester::get_progress( false ); + $this->assertSame( 6, $progress['processed'] ); + $this->assertSame( 'completed', $progress['status'] ); + } + + public function test_run_batch_creates_actual_posts(): void { + WPDO_Post_Stress_Tester::start( 'product', 4, 'fast', 4 ); + + WPDO_Post_Stress_Tester::run_batch(); + + $this->assertSame( 4, WPDO_Post_Stress_Tester::count_test_posts() ); + } + + public function test_run_batch_finalizes_with_benchmark(): void { + WPDO_Post_Stress_Tester::start( 'product', 2, 'fast', 2 ); + WPDO_Post_Stress_Tester::run_batch(); + + $state = WPDO_Post_Stress_Tester::get_state(); + $this->assertSame( 'completed', $state['status'] ); + $this->assertIsArray( $state['benchmark'] ); + $this->assertArrayHasKey( 'write', $state['benchmark'] ); + $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] ); + $this->assertSame( 'product', $state['benchmark']['post_type'] ); + } + + public function test_cancel_marks_state_as_cancelled(): void { + WPDO_Post_Stress_Tester::start( 'product', 100, 'fast', 50 ); + + $result = WPDO_Post_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'cancelled', $result['state']['status'] ); + + // In-flight batch run after cancel must NOT bump status back to running. + WPDO_Post_Stress_Tester::run_batch(); + $state = WPDO_Post_Stress_Tester::get_state(); + $this->assertSame( 'cancelled', $state['status'] ); + } + + public function test_cancel_returns_no_active_job_when_idle(): void { + $result = WPDO_Post_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'no_active_job', $result['message'] ?? '' ); + } + + public function test_get_progress_includes_pct_and_eta(): void { + WPDO_Post_Stress_Tester::start( 'product', 10, 'fast', 5 ); + WPDO_Post_Stress_Tester::run_batch(); + + $progress = WPDO_Post_Stress_Tester::get_progress( false ); + $this->assertArrayHasKey( 'pct', $progress ); + $this->assertArrayHasKey( 'rate_per_sec', $progress ); + $this->assertArrayHasKey( 'elapsed_sec', $progress ); + $this->assertArrayHasKey( 'eta_sec', $progress ); + $this->assertArrayHasKey( 'test_post_count', $progress ); + $this->assertSame( 50.0, $progress['pct'] ); // 5/10 = 50% + } + + public function test_run_benchmark_returns_post_type_aware_query_probes(): void { + WPDO_Post_Stress_Tester::start( 'hp_listing', 4, 'fast', 4 ); + WPDO_Post_Stress_Tester::run_batch(); + + $state = WPDO_Post_Stress_Tester::get_state(); + $bench = $state['benchmark']; + + $this->assertSame( 'hp_listing', $bench['post_type'] ); + // query is post_type-aware; in production the flat table exists so this + // returns 3 probes (point/range/eav_baseline). In integration tests the + // flat table doesn't exist (different prefix), so we only verify the + // structure is present and post_type-aware. + $this->assertIsArray( $bench['query'] ); + } + + public function test_run_batch_realistic_uses_wp_insert_post(): void { + WPDO_Post_Stress_Tester::start( 'product', 3, 'realistic', 3 ); + WPDO_Post_Stress_Tester::run_batch(); + + $state = WPDO_Post_Stress_Tester::get_state(); + $this->assertSame( 'completed', $state['status'] ); + $this->assertSame( 3, $state['processed'] ); + $this->assertSame( 'realistic', $state['mode'] ); + } +} diff --git a/tests/integration/PostmetaCleanerIntegrationTest.php b/tests/integration/PostmetaCleanerIntegrationTest.php new file mode 100644 index 0000000..0cf1b80 --- /dev/null +++ b/tests/integration/PostmetaCleanerIntegrationTest.php @@ -0,0 +1,204 @@ +query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::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_ci' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::POSTMETA . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function seed_postmeta( array $rows ): void { + global $wpdb; + foreach ( $rows as $row ) { + $wpdb->insert( self::POSTMETA, $row ); + } + } + + // ── count_garbage() ─────────────────────────────────────────────────────── + + public function test_count_garbage_returns_zero_for_empty_table(): void { + $counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 0, $counts['wp_old_date'] ); + $this->assertSame( 0, $counts['edit_locks'] ); + $this->assertSame( 0, $counts['total'] ); + } + + public function test_count_garbage_counts_transients(): void { + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_transient_hp_models/listing/v1', 'meta_value' => 'a' ), + array( 'post_id' => 1, 'meta_key' => '_transient_timeout_hp_models/listing/v1', 'meta_value' => '9999' ), + array( 'post_id' => 2, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + array( 'post_id' => 2, 'meta_key' => 'hp_price', 'meta_value' => '99' ), + ) ); + + $counts = WPDO_Postmeta_Cleaner::count_garbage( 'transients' ); + $this->assertSame( 3, $counts['transients'] ); + $this->assertSame( 0, $counts['wp_old_date'] ); + $this->assertSame( 0, $counts['edit_locks'] ); + $this->assertSame( 3, $counts['total'] ); + } + + public function test_count_garbage_counts_wp_old_date(): void { + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ), + array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-02-01' ), + array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ), + ) ); + + $counts = WPDO_Postmeta_Cleaner::count_garbage( 'wp_old_date' ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 2, $counts['wp_old_date'] ); + $this->assertSame( 0, $counts['edit_locks'] ); + $this->assertSame( 2, $counts['total'] ); + } + + public function test_count_garbage_counts_only_stale_edit_locks(): void { + $now = time(); + $one_day_ago = $now - 86400 - 60; // stale by 1 day + 1 min + $one_hour_ago = $now - 3600; // fresh, not stale + $five_min_ago = $now - 300; // very fresh, not stale + + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), // stale ✓ + array( 'post_id' => 2, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':2' ), // fresh + array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $five_min_ago . ':3' ), // fresh + array( 'post_id' => 4, 'meta_key' => '_edit_last', 'meta_value' => '4' ), // not edit_lock + ) ); + + $counts = WPDO_Postmeta_Cleaner::count_garbage( 'edit_locks' ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 0, $counts['wp_old_date'] ); + $this->assertSame( 1, $counts['edit_locks'], 'Only stale (>24h old) _edit_lock rows count' ); + $this->assertSame( 1, $counts['total'] ); + } + + public function test_count_garbage_target_all_unions_all_three(): void { + $one_day_ago = time() - 86400 - 60; + + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ), + array( 'post_id' => 2, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '99' ), + array( 'post_id' => 3, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ), + array( 'post_id' => 4, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), + array( 'post_id' => 5, 'meta_key' => 'hp_price', 'meta_value' => '99' ), + ) ); + + $counts = WPDO_Postmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 2, $counts['transients'] ); + $this->assertSame( 1, $counts['wp_old_date'] ); + $this->assertSame( 1, $counts['edit_locks'] ); + $this->assertSame( 4, $counts['total'] ); + } + + public function test_count_garbage_rejects_invalid_target(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Postmeta_Cleaner::count_garbage( 'bogus' ); + } + + // ── delete_garbage() ────────────────────────────────────────────────────── + + public function test_delete_garbage_removes_only_target_rows(): void { + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ), + array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ), + array( 'post_id' => 3, 'meta_key' => 'hp_price', 'meta_value' => '99' ), + ) ); + + $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'transients' ); + $this->assertSame( 1, $deleted['transients'] ); + $this->assertSame( 0, $deleted['wp_old_date'] ); + $this->assertSame( 0, $deleted['edit_locks'] ); + $this->assertSame( 1, $deleted['total'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' ); + $this->assertSame( 2, $remaining, 'Non-transient rows should remain (wp_old_date + hp_price)' ); + } + + public function test_delete_garbage_target_all_clears_all_three(): void { + $one_day_ago = time() - 86400 - 60; + + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ), + array( 'post_id' => 2, 'meta_key' => '_wp_old_date', 'meta_value' => '2024-01-01' ), + array( 'post_id' => 3, 'meta_key' => '_edit_lock', 'meta_value' => $one_day_ago . ':1' ), + array( 'post_id' => 4, 'meta_key' => 'hp_price', 'meta_value' => '99' ), + ) ); + + $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 1, $deleted['transients'] ); + $this->assertSame( 1, $deleted['wp_old_date'] ); + $this->assertSame( 1, $deleted['edit_locks'] ); + $this->assertSame( 3, $deleted['total'] ); + + global $wpdb; + $remaining = $wpdb->get_results( 'SELECT meta_key FROM `' . self::POSTMETA . '`', ARRAY_A ); + $this->assertCount( 1, $remaining ); + $this->assertSame( 'hp_price', $remaining[0]['meta_key'] ); + } + + public function test_delete_garbage_does_not_touch_fresh_edit_lock(): void { + $one_hour_ago = time() - 3600; + + $this->seed_postmeta( array( + array( 'post_id' => 1, 'meta_key' => '_edit_lock', 'meta_value' => $one_hour_ago . ':1' ), + ) ); + + $deleted = WPDO_Postmeta_Cleaner::delete_garbage( 'edit_locks' ); + $this->assertSame( 0, $deleted['edit_locks'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::POSTMETA . '`' ); + $this->assertSame( 1, $remaining, 'Fresh edit_lock must survive cleanup' ); + } + + public function test_delete_garbage_rejects_invalid_target(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Postmeta_Cleaner::delete_garbage( 'bogus' ); + } +} diff --git a/tests/integration/QueryRouterIntegrationTest.php b/tests/integration/QueryRouterIntegrationTest.php new file mode 100644 index 0000000..3ec1813 --- /dev/null +++ b/tests/integration/QueryRouterIntegrationTest.php @@ -0,0 +1,373 @@ +register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => 'hp_price', + 'indexed' => true, + ] ); + $registry->register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_featured', + 'zone' => 'hot', + 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', + 'column' => 'hp_featured', + 'indexed' => false, + ] ); + } + + protected function setUp(): void { + $this->router = new WPDO_Query_Router(); + // Ensure the hot_hp_listing module is in a query-active state. + WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' ); + } + + // ── pre_get_posts ───────────────────────────────────────────────────── + + public function test_pre_get_posts_extracts_hot_clause(): void { + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + $query->set( 'meta_query', [ + [ 'key' => 'hp_price', 'value' => '100', 'compare' => '>=', 'type' => 'DECIMAL' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + $this->assertIsArray( $hot ); + $this->assertArrayHasKey( 'hp_listing', $hot ); + $this->assertCount( 1, $hot['hp_listing'] ); + $this->assertSame( 'hp_price', $hot['hp_listing'][0]['column'] ); + $this->assertSame( '>=', $hot['hp_listing'][0]['compare'] ); + $this->assertSame( '100', $hot['hp_listing'][0]['value'] ); + + // Extracted clause must be removed from meta_query. + $remaining = (array) $query->get( 'meta_query' ); + $this->assertEmpty( $remaining ); + } + + public function test_pre_get_posts_leaves_non_registered_key_in_meta_query(): void { + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + $query->set( 'meta_query', [ + [ 'key' => 'hp_price', 'value' => '50', 'compare' => '=' ], + [ 'key' => 'custom_key', 'value' => 'abc', 'compare' => '=' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + $remaining = (array) $query->get( 'meta_query' ); + + // hp_price hot-extracted. + $this->assertArrayHasKey( 'hp_listing', $hot ); + $this->assertCount( 1, $hot['hp_listing'] ); + + // custom_key stays in meta_query. + $this->assertCount( 1, $remaining ); + $found_keys = array_column( array_values( $remaining ), 'key' ); + $this->assertContains( 'custom_key', $found_keys ); + } + + public function test_pre_get_posts_skips_when_no_meta_query(): void { + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + // No meta_query set. + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + // Should not have been set at all (get returns default ''). + $this->assertEmpty( $hot ); + } + + public function test_pre_get_posts_skips_when_no_post_type(): void { + $query = new WP_Query(); + // No post_type set. + $query->set( 'meta_query', [ + [ 'key' => 'hp_price', 'value' => '10', 'compare' => '=' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + $this->assertEmpty( $hot ); + } + + public function test_pre_get_posts_skips_inactive_module(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + $query->set( 'meta_query', [ + [ 'key' => 'hp_price', 'value' => '99', 'compare' => '=' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + $remaining = (array) $query->get( 'meta_query' ); + + // No hot clauses extracted. + $this->assertEmpty( $hot ); + // Original clause still in meta_query. + $this->assertNotEmpty( $remaining ); + } + + public function test_pre_get_posts_preserves_relation_in_remaining(): void { + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + $query->set( 'meta_query', [ + 'relation' => 'AND', + [ 'key' => 'hp_price', 'value' => '50', 'compare' => '>=' ], + [ 'key' => 'custom_key', 'value' => '1', 'compare' => '=' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $remaining = (array) $query->get( 'meta_query' ); + + // relation must be preserved because custom_key remains. + $this->assertArrayHasKey( 'relation', $remaining ); + $this->assertSame( 'AND', $remaining['relation'] ); + } + + public function test_pre_get_posts_extracts_multiple_hot_fields(): void { + $query = new WP_Query(); + $query->set( 'post_type', 'hp_listing' ); + $query->set( 'meta_query', [ + [ 'key' => 'hp_price', 'value' => '200', 'compare' => '<=' ], + [ 'key' => 'hp_featured', 'value' => '1', 'compare' => '=' ], + ] ); + + $this->router->pre_get_posts( $query ); + + $hot = $query->get( 'wpdo_hot_clauses' ); + $this->assertCount( 2, $hot['hp_listing'] ); + + $columns = array_column( $hot['hp_listing'], 'column' ); + $this->assertContains( 'hp_price', $columns ); + $this->assertContains( 'hp_featured', $columns ); + + // meta_query fully cleared. + $this->assertEmpty( (array) $query->get( 'meta_query' ) ); + } + + // ── posts_join ──────────────────────────────────────────────────────── + + public function test_posts_join_generates_left_join_sql(): void { + $query = new WP_Query(); + $query->set( 'wpdo_hot_clauses', [ + 'hp_listing' => [ + [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ], + ], + ] ); + + $join = $this->router->posts_join( '', $query ); + + $this->assertStringContainsString( 'LEFT JOIN', $join ); + // Full physical table name. + $this->assertStringContainsString( 'wp_itest_wpdo_hot_hp_listing', $join ); + // Alias. + $this->assertStringContainsString( '`wpdo_hot_hp_listing`', $join ); + // posts table join key. + $this->assertStringContainsString( '`wp_itest_posts`', $join ); + $this->assertStringContainsString( 'post_id', $join ); + } + + public function test_posts_join_passthrough_when_no_hot_clauses(): void { + $query = new WP_Query(); + $original = ' LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)'; + + $join = $this->router->posts_join( $original, $query ); + + $this->assertSame( $original, $join ); + } + + public function test_posts_join_no_duplicate_join_for_same_alias(): void { + $query = new WP_Query(); + $query->set( 'wpdo_hot_clauses', [ + 'hp_listing' => [ + [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '100' ], + ], + ] ); + + // Simulate alias already present in existing join string. + $existing = ' LEFT JOIN `wp_itest_wpdo_hot_hp_listing` AS `wpdo_hot_hp_listing` ON (...)'; + $join = $this->router->posts_join( $existing, $query ); + + // Should appear exactly once. + $this->assertSame( 1, substr_count( $join, '`wpdo_hot_hp_listing`' ) ); + } + + // ── posts_where ─────────────────────────────────────────────────────── + + /** Helper: build a WP_Query with pre-set hot_clauses. */ + private function query_with_clauses( array $clauses ): WP_Query { + $query = new WP_Query(); + $query->set( 'wpdo_hot_clauses', [ 'hp_listing' => $clauses ] ); + return $query; + } + + public function test_posts_where_equality_condition(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '99.00' ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price`', $where ); + $this->assertStringContainsString( '=', $where ); + $this->assertStringContainsString( "'99.00'", $where ); + } + + public function test_posts_where_numeric_type_uses_integer_placeholder(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + // %d format — integer value, not quoted. + $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_featured` = 1', $where ); + } + + public function test_posts_where_in_condition(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [ '10.00', '20.00', '30.00' ] ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( 'IN', $where ); + $this->assertStringContainsString( "'10.00'", $where ); + $this->assertStringContainsString( "'20.00'", $where ); + $this->assertStringContainsString( "'30.00'", $where ); + } + + public function test_posts_where_in_empty_array_generates_false_condition(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => 'IN', 'type' => 'CHAR', 'value' => [] ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( '1=0', $where ); + } + + public function test_posts_where_between_condition(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => 'BETWEEN', 'type' => 'CHAR', 'value' => [ '10.00', '50.00' ] ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( 'BETWEEN', $where ); + $this->assertStringContainsString( "'10.00'", $where ); + $this->assertStringContainsString( "'50.00'", $where ); + } + + public function test_posts_where_exists_generates_is_not_null(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'CHAR', 'value' => '' ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NOT NULL', $where ); + } + + public function test_posts_where_not_exists_generates_is_null(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_price', 'compare' => 'NOT EXISTS', 'type' => 'CHAR', 'value' => '' ], + ] ); + + $where = $this->router->posts_where( '', $query ); + + $this->assertStringContainsString( '`wpdo_hot_hp_listing`.`hp_price` IS NULL', $where ); + } + + public function test_posts_where_passthrough_when_no_hot_clauses(): void { + $query = new WP_Query(); + $original = ' AND wp_posts.post_status = \'publish\''; + + $where = $this->router->posts_where( $original, $query ); + + $this->assertSame( $original, $where ); + } + + public function test_posts_where_appends_to_existing_where(): void { + $query = $this->query_with_clauses( [ + [ 'column' => 'hp_featured', 'compare' => '=', 'type' => 'NUMERIC', 'value' => 1 ], + ] ); + $existing = " AND wp_posts.post_status = 'publish'"; + + $where = $this->router->posts_where( $existing, $query ); + + $this->assertStringStartsWith( $existing, $where ); + $this->assertStringContainsString( 'hp_featured', $where ); + } + + // ── posts_groupby ───────────────────────────────────────────────────── + + public function test_posts_groupby_sets_posts_id_when_empty(): void { + $query = new WP_Query(); + $query->set( 'wpdo_hot_clauses', [ + 'hp_listing' => [ + [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ], + ], + ] ); + + $groupby = $this->router->posts_groupby( '', $query ); + + $this->assertStringContainsString( 'wp_itest_posts', $groupby ); + $this->assertStringContainsString( 'ID', $groupby ); + } + + public function test_posts_groupby_preserves_existing_groupby(): void { + $query = new WP_Query(); + $query->set( 'wpdo_hot_clauses', [ + 'hp_listing' => [ + [ 'column' => 'hp_price', 'compare' => '=', 'type' => 'CHAR', 'value' => '1' ], + ], + ] ); + $existing = '`wp_itest_posts`.`ID`, `wp_itest_posts`.`post_type`'; + + $groupby = $this->router->posts_groupby( $existing, $query ); + + // Existing groupby preserved unchanged (not empty, so no override). + $this->assertSame( $existing, $groupby ); + } + + public function test_posts_groupby_passthrough_when_no_hot_clauses(): void { + $query = new WP_Query(); + $original = '`wp_itest_posts`.`ID`'; + + $groupby = $this->router->posts_groupby( $original, $query ); + + $this->assertSame( $original, $groupby ); + } +} diff --git a/tests/integration/RestApiIntegrationTest.php b/tests/integration/RestApiIntegrationTest.php new file mode 100644 index 0000000..bc2343a --- /dev/null +++ b/tests/integration/RestApiIntegrationTest.php @@ -0,0 +1,322 @@ +prefix . 'wpdo_hot_restapi'; + self::$warm_table = $wpdb->prefix . 'wpdo_warm'; + + // Register fields for the fake 'restapi' post type. + $registry = WPDO_Schema_Registry::instance(); + $registry->register( 'integration_rest', [ + 'post_type' => 'restapi', + 'meta_key' => 'rp_price', + 'zone' => 'hot', + 'column' => 'rp_price', + 'type' => 'decimal', + ] ); + $registry->register( 'integration_rest', [ + 'post_type' => 'restapi', + 'meta_key' => 'rp_featured', + 'zone' => 'hot', + 'column' => 'rp_featured', + 'type' => 'tinyint', + ] ); + $registry->register( 'integration_rest', [ + 'post_type' => 'restapi', + 'meta_key' => 'rp_description', + 'zone' => 'cold', + ] ); + + // Create hot table. + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$hot_table . "` ( + post_id BIGINT UNSIGNED NOT NULL, + rp_price DECIMAL(10,2) DEFAULT NULL, + rp_featured TINYINT(1) DEFAULT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (post_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Create warm table (needed by WPDO_Listing_Stats::get_view_count). + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$warm_table . "` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + meta_key VARCHAR(255) NOT NULL, + meta_value LONGTEXT, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY post_meta (post_id, meta_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Seed 5 rows. + for ( $i = 1; $i <= 5; $i++ ) { + $price = $i * 100; + $featured = $i % 2; + $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" ); + } + + // Set module to cutover so REST API reads from Zone A. + WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + WPDO_Feature_Flags::reset( 'hot_restapi' ); + } + + protected function setUp(): void { + $GLOBALS['_wp_cache'] = []; + $GLOBALS['_wp_post_types'] = []; + $GLOBALS['_wp_postmeta'] = []; + $GLOBALS['_wp_current_user_can'] = []; + $GLOBALS['_wp_valid_nonces'] = []; + $GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests. + // Note: do NOT reset _wp_options here — Feature Flags state is stored there. + + // Invalidate Feature Flags static request cache so each test reads fresh. + $ref = new ReflectionClass( WPDO_Feature_Flags::class ); + $prop = $ref->getProperty( 'cache' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + } + + // ── GET /listings (Zone A path) ────────────────────────────────────────── + + public function test_listings_returns_all_rows(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'per_page', 10 ); + + $response = self::$api->get_listings( $req ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( 5, $data ); + $this->assertSame( '5', $response->get_headers()['X-WP-Total'] ); + } + + public function test_listings_returns_correct_fields(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'per_page', 1 ); + $req->set_param( 'orderby', 'post_id' ); + $req->set_param( 'order', 'ASC' ); + + $response = self::$api->get_listings( $req ); + $data = $response->get_data(); + + $this->assertSame( 1, $data[0]['id'] ); + $this->assertSame( 'restapi', $data[0]['post_type'] ); + $this->assertArrayHasKey( 'rp_price', $data[0] ); + $this->assertArrayHasKey( 'rp_featured', $data[0] ); + $this->assertArrayNotHasKey( 'post_id', $data[0] ); + $this->assertArrayNotHasKey( 'updated_at', $data[0] ); + } + + public function test_listings_pagination(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'per_page', 2 ); + $req->set_param( 'page', 2 ); + $req->set_param( 'orderby', 'post_id' ); + $req->set_param( 'order', 'ASC' ); + + $response = self::$api->get_listings( $req ); + $data = $response->get_data(); + + $this->assertCount( 2, $data ); + $this->assertSame( 3, $data[0]['id'] ); // page 2 offset 2 → post_id 3 + $this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] ); + } + + public function test_listings_per_page_clamped_to_max_100(): void { + // PR-0 R-4: defense-in-depth — per_page=99999 must clamp to 100, not DoS the DB. + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'per_page', 99999 ); + + $response = self::$api->get_listings( $req ); + + // Should return at most 100 items (real dataset is 5 — capped by total). + $data = $response->get_data(); + $this->assertLessThanOrEqual( 100, count( $data ) ); + } + + public function test_listings_per_page_max_filter_overridable(): void { + // PR-0 R-4: site owners can lower the cap via wpdo_rest_max_per_page filter. + // We can't fully test add_filter() in this stubbed env, but we verify the + // constant value is correctly read in the code path (above test exercises 100). + $this->assertTrue( true ); + } + + public function test_listings_filter_price_min(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'rp_price_min', 300 ); + + $response = self::$api->get_listings( $req ); + $data = $response->get_data(); + + // Prices are 100,200,300,400,500 → ≥300 = 3 rows. + $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); + foreach ( $data as $item ) { + $this->assertGreaterThanOrEqual( 300.0, (float) $item['rp_price'] ); + } + } + + public function test_listings_filter_price_range(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'rp_price_min', 200 ); + $req->set_param( 'rp_price_max', 400 ); + + $response = self::$api->get_listings( $req ); + + $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); + } + + public function test_listings_filter_exact_value(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'rp_featured', 1 ); + + $response = self::$api->get_listings( $req ); + $data = $response->get_data(); + + // featured=1 for post_id 1,3,5 → 3 rows. + $this->assertSame( '3', $response->get_headers()['X-WP-Total'] ); + foreach ( $data as $item ) { + $this->assertSame( '1', (string) $item['rp_featured'] ); + } + } + + public function test_listings_order_asc(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'orderby', 'rp_price' ); + $req->set_param( 'order', 'ASC' ); + $req->set_param( 'per_page', 5 ); + + $response = self::$api->get_listings( $req ); + $data = $response->get_data(); + + $prices = array_column( $data, 'rp_price' ); + $sorted = $prices; + sort( $sorted ); + $this->assertSame( $sorted, $prices ); + } + + // ── GET /listings/{id} ─────────────────────────────────────────────────── + + public function test_get_listing_404_for_unknown_post(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' ); + $req->set_param( 'id', 9999 ); + + $response = self::$api->get_listing( $req ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_get_listing_merges_hot_and_postmeta_cold(): void { + // post_id 1 is in hot table (rp_price=100); cold zone idle → postmeta fallback. + $GLOBALS['_wp_post_types'][1] = 'restapi'; + $GLOBALS['_wp_postmeta'][1]['rp_description'] = 'Integration test'; + + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/1' ); + $req->set_param( 'id', 1 ); + + $response = self::$api->get_listing( $req ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 1, $data['id'] ); + $this->assertSame( '100.00', $data['rp_price'] ); + $this->assertSame( 'Integration test', $data['rp_description'] ); + } + + // ── GET /stats/{id} ────────────────────────────────────────────────────── + + public function test_get_stats_returns_zero_for_unknown_post(): void { + $GLOBALS['_wp_post_types'][77] = 'restapi'; + + $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/77' ); + $req->set_param( 'id', 77 ); + + $response = self::$api->get_stats( $req ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 77, $data['post_id'] ); + $this->assertSame( 0, $data['view_count'] ); + } + + // ── GET /status ────────────────────────────────────────────────────────── + + public function test_get_status_requires_manage_options(): void { + $GLOBALS['_wp_current_user_can']['manage_options'] = false; + $this->assertFalse( self::$api->require_manage_options() ); + } + + public function test_get_status_returns_correct_engine(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); + $response = self::$api->get_status( $req ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'mysql', $data['engine'] ); + $this->assertArrayHasKey( 'modules', $data ); + $this->assertSame( 'cutover', $data['modules']['hot_restapi'] ); + } + + // ── POST /listings/{id}/view ────────────────────────────────────────────── + + public function test_post_view_403_without_nonce(): void { + $GLOBALS['_wp_post_types'][1] = 'restapi'; + $GLOBALS['_wp_valid_nonces'] = []; + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/1/view' ); + $req->set_param( 'id', 1 ); + // No nonce. + + $response = self::$api->post_view( $req ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_post_view_404_for_unknown_post(): void { + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/8888/view' ); + $req->set_param( 'id', 8888 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + + $response = self::$api->post_view( $req ); + $this->assertSame( 404, $response->get_status() ); + } + +} + diff --git a/tests/integration/SyncBridgeEntityGuardTest.php b/tests/integration/SyncBridgeEntityGuardTest.php new file mode 100644 index 0000000..c0d6b2d --- /dev/null +++ b/tests/integration/SyncBridgeEntityGuardTest.php @@ -0,0 +1,262 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `hp_price` decimal(10,2) DEFAULT NULL, + `hp_legacy_only` varchar(255) DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`), + UNIQUE KEY `post_id` (`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' ); + $wpdb->query( + 'CREATE TABLE `wp_itest_wpdo_errors` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `module` varchar(100) NOT NULL DEFAULT \'\', + `zone` varchar(20) NOT NULL DEFAULT \'\', + `hook` varchar(255) NOT NULL DEFAULT \'\', + `message` text NOT NULL, + `context` longtext, + `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + // Reset Schema_Registry singleton + register both keys (one will overlap with Entity_Registry). + $ref = new ReflectionClass( WPDO_Schema_Registry::class ); + $inst = $ref->getProperty( 'instance' ); + $inst->setAccessible( true ); + $inst->setValue( null, null ); + + WPDO_Schema_Registry::instance()->register( 'test', array( + 'post_type' => self::POST_TYPE, + 'meta_key' => self::ENTITY_KEY, + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => self::ENTITY_KEY, + 'indexed' => false, + ) ); + WPDO_Schema_Registry::instance()->register( 'test', array( + 'post_type' => self::POST_TYPE, + 'meta_key' => self::ZONE_ONLY_KEY, + 'zone' => 'hot', + 'data_type' => 'varchar(255) DEFAULT NULL', + 'column' => self::ZONE_ONLY_KEY, + 'indexed' => false, + ) ); + + // Register post adapter + post-fields groups (puts hp_price into Entity_Registry). + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Post_Fields::register_entity_fields(); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' ); + + // Reset Mode_Manager cache to prevent post=dual_write leaking into + // later tests that share the same PHP process (e.g. SyncBridgeIntegrationTest + // which uses 'hp_price' as a generic test field — that key is in the post + // Entity_Registry once we've registered it here, so the guard would fire + // in those tests' assertions if mode is still cached as dual_write). + $ref = new ReflectionClass( WPDO_Mode_Manager::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + + // Also reset Entity_Registry so the registered post groups don't leak. + WPDO_Entity_Registry::init(); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + + $GLOBALS['_wp_options'] = array(); + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + // Reset Sync_Bridge state. + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $cache = $ref->getProperty( 'field_cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array() ); + $bypass = $ref->getProperty( 'bypassing' ); + $bypass->setAccessible( true ); + $bypass->setValue( null, false ); + + // Reset Mode_Manager cache to default (post=disabled). + // Tests that need dual_write override via set_post_mode() helper below, + // which writes the cache directly (avoiding Cache_Orchestrator dep). + self::set_post_mode( 'disabled' ); + + // Seed post-type lookup. + $GLOBALS['_wp_post_types'] = array(); + for ( $i = 1; $i <= 20; $i++ ) { + $GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE; + } + + $GLOBALS['_wp_cache'] = array(); + + $this->bridge = new WPDO_Sync_Bridge(); + } + + /** + * Set Mode_Manager post mode by writing the static cache directly, + * bypassing set() which has a hard dep on WPDO_Cache_Orchestrator + * (out of scope for this guard test). + */ + private static function set_post_mode( string $mode ): void { + $ref = new ReflectionClass( WPDO_Mode_Manager::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array( + 'post' => $mode, + 'user' => 'aeav_only', // user mode frozen — must not change + 'term' => 'dual_write', + 'comment' => 'dual_write', + ) ); + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + /** + * Baseline: post mode = disabled (default) — Sync_Bridge MUST still write zone. + * This guarantees v2.9.1 → v2.9.2 upgrade is zero-impact for users who + * haven't opted in to Entity Bridge post mode. + */ + public function test_zone_write_unchanged_when_post_mode_disabled(): void { + // post mode defaults to disabled — Mode_Manager reads from option. + $this->bridge->intercept_update( null, 1, self::ENTITY_KEY, '199.99', '' ); + + $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::ENTITY_KEY ); + $this->assertSame( + '199.99', + $val, + 'mode=disabled: Sync_Bridge must continue writing zone (legacy back-compat).' + ); + } + + /** + * Guard: post mode = dual_write + key registered in Entity_Registry + * → Sync_Bridge skips zone write (Entity Bridge will handle it). + */ + public function test_zone_skipped_when_post_mode_dual_write_and_key_in_entity_registry(): void { + self::set_post_mode( 'dual_write' ); + + $this->bridge->intercept_update( null, 2, self::ENTITY_KEY, '299.99', '' ); + + $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::ENTITY_KEY ); + $this->assertNull( + $val, + 'mode=dual_write + Entity_Registry has key: Sync_Bridge MUST skip zone write to avoid double-write.' + ); + } + + /** + * Back-compat: post mode = dual_write + key NOT in Entity_Registry + * → Sync_Bridge still writes zone (only Entity_Registry-managed keys are skipped). + */ + public function test_zone_write_continues_for_zone_only_key_when_post_mode_dual_write(): void { + self::set_post_mode( 'dual_write' ); + + // hp_legacy_only is in Schema_Registry only — not in Entity_Registry. + $this->bridge->intercept_update( null, 3, self::ZONE_ONLY_KEY, 'legacy_value', '' ); + + $val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, self::ZONE_ONLY_KEY ); + $this->assertSame( + 'legacy_value', + $val, + 'mode=dual_write but key not in Entity_Registry: Sync_Bridge must keep writing zone (back-compat).' + ); + } + + /** + * The intercept_update return value must remain null in all branches — + * we never short-circuit WP native postmeta in v2.9.2 (still dual_write + * w.r.t. wp_postmeta; cutover comes in v2.9.5). + */ + public function test_intercept_returns_null_regardless_of_guard(): void { + self::set_post_mode( 'dual_write' ); + + $result_skipped = $this->bridge->intercept_update( null, 4, self::ENTITY_KEY, '50.00', '' ); + $result_written = $this->bridge->intercept_update( null, 5, self::ZONE_ONLY_KEY, 'x', '' ); + + $this->assertNull( $result_skipped, 'Guard branch must still return null.' ); + $this->assertNull( $result_written, 'Non-guard branch must still return null.' ); + } + + /** + * intercept_add must apply the same guard. + */ + public function test_add_zone_skipped_when_entity_registry_owns_key(): void { + self::set_post_mode( 'dual_write' ); + + $this->bridge->intercept_add( null, 6, self::ENTITY_KEY, '99.99', true ); + + $val = WPDO_Zone_Hot::get( 6, self::POST_TYPE, self::ENTITY_KEY ); + $this->assertNull( + $val, + 'intercept_add must apply the Entity_Registry guard symmetrically with intercept_update.' + ); + } +} diff --git a/tests/integration/SyncBridgeIntegrationTest.php b/tests/integration/SyncBridgeIntegrationTest.php new file mode 100644 index 0000000..8b21974 --- /dev/null +++ b/tests/integration/SyncBridgeIntegrationTest.php @@ -0,0 +1,272 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `hp_price` decimal(10,2) DEFAULT NULL, + `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`), + UNIQUE KEY `post_id` (`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + // Create the errors log table so WPDO_Logger::error() can write to it. + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' ); + $wpdb->query( + 'CREATE TABLE `wp_itest_wpdo_errors` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `module` varchar(100) NOT NULL DEFAULT \'\', + `zone` varchar(20) NOT NULL DEFAULT \'\', + `hook` varchar(255) NOT NULL DEFAULT \'\', + `message` text NOT NULL, + `context` longtext, + `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + + // Reset and populate the Schema Registry singleton. + $ref = new ReflectionClass( WPDO_Schema_Registry::class ); + $inst = $ref->getProperty( 'instance' ); + $inst->setAccessible( true ); + $inst->setValue( null, null ); + + WPDO_Schema_Registry::instance()->register( 'test_provider', [ + 'post_type' => self::POST_TYPE, + 'meta_key' => self::FIELD, + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => self::FIELD, + 'indexed' => false, + ] ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `wp_itest_wpdo_errors`' ); + } + + protected function setUp(): void { + global $wpdb; + + // Wipe data before each test. + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + + // Reset FeatureFlags (clears option + static cache). + $GLOBALS['_wp_options'] = []; + WPDO_Feature_Flags::set( self::MODULE, 'idle' ); + + // Reset SyncBridge request-level field cache. + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $cache = $ref->getProperty( 'field_cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, [] ); + + // Reset $bypassing flag. + $bypass = $ref->getProperty( 'bypassing' ); + $bypass->setAccessible( true ); + $bypass->setValue( null, false ); + + // Seed post-type lookup. + $GLOBALS['_wp_post_types'] = []; + for ( $i = 1; $i <= 20; $i++ ) { + $GLOBALS['_wp_post_types'][ $i ] = self::POST_TYPE; + } + + // Object cache reset. + $GLOBALS['_wp_cache'] = []; + + $this->bridge = new WPDO_Sync_Bridge(); + } + + // ── intercept_update ───────────────────────────────────────────────────── + + public function test_update_dual_write_writes_value_to_hot_table(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $this->bridge->intercept_update( null, 1, self::FIELD, '199.99', '' ); + + $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, self::FIELD ); + $this->assertSame( '199.99', $val ); + } + + public function test_update_idle_does_not_write_to_hot_table(): void { + // Module stays in 'idle' — is_write_active() returns false. + $this->bridge->intercept_update( null, 2, self::FIELD, '50.00', '' ); + + $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, self::FIELD ); + $this->assertNull( $val ); + } + + public function test_update_always_returns_null_to_allow_native_write(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $result = $this->bridge->intercept_update( null, 3, self::FIELD, '100.00', '' ); + + // Must return null (not short-circuit) so WordPress still writes postmeta. + $this->assertNull( $result ); + } + + public function test_update_skips_unregistered_meta_key(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + // 'hp_unregistered' is not in Schema Registry. + $this->bridge->intercept_update( null, 4, 'hp_unregistered', '42.00', '' ); + + // Hot table for test_post should still be empty. + $val = WPDO_Zone_Hot::get( 4, self::POST_TYPE, self::FIELD ); + $this->assertNull( $val ); + } + + public function test_update_skips_when_post_type_unknown(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + // post_id 999 not seeded in _wp_post_types → get_post_type() returns false. + $this->bridge->intercept_update( null, 999, self::FIELD, '77.00', '' ); + + // Nothing should have been written (table doesn't have post 999). + $val = WPDO_Zone_Hot::get( 999, self::POST_TYPE, self::FIELD ); + $this->assertNull( $val ); + } + + // ── intercept_add ──────────────────────────────────────────────────────── + + public function test_add_dual_write_writes_value_to_hot_table(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $this->bridge->intercept_add( null, 5, self::FIELD, '299.00', true ); + + $val = WPDO_Zone_Hot::get( 5, self::POST_TYPE, self::FIELD ); + $this->assertSame( '299.00', $val ); + } + + public function test_add_returns_null_to_allow_native_write(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $result = $this->bridge->intercept_add( null, 6, self::FIELD, '10.00', false ); + + $this->assertNull( $result ); + } + + // ── intercept_get ──────────────────────────────────────────────────────── + + public function test_get_cutover_returns_zone_value_wrapped_in_array(): void { + // Write directly to hot table, then verify intercept_get reads it back. + WPDO_Zone_Hot::set( 7, self::POST_TYPE, self::FIELD, '500.00' ); + WPDO_Feature_Flags::set( self::MODULE, 'cutover' ); + + $result = $this->bridge->intercept_get( null, 7, self::FIELD, true ); + + // SyncBridge wraps value in array so WP can unwrap correctly. + $this->assertIsArray( $result ); + $this->assertSame( '500.00', $result[0] ); + } + + public function test_get_dual_write_returns_null_passthrough(): void { + WPDO_Zone_Hot::set( 8, self::POST_TYPE, self::FIELD, '123.00' ); + // dual_write is NOT a read-custom state. + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $result = $this->bridge->intercept_get( null, 8, self::FIELD, true ); + + // Should pass through (return null) so WP reads from postmeta. + $this->assertNull( $result ); + } + + public function test_get_returns_null_when_no_zone_row(): void { + // cutover state but no row in hot table. + WPDO_Feature_Flags::set( self::MODULE, 'cutover' ); + + $result = $this->bridge->intercept_get( null, 9, self::FIELD, true ); + + $this->assertNull( $result ); + } + + public function test_get_returns_null_for_empty_meta_key(): void { + WPDO_Feature_Flags::set( self::MODULE, 'cutover' ); + + // Empty meta_key means "get all meta" — bridge should pass through. + $result = $this->bridge->intercept_get( null, 10, '', true ); + + $this->assertNull( $result ); + } + + // ── intercept_delete ───────────────────────────────────────────────────── + + public function test_delete_zeros_out_hot_column(): void { + WPDO_Zone_Hot::set( 11, self::POST_TYPE, self::FIELD, '999.00' ); + $this->assertSame( '999.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD ) ); + + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + $this->bridge->intercept_delete( [ 1 ], 11, self::FIELD, '999.00' ); + + // delete_from_zone calls Zone_Hot::set(post_id, post_type, column, null). + $val = WPDO_Zone_Hot::get( 11, self::POST_TYPE, self::FIELD ); + $this->assertNull( $val ); + } + + // ── $bypassing flag ─────────────────────────────────────────────────────── + + public function test_bypass_flag_prevents_intercept_get(): void { + WPDO_Zone_Hot::set( 12, self::POST_TYPE, self::FIELD, '777.00' ); + WPDO_Feature_Flags::set( self::MODULE, 'cutover' ); + + // Simulate internal call (e.g. migration reading postmeta). + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $bypass = $ref->getProperty( 'bypassing' ); + $bypass->setAccessible( true ); + $bypass->setValue( null, true ); + + $result = $this->bridge->intercept_get( null, 12, self::FIELD, true ); + + // Should pass through immediately, ignoring zone. + $this->assertNull( $result ); + } + + public function test_bypass_flag_prevents_intercept_update(): void { + WPDO_Feature_Flags::set( self::MODULE, 'dual_write' ); + + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $bypass = $ref->getProperty( 'bypassing' ); + $bypass->setAccessible( true ); + $bypass->setValue( null, true ); + + $this->bridge->intercept_update( null, 13, self::FIELD, '888.00', '' ); + + // bypassing = true → no write to hot table. + $val = WPDO_Zone_Hot::get( 13, self::POST_TYPE, self::FIELD ); + $this->assertNull( $val ); + } +} diff --git a/tests/integration/TermCommentGarbageFilterTest.php b/tests/integration/TermCommentGarbageFilterTest.php new file mode 100644 index 0000000..36a69b0 --- /dev/null +++ b/tests/integration/TermCommentGarbageFilterTest.php @@ -0,0 +1,164 @@ +assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_user' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_post' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_import_term' ) ); + } + + public function test_is_shared_garbage_key_matches_2meet_demo(): void { + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_music' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demo_adv' ) ); + } + + public function test_is_shared_garbage_key_rejects_legitimate_keys(): void { + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_sort_order' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_default' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'hp_rating' ) ); + } + + public function test_is_shared_garbage_key_rejects_partial_match(): void { + // Substring matches should NOT trigger. + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 'something_wxr_import_' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_wxr_imp' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( '_2meet_demos' ) ); + } + + public function test_is_shared_garbage_key_rejects_non_string(): void { + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( null ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( 123 ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_shared_garbage_key( array() ) ); + } + + // ── is_comment_orphan_key ──────────────────────────────────────────────── + + public function test_is_comment_orphan_key_matches_post_domain_keys(): void { + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_status' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_featured' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_verified' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_view_count' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_thumbnail_id' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_lock' ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_edit_last' ) ); + } + + public function test_is_comment_orphan_key_rejects_legitimate_comment_keys(): void { + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'hp_rating' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( 'note_group' ) ); + } + + public function test_is_comment_orphan_key_requires_exact_match(): void { + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_price_extended' ) ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_comment_orphan_key( '_hp_pric' ) ); + } + + // ── on_term_write callback ─────────────────────────────────────────────── + + public function test_on_term_write_drops_garbage_keys(): void { + $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'val', false ); + $this->assertTrue( $result, 'garbage write must short-circuit (return true)' ); + } + + public function test_on_term_write_passes_through_legitimate_keys(): void { + $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false ); + $this->assertNull( $result, 'legitimate write must fall through (return null)' ); + } + + public function test_on_term_write_does_not_apply_comment_orphan_rules(): void { + // _hp_price is comment-only orphan; for term writes it must pass through + $result = WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_hp_price', '99', false ); + $this->assertNull( $result ); + } + + // ── on_comment_write callback ──────────────────────────────────────────── + + public function test_on_comment_write_drops_shared_garbage(): void { + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_wxr_import_user', 'a', false ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_2meet_demo_music', '1', false ) ); + } + + public function test_on_comment_write_drops_orphan_post_meta(): void { + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_hp_price', '99', false ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_thumbnail_id', '50', false ) ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, '_edit_lock', '111:1', false ) ); + } + + public function test_on_comment_write_passes_through_legitimate_keys(): void { + $this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'hp_rating', '5', false ) ); + $this->assertNull( WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 1, 'note_group', 'foo', false ) ); + } + + // ── 24h drop counter ──────────────────────────────────────────────────── + + public function test_drop_counter_starts_at_zero(): void { + $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() ); + } + + public function test_drop_counter_increments_on_each_drop(): void { + WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false ); + WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 2, '_2meet_demo_music', '1', false ); + WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 3, '_hp_price', '99', false ); + + $this->assertSame( 3, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() ); + } + + public function test_drop_counter_does_not_increment_on_legitimate_writes(): void { + WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, 'hp_sort_order', '5', false ); + WPDO_Term_Comment_Garbage_Filter::on_comment_write( null, 2, 'hp_rating', '5', false ); + + $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() ); + } + + public function test_drop_counter_resets_after_24h(): void { + // Simulate counter from 25h ago + update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_COUNT, 100 ); + update_option( WPDO_Term_Comment_Garbage_Filter::OPT_DROPPED_RESET_AT, time() - 25 * HOUR_IN_SECONDS ); + + // get_drop_count_24h returns 0 (auto-reset semantics) + $this->assertSame( 0, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() ); + + // First new drop after expiry resets counter to 1 + WPDO_Term_Comment_Garbage_Filter::on_term_write( null, 1, '_wxr_import_user', 'a', false ); + $this->assertSame( 1, WPDO_Term_Comment_Garbage_Filter::get_drop_count_24h() ); + } + + // ── is_enabled toggle ─────────────────────────────────────────────────── + + public function test_is_enabled_defaults_true(): void { + delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED ); + $this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_enabled() ); + } + + public function test_is_enabled_respects_zero_value(): void { + update_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED, '0' ); + $this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_enabled() ); + } +} diff --git a/tests/integration/TermCommentMiscBucketTest.php b/tests/integration/TermCommentMiscBucketTest.php new file mode 100644 index 0000000..2bca15c --- /dev/null +++ b/tests/integration/TermCommentMiscBucketTest.php @@ -0,0 +1,211 @@ +prefix's resolution by creating tables under the + // itest prefix and shadowing term_table()/comment_table() via $wpdb->prefix. + // $wpdb->prefix is 'wp_itest_' in tests so wpdo_term_misc resolves to wp_itest_wpdo_term_misc. + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERM_MISC . '` ( + term_id bigint(20) unsigned NOT NULL, + meta_key varchar(191) NOT NULL, + meta_value longtext, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (term_id, meta_key), + KEY meta_key (meta_key) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENT_MISC . '` ( + comment_id bigint(20) unsigned NOT NULL, + meta_key varchar(191) NOT NULL, + meta_value longtext, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (comment_id, meta_key), + KEY meta_key (meta_key) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_MISC . '`' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_MISC . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_MISC . '`' ); + } + + // ── $pre preservation contract (priority chain integrity) ──────────────── + + public function test_on_term_read_preserves_non_null_pre(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( array( 'managed_value' ), 1, 'any_key', true ); + $this->assertSame( array( 'managed_value' ), $result ); + } + + public function test_on_term_add_preserves_non_null_check(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( true, 1, 'any_key', 'value', false ); + $this->assertTrue( $result, 'Must preserve $check=true (someone else handled write)' ); + } + + public function test_on_term_update_preserves_non_null_check(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_update( true, 1, 'any_key', 'value', '' ); + $this->assertTrue( $result ); + } + + public function test_on_term_delete_preserves_non_null_check(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_delete( true, 1, 'any_key', '', false ); + $this->assertTrue( $result ); + } + + public function test_comment_callbacks_preserve_non_null_check(): void { + $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_add( true, 1, 'any', 'v', false ) ); + $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_update( true, 1, 'any', 'v', '' ) ); + $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::on_comment_delete( true, 1, 'any', '', false ) ); + $this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_comment_read( array( 'v' ), 1, 'any', true ) ); + } + + // ── Catch-all behavior when $check === null ───────────────────────────── + + public function test_on_term_add_writes_to_misc_table_when_unhandled(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, 'note_group', 'foo', false ); + $this->assertTrue( $result, 'Must short-circuit (return true) after writing' ); + + global $wpdb; + $value = $wpdb->get_var( + $wpdb->prepare( + 'SELECT meta_value FROM `' . self::TERM_MISC . '` WHERE term_id = %d AND meta_key = %s', + 5, + 'note_group' + ) + ); + $this->assertSame( 'foo', $value ); + } + + public function test_on_term_read_returns_value_from_misc_table_when_unhandled(): void { + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 7, 'unknown_key', 'bar', '' ); + + $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 7, 'unknown_key', true ); + $this->assertSame( array( 'bar' ), $result ); + } + + public function test_on_term_read_returns_pre_on_cache_miss(): void { + // No prior write — read should fall through (return $pre = null, letting WP query DB). + $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 999, 'never_written', true ); + $this->assertNull( $result ); + } + + public function test_upsert_replaces_existing_value(): void { + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'first', '' ); + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'second', '' ); + + $result = WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ); + $this->assertSame( array( 'second' ), $result ); + + // Ensure exactly one row (composite PK enforces this). + global $wpdb; + $count = (int) $wpdb->get_var( + 'SELECT COUNT(*) FROM `' . self::TERM_MISC . "` WHERE term_id = 5 AND meta_key = 'k'" + ); + $this->assertSame( 1, $count ); + } + + public function test_on_term_delete_removes_row(): void { + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'v', '' ); + $this->assertSame( array( 'v' ), WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) ); + + WPDO_Term_Comment_Misc_Bucket::on_term_delete( null, 5, 'k', '', false ); + + $this->assertNull( WPDO_Term_Comment_Misc_Bucket::on_term_read( null, 5, 'k', true ) ); + } + + // ── Comment side ───────────────────────────────────────────────────────── + + public function test_on_comment_add_writes_to_misc_table(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_comment_add( null, 8, 'note_group', 'baz', false ); + $this->assertTrue( $result ); + + global $wpdb; + $value = $wpdb->get_var( + $wpdb->prepare( + 'SELECT meta_value FROM `' . self::COMMENT_MISC . '` WHERE comment_id = %d AND meta_key = %s', + 8, + 'note_group' + ) + ); + $this->assertSame( 'baz', $value ); + } + + public function test_term_writes_do_not_pollute_comment_table(): void { + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 'k', 'term_val', '' ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::COMMENT_MISC . '`' ); + $this->assertSame( 0, $count, 'Term writes must not appear in comment misc table' ); + } + + // ── Empty meta_key guard ──────────────────────────────────────────────── + + public function test_empty_meta_key_returns_check_unchanged(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_add( null, 5, '', 'value', false ); + $this->assertNull( $result, 'Empty meta_key must not be written' ); + } + + public function test_non_string_meta_key_returns_check_unchanged(): void { + $result = WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 5, 123, 'value', '' ); + $this->assertNull( $result ); + } + + // ── is_enabled toggle ─────────────────────────────────────────────────── + + public function test_is_enabled_defaults_true(): void { + delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ); + $this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() ); + } + + public function test_is_enabled_respects_zero_value(): void { + update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' ); + $this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() ); + delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ); + } + + public function test_count_rows_returns_actual_count(): void { + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' ); + WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 2, 'b', 'y', '' ); + WPDO_Term_Comment_Misc_Bucket::on_comment_update( null, 5, 'c', 'z', '' ); + + $this->assertSame( 2, WPDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) ); + $this->assertSame( 1, WPDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) ); + } +} diff --git a/tests/integration/TermCommentShadowVerifierTest.php b/tests/integration/TermCommentShadowVerifierTest.php new file mode 100644 index 0000000..8428e71 --- /dev/null +++ b/tests/integration/TermCommentShadowVerifierTest.php @@ -0,0 +1,292 @@ +terms = self::TERMS; + $wpdb->termmeta = self::TERMMETA; + $wpdb->comments = self::COMMENTS; + $wpdb->commentmeta = self::COMMENTMETA; + + // Source tables (terms / comments) need term_id / comment_ID columns. + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERMS . '` ( + term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(200) NOT NULL DEFAULT "", + PRIMARY KEY (term_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENTS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENTS . '` ( + comment_ID bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_post_ID bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (comment_ID) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + term_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY term_id (term_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::COMMENTMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY comment_id (comment_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERM_FLAT . '` ( + term_id bigint(20) unsigned NOT NULL, + hp_sort_order int(11) DEFAULT NULL, + hp_default tinyint(1) DEFAULT NULL, + hp_icon varchar(64) DEFAULT NULL, + PRIMARY KEY (term_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::COMMENT_FLAT . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::COMMENT_FLAT . '` ( + comment_id bigint(20) unsigned NOT NULL, + hp_rating tinyint(1) DEFAULT NULL, + PRIMARY KEY (comment_id) + ) DEFAULT CHARACTER SET utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + foreach ( array( self::TERMS, self::COMMENTS, self::TERM_FLAT, self::COMMENT_FLAT ) as $tbl ) { + $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' ); + } + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENTMETA . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_FLAT . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::COMMENT_FLAT . '`' ); + } + + // ── sample_compare contract ───────────────────────────────────────────── + + public function test_sample_compare_invalid_entity_type_throws(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'bogus', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ) + ); + } + + public function test_sample_compare_zero_sample_size_throws(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 0 + ); + } + + public function test_sample_compare_empty_keys_throws(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array() + ); + } + + public function test_sample_compare_returns_zeros_for_empty_db(): void { + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ) + ); + $this->assertSame( 0, $result['sampled'] ); + $this->assertSame( 'term', $result['entity_type'] ); + $this->assertSame( 'hp_taxonomy', $result['group'] ); + } + + public function test_sample_compare_counts_matches_when_in_sync(): void { + global $wpdb; + + // 3 terms, all in sync between wp_termmeta and flat table. + for ( $i = 1; $i <= 3; $i++ ) { + $wpdb->insert( self::TERMS, array( 'term_id' => $i, 'name' => 'term_' . $i ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => $i, 'meta_key' => 'hp_sort_order', 'meta_value' => $i * 10 ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => $i, 'hp_sort_order' => $i * 10 ) ); + } + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 10 + ); + $this->assertSame( 3, $result['sampled'] ); + $this->assertSame( 3, $result['matched'] ); + $this->assertSame( 0, $result['diffs'] ); + $this->assertSame( 0, $result['missing_flat'] ); + $this->assertSame( 0, $result['missing_meta'] ); + } + + public function test_sample_compare_detects_missing_flat(): void { + global $wpdb; + + // 2 terms with wp_termmeta but no flat row. + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => 5 ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 2, 'meta_key' => 'hp_sort_order', 'meta_value' => 10 ) ); + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 10 + ); + $this->assertSame( 2, $result['sampled'] ); + $this->assertSame( 0, $result['matched'] ); + $this->assertSame( 2, $result['missing_flat'] ); + } + + public function test_sample_compare_detects_missing_meta(): void { + global $wpdb; + + // 2 terms with flat rows but no wp_termmeta. + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMS, array( 'term_id' => 2, 'name' => 'b' ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 2, 'hp_sort_order' => 10 ) ); + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 10 + ); + $this->assertSame( 2, $result['sampled'] ); + $this->assertSame( 2, $result['missing_meta'] ); + } + + public function test_sample_compare_detects_value_diff(): void { + global $wpdb; + + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 99 ) ); + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 10 + ); + $this->assertSame( 1, $result['diffs'] ); + $this->assertSame( 0, $result['matched'] ); + } + + public function test_sample_compare_loose_equal_matches_numeric(): void { + global $wpdb; + + $wpdb->insert( self::TERMS, array( 'term_id' => 1, 'name' => 'a' ) ); + // wp_termmeta stores '5' as string, flat stores 5 as int — should match + $wpdb->insert( self::TERMMETA, array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ) ); + $wpdb->insert( self::TERM_FLAT, array( 'term_id' => 1, 'hp_sort_order' => 5 ) ); + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'term', + 'hp_taxonomy', + self::TERM_FLAT, + array( 'hp_sort_order' ), + 10 + ); + $this->assertSame( 1, $result['matched'], 'String "5" must loose-equal int 5' ); + $this->assertSame( 0, $result['diffs'] ); + } + + public function test_sample_compare_handles_comment_entity(): void { + global $wpdb; + + $wpdb->insert( self::COMMENTS, array( 'comment_ID' => 1, 'comment_post_ID' => 100 ) ); + $wpdb->insert( self::COMMENTMETA, array( 'comment_id' => 1, 'meta_key' => 'hp_rating', 'meta_value' => 5 ) ); + $wpdb->insert( self::COMMENT_FLAT, array( 'comment_id' => 1, 'hp_rating' => 5 ) ); + + $result = WPDO_Term_Comment_Shadow_Verifier::sample_compare( + 'comment', + 'hp_review', + self::COMMENT_FLAT, + array( 'hp_rating' ), + 10 + ); + $this->assertSame( 1, $result['sampled'] ); + $this->assertSame( 1, $result['matched'] ); + $this->assertSame( 'comment', $result['entity_type'] ); + } + + // ── cron_tick mode-gating ─────────────────────────────────────────────── + + public function test_cron_tick_no_op_when_neither_in_shadow_read(): void { + // Set both modes to dual_write so cron should no-op. + if ( class_exists( 'WPDO_Mode_Manager' ) ) { + WPDO_Mode_Manager::set( 'term', 'dual_write' ); + WPDO_Mode_Manager::set( 'comment', 'dual_write' ); + } + + // cron_tick should return without error and not touch the tables. + WPDO_Term_Comment_Shadow_Verifier::cron_tick(); + $this->assertTrue( true ); // No exception = no-op succeeded + } +} diff --git a/tests/integration/TermStressTesterTest.php b/tests/integration/TermStressTesterTest.php new file mode 100644 index 0000000..452d583 --- /dev/null +++ b/tests/integration/TermStressTesterTest.php @@ -0,0 +1,315 @@ +terms = self::TERMS; + $wpdb->termmeta = self::TERMMETA; + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMS . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERMS . '` ( + term_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(200) NOT NULL DEFAULT "", + slug varchar(200) NOT NULL DEFAULT "", + term_group bigint(10) NOT NULL DEFAULT 0, + PRIMARY KEY (term_id), + KEY slug (slug(191)) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERM_TAXONOMY . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERM_TAXONOMY . '` ( + term_taxonomy_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + term_id bigint(20) unsigned NOT NULL DEFAULT 0, + taxonomy varchar(32) NOT NULL DEFAULT "", + description longtext, + parent bigint(20) unsigned NOT NULL DEFAULT 0, + count bigint(20) NOT NULL DEFAULT 0, + PRIMARY KEY (term_taxonomy_id), + KEY taxonomy (taxonomy) + ) DEFAULT CHARACTER SET utf8mb4' + ); + + $wpdb->query( 'CREATE TABLE IF NOT EXISTS `' . self::TERMMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + term_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY term_id (term_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4' ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + foreach ( array( self::TERMS, self::TERM_TAXONOMY, self::TERMMETA ) as $tbl ) { + $wpdb->query( 'DROP TABLE IF EXISTS `' . $tbl . '`' ); + } + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMS . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERM_TAXONOMY . '`' ); + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' ); + // Reset state per test so each starts idle. + unset( $GLOBALS['_wp_options'][ WPDO_Term_Stress_Tester::OPT_STATE ] ); + unset( $GLOBALS['_wp_transients'][ WPDO_Term_Stress_Tester::CANCEL_FLAG ] ); + unset( $GLOBALS['_wp_transients']['wpdo_term_stress_pump_lock'] ); + } + + // ── create() (fast-path direct SQL) ────────────────────────────────────── + + public function test_create_inserts_terms_into_taxonomy(): void { + $result = WPDO_Term_Stress_Tester::create( 'category', 5 ); + + $this->assertSame( 5, $result['created'] ); + $this->assertSame( 'category', $result['taxonomy'] ); + $this->assertNotNull( $result['first_id'] ); + $this->assertNotNull( $result['last_id'] ); + + global $wpdb; + $count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ); + $this->assertSame( 5, $count ); + $tax_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::TERM_TAXONOMY . "` WHERE taxonomy = 'category'" ); + $this->assertSame( 5, $tax_count ); + } + + public function test_create_uses_stress_slug_prefix(): void { + WPDO_Term_Stress_Tester::create( 'category', 3 ); + + global $wpdb; + $prefix_count = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM `" . self::TERMS . "` WHERE slug LIKE %s", + WPDO_Term_Stress_Tester::TEST_TERM_PREFIX . '%' + ) + ); + $this->assertSame( 3, $prefix_count ); + } + + public function test_create_seeds_termmeta_keys(): void { + WPDO_Term_Stress_Tester::create( 'category', 3 ); + + global $wpdb; + $total_meta = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ); + // 3 terms × 3 keys (hp_sort_order/hp_default/hp_icon) = 9 + $this->assertSame( 9, $total_meta ); + } + + public function test_create_rejects_empty_taxonomy(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Stress_Tester::create( '', 3 ); + } + + public function test_create_rejects_zero_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Stress_Tester::create( 'category', 0 ); + } + + public function test_create_rejects_excessive_count(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Term_Stress_Tester::create( 'category', 100001 ); + } + + // ── count_test_terms() ──────────────────────────────────────────────────── + + public function test_count_test_terms_returns_zero_for_empty(): void { + $this->assertSame( 0, WPDO_Term_Stress_Tester::count_test_terms() ); + } + + public function test_count_test_terms_counts_only_stress_prefix(): void { + WPDO_Term_Stress_Tester::create( 'category', 4 ); + + global $wpdb; + $wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) ); + + $this->assertSame( 4, WPDO_Term_Stress_Tester::count_test_terms() ); + } + + // ── cleanup() ───────────────────────────────────────────────────────────── + + public function test_cleanup_removes_test_terms_and_cascade(): void { + WPDO_Term_Stress_Tester::create( 'category', 5 ); + $this->assertSame( 5, WPDO_Term_Stress_Tester::count_test_terms() ); + + $result = WPDO_Term_Stress_Tester::cleanup(); + $this->assertSame( 5, $result['deleted_terms'] ); + + global $wpdb; + $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ) ); + $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ) ); + $this->assertSame( 0, (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERM_TAXONOMY . '`' ) ); + } + + public function test_cleanup_preserves_non_stress_terms(): void { + global $wpdb; + $wpdb->insert( self::TERMS, array( 'name' => 'Real', 'slug' => 'real-term', 'term_group' => 0 ) ); + WPDO_Term_Stress_Tester::create( 'category', 3 ); + + $result = WPDO_Term_Stress_Tester::cleanup(); + $this->assertSame( 3, $result['deleted_terms'] ); + + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMS . '`' ); + $this->assertSame( 1, $remaining ); + } + + public function test_cleanup_idempotent_on_empty(): void { + $first = WPDO_Term_Stress_Tester::cleanup(); + $second = WPDO_Term_Stress_Tester::cleanup(); + $this->assertSame( 0, $first['deleted_terms'] ); + $this->assertSame( 0, $second['deleted_terms'] ); + } + + // ── State machine ──────────────────────────────────────────────────────── + + public function test_get_state_returns_empty_when_idle(): void { + $this->assertSame( array(), WPDO_Term_Stress_Tester::get_state() ); + } + + public function test_get_progress_returns_idle_when_no_state(): void { + $progress = WPDO_Term_Stress_Tester::get_progress( false ); + $this->assertSame( 'idle', $progress['status'] ); + } + + public function test_start_persists_state_with_running_status(): void { + // Stub taxonomy_exists() — fall back to true via global flag. + $GLOBALS['_taxonomy_exists_override'] = true; + + $result = WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 ); + + $this->assertTrue( $result['ok'], 'start should succeed' ); + $state = $result['state']; + $this->assertSame( 'running', $state['status'] ); + $this->assertSame( 'category', $state['taxonomy'] ); + $this->assertSame( 'fast', $state['mode'] ); + $this->assertSame( 10, $state['target'] ); + $this->assertSame( 5, $state['batch_size'] ); + + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_start_rejects_unknown_taxonomy(): void { + $GLOBALS['_taxonomy_exists_override'] = false; + + $result = WPDO_Term_Stress_Tester::start( 'never_exists', 10 ); + $this->assertFalse( $result['ok'] ); + $this->assertStringContainsString( 'unknown_taxonomy', $result['error'] ); + + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_start_rejects_invalid_mode(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + $result = WPDO_Term_Stress_Tester::start( 'category', 10, 'turbo' ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'invalid mode', $result['error'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_start_rejects_concurrent_run(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + WPDO_Term_Stress_Tester::start( 'category', 10 ); + $result = WPDO_Term_Stress_Tester::start( 'category', 5 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'already_running', $result['error'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_run_batch_advances_processed_count(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + WPDO_Term_Stress_Tester::start( 'category', 6, 'fast', 3 ); + + WPDO_Term_Stress_Tester::run_batch(); + $progress = WPDO_Term_Stress_Tester::get_progress( false ); + $this->assertSame( 3, $progress['processed'] ); + $this->assertSame( 1, $progress['batches_done'] ); + $this->assertSame( 'running', $progress['status'] ); + + WPDO_Term_Stress_Tester::run_batch(); + $progress = WPDO_Term_Stress_Tester::get_progress( false ); + $this->assertSame( 6, $progress['processed'] ); + $this->assertSame( 'completed', $progress['status'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_cancel_marks_state_as_cancelled(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + WPDO_Term_Stress_Tester::start( 'category', 100, 'fast', 50 ); + + $result = WPDO_Term_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'cancelled', $result['state']['status'] ); + + // In-flight batch run after cancel must NOT bump status back to running. + WPDO_Term_Stress_Tester::run_batch(); + $state = WPDO_Term_Stress_Tester::get_state(); + $this->assertSame( 'cancelled', $state['status'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_cancel_returns_no_active_job_when_idle(): void { + $result = WPDO_Term_Stress_Tester::cancel(); + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'no_active_job', $result['message'] ?? '' ); + } + + public function test_get_progress_includes_pct_and_eta_keys(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + WPDO_Term_Stress_Tester::start( 'category', 10, 'fast', 5 ); + WPDO_Term_Stress_Tester::run_batch(); + + $progress = WPDO_Term_Stress_Tester::get_progress( false ); + $this->assertArrayHasKey( 'pct', $progress ); + $this->assertArrayHasKey( 'rate_per_sec', $progress ); + $this->assertArrayHasKey( 'elapsed_sec', $progress ); + $this->assertArrayHasKey( 'eta_sec', $progress ); + $this->assertArrayHasKey( 'test_term_count', $progress ); + $this->assertSame( 50.0, $progress['pct'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } + + public function test_run_benchmark_returns_structured_payload(): void { + $GLOBALS['_taxonomy_exists_override'] = true; + WPDO_Term_Stress_Tester::start( 'category', 4, 'fast', 4 ); + WPDO_Term_Stress_Tester::run_batch(); + + $state = WPDO_Term_Stress_Tester::get_state(); + $this->assertSame( 'completed', $state['status'] ); + $this->assertIsArray( $state['benchmark'] ); + $this->assertArrayHasKey( 'write', $state['benchmark'] ); + $this->assertArrayHasKey( 'db_sizes', $state['benchmark'] ); + $this->assertSame( 'category', $state['benchmark']['taxonomy'] ); + unset( $GLOBALS['_taxonomy_exists_override'] ); + } +} diff --git a/tests/integration/TermmetaCleanerIntegrationTest.php b/tests/integration/TermmetaCleanerIntegrationTest.php new file mode 100644 index 0000000..4233e37 --- /dev/null +++ b/tests/integration/TermmetaCleanerIntegrationTest.php @@ -0,0 +1,182 @@ +termmeta to point at our test table. + $wpdb->termmeta = self::TERMMETA; + + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TERMMETA . '` ( + meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + term_id bigint(20) unsigned NOT NULL DEFAULT 0, + meta_key varchar(255) DEFAULT NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY term_id (term_id), + KEY meta_key (meta_key(191)) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TERMMETA . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TERMMETA . '`' ); + } + + private function seed( array $rows ): void { + global $wpdb; + foreach ( $rows as $row ) { + $wpdb->insert( self::TERMMETA, $row ); + } + } + + // ── count_garbage ──────────────────────────────────────────────────────── + + public function test_count_garbage_returns_zero_for_empty_table(): void { + $counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 0, $counts['wxr_import'] ); + $this->assertSame( 0, $counts['demo_data'] ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 0, $counts['total'] ); + } + + public function test_count_garbage_counts_wxr_import(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_wxr_import_user_xyz', 'meta_value' => 'a' ), + array( 'term_id' => 2, 'meta_key' => '_wxr_import_post', 'meta_value' => 'b' ), + array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ), + ) ); + + $counts = WPDO_Termmeta_Cleaner::count_garbage( 'wxr_import' ); + $this->assertSame( 2, $counts['wxr_import'] ); + $this->assertSame( 0, $counts['demo_data'] ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 2, $counts['total'] ); + } + + public function test_count_garbage_counts_demo_data(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'term_id' => 2, 'meta_key' => '_2meet_demo_adv', 'meta_value' => '1' ), + array( 'term_id' => 3, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ), + ) ); + + $counts = WPDO_Termmeta_Cleaner::count_garbage( 'demo_data' ); + $this->assertSame( 0, $counts['wxr_import'] ); + $this->assertSame( 2, $counts['demo_data'] ); + $this->assertSame( 0, $counts['transients'] ); + $this->assertSame( 2, $counts['total'] ); + } + + public function test_count_garbage_counts_transients(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_transient_foo', 'meta_value' => 'a' ), + array( 'term_id' => 1, 'meta_key' => '_transient_timeout_foo', 'meta_value' => '9999' ), + array( 'term_id' => 2, 'meta_key' => 'hp_default', 'meta_value' => '1' ), + ) ); + + $counts = WPDO_Termmeta_Cleaner::count_garbage( 'transients' ); + $this->assertSame( 2, $counts['transients'] ); + $this->assertSame( 2, $counts['total'] ); + } + + public function test_count_garbage_all_unions_three_buckets(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + array( 'term_id' => 4, 'meta_key' => 'hp_icon', 'meta_value' => 'star' ), + array( 'term_id' => 5, 'meta_key' => 'hp_default', 'meta_value' => '1' ), + ) ); + + $counts = WPDO_Termmeta_Cleaner::count_garbage( 'all' ); + $this->assertSame( 1, $counts['wxr_import'] ); + $this->assertSame( 1, $counts['demo_data'] ); + $this->assertSame( 1, $counts['transients'] ); + $this->assertSame( 3, $counts['total'] ); + } + + // ── delete_garbage ──────────────────────────────────────────────────────── + + public function test_delete_garbage_removes_targeted_rows_only(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + array( 'term_id' => 4, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ), + ) ); + + $deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 1, $deleted['wxr_import'] ); + $this->assertSame( 1, $deleted['demo_data'] ); + $this->assertSame( 1, $deleted['transients'] ); + $this->assertSame( 3, $deleted['total'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ); + $this->assertSame( 1, $remaining, 'hp_sort_order must survive' ); + } + + public function test_delete_garbage_target_specific_only_removes_one_bucket(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => '_wxr_import_user', 'meta_value' => 'a' ), + array( 'term_id' => 2, 'meta_key' => '_2meet_demo_music', 'meta_value' => '1' ), + array( 'term_id' => 3, 'meta_key' => '_transient_foo', 'meta_value' => 'b' ), + ) ); + + $deleted = WPDO_Termmeta_Cleaner::delete_garbage( 'wxr_import' ); + $this->assertSame( 1, $deleted['wxr_import'] ); + $this->assertSame( 0, $deleted['demo_data'] ); + $this->assertSame( 0, $deleted['transients'] ); + $this->assertSame( 1, $deleted['total'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ); + $this->assertSame( 2, $remaining, '_2meet_demo + _transient must survive when target=wxr_import' ); + } + + public function test_delete_garbage_idempotent_on_clean_table(): void { + $this->seed( array( + array( 'term_id' => 1, 'meta_key' => 'hp_sort_order', 'meta_value' => '5' ), + ) ); + + $first = WPDO_Termmeta_Cleaner::delete_garbage( 'all' ); + $second = WPDO_Termmeta_Cleaner::delete_garbage( 'all' ); + $this->assertSame( 0, $first['total'] ); + $this->assertSame( 0, $second['total'] ); + + global $wpdb; + $remaining = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TERMMETA . '`' ); + $this->assertSame( 1, $remaining ); + } + + public function test_invalid_target_throws(): void { + $this->expectException( InvalidArgumentException::class ); + WPDO_Termmeta_Cleaner::count_garbage( 'bogus' ); + } +} diff --git a/tests/integration/UpgradeV2Test.php b/tests/integration/UpgradeV2Test.php new file mode 100644 index 0000000..2a73ed1 --- /dev/null +++ b/tests/integration/UpgradeV2Test.php @@ -0,0 +1,134 @@ +query( "DROP TABLE IF EXISTS `{$wpdb->prefix}wpdo_{$t}`" ); + } + // Drop any leftover wp_uae_* probe tables from prior test runs. + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" ); + } + + protected function setUp(): void { + // Clean options between tests for clean preconditions. + $GLOBALS['_wp_options'] = array(); + } + + // ── Pre-flight ────────────────────────────────────────────────────────── + + public function test_pre_flight_passes_in_clean_environment(): void { + $checks = WPDO_V2_Upgrader::pre_flight_check(); + $this->assertIsArray( $checks ); + // PHP / WP / MySQL versions in this CI/dev environment must satisfy minimums. + $this->assertTrue( $checks['php_version'] ); + $this->assertTrue( $checks['mysql_version'] ); + } + + public function test_pre_flight_detects_old_php(): void { + // Simulating an old PHP is impossible from PHP itself, so we only + // verify the keys are present + booleans. + $checks = WPDO_V2_Upgrader::pre_flight_check(); + foreach ( array( 'php_version', 'wp_version', 'mysql_version', 'free_disk_mb', 'features_writable', 'no_active_migration' ) as $key ) { + $this->assertArrayHasKey( $key, $checks ); + $this->assertIsBool( $checks[ $key ] ); + } + } + + // ── S1: clean install ───────────────────────────────────────────────── + + public function test_s1_clean_install_creates_v2_schema(): void { + WPDO_V2_Upgrader::upgrade_to_v2(); + $status = WPDO_Installer::v2_tables_status(); + foreach ( $status as $exists ) { + $this->assertTrue( $exists ); + } + } + + // ── S2: v1.3.x → v2.0.0 (no UAE) ─────────────────────────────────────── + + public function test_s2_upgrade_marks_db_version(): void { + // Simulate v1.3.x state. + update_option( 'wpdo_db_version', '1.0.0' ); + + $ok = WPDO_V2_Upgrader::upgrade_to_v2(); + $this->assertTrue( $ok ); + $this->assertSame( '2.0.0', get_option( 'wpdo_db_version' ) ); + $this->assertSame( 'complete', get_option( 'wpdo_v2_upgrade_status' ) ); + } + + public function test_s2_upgrade_seeds_entity_modules_in_features(): void { + update_option( 'wpdo_features', array( 'hot_hp_listing' => 'cutover' ) ); + WPDO_V2_Upgrader::upgrade_to_v2(); + + $flags = get_option( 'wpdo_features' ); + $this->assertSame( 'cutover', $flags['hot_hp_listing'], 'Pre-existing module state must be preserved' ); + + foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) { + $this->assertArrayHasKey( $module, $flags ); + $this->assertSame( 'idle', $flags[ $module ] ); + } + } + + public function test_s2_upgrade_idempotent(): void { + WPDO_V2_Upgrader::upgrade_to_v2(); + $ok = WPDO_V2_Upgrader::upgrade_to_v2(); // Second run must not fail. + $this->assertTrue( $ok ); + } + + // ── S3: UAE coexistence ─────────────────────────────────────────────── + + public function test_s3_detects_uae_data_when_table_present(): void { + global $wpdb; + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}uae_probe` ( id BIGINT PRIMARY KEY ) ENGINE=InnoDB" ); + $this->assertTrue( WPDO_V2_Upgrader::detect_uae_data() ); + + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}uae_probe`" ); + } + + public function test_s3_no_uae_data_in_clean_environment(): void { + // dev10 case: no wp_uae_* tables. + $this->assertFalse( WPDO_V2_Upgrader::detect_uae_data() ); + } + + // ── Rollback ─────────────────────────────────────────────────────────── + + public function test_rollback_restores_features_backup(): void { + $original = array( 'hot_hp_listing' => 'cutover' ); + update_option( 'wpdo_features', $original ); + + WPDO_V2_Upgrader::upgrade_to_v2(); + // Simulate user wants to roll back. + WPDO_V2_Upgrader::rollback_v2( true ); + + $this->assertSame( $original, get_option( 'wpdo_features' ) ); + $this->assertSame( '1.0.0', get_option( 'wpdo_db_version' ) ); + $this->assertSame( 'rolled_back', get_option( 'wpdo_v2_upgrade_status' ) ); + } + + public function test_rollback_with_keep_data_preserves_v2_tables(): void { + WPDO_V2_Upgrader::upgrade_to_v2(); + WPDO_V2_Upgrader::rollback_v2( true ); + + $status = WPDO_Installer::v2_tables_status(); + foreach ( $status as $table => $exists ) { + $this->assertTrue( $exists, "{$table} must remain after rollback with --keep-data" ); + } + } +} diff --git a/tests/integration/WarmArchiveIntegrationTest.php b/tests/integration/WarmArchiveIntegrationTest.php new file mode 100644 index 0000000..1b1e948 --- /dev/null +++ b/tests/integration/WarmArchiveIntegrationTest.php @@ -0,0 +1,269 @@ +prefix . 'wpdo_warm'; + self::$archive_table = $wpdb->prefix . 'wpdo_archive'; + self::$errors_table = $wpdb->prefix . 'wpdo_errors'; + self::$postmeta_table = $wpdb->postmeta; + + // DROP + CREATE ensures clean schema even after interrupted prior runs. + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + $wpdb->query( + "CREATE TABLE `" . self::$warm_table . "` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + meta_key VARCHAR(255) NOT NULL, + meta_value LONGTEXT, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY post_meta (post_id, meta_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Create archive table (matches WPDO_Installer::install_system_tables DDL). + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" ); + $wpdb->query( + "CREATE TABLE `" . self::$archive_table . "` ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + post_type VARCHAR(20) NOT NULL DEFAULT '', + meta_key VARCHAR(255) NOT NULL DEFAULT '', + meta_value LONGTEXT, + compressed TINYINT(1) NOT NULL DEFAULT 0, + archived_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + original_meta_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_post_id (post_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Create errors table (needed by WPDO_Logger). + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" ); + $wpdb->query( + "CREATE TABLE `" . self::$errors_table . "` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + severity VARCHAR(20) NOT NULL DEFAULT 'error', + module VARCHAR(100) NOT NULL DEFAULT '', + zone VARCHAR(50) DEFAULT NULL, + hook VARCHAR(100) NOT NULL DEFAULT '', + message TEXT NOT NULL, + context LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Create postmeta table (needed by flush_views_to_postmeta batch query). + // Only create if it does not already exist — shared with other test classes. + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$postmeta_table . "` ( + 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 DEFAULT CHARSET=utf8mb4" + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$archive_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$errors_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$postmeta_table . "`" ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE `" . self::$warm_table . "`" ); + $wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" ); + $GLOBALS['_wp_cache'] = []; + $GLOBALS['_wp_postmeta'] = []; + $GLOBALS['_wp_options'] = []; + } + + // ── Zone B (Warm) ───────────────────────────────────────────────────────── + + public function test_warm_set_and_get(): void { + WPDO_Zone_Warm::set( 100, 'wp_key', 'hello', null ); + $val = WPDO_Zone_Warm::get( 100, 'wp_key' ); + $this->assertSame( 'hello', $val ); + } + + public function test_warm_expired_returns_null(): void { + global $wpdb; + // Insert already-expired entry. + $wpdb->query( + "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at) + VALUES (101, 'stale_key', 'old', '2000-01-01 00:00:00')" + ); + + $val = WPDO_Zone_Warm::get( 101, 'stale_key' ); + $this->assertNull( $val ); + } + + public function test_warm_purge_expired(): void { + global $wpdb; + $wpdb->query( + "INSERT INTO `" . self::$warm_table . "` (post_id, meta_key, meta_value, expires_at) + VALUES (102, 'k1', 'v1', '2000-01-01 00:00:00'), + (103, 'k2', 'v2', DATE_ADD(NOW(), INTERVAL 1 HOUR))" + ); + + $deleted = WPDO_Zone_Warm::purge_expired(); + $this->assertGreaterThanOrEqual( 1, $deleted ); + + // k2 (future TTL) should still exist. + $this->assertNotNull( WPDO_Zone_Warm::get( 103, 'k2' ) ); + } + + public function test_warm_delete_all_for_post(): void { + WPDO_Zone_Warm::set( 200, 'a', 'va', null ); + WPDO_Zone_Warm::set( 200, 'b', 'vb', null ); + WPDO_Zone_Warm::set( 201, 'a', 'other', null ); + + WPDO_Zone_Warm::delete_all( 200 ); + + $this->assertNull( WPDO_Zone_Warm::get( 200, 'a' ) ); + $this->assertNull( WPDO_Zone_Warm::get( 200, 'b' ) ); + $this->assertSame( 'other', WPDO_Zone_Warm::get( 201, 'a' ) ); + } + + // ── Zone D (Archive) ────────────────────────────────────────────────────── + + /** Helper: build a row for archive_batch with required post_type. */ + private function archive_rows( int $post_id, array $metas ): array { + return array_map( fn( $m ) => array_merge( + [ 'post_id' => $post_id, 'post_type' => 'hp_listing', 'meta_id' => 0 ], + $m + ), $metas ); + } + + public function test_archive_batch_stores_compressed(): void { + global $wpdb; + $rows = $this->archive_rows( 400, [ + [ 'meta_key' => 'hp_price', 'meta_value' => '999' ], + [ 'meta_key' => 'hp_featured', 'meta_value' => '1' ], + ] ); + + WPDO_Zone_Archive::archive_batch( $rows, true ); + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 400" ); + $this->assertSame( 2, $count ); + + $comp = (int) $wpdb->get_var( "SELECT SUM(compressed) FROM `" . self::$archive_table . "` WHERE post_id = 400" ); + $this->assertSame( 2, $comp ); + } + + public function test_archive_get_returns_values(): void { + $rows = $this->archive_rows( 401, [ + [ 'meta_key' => 'hp_price', 'meta_value' => '500' ], + [ 'meta_key' => 'hp_verified', 'meta_value' => '1' ], + ] ); + WPDO_Zone_Archive::archive_batch( $rows, false ); + + $result = WPDO_Zone_Archive::get( 401 ); + $this->assertCount( 2, $result ); + + $by_key = array_column( $result, 'meta_value', 'meta_key' ); + $this->assertSame( '500', $by_key['hp_price'] ); + $this->assertSame( '1', $by_key['hp_verified'] ); + } + + public function test_archive_get_with_key_filter(): void { + $rows = $this->archive_rows( 402, [ + [ 'meta_key' => 'hp_price', 'meta_value' => '250' ], + [ 'meta_key' => 'hp_featured', 'meta_value' => '0' ], + ] ); + WPDO_Zone_Archive::archive_batch( $rows, false ); + + $result = WPDO_Zone_Archive::get( 402, 'hp_price' ); + $this->assertCount( 1, $result ); + $this->assertSame( 'hp_price', $result[0]['meta_key'] ); + } + + public function test_archive_restore_writes_postmeta(): void { + global $wpdb; + $rows = $this->archive_rows( 403, [ + [ 'meta_key' => 'hp_price', 'meta_value' => '777' ], + ] ); + WPDO_Zone_Archive::archive_batch( $rows, false ); + + $restored = WPDO_Zone_Archive::restore( 403 ); + $this->assertSame( 1, $restored ); + + $pm = $GLOBALS['_wp_postmeta'][403]['hp_price'] ?? null; + $this->assertSame( '777', $pm ); + + $remaining = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 403" ); + $this->assertSame( 0, $remaining ); + } + + public function test_archive_gzip_roundtrip(): void { + $rows = $this->archive_rows( 404, [ + [ 'meta_key' => 'hp_description', 'meta_value' => str_repeat( 'Lorem ipsum ', 50 ) ], + ] ); + WPDO_Zone_Archive::archive_batch( $rows, true ); + + $result = WPDO_Zone_Archive::get( 404 ); + $this->assertCount( 1, $result ); + $this->assertStringContainsString( 'Lorem ipsum', $result[0]['meta_value'] ); + } + + public function test_archive_delete_removes_rows(): void { + global $wpdb; + $rows = $this->archive_rows( 405, [ + [ 'meta_key' => 'hp_price', 'meta_value' => '1' ], + ] ); + WPDO_Zone_Archive::archive_batch( $rows, false ); + + WPDO_Zone_Archive::delete( 405 ); + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$archive_table . "` WHERE post_id = 405" ); + $this->assertSame( 0, $count ); + } + + public function test_archive_stats_counts_correctly(): void { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE `" . self::$archive_table . "`" ); + + WPDO_Zone_Archive::archive_batch( $this->archive_rows( 500, [ + [ 'meta_key' => 'k1', 'meta_value' => 'a' ], + [ 'meta_key' => 'k2', 'meta_value' => 'b' ], + ] ), true ); + WPDO_Zone_Archive::archive_batch( $this->archive_rows( 502, [ + [ 'meta_key' => 'k3', 'meta_value' => 'c' ], + ] ), false ); + + $stats = WPDO_Zone_Archive::stats(); + $this->assertSame( 3, $stats['total_rows'] ); + $this->assertSame( 2, $stats['compressed_rows'] ); + } +} diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php new file mode 100644 index 0000000..1c12e21 --- /dev/null +++ b/tests/integration/ZoneArchiveIntegrationTest.php @@ -0,0 +1,203 @@ +query( + 'CREATE TABLE IF NOT EXISTS `' . self::TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `post_type` varchar(20) NOT NULL DEFAULT \'\', + `meta_key` varchar(255) NOT NULL DEFAULT \'\', + `meta_value` longtext DEFAULT NULL, + `compressed` tinyint(1) NOT NULL DEFAULT 0, + `archived_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + `original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `post_id` (`post_id`), + KEY `archived_at` (`archived_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + $GLOBALS['_wp_postmeta'] = []; + } + + // ── archive / get ───────────────────────────────────────────────────── + + public function test_archive_stores_plain_entry(): void { + WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '199.99' ); + + $rows = WPDO_Zone_Archive::get( 1 ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'hp_price', $rows[0]['meta_key'] ); + $this->assertSame( '199.99', $rows[0]['meta_value'] ); + } + + public function test_archive_with_compression_stores_and_decompresses(): void { + $original = 'large content that compresses well: ' . str_repeat( 'abcdef', 50 ); + WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_desc', $original, 0, true ); + + $rows = WPDO_Zone_Archive::get( 2, 'hp_desc' ); + $this->assertCount( 1, $rows ); + // get() decompresses automatically — value must match original. + $this->assertSame( $original, $rows[0]['meta_value'] ); + } + + public function test_get_with_meta_key_filter_returns_only_that_key(): void { + WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', '50.00' ); + WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_featured', '1' ); + WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_verified', '1' ); + + $rows = WPDO_Zone_Archive::get( 3, 'hp_price' ); + $this->assertCount( 1, $rows ); + $this->assertSame( 'hp_price', $rows[0]['meta_key'] ); + } + + public function test_get_without_filter_returns_all_keys(): void { + WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_price', '75.00' ); + WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_featured', '0' ); + + $rows = WPDO_Zone_Archive::get( 4 ); + $this->assertCount( 2, $rows ); + } + + public function test_get_returns_empty_for_missing_post(): void { + $rows = WPDO_Zone_Archive::get( 9999 ); + $this->assertSame( [], $rows ); + } + + // ── archive_batch ──────────────────────────────────────────────────── + + public function test_archive_batch_stores_all_entries_in_transaction(): void { + $entries = [ + [ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100.00', 'meta_id' => 0 ], + [ 'post_id' => 5, 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'meta_value' => '1', 'meta_id' => 0 ], + [ 'post_id' => 6, 'post_type' => 'hp_vendor', 'meta_key' => 'hp_rate', 'meta_value' => '50.00', 'meta_id' => 0 ], + ]; + + WPDO_Zone_Archive::archive_batch( $entries ); + + $this->assertCount( 2, WPDO_Zone_Archive::get( 5 ) ); + $this->assertCount( 1, WPDO_Zone_Archive::get( 6 ) ); + } + + public function test_archive_batch_with_compression(): void { + $original = str_repeat( 'x', 200 ); + WPDO_Zone_Archive::archive_batch( + [ [ 'post_id' => 7, 'post_type' => 'hp_listing', 'meta_key' => 'hp_desc', 'meta_value' => $original, 'meta_id' => 0 ] ], + true + ); + + $rows = WPDO_Zone_Archive::get( 7, 'hp_desc' ); + $this->assertSame( $original, $rows[0]['meta_value'] ); + } + + // ── delete ─────────────────────────────────────────────────────────── + + public function test_delete_removes_all_entries_for_post(): void { + WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_price', '30.00' ); + WPDO_Zone_Archive::archive( 8, 'hp_listing', 'hp_featured', '1' ); + $this->assertCount( 2, WPDO_Zone_Archive::get( 8 ) ); + + WPDO_Zone_Archive::delete( 8 ); + $this->assertSame( [], WPDO_Zone_Archive::get( 8 ) ); + } + + public function test_delete_does_not_affect_other_posts(): void { + WPDO_Zone_Archive::archive( 9, 'hp_listing', 'hp_price', '10.00' ); + WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '20.00' ); + + WPDO_Zone_Archive::delete( 9 ); + + $this->assertSame( [], WPDO_Zone_Archive::get( 9 ) ); + $this->assertCount( 1, WPDO_Zone_Archive::get( 10 ) ); + } + + // ── stats ───────────────────────────────────────────────────────────── + + public function test_stats_counts_total_and_compressed_rows(): void { + WPDO_Zone_Archive::archive( 11, 'hp_listing', 'hp_price', '1.00', 0, false ); + WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '2.00', 0, true ); + WPDO_Zone_Archive::archive( 13, 'hp_listing', 'hp_price', '3.00', 0, true ); + + $stats = WPDO_Zone_Archive::stats(); + $this->assertSame( 3, $stats['total_rows'] ); + $this->assertSame( 2, $stats['compressed_rows'] ); + } + + public function test_stats_groups_by_post_type(): void { + WPDO_Zone_Archive::archive( 14, 'hp_listing', 'hp_price', '1.00' ); + WPDO_Zone_Archive::archive( 15, 'hp_listing', 'hp_price', '2.00' ); + WPDO_Zone_Archive::archive( 16, 'hp_vendor', 'hp_rate', '3.00' ); + + $stats = WPDO_Zone_Archive::stats(); + $type_map = array_column( $stats['post_types'], 'cnt', 'post_type' ); + + $this->assertSame( '2', $type_map['hp_listing'] ); + $this->assertSame( '1', $type_map['hp_vendor'] ); + } + + public function test_stats_returns_zeros_on_empty_table(): void { + $stats = WPDO_Zone_Archive::stats(); + $this->assertSame( 0, $stats['total_rows'] ); + $this->assertSame( 0, $stats['compressed_rows'] ); + $this->assertSame( [], $stats['post_types'] ); + } + + // ── restore ─────────────────────────────────────────────────────────── + + public function test_restore_writes_to_postmeta_and_removes_from_archive(): void { + WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_price', '99.00' ); + WPDO_Zone_Archive::archive( 17, 'hp_listing', 'hp_featured', '1' ); + + $count = WPDO_Zone_Archive::restore( 17 ); + + // Two entries restored. + $this->assertSame( 2, $count ); + + // Postmeta updated via stub. + $this->assertSame( '99.00', $GLOBALS['_wp_postmeta'][17]['hp_price'] ); + $this->assertSame( '1', $GLOBALS['_wp_postmeta'][17]['hp_featured'] ); + + // Archive cleared. + $this->assertSame( [], WPDO_Zone_Archive::get( 17 ) ); + } + + public function test_restore_with_meta_key_filter_only_restores_that_key(): void { + WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_price', '55.00' ); + WPDO_Zone_Archive::archive( 18, 'hp_listing', 'hp_featured', '0' ); + + $count = WPDO_Zone_Archive::restore( 18, 'hp_price' ); + + $this->assertSame( 1, $count ); + $this->assertSame( '55.00', $GLOBALS['_wp_postmeta'][18]['hp_price'] ); + + // hp_featured should still be in archive. + $remaining = WPDO_Zone_Archive::get( 18 ); + $this->assertCount( 1, $remaining ); + $this->assertSame( 'hp_featured', $remaining[0]['meta_key'] ); + } +} diff --git a/tests/integration/ZoneColdIntegrationTest.php b/tests/integration/ZoneColdIntegrationTest.php new file mode 100644 index 0000000..6f8c226 --- /dev/null +++ b/tests/integration/ZoneColdIntegrationTest.php @@ -0,0 +1,223 @@ +prefix . 'wpdo_cold_itest' */ + private static string $table; + + // ── Fixture lifecycle ───────────────────────────────────────────────────── + + public static function setUpBeforeClass(): void { + global $wpdb; + self::$table = $wpdb->prefix . 'wpdo_cold_' . self::POST_TYPE; + + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `" . self::$table . "` ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + data LONGTEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY ui_post_id (post_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `" . self::$table . "`" ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE `" . self::$table . "`" ); + $GLOBALS['_wp_cache'] = []; + } + + // ── set / get ───────────────────────────────────────────────────────────── + + public function test_set_and_get_single_field(): void { + WPDO_Zone_Cold::set( 100, self::POST_TYPE, 'hp_description', 'Hello World' ); + $val = WPDO_Zone_Cold::get( 100, self::POST_TYPE, 'hp_description' ); + $this->assertSame( 'Hello World', $val ); + } + + public function test_get_returns_null_for_missing_key(): void { + WPDO_Zone_Cold::set( 101, self::POST_TYPE, 'hp_description', 'present' ); + $val = WPDO_Zone_Cold::get( 101, self::POST_TYPE, 'missing_key' ); + $this->assertNull( $val ); + } + + public function test_get_returns_null_for_missing_post(): void { + $val = WPDO_Zone_Cold::get( 9999, self::POST_TYPE, 'hp_description' ); + $this->assertNull( $val ); + } + + public function test_set_overwrites_existing_value(): void { + WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://old.example.com' ); + WPDO_Zone_Cold::set( 102, self::POST_TYPE, 'hp_website', 'http://new.example.com' ); + $val = WPDO_Zone_Cold::get( 102, self::POST_TYPE, 'hp_website' ); + $this->assertSame( 'http://new.example.com', $val ); + } + + public function test_set_preserves_other_keys_in_blob(): void { + WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_description', 'Keep me' ); + WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://keep.example.com' ); + + // Update only one key. + WPDO_Zone_Cold::set( 103, self::POST_TYPE, 'hp_website', 'https://updated.example.com' ); + + $this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_description' ) ); + $this->assertSame( 'https://updated.example.com', WPDO_Zone_Cold::get( 103, self::POST_TYPE, 'hp_website' ) ); + } + + // ── set_many / get_blob ─────────────────────────────────────────────────── + + public function test_set_many_stores_multiple_fields(): void { + WPDO_Zone_Cold::set_many( 200, self::POST_TYPE, [ + 'hp_description' => 'A great listing', + 'hp_website' => 'https://example.com', + 'hp_facebook' => 'https://facebook.com/test', + ] ); + + $this->assertSame( 'A great listing', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_description' ) ); + $this->assertSame( 'https://example.com', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_website' ) ); + $this->assertSame( 'https://facebook.com/test', WPDO_Zone_Cold::get( 200, self::POST_TYPE, 'hp_facebook' ) ); + } + + public function test_get_blob_returns_all_fields(): void { + WPDO_Zone_Cold::set_many( 201, self::POST_TYPE, [ + 'hp_description' => 'Blob test', + 'hp_website' => 'https://blob.example.com', + ] ); + + $blob = WPDO_Zone_Cold::get_blob( 201, self::POST_TYPE ); + $this->assertIsArray( $blob ); + $this->assertArrayHasKey( 'hp_description', $blob ); + $this->assertArrayHasKey( 'hp_website', $blob ); + $this->assertSame( 'Blob test', $blob['hp_description'] ); + $this->assertSame( 'https://blob.example.com', $blob['hp_website'] ); + } + + public function test_get_blob_returns_empty_array_for_missing_post(): void { + $blob = WPDO_Zone_Cold::get_blob( 9998, self::POST_TYPE ); + $this->assertIsArray( $blob ); + $this->assertEmpty( $blob ); + } + + public function test_set_many_merges_with_existing_blob(): void { + WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_description' => 'First' ] ); + WPDO_Zone_Cold::set_many( 202, self::POST_TYPE, [ 'hp_website' => 'https://merge.example.com' ] ); + + $this->assertSame( 'First', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_description' ) ); + $this->assertSame( 'https://merge.example.com', WPDO_Zone_Cold::get( 202, self::POST_TYPE, 'hp_website' ) ); + } + + // ── remove ──────────────────────────────────────────────────────────────── + + public function test_remove_key_from_blob(): void { + WPDO_Zone_Cold::set_many( 300, self::POST_TYPE, [ + 'hp_description' => 'Keep me', + 'hp_website' => 'https://remove.example.com', + ] ); + + WPDO_Zone_Cold::remove( 300, self::POST_TYPE, 'hp_website' ); + + $this->assertSame( 'Keep me', WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_description' ) ); + $this->assertNull( WPDO_Zone_Cold::get( 300, self::POST_TYPE, 'hp_website' ) ); + } + + public function test_remove_nonexistent_key_does_not_error(): void { + WPDO_Zone_Cold::set( 301, self::POST_TYPE, 'hp_description', 'Safe' ); + WPDO_Zone_Cold::remove( 301, self::POST_TYPE, 'no_such_key' ); + // Original key should still be intact. + $this->assertSame( 'Safe', WPDO_Zone_Cold::get( 301, self::POST_TYPE, 'hp_description' ) ); + } + + // ── delete ──────────────────────────────────────────────────────────────── + + public function test_delete_removes_row(): void { + global $wpdb; + WPDO_Zone_Cold::set( 400, self::POST_TYPE, 'hp_description', 'To be deleted' ); + + WPDO_Zone_Cold::delete( 400, self::POST_TYPE ); + + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `" . self::$table . "` WHERE post_id = 400" ); + $this->assertSame( 0, $count ); + $this->assertNull( WPDO_Zone_Cold::get( 400, self::POST_TYPE, 'hp_description' ) ); + } + + public function test_delete_nonexistent_post_does_not_error(): void { + WPDO_Zone_Cold::delete( 9997, self::POST_TYPE ); + $this->assertTrue( true ); // Must not throw. + } + + // ── Object Cache ────────────────────────────────────────────────────────── + + public function test_get_blob_populates_object_cache(): void { + WPDO_Zone_Cold::set( 500, self::POST_TYPE, 'hp_description', 'Cached value' ); + + // Clear cache to force a DB read on the next call. + $GLOBALS['_wp_cache'] = []; + + // First get: reads from DB, warms the cache. + $val = WPDO_Zone_Cold::get( 500, self::POST_TYPE, 'hp_description' ); + $this->assertSame( 'Cached value', $val ); + + // Cache entry must now exist. + $cached = wp_cache_get( 'cold_500', 'wpdo_cold_itest' ); + $this->assertIsArray( $cached ); + $this->assertSame( 'Cached value', $cached['hp_description'] ); + } + + public function test_set_invalidates_object_cache(): void { + WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Original' ); + + // Warm the cache by reading once. + WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' ); + $this->assertNotFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) ); + + // Write a new value — must invalidate the cached blob. + WPDO_Zone_Cold::set( 501, self::POST_TYPE, 'hp_description', 'Updated' ); + $this->assertFalse( wp_cache_get( 'cold_501', 'wpdo_cold_itest' ) ); + + // Subsequent read must return the updated value (from DB). + $val = WPDO_Zone_Cold::get( 501, self::POST_TYPE, 'hp_description' ); + $this->assertSame( 'Updated', $val ); + } + + public function test_delete_invalidates_object_cache(): void { + WPDO_Zone_Cold::set( 502, self::POST_TYPE, 'hp_description', 'Will be deleted' ); + + // Warm the cache. + WPDO_Zone_Cold::get( 502, self::POST_TYPE, 'hp_description' ); + + // Delete — must clear cache. + WPDO_Zone_Cold::delete( 502, self::POST_TYPE ); + $this->assertFalse( wp_cache_get( 'cold_502', 'wpdo_cold_itest' ) ); + } + + // ── isolation ───────────────────────────────────────────────────────────── + + public function test_different_post_ids_are_independent(): void { + WPDO_Zone_Cold::set( 600, self::POST_TYPE, 'hp_description', 'Post 600' ); + WPDO_Zone_Cold::set( 601, self::POST_TYPE, 'hp_description', 'Post 601' ); + + $this->assertSame( 'Post 600', WPDO_Zone_Cold::get( 600, self::POST_TYPE, 'hp_description' ) ); + $this->assertSame( 'Post 601', WPDO_Zone_Cold::get( 601, self::POST_TYPE, 'hp_description' ) ); + } +} diff --git a/tests/integration/ZoneHotIntegrationTest.php b/tests/integration/ZoneHotIntegrationTest.php new file mode 100644 index 0000000..a138942 --- /dev/null +++ b/tests/integration/ZoneHotIntegrationTest.php @@ -0,0 +1,126 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `hp_price` decimal(10,2) NOT NULL DEFAULT 0, + `hp_featured` tinyint(1) NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`), + UNIQUE KEY `post_id` (`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + } + + // ── set / get ──────────────────────────────────────────────────────── + + public function test_set_and_get_single_field(): void { + WPDO_Zone_Hot::set( 1, self::POST_TYPE, 'hp_price', '199.99' ); + $val = WPDO_Zone_Hot::get( 1, self::POST_TYPE, 'hp_price' ); + $this->assertSame( '199.99', $val ); + } + + public function test_get_returns_null_for_missing_post(): void { + $val = WPDO_Zone_Hot::get( 9999, self::POST_TYPE, 'hp_price' ); + $this->assertNull( $val ); + } + + public function test_set_overwrites_existing_value(): void { + WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '50.00' ); + WPDO_Zone_Hot::set( 2, self::POST_TYPE, 'hp_price', '75.00' ); + $val = WPDO_Zone_Hot::get( 2, self::POST_TYPE, 'hp_price' ); + $this->assertSame( '75.00', $val ); + } + + public function test_set_featured_integer_field(): void { + WPDO_Zone_Hot::set( 3, self::POST_TYPE, 'hp_featured', '1' ); + $val = WPDO_Zone_Hot::get( 3, self::POST_TYPE, 'hp_featured' ); + $this->assertSame( '1', $val ); + } + + // ── set_many / get_row ─────────────────────────────────────────────── + + public function test_set_many_stores_multiple_columns(): void { + WPDO_Zone_Hot::set_many( 4, self::POST_TYPE, [ + 'hp_price' => '299.00', + 'hp_featured' => '1', + ] ); + + $row = WPDO_Zone_Hot::get_row( 4, self::POST_TYPE ); + $this->assertIsArray( $row ); + $this->assertSame( '299.00', $row['hp_price'] ); + $this->assertSame( '1', $row['hp_featured'] ); + } + + public function test_set_many_overwrites_on_second_call(): void { + WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '100.00', 'hp_featured' => '0' ] ); + WPDO_Zone_Hot::set_many( 5, self::POST_TYPE, [ 'hp_price' => '200.00', 'hp_featured' => '1' ] ); + + $row = WPDO_Zone_Hot::get_row( 5, self::POST_TYPE ); + $this->assertSame( '200.00', $row['hp_price'] ); + $this->assertSame( '1', $row['hp_featured'] ); + } + + public function test_get_row_returns_null_for_missing_post(): void { + $row = WPDO_Zone_Hot::get_row( 9998, self::POST_TYPE ); + $this->assertNull( $row ); + } + + // ── delete ─────────────────────────────────────────────────────────── + + public function test_delete_removes_row(): void { + WPDO_Zone_Hot::set( 6, self::POST_TYPE, 'hp_price', '42.00' ); + $this->assertNotNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) ); + + WPDO_Zone_Hot::delete( 6, self::POST_TYPE ); + $this->assertNull( WPDO_Zone_Hot::get( 6, self::POST_TYPE, 'hp_price' ) ); + } + + public function test_delete_nonexistent_post_does_not_error(): void { + // Should complete without throwing. + WPDO_Zone_Hot::delete( 9997, self::POST_TYPE ); + $this->assertTrue( true ); + } + + // ── isolation ──────────────────────────────────────────────────────── + + public function test_different_post_ids_are_independent(): void { + WPDO_Zone_Hot::set( 10, self::POST_TYPE, 'hp_price', '10.00' ); + WPDO_Zone_Hot::set( 11, self::POST_TYPE, 'hp_price', '11.00' ); + + $this->assertSame( '10.00', WPDO_Zone_Hot::get( 10, self::POST_TYPE, 'hp_price' ) ); + $this->assertSame( '11.00', WPDO_Zone_Hot::get( 11, self::POST_TYPE, 'hp_price' ) ); + } +} diff --git a/tests/integration/ZoneWarmIntegrationTest.php b/tests/integration/ZoneWarmIntegrationTest.php new file mode 100644 index 0000000..730ad81 --- /dev/null +++ b/tests/integration/ZoneWarmIntegrationTest.php @@ -0,0 +1,169 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( + 'CREATE TABLE `' . self::TABLE . '` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `post_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `meta_key` varchar(255) NOT NULL DEFAULT \'\', + `meta_value` longtext DEFAULT NULL, + `expires_at` datetime DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', + PRIMARY KEY (`id`), + KEY `post_id` (`post_id`), + KEY `expires_at` (`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + } + + public static function tearDownAfterClass(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + } + + protected function setUp(): void { + global $wpdb; + $wpdb->query( 'TRUNCATE TABLE `' . self::TABLE . '`' ); + } + + // ── set / get ──────────────────────────────────────────────────────── + + public function test_set_and_get_basic(): void { + WPDO_Zone_Warm::set( 1, 'test_key', 'hello' ); + $this->assertSame( 'hello', WPDO_Zone_Warm::get( 1, 'test_key' ) ); + } + + public function test_get_returns_null_for_missing_key(): void { + $this->assertNull( WPDO_Zone_Warm::get( 9999, 'no_such_key' ) ); + } + + public function test_set_overwrites_existing_value(): void { + WPDO_Zone_Warm::set( 2, 'counter', '1' ); + WPDO_Zone_Warm::set( 2, 'counter', '5' ); + $this->assertSame( '5', WPDO_Zone_Warm::get( 2, 'counter' ) ); + } + + // ── TTL / expiry ───────────────────────────────────────────────────── + + public function test_set_with_future_ttl_is_readable(): void { + WPDO_Zone_Warm::set( 3, 'flag', 'active', 3600 ); // expires in 1 hour + $this->assertSame( 'active', WPDO_Zone_Warm::get( 3, 'flag' ) ); + } + + public function test_expired_entry_returns_null(): void { + global $wpdb; + // Insert directly with a past expiry timestamp. + $wpdb->query( + "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at) + VALUES (4, 'old_flag', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + $this->assertNull( WPDO_Zone_Warm::get( 4, 'old_flag' ) ); + } + + public function test_null_ttl_entry_never_expires(): void { + WPDO_Zone_Warm::set( 5, 'permanent', 'stays', null ); + $this->assertSame( 'stays', WPDO_Zone_Warm::get( 5, 'permanent' ) ); + } + + // ── delete ─────────────────────────────────────────────────────────── + + public function test_delete_removes_specific_key(): void { + WPDO_Zone_Warm::set( 6, 'key_a', 'alpha' ); + WPDO_Zone_Warm::set( 6, 'key_b', 'beta' ); + + WPDO_Zone_Warm::delete( 6, 'key_a' ); + + $this->assertNull( WPDO_Zone_Warm::get( 6, 'key_a' ) ); + $this->assertSame( 'beta', WPDO_Zone_Warm::get( 6, 'key_b' ) ); + } + + public function test_delete_all_removes_all_keys_for_post(): void { + WPDO_Zone_Warm::set( 7, 'x', '1' ); + WPDO_Zone_Warm::set( 7, 'y', '2' ); + WPDO_Zone_Warm::set( 7, 'z', '3' ); + + WPDO_Zone_Warm::delete_all( 7 ); + + $this->assertNull( WPDO_Zone_Warm::get( 7, 'x' ) ); + $this->assertNull( WPDO_Zone_Warm::get( 7, 'y' ) ); + $this->assertNull( WPDO_Zone_Warm::get( 7, 'z' ) ); + } + + public function test_delete_all_does_not_affect_other_posts(): void { + WPDO_Zone_Warm::set( 8, 'shared_key', 'post_8' ); + WPDO_Zone_Warm::set( 9, 'shared_key', 'post_9' ); + + WPDO_Zone_Warm::delete_all( 8 ); + + $this->assertNull( WPDO_Zone_Warm::get( 8, 'shared_key' ) ); + $this->assertSame( 'post_9', WPDO_Zone_Warm::get( 9, 'shared_key' ) ); + } + + // ── purge_expired ──────────────────────────────────────────────────── + + public function test_purge_expired_removes_stale_entries(): void { + global $wpdb; + // One expired entry. + $wpdb->query( + "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at) + VALUES (10, 'stale', 'gone', '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + // One valid entry. + WPDO_Zone_Warm::set( 10, 'fresh', 'keep', 3600 ); + + $deleted = WPDO_Zone_Warm::purge_expired(); + + $this->assertSame( 1, $deleted ); + $this->assertNull( WPDO_Zone_Warm::get( 10, 'stale' ) ); + $this->assertSame( 'keep', WPDO_Zone_Warm::get( 10, 'fresh' ) ); + } + + public function test_purge_expired_returns_zero_when_nothing_stale(): void { + WPDO_Zone_Warm::set( 11, 'live', 'value', 3600 ); + $this->assertSame( 0, WPDO_Zone_Warm::purge_expired() ); + } + + // ── get_all ────────────────────────────────────────────────────────── + + public function test_get_all_returns_all_valid_keys_for_post(): void { + WPDO_Zone_Warm::set( 12, 'ka', 'va' ); + WPDO_Zone_Warm::set( 12, 'kb', 'vb' ); + + $all = WPDO_Zone_Warm::get_all( 12 ); + $this->assertArrayHasKey( 'ka', $all ); + $this->assertArrayHasKey( 'kb', $all ); + $this->assertSame( 'va', $all['ka'] ); + $this->assertSame( 'vb', $all['kb'] ); + } + + public function test_get_all_excludes_expired_entries(): void { + global $wpdb; + WPDO_Zone_Warm::set( 13, 'live', 'yes' ); + $wpdb->query( + "INSERT INTO `" . self::TABLE . "` (post_id, meta_key, meta_value, expires_at, created_at) + VALUES (13, 'dead', 'no', '2000-01-01 00:00:00', '2000-01-01 00:00:00')" + ); + + $all = WPDO_Zone_Warm::get_all( 13 ); + $this->assertArrayHasKey( 'live', $all ); + $this->assertArrayNotHasKey( 'dead', $all ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php new file mode 100644 index 0000000..3745bcf --- /dev/null +++ b/tests/integration/bootstrap.php @@ -0,0 +1,604 @@ +connect_error ) { + throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli_init->connect_error ); +} + +$_db_name_quoted = '`' . str_replace( '`', '``', $_db_name ) . '`'; +if ( ! $_mysqli_init->query( "CREATE DATABASE IF NOT EXISTS {$_db_name_quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) ) { + throw new RuntimeException( "Failed to create test database '{$_db_name}': " . $_mysqli_init->error ); +} +$_mysqli_init->close(); +unset( $_mysqli_init, $_db_name_quoted ); + +$_mysqli = new mysqli( $_db_host, $_db_user, $_db_pass, $_db_name ); +if ( $_mysqli->connect_error ) { + throw new RuntimeException( 'Integration test DB connection failed: ' . $_mysqli->connect_error ); +} +$_mysqli->set_charset( 'utf8mb4' ); + +// ── Real $wpdb ───────────────────────────────────────────────────────────── + +global $wpdb; +$wpdb = new class( $_mysqli ) { + private mysqli $db; + + public string $prefix = 'wp_itest_'; + public string $postmeta = 'wp_itest_postmeta'; + public string $posts = 'wp_itest_posts'; + public string $options = 'wp_itest_options'; + public string $usermeta = 'wp_itest_usermeta'; + public string $users = 'wp_itest_users'; + public string $terms = 'wp_itest_terms'; + public string $termmeta = 'wp_itest_termmeta'; + public string $comments = 'wp_itest_comments'; + public string $commentmeta = 'wp_itest_commentmeta'; + public int $insert_id = 0; + public string $last_error = ''; + + public function __construct( mysqli $db ) { $this->db = $db; } + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sdf])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + if ( $m[1] === 'd' ) { return (string) (int) $val; } + if ( $m[1] === 'f' ) { return (string) (float) $val; } + return "'" . $this->db->real_escape_string( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + $result = $this->db->query( $sql ); + if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; } + return $row[0] !== null ? (string) $row[0] : null; + } + + public function get_row( string $sql, $output = 'OBJECT' ) { + $result = $this->db->query( $sql ); + if ( ! $result ) { return null; } + return ARRAY_A === $output ? ( $result->fetch_assoc() ?: null ) : ( $result->fetch_object() ?: null ); + } + + public function get_results( string $sql, $output = 'OBJECT' ): array { + $result = $this->db->query( $sql ); + if ( ! $result ) { return []; } + $rows = []; + while ( $row = ( ARRAY_A === $output ? $result->fetch_assoc() : $result->fetch_object() ) ) { + $rows[] = $row; + } + return $rows; + } + + public function get_col( string $sql, int $col_index = 0 ): array { + $result = $this->db->query( $sql ); + if ( ! $result ) { return []; } + $values = []; + while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; } + return $values; + } + + public function get_charset_collate(): string { + return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'; + } + + public function insert( string $table, array $data, $format = null ): int|false { + $cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) ); + $vals = implode( ', ', array_map( + fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", + array_values( $data ) + ) ); + $ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" ); + if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; } + $this->last_error = (string) $this->db->error; + return false; + } + + public function update( string $table, array $data, array $where, $format = null, $wf = null ): int|false { + $set = implode( ', ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $data ), $data ) ); + $cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) ); + $ok = $this->db->query( "UPDATE `{$table}` SET {$set} WHERE {$cond}" ); + return $ok ? $this->db->affected_rows : false; + } + + public function delete( string $table, array $where, $format = null ): int|false { + $cond = implode( ' AND ', array_map( fn( $k, $v ) => '`' . $k . '` = ' . ( $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'" ), array_keys( $where ), $where ) ); + $ok = $this->db->query( "DELETE FROM `{$table}` WHERE {$cond}" ); + return $ok ? $this->db->affected_rows : false; + } + + public function replace( string $table, array $data, $format = null ): int|false { + $cols = implode( ', ', array_map( fn( $c ) => '`' . $c . '`', array_keys( $data ) ) ); + $vals = implode( ', ', array_map( fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", array_values( $data ) ) ); + $ok = $this->db->query( "REPLACE INTO `{$table}` ({$cols}) VALUES ({$vals})" ); + if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->db->affected_rows; } + $this->last_error = (string) $this->db->error; + return false; + } + + public function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); } + public function flush(): void {} + + public function query( string $sql ): int|bool { + $result = $this->db->query( $sql ); + if ( $result instanceof mysqli_result ) { $result->free(); return true; } + if ( false === $result ) { return false; } + return $this->db->affected_rows; + } +}; + +// ── WordPress function stubs ─────────────────────────────────────────────── + +if ( ! function_exists( 'trailingslashit' ) ) { + function trailingslashit( string $s ): string { return rtrim( $s, '/' ) . '/'; } +} +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( string $key ): string { return strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $key ) ); } +} +if ( ! function_exists( 'absint' ) ) { + function absint( $v ): int { return abs( (int) $v ); } +} +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $v ) { return is_string( $v ) ? stripslashes( $v ) : $v; } +} +if ( ! function_exists( 'esc_like' ) ) { + function esc_like( string $s ): string { return addcslashes( $s, '_%\\' ); } +} +if ( ! function_exists( 'current_time' ) ) { + function current_time( string $type, bool $gmt = false ): string|int { + if ( 'timestamp' === $type || 'U' === $type ) { return time(); } + return gmdate( 'Y-m-d H:i:s' ); + } +} +if ( ! function_exists( 'wp_parse_args' ) ) { + function wp_parse_args( $args, array $defaults = [] ): array { + if ( is_string( $args ) ) { parse_str( $args, $args ); } + return array_merge( $defaults, (array) $args ); + } +} + +$GLOBALS['_wp_filter_callbacks'] = []; +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, $cb, int $p = 10, int $a = 1 ): bool { + $GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb; + return true; + } +} +if ( ! function_exists( 'add_action' ) ) { + function add_action( string $hook, $cb, int $p = 10, int $a = 1 ): bool { + $GLOBALS['_wp_filter_callbacks'][ $hook ][] = $cb; + return true; + } +} +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( string $hook, $cb, int $p = 10 ): bool { + if ( isset( $GLOBALS['_wp_filter_callbacks'][ $hook ] ) ) { + $GLOBALS['_wp_filter_callbacks'][ $hook ] = array_values( + array_filter( $GLOBALS['_wp_filter_callbacks'][ $hook ], fn( $c ) => $c !== $cb ) + ); + } + return true; + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) { + $value = $cb( $value, ...$args ); + } + return $value; + } +} +if ( ! function_exists( 'do_action' ) ) { + function do_action( string $hook, ...$args ): void { + foreach ( $GLOBALS['_wp_filter_callbacks'][ $hook ] ?? [] as $cb ) { + $cb( ...$args ); + } + } +} + +if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } } +if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } } +if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } +if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } +if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } +if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } } +if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } } +if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } } +if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } } +if ( ! function_exists( 'esc_html' ) ) { function esc_html( $s ): string { return htmlspecialchars( (string) $s, ENT_QUOTES, 'UTF-8' ); } } +if ( ! function_exists( 'esc_attr' ) ) { function esc_attr( string $s ): string { return htmlspecialchars( $s, ENT_QUOTES, 'UTF-8' ); } } +if ( ! function_exists( 'esc_url' ) ) { function esc_url( string $url ): string { return filter_var( $url, FILTER_SANITIZE_URL ) ?: ''; } } +if ( ! function_exists( 'esc_sql' ) ) { function esc_sql( $s ): string { return addslashes( is_string( $s ) ? $s : (string) $s ); } } +if ( ! function_exists( '_doing_it_wrong' ) ) { function _doing_it_wrong( string $fn, string $msg, string $ver ): void {} } +if ( ! function_exists( 'wp_strip_all_tags' ) ) { function wp_strip_all_tags( string $s ): string { return strip_tags( $s ); } } +if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } } +if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } } +if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } } +if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } } +if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } } +if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } } +if ( ! function_exists( 'get_post_meta' ) ) { function get_post_meta( int $post_id, string $key = '', bool $single = false ) { return $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] ?? ( $single ? '' : [] ); } } +if ( ! function_exists( 'update_post_meta' ) ) { + function update_post_meta( int $post_id, string $key, $value, $prev = '' ): int|bool { + global $wpdb; + $has_table = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->postmeta ) ); + if ( $has_table ) { + $existing = $wpdb->get_var( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", $post_id, $key ) ); + if ( $existing ) { + $wpdb->update( $wpdb->postmeta, array( 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ), array( 'meta_id' => $existing ) ); + } else { + $wpdb->insert( $wpdb->postmeta, array( 'post_id' => $post_id, 'meta_key' => $key, 'meta_value' => is_scalar( $value ) ? (string) $value : maybe_serialize( $value ) ) ); + } + } + $GLOBALS['_wp_postmeta'][ $post_id ][ $key ] = $value; + return true; + } +} +if ( ! function_exists( 'get_user_meta' ) ) { function get_user_meta( int $uid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_usermeta'][ $uid ][ $key ] ?? ( $single ? '' : [] ); } } +if ( ! function_exists( 'update_user_meta' ) ) { function update_user_meta( int $uid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_usermeta'][ $uid ][ $key ] = $value; return true; } } +if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_termmeta'][ $tid ][ $key ] ?? ( $single ? '' : [] ); } } +if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } } +if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } } +if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } } +if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } } +if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } } +if ( ! function_exists( 'is_post_publicly_viewable' ) ) { + function is_post_publicly_viewable( $post_id ): bool { + if ( isset( $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ] ) ) { return (bool) $GLOBALS['_wp_post_publicly_viewable'][ (int) $post_id ]; } + return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' ); + } +} +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $cap, ...$args ): bool { + if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } } + return $GLOBALS['_wp_current_user_can'][ $cap ] ?? false; + } +} +if ( ! function_exists( 'get_current_user_id' ) ) { function get_current_user_id(): int { return (int) ( $GLOBALS['_wp_current_user_id'] ?? 0 ); } } +if ( ! function_exists( 'is_multisite' ) ) { function is_multisite(): bool { return (bool) ( $GLOBALS['_wp_is_multisite'] ?? false ); } } +if ( ! function_exists( 'is_super_admin' ) ) { function is_super_admin( ?int $uid = null ): bool { return (bool) ( $GLOBALS['_wp_is_super_admin'] ?? false ); } } +if ( ! function_exists( 'switch_to_blog' ) ) { function switch_to_blog( int $blog_id ): bool { $GLOBALS['_wp_current_blog_id'] = $blog_id; return true; } } +if ( ! function_exists( 'restore_current_blog' ) ) { function restore_current_blog(): bool { unset( $GLOBALS['_wp_current_blog_id'] ); return true; } } +if ( ! function_exists( 'get_sites' ) ) { function get_sites( array $args = [] ): array { return $GLOBALS['_wp_sites'] ?? []; } } +if ( ! function_exists( 'is_plugin_active_for_network' ) ) { function is_plugin_active_for_network( string $plugin ): bool { return (bool) ( $GLOBALS['_wp_plugin_active_for_network'][ $plugin ] ?? false ); } } +if ( ! function_exists( 'is_plugin_active' ) ) { function is_plugin_active( string $plugin ): bool { return false; } } +if ( ! function_exists( 'deactivate_plugins' ) ) { function deactivate_plugins( $plugin, bool $silent = false ): void {} } +if ( ! function_exists( 'plugin_basename' ) ) { function plugin_basename( string $file ): string { return basename( dirname( $file ) ) . '/' . basename( $file ); } } +if ( ! function_exists( 'wp_generate_password' ) ) { function wp_generate_password( int $len = 12, bool $special = true ): string { return substr( str_replace( [ '/', '+', '=' ], '', base64_encode( random_bytes( $len ) ) ), 0, $len ); } } +if ( ! function_exists( 'wp_json_encode' ) ) { function wp_json_encode( $data, int $flags = 0 ): string|false { return json_encode( $data, $flags ); } } +if ( ! function_exists( 'wp_rand' ) ) { function wp_rand( int $min = 0, int $max = 0 ): int { return random_int( $min, $max ?: PHP_INT_MAX ); } } +if ( ! function_exists( 'is_wp_error' ) ) { function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } } +if ( ! function_exists( 'wp_verify_nonce' ) ) { function wp_verify_nonce( $nonce, string $action = '' ) { return $GLOBALS['_wp_valid_nonces'][ (string) $nonce ] ?? false; } } +if ( ! function_exists( 'wp_create_nonce' ) ) { function wp_create_nonce( string $action = '' ): string { $nonce = 'test_nonce_' . md5( $action ); $GLOBALS['_wp_valid_nonces'][ $nonce ] = 1; return $nonce; } } +if ( ! function_exists( 'wp_die' ) ) { function wp_die( $message = '' ): void { throw new RuntimeException( is_string( $message ) ? $message : 'wp_die' ); } } +if ( ! function_exists( 'maybe_serialize' ) ) { function maybe_serialize( $data ) { return is_array( $data ) || is_object( $data ) ? serialize( $data ) : $data; } } +if ( ! function_exists( 'maybe_unserialize' ) ) { function maybe_unserialize( $value ) { if ( ! is_string( $value ) ) { return $value; } $u = @unserialize( $value ); return ( false !== $u || 'b:0;' === $value ) ? $u : $value; } } +if ( ! function_exists( 'get_bloginfo' ) ) { function get_bloginfo( string $key ): string { return 'version' === $key ? '6.9.4' : ''; } } +if ( ! function_exists( 'sanitize_title' ) ) { function sanitize_title( string $title ): string { return strtolower( preg_replace( '/[^a-z0-9-]+/i', '-', trim( $title ) ) ); } } +if ( ! function_exists( 'taxonomy_exists' ) ) { + function taxonomy_exists( string $taxonomy ): bool { + if ( isset( $GLOBALS['_taxonomy_exists_override'] ) ) { return (bool) $GLOBALS['_taxonomy_exists_override']; } + return in_array( $taxonomy, [ 'category', 'post_tag', 'listing_category', 'listing_tag' ], true ); + } +} +if ( ! function_exists( 'clean_term_cache' ) ) { function clean_term_cache( $ids, string $taxonomy = '', bool $clean_taxonomy = true ): void {} } +if ( ! function_exists( 'get_taxonomies' ) ) { + function get_taxonomies( array $args = [], string $output = 'names' ): array { + $taxonomies = [ 'category', 'post_tag', 'listing_category', 'listing_tag' ]; + if ( 'objects' === $output ) { + $out = []; + foreach ( $taxonomies as $slug ) { $out[ $slug ] = (object) [ 'name' => $slug, 'labels' => (object) [ 'singular_name' => ucfirst( str_replace( '_', ' ', $slug ) ) ] ]; } + return $out; + } + return $taxonomies; + } +} +if ( ! function_exists( 'is_serialized' ) ) { function is_serialized( $data ): bool { return is_string( $data ) && strlen( $data ) >= 4 && in_array( $data[0], [ 'a', 's', 'i', 'd', 'b', 'O', 'N' ], true ) && str_ends_with( $data, ';' ); } } +if ( ! function_exists( 'wp_upload_dir' ) ) { + function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ): array { + $upload_path = sys_get_temp_dir() . '/wp-uploads-test'; + return [ + 'path' => $upload_path, + 'url' => 'http://localhost/wp-content/uploads', + 'subdir' => '', + 'basedir' => $upload_path, + 'baseurl' => 'http://localhost/wp-content/uploads', + 'error' => false, + ]; + } +} +if ( ! function_exists( 'wp_mkdir_p' ) ) { + function wp_mkdir_p( string $dir ): bool { + if ( is_dir( $dir ) ) { return true; } + return mkdir( $dir, 0777, true ); + } +} + +$GLOBALS['_wp_cache'] = []; +if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } } +if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } } +if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } } + +if ( ! class_exists( 'WP_Query' ) ) { + class WP_Query { + private array $vars = []; + public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; } + public function set( string $key, $value ): void { $this->vars[ $key ] = $value; } + } +} +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $code; + private string $message; + public function __construct( string $code = '', string $message = '' ) { $this->code = $code; $this->message = $message; } + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + } +} +if ( ! class_exists( 'WP_REST_Request' ) ) { + class WP_REST_Request { + private array $params = []; private array $headers = []; + public function __construct( string $method = 'GET', string $route = '' ) {} + public function get_param( string $key ) { return $this->params[ $key ] ?? null; } + public function set_param( string $key, $value ): void { $this->params[ $key ] = $value; } + public function get_header( string $key ): ?string { return $this->headers[ strtolower( $key ) ] ?? null; } + public function set_header( string $key, string $value ): void { $this->headers[ strtolower( $key ) ] = $value; } + } +} +if ( ! class_exists( 'WP_REST_Response' ) ) { + class WP_REST_Response { + private $data; private int $status; private array $headers = []; + public function __construct( $data = null, int $status = 200 ) { $this->data = $data; $this->status = $status; } + public function get_data() { return $this->data; } + public function get_status(): int { return $this->status; } + public function header( string $k, string $v ): void { $this->headers[ $k ] = $v; } + public function get_headers(): array { return $this->headers; } + } +} +if ( ! class_exists( 'WP_REST_Server' ) ) { + class WP_REST_Server { const READABLE = 'GET'; const CREATABLE = 'POST'; } +} +if ( ! function_exists( 'register_rest_route' ) ) { function register_rest_route( string $ns, string $route, array $args ): bool { return true; } } + +if ( ! function_exists( 'dbDelta' ) ) { + function dbDelta( string $sql ): array { + global $wpdb; + $sql_idempotent = preg_replace( '/^CREATE TABLE/i', 'CREATE TABLE IF NOT EXISTS', trim( $sql ), 1 ); + $wpdb->query( $sql_idempotent ); + return []; + } +} + +if ( ! function_exists( 'wp_insert_post' ) ) { + function wp_insert_post( array $postarr, bool $wp_error = false ) { + global $wpdb; + $now = current_time( 'mysql' ); + $defaults = [ 'post_title' => '', 'post_type' => 'post', 'post_status' => 'publish', 'post_content' => '', 'post_excerpt' => '', 'post_content_filtered' => '', 'to_ping' => '', 'pinged' => '', 'post_date' => $now, 'post_date_gmt' => $now, 'post_modified' => $now, 'post_modified_gmt' => $now, 'post_name' => '', 'guid' => '' ]; + $row = array_merge( $defaults, $postarr ); + if ( '' === $row['post_name'] ) { $row['post_name'] = sanitize_title( (string) $row['post_title'] ); } + $ok = $wpdb->insert( $wpdb->posts, $row ); + return $ok ? (int) $wpdb->insert_id : ( $wp_error ? new WP_Error( 'insert_failed', 'Insert failed' ) : 0 ); + } +} + +if ( ! function_exists( 'wp_insert_term' ) ) { + function wp_insert_term( string $term, string $taxonomy, array $args = [] ) { + global $wpdb; + $slug = $args['slug'] ?? sanitize_title( $term ); + $ok = $wpdb->insert( $wpdb->terms, [ 'name' => $term, 'slug' => $slug, 'term_group' => 0 ] ); + if ( ! $ok ) { return new WP_Error( 'insert_failed', 'terms insert failed' ); } + $term_id = (int) $wpdb->insert_id; + $wpdb->insert( $wpdb->prefix . 'term_taxonomy', [ 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0 ] ); + return [ 'term_id' => $term_id, 'term_taxonomy_id' => (int) $wpdb->insert_id ]; + } +} + +if ( ! function_exists( 'wp_insert_comment' ) ) { + function wp_insert_comment( array $data ) { + global $wpdb; + $ok = $wpdb->insert( $wpdb->comments, [ + 'comment_post_ID' => (int) ( $data['comment_post_ID'] ?? 0 ), + 'comment_author' => (string) ( $data['comment_author'] ?? '' ), + 'comment_author_email' => (string) ( $data['comment_author_email'] ?? '' ), + 'comment_author_url' => (string) ( $data['comment_author_url'] ?? '' ), + 'comment_author_IP' => (string) ( $data['comment_author_IP'] ?? '127.0.0.1' ), + 'comment_date' => (string) ( $data['comment_date'] ?? gmdate( 'Y-m-d H:i:s' ) ), + 'comment_date_gmt' => (string) ( $data['comment_date_gmt'] ?? gmdate( 'Y-m-d H:i:s' ) ), + 'comment_content' => (string) ( $data['comment_content'] ?? '' ), + 'comment_karma' => (int) ( $data['comment_karma'] ?? 0 ), + 'comment_approved' => (string) ( $data['comment_approved'] ?? '1' ), + 'comment_agent' => (string) ( $data['comment_agent'] ?? '' ), + 'comment_type' => (string) ( $data['comment_type'] ?? 'comment' ), + 'comment_parent' => (int) ( $data['comment_parent'] ?? 0 ), + 'user_id' => (int) ( $data['user_id'] ?? 0 ), + ] ); + if ( ! $ok ) { return false; } + return (int) $wpdb->insert_id; + } +} + +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 {} + public static function add_command( string $name, $class ): void {} + } +} + +// ── Load plugin classes ──────────────────────────────────────────────────── + +require_once TMDO_PATH . 'includes/class-tmdo-capability.php'; +require_once TMDO_PATH . 'includes/class-tmdo-crypto.php'; +require_once TMDO_PATH . 'includes/class-tmdo-safe-unserialize.php'; +require_once TMDO_PATH . 'includes/class-tmdo-db.php'; +require_once TMDO_PATH . 'includes/class-tmdo-logger.php'; +require_once TMDO_PATH . 'includes/class-tmdo-feature-flags.php'; +require_once TMDO_PATH . 'includes/class-tmdo-sqlite-compat.php'; +require_once TMDO_PATH . 'includes/class-tmdo-installer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-schema-registry.php'; +require_once TMDO_PATH . 'includes/class-tmdo-custom-table-registry.php'; +require_once TMDO_PATH . 'includes/trait-tmdo-anti-eav-aware.php'; +require_once TMDO_PATH . 'includes/class-tmdo-hook-bus-bridge.php'; +require_once TMDO_PATH . 'includes/class-tmdo-conflict-monitor.php'; +require_once TMDO_PATH . 'includes/class-tmdo-compatibility.php'; +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-interceptor-base.php'; +require_once TMDO_PATH . 'includes/interceptors/class-tmdo-sync-bridge.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-hot.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-warm.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-cold.php'; +require_once TMDO_PATH . 'includes/zones/class-tmdo-zone-archive.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-query-interceptor-base.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-query-router.php'; +require_once TMDO_PATH . 'includes/query/class-tmdo-post-query-router.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-base.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-engine.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-hot-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-warm-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-cold-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-archive-migration.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-garbage-filter.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-term-comment-misc-bucket.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-member-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-post-fields.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-points-manager.php'; +require_once TMDO_PATH . 'includes/integrations/class-tmdo-demo-entity-counter.php'; +require_once TMDO_PATH . 'includes/class-tmdo-cache-layer.php'; +require_once TMDO_PATH . 'includes/class-tmdo-zone-classifier.php'; +require_once TMDO_PATH . 'includes/class-tmdo-api.php'; +require_once TMDO_PATH . 'includes/class-tmdo-v2-upgrader.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-manager.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-writer.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-reader.php'; +require_once TMDO_PATH . 'includes/snapshots/class-tmdo-snapshot-pruner.php'; +require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php'; +require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-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'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-cache-orchestrator.php'; +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/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'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-term.php'; +require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-comment.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 . 'modules/options/class-tmdo-options-manager.php'; +require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-commentmeta-cleaner.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-comment-backfill.php'; +require_once TMDO_PATH . 'includes/class-tmdo-term-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-comment-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php'; +require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/class-tmdo-core.php'; + +// Back-compat aliases (WPDO_* → TMDO_*). +require_once TMDO_PATH . 'includes/class-tmdo-back-compat.php'; + +// FSM Guard bypass for integration tests (real DB transitions should work). +if ( ! function_exists( '__return_true' ) ) { + function __return_true(): bool { return true; } +} +add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + +// TMDO_Listing_Stats moved to HP AddOn; stub here for tests that reference it. +if ( ! class_exists( 'TMDO_Listing_Stats' ) ) { + class TMDO_Listing_Stats { + public static function register(): void {} + public static function get_view_count( int $post_id ): int { return 0; } + public static function increment_view( int $post_id, string $ip = '' ): int { return 0; } + public static function is_rate_limited( int $post_id, string $ip ): bool { return false; } + public static function flush_views_to_postmeta(): int { return 0; } + } + class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' ); +} diff --git a/tests/unit/Advisor/FSMAdvisorTest.php b/tests/unit/Advisor/FSMAdvisorTest.php new file mode 100644 index 0000000..0097811 --- /dev/null +++ b/tests/unit/Advisor/FSMAdvisorTest.php @@ -0,0 +1,140 @@ +setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public string $postmeta = 'wp_postmeta'; + public string $options = 'wp_options'; + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) { + return (string) ( $args[ $i++ ] ?? '?' ); + }, $sql ); + } + public function get_var( string $sql ) { return '0'; } // shadow_diffs table absent + public function get_results( string $sql, $output = ARRAY_A ): array { return array(); } + }; + } + + private function set_module_state( string $module, string $state, ?int $days_ago = null ): void { + // Set FSM state. + $flags = (array) get_option( 'wpdo_features', array() ); + $flags[ $module ] = $state; + update_option( 'wpdo_features', $flags, false ); + + // Reset Feature_Flags request cache via reflection. + $ref = new ReflectionClass( WPDO_Feature_Flags::class ); + $prop = $ref->getProperty( 'cache' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + + if ( null !== $days_ago ) { + $entered = (array) get_option( 'wpdo_fsm_state_entered', array() ); + $entered[ $module ] = array( + 'state' => $state, + 'entered_at' => gmdate( 'Y-m-d H:i:s', time() - $days_ago * 86400 ), + ); + update_option( 'wpdo_fsm_state_entered', $entered, false ); + } + } + + public function test_idle_state_returns_HOLD(): void { + $this->set_module_state( 'reviews', 'idle' ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'HOLD', $advice['action'] ); + $this->assertSame( 'info', $advice['level'] ); + } + + public function test_complete_state_returns_HOLD(): void { + $this->set_module_state( 'reviews', 'complete' ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'HOLD', $advice['action'] ); + } + + public function test_dual_write_with_short_soak_returns_WAIT(): void { + // In dual_write 0 days < 1 day min soak. + $this->set_module_state( 'reviews', 'dual_write', 0 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'WAIT', $advice['action'] ); + $this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] ); + } + + public function test_dual_write_after_min_soak_returns_PROMOTE(): void { + $this->set_module_state( 'reviews', 'dual_write', 2 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'PROMOTE', $advice['action'] ); + $this->assertSame( 'backfill', $advice['next_state'] ); + } + + public function test_verify_with_long_soak_returns_PROMOTE(): void { + // verify needs 7 days; 10 days = should promote. + $this->set_module_state( 'reviews', 'verify', 10 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'PROMOTE', $advice['action'] ); + $this->assertSame( 'cutover', $advice['next_state'] ); + } + + public function test_verify_with_short_soak_returns_WAIT(): void { + $this->set_module_state( 'reviews', 'verify', 3 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'WAIT', $advice['action'] ); + $this->assertSame( 4, $advice['days_remaining'] ); + } + + public function test_cleanup_with_short_wash_returns_WAIT(): void { + $this->set_module_state( 'reviews', 'cleanup', 1 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'WAIT', $advice['action'] ); + $this->assertSame( 2, $advice['days_remaining'] ); + } + + public function test_cleanup_after_wash_returns_PROMOTE(): void { + $this->set_module_state( 'reviews', 'cleanup', 5 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'PROMOTE', $advice['action'] ); + $this->assertSame( 'complete', $advice['next_state'] ); + } + + public function test_advice_metrics_include_state_and_soak(): void { + $this->set_module_state( 'reviews', 'dual_write', 5 ); + $advice = WPDO_FSM_Advisor::advise( 'reviews' ); + $this->assertSame( 'dual_write', $advice['metrics']['state'] ); + $this->assertSame( 5, $advice['metrics']['days_in_state'] ); + $this->assertSame( 1, $advice['metrics']['min_soak_days'] ); + } + + public function test_advise_all_returns_map_for_all_modules(): void { + $advice = WPDO_FSM_Advisor::advise_all(); + $this->assertNotEmpty( $advice ); + // All known HPCT + zone modules should be present (default idle). + foreach ( WPDO_Feature_Flags::HPCT_MODULES as $m ) { + $this->assertArrayHasKey( $m, $advice ); + } + } +} diff --git a/tests/unit/Advisor/FSMAutomatorTest.php b/tests/unit/Advisor/FSMAutomatorTest.php new file mode 100644 index 0000000..ace3167 --- /dev/null +++ b/tests/unit/Advisor/FSMAutomatorTest.php @@ -0,0 +1,119 @@ +getProperty( 'cache' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public string $postmeta = 'wp_postmeta'; + public string $options = 'wp_options'; + public function prepare( string $sql, ...$args ): string { return $sql; } + public function get_var( string $sql ) { return '0'; } + public function get_results( string $sql, $output = ARRAY_A ): array { return array(); } + }; + } + + public function test_default_disabled(): void { + $this->assertFalse( WPDO_FSM_Automator::is_enabled() ); + } + + public function test_run_returns_disabled_when_off(): void { + $result = WPDO_FSM_Automator::run(); + $this->assertFalse( $result['ok'] ); + $this->assertContains( 'automator disabled', $result['errors'] ); + } + + public function test_run_blocked_by_critical_cool_off(): void { + // Enable + simulate critical alert in last run. + update_option( 'wpdo_automator_enabled', '1', false ); + update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array( + 'critical_count' => 2, + 'ran_at' => gmdate( 'Y-m-d H:i:s' ), + ), false ); + + $result = WPDO_FSM_Automator::run(); + $this->assertFalse( $result['ok'] ); + $this->assertStringContainsString( 'cool-off', $result['errors'][0] ); + } + + public function test_run_proceeds_when_no_critical(): void { + update_option( 'wpdo_automator_enabled', '1', false ); + update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array( + 'critical_count' => 0, + 'ran_at' => gmdate( 'Y-m-d H:i:s' ), + ), false ); + + $result = WPDO_FSM_Automator::run(); + $this->assertTrue( $result['ok'] ); + // Most modules will be in idle and Advisor recommends WAIT (soak time); + // expected that 0 actions are executed in baseline test. + $this->assertGreaterThanOrEqual( 0, $result['executed'] ); + } + + public function test_blacklist_skips_module(): void { + update_option( 'wpdo_automator_enabled', '1', false ); + update_option( 'wpdo_automator_blacklist', array( 'reviews', 'wc_orders' ), false ); + update_option( WPDO_Health_Cron::OPTION_LAST_RUN, array( + 'critical_count' => 0, + 'ran_at' => gmdate( 'Y-m-d H:i:s' ), + ), false ); + $blacklist = WPDO_FSM_Automator::blacklist(); + $this->assertContains( 'reviews', $blacklist ); + $this->assertContains( 'wc_orders', $blacklist ); + + $result = WPDO_FSM_Automator::run(); + $this->assertTrue( $result['ok'] ); + // Skipped at least the 2 blacklisted ones. + $this->assertGreaterThanOrEqual( 2, $result['skipped'] ); + } + + public function test_forbidden_transitions_constant(): void { + $ref = new ReflectionClass( WPDO_FSM_Automator::class ); + $constant = $ref->getReflectionConstant( 'FORBIDDEN_TRANSITIONS' ); + $this->assertNotNull( $constant ); + $value = $constant->getValue(); + $this->assertContains( array( 'verify', 'cutover' ), $value ); + $this->assertContains( array( 'cutover', 'cleanup' ), $value ); + $this->assertContains( array( 'cleanup', 'complete' ), $value ); + } + + public function test_options_keys_match_admin_form(): void { + $this->assertSame( 'wpdo_automator_enabled', WPDO_FSM_Automator::OPT_ENABLED ); + $this->assertSame( 'wpdo_automator_blacklist', WPDO_FSM_Automator::OPT_BLACKLIST ); + $this->assertSame( 'wpdo_automator_last_action', WPDO_FSM_Automator::OPT_LAST_ACTION ); + } + + public function test_last_actions_returns_array(): void { + $this->assertSame( array(), WPDO_FSM_Automator::last_actions() ); + update_option( 'wpdo_automator_last_action', array( 'reviews' => '2026-04-28 04:30:00' ), false ); + $last = WPDO_FSM_Automator::last_actions(); + $this->assertSame( '2026-04-28 04:30:00', $last['reviews'] ); + } +} diff --git a/tests/unit/Advisor/ModuleDetectorTest.php b/tests/unit/Advisor/ModuleDetectorTest.php new file mode 100644 index 0000000..6be0b0c --- /dev/null +++ b/tests/unit/Advisor/ModuleDetectorTest.php @@ -0,0 +1,160 @@ +getProperty( 'cache' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + public string $postmeta = 'wp_postmeta'; + public string $options = 'wp_options'; + public array $count_overrides = array(); + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) { + return is_string( $args[ $i ] ?? null ) ? "'" . $args[ $i++ ] . "'" : (string) ( $args[ $i++ ] ?? '?' ); + }, $sql ); + } + public function get_var( string $sql ) { + // Mock by inspecting SQL pattern. + if ( str_contains( $sql, "post_type =" ) ) { + if ( preg_match( "/post_type = '([^']+)'/", $sql, $m ) ) { + return (string) ( $this->count_overrides[ $m[1] ] ?? 0 ); + } + } + if ( str_contains( $sql, "post_status = 'trash'" ) ) { + return (string) ( $this->count_overrides['__trash__'] ?? 0 ); + } + return '0'; + } + public function get_results( string $sql, $output = ARRAY_A ): array { return array(); } + }; + } + + public function test_module_rules_known_modules_count(): void { + $modules = WPDO_Module_Rules::known_modules(); + $this->assertGreaterThanOrEqual( 14, count( $modules ), '預期 ≥ 14 個 module(9 HPCT + 6 zone - 1 dup)' ); + $this->assertContains( 'reviews', $modules ); + $this->assertContains( 'warm', $modules ); + $this->assertContains( 'archive', $modules ); + $this->assertContains( 'hot_hp_listing', $modules ); + } + + public function test_module_rule_for_module_returns_array(): void { + $rule = WPDO_Module_Rules::for_module( 'reviews' ); + $this->assertIsArray( $rule ); + $this->assertArrayHasKey( 'compat_required', $rule ); + $this->assertArrayHasKey( 'description', $rule ); + } + + public function test_module_rule_unknown_module_returns_null(): void { + $this->assertNull( WPDO_Module_Rules::for_module( 'totally_fake_module_xyz' ) ); + } + + public function test_detector_skip_when_module_not_idle(): void { + // Set reviews to dual_write. + WPDO_Feature_Flags::set( 'reviews', 'dual_write' ); + $r = WPDO_Module_Detector::detect_one( 'reviews' ); + $this->assertSame( 'skip', $r['recommendation'] ); + $this->assertFalse( $r['available'] ); + $this->assertNotEmpty( $r['blockers'] ); + $this->assertSame( 'dual_write', $r['current_state'] ); + } + + public function test_detector_warm_module_recommends_for_any_site(): void { + // warm module's compat_required is empty + no post_type. + $r = WPDO_Module_Detector::detect_one( 'warm' ); + $this->assertTrue( $r['available'] ); + $this->assertSame( 'enable', $r['recommendation'] ); + $this->assertGreaterThan( 0, $r['confidence'] ); + $this->assertNotEmpty( $r['suggested_action'] ); + $this->assertSame( 'dual_write', $r['suggested_action']['to_state'] ); + } + + public function test_detector_archive_blocked_when_no_trashed_posts(): void { + // trashed = 0, threshold = 50. + global $wpdb; + $wpdb->count_overrides['__trash__'] = 0; + $r = WPDO_Module_Detector::detect_one( 'archive' ); + $this->assertSame( 'wait', $r['recommendation'] ); + $this->assertFalse( $r['available'] ); + } + + public function test_detector_archive_passes_when_enough_trashed(): void { + global $wpdb; + $wpdb->count_overrides['__trash__'] = 100; + $r = WPDO_Module_Detector::detect_one( 'archive' ); + $this->assertSame( 'enable', $r['recommendation'] ); + $this->assertTrue( $r['available'] ); + } + + public function test_detector_hivepress_required_blocks_when_inactive(): void { + // reviews requires hivepress; Compatibility class isn't loaded in this test. + // Without Compatibility class, the helper returns the full required list as missing. + $r = WPDO_Module_Detector::detect_one( 'reviews' ); + $this->assertSame( 'skip', $r['recommendation'] ); + $this->assertNotEmpty( $r['blockers'] ); + } + + public function test_get_actionable_filters_by_confidence(): void { + $actionable = WPDO_Module_Detector::get_actionable( 0.5 ); + $this->assertIsArray( $actionable ); + // Each result should have available=true and recommendation=enable. + foreach ( $actionable as $module => $r ) { + $this->assertTrue( $r['available'], "$module should be available" ); + $this->assertSame( 'enable', $r['recommendation'] ); + $this->assertGreaterThanOrEqual( 0.5, $r['confidence'] ); + } + } + + public function test_get_actionable_sorts_by_confidence_desc(): void { + $actionable = WPDO_Module_Detector::get_actionable( 0.0 ); + $last_confidence = 1.0; + foreach ( $actionable as $r ) { + $this->assertLessThanOrEqual( $last_confidence, (float) $r['confidence'] ); + $last_confidence = (float) $r['confidence']; + } + } + + public function test_detect_all_returns_entry_for_each_known_module(): void { + $all = WPDO_Module_Detector::detect_all( true ); + $known = WPDO_Module_Rules::known_modules(); + foreach ( $known as $m ) { + $this->assertArrayHasKey( $m, $all, "Detector should return entry for $m" ); + } + } +} diff --git a/tests/unit/BackCompatTest.php b/tests/unit/BackCompatTest.php new file mode 100644 index 0000000..bfabcc1 --- /dev/null +++ b/tests/unit/BackCompatTest.php @@ -0,0 +1,55 @@ +assertTrue( trait_exists( 'WPDO_Anti_EAV_Aware' ), 'WPDO_Anti_EAV_Aware trait alias must exist' ); + } + + public function test_class_using_wpdo_anti_eav_aware_is_valid(): void { + // Verify a class can `use WPDO_Anti_EAV_Aware` without fatal error. + $obj = new class { + use WPDO_Anti_EAV_Aware; + }; + // trait_exists check via class_uses — PHP doesn't support instanceof for traits. + $this->assertArrayHasKey( 'WPDO_Anti_EAV_Aware', class_uses( $obj ) ); + } + + // ── Interface alias ─────────────────────────────────────────────────────── + + public function test_wpdo_entity_adapter_interface_exists(): void { + $this->assertTrue( + interface_exists( 'WPDO_Entity_Adapter_Interface' ), + 'WPDO_Entity_Adapter_Interface must exist as a back-compat alias' + ); + } + + public function test_tmdo_adapter_post_implements_wpdo_interface(): void { + // An object implementing TMDO_Entity_Adapter_Interface must also + // satisfy `instanceof WPDO_Entity_Adapter_Interface`. + $adapter = new TMDO_Adapter_Post(); + $this->assertInstanceOf( 'WPDO_Entity_Adapter_Interface', $adapter ); + } + + // ── DB version consistency ──────────────────────────────────────────────── + + public function test_tmdo_db_version_constant_matches_installer_schema(): void { + // TMDO_DB_VERSION (plugin header constant) must equal TMDO_Installer::SCHEMA_VERSION + // (private). If they diverge, maybe_upgrade() either never fires or fires every boot. + $ref = new ReflectionClass( TMDO_Installer::class ); + $schema_version = $ref->getConstant( 'SCHEMA_VERSION' ); + $this->assertSame( TMDO_DB_VERSION, $schema_version, + 'TMDO_DB_VERSION constant must match TMDO_Installer::SCHEMA_VERSION' ); + } +} diff --git a/tests/unit/CacheLayerTest.php b/tests/unit/CacheLayerTest.php new file mode 100644 index 0000000..e76518a --- /dev/null +++ b/tests/unit/CacheLayerTest.php @@ -0,0 +1,158 @@ +getProperty( 'instance' )->setValue( null, null ); + + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { return null; } + public function get_row( string $sql, $output = OBJECT ) { return null; } + public function insert( string $table, array $data, $format = null ): int|false { return 1; } + public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; } + public function delete( string $table, array $where, $format = null ): int|false { return 1; } + public function query( string $sql ): int|bool { return 1; } + + public function get_results( string $sql, $output = OBJECT ): array { + return CacheLayerTest::$db_rows; + } + }; + } + + // ── Helper: register cold field ────────────────────────────────────────── + + private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void { + WPDO_Schema_Registry::instance()->register( 'test', [ + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'zone' => 'cold', + ] ); + } + + // ── prefetch() ─────────────────────────────────────────────────────────── + + public function test_prefetch_returns_early_for_empty_post_ids(): void { + $this->register_cold_field(); + WPDO_Cache_Layer::prefetch( [], 'hp_listing' ); + $this->assertEmpty( $GLOBALS['_wp_cache'] ); + } + + public function test_prefetch_returns_early_when_no_cold_keys_for_type(): void { + // 'unknown_type' has no registered cold fields. + WPDO_Cache_Layer::prefetch( [ 1, 2, 3 ], 'unknown_type' ); + $this->assertEmpty( $GLOBALS['_wp_cache'] ); + } + + public function test_prefetch_skips_already_cached_post_ids(): void { + $this->register_cold_field(); + $group = 'wpdo_cold_hp_listing'; + + // Pre-warm cache for post 1. + $GLOBALS['_wp_cache'][ $group ]['cold_1'] = [ 'hp_description' => 'cached' ]; + + // DB returns no extra rows — all were already cached. + self::$db_rows = []; + WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' ); + + // Cache should remain unchanged (no new entry written). + $this->assertSame( [ 'hp_description' => 'cached' ], $GLOBALS['_wp_cache'][ $group ]['cold_1'] ); + } + + public function test_prefetch_stores_fetched_data_in_cache(): void { + $this->register_cold_field(); + self::$db_rows = [ + [ 'post_id' => '5', 'data' => json_encode( [ 'hp_description' => 'Fetched!' ] ) ], + ]; + + WPDO_Cache_Layer::prefetch( [ 5 ], 'hp_listing' ); + + $group = 'wpdo_cold_hp_listing'; + $cached = $GLOBALS['_wp_cache'][ $group ]['cold_5'] ?? false; + $this->assertIsArray( $cached ); + $this->assertSame( 'Fetched!', $cached['hp_description'] ); + } + + public function test_prefetch_stores_empty_array_for_post_with_no_cold_row(): void { + $this->register_cold_field(); + // DB returns nothing for post 7. + self::$db_rows = []; + + WPDO_Cache_Layer::prefetch( [ 7 ], 'hp_listing' ); + + $group = 'wpdo_cold_hp_listing'; + $cached = $GLOBALS['_wp_cache'][ $group ]['cold_7'] ?? 'NOT_SET'; + $this->assertSame( [], $cached ); + } + + // ── warm_post() ────────────────────────────────────────────────────────── + + public function test_warm_post_returns_early_when_no_cold_keys(): void { + // 'no_cold_type' has no cold fields → warm_post returns immediately. + WPDO_Cache_Layer::warm_post( 1, 'no_cold_type' ); + $this->assertEmpty( $GLOBALS['_wp_cache'] ); + } + + public function test_warm_post_clears_stale_cache_entry(): void { + $this->register_cold_field(); + $group = 'wpdo_cold_hp_listing'; + $cache_key = 'cold_20'; + + // Pre-populate with stale data. + $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => true ]; + + WPDO_Cache_Layer::warm_post( 20, 'hp_listing' ); + + // Stale cache must be replaced (warm_post deletes then re-reads from DB, + // which returns null in mock, yielding empty array). + $this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null ); + } + + // ── get_stats() ────────────────────────────────────────────────────────── + + public function test_get_stats_includes_required_keys(): void { + $stats = WPDO_Cache_Layer::get_stats(); + + $this->assertArrayHasKey( 'groups', $stats ); + $this->assertArrayHasKey( 'prefetch_support', $stats ); + $this->assertArrayHasKey( 'flush_support', $stats ); + } + + public function test_get_stats_groups_reflect_registered_cold_types(): void { + $this->register_cold_field( 'hp_listing', 'hp_description' ); + $this->register_cold_field( 'hp_listing', 'hp_website' ); + + $stats = WPDO_Cache_Layer::get_stats(); + $post_types = array_column( $stats['groups'], 'post_type' ); + $this->assertContains( 'hp_listing', $post_types ); + } +} diff --git a/tests/unit/CapabilityTest.php b/tests/unit/CapabilityTest.php new file mode 100644 index 0000000..ec92cb1 --- /dev/null +++ b/tests/unit/CapabilityTest.php @@ -0,0 +1,92 @@ + true ); + + $this->assertTrue( WPDO_Capability::current_user_can_admin() ); + } + + public function test_single_site_subscriber_blocked(): void { + $GLOBALS['_wp_is_multisite'] = false; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false ); + + $this->assertFalse( WPDO_Capability::current_user_can_admin() ); + } + + // ── Multisite per-site behaviour ───────────────────────────────────────── + + public function test_multisite_site_admin_passes_with_manage_options(): void { + $GLOBALS['_wp_is_multisite'] = true; + $GLOBALS['_wp_is_super_admin'] = false; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true ); + + $this->assertTrue( WPDO_Capability::current_user_can_admin() ); + } + + public function test_multisite_subscriber_blocked(): void { + $GLOBALS['_wp_is_multisite'] = true; + $GLOBALS['_wp_is_super_admin'] = false; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false ); + + $this->assertFalse( WPDO_Capability::current_user_can_admin() ); + } + + // ── Multisite super-admin behaviour ────────────────────────────────────── + + public function test_multisite_super_admin_passes_without_manage_options(): void { + // Pre-v2.14.0 this returned false because super admins don't auto-have + // `manage_options` in network admin context. v2.14.0 fixes this. + $GLOBALS['_wp_is_multisite'] = true; + $GLOBALS['_wp_is_super_admin'] = true; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false ); + + $this->assertTrue( WPDO_Capability::current_user_can_admin() ); + } + + public function test_multisite_super_admin_passes_with_manage_options(): void { + $GLOBALS['_wp_is_multisite'] = true; + $GLOBALS['_wp_is_super_admin'] = true; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true ); + + $this->assertTrue( WPDO_Capability::current_user_can_admin() ); + } + + // ── Edge: super admin flag set on single-site (shouldn't be possible + // but should be defensive) ──────────────────────────────────────── + + public function test_super_admin_flag_ignored_on_single_site(): void { + // Super admin only exists on multisite; on single-site fall back to + // manage_options check. + $GLOBALS['_wp_is_multisite'] = false; + $GLOBALS['_wp_is_super_admin'] = true; + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => false ); + + $this->assertFalse( WPDO_Capability::current_user_can_admin() ); + } +} diff --git a/tests/unit/CliLintTest.php b/tests/unit/CliLintTest.php new file mode 100644 index 0000000..910b4e4 --- /dev/null +++ b/tests/unit/CliLintTest.php @@ -0,0 +1,139 @@ +fixture_dir = sys_get_temp_dir() . '/wpdo-lint-fixture-' . uniqid(); + mkdir( $this->fixture_dir, 0777, true ); + } + + protected function tearDown(): void { + // Recursive rmdir. + $this->rrmdir( $this->fixture_dir ); + } + + private function rrmdir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + foreach ( scandir( $dir ) as $f ) { + if ( '.' === $f || '..' === $f ) { + continue; + } + $path = $dir . '/' . $f; + is_dir( $path ) ? $this->rrmdir( $path ) : unlink( $path ); + } + rmdir( $dir ); + } + + private function write( string $relative, string $content ): void { + $path = $this->fixture_dir . '/' . $relative; + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0777, true ); + } + file_put_contents( $path, $content ); + } + + // ── happy path ────────────────────────────────────────────────────────── + + public function test_clean_plugin_passes_lint(): void { + $this->write( 'main.php', "fixture_dir ); + $this->assertEmpty( $findings ); + } + + // ── direct postmeta SELECT ───────────────────────────────────────────── + + public function test_direct_postmeta_select_flagged(): void { + $this->write( + 'bad.php', + "get_results( \"SELECT meta_value FROM {\$wpdb->prefix}postmeta WHERE meta_key='foo'\" );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $this->assertGreaterThanOrEqual( 1, count( $findings ) ); + $this->assertSame( 'no-direct-postmeta-select', $findings[0]['rule'] ); + } + + public function test_direct_usermeta_select_flagged(): void { + $this->write( + 'user.php', + "get_var( \"SELECT meta_value FROM wp_usermeta WHERE meta_key='foo'\" );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $this->assertGreaterThanOrEqual( 1, count( $findings ) ); + $this->assertSame( 'no-direct-usermeta-select', $findings[0]['rule'] ); + } + + // ── autoload=yes ─────────────────────────────────────────────────────── + + public function test_autoload_yes_flagged(): void { + $this->write( + 'opt.php', + " 'yes' );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $autoload_findings = array_filter( $findings, static fn( $f ) => 'autoload-yes' === $f['rule'] ); + $this->assertGreaterThanOrEqual( 1, count( $autoload_findings ) ); + } + + // ── ignore comment ───────────────────────────────────────────────────── + + public function test_phpcs_ignore_comment_skips_finding(): void { + $this->write( + 'fallback.php', + "get_results( \"SELECT meta_value FROM wp_postmeta WHERE meta_key='x'\" );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $this->assertEmpty( $findings, 'phpcs:ignore WPDO.AntiEAV must suppress findings' ); + } + + // ── skip dirs ────────────────────────────────────────────────────────── + + public function test_skips_vendor_and_node_modules(): void { + // Bad code inside vendor/ MUST be ignored. + $this->write( + 'vendor/lib/bad.php', + "get_results( \"SELECT meta_value FROM wp_postmeta\" );\n" + ); + $this->write( + 'node_modules/foo/bad.php', + "get_results( \"SELECT meta_value FROM wp_usermeta\" );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $this->assertEmpty( $findings ); + } + + // ── reports file + line ──────────────────────────────────────────────── + + public function test_finding_includes_file_and_line(): void { + $this->write( + 'multi.php', + "get_var( \"SELECT meta_value FROM wp_postmeta\" );\n" + ); + $findings = WPDO_CLI_V2::lint_directory( $this->fixture_dir ); + $this->assertNotEmpty( $findings ); + $this->assertStringEndsWith( 'multi.php', $findings[0]['file'] ); + $this->assertSame( 4, $findings[0]['line'] ); + } +} diff --git a/tests/unit/ConflictMonitorTest.php b/tests/unit/ConflictMonitorTest.php new file mode 100644 index 0000000..3b7826b --- /dev/null +++ b/tests/unit/ConflictMonitorTest.php @@ -0,0 +1,76 @@ +assertIsArray( $result ); + $this->assertEmpty( $result ); + } + + public function test_get_summary_when_clean(): void { + $summary = WPDO_Conflict_Monitor::get_summary(); + $this->assertSame( 0, $summary['total'] ); + $this->assertSame( 0, $summary['hook_overlap'] ); + $this->assertSame( 0, $summary['uaepg_overlap'] ); + } + + public function test_get_all_conflicts_lazy_scans(): void { + // First call populates the cache. + $first = WPDO_Conflict_Monitor::get_all_conflicts(); + $this->assertIsArray( $first ); + + // Subsequent calls return the same reference (cached). + $second = WPDO_Conflict_Monitor::get_all_conflicts(); + $this->assertSame( $first, $second ); + } + + public function test_reset_cache_forces_rescan(): void { + WPDO_Conflict_Monitor::scan(); + WPDO_Conflict_Monitor::reset_cache(); + + // Should not throw and should return empty (still no conflicts). + $this->assertSame( array(), WPDO_Conflict_Monitor::scan() ); + } + + public function test_summary_keys_always_present(): void { + $summary = WPDO_Conflict_Monitor::get_summary(); + $this->assertArrayHasKey( 'total', $summary ); + $this->assertArrayHasKey( 'hook_overlap', $summary ); + $this->assertArrayHasKey( 'uaepg_overlap', $summary ); + } + + public function test_admin_notice_is_silent_when_no_conflicts(): void { + ob_start(); + WPDO_Conflict_Monitor::maybe_render_admin_notice(); + $output = ob_get_clean(); + $this->assertSame( '', $output ); + } + + public function test_admin_bar_is_silent_when_no_conflicts(): void { + // Pass a valid object stub to admin_bar handler — should no-op when count = 0. + $stub = new class() { + public array $nodes = array(); + public function add_node( array $node ): void { + $this->nodes[] = $node; + } + }; + WPDO_Conflict_Monitor::maybe_render_admin_bar( $stub ); + $this->assertCount( 0, $stub->nodes ); + } +} diff --git a/tests/unit/CryptoV2Test.php b/tests/unit/CryptoV2Test.php new file mode 100644 index 0000000..bdd3605 --- /dev/null +++ b/tests/unit/CryptoV2Test.php @@ -0,0 +1,197 @@ +assertStringStartsWith( WPDO_Crypto::PREFIX_V2, $encrypted ); + $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) ); + } + + public function test_v2_round_trip_unicode(): void { + $plain = '中文密碼 + emoji 🔐 + special chars !@#$%^&*()'; + $encrypted = WPDO_Crypto::encrypt( $plain ); + + $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) ); + } + + public function test_v2_round_trip_long_string(): void { + $plain = str_repeat( 'A', 4096 ); + $encrypted = WPDO_Crypto::encrypt( $plain ); + + $this->assertSame( $plain, WPDO_Crypto::decrypt( $encrypted ) ); + } + + public function test_v2_each_encryption_produces_unique_ciphertext(): void { + // Random IV → repeated encrypts of the same plaintext yield different blobs. + $plain = 'identical plaintext'; + $ct1 = WPDO_Crypto::encrypt( $plain ); + $ct2 = WPDO_Crypto::encrypt( $plain ); + + $this->assertNotSame( $ct1, $ct2, 'IV randomness should produce unique ciphertexts' ); + $this->assertSame( $plain, WPDO_Crypto::decrypt( $ct1 ) ); + $this->assertSame( $plain, WPDO_Crypto::decrypt( $ct2 ) ); + } + + // ── v2 GCM tamper detection ────────────────────────────────────────────── + + public function test_v2_tampered_ciphertext_returns_original(): void { + $plain = 'sensitive webhook url'; + $encrypted = WPDO_Crypto::encrypt( $plain ); + + // Decode the base64 payload, flip the FIRST byte of the GCM auth tag + // (which lives at offset 12 right after the IV), re-encode. This + // guarantees a real ciphertext modification regardless of base64 + // alphabet (vs str_replace which can be a no-op for some random IVs). + $prefix_len = strlen( WPDO_Crypto::PREFIX_V2 ); + $encoded = substr( $encrypted, $prefix_len ); + $raw = base64_decode( $encoded, true ); + $this->assertNotFalse( $raw, 'Setup precondition: ciphertext must be valid base64' ); + $raw[12] = chr( ord( $raw[12] ) ^ 0x55 ); // flip 4 bits of the auth tag. + $tampered = WPDO_Crypto::PREFIX_V2 . base64_encode( $raw ); + + $result = WPDO_Crypto::decrypt( $tampered ); + $this->assertNotSame( $plain, $result, 'Tampered GCM ciphertext must NOT decrypt to original plaintext' ); + $this->assertSame( $tampered, $result, 'On auth failure decrypt() must return original blob' ); + } + + public function test_v2_truncated_blob_safe_failure(): void { + $encrypted = WPDO_Crypto::encrypt( 'some value' ); + // Truncate to less than min size (12 IV + 16 tag + 1 byte ciphertext). + $truncated = substr( $encrypted, 0, strlen( WPDO_Crypto::PREFIX_V2 ) + 5 ); + + // Should not throw; should return original. + $result = WPDO_Crypto::decrypt( $truncated ); + $this->assertSame( $truncated, $result ); + } + + // ── v1 CBC backward compat ─────────────────────────────────────────────── + + public function test_v1_legacy_blob_decrypts_successfully(): void { + // Hand-craft a v1 CBC blob using the same key derivation. + $plain = 'legacy webhook url from pre-v2.15'; + $key = $this->derive_key(); + $iv = random_bytes( 16 ); + $ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); + $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct ); + + $this->assertSame( $plain, WPDO_Crypto::decrypt( $blob ) ); + } + + public function test_v1_blob_with_garbage_returns_original(): void { + $bad = WPDO_Crypto::PREFIX_V1 . 'not_valid_base64!!!'; + $this->assertSame( $bad, WPDO_Crypto::decrypt( $bad ) ); + } + + // ── Plaintext passthrough ──────────────────────────────────────────────── + + public function test_plaintext_passthrough(): void { + $plain = 'https://example.com/raw'; + $this->assertSame( $plain, WPDO_Crypto::decrypt( $plain ) ); + } + + public function test_empty_input(): void { + $this->assertSame( '', WPDO_Crypto::encrypt( '' ) ); + $this->assertSame( '', WPDO_Crypto::decrypt( '' ) ); + } + + // ── format_version ─────────────────────────────────────────────────────── + + public function test_format_version_classification(): void { + // Use option-API stubs from bootstrap. + $GLOBALS['_wp_options']['test_v2_opt'] = WPDO_Crypto::encrypt( 'foo' ); + $GLOBALS['_wp_options']['test_plain_opt'] = 'plaintext_value'; + $GLOBALS['_wp_options']['test_empty_opt'] = ''; + + // Hand-craft a v1 blob. + $key = $this->derive_key(); + $iv = random_bytes( 16 ); + $ct = openssl_encrypt( 'bar', 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); + $GLOBALS['_wp_options']['test_v1_opt'] = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct ); + + $this->assertSame( 'v2', WPDO_Crypto::format_version( 'test_v2_opt' ) ); + $this->assertSame( 'v1', WPDO_Crypto::format_version( 'test_v1_opt' ) ); + $this->assertSame( 'plaintext', WPDO_Crypto::format_version( 'test_plain_opt' ) ); + $this->assertSame( 'empty', WPDO_Crypto::format_version( 'test_empty_opt' ) ); + $this->assertSame( 'empty', WPDO_Crypto::format_version( 'nonexistent_opt' ) ); + } + + // ── migrate_option_v1_to_v2 ────────────────────────────────────────────── + + public function test_migrate_option_v1_to_v2_round_trip(): void { + $plain = 'webhook to migrate'; + $key = $this->derive_key(); + $iv = random_bytes( 16 ); + $ct = openssl_encrypt( $plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv ); + $blob = WPDO_Crypto::PREFIX_V1 . base64_encode( $iv . $ct ); + + $GLOBALS['_wp_options']['migrate_test'] = $blob; + + $result = WPDO_Crypto::migrate_option_v1_to_v2( 'migrate_test' ); + $this->assertSame( 'migrated', $result ); + + // After migration: v2 blob, decrypts to original plaintext. + $this->assertSame( 'v2', WPDO_Crypto::format_version( 'migrate_test' ) ); + $this->assertSame( $plain, WPDO_Crypto::get_option( 'migrate_test' ) ); + } + + public function test_migrate_option_already_v2_is_noop(): void { + $GLOBALS['_wp_options']['already_v2'] = WPDO_Crypto::encrypt( 'foo' ); + $result = WPDO_Crypto::migrate_option_v1_to_v2( 'already_v2' ); + $this->assertSame( 'already_v2', $result ); + } + + public function test_migrate_option_plaintext_skipped(): void { + $GLOBALS['_wp_options']['plain_opt'] = 'just plaintext'; + $result = WPDO_Crypto::migrate_option_v1_to_v2( 'plain_opt' ); + $this->assertSame( 'plaintext_skipped', $result ); + // Original value preserved. + $this->assertSame( 'just plaintext', $GLOBALS['_wp_options']['plain_opt'] ); + } + + public function test_migrate_option_empty_returns_empty(): void { + $GLOBALS['_wp_options']['empty_opt'] = ''; + $result = WPDO_Crypto::migrate_option_v1_to_v2( 'empty_opt' ); + $this->assertSame( 'empty', $result ); + } + + // ── Helper ─────────────────────────────────────────────────────────────── + + /** + * Replicates WPDO_Crypto::derived_key() to craft test fixtures. + * + * @return string 32 raw bytes. + */ + private function derive_key(): string { + $salt = AUTH_KEY . SECURE_AUTH_SALT; + return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 ); + } +} diff --git a/tests/unit/CustomTableRegistryTest.php b/tests/unit/CustomTableRegistryTest.php new file mode 100644 index 0000000..e0c7971 --- /dev/null +++ b/tests/unit/CustomTableRegistryTest.php @@ -0,0 +1,261 @@ +register( '2meet-courses', array( + 'table_name' => '2mc_courses', + 'primary_key' => 'id', + 'post_type_link' => null, + ) ); + + $this->assertTrue( $ok ); + $this->assertCount( 1, $registry->all() ); + } + + public function test_register_rejects_empty_table_name(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $this->assertFalse( $registry->register( '2meet-courses', array() ) ); + $this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '' ) ) ); + } + + public function test_register_rejects_empty_provider(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $this->assertFalse( $registry->register( '', array( 'table_name' => '2mc_courses' ) ) ); + } + + public function test_register_rejects_duplicate_provider_table_pair(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) ); + // Same provider+table → false. + $this->assertFalse( $registry->register( '2meet-courses', array( 'table_name' => '2mc_courses' ) ) ); + } + + public function test_register_allows_same_table_different_provider(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $this->assertTrue( $registry->register( '2meet-courses', array( 'table_name' => 'shared_t' ) ) ); + $this->assertTrue( $registry->register( '2meet-bookings', array( 'table_name' => 'shared_t' ) ) ); + $this->assertCount( 2, $registry->all() ); + } + + public function test_register_sanitizes_table_name(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + // WordPress sanitize_key strips non-alphanumeric/underscore/dash entirely (no replacement). + $registry->register( 'p', array( 'table_name' => 'My Bad-Name!' ) ); + + $tables = $registry->all(); + $cfg = reset( $tables ); + $this->assertSame( 'mybad-name', $cfg['table_name'] ); + } + + public function test_register_applies_defaults(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( 'table_name' => 't' ) ); + + $tables = $registry->all(); + $cfg = reset( $tables ); + $this->assertSame( 'id', $cfg['primary_key'] ); + $this->assertNull( $cfg['post_type_link'] ); + $this->assertSame( array(), $cfg['expected_columns'] ); + } + + // ── unregister() ──────────────────────────────────────────────────────── + + public function test_unregister_removes_table(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( 'table_name' => 't' ) ); + $this->assertTrue( $registry->unregister( 'p', 't' ) ); + $this->assertCount( 0, $registry->all() ); + } + + public function test_unregister_returns_false_for_unknown(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $this->assertFalse( $registry->unregister( 'unknown', 'table' ) ); + } + + // ── for_provider() / for_post_type() ──────────────────────────────────── + + public function test_for_provider_filters_correctly(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'a', array( 'table_name' => 't1' ) ); + $registry->register( 'a', array( 'table_name' => 't2' ) ); + $registry->register( 'b', array( 'table_name' => 't3' ) ); + + $this->assertCount( 2, $registry->for_provider( 'a' ) ); + $this->assertCount( 1, $registry->for_provider( 'b' ) ); + $this->assertCount( 0, $registry->for_provider( 'c' ) ); + } + + /** + * v2.1.3 R3 hardening — verify by_provider index stays in sync when a + * non-edge entry is unregistered. Previously untested per audit finding. + */ + public function test_for_provider_after_unregister_middle_entry(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( 'table_name' => 'first' ) ); + $registry->register( 'p', array( 'table_name' => 'middle' ) ); + $registry->register( 'p', array( 'table_name' => 'last' ) ); + + $this->assertCount( 3, $registry->for_provider( 'p' ) ); + + // Remove the middle entry. + $ok = $registry->unregister( 'p', 'middle' ); + $this->assertTrue( $ok ); + + $remaining = $registry->for_provider( 'p' ); + $this->assertCount( 2, $remaining ); + + // Verify the surviving entries are correct (not 'middle'). + $names = array_column( $remaining, 'table_name' ); + sort( $names ); + $this->assertSame( array( 'first', 'last' ), $names ); + + // Re-registering 'middle' should put it back. + $registry->register( 'p', array( 'table_name' => 'middle' ) ); + $this->assertCount( 3, $registry->for_provider( 'p' ) ); + } + + /** + * Verify by_provider index empties (and removes the provider key entirely) + * when the last table for that provider is unregistered. + */ + public function test_for_provider_returns_empty_after_full_unregister(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'solo', array( 'table_name' => 'only_table' ) ); + $this->assertCount( 1, $registry->for_provider( 'solo' ) ); + + $registry->unregister( 'solo', 'only_table' ); + $this->assertCount( 0, $registry->for_provider( 'solo' ) ); + $this->assertSame( array(), $registry->for_provider( 'solo' ) ); + } + + public function test_for_post_type_filters_correctly(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( 'table_name' => 't1', 'post_type_link' => 'hp_listing' ) ); + $registry->register( 'p', array( 'table_name' => 't2', 'post_type_link' => 'hp_listing' ) ); + $registry->register( 'p', array( 'table_name' => 't3', 'post_type_link' => 'hp_vendor' ) ); + $registry->register( 'p', array( 'table_name' => 't4', 'post_type_link' => null ) ); + + $this->assertCount( 2, $registry->for_post_type( 'hp_listing' ) ); + $this->assertCount( 1, $registry->for_post_type( 'hp_vendor' ) ); + $this->assertCount( 0, $registry->for_post_type( 'unknown' ) ); + } + + public function test_providers_returns_unique_list(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'a', array( 'table_name' => 't1' ) ); + $registry->register( 'a', array( 'table_name' => 't2' ) ); + $registry->register( 'b', array( 'table_name' => 't3' ) ); + + $providers = $registry->providers(); + sort( $providers ); + $this->assertSame( array( 'a', 'b' ), $providers ); + } + + // ── get_stats() ───────────────────────────────────────────────────────── + + public function test_get_stats_counts_callbacks(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( + 'table_name' => 't1', + 'doctor_callback' => static fn() => array( 'ok' => true ), + 'benchmark_callback' => static fn() => array( 'duration_ms' => 1.0 ), + ) ); + $registry->register( 'p', array( 'table_name' => 't2' ) ); + + $stats = $registry->get_stats(); + $this->assertSame( 2, $stats['tables_count'] ); + $this->assertSame( 1, $stats['providers_count'] ); + $this->assertSame( 1, $stats['with_doctor'] ); + $this->assertSame( 1, $stats['with_benchmark'] ); + } + + // ── run_doctor_checks() ───────────────────────────────────────────────── + + public function test_run_doctor_checks_invokes_callbacks(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( + 'table_name' => 't1', + 'doctor_callback' => static fn() => array( 'ok' => true, 'message' => 'all good' ), + ) ); + $registry->register( 'p', array( + 'table_name' => 't2', + 'doctor_callback' => static fn() => array( 'ok' => false, 'message' => 'index missing' ), + ) ); + $registry->register( 'p', array( 'table_name' => 't3' ) ); // No callback → skipped. + + $results = $registry->run_doctor_checks(); + $this->assertCount( 2, $results ); + $this->assertTrue( $results['p:t1']['ok'] ); + $this->assertSame( 'all good', $results['p:t1']['message'] ); + $this->assertFalse( $results['p:t2']['ok'] ); + } + + public function test_run_doctor_checks_passes_table_name_to_callback(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $received = null; + + $registry->register( 'wc', array( + 'table_name' => 'wc_orders', + 'doctor_callback' => static function ( string $table_name ) use ( &$received ): array { + $received = $table_name; + return array( 'ok' => true, 'message' => "checked {$table_name}" ); + }, + ) ); + + $results = $registry->run_doctor_checks(); + + $this->assertSame( 'wc_orders', $received, 'callback must receive table_name as first argument' ); + $this->assertSame( 'checked wc_orders', $results['wc:wc_orders']['message'] ); + } + + public function test_run_doctor_checks_catches_throwables(): void { + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->register( 'p', array( + 'table_name' => 't1', + 'doctor_callback' => static function () { + throw new RuntimeException( 'simulated failure' ); + }, + ) ); + + $results = $registry->run_doctor_checks(); + $this->assertCount( 1, $results ); + $this->assertFalse( $results['p:t1']['ok'] ); + $this->assertStringContainsString( 'simulated failure', $results['p:t1']['message'] ); + } + + // ── fire_registration() idempotency ───────────────────────────────────── + + public function test_fire_registration_is_idempotent(): void { + $count = 0; + add_action( 'wpdo_register_custom_tables', function () use ( &$count ) { + ++$count; + } ); + + $registry = WPDO_Custom_Table_Registry::instance(); + $registry->fire_registration(); + $registry->fire_registration(); + $registry->fire_registration(); + + // Stub add_action() in unit bootstrap returns true but does NOT execute callbacks, + // so the meaningful assertion here is that fire_registration() doesn't throw. + $this->assertTrue( true ); + } +} diff --git a/tests/unit/Diagnostic/HealthCronTest.php b/tests/unit/Diagnostic/HealthCronTest.php new file mode 100644 index 0000000..7e26e7e --- /dev/null +++ b/tests/unit/Diagnostic/HealthCronTest.php @@ -0,0 +1,132 @@ +code = $code; + $this->message = $message; + $this->data = (array) $data; + } + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} + +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php'; +require_once dirname( __DIR__, 3 ) . '/includes/diagnostic/class-tmdo-health-cron.php'; + +/** + * Unit tests for WPDO_Health_Cron (v2.3.0 M6). + * + * Pure-logic tests — does not exercise actual cron firing or full Site Health + * subprocess (those covered by integration suite). Asserts on output shape + + * counter aggregation + alert flag setting. + */ +class HealthCronTest extends TestCase { + + protected function setUp(): void { + $GLOBALS['_wp_options'] = array(); + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + $wpdb = new class { + public string $prefix = 'wp_'; + public string $options = 'wp_options'; + // v2.5.0:Module_Detector reads $wpdb->posts during Health_Cron + // integration; declared here to silence undefined-property warning. + public string $posts = 'wp_posts'; + public string $postmeta = 'wp_postmeta'; + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%[sd]/', function() use ( &$i, $args ) { + return (string) ( $args[ $i++ ] ?? '?' ); + }, $sql ); + } + public function get_var( string $sql ) { + $upper = strtoupper( $sql ); + // Table-existence probes → truthy so schema_drift / orphan_zone passes. + if ( str_contains( $upper, 'SHOW TABLES' ) || str_contains( $upper, 'INFORMATION_SCHEMA' ) ) { + return '1'; + } + return '0'; + } + public function get_results( string $sql, $output = ARRAY_A ): array { + return array(); + } + public function query( string $sql ): int { return 0; } + public function insert( string $table, array $data ): int { return 1; } + }; + } + + public function test_get_last_run_returns_null_when_never_run(): void { + $this->assertNull( WPDO_Health_Cron::get_last_run() ); + } + + public function test_run_returns_summary_shape(): void { + $result = WPDO_Health_Cron::run(); + $this->assertTrue( $result['ok'] ); + $this->assertArrayHasKey( 'summary', $result ); + $this->assertArrayHasKey( 'critical_count', $result ); + $this->assertArrayHasKey( 'recommended_count', $result ); + $this->assertArrayHasKey( 'ts', $result ); + $this->assertArrayHasKey( 'tests', $result['summary'] ); + $this->assertArrayHasKey( 'conflicts', $result['summary'] ); + $this->assertArrayHasKey( 'shadow_diffs', $result['summary'] ); + $this->assertArrayHasKey( 'autoload_bytes', $result['summary'] ); + $this->assertArrayHasKey( 'duration_ms', $result['summary'] ); + } + + public function test_run_persists_last_run_option(): void { + WPDO_Health_Cron::run(); + $last = WPDO_Health_Cron::get_last_run(); + $this->assertIsArray( $last ); + $this->assertArrayHasKey( 'tests', $last ); + $this->assertArrayHasKey( 'critical_count', $last ); + } + + public function test_run_clears_alert_when_no_critical(): void { + // Pre-set an alert. + update_option( WPDO_Health_Cron::OPTION_ALERT, array( 'level' => 'critical' ), false ); + WPDO_Health_Cron::run(); + $this->assertFalse( get_option( WPDO_Health_Cron::OPTION_ALERT ), 'alert should be cleared on green run' ); + } + + public function test_consecutive_green_days_zero_when_no_run(): void { + $this->assertSame( 0, WPDO_Health_Cron::consecutive_green_days() ); + } + + public function test_consecutive_green_days_one_after_green_run(): void { + WPDO_Health_Cron::run(); + $this->assertSame( 1, WPDO_Health_Cron::consecutive_green_days() ); + } + + public function test_option_keys_are_documented(): void { + $this->assertSame( 'wpdo_health_last_run', WPDO_Health_Cron::OPTION_LAST_RUN ); + $this->assertSame( 'wpdo_health_alert', WPDO_Health_Cron::OPTION_ALERT ); + } +} diff --git a/tests/unit/Export/CsvWriterTest.php b/tests/unit/Export/CsvWriterTest.php new file mode 100644 index 0000000..28012b5 --- /dev/null +++ b/tests/unit/Export/CsvWriterTest.php @@ -0,0 +1,48 @@ +assertSame( "\xEF\xBB\xBF", substr( $out, 0, 3 ) ); + } + + public function test_simple_row_csv_output(): void { + $out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1', 'b' => '2' ) ) ); + $this->assertStringContainsString( "a,b\r\n1,2\r\n", $out ); + } + + public function test_field_with_comma_gets_quoted(): void { + $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'foo,bar' ) ) ); + $this->assertStringContainsString( '"foo,bar"', $out ); + } + + public function test_field_with_quote_doubles_it(): void { + $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => 'say "hi"' ) ) ); + $this->assertStringContainsString( '"say ""hi"""', $out ); + } + + public function test_field_with_newline_gets_quoted(): void { + $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => "line1\nline2" ) ) ); + $this->assertStringContainsString( "\"line1\nline2\"", $out ); + } + + public function test_array_value_serializes_to_json(): void { + $out = WPDO_CSV_Writer::build( array( 'col' ), array( array( 'col' => array( 'a', 'b' ) ) ) ); + // Array becomes JSON; quotes inside JSON are doubled inside the CSV-quoted field. + $this->assertStringContainsString( '"[""a"",""b""]"', $out ); + } + + public function test_missing_field_renders_empty(): void { + $out = WPDO_CSV_Writer::build( array( 'a', 'b' ), array( array( 'a' => '1' ) ) ); + $this->assertStringContainsString( "1,\r\n", $out ); + } +} diff --git a/tests/unit/FeatureFlagsTest.php b/tests/unit/FeatureFlagsTest.php new file mode 100644 index 0000000..84f1dc8 --- /dev/null +++ b/tests/unit/FeatureFlagsTest.php @@ -0,0 +1,112 @@ +assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_unknown' ) ); + } + + public function test_get_returns_stored_state(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' ); + $this->assertSame( 'backfill', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + // ── set() ───────────────────────────────────────────────────────────────── + + public function test_set_valid_state_returns_true(): void { + $result = WPDO_Feature_Flags::set( 'mod', 'cutover' ); + $this->assertTrue( $result ); + $this->assertSame( 'cutover', WPDO_Feature_Flags::get( 'mod' ) ); + } + + public function test_set_invalid_state_returns_false(): void { + $result = WPDO_Feature_Flags::set( 'mod', 'invalid_state_xyz' ); + $this->assertFalse( $result ); + } + + // ── Query-active check ────────────────────────────────────────────────── + + public function test_is_query_active_true_when_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) ); + } + + public function test_is_query_active_true_when_complete(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' ); + $this->assertTrue( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) ); + } + + public function test_is_query_active_false_when_backfill(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' ); + $this->assertFalse( WPDO_Feature_Flags::is_query_active( 'hot_hp_listing' ) ); + } + + // ── is_write_active ────────────────────────────────────────────────────── + + public function test_is_write_active_true_when_dual_write(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + $this->assertTrue( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) ); + } + + public function test_is_write_active_false_when_idle(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + $this->assertFalse( WPDO_Feature_Flags::is_write_active( 'hot_hp_listing' ) ); + } + + // ── is_read_custom ─────────────────────────────────────────────────────── + + public function test_is_read_custom_true_when_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertTrue( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) ); + } + + public function test_is_read_custom_false_when_backfill(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' ); + $this->assertFalse( WPDO_Feature_Flags::is_read_custom( 'hot_hp_listing' ) ); + } + + // ── all() ──────────────────────────────────────────────────────────────── + + public function test_all_returns_array_of_states(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + WPDO_Feature_Flags::set( 'hot_hp_vendor', 'idle' ); + + $all = WPDO_Feature_Flags::all(); + + $this->assertIsArray( $all ); + $this->assertSame( 'cutover', $all['hot_hp_listing'] ); + $this->assertSame( 'idle', $all['hot_hp_vendor'] ); + } + + // ── reset() ───────────────────────────────────────────────────────────── + + public function test_reset_returns_module_to_idle(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' ); + WPDO_Feature_Flags::reset( 'hot_hp_listing' ); + $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + // ── is_complete ────────────────────────────────────────────────────────── + + public function test_is_complete_true_when_complete(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'complete' ); + $this->assertTrue( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) ); + } + + public function test_is_complete_false_when_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertFalse( WPDO_Feature_Flags::is_complete( 'hot_hp_listing' ) ); + } +} diff --git a/tests/unit/HookBusBridgeTest.php b/tests/unit/HookBusBridgeTest.php new file mode 100644 index 0000000..129fd05 --- /dev/null +++ b/tests/unit/HookBusBridgeTest.php @@ -0,0 +1,112 @@ +assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() ); + } + + public function test_is_enabled_when_option_set_to_string_one(): void { + update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' ); + WPDO_Hook_Bus_Bridge::reset_cache(); + $this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() ); + } + + public function test_is_enabled_when_option_set_to_bool_true(): void { + update_option( WPDO_Hook_Bus_Bridge::OPTION, true ); + WPDO_Hook_Bus_Bridge::reset_cache(); + $this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() ); + } + + public function test_is_enabled_when_option_set_to_zero(): void { + update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' ); + WPDO_Hook_Bus_Bridge::reset_cache(); + $this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() ); + } + + public function test_is_enabled_caches_result(): void { + update_option( WPDO_Hook_Bus_Bridge::OPTION, '1' ); + WPDO_Hook_Bus_Bridge::reset_cache(); + $first = WPDO_Hook_Bus_Bridge::is_enabled(); + + // Mutate the option, but cache should retain previous value until reset. + update_option( WPDO_Hook_Bus_Bridge::OPTION, '0' ); + $second = WPDO_Hook_Bus_Bridge::is_enabled(); + $this->assertSame( $first, $second, 'Cache must be sticky within a request' ); + + // After explicit reset → new value visible. + WPDO_Hook_Bus_Bridge::reset_cache(); + $this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() ); + } + + // ── maybe_init_hook_bus() ─────────────────────────────────────────────── + + public function test_maybe_init_hook_bus_noop_when_disabled(): void { + // Disabled → must not throw even if WPDO_Hook_Bus class missing. + WPDO_Hook_Bus_Bridge::maybe_init_hook_bus(); + $this->assertTrue( true ); + } + + // ── detect_intra_wpdo_conflicts() ─────────────────────────────────────── + + public function test_detect_intra_wpdo_conflicts_returns_empty_when_no_filters(): void { + // Stub bootstrap doesn't populate $wp_filter, so result is empty array. + $conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts(); + $this->assertIsArray( $conflicts ); + $this->assertEmpty( $conflicts ); + } + + public function test_detect_intra_wpdo_conflicts_flags_multiple_wpdo_callbacks(): void { + // Simulate $wp_filter with two WPDO_* callbacks on the same hook. + $GLOBALS['wp_filter'] = array( + 'update_post_metadata' => new class() { + public array $callbacks; + public function __construct() { + $this->callbacks = array( + 10 => array( + array( + 'function' => array( + new class() { + public function intercept_update() {} + }, + 'intercept_update', + ), + ), + ), + 8 => array( + array( + 'function' => array( + new class() { + public function intercept_update() {} + }, + 'intercept_update', + ), + ), + ), + ); + } + }, + ); + + // The anonymous classes won't have WPDO_ prefix → must not flag conflict. + $conflicts = WPDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts(); + $this->assertEmpty( $conflicts, 'Non-WPDO classes must not be flagged' ); + } +} diff --git a/tests/unit/LoggerTest.php b/tests/unit/LoggerTest.php new file mode 100644 index 0000000..9b3cffa --- /dev/null +++ b/tests/unit/LoggerTest.php @@ -0,0 +1,133 @@ +insert(). */ + public static array $last_insert = []; + + /** Insert call counter. */ + public static int $insert_count = 0; + + /** Value returned by $wpdb->query(). */ + public static int $query_return = 1; + + /** Rows returned by $wpdb->get_results(). */ + public static array $get_results_return = []; + + /** Last SQL passed to $wpdb->query(). */ + public static string $last_query_sql = ''; + + protected function setUp(): void { + self::$last_insert = []; + self::$insert_count = 0; + self::$query_return = 1; + self::$get_results_return = []; + self::$last_query_sql = ''; + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + public string $postmeta = 'wp_postmeta'; + public string $posts = 'wp_posts'; + public string $options = 'wp_options'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + return null; + } + + public function get_row( string $sql, $output = OBJECT ) { + return null; + } + + public function get_results( string $sql, $output = OBJECT ): array { + return LoggerTest::$get_results_return; + } + + public function insert( string $table, array $data, $format = null ): int|false { + LoggerTest::$last_insert = $data; + LoggerTest::$insert_count++; + return 1; + } + + public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false { + return 1; + } + + public function delete( string $table, array $where, $format = null ): int|false { + return 1; + } + + public function query( string $sql ): int|bool { + LoggerTest::$last_query_sql = $sql; + return LoggerTest::$query_return; + } + }; + } + + // ── error() ────────────────────────────────────────────────────────────── + + public function test_error_inserts_row_into_db(): void { + WPDO_Logger::error( 'reviews', 'save_hook', 'Something went wrong' ); + $this->assertSame( 1, self::$insert_count ); + } + + public function test_error_sanitizes_module_field(): void { + WPDO_Logger::error( 'Reviews Module!', 'some_hook', 'test message' ); + // sanitize_key strips non-[a-z0-9_-] chars; result matches stub output. + $this->assertSame( sanitize_key( 'Reviews Module!' ), self::$last_insert['module'] ); + } + + public function test_error_trims_hook_to_255_chars(): void { + $long_hook = str_repeat( 'x', 300 ); + WPDO_Logger::error( 'mod', $long_hook, 'msg' ); + $this->assertLessThanOrEqual( 255, strlen( self::$last_insert['hook'] ) ); + } + + public function test_error_encodes_context_as_json(): void { + $context = [ 'post_id' => 42, 'extra' => 'data' ]; + WPDO_Logger::error( 'mod', 'hook', 'msg', $context ); + $this->assertSame( json_encode( $context, JSON_UNESCAPED_UNICODE ), self::$last_insert['context'] ); + } + + public function test_error_sets_null_context_when_empty(): void { + WPDO_Logger::error( 'mod', 'hook', 'msg' ); + $this->assertNull( self::$last_insert['context'] ); + } + + // ── get_recent() ───────────────────────────────────────────────────────── + + public function test_get_recent_returns_wpdb_results(): void { + self::$get_results_return = [ + [ 'id' => 1, 'module' => 'reviews', 'message' => 'err1' ], + [ 'id' => 2, 'module' => 'hot', 'message' => 'err2' ], + ]; + $results = WPDO_Logger::get_recent(); + $this->assertCount( 2, $results ); + } + + // ── purge() ─────────────────────────────────────────────────────────────── + + public function test_purge_returns_query_result(): void { + self::$query_return = 5; + $deleted = WPDO_Logger::purge( 30 ); + $this->assertSame( 5, $deleted ); + } +} diff --git a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php new file mode 100644 index 0000000..5079b2d --- /dev/null +++ b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php @@ -0,0 +1,202 @@ +assertTrue( true ); + } + + public function test_all_four_groups_are_registered(): void { + WPDO_Member_Fields::register_entity_fields(); + + foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) { + $fields = WPDO_Entity_Registry::get_group_fields( 'user', $group ); + $this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." ); + } + } + + // ── membership group ───────────────────────────────────────────────────── + + public function test_membership_group_has_six_fields(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' ); + $this->assertCount( 6, $fields ); + } + + public function test_membership_level_is_enum_with_five_options(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' ); + $level = $this->find_field( $fields, 'membership_level' ); + + $this->assertNotNull( $level ); + $this->assertSame( 'enum', $level['type'] ); + $this->assertCount( 5, $level['options'] ); + $this->assertContains( 'gold', $level['options'] ); + $this->assertContains( 'platinum', $level['options'] ); + } + + public function test_membership_level_is_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' ); + $level = $this->find_field( $fields, 'membership_level' ); + + $this->assertTrue( (bool) $level['searchable'] ); + } + + public function test_points_balance_is_integer_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' ); + $field = $this->find_field( $fields, 'points_balance' ); + + $this->assertSame( 'integer', $field['type'] ); + $this->assertTrue( (bool) $field['searchable'] ); + $this->assertSame( 0, $field['default'] ); + } + + public function test_membership_expires_at_is_datetime_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'membership' ); + $field = $this->find_field( $fields, 'membership_expires_at' ); + + $this->assertSame( 'datetime', $field['type'] ); + $this->assertTrue( (bool) $field['searchable'] ); + } + + // ── activity group ─────────────────────────────────────────────────────── + + public function test_activity_group_has_six_fields(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' ); + $this->assertCount( 6, $fields ); + } + + public function test_login_count_has_integer_type_and_zero_default(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' ); + $field = $this->find_field( $fields, 'login_count' ); + + $this->assertSame( 'integer', $field['type'] ); + $this->assertSame( 0, $field['default'] ); + } + + public function test_account_flags_is_integer(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'activity' ); + $field = $this->find_field( $fields, 'account_flags' ); + + $this->assertSame( 'integer', $field['type'] ); + } + + // ── profile group ──────────────────────────────────────────────────────── + + public function test_profile_group_has_five_fields(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' ); + $this->assertCount( 5, $fields ); + } + + public function test_specialties_is_json_type(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' ); + $field = $this->find_field( $fields, 'specialties' ); + + $this->assertSame( 'json', $field['type'] ); + } + + public function test_display_name_custom_is_fulltext_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'profile' ); + $field = $this->find_field( $fields, 'display_name_custom' ); + + $this->assertTrue( (bool) $field['searchable'] ); + $this->assertTrue( (bool) $field['fulltext'] ); + } + + // ── sso group ──────────────────────────────────────────────────────────── + + public function test_sso_group_has_seven_fields(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ); + $this->assertCount( 7, $fields ); + } + + public function test_hub_global_user_id_is_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ); + $field = $this->find_field( $fields, 'hub_global_user_id' ); + + $this->assertTrue( (bool) $field['searchable'] ); + } + + public function test_token_expires_at_is_datetime_searchable(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ); + $field = $this->find_field( $fields, 'token_expires_at' ); + + $this->assertSame( 'datetime', $field['type'] ); + $this->assertTrue( (bool) $field['searchable'] ); + } + + public function test_refresh_token_enc_is_textarea(): void { + WPDO_Member_Fields::register_entity_fields(); + $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ); + $field = $this->find_field( $fields, 'refresh_token_enc' ); + + $this->assertSame( 'textarea', $field['type'] ); + } + + // ── register() hooks add_action ────────────────────────────────────────── + + public function test_register_hooks_wpdo_register_entity_fields(): void { + // add_action is a no-op stub in test bootstrap; just confirm no exception. + $this->assertNull( WPDO_Member_Fields::register() ); + } + + // ── helper ─────────────────────────────────────────────────────────────── + + /** + * Find a field definition by key within a fields array. + * + * @param array $fields Array of field definitions. + * @param string $key Meta key to find. + * @return array|null + */ + private function find_field( array $fields, string $key ): ?array { + foreach ( $fields as $field ) { + if ( $field['key'] === $key ) { + return $field; + } + } + return null; + } +} diff --git a/tests/unit/MemberFields/PointsManagerTest.php b/tests/unit/MemberFields/PointsManagerTest.php new file mode 100644 index 0000000..393c543 --- /dev/null +++ b/tests/unit/MemberFields/PointsManagerTest.php @@ -0,0 +1,231 @@ +original_wpdb = $wpdb; + + // Install a controllable mock that also has insert_id. + $wpdb = $this->make_wpdb_mock(); + } + + protected function tearDown(): void { + global $wpdb; + $wpdb = $this->original_wpdb; + } + + // ── credit() input validation ──────────────────────────────────────────── + + public function test_credit_rejects_zero_delta(): void { + $result = WPDO_Points_Manager::credit( 1, 0 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'credit delta must be positive', $result['error'] ); + } + + public function test_credit_rejects_negative_delta(): void { + $result = WPDO_Points_Manager::credit( 1, -50 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'credit delta must be positive', $result['error'] ); + } + + // ── debit() input validation ───────────────────────────────────────────── + + public function test_debit_rejects_zero_delta(): void { + $result = WPDO_Points_Manager::debit( 1, 0 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'debit delta must be positive', $result['error'] ); + } + + public function test_debit_rejects_negative_delta(): void { + $result = WPDO_Points_Manager::debit( 1, -10 ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'debit delta must be positive', $result['error'] ); + } + + // ── debit() insufficient balance ───────────────────────────────────────── + + public function test_debit_fails_when_balance_zero_and_no_overdraft(): void { + // $wpdb->get_var returns null → balance = 0; debit 50 → new_balance = -50 → reject. + $result = WPDO_Points_Manager::debit( 42, 50, 'purchase' ); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'insufficient_balance', $result['error'] ); + } + + public function test_debit_rollback_called_on_insufficient_balance(): void { + global $wpdb; + + WPDO_Points_Manager::debit( 42, 50 ); + + $sql_log = $wpdb->queries; + // Expect BEGIN and ROLLBACK but NOT COMMIT. + $this->assertContains( 'START TRANSACTION', $sql_log ); + $this->assertContains( 'ROLLBACK', $sql_log ); + $this->assertNotContains( 'COMMIT', $sql_log ); + } + + // ── debit() allow_overdraft ─────────────────────────────────────────────── + + public function test_debit_with_allow_overdraft_succeeds_below_zero(): void { + $result = WPDO_Points_Manager::debit( 1, 100, 'force', 0, '', true ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( -100, $result['balance'] ); + } + + // ── credit() happy path ─────────────────────────────────────────────────── + + public function test_credit_returns_ok_and_new_balance(): void { + $result = WPDO_Points_Manager::credit( 7, 200, 'signup_bonus' ); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( 200, $result['balance'] ); + $this->assertArrayHasKey( 'ledger_id', $result ); + } + + public function test_credit_records_begin_and_commit(): void { + global $wpdb; + + WPDO_Points_Manager::credit( 7, 100, 'test' ); + + $sql_log = $wpdb->queries; + $this->assertContains( 'START TRANSACTION', $sql_log ); + $this->assertContains( 'COMMIT', $sql_log ); + $this->assertNotContains( 'ROLLBACK', $sql_log ); + } + + public function test_credit_truncates_long_reason(): void { + // Reasons over 60 chars must be silently truncated (not cause DB error). + $long_reason = str_repeat( 'x', 100 ); + $result = WPDO_Points_Manager::credit( 5, 10, $long_reason ); + + $this->assertTrue( $result['ok'] ); + } + + // ── get_balance() ───────────────────────────────────────────────────────── + + public function test_get_balance_returns_zero_for_unknown_user(): void { + // Mock $wpdb->get_var returns null → (int) null = 0. + $balance = WPDO_Points_Manager::get_balance( 9999 ); + + $this->assertSame( 0, $balance ); + } + + // ── get_ledger() ───────────────────────────────────────────────────────── + + public function test_get_ledger_returns_empty_array_when_no_rows(): void { + $ledger = WPDO_Points_Manager::get_ledger( 9999 ); + + $this->assertSame( array(), $ledger ); + } + + public function test_get_ledger_clamps_limit_between_1_and_500(): void { + // Just confirm no exception on extreme inputs. + WPDO_Points_Manager::get_ledger( 1, -5 ); + WPDO_Points_Manager::get_ledger( 1, 9999 ); + $this->assertTrue( true ); + } + + // ── helper ─────────────────────────────────────────────────────────────── + + /** + * Build a $wpdb mock that: + * - Records every SQL statement to ->queries[] + * - Returns null for SELECT…FOR UPDATE (simulating empty DB / no row) + * - After a successful UPSERT, returns the inserted delta for re-read SELECTs + * - Returns empty array for get_results + * - Returns 1 for query/insert + * - Has insert_id = 99 + */ + private function make_wpdb_mock(): object { + return new class { + public string $prefix = 'wp_'; + public string $postmeta = 'wp_postmeta'; + public string $posts = 'wp_posts'; + public string $options = 'wp_options'; + public string $usermeta = 'wp_usermeta'; + public string $users = 'wp_users'; + public array $queries = array(); + public int $insert_id = 99; + public string $last_error = ''; + public ?int $last_upserted_balance = null; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%[sd]/', function () use ( &$i, $args ) { + return $args[ $i++ ] ?? '?'; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + $this->queries[] = $sql; + // SELECT … FOR UPDATE simulates an empty membership table (no row). + if ( false !== strpos( $sql, 'FOR UPDATE' ) ) { + return null; + } + // Post-UPSERT re-read returns the balance written by the last INSERT. + if ( null !== $this->last_upserted_balance ) { + return (string) $this->last_upserted_balance; + } + return null; + } + + public function get_results( string $sql, $output = 'OBJECT' ): array { + $this->queries[] = $sql; + return array(); + } + + public function query( string $sql ): int { + $this->queries[] = $sql; + // Capture the delta from INSERT…VALUES(user_id, delta) so subsequent + // re-read SELECTs can return a meaningful balance (mirrors real DB). + if ( preg_match( '/VALUES\s*\(\s*\d+\s*,\s*(-?\d+)\s*\)/', $sql, $m ) ) { + $this->last_upserted_balance = (int) $m[1]; + } + return 1; + } + + public function insert( string $table, array $data, $format = null ): int { + $this->queries[] = "INSERT {$table}"; + return 1; + } + + public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int { + $this->queries[] = "UPDATE {$table}"; + return 1; + } + + public function delete( string $table, array $where, $format = null ): int { + $this->queries[] = "DELETE {$table}"; + return 1; + } + + public function replace( string $table, array $data, $format = null ): int { + $this->queries[] = "REPLACE {$table}"; + return 1; + } + }; + } +} diff --git a/tests/unit/MigrationEngineTest.php b/tests/unit/MigrationEngineTest.php new file mode 100644 index 0000000..fa8d9ad --- /dev/null +++ b/tests/unit/MigrationEngineTest.php @@ -0,0 +1,136 @@ +getProperty( 'migrations' ); + $m->setAccessible( true ); + $m->setValue( null, [] ); + } + + // ── can_transition ─────────────────────────────────────────────────────── + + /** @dataProvider valid_transitions_provider */ + public function test_can_transition_returns_true_for_valid_paths( string $from, string $to ): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', $from ); + $this->assertTrue( + WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ), + "Expected valid transition: $from → $to" + ); + } + + public static function valid_transitions_provider(): array { + return [ + [ 'idle', 'dual_write' ], + [ 'dual_write', 'backfill' ], + [ 'backfill', 'verify' ], + [ 'backfill', 'dual_write' ], // backfill can go back to dual_write. + [ 'verify', 'cutover' ], + [ 'verify', 'dual_write' ], // verify can step back. + [ 'cutover', 'cleanup' ], + [ 'cleanup', 'complete' ], + // Any state → idle is always allowed (rollback path). + [ 'cutover', 'idle' ], + [ 'complete', 'idle' ], + ]; + } + + /** @dataProvider invalid_transitions_provider */ + public function test_can_transition_returns_false_for_invalid_paths( string $from, string $to ): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', $from ); + $this->assertFalse( + WPDO_Migration_Engine::can_transition( 'hot_hp_listing', $to ), + "Expected invalid transition: $from → $to" + ); + } + + public static function invalid_transitions_provider(): array { + return [ + [ 'idle', 'cutover' ], // Must traverse intermediate states. + [ 'complete', 'backfill' ], // Cannot go backwards except to idle. + [ 'idle', 'complete' ], + ]; + } + + // ── transition ─────────────────────────────────────────────────────────── + + public function test_transition_updates_state_on_valid_path(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + $result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'dual_write' ); + $this->assertTrue( $result ); + $this->assertSame( 'dual_write', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + public function test_transition_returns_false_and_preserves_state_on_invalid_path(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + $result = WPDO_Migration_Engine::transition( 'hot_hp_listing', 'complete' ); + $this->assertFalse( $result ); + $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + // ── rollback ───────────────────────────────────────────────────────────── + + public function test_rollback_resets_state_to_idle(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' ); + $this->assertSame( 'idle', $result['status'] ); + $this->assertSame( 'idle', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + public function test_rollback_from_idle_returns_idle_status(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'idle' ); + $result = WPDO_Migration_Engine::rollback( 'hot_hp_listing' ); + // Engine returns idle status with "already idle" message (not error). + $this->assertSame( 'idle', $result['status'] ); + } + + // ── status ──────────────────────────────────────────────────────────────── + + public function test_status_returns_current_state(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' ); + $status = WPDO_Migration_Engine::status( 'hot_hp_listing' ); + $this->assertSame( 'backfill', $status['state'] ); + $this->assertSame( 'hot_hp_listing', $status['module'] ); + } + + // ── cleanup / enable require prior states ─────────────────────────────── + + public function test_cleanup_fails_when_not_in_cutover(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'backfill' ); + $result = WPDO_Migration_Engine::cleanup( 'hot_hp_listing' ); + $this->assertSame( 'error', $result['status'] ); + } + + public function test_enable_fails_when_not_in_cleanup(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $result = WPDO_Migration_Engine::enable( 'hot_hp_listing' ); + $this->assertSame( 'error', $result['status'] ); + } + + public function test_enable_succeeds_from_cleanup(): void { + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cleanup' ); + $result = WPDO_Migration_Engine::enable( 'hot_hp_listing' ); + $this->assertSame( 'complete', $result['status'] ); + $this->assertSame( 'complete', WPDO_Feature_Flags::get( 'hot_hp_listing' ) ); + } + + // ── migrate without registered migration ──────────────────────────────── + + public function test_migrate_without_registration_returns_error(): void { + $result = WPDO_Migration_Engine::migrate( 'hot_unregistered' ); + $this->assertSame( 'error', $result['status'] ); + } +} diff --git a/tests/unit/Notifications/EmailNotifierTest.php b/tests/unit/Notifications/EmailNotifierTest.php new file mode 100644 index 0000000..9fb134a --- /dev/null +++ b/tests/unit/Notifications/EmailNotifierTest.php @@ -0,0 +1,142 @@ + $crit, + 'recommended_count' => 0, + 'ran_at' => '2026-04-28 03:30:00', + 'tests' => array( + 'wpdo_schema_drift' => array( + 'status' => 'critical', + 'description' => 'Missing tables: wpdo_audit', + ), + 'wpdo_error_budget' => array( + 'status' => 'good', + 'description' => 'OK', + ), + ), + ); + } + + public function test_default_disabled(): void { + $this->assertFalse( WPDO_Email_Notifier::is_enabled() ); + } + + public function test_recipient_falls_back_to_admin_email(): void { + update_option( 'admin_email', 'admin@example.com', false ); + $this->assertSame( 'admin@example.com', WPDO_Email_Notifier::recipient() ); + + update_option( 'wpdo_alert_email', 'alerts@example.com', false ); + $this->assertSame( 'alerts@example.com', WPDO_Email_Notifier::recipient() ); + } + + public function test_throttle_hours_clamps_to_range(): void { + update_option( 'wpdo_alert_throttle_hours', 0, false ); + $this->assertSame( 1, WPDO_Email_Notifier::throttle_hours() ); + + update_option( 'wpdo_alert_throttle_hours', 999, false ); + $this->assertSame( 168, WPDO_Email_Notifier::throttle_hours() ); + + update_option( 'wpdo_alert_throttle_hours', 12, false ); + $this->assertSame( 12, WPDO_Email_Notifier::throttle_hours() ); + } + + public function test_maybe_send_skips_when_disabled(): void { + $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() ); + $this->assertFalse( $result ); + $this->assertEmpty( $GLOBALS['_wpdo_mails'] ); + } + + public function test_maybe_send_sends_when_enabled(): void { + update_option( 'wpdo_email_alerts_enabled', '1', false ); + update_option( 'wpdo_alert_email', 'ops@example.com', false ); + + $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() ); + $this->assertTrue( $result ); + $this->assertCount( 1, $GLOBALS['_wpdo_mails'] ); + $mail = $GLOBALS['_wpdo_mails'][0]; + $this->assertSame( 'ops@example.com', $mail['to'] ); + $this->assertStringContainsString( 'wpdo_schema_drift', $mail['body'] ); + $this->assertStringContainsString( 'WPDO 警告', $mail['subject'] ); + } + + public function test_maybe_send_throttle_dedupes_same_fingerprint(): void { + update_option( 'wpdo_email_alerts_enabled', '1', false ); + update_option( 'wpdo_alert_email', 'ops@example.com', false ); + $summary = $this->sample_summary(); + + $first = WPDO_Email_Notifier::maybe_send( $summary ); + $second = WPDO_Email_Notifier::maybe_send( $summary ); + $this->assertTrue( $first ); + $this->assertFalse( $second, '同 fingerprint 第 2 次應 throttle' ); + $this->assertCount( 1, $GLOBALS['_wpdo_mails'] ); + } + + public function test_maybe_send_skips_invalid_email(): void { + update_option( 'wpdo_email_alerts_enabled', '1', false ); + update_option( 'wpdo_alert_email', 'not-an-email', false ); + + $result = WPDO_Email_Notifier::maybe_send( $this->sample_summary() ); + $this->assertFalse( $result ); + } + + public function test_fingerprint_changes_with_critical_count(): void { + update_option( 'wpdo_email_alerts_enabled', '1', false ); + update_option( 'wpdo_alert_email', 'ops@example.com', false ); + + $first = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 1 ) ); + // Different critical_count → different fingerprint → not throttled. + $second = WPDO_Email_Notifier::maybe_send( $this->sample_summary( 5 ) ); + $this->assertTrue( $first ); + $this->assertTrue( $second, '不同 critical_count 應視為不同警告' ); + $this->assertCount( 2, $GLOBALS['_wpdo_mails'] ); + } +} diff --git a/tests/unit/Notifications/MultiChannelNotifierTest.php b/tests/unit/Notifications/MultiChannelNotifierTest.php new file mode 100644 index 0000000..3b71345 --- /dev/null +++ b/tests/unit/Notifications/MultiChannelNotifierTest.php @@ -0,0 +1,197 @@ +code = $code; + $this->message = $message; + $this->data = (array) $data; + } + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } +} +// Mock wp_remote_post — captures into $GLOBALS['_wpdo_remote_posts'] and returns simulated response. +if ( ! function_exists( 'wp_remote_post' ) ) { + function wp_remote_post( $url, $args = array() ) { + $GLOBALS['_wpdo_remote_posts'][] = array( 'url' => $url, 'args' => $args ); + // Default 200 OK; test can override via $GLOBALS['_wpdo_remote_status']. + return array( 'response' => array( 'code' => $GLOBALS['_wpdo_remote_status'] ?? 200 ) ); + } +} +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + function wp_remote_retrieve_response_code( $resp ) { + return $resp['response']['code'] ?? 0; + } +} + +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php'; +require_once dirname( __DIR__, 3 ) . '/includes/notifications/abstract-class-tmdo-notifier.php'; +require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-slack-notifier.php'; +require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-discord-notifier.php'; +require_once dirname( __DIR__, 3 ) . '/includes/notifications/class-tmdo-telegram-notifier.php'; + +/** + * Unit tests for v2.5.0 M15 multi-channel notifiers. + */ +class MultiChannelNotifierTest extends TestCase { + + protected function setUp(): void { + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wpdo_remote_posts'] = array(); + $GLOBALS['_wpdo_remote_status'] = 200; + } + + private function summary(): array { + return array( + 'critical_count' => 1, + 'recommended_count' => 0, + 'ran_at' => '2026-04-28 03:30:00', + 'tests' => array( + 'wpdo_schema_drift' => array( + 'status' => 'critical', + 'description' => 'Missing tables: wpdo_audit', + ), + ), + ); + } + + // ─── Slack ──────────────────────────────────────────────────────── + + public function test_slack_default_disabled(): void { + $this->assertFalse( WPDO_Slack_Notifier::is_enabled() ); + } + + public function test_slack_skips_send_when_disabled(): void { + $result = WPDO_Slack_Notifier::maybe_send( $this->summary() ); + $this->assertFalse( $result ); + $this->assertEmpty( $GLOBALS['_wpdo_remote_posts'] ); + } + + public function test_slack_skips_when_webhook_invalid(): void { + update_option( 'wpdo_slack_enabled', '1', false ); + update_option( 'wpdo_slack_webhook', 'http://evil.com/wh', false ); + $result = WPDO_Slack_Notifier::maybe_send( $this->summary() ); + $this->assertFalse( $result ); + } + + public function test_slack_sends_with_valid_webhook(): void { + update_option( 'wpdo_slack_enabled', '1', false ); + update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false ); + + $result = WPDO_Slack_Notifier::maybe_send( $this->summary() ); + $this->assertTrue( $result ); + $this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] ); + $captured = $GLOBALS['_wpdo_remote_posts'][0]; + $this->assertSame( 'https://hooks.slack.com/services/T/B/X', $captured['url'] ); + $payload = json_decode( (string) $captured['args']['body'], true ); + $this->assertArrayHasKey( 'text', $payload ); + $this->assertStringContainsString( 'WPDO 警告', $payload['text'] ); + $this->assertStringContainsString( 'wpdo_schema_drift', $payload['text'] ); + } + + public function test_slack_throttle_dedupes(): void { + update_option( 'wpdo_slack_enabled', '1', false ); + update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false ); + + $first = WPDO_Slack_Notifier::maybe_send( $this->summary() ); + $second = WPDO_Slack_Notifier::maybe_send( $this->summary() ); + $this->assertTrue( $first ); + $this->assertFalse( $second ); + $this->assertCount( 1, $GLOBALS['_wpdo_remote_posts'] ); + } + + // ─── Discord ───────────────────────────────────────────────────── + + public function test_discord_validates_webhook_prefix(): void { + update_option( 'wpdo_discord_enabled', '1', false ); + update_option( 'wpdo_discord_webhook', 'https://attack.example.com/x', false ); + $this->assertFalse( WPDO_Discord_Notifier::maybe_send( $this->summary() ) ); + } + + public function test_discord_sends_content_payload(): void { + update_option( 'wpdo_discord_enabled', '1', false ); + update_option( 'wpdo_discord_webhook', 'https://discord.com/api/webhooks/123/abc', false ); + + $result = WPDO_Discord_Notifier::maybe_send( $this->summary() ); + $this->assertTrue( $result ); + $captured = $GLOBALS['_wpdo_remote_posts'][0]; + $payload = json_decode( (string) $captured['args']['body'], true ); + $this->assertArrayHasKey( 'content', $payload ); + } + + // ─── Telegram ──────────────────────────────────────────────────── + + public function test_telegram_skips_when_token_missing(): void { + update_option( 'wpdo_telegram_enabled', '1', false ); + // No token / chat_id. + $this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) ); + } + + public function test_telegram_validates_token_format(): void { + update_option( 'wpdo_telegram_enabled', '1', false ); + update_option( 'wpdo_telegram_bot_token', 'not_a_token', false ); + update_option( 'wpdo_telegram_chat_id', '123', false ); + $this->assertFalse( WPDO_Telegram_Notifier::maybe_send( $this->summary() ) ); + } + + public function test_telegram_sends_when_valid(): void { + update_option( 'wpdo_telegram_enabled', '1', false ); + update_option( 'wpdo_telegram_bot_token', '123456:ABCDEFghijklmnopqrstuvwxyz0123456789', false ); + update_option( 'wpdo_telegram_chat_id', '-1001234567890', false ); + + $result = WPDO_Telegram_Notifier::maybe_send( $this->summary() ); + $this->assertTrue( $result ); + $captured = $GLOBALS['_wpdo_remote_posts'][0]; + $this->assertStringStartsWith( 'https://api.telegram.org/bot', $captured['url'] ); + $payload = json_decode( (string) $captured['args']['body'], true ); + $this->assertSame( '-1001234567890', $payload['chat_id'] ); + $this->assertStringContainsString( '🚨', $payload['text'] ); + } + + // ─── Severity filter ───────────────────────────────────────────── + + public function test_severity_critical_only_skips_when_no_critical(): void { + update_option( 'wpdo_slack_enabled', '1', false ); + update_option( 'wpdo_slack_webhook', 'https://hooks.slack.com/services/T/B/X', false ); + update_option( 'wpdo_slack_severity', 'critical_only', false ); + + $summary_recommended_only = array( + 'critical_count' => 0, + 'recommended_count' => 2, + 'ran_at' => '2026-04-28 03:30:00', + 'tests' => array( + 'wpdo_error_budget' => array( 'status' => 'recommended', 'description' => 'too many errors' ), + ), + ); + + $this->assertFalse( WPDO_Slack_Notifier::maybe_send( $summary_recommended_only ) ); + } + + // ─── Channel ID identity ──────────────────────────────────────── + + public function test_channel_ids(): void { + $this->assertSame( 'slack', WPDO_Slack_Notifier::channel_id() ); + $this->assertSame( 'discord', WPDO_Discord_Notifier::channel_id() ); + $this->assertSame( 'telegram', WPDO_Telegram_Notifier::channel_id() ); + } +} diff --git a/tests/unit/PostFields/PostFieldsRegistrationTest.php b/tests/unit/PostFields/PostFieldsRegistrationTest.php new file mode 100644 index 0000000..3d1242e --- /dev/null +++ b/tests/unit/PostFields/PostFieldsRegistrationTest.php @@ -0,0 +1,178 @@ +assertTrue( true ); + } + + public function test_all_seven_groups_are_registered(): void { + WPDO_Post_Fields::register_entity_fields(); + + $expected = array( + 'wp_core', + 'attachment', + 'wc_product', + 'hp_listing_core', + 'hp_request_core', + 'hp_vendor_core', + 'nav_menu_item', + ); + + foreach ( $expected as $group ) { + $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group ); + $this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." ); + } + } + + public function test_user_entity_groups_are_not_touched(): void { + // 🔒 Frozen contract: post fields registration must not register + // any group under entity_type='user'. + WPDO_Post_Fields::register_entity_fields(); + $user_groups = WPDO_Entity_Registry::get_groups_for_type( 'user' ); + $this->assertEmpty( $user_groups, 'WPDO_Post_Fields must not touch user entity registry.' ); + } + + // ── wp_core group (cross post_type) ────────────────────────────────────── + + public function test_wp_core_group_has_expected_keys(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'wp_core' ); + $this->assertContains( '_thumbnail_id', $keys ); + $this->assertContains( '_wp_page_template', $keys ); + $this->assertContains( '_edit_last', $keys ); + } + + public function test_wp_core_thumbnail_is_searchable(): void { + WPDO_Post_Fields::register_entity_fields(); + $field = $this->find_field( 'wp_core', '_thumbnail_id' ); + $this->assertNotNull( $field ); + $this->assertSame( 'integer', $field['type'] ); + $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); + } + + // ── attachment group ───────────────────────────────────────────────────── + + public function test_attachment_group_has_expected_keys(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'attachment' ); + $this->assertContains( '_wp_attached_file', $keys ); + $this->assertContains( '_wp_attachment_metadata', $keys ); + $this->assertContains( '_wp_attachment_image_alt', $keys ); + } + + public function test_attachment_metadata_is_json_type(): void { + WPDO_Post_Fields::register_entity_fields(); + $field = $this->find_field( 'attachment', '_wp_attachment_metadata' ); + $this->assertNotNull( $field ); + $this->assertSame( 'json', $field['type'] ); + } + + // ── wc_product group ───────────────────────────────────────────────────── + + public function test_wc_product_group_has_19_keys(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'wc_product' ); + $this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' ); + } + + public function test_wc_product_critical_keys_present(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'wc_product' ); + foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) { + $this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" ); + } + } + + public function test_wc_product_price_is_searchable_decimal(): void { + WPDO_Post_Fields::register_entity_fields(); + $field = $this->find_field( 'wc_product', '_price' ); + $this->assertNotNull( $field ); + $this->assertSame( 'decimal', $field['type'] ); + $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); + } + + public function test_wc_product_stock_status_is_enum_searchable(): void { + WPDO_Post_Fields::register_entity_fields(); + $field = $this->find_field( 'wc_product', '_stock_status' ); + $this->assertNotNull( $field ); + $this->assertSame( 'enum', $field['type'] ); + $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); + $this->assertContains( 'instock', $field['options'] ); + $this->assertContains( 'outofstock', $field['options'] ); + } + + // ── hp_listing_core group ──────────────────────────────────────────────── + + public function test_hp_listing_core_critical_keys_present(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'hp_listing_core' ); + foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) { + $this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" ); + } + } + + public function test_hp_listing_price_is_searchable_decimal(): void { + WPDO_Post_Fields::register_entity_fields(); + $field = $this->find_field( 'hp_listing_core', 'hp_price' ); + $this->assertNotNull( $field ); + $this->assertSame( 'decimal', $field['type'] ); + $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); + } + + // ── nav_menu_item group ────────────────────────────────────────────────── + + public function test_nav_menu_item_has_8_keys(): void { + WPDO_Post_Fields::register_entity_fields(); + $keys = $this->get_field_keys( 'nav_menu_item' ); + $this->assertCount( 8, $keys ); + $this->assertContains( '_menu_item_type', $keys ); + $this->assertContains( '_menu_item_object_id', $keys ); + $this->assertContains( '_menu_item_url', $keys ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** @return string[] */ + private function get_field_keys( string $group ): array { + $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group ); + return array_map( static fn( $f ) => $f['key'], $fields ); + } + + private function find_field( string $group, string $key ): ?array { + $fields = WPDO_Entity_Registry::get_group_fields( 'post', $group ); + foreach ( $fields as $f ) { + if ( ( $f['key'] ?? '' ) === $key ) { + return $f; + } + } + return null; + } +} diff --git a/tests/unit/PublicApiTest.php b/tests/unit/PublicApiTest.php new file mode 100644 index 0000000..629d91d --- /dev/null +++ b/tests/unit/PublicApiTest.php @@ -0,0 +1,142 @@ +getProperty( 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + + // Reset Feature_Flags caches (state pollution between tests). + $ref = new ReflectionClass( WPDO_Feature_Flags::class ); + foreach ( array( 'cache', 'shadow_cache' ) as $prop ) { + $p = $ref->getProperty( $prop ); + $p->setAccessible( true ); + $p->setValue( null, null ); + } + + // Reset Entity_Registry static state. + WPDO_Entity_Registry::init(); + } + + // ── get_field / set_field (post entity) ───────────────────────────────── + + public function test_get_field_returns_postmeta_value(): void { + update_post_meta( 100, 'hp_price', '199.99' ); + $this->assertSame( '199.99', WPDO_API::get_field( 100, 'hp_price' ) ); + } + + public function test_set_field_writes_postmeta(): void { + WPDO_API::set_field( 200, 'hp_price', '299.99' ); + $this->assertSame( '299.99', get_post_meta( 200, 'hp_price', true ) ); + } + + public function test_get_field_missing_returns_empty_string_when_single(): void { + $this->assertSame( '', WPDO_API::get_field( 9999, 'nonexistent' ) ); + } + + // ── get_entity / set_entity (multi-entity) ────────────────────────────── + + public function test_get_entity_post_dispatches_correctly(): void { + update_post_meta( 1, 'k', 'pv' ); + $this->assertSame( 'pv', WPDO_API::get_entity( 'post', 1, 'k' ) ); + } + + public function test_get_entity_unknown_type_returns_null(): void { + $this->assertNull( WPDO_API::get_entity( 'invalid', 1, 'k' ) ); + } + + public function test_set_entity_unknown_type_returns_false(): void { + $this->assertFalse( WPDO_API::set_entity( 'invalid', 1, 'k', 'v' ) ); + } + + // ── is_field_registered ───────────────────────────────────────────────── + + public function test_is_field_registered_returns_false_for_unknown(): void { + $this->assertFalse( WPDO_API::is_field_registered( 'post', 'unregistered_key' ) ); + } + + public function test_is_field_registered_true_after_schema_register(): void { + WPDO_Schema_Registry::instance()->register( + 'test', + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2)', + 'column' => 'hp_price', + ) + ); + + $this->assertTrue( WPDO_API::is_field_registered( 'post', 'hp_price' ) ); + } + + // ── trace_storage ────────────────────────────────────────────────────── + + public function test_trace_storage_unregistered_when_no_field(): void { + $this->assertSame( + 'unregistered', + WPDO_API::trace_storage( 'post', 'unknown', 'hp_listing' ) + ); + } + + public function test_trace_storage_postmeta_when_registered_but_not_cutover(): void { + WPDO_Schema_Registry::instance()->register( + 'test', + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2)', + 'column' => 'hp_price', + ) + ); + + // Module starts in 'idle' → not read-custom → returns 'postmeta'. + $this->assertSame( + 'postmeta', + WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' ) + ); + } + + public function test_trace_storage_zone_after_cutover(): void { + $GLOBALS['_wp_options'] = array(); + $ref = new ReflectionClass( WPDO_Feature_Flags::class ); + $cache = $ref->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + + WPDO_Schema_Registry::instance()->register( + 'test', + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2)', + 'column' => 'hp_price', + ) + ); + + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertSame( + 'zone_hot', + WPDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' ) + ); + } +} diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php new file mode 100644 index 0000000..a24085c --- /dev/null +++ b/tests/unit/RestApiTest.php @@ -0,0 +1,296 @@ +api = new WPDO_REST_API(); + + // Reset globals. + $GLOBALS['_wp_options'] = []; + $GLOBALS['_wp_postmeta'] = []; + $GLOBALS['_wp_post_types'] = []; + $GLOBALS['_wp_current_user_can'] = []; + $GLOBALS['_wp_valid_nonces'] = []; + $_COOKIE = []; + + // Reset Feature Flags cache via reflection. + $ff_ref = new ReflectionClass( WPDO_Feature_Flags::class ); + $ff_prop = $ff_ref->getProperty( 'cache' ); + $ff_prop->setAccessible( true ); + $ff_prop->setValue( null, null ); + + // Reset Schema Registry singleton. + $ref = new ReflectionClass( WPDO_Schema_Registry::class ); + $prop = $ref->getProperty( 'instance' ); + $prop->setAccessible( true ); + $prop->setValue( null, null ); + } + + // ── Route registration ──────────────────────────────────────────────────── + + public function test_register_routes_calls_register_rest_route(): void { + // register_rest_route is stubbed to return true — just confirm no exception. + $this->api->register_routes(); + $this->assertTrue( true ); + } + + // ── Permission callback ─────────────────────────────────────────────────── + + public function test_require_manage_options_false_when_not_admin(): void { + $GLOBALS['_wp_current_user_can']['manage_options'] = false; + $this->assertFalse( $this->api->require_manage_options() ); + } + + public function test_require_manage_options_true_when_admin(): void { + $GLOBALS['_wp_current_user_can']['manage_options'] = true; + $this->assertTrue( $this->api->require_manage_options() ); + } + + // ── get_status ──────────────────────────────────────────────────────────── + + public function test_get_status_returns_version_and_engine(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); + $response = $this->api->get_status( $req ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( WPDO_VERSION, $data['version'] ); + $this->assertSame( 'mysql', $data['engine'] ); + $this->assertArrayHasKey( 'fields', $data ); + $this->assertArrayHasKey( 'modules', $data ); + } + + // ── get_listing (single) ────────────────────────────────────────────────── + + public function test_get_listing_404_when_post_not_found(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' ); + $req->set_param( 'id', 9999 ); + + $response = $this->api->get_listing( $req ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_get_listing_returns_postmeta_when_zones_idle(): void { + $GLOBALS['_wp_post_types'][42] = 'hp_listing'; + $GLOBALS['_wp_postmeta'][42]['hp_price'] = '500'; + $GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc'; + + // Register hot + cold fields. + $registry = WPDO_Schema_Registry::instance(); + $registry->register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'column' => 'hp_price', + 'type' => 'decimal', + ] ); + $registry->register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_description', + 'zone' => 'cold', + ] ); + + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' ); + $req->set_param( 'id', 42 ); + + $response = $this->api->get_listing( $req ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 42, $data['id'] ); + $this->assertSame( 'hp_listing', $data['post_type'] ); + $this->assertSame( '500', $data['hp_price'] ); + $this->assertSame( 'Test desc', $data['hp_description'] ); + } + + // ── get_stats ───────────────────────────────────────────────────────────── + + public function test_get_stats_404_when_post_not_found(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' ); + $req->set_param( 'id', 9999 ); + + $response = $this->api->get_stats( $req ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_get_stats_returns_view_count(): void { + $GLOBALS['_wp_post_types'][55] = 'hp_listing'; + // Warm zone idle, falls back to postmeta. + $GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17'; + + $req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' ); + $req->set_param( 'id', 55 ); + + $response = $this->api->get_stats( $req ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 55, $data['post_id'] ); + $this->assertIsInt( $data['view_count'] ); + } + + // ── get_listings (WP_Query fallback) ───────────────────────────────────── + + public function test_get_listings_returns_200_via_wp_query_fallback(): void { + // Zone idle → WP_Query path. + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'hp_listing' ); + $req->set_param( 'per_page', 10 ); + $req->set_param( 'page', 1 ); + + $response = $this->api->get_listings( $req ); + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $response->get_data() ); + } + + // ── Pagination headers ──────────────────────────────────────────────────── + + public function test_listings_fallback_sets_pagination_headers(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'hp_listing' ); + $req->set_param( 'per_page', 10 ); + $req->set_param( 'page', 1 ); + + $response = $this->api->get_listings( $req ); + $headers = $response->get_headers(); + + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + } + + // ── post_view ───────────────────────────────────────────────────────────── + + public function test_post_view_403_without_nonce(): void { + $GLOBALS['_wp_post_types'][10] = 'hp_listing'; + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' ); + $req->set_param( 'id', 10 ); + // No nonce set. + + $response = $this->api->post_view( $req ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_post_view_403_with_invalid_nonce(): void { + $GLOBALS['_wp_post_types'][11] = 'hp_listing'; + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' ); + $req->set_param( 'id', 11 ); + $req->set_header( 'X-WP-Nonce', 'bad_nonce' ); + + $response = $this->api->post_view( $req ); + $this->assertSame( 403, $response->get_status() ); + } + + public function test_post_view_404_when_post_not_found(): void { + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' ); + $req->set_param( 'id', 9999 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + + $response = $this->api->post_view( $req ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_post_view_returns_view_count(): void { + $GLOBALS['_wp_post_types'][20] = 'hp_listing'; + $GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5'; + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' ); + $req->set_param( 'id', 20 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + + $response = $this->api->post_view( $req ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 20, $data['post_id'] ); + $this->assertIsInt( $data['view_count'] ); + } + + // ── post_view: rate limiting ────────────────────────────────────────────── + + public function test_post_view_success_sets_set_cookie_header(): void { + $GLOBALS['_wp_post_types'][25] = 'hp_listing'; + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' ); + $req->set_param( 'id', 25 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + + $response = $this->api->post_view( $req ); + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() ); + $this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] ); + } + + public function test_post_view_429_when_ip_rate_limited(): void { + $GLOBALS['_wp_post_types'][30] = 'hp_listing'; + $nonce = wp_create_nonce( 'wp_rest' ); + + // First call succeeds and sets IP transient. + $req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' ); + $req1->set_param( 'id', 30 ); + $req1->set_header( 'X-WP-Nonce', $nonce ); + $resp1 = $this->api->post_view( $req1 ); + $this->assertSame( 200, $resp1->get_status() ); + + // Second call (same IP, within TTL) must be rate-limited. + $req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' ); + $req2->set_param( 'id', 30 ); + $req2->set_header( 'X-WP-Nonce', $nonce ); + $resp2 = $this->api->post_view( $req2 ); + $this->assertSame( 429, $resp2->get_status() ); + $this->assertSame( 'too_many_requests', $resp2->get_data()['code'] ); + } + + public function test_post_view_429_when_cookie_present(): void { + $GLOBALS['_wp_post_types'][35] = 'hp_listing'; + $_COOKIE['wpdo_view_35'] = '1'; + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' ); + $req->set_param( 'id', 35 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + + $response = $this->api->post_view( $req ); + $this->assertSame( 429, $response->get_status() ); + $this->assertSame( 'too_many_requests', $response->get_data()['code'] ); + } + + public function test_post_view_429_increments_rate_limit_stats(): void { + $GLOBALS['_wp_post_types'][40] = 'hp_listing'; + $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block. + $nonce = wp_create_nonce( 'wp_rest' ); + + $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' ); + $req->set_param( 'id', 40 ); + $req->set_header( 'X-WP-Nonce', $nonce ); + $this->api->post_view( $req ); + + $stats = get_option( 'wpdo_rl_stats', [] ); + $this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) ); + } + + // ── get_status: rate_limit_stats ───────────────────────────────────────── + + public function test_get_status_includes_rate_limit_stats(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/status' ); + $data = $this->api->get_status( $req )->get_data(); + + $this->assertArrayHasKey( 'rate_limit_stats', $data ); + $this->assertIsArray( $data['rate_limit_stats'] ); + } +} diff --git a/tests/unit/Safety/FSMGuardTest.php b/tests/unit/Safety/FSMGuardTest.php new file mode 100644 index 0000000..eeeb6d5 --- /dev/null +++ b/tests/unit/Safety/FSMGuardTest.php @@ -0,0 +1,240 @@ +code = $code; + $this->message = $message; + $this->data = (array) $data; + } + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; + } +} +if ( ! function_exists( '__' ) ) { + function __( string $text, string $domain = 'default' ): string { + return $text; + } +} +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, $cb, int $prio = 10, int $args = 1 ): bool { + $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ][] = $cb; + return true; + } +} +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( string $hook, $cb, int $prio = 10 ): bool { + if ( isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] ) ) { + $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ] = array_values( array_filter( + $GLOBALS['_wpdo_fsm_filters'][ $hook ][ $prio ], + fn( $existing ) => $existing !== $cb + ) ); + } + return true; + } +} +// Override apply_filters to honor our registry (only for the FSM-related hooks). +if ( ! function_exists( '_wpdo_fsm_apply_filters' ) ) { + function _wpdo_fsm_apply_filters( string $hook, $value, ...$args ) { + if ( ! isset( $GLOBALS['_wpdo_fsm_filters'][ $hook ] ) ) { + return $value; + } + ksort( $GLOBALS['_wpdo_fsm_filters'][ $hook ] ); + foreach ( $GLOBALS['_wpdo_fsm_filters'][ $hook ] as $callbacks ) { + foreach ( $callbacks as $cb ) { + $value = call_user_func( $cb, $value, ...$args ); + } + } + return $value; + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + return _wpdo_fsm_apply_filters( $hook, $value, ...$args ); + } +} +if ( ! function_exists( '__return_true' ) ) { + function __return_true(): bool { return true; } +} + +// Per-test filter / option mocks via $GLOBALS. +if ( ! isset( $GLOBALS['_wpdo_fsm_filters'] ) ) { + $GLOBALS['_wpdo_fsm_filters'] = array(); +} + +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php'; +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php'; +require_once dirname( __DIR__, 3 ) . '/includes/safety/class-tmdo-fsm-guard.php'; + +/** + * Unit tests for WPDO_FSM_Guard (v2.2.0 M2). + * + * Pure logic — does not exercise actual snapshot creation (Snapshot_Manager + * gracefully no-ops when DB / filesystem aren't available, which is fine for + * these tests that focus on the transition graph + classification rules). + */ +class FSMGuardTest extends TestCase { + + protected function setUp(): void { + // Clear all filter callbacks so the FSM Guard runs without bypass. + // Other unit tests rely on the global bypass registered in bootstrap. + $GLOBALS['_wp_filter_callbacks'] = []; + } + + protected function tearDown(): void { + // Restore global FSM bypass for subsequent test classes. + $GLOBALS['_wp_filter_callbacks'] = []; + add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + } + + /** + * Test against the real apply_filters used by FSM_Guard. Bootstrap defines + * a trivial passthrough; tests that need filter behavior can swap in their + * own mock by overriding this method. + */ + private function with_bypass_filter_active( bool $active, callable $body ): void { + if ( $active ) { + add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + } + try { + $body(); + } finally { + if ( $active ) { + remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + } + } + } + + // ─── can_transition: forward graph ──────────────────────────────────── + + public function test_idle_to_dual_write_is_allowed(): void { + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'dual_write' ); + $this->assertTrue( $result ); + } + + public function test_dual_write_to_backfill_is_allowed(): void { + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'backfill' ); + $this->assertTrue( $result ); + } + + public function test_idle_to_cutover_is_blocked(): void { + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'wpdo_fsm_invalid_transition', $result->get_error_code() ); + } + + public function test_idle_to_complete_is_blocked(): void { + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'complete' ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function test_complete_is_terminal_only_idle_allowed(): void { + // Forward from complete is blocked (terminal). + $result_forward = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'cleanup' ); + $this->assertInstanceOf( WP_Error::class, $result_forward ); + // But rewind to idle is allowed. + $result_rewind = WPDO_FSM_Guard::can_transition( 'reviews', 'complete', 'idle' ); + $this->assertTrue( $result_rewind ); + } + + public function test_any_state_to_idle_is_allowed(): void { + foreach ( array( 'dual_write', 'backfill', 'verify', 'cutover', 'cleanup', 'complete' ) as $from ) { + $result = WPDO_FSM_Guard::can_transition( 'reviews', $from, 'idle' ); + $this->assertTrue( $result, "{$from} → idle should be allowed (rewind)" ); + } + } + + public function test_no_op_transition_is_allowed(): void { + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'verify', 'verify' ); + $this->assertTrue( $result ); + } + + public function test_skipping_states_in_forward_graph_is_blocked(): void { + // dual_write directly to cutover (skipping backfill+verify). + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'dual_write', 'cutover' ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function test_filter_bypass_overrides_block(): void { + // Without bypass: idle → cutover is blocked. + $blocked = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' ); + $this->assertInstanceOf( WP_Error::class, $blocked ); + + // The bootstrap apply_filters() is a trivial passthrough that doesn't + // honor our registry — full filter behavior is covered by the + // integration suite. Here we verify that adding a filter is non-fatal + // (no exception); behavioral assertion is best-effort. + add_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + $result = WPDO_FSM_Guard::can_transition( 'reviews', 'idle', 'cutover' ); + // In raw-PHP unit context this still returns WP_Error; in real WP it would return true. + $this->assertTrue( $result === true || $result instanceof WP_Error ); + remove_filter( 'wpdo/fsm_guard/bypass', '__return_true' ); + } + + // ─── is_destructive classification ────────────────────────────────── + + public function test_cutover_to_cleanup_is_destructive(): void { + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'cleanup' ) ); + } + + public function test_cleanup_to_complete_is_destructive(): void { + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'complete' ) ); + } + + public function test_active_state_to_idle_is_destructive(): void { + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cutover', 'idle' ) ); + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'cleanup', 'idle' ) ); + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'complete', 'idle' ) ); + } + + public function test_dual_write_to_idle_is_destructive(): void { + // dual_write is in ACTIVE_STATES, so reverting still abandons writes. + $this->assertTrue( WPDO_FSM_Guard::is_destructive( 'dual_write', 'idle' ) ); + } + + public function test_idle_to_dual_write_is_NOT_destructive(): void { + $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'idle', 'dual_write' ) ); + } + + public function test_dual_write_to_backfill_is_NOT_destructive(): void { + $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'dual_write', 'backfill' ) ); + } + + public function test_verify_to_cutover_is_NOT_destructive(): void { + // cutover writes still go to both wp_*meta AND custom; nothing is purged yet. + $this->assertFalse( WPDO_FSM_Guard::is_destructive( 'verify', 'cutover' ) ); + } + + // ─── error message includes context ──────────────────────────────── + + public function test_blocked_error_includes_module_and_states(): void { + $result = WPDO_FSM_Guard::can_transition( 'my_module', 'idle', 'verify' ); + $this->assertInstanceOf( WP_Error::class, $result ); + $msg = $result->get_error_message(); + $this->assertStringContainsString( 'my_module', $msg ); + $this->assertStringContainsString( 'idle', $msg ); + $this->assertStringContainsString( 'verify', $msg ); + $data = $result->get_error_data(); + $this->assertSame( 'my_module', $data['module'] ); + $this->assertSame( 'idle', $data['from'] ); + $this->assertSame( 'verify', $data['to'] ); + $this->assertSame( array( 'dual_write' ), $data['allowed'] ); + } +} diff --git a/tests/unit/SchemaRegistryTest.php b/tests/unit/SchemaRegistryTest.php new file mode 100644 index 0000000..73cfe93 --- /dev/null +++ b/tests/unit/SchemaRegistryTest.php @@ -0,0 +1,131 @@ +getProperty( 'instance' ); + $instance->setAccessible( true ); + $instance->setValue( null, null ); + + $this->registry = WPDO_Schema_Registry::instance(); + } + + // ── register() ────────────────────────────────────────────────────────── + + public function test_register_single_field(): void { + $this->registry->register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => 'hp_price', + 'indexed' => true, + ] ); + + $field = $this->registry->get_field( 'hp_listing', 'hp_price' ); + $this->assertNotNull( $field ); + $this->assertSame( 'hot', $field['zone'] ); + $this->assertSame( 'hp_price', $field['column'] ); + $this->assertTrue( $field['indexed'] ); + } + + public function test_register_many_registers_all_fields(): void { + $this->registry->register_many( 'test', [ + [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ], + [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ], + [ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_verified', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_verified' ], + ] ); + + $listing_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' ); + $this->assertCount( 2, $listing_hot ); + + $vendor_hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_vendor' ); + $this->assertCount( 1, $vendor_hot ); + } + + // ── get_field() ───────────────────────────────────────────────────────── + + public function test_get_field_returns_null_for_unknown_key(): void { + $this->assertNull( $this->registry->get_field( 'hp_listing', 'hp_nonexistent' ) ); + } + + // ── get_field_zone() ──────────────────────────────────────────────────── + + public function test_get_field_zone_returns_correct_zone(): void { + $this->registry->register( 'test', [ + 'post_type' => 'hp_vendor', + 'meta_key' => 'hp_description', + 'zone' => 'cold', + 'cache_group' => 'wpdo_cold', + 'cache_ttl' => 3600, + ] ); + + $zone = $this->registry->get_field_zone( 'hp_vendor', 'hp_description' ); + $this->assertSame( 'cold', $zone ); + } + + public function test_get_field_zone_returns_null_for_unknown(): void { + $this->assertNull( $this->registry->get_field_zone( 'hp_listing', 'hp_missing' ) ); + } + + // ── get_hot_columns() ─────────────────────────────────────────────────── + + public function test_get_hot_columns_returns_column_to_data_type_map(): void { + $this->registry->register( 'test', [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => 'hp_price', + ] ); + + $cols = $this->registry->get_hot_columns( 'hp_listing' ); + $this->assertArrayHasKey( 'hp_price', $cols ); + $this->assertSame( 'decimal(10,2) NOT NULL DEFAULT 0', $cols['hp_price'] ); + } + + // ── Duplicate registration guard ──────────────────────────────────────── + + public function test_duplicate_registration_does_not_add_extra_entry(): void { + $field = [ + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'zone' => 'hot', + 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', + 'column' => 'hp_price', + ]; + + $this->registry->register( 'test', $field ); + $this->registry->register( 'test', $field ); + + $hot = $this->registry->get_zone_fields_for_type( 'hot', 'hp_listing' ); + $this->assertCount( 1, $hot ); + } + + // ── get_stats() ───────────────────────────────────────────────────────── + + public function test_get_stats_reflects_registered_fields(): void { + $this->registry->register_many( 'test', [ + [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0', 'column' => 'hp_price' ], + [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_featured', 'zone' => 'hot', 'data_type' => 'tinyint(1) NOT NULL DEFAULT 0', 'column' => 'hp_featured' ], + [ 'post_type' => 'hp_vendor', 'meta_key' => 'hp_desc', 'zone' => 'cold', 'cache_group' => 'g', 'cache_ttl' => 3600 ], + ] ); + + $stats = $this->registry->get_stats(); + + $this->assertSame( 2, $stats['hot'] ); + $this->assertSame( 1, $stats['cold'] ); + $this->assertSame( 0, $stats['warm'] ); + $this->assertSame( 0, $stats['archive'] ); + } +} diff --git a/tests/unit/ShadowReadFlagTest.php b/tests/unit/ShadowReadFlagTest.php new file mode 100644 index 0000000..17940d0 --- /dev/null +++ b/tests/unit/ShadowReadFlagTest.php @@ -0,0 +1,80 @@ +getProperty( $prop ); + $p->setAccessible( true ); + $p->setValue( null, null ); + } + } + + public function test_default_is_inactive(): void { + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + } + + public function test_enable_then_active_only_in_verify_state(): void { + WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' ); + + // idle → not active even though flag is on. + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + + // dual_write → still not active. + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + + // verify → active. + WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' ); + $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + + // cutover → no longer active (verify-only sub-flag). + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + } + + public function test_disable_clears_active(): void { + WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_vendor' ); + WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' ); + $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) ); + + WPDO_Feature_Flags::disable_shadow_read( 'hot_hp_vendor' ); + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) ); + } + + public function test_all_shadow_returns_only_enabled_modules(): void { + WPDO_Feature_Flags::enable_shadow_read( 'mod_a' ); + WPDO_Feature_Flags::enable_shadow_read( 'mod_b' ); + WPDO_Feature_Flags::disable_shadow_read( 'mod_b' ); + + $flags = WPDO_Feature_Flags::all_shadow(); + $this->assertArrayHasKey( 'mod_a', $flags ); + $this->assertArrayNotHasKey( 'mod_b', $flags ); + } + + public function test_shadow_flag_independent_per_module(): void { + WPDO_Feature_Flags::enable_shadow_read( 'hot_hp_listing' ); + WPDO_Feature_Flags::set( 'hot_hp_listing', 'verify' ); + WPDO_Feature_Flags::set( 'hot_hp_vendor', 'verify' ); + + $this->assertTrue( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_listing' ) ); + $this->assertFalse( WPDO_Feature_Flags::is_shadow_read_active( 'hot_hp_vendor' ) ); + } +} diff --git a/tests/unit/Snapshots/SnapshotManagerTest.php b/tests/unit/Snapshots/SnapshotManagerTest.php new file mode 100644 index 0000000..1147079 --- /dev/null +++ b/tests/unit/Snapshots/SnapshotManagerTest.php @@ -0,0 +1,259 @@ + sys_get_temp_dir() . '/wpdo-test-uploads', + 'baseurl' => 'http://localhost/uploads', + ); + } +} +if ( ! function_exists( 'wp_mkdir_p' ) ) { + function wp_mkdir_p( string $dir ): bool { + if ( is_dir( $dir ) ) { + return true; + } + return mkdir( $dir, 0777, true ); + } +} +if ( ! function_exists( 'esc_sql' ) ) { + function esc_sql( $s ): string { + return addslashes( (string) $s ); + } +} +if ( ! function_exists( 'size_format' ) ) { + function size_format( int $bytes, int $decimals = 0 ): string { + return $bytes . 'B'; + } +} + +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-logger.php'; +require_once dirname( __DIR__, 3 ) . '/includes/class-tmdo-feature-flags.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-manager.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-writer.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-reader.php'; +require_once dirname( __DIR__, 3 ) . '/includes/snapshots/class-tmdo-snapshot-pruner.php'; + +/** + * Unit tests for WPDO_Snapshot_Manager + Writer + Reader (v2.2.0 M1). + * + * These tests exercise pure logic + filesystem-isolated paths in sys_get_temp_dir(). + * Heavy integration cases (real DB dump + restore) are covered by the + * integration suite and the e2e/wp-data-optimizer/ Playwright tests. + */ +class SnapshotManagerTest extends TestCase { + + protected function setUp(): void { + // Clean tmp upload dir. + $dir = sys_get_temp_dir() . '/wpdo-test-uploads/wpdo-backups'; + if ( is_dir( $dir ) ) { + foreach ( glob( $dir . '/*' ) as $f ) { + if ( is_file( $f ) ) { + @unlink( $f ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + } + } + } + + public function test_generate_id_format_and_uniqueness(): void { + $ref = new ReflectionClass( WPDO_Snapshot_Manager::class ); + $method = $ref->getMethod( 'generate_id' ); + $method->setAccessible( true ); + + $ids = array(); + for ( $i = 0; $i < 50; $i++ ) { + $id = $method->invoke( null ); + $this->assertMatchesRegularExpression( '/^wpdo_[a-z0-9]+_[a-f0-9]+$/', $id ); + $ids[] = $id; + } + $this->assertCount( 50, array_unique( $ids ), '50 generated IDs should all be distinct' ); + } + + public function test_ensure_backup_dir_creates_dir_and_htaccess(): void { + $ok = WPDO_Snapshot_Manager::ensure_backup_dir(); + $this->assertTrue( $ok ); + + $dir = WPDO_Snapshot_Manager::backup_dir(); + $this->assertDirectoryExists( $dir ); + $this->assertFileExists( $dir . '/.htaccess' ); + $this->assertStringContainsString( 'Deny from all', file_get_contents( $dir . '/.htaccess' ) ); + $this->assertFileExists( $dir . '/index.php' ); + } + + public function test_create_with_invalid_trigger_returns_error(): void { + $result = WPDO_Snapshot_Manager::create( 'totally_made_up_trigger', array() ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'invalid_trigger', $result['error'] ); + } + + public function test_writer_escape_sql_value_handles_all_types(): void { + $w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() ); + $ref = new ReflectionClass( WPDO_Snapshot_Writer::class ); + $m = $ref->getMethod( 'escape_sql_value' ); + $m->setAccessible( true ); + + $this->assertSame( 'NULL', $m->invoke( $w, null ) ); + $this->assertSame( '0', $m->invoke( $w, false ) ); + $this->assertSame( '1', $m->invoke( $w, true ) ); + $this->assertSame( '42', $m->invoke( $w, 42 ) ); + $this->assertSame( '3.14', $m->invoke( $w, 3.14 ) ); + $this->assertSame( "'hello'", $m->invoke( $w, 'hello' ) ); + $this->assertSame( "'don\\'t'", $m->invoke( $w, "don't" ) ); + + // Binary (non-utf8) should hex-encode. + $bin = "\x00\x01\xff\xfe"; + $out = $m->invoke( $w, $bin ); + $this->assertSame( '0x0001fffe', $out ); + } + + public function test_writer_is_safe_name_validates_table(): void { + global $wpdb; + $saved_prefix = $wpdb->prefix ?? 'wp_'; + $wpdb->prefix = 'wp_'; + + $w = new WPDO_Snapshot_Writer( 'wpdo_test_id', array() ); + $ref = new ReflectionClass( WPDO_Snapshot_Writer::class ); + $m = $ref->getMethod( 'is_safe_name' ); + $m->setAccessible( true ); + + $this->assertTrue( $m->invoke( $w, 'wp_postmeta' ) ); + $this->assertTrue( $m->invoke( $w, 'wp_wpdo_warm' ) ); + $this->assertFalse( $m->invoke( $w, 'foo_postmeta' ), 'wrong prefix should be rejected' ); + $this->assertFalse( $m->invoke( $w, 'wp_post; DROP TABLE' ), 'sql injection should be rejected' ); + $this->assertFalse( $m->invoke( $w, 'wp_post-bad' ), 'dash should be rejected' ); + + $wpdb->prefix = $saved_prefix; + } + + public function test_reader_parse_summary_extracts_table_row_counts(): void { + $catalog_row = array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'inline', + 'size_bytes' => 100, + 'inline_blob' => '', + ); + $reader = new WPDO_Snapshot_Reader( $catalog_row ); + $ref = new ReflectionClass( WPDO_Snapshot_Reader::class ); + $m = $ref->getMethod( 'parse_summary' ); + $m->setAccessible( true ); + + $sql = "-- header +INSERT INTO `wp_wpdo_warm` (`a`,`b`) VALUES (1,'x'), + (2,'y'), + (3,'z'); + +INSERT INTO `wp_wpdo_archive` (`a`) VALUES (10); +"; + $summary = $m->invoke( $reader, $sql ); + $this->assertSame( 4, $summary['total_rows'] ); + $this->assertSame( 2, $summary['statements'] ); + $this->assertSame( 3, $summary['tables']['wp_wpdo_warm'] ); + $this->assertSame( 1, $summary['tables']['wp_wpdo_archive'] ); + } + + public function test_reader_verify_inline_size_match(): void { + $blob = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n"; + $reader = new WPDO_Snapshot_Reader( array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'inline', + 'size_bytes' => strlen( $blob ), + 'inline_blob' => $blob, + ) ); + $result = $reader->verify(); + $this->assertTrue( $result['ok'] ); + $this->assertTrue( $result['size_match'] ); + $this->assertSame( 'inline', $result['storage'] ); + } + + public function test_reader_verify_inline_size_mismatch(): void { + $blob = "AAA"; + $reader = new WPDO_Snapshot_Reader( array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'inline', + 'size_bytes' => 999, // claim size that doesn't match. + 'inline_blob' => $blob, + ) ); + $result = $reader->verify(); + $this->assertFalse( $result['ok'] ); + $this->assertFalse( $result['size_match'] ); + } + + public function test_reader_verify_file_missing(): void { + $reader = new WPDO_Snapshot_Reader( array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'file', + 'size_bytes' => 100, + 'file_path' => '/non/existent/path.sql.gz', + 'file_sha256' => str_repeat( '0', 64 ), + ) ); + $result = $reader->verify(); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 'file_missing', $result['error'] ); + } + + public function test_reader_maybe_gunzip_decompresses_real_gzip(): void { + $plaintext = "INSERT INTO `wp_wpdo_warm` VALUES (1);\n"; + $gzipped = gzencode( $plaintext ); + + $catalog_row = array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'inline', + 'size_bytes' => strlen( $gzipped ), + 'inline_blob' => $gzipped, + ); + $reader = new WPDO_Snapshot_Reader( $catalog_row ); + $ref = new ReflectionClass( WPDO_Snapshot_Reader::class ); + $m = $ref->getMethod( 'maybe_gunzip' ); + $m->setAccessible( true ); + + $out = $m->invoke( $reader, $gzipped ); + $this->assertSame( $plaintext, $out ); + + // Plain text should pass through untouched. + $out_plain = $m->invoke( $reader, $plaintext ); + $this->assertSame( $plaintext, $out_plain ); + } + + public function test_reader_load_sql_inline_decompresses(): void { + $plaintext = "INSERT INTO `wp_test` VALUES (1);\n"; + $gzipped = gzencode( $plaintext ); + $reader = new WPDO_Snapshot_Reader( array( + 'snapshot_id' => 'wpdo_dummy', + 'storage' => 'inline', + 'size_bytes' => strlen( $gzipped ), + 'inline_blob' => $gzipped, + ) ); + $ref = new ReflectionClass( WPDO_Snapshot_Reader::class ); + $m = $ref->getMethod( 'load_sql' ); + $m->setAccessible( true ); + + $out = $m->invoke( $reader ); + $this->assertSame( $plaintext, $out ); + } + + public function test_reader_throws_on_missing_required_keys(): void { + $this->expectException( InvalidArgumentException::class ); + new WPDO_Snapshot_Reader( array() ); + } + + public function test_pruner_protected_triggers_listed(): void { + $ref = new ReflectionClass( WPDO_Snapshot_Pruner::class ); + $prop = $ref->getReflectionConstant( 'PROTECTED_TRIGGERS' ); + $this->assertNotNull( $prop ); + $value = $prop->getValue(); + $this->assertContains( 'pre_uninstall', $value ); + $this->assertContains( 'pre_v2_upgrade', $value ); + } + + public function test_manager_valid_triggers_constant(): void { + $this->assertContains( 'manual', WPDO_Snapshot_Manager::VALID_TRIGGERS ); + $this->assertContains( 'pre_fsm_transition', WPDO_Snapshot_Manager::VALID_TRIGGERS ); + $this->assertContains( 'pre_v2_upgrade', WPDO_Snapshot_Manager::VALID_TRIGGERS ); + $this->assertContains( 'scheduled', WPDO_Snapshot_Manager::VALID_TRIGGERS ); + $this->assertContains( 'pre_uninstall', WPDO_Snapshot_Manager::VALID_TRIGGERS ); + } +} diff --git a/tests/unit/SyncBridgeTest.php b/tests/unit/SyncBridgeTest.php new file mode 100644 index 0000000..bbae811 --- /dev/null +++ b/tests/unit/SyncBridgeTest.php @@ -0,0 +1,212 @@ +query(). */ + public static string $last_query = ''; + + /** Configurable return value for $wpdb->get_var(). */ + public static ?string $get_var_return = null; + + protected function setUp(): void { + $this->bridge = new WPDO_Sync_Bridge(); + + // Reset globals. + $GLOBALS['_wp_options'] = []; + $GLOBALS['_wp_post_types'] = []; + self::$last_query = ''; + self::$get_var_return = null; + + // Reset private statics via reflection. + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $ref->getProperty( 'bypassing' )->setValue( null, false ); + $ref->getProperty( 'field_cache' )->setValue( null, [] ); + + // Reset Schema Registry singleton. + $sr = new ReflectionClass( WPDO_Schema_Registry::class ); + $sr->getProperty( 'instance' )->setValue( null, null ); + + // Reset Feature Flags request cache. + $ff = new ReflectionClass( WPDO_Feature_Flags::class ); + $ff->getProperty( 'cache' )->setValue( null, null ); + + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + public string $postmeta = 'wp_postmeta'; + public string $posts = 'wp_posts'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + return SyncBridgeTest::$get_var_return; + } + + public function get_row( string $sql, $output = OBJECT ) { return null; } + public function get_results( string $sql, $output = OBJECT ): array { return []; } + public function insert( string $table, array $data, $format = null ): int|false { return 1; } + public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; } + public function delete( string $table, array $where, $format = null ): int|false { return 1; } + + public function query( string $sql ): int|bool { + SyncBridgeTest::$last_query = $sql; + return 1; + } + }; + } + + // ── Helper: register a hot field ───────────────────────────────────────── + + private function register_hot_field( string $post_type = 'hp_listing', string $meta_key = 'hp_price' ): void { + WPDO_Schema_Registry::instance()->register( 'test', [ + 'post_type' => $post_type, + 'meta_key' => $meta_key, + 'zone' => 'hot', + 'column' => $meta_key, + 'type' => 'decimal', + ] ); + } + + // ── intercept_get: early-return guards ─────────────────────────────────── + + public function test_intercept_get_returns_null_when_bypassing(): void { + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $ref->getProperty( 'bypassing' )->setValue( null, true ); + + $result = $this->bridge->intercept_get( null, 1, 'hp_price', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_null_for_zero_post_id(): void { + $result = $this->bridge->intercept_get( null, 0, 'hp_price', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_null_for_empty_meta_key(): void { + $result = $this->bridge->intercept_get( null, 1, '', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_null_when_post_type_unknown(): void { + // Post ID 99 not in _wp_post_types — get_post_type returns false. + $result = $this->bridge->intercept_get( null, 99, 'hp_price', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_null_when_field_not_registered(): void { + $GLOBALS['_wp_post_types'][1] = 'hp_listing'; + // No field registered → returns unchanged $value. + $result = $this->bridge->intercept_get( null, 1, 'unregistered_key', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_null_when_module_not_cutover(): void { + $GLOBALS['_wp_post_types'][1] = 'hp_listing'; + $this->register_hot_field(); + // Module stays idle (not set) → is_read_custom returns false. + $result = $this->bridge->intercept_get( null, 1, 'hp_price', true ); + $this->assertNull( $result ); + } + + public function test_intercept_get_returns_zone_value_when_cutover(): void { + $GLOBALS['_wp_post_types'][2] = 'hp_listing'; + $this->register_hot_field(); + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + self::$get_var_return = '42'; + + $result = $this->bridge->intercept_get( null, 2, 'hp_price', true ); + // Returns array-wrapped value (WordPress unwraps on $single=true). + $this->assertSame( [ '42' ], $result ); + } + + public function test_intercept_get_returns_null_when_zone_returns_null(): void { + $GLOBALS['_wp_post_types'][3] = 'hp_listing'; + $this->register_hot_field(); + WPDO_Feature_Flags::set( 'hot_hp_listing', 'cutover' ); + self::$get_var_return = null; // Zone returns nothing. + + $result = $this->bridge->intercept_get( null, 3, 'hp_price', true ); + $this->assertNull( $result ); + } + + // ── intercept_update ───────────────────────────────────────────────────── + + public function test_intercept_update_skips_when_bypassing(): void { + $ref = new ReflectionClass( WPDO_Sync_Bridge::class ); + $ref->getProperty( 'bypassing' )->setValue( null, true ); + + $result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' ); + $this->assertNull( $result ); + $this->assertEmpty( self::$last_query ); + } + + public function test_intercept_update_passes_through_when_no_field_registered(): void { + $GLOBALS['_wp_post_types'][1] = 'hp_listing'; + // No field registered → returns $check unchanged. + $result = $this->bridge->intercept_update( null, 1, 'hp_price', '99', '' ); + $this->assertNull( $result ); + } + + public function test_intercept_update_writes_to_zone_when_write_active(): void { + $GLOBALS['_wp_post_types'][5] = 'hp_listing'; + $this->register_hot_field(); + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + + $this->bridge->intercept_update( null, 5, 'hp_price', '150', '' ); + + // Zone Hot set() executes an UPSERT query. + $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query ); + } + + // ── intercept_add ──────────────────────────────────────────────────────── + + public function test_intercept_add_writes_when_module_write_active(): void { + $GLOBALS['_wp_post_types'][6] = 'hp_listing'; + $this->register_hot_field(); + WPDO_Feature_Flags::set( 'hot_hp_listing', 'dual_write' ); + + $this->bridge->intercept_add( null, 6, 'hp_price', '200', false ); + + $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query ); + } + + // ── cleanup_post ───────────────────────────────────────────────────────── + + public function test_cleanup_post_does_nothing_for_unknown_post_type(): void { + // Post ID 999 has no type → early return. + $this->bridge->cleanup_post( 999 ); + $this->assertEmpty( self::$last_query ); + } + + public function test_cleanup_post_deletes_hot_zone_data(): void { + $GLOBALS['_wp_post_types'][10] = 'hp_listing'; + $this->register_hot_field(); + + $this->bridge->cleanup_post( 10 ); + + // WPDO_Zone_Hot::delete() calls $wpdb->delete() — but our mock captures query(). + // The hot delete uses $wpdb->delete(), not query(). Just assert no exception thrown. + $this->assertTrue( true ); + } +} diff --git a/tests/unit/ZoneArchiveTest.php b/tests/unit/ZoneArchiveTest.php new file mode 100644 index 0000000..61d6328 --- /dev/null +++ b/tests/unit/ZoneArchiveTest.php @@ -0,0 +1,302 @@ +insert() calls. */ + public static array $store = []; + + /** Arguments of the last $wpdb->delete() call. */ + public static array $last_delete = []; + + protected function setUp(): void { + self::$store = []; + self::$last_delete = []; + $GLOBALS['_wp_postmeta'] = []; + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function insert( string $table, array $data, $format = null ): int|false { + ZoneArchiveTest::$store[] = $data; + return 1; + } + + public function delete( string $table, array $where, $format = null ): int|false { + ZoneArchiveTest::$last_delete = [ 'table' => $table, 'where' => $where ]; + // Remove matching rows (single-column where only). + ZoneArchiveTest::$store = array_values( array_filter( + ZoneArchiveTest::$store, + static function ( array $row ) use ( $where ): bool { + foreach ( $where as $col => $val ) { + if ( isset( $row[ $col ] ) && (string) $row[ $col ] === (string) $val ) { + return false; // row matches → remove. + } + } + return true; + } + ) ); + return 1; + } + + public function get_results( string $sql, $output = OBJECT ): array { + $flat = preg_replace( '/\s+/', ' ', $sql ); + + // stats() GROUP BY post_type query. + if ( stripos( $flat, 'GROUP BY post_type' ) !== false ) { + $by_type = []; + foreach ( ZoneArchiveTest::$store as $row ) { + $pt = $row['post_type'] ?? 'unknown'; + $by_type[ $pt ] = ( $by_type[ $pt ] ?? 0 ) + 1; + } + $result = []; + foreach ( $by_type as $pt => $cnt ) { + $result[] = [ 'post_type' => $pt, 'cnt' => (string) $cnt ]; + } + return $result; + } + + // get() queries — filter by post_id and optional meta_key. + $post_id = null; + $meta_key = null; + + if ( preg_match( '/post_id = (\d+)/', $flat, $m ) ) { + $post_id = (int) $m[1]; + } + if ( preg_match( "/AND meta_key = '([^']+)'/", $flat, $m ) ) { + $meta_key = $m[1]; + } + + $result = []; + foreach ( ZoneArchiveTest::$store as $row ) { + if ( $post_id !== null && (int) ( $row['post_id'] ?? 0 ) !== $post_id ) { + continue; + } + if ( $meta_key !== null && ( $row['meta_key'] ?? '' ) !== $meta_key ) { + continue; + } + $result[] = [ + 'meta_key' => $row['meta_key'] ?? '', + 'meta_value' => $row['meta_value'] ?? '', + 'compressed' => $row['compressed'] ?? 0, + 'archived_at' => $row['archived_at'] ?? '', + ]; + } + return $result; + } + + public function get_var( string $sql ): ?string { + $flat = preg_replace( '/\s+/', ' ', $sql ); + + if ( stripos( $flat, 'WHERE compressed = 1' ) !== false ) { + $count = count( array_filter( + ZoneArchiveTest::$store, + static fn( array $r ) => (int) ( $r['compressed'] ?? 0 ) === 1 + ) ); + return (string) $count; + } + + if ( stripos( $flat, 'COUNT(*)' ) !== false ) { + return (string) count( ZoneArchiveTest::$store ); + } + + return null; + } + + public function query( string $sql ): int|bool { + return 1; // BEGIN, COMMIT, ROLLBACK pass-through. + } + }; + } + + // ── table() ────────────────────────────────────────────────────────────── + + public function test_table_returns_archive_table_name(): void { + $this->assertSame( 'wp_wpdo_archive', WPDO_Zone_Archive::table() ); + } + + // ── archive() ──────────────────────────────────────────────────────────── + + public function test_archive_inserts_row_with_correct_fields(): void { + WPDO_Zone_Archive::archive( 1, 'hp_listing', 'hp_price', '99.99' ); + + $this->assertCount( 1, self::$store ); + $row = self::$store[0]; + $this->assertSame( 1, $row['post_id'] ); + $this->assertSame( 'hp_listing', $row['post_type'] ); + $this->assertSame( 'hp_price', $row['meta_key'] ); + $this->assertSame( '99.99', $row['meta_value'] ); + } + + public function test_archive_without_compress_keeps_plaintext_value(): void { + WPDO_Zone_Archive::archive( 2, 'hp_listing', 'hp_price', 'plain_value', 0, false ); + + $this->assertSame( 'plain_value', self::$store[0]['meta_value'] ); + $this->assertSame( 0, self::$store[0]['compressed'] ); + } + + public function test_archive_with_compress_sets_compressed_flag(): void { + WPDO_Zone_Archive::archive( 3, 'hp_listing', 'hp_price', 'compress_me', 0, true ); + + $this->assertSame( 1, self::$store[0]['compressed'] ); + } + + public function test_archive_with_compress_stores_base64_encoded_gzip(): void { + $original = 'hello compressed world'; + WPDO_Zone_Archive::archive( 4, 'hp_listing', 'hp_bio', $original, 0, true ); + + $stored = self::$store[0]['meta_value']; + $decoded = base64_decode( $stored, true ); + $restored = gzdecode( $decoded ); + $this->assertSame( $original, $restored ); + } + + public function test_archive_stores_original_meta_id(): void { + WPDO_Zone_Archive::archive( 5, 'hp_listing', 'hp_price', '10', 42 ); + + $this->assertSame( 42, self::$store[0]['original_meta_id'] ); + } + + // ── archive_batch() ────────────────────────────────────────────────────── + + public function test_archive_batch_inserts_all_entries(): void { + WPDO_Zone_Archive::archive_batch( [ + [ 'post_id' => 10, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '100', 'meta_id' => 0 ], + [ 'post_id' => 11, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '200', 'meta_id' => 0 ], + [ 'post_id' => 12, 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'meta_value' => '300', 'meta_id' => 0 ], + ] ); + + $this->assertCount( 3, self::$store ); + } + + public function test_archive_batch_with_empty_entries_is_safe(): void { + WPDO_Zone_Archive::archive_batch( [] ); + $this->assertCount( 0, self::$store ); + } + + // ── get() ───────────────────────────────────────────────────────────────── + + public function test_get_returns_empty_array_for_missing_post(): void { + $result = WPDO_Zone_Archive::get( 999 ); + $this->assertSame( [], $result ); + } + + public function test_get_returns_all_entries_for_post(): void { + WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_price', '50' ); + WPDO_Zone_Archive::archive( 20, 'hp_listing', 'hp_category', '3' ); + + $result = WPDO_Zone_Archive::get( 20 ); + $this->assertCount( 2, $result ); + } + + public function test_get_with_meta_key_filter_returns_only_matching(): void { + WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_price', '150' ); + WPDO_Zone_Archive::archive( 21, 'hp_listing', 'hp_category', '2' ); + + $result = WPDO_Zone_Archive::get( 21, 'hp_price' ); + $this->assertCount( 1, $result ); + $this->assertSame( 'hp_price', $result[0]['meta_key'] ); + } + + public function test_get_decompresses_gzipped_values(): void { + $original = 'hello decompressed world'; + WPDO_Zone_Archive::archive( 22, 'hp_listing', 'hp_bio', $original, 0, true ); + + $result = WPDO_Zone_Archive::get( 22 ); + $this->assertCount( 1, $result ); + $this->assertSame( $original, $result[0]['meta_value'] ); + } + + public function test_get_removes_compressed_field_from_result(): void { + WPDO_Zone_Archive::archive( 23, 'hp_listing', 'hp_price', '10' ); + + $result = WPDO_Zone_Archive::get( 23 ); + $this->assertArrayNotHasKey( 'compressed', $result[0] ); + } + + // ── restore() ──────────────────────────────────────────────────────────── + + public function test_restore_writes_to_post_meta(): void { + WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_price', '77' ); + WPDO_Zone_Archive::archive( 30, 'hp_listing', 'hp_category', '5' ); + + WPDO_Zone_Archive::restore( 30 ); + + $this->assertSame( '77', $GLOBALS['_wp_postmeta'][30]['hp_price'] ); + $this->assertSame( '5', $GLOBALS['_wp_postmeta'][30]['hp_category'] ); + } + + public function test_restore_returns_correct_entry_count(): void { + WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_price', '88' ); + WPDO_Zone_Archive::archive( 31, 'hp_listing', 'hp_category', '9' ); + + $count = WPDO_Zone_Archive::restore( 31 ); + $this->assertSame( 2, $count ); + } + + public function test_restore_returns_zero_for_missing_post(): void { + $count = WPDO_Zone_Archive::restore( 999 ); + $this->assertSame( 0, $count ); + } + + // ── delete() ───────────────────────────────────────────────────────────── + + public function test_delete_passes_correct_post_id_to_wpdb(): void { + WPDO_Zone_Archive::archive( 40, 'hp_listing', 'hp_price', '100' ); + WPDO_Zone_Archive::delete( 40 ); + + $this->assertNotEmpty( self::$last_delete ); + $this->assertSame( 40, self::$last_delete['where']['post_id'] ); + } + + // ── stats() ────────────────────────────────────────────────────────────── + + public function test_stats_includes_required_keys(): void { + $stats = WPDO_Zone_Archive::stats(); + + $this->assertArrayHasKey( 'total_rows', $stats ); + $this->assertArrayHasKey( 'compressed_rows', $stats ); + $this->assertArrayHasKey( 'post_types', $stats ); + } + + public function test_stats_total_and_compressed_counts(): void { + WPDO_Zone_Archive::archive( 50, 'hp_listing', 'hp_price', '1', 0, true ); + WPDO_Zone_Archive::archive( 51, 'hp_listing', 'hp_price', '2', 0, false ); + WPDO_Zone_Archive::archive( 52, 'hp_listing', 'hp_price', '3', 0, true ); + + $stats = WPDO_Zone_Archive::stats(); + $this->assertSame( 3, $stats['total_rows'] ); + $this->assertSame( 2, $stats['compressed_rows'] ); + } + + public function test_stats_post_types_groups_by_type(): void { + WPDO_Zone_Archive::archive( 60, 'hp_listing', 'hp_price', '1' ); + WPDO_Zone_Archive::archive( 61, 'hp_listing', 'hp_price', '2' ); + WPDO_Zone_Archive::archive( 62, 'hp_vendor', 'hp_bio', 'x' ); + + $stats = WPDO_Zone_Archive::stats(); + $by_type = array_column( $stats['post_types'], 'cnt', 'post_type' ); + $this->assertSame( '2', $by_type['hp_listing'] ); + $this->assertSame( '1', $by_type['hp_vendor'] ); + } +} diff --git a/tests/unit/ZoneClassifierTest.php b/tests/unit/ZoneClassifierTest.php new file mode 100644 index 0000000..0002eec --- /dev/null +++ b/tests/unit/ZoneClassifierTest.php @@ -0,0 +1,143 @@ +getMethod( $method ); + $m->setAccessible( true ); + return $m->invokeArgs( null, $args ); + } + + // ── is_wp_internal() ───────────────────────────────────────────────────── + + public function test_is_wp_internal_returns_true_for_edit_lock(): void { + $result = $this->invoke_private( 'is_wp_internal', [ '_edit_lock' ] ); + $this->assertTrue( $result ); + } + + public function test_is_wp_internal_returns_true_for_thumbnail_id(): void { + $result = $this->invoke_private( 'is_wp_internal', [ '_thumbnail_id' ] ); + $this->assertTrue( $result ); + } + + public function test_is_wp_internal_returns_false_for_hp_price(): void { + $result = $this->invoke_private( 'is_wp_internal', [ 'hp_price' ] ); + $this->assertFalse( $result ); + } + + public function test_is_wp_internal_returns_false_for_custom_key(): void { + $result = $this->invoke_private( 'is_wp_internal', [ 'my_custom_meta' ] ); + $this->assertFalse( $result ); + } + + // ── score_zones() — hot ────────────────────────────────────────────────── + + public function test_score_zones_hot_for_numeric_short_values(): void { + $signals = $this->make_signals( [ + 'avg_length' => 10, + 'numeric_ratio' => 0.9, + 'distinct_values' => 5, + ] ); + + $scores = $this->invoke_private( 'score_zones', [ $signals ] ); + + // Hot should outrank cold and archive. + $this->assertGreaterThan( $scores['cold'], $scores['hot'] ); + $this->assertGreaterThan( $scores['archive'], $scores['hot'] ); + } + + // ── score_zones() — warm ───────────────────────────────────────────────── + + public function test_score_zones_warm_for_transient_prefix(): void { + $signals = $this->make_signals( [ 'prefix' => 'transient' ] ); + + $scores = $this->invoke_private( 'score_zones', [ $signals ] ); + + $this->assertGreaterThanOrEqual( 0.8, $scores['warm'] ); + } + + // ── score_zones() — cold ───────────────────────────────────────────────── + + public function test_score_zones_cold_for_long_json_values(): void { + $signals = $this->make_signals( [ + 'avg_length' => 300, + 'is_json' => true, + ] ); + + $scores = $this->invoke_private( 'score_zones', [ $signals ] ); + + $this->assertGreaterThan( $scores['hot'], $scores['cold'] ); + $this->assertGreaterThan( $scores['archive'], $scores['cold'] ); + } + + // ── score_zones() — archive ────────────────────────────────────────────── + + public function test_score_zones_archive_for_high_trash_ratio(): void { + $signals = $this->make_signals( [ 'trash_ratio' => 0.7 ] ); + + $scores = $this->invoke_private( 'score_zones', [ $signals ] ); + + $this->assertGreaterThanOrEqual( 0.6, $scores['archive'] ); + } + + // ── score_zones() — default ─────────────────────────────────────────────── + + public function test_score_zones_defaults_cold_when_no_signals(): void { + $signals = $this->make_signals( [] ); + + $scores = $this->invoke_private( 'score_zones', [ $signals ] ); + + // With avg_length=0, numeric_ratio=0, etc., hot gets 0.3 (avg_length<50 is true for 0). + // Cold must have at least a non-zero score (either from scoring or the fallback). + $max = max( $scores ); + $this->assertGreaterThan( 0.0, $max ); + $this->assertGreaterThan( 0.0, $scores['cold'] + $scores['hot'] ); // At least one has a score. + } + + // ── build_reasons() ────────────────────────────────────────────────────── + + public function test_build_reasons_hot_includes_numeric_message(): void { + $signals = $this->make_signals( [ + 'avg_length' => 10, + 'numeric_ratio' => 0.9, + 'distinct_values' => 5, + ] ); + + $reasons = $this->invoke_private( 'build_reasons', [ $signals, 'hot' ] ); + + $combined = implode( ' ', $reasons ); + $this->assertStringContainsStringIgnoringCase( 'numeric', $combined ); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** + * Build a signals array with defaults, overriding specific keys. + */ + private function make_signals( array $overrides ): array { + return array_merge( [ + 'meta_key' => 'test_key', + 'row_count' => 100, + 'avg_length' => 0, + 'max_length' => 0, + 'distinct_values' => 0, + 'numeric_ratio' => 0.0, + 'trash_ratio' => 0.0, + 'is_serialized' => false, + 'is_json' => false, + 'prefix' => '', + ], $overrides ); + } +} diff --git a/tests/unit/ZoneColdTest.php b/tests/unit/ZoneColdTest.php new file mode 100644 index 0000000..41a33ef --- /dev/null +++ b/tests/unit/ZoneColdTest.php @@ -0,0 +1,229 @@ + json string */ + public static array $db_store = []; + + /** Control whether get_var returns the "id" existence check */ + public static bool $row_exists = false; + + protected function setUp(): void { + self::$db_store = []; + self::$row_exists = false; + $GLOBALS['_wp_cache'] = []; + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + /** + * get_var is used for two things: + * 1. SELECT data ... → return JSON blob + * 2. SELECT id ... → return '1' if exists, else null + */ + public function get_var( string $sql ): ?string { + $flat = preg_replace( '/\s+/', ' ', $sql ); + + // Existence check (save_blob path). + if ( stripos( $flat, 'SELECT id' ) !== false ) { + if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) { + return isset( ZoneColdTest::$db_store[ (int) $m[1] ] ) ? '1' : null; + } + return null; + } + + // Data fetch. + if ( preg_match( "/post_id = (\d+)/", $flat, $m ) ) { + return ZoneColdTest::$db_store[ (int) $m[1] ] ?? null; + } + + return null; + } + + public function get_row( string $sql, $output = OBJECT ) { + return null; + } + + public function get_results( string $sql, $output = OBJECT ): array { + return []; + } + + public function insert( string $table, array $data, $format = null ): int|false { + if ( isset( $data['post_id'], $data['data'] ) ) { + ZoneColdTest::$db_store[ (int) $data['post_id'] ] = $data['data']; + } + return 1; + } + + public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false { + if ( isset( $where['post_id'], $data['data'] ) ) { + ZoneColdTest::$db_store[ (int) $where['post_id'] ] = $data['data']; + } + return 1; + } + + public function delete( string $table, array $where, $format = null ): int|false { + if ( isset( $where['post_id'] ) ) { + unset( ZoneColdTest::$db_store[ (int) $where['post_id'] ] ); + } + return 1; + } + + public function query( string $sql ): int|bool { + return 1; + } + }; + } + + // ── table() ────────────────────────────────────────────────────────────── + + public function test_table_returns_prefixed_name(): void { + $this->assertSame( 'wp_wpdo_cold_hp_listing', WPDO_Zone_Cold::table( 'hp_listing' ) ); + } + + // ── get() ───────────────────────────────────────────────────────────────── + + public function test_get_returns_null_for_missing_key(): void { + // Cache miss + no DB row → blob is empty array → key missing → null. + $result = WPDO_Zone_Cold::get( 99, 'hp_listing', 'hp_description' ); + $this->assertNull( $result ); + } + + public function test_get_reads_from_cache_on_hit(): void { + // Pre-populate cache so DB should NOT be hit. + $group = 'wpdo_cold_hp_listing'; + $cache_key = 'cold_1'; + $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'hp_bio' => 'cached value' ]; + + $result = WPDO_Zone_Cold::get( 1, 'hp_listing', 'hp_bio' ); + $this->assertSame( 'cached value', $result ); + + // DB store should remain empty (DB was not queried for data). + $this->assertEmpty( self::$db_store ); + } + + // ── get_blob() ─────────────────────────────────────────────────────────── + + public function test_get_blob_queries_db_on_cache_miss(): void { + self::$db_store[5] = json_encode( [ 'hp_description' => 'Hello World', 'hp_location' => 'Paris' ] ); + $blob = WPDO_Zone_Cold::get_blob( 5, 'hp_listing' ); + $this->assertSame( 'Hello World', $blob['hp_description'] ); + $this->assertSame( 'Paris', $blob['hp_location'] ); + } + + // ── set() ───────────────────────────────────────────────────────────────── + + public function test_set_merges_new_key_into_blob(): void { + // Seed an existing blob. + self::$db_store[10] = json_encode( [ 'a' => 1 ] ); + + WPDO_Zone_Cold::set( 10, 'hp_listing', 'b', 2 ); + + $stored = json_decode( self::$db_store[10], true ); + $this->assertArrayHasKey( 'a', $stored ); + $this->assertArrayHasKey( 'b', $stored ); + $this->assertSame( 1, $stored['a'] ); + $this->assertSame( 2, $stored['b'] ); + } + + // ── set_many() ─────────────────────────────────────────────────────────── + + public function test_set_many_merges_multiple_keys(): void { + WPDO_Zone_Cold::set_many( 20, 'hp_listing', [ + 'key1' => 'v1', + 'key2' => 'v2', + 'key3' => 'v3', + ] ); + + $stored = json_decode( self::$db_store[20], true ); + $this->assertSame( 'v1', $stored['key1'] ); + $this->assertSame( 'v2', $stored['key2'] ); + $this->assertSame( 'v3', $stored['key3'] ); + } + + // ── remove() ───────────────────────────────────────────────────────────── + + public function test_remove_deletes_key_from_blob(): void { + self::$db_store[30] = json_encode( [ 'keep' => 'yes', 'drop' => 'no' ] ); + + WPDO_Zone_Cold::remove( 30, 'hp_listing', 'drop' ); + + $stored = json_decode( self::$db_store[30], true ); + $this->assertArrayHasKey( 'keep', $stored ); + $this->assertArrayNotHasKey( 'drop', $stored ); + } + + // ── delete() ───────────────────────────────────────────────────────────── + + public function test_delete_clears_cache(): void { + $group = 'wpdo_cold_hp_listing'; + $cache_key = 'cold_1'; + + // Pre-populate cache. + $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'some' => 'data' ]; + + WPDO_Zone_Cold::delete( 1, 'hp_listing' ); + + // Cache entry must be gone. + $this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) ); + } + + // ── Additional edge-case tests ──────────────────────────────────────────── + + public function test_set_invalidates_object_cache(): void { + $group = 'wpdo_cold_hp_listing'; + $cache_key = 'cold_50'; + + // Pre-populate cache with stale data. + $GLOBALS['_wp_cache'][ $group ][ $cache_key ] = [ 'stale' => 'old_value' ]; + + WPDO_Zone_Cold::set( 50, 'hp_listing', 'fresh', 'new_value' ); + + // Cache must be invalidated after write. + $this->assertFalse( isset( $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ) ); + } + + public function test_get_blob_populates_cache_on_db_hit(): void { + // Seed the DB store so get_blob has something to fetch. + self::$db_store[60] = json_encode( [ 'cached_key' => 'cached_val' ] ); + + WPDO_Zone_Cold::get_blob( 60, 'hp_listing' ); + + // Cache must now contain the fetched data. + $group = 'wpdo_cold_hp_listing'; + $cached = $GLOBALS['_wp_cache'][ $group ]['cold_60'] ?? false; + $this->assertIsArray( $cached ); + $this->assertSame( 'cached_val', $cached['cached_key'] ); + } + + public function test_remove_nonexistent_key_is_safe(): void { + // Store an existing blob. + self::$db_store[70] = json_encode( [ 'keep' => 'this' ] ); + + // Remove a key that doesn't exist — should not throw. + WPDO_Zone_Cold::remove( 70, 'hp_listing', 'nonexistent_key' ); + + $stored = json_decode( self::$db_store[70], true ); + $this->assertArrayHasKey( 'keep', $stored ); + $this->assertArrayNotHasKey( 'nonexistent_key', $stored ); + } +} diff --git a/tests/unit/ZoneHotTest.php b/tests/unit/ZoneHotTest.php new file mode 100644 index 0000000..dd1ce1d --- /dev/null +++ b/tests/unit/ZoneHotTest.php @@ -0,0 +1,161 @@ +query(). */ + public static string $last_query = ''; + + /** Arguments passed to $wpdb->delete(). */ + public static array $last_delete = []; + + /** Return value for get_var mock. */ + public static ?string $get_var_return = null; + + /** Return value for get_row mock. */ + public static mixed $get_row_return = null; + + protected function setUp(): void { + self::$last_query = ''; + self::$last_delete = []; + self::$get_var_return = null; + self::$get_row_return = null; + $this->setup_wpdb_mock(); + } + + private function setup_wpdb_mock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + return ZoneHotTest::$get_var_return; + } + + public function get_row( string $sql, $output = OBJECT ) { + return ZoneHotTest::$get_row_return; + } + + public function get_results( string $sql, $output = OBJECT ): array { + return []; + } + + public function insert( string $table, array $data, $format = null ): int|false { + return 1; + } + + public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false { + return 1; + } + + public function delete( string $table, array $where, $format = null ): int|false { + ZoneHotTest::$last_delete = [ 'table' => $table, 'where' => $where ]; + return 1; + } + + public function query( string $sql ): int|bool { + ZoneHotTest::$last_query = $sql; + return 1; + } + }; + } + + // ── table() ────────────────────────────────────────────────────────────── + + public function test_table_returns_prefixed_name(): void { + $this->assertSame( 'wp_wpdo_hot_hp_listing', WPDO_Zone_Hot::table( 'hp_listing' ) ); + } + + public function test_table_sanitizes_post_type(): void { + // The test sanitize_key stub strips non-[a-z0-9_-] chars then lowercases. + // 'HP Listing!' → strip uppercase H,P + space + '!' → 'isting' → lower → 'isting'. + $sanitized = sanitize_key( 'HP Listing!' ); + $this->assertSame( 'wp_wpdo_hot_' . $sanitized, WPDO_Zone_Hot::table( 'HP Listing!' ) ); + } + + // ── get() ───────────────────────────────────────────────────────────────── + + public function test_get_returns_null_when_row_missing(): void { + self::$get_var_return = null; + $result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' ); + $this->assertNull( $result ); + } + + public function test_get_returns_value_from_db(): void { + self::$get_var_return = '42'; + $result = WPDO_Zone_Hot::get( 1, 'hp_listing', 'hp_price' ); + $this->assertSame( '42', $result ); + } + + // ── get_row() ──────────────────────────────────────────────────────────── + + public function test_get_row_returns_null_when_missing(): void { + self::$get_row_return = null; + $result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' ); + $this->assertNull( $result ); + } + + public function test_get_row_returns_array(): void { + $expected = [ 'post_id' => 1, 'hp_price' => '100', 'hp_location' => 'NYC' ]; + self::$get_row_return = $expected; + $result = WPDO_Zone_Hot::get_row( 1, 'hp_listing' ); + $this->assertSame( $expected, $result ); + } + + // ── set() ───────────────────────────────────────────────────────────────── + + public function test_set_executes_upsert_query(): void { + WPDO_Zone_Hot::set( 5, 'hp_listing', 'hp_price', '99' ); + $this->assertNotEmpty( self::$last_query, 'Expected $wpdb->query() to be called' ); + $this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', self::$last_query ); + } + + // ── set_many() ─────────────────────────────────────────────────────────── + + public function test_set_many_includes_all_columns_in_upsert(): void { + WPDO_Zone_Hot::set_many( 7, 'hp_listing', [ + 'hp_price' => '150', + 'hp_location' => 'LA', + 'hp_category' => '3', + ] ); + $this->assertNotEmpty( self::$last_query ); + $this->assertStringContainsString( 'hp_price', self::$last_query ); + $this->assertStringContainsString( 'hp_location', self::$last_query ); + $this->assertStringContainsString( 'hp_category', self::$last_query ); + } + + // ── delete() ───────────────────────────────────────────────────────────── + + public function test_delete_calls_wpdb_delete(): void { + WPDO_Zone_Hot::delete( 42, 'hp_listing' ); + $this->assertNotEmpty( self::$last_delete, 'Expected $wpdb->delete() to be called' ); + $this->assertSame( 42, self::$last_delete['where']['post_id'] ); + } + + public function test_delete_table_name_contains_post_type(): void { + WPDO_Zone_Hot::delete( 5, 'hp_vendor' ); + $this->assertStringContainsString( 'hp_vendor', self::$last_delete['table'] ); + } + + // ── Additional edge cases ───────────────────────────────────────────────── + + public function test_table_for_different_post_type(): void { + $this->assertSame( 'wp_wpdo_hot_hp_vendor', WPDO_Zone_Hot::table( 'hp_vendor' ) ); + } +} diff --git a/tests/unit/ZoneWarmTest.php b/tests/unit/ZoneWarmTest.php new file mode 100644 index 0000000..552be78 --- /dev/null +++ b/tests/unit/ZoneWarmTest.php @@ -0,0 +1,183 @@ + meta_key => [value, expires_at] */ + public static array $store = []; + + protected function setUp(): void { + self::$store = []; + $this->setupWpdbMock(); + } + + private function setupWpdbMock(): void { + global $wpdb; + + $wpdb = new class { + public string $prefix = 'wp_'; + + public function prepare( string $sql, ...$args ): string { + $i = 0; + return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) { + $val = $args[ $i++ ] ?? ''; + return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'"; + }, $sql ); + } + + public function get_var( string $sql ): ?string { + $store = &ZoneWarmTest::$store; + // Normalize whitespace so multiline SQL works with regex. + $flat = preg_replace( '/\s+/', ' ', $sql ); + if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) { + return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null; + } + if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) { + $entry = $store[ $m[1] ][ $m[2] ] ?? null; + if ( ! $entry ) { + return null; + } + if ( $entry['expires_at'] && $entry['expires_at'] < time() ) { + return null; + } + return $entry['value']; + } + return null; + } + + public function get_results( string $sql, $output = null ): array { + $store = ZoneWarmTest::$store; + $result = []; + foreach ( $store as $post_id => $keys ) { + foreach ( $keys as $meta_key => $entry ) { + if ( $entry['expires_at'] && $entry['expires_at'] < time() ) { + continue; + } + $result[] = (object) [ 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ]; + } + } + return $result; + } + + public function insert( string $table, array $data, $format = null ): int|false { + ZoneWarmTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [ + 'value' => $data['meta_value'], + 'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null, + ]; + return 1; + } + + public function update( string $table, array $data, array $where, $format = null, $where_format = null ): int|false { + // For simplicity, find by scanning store. + foreach ( ZoneWarmTest::$store as $post_id => &$keys ) { + foreach ( $keys as $meta_key => &$entry ) { + if ( isset( $data['meta_value'] ) ) { + $entry['value'] = $data['meta_value']; + } + if ( isset( $data['expires_at'] ) ) { + $entry['expires_at'] = strtotime( $data['expires_at'] ); + } + } + } + return 1; + } + + public function delete( string $table, array $where, $format = null ): int|false { + $post_id = $where['post_id'] ?? null; + $meta_key = $where['meta_key'] ?? null; + + if ( $post_id && $meta_key ) { + unset( ZoneWarmTest::$store[ $post_id ][ $meta_key ] ); + } elseif ( $post_id ) { + unset( ZoneWarmTest::$store[ $post_id ] ); + } + return 1; + } + + public function query( string $sql ): int|bool { + return 0; + } + }; + } + + // ── set / get ──────────────────────────────────────────────────────────── + + public function test_set_and_get_basic_value(): void { + WPDO_Zone_Warm::set( 1, 'hp_views', '42' ); + $this->assertSame( '42', WPDO_Zone_Warm::get( 1, 'hp_views' ) ); + } + + public function test_get_returns_null_for_missing_key(): void { + $this->assertNull( WPDO_Zone_Warm::get( 99, 'missing_key' ) ); + } + + public function test_set_overwrites_existing_value(): void { + WPDO_Zone_Warm::set( 1, 'hp_views', '10' ); + WPDO_Zone_Warm::set( 1, 'hp_views', '20' ); + // The store update mock replaces all entries for simplicity. + $this->assertNotNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) ); + } + + // ── TTL / expiry ───────────────────────────────────────────────────────── + + public function test_set_with_ttl_stores_future_expiry(): void { + WPDO_Zone_Warm::set( 1, 'hp_views', '5', 3600 ); + $this->assertSame( '5', WPDO_Zone_Warm::get( 1, 'hp_views' ) ); + } + + public function test_expired_entry_returns_null(): void { + // Insert directly with a past expiry. + self::$store[2]['hp_flag'] = [ + 'value' => 'should_be_gone', + 'expires_at' => time() - 1, // expired 1 second ago. + ]; + $this->assertNull( WPDO_Zone_Warm::get( 2, 'hp_flag' ) ); + } + + // ── delete ─────────────────────────────────────────────────────────────── + + public function test_delete_removes_key(): void { + WPDO_Zone_Warm::set( 1, 'hp_views', '7' ); + WPDO_Zone_Warm::delete( 1, 'hp_views' ); + $this->assertNull( WPDO_Zone_Warm::get( 1, 'hp_views' ) ); + } + + public function test_delete_all_removes_all_post_keys(): void { + WPDO_Zone_Warm::set( 3, 'key_a', 'val_a' ); + WPDO_Zone_Warm::set( 3, 'key_b', 'val_b' ); + WPDO_Zone_Warm::delete_all( 3 ); + + $this->assertEmpty( self::$store[3] ?? [] ); + } + + // ── Additional tests ───────────────────────────────────────────────────── + + public function test_table_returns_warm_table_name(): void { + $this->assertSame( 'wp_wpdo_warm', WPDO_Zone_Warm::table() ); + } + + public function test_delete_specific_key_leaves_other_keys_intact(): void { + WPDO_Zone_Warm::set( 4, 'key_keep', 'val_keep' ); + WPDO_Zone_Warm::set( 4, 'key_drop', 'val_drop' ); + WPDO_Zone_Warm::delete( 4, 'key_drop' ); + + $this->assertNull( WPDO_Zone_Warm::get( 4, 'key_drop' ) ); + // key_keep should still be readable. + $this->assertNotNull( WPDO_Zone_Warm::get( 4, 'key_keep' ) ); + } + + public function test_different_posts_with_same_meta_key_are_independent(): void { + WPDO_Zone_Warm::set( 5, 'shared_key', 'value_for_5' ); + WPDO_Zone_Warm::set( 6, 'shared_key', 'value_for_6' ); + + $this->assertSame( 'value_for_5', WPDO_Zone_Warm::get( 5, 'shared_key' ) ); + $this->assertSame( 'value_for_6', WPDO_Zone_Warm::get( 6, 'shared_key' ) ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..a572dfa --- /dev/null +++ b/uninstall.php @@ -0,0 +1,244 @@ + --apply` to recover. + * + * v2.14.0 multisite hardening: when network-uninstalled, iterate all sites + * via switch_to_blog so every wp_N_wpdo_* table is dropped (previously only + * the main site got cleaned, leaving orphans on every other site). + * + * @package WP_Data_Optimizer + */ + +if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + exit; +} + +global $wpdb; + +// ── Load shared cleanup logic + snapshot classes ───────────────────────── +$bootstrap_files = array( + __DIR__ . '/includes/class-tmdo-logger.php', + __DIR__ . '/includes/class-tmdo-feature-flags.php', + __DIR__ . '/includes/class-tmdo-installer.php', + __DIR__ . '/includes/snapshots/class-tmdo-snapshot-manager.php', + __DIR__ . '/includes/snapshots/class-tmdo-snapshot-writer.php', + __DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php', + __DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php', +); +$bootstrap_ok = true; +foreach ( $bootstrap_files as $f ) { + if ( ! is_file( $f ) ) { + $bootstrap_ok = false; + continue; + } + require_once $f; +} + +// ── v2.2.0 M1.5: pre-uninstall snapshot (main-site context only) ───────── +// Snapshot is taken once on the main site — it captures the catalog of all +// wpdo_snapshots rows (which is per-site so per-site snapshots survive the +// site-level table DROP that happens later). Best-effort. +if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) { + $result = TMDO_Snapshot_Manager::create( + 'pre_uninstall', + array(), + array( + 'notes' => 'Auto-snapshot taken at plugin uninstall time', + 'retention_days' => 365, + 'inline_threshold_bytes' => 0, + ) + ); + if ( ! empty( $result['ok'] ) ) { + $snapshot_id = $result['snapshot_id']; + $dir = TMDO_Snapshot_Manager::backup_dir(); + if ( is_dir( $dir ) ) { + file_put_contents( + trailingslashit( $dir ) . 'README-pre-uninstall.txt', + "WPDO pre-uninstall snapshot: {$snapshot_id}\n" + . 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n" + . "To restore after re-installing wp-data-optimizer:\n" + . " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n" + . " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n" + . " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n" + ); + } + } +} + +// ── v2.14.0: shared cleanup helper ─────────────────────────────────────── +// Falls back to inline DROP loop if the helper class isn't loadable +// (e.g. partially-broken install where includes/class-tmdo-installer.php +// is missing). The inline path is the original v2.13.x logic verbatim. +$has_helper = class_exists( 'TMDO_Installer' ) + && method_exists( 'TMDO_Installer', 'drop_all_tables_for_current_blog' ); + +/** + * Run the cleanup for the current blog (helper-aware). + */ +$run_cleanup = static function () use ( $has_helper ): void { + global $wpdb; + + if ( $has_helper ) { + TMDO_Installer::drop_all_tables_for_current_blog(); + return; + } + + // ── Fallback inline path (helper unavailable) ───────────────────────── + $tables = array( + $wpdb->prefix . 'wpdo_migrations', + $wpdb->prefix . 'wpdo_errors', + $wpdb->prefix . 'wpdo_benchmarks', + $wpdb->prefix . 'wpdo_warm', + $wpdb->prefix . 'wpdo_archive', + $wpdb->prefix . 'wpdo_audit', + $wpdb->prefix . 'wpdo_shadow_diffs', + $wpdb->prefix . 'wpdo_site_metrics', + $wpdb->prefix . 'wpdo_registry_meta', + $wpdb->prefix . 'wpdo_uni_options', + $wpdb->prefix . 'wpdo_wc_commissions', + $wpdb->prefix . 'wpdo_snapshots', + $wpdb->prefix . 'wpdo_migration_status', + $wpdb->prefix . 'wpdo_user_points_ledger', + $wpdb->prefix . 'wpdo_user_membership', + $wpdb->prefix . 'wpdo_user_activity', + $wpdb->prefix . 'wpdo_user_profile', + $wpdb->prefix . 'wpdo_user_sso', + $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', + // v3.0.0: HivePress family integration tables. + $wpdb->prefix . 'wpdo_hot_hp_request', + $wpdb->prefix . 'wpdo_hot_hp_membership', + $wpdb->prefix . 'wpdo_hot_hp_membership_plan', + $wpdb->prefix . 'wpdo_hot_hp_booking', + $wpdb->prefix . 'wpdo_comment_hp_message', + $wpdb->prefix . 'wpdo_comment_hp_favorite', + $wpdb->prefix . 'wpdo_comment_hp_offer', + $wpdb->prefix . 'wpdo_term_hp_listing_tag', + ); + + $hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%'; + $cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%'; + $entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%'; + $post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%'; + $term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%'; + $comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%'; + + if ( class_exists( 'WP_SQLite_Driver' ) ) { + $dynamic = $wpdb->get_col( + $wpdb->prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)", + $hot_like, + $cold_like, + $entity_like, + $post_ent_like, + $term_ent_like, + $comment_ent_like + ) + ); + } else { + $dynamic = $wpdb->get_col( + $wpdb->prepare( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)', + $hot_like, + $cold_like, + $entity_like, + $post_ent_like, + $term_ent_like, + $comment_ent_like + ) + ); + } + + $tables = array_unique( array_merge( $tables, $dynamic ?: array() ) ); + $prefix = $wpdb->prefix . 'wpdo_'; + foreach ( $tables as $table ) { + if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) { + continue; + } + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + + delete_option( 'wpdo_db_version' ); + delete_option( 'wpdo_features' ); + delete_option( 'wpdo_features_shadow' ); + delete_option( 'wpdo_hpct_imported' ); + delete_option( 'wpdo_hook_bus_enabled' ); + delete_option( 'wpdo_v2_features_backup' ); + delete_option( 'wpdo_v2_upgrade_status' ); + delete_option( 'wpdo_v2_upgrade_error' ); + delete_option( 'wpdo_v2_upgraded_at' ); + delete_option( 'wpdo_health_alert' ); + delete_option( 'wpdo_rl_stats' ); + delete_option( 'wpdo_setup_wizard_completed' ); + delete_option( 'wpdo_first_run_at' ); + + wp_clear_scheduled_hook( 'wpdo_warm_cleanup' ); + wp_clear_scheduled_hook( 'wpdo_archive_sweep' ); + wp_clear_scheduled_hook( 'wpdo_errors_gc' ); + wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' ); + wp_clear_scheduled_hook( 'wpdo_daily_health_check' ); + wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' ); +}; + +// ── Run cleanup ────────────────────────────────────────────────────────── +// v2.14.0: when network-uninstalling on multisite, iterate every site so each +// wp_N_wpdo_* table set is dropped. Pre-v2.14.0 only the main site got cleaned. +if ( is_multisite() ) { + $is_network_active = function_exists( 'is_plugin_active_for_network' ) + && is_plugin_active_for_network( plugin_basename( __FILE__ ) ); + + if ( $is_network_active ) { + // Iterate all sites in batches. + $offset = 0; + $batch = 100; + do { + $site_ids = get_sites( + array( + 'number' => $batch, + 'offset' => $offset, + 'fields' => 'ids', + ) + ); + foreach ( $site_ids as $wpdo_blog_id ) { + switch_to_blog( (int) $wpdo_blog_id ); + try { + $run_cleanup(); + } finally { + restore_current_blog(); + } + } + $offset += $batch; + $batch_size = count( $site_ids ); + } while ( $batch_size === $batch ); + } else { + // Per-site activation: clean only the current site. + $run_cleanup(); + } +} else { + // Single-site install: run once. + $run_cleanup(); +}