commit d36bb954d10b90e5e37908f9d928cb9ea52f1e46 Author: wpdev Date: Fri Jul 31 05:06:36 2026 +0800 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 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 0000000..7e8c670 Binary files /dev/null and b/languages/2meet-data-optimizer-zh_TW.mo differ 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(); +}