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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
+16
@@ -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/
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: 2meet Data Optimizer
|
||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.0
|
||||
* Tested up to: 6.9.4
|
||||
* Requires PHP: 8.1
|
||||
* Author: 2meet
|
||||
* Author URI: https://2meet.io
|
||||
* License: GPL-2.0-or-later
|
||||
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
* Text Domain: 2meet-data-optimizer
|
||||
* Domain Path: /languages
|
||||
*
|
||||
* TablePrefix: ^wpdo_(hot_|cold_|warm$|archive$|migrations$|errors$|benchmarks$|audit$|shadow_diffs$|site_metrics$|uni_options$|snapshots$|registry_meta$|migration_status$|user_points_ledger$|user_membership$|user_activity$|user_profile$|user_sso$|user_core_profile$|user_social$|user_commerce$|user_hp_user$|user_admin_prefs$|user_hot$|user_cold$|post_wp_core$|post_attachment$|post_nav_menu_item$|term_misc$|comment_misc$|demo_entity_counters$)
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
define( 'TMDO_VERSION', '0.1.0' );
|
||||
define( 'TMDO_DB_VERSION', '2.0.0' );
|
||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'TMDO_FILE', __FILE__ );
|
||||
define( 'TMDO_MIN_PHP', '8.1' );
|
||||
define( 'TMDO_MIN_WP', '6.0' );
|
||||
|
||||
// 沿用既有 wp_wpdo_* schema(不 rename,與 wp-data-optimizer v2.16.0 共存相容)。
|
||||
if ( ! defined( 'TMDO_TABLE_PREFIX' ) ) {
|
||||
define( 'TMDO_TABLE_PREFIX', 'wpdo_' );
|
||||
}
|
||||
if ( ! defined( 'TMDO_CACHE_GROUP' ) ) {
|
||||
define( 'TMDO_CACHE_GROUP', 'wpdo' );
|
||||
}
|
||||
|
||||
/** True when running on the SQLite drop-in. */
|
||||
define(
|
||||
'TMDO_IS_SQLITE',
|
||||
class_exists( 'WP_SQLite_Driver' ) ||
|
||||
class_exists( 'WP_SQLite_DB' ) ||
|
||||
class_exists( 'WP_SQLite_Translator' )
|
||||
);
|
||||
define( 'TMDO_IS_MYSQL', ! TMDO_IS_SQLITE );
|
||||
|
||||
// ── 跨外掛偵測訊號(dual-fire 對外保留 wp-data-optimizer 訊號)────────────
|
||||
const TWO_MEET_DATA_OPTIMIZER_VERSION = '0.1.0';
|
||||
const TWO_MEET_DATA_OPTIMIZER_FILE = __FILE__;
|
||||
const TWO_MEET_DATA_OPTIMIZER_DB_VERSION = '1.2.0';
|
||||
// 向後相容:對外 sister plugins 仍以 WP_DATA_OPTIMIZER_VERSION 偵測。
|
||||
if ( ! defined( 'WP_DATA_OPTIMIZER_VERSION' ) ) {
|
||||
define( 'WP_DATA_OPTIMIZER_VERSION', '0.1.0' );
|
||||
}
|
||||
if ( ! defined( 'WP_DATA_OPTIMIZER_FILE' ) ) {
|
||||
define( 'WP_DATA_OPTIMIZER_FILE', __FILE__ );
|
||||
}
|
||||
if ( ! defined( 'WP_DATA_OPTIMIZER_DB_VERSION' ) ) {
|
||||
define( 'WP_DATA_OPTIMIZER_DB_VERSION', '1.2.0' );
|
||||
}
|
||||
|
||||
// PHP 版本守門。
|
||||
if ( version_compare( PHP_VERSION, TMDO_MIN_PHP, '<' ) ) {
|
||||
add_action(
|
||||
'admin_notices',
|
||||
static function () {
|
||||
echo '<div class="notice notice-error"><p>';
|
||||
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 '</p></div>';
|
||||
}
|
||||
);
|
||||
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 '<div class="notice notice-error"><p>';
|
||||
esc_html_e( '⚠ 偵測到舊版 wp-data-optimizer 已啟用。請停用舊外掛以避免 hook 雙觸發。', '2meet-data-optimizer' );
|
||||
echo '</p></div>';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── 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 );
|
||||
}
|
||||
+114
@@ -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
|
||||
@@ -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`。
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 協作指引
|
||||
+76
@@ -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。
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} )();
|
||||
@@ -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 += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post ID</td><td><code>#${ esc( w.post_id ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> comments/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(comment 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
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 = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_commentmeta,flat probes 走 wpdo_comment_hp_review。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
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();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -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 '<span class="wpdo-pipeline-dot' + active + '" title="' + labels[ m ] + '">' +
|
||||
'<span class="wpdo-dot"></span>' +
|
||||
'<span class="wpdo-dot-label">' + labels[ m ] + '</span>' +
|
||||
'</span>';
|
||||
} ).join( '<span class="wpdo-pipeline-arrow">→</span>' );
|
||||
}
|
||||
|
||||
// ─── 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 );
|
||||
|
||||
} )();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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 += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Post Type</td><td><code>${ esc( w.post_type ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> posts/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes (wp_posts + wp_postmeta + flat table for this run)
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(post 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes per post_type, with EAV baseline last for visual comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
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 = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_postmeta,flat probes 走專屬 group 表。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
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();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -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<object>}
|
||||
*/
|
||||
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 ) );
|
||||
@@ -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 += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> users/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(user 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>樣本</th><th>總時間 (ms)</th><th>平均 (ms)</th><th>QPS</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
Object.keys( labels ).forEach( ( key ) => {
|
||||
const v = q[ key ];
|
||||
if ( ! v ) {
|
||||
return;
|
||||
}
|
||||
const total = v.total_ms ?? v.duration_ms ?? '—';
|
||||
html += `<tr><td>${ esc( labels[ key ] ) }</td><td>${ v.n || 1 }</td>`;
|
||||
html += `<td><strong>${ total }</strong></td><td>${ v.avg_ms ?? '—' }</td><td>${ v.qps ?? '—' }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">原生 EAV baseline 與 flat table 範圍查詢的時間差,即代表此規模下反 EAV 帶來的查詢加速倍數。</p>';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
@@ -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 += '<h4 style="margin-bottom:6px;">▍ 寫入指標</h4>';
|
||||
html += '<table class="widefat" style="margin-bottom:14px;"><tbody>';
|
||||
html += `<tr><td>模式</td><td><code>${ esc( w.mode ) }</code></td></tr>`;
|
||||
html += `<tr><td>Taxonomy</td><td><code>${ esc( w.taxonomy ) }</code></td></tr>`;
|
||||
html += `<tr><td>完成 / 目標</td><td>${ fmt( w.processed ) } / ${ fmt( w.target ) }</td></tr>`;
|
||||
html += `<tr><td>總耗時</td><td>${ fmt( w.elapsed_sec ) } 秒</td></tr>`;
|
||||
html += `<tr><td>平均速率</td><td><strong>${ fmt( w.rate_per_sec ) }</strong> terms/sec</td></tr>`;
|
||||
html += `<tr><td>批次數</td><td>${ fmt( w.batches_done ) }</td></tr>`;
|
||||
html += `<tr><td>批次最快/平均/最慢</td><td>${ fmt( w.batch_min_ms ) } / ${ fmt( w.batch_avg_ms ) } / ${ fmt( w.batch_max_ms ) } ms</td></tr>`;
|
||||
html += `<tr><td>PHP Peak Memory</td><td>${ fmt( w.peak_memory_mb ) } MB</td></tr>`;
|
||||
html += '</tbody></table>';
|
||||
|
||||
// DB sizes
|
||||
html += '<h4 style="margin-bottom:6px;">▍ DB 容量(term 相關表)</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:14px;"><thead><tr>';
|
||||
html += '<th>Table</th><th>Rows</th><th>Data MB</th><th>Index MB</th><th>Total MB</th><th>Avg bytes/row</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
dbSizes.forEach( ( r ) => {
|
||||
html += `<tr><td><code>${ esc( r.table ) }</code></td><td>${ fmt( r.rows ) }</td>`;
|
||||
html += `<td>${ r.data_mb ?? '—' }</td><td>${ r.index_mb ?? '—' }</td>`;
|
||||
html += `<td><strong>${ r.total_mb ?? '—' }</strong></td><td>${ fmt( r.avg_bytes ) }</td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
|
||||
// Query perf — 3 probes with EAV baseline last for visual speedup comparison
|
||||
html += '<h4 style="margin-bottom:6px;">▍ 查詢效能</h4>';
|
||||
html += '<table class="widefat striped" style="margin-bottom:8px;"><thead><tr>';
|
||||
html += '<th>測試項目</th><th>耗時 (ms)</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
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 = ` <span style="color:#28a745;font-weight:600;">(${ ratio.toFixed( 2 ) }× faster)</span>`;
|
||||
}
|
||||
}
|
||||
html += `<tr><td>${ esc( label ) }${ speedup }</td><td><strong>${ v.duration_ms }</strong></td></tr>`;
|
||||
} );
|
||||
html += '</tbody></table>';
|
||||
html += '<p class="description">EAV baseline 走 wp_termmeta,flat probes 走 wpdo_term_hp_taxonomy。倍率即此規模下反 EAV 的查詢加速。</p>';
|
||||
|
||||
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();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} )();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: native postmeta count for dashboard widget
|
||||
/**
|
||||
* TMDO_Dashboard_Widget — wp-admin home dashboard widget (v2.4.0 M9).
|
||||
*
|
||||
* Adds a "WP Data Optimizer 健康狀態" widget on the WP admin dashboard for
|
||||
* users with `manage_options`. Surfaces:
|
||||
* - Today's health check status (green / yellow / red traffic light)
|
||||
* - Consecutive green days streak
|
||||
* - 3 quick stats:last snapshot age, largest zone table, oldest open conflict
|
||||
* - Quick links to Run Health Check, Entity Bridge, Create Snapshot
|
||||
*
|
||||
* Uses transient cache (5 minutes) to keep the dashboard responsive.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard widget — stateless static API.
|
||||
*/
|
||||
class TMDO_Dashboard_Widget {
|
||||
|
||||
public const WIDGET_ID = 'wpdo_health_widget';
|
||||
public const CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Register the dashboard widget hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wp_dashboard_setup', array( __CLASS__, 'add_widget' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the widget if user has manage_options.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add_widget(): void {
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
wp_add_dashboard_widget(
|
||||
self::WIDGET_ID,
|
||||
__( 'WP Data Optimizer 健康狀態', '2meet-data-optimizer' ),
|
||||
array( __CLASS__, 'render' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the widget HTML.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(): void {
|
||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer' );
|
||||
$run_health_url = wp_nonce_url( add_query_arg( array( 'wpdo_run_health' => '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'] );
|
||||
?>
|
||||
<style>
|
||||
.wpdo-widget-light { display: inline-block; width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; margin-right: 6px; }
|
||||
.wpdo-widget-light.good { background: #46b450; box-shadow: 0 0 8px rgba(70,180,80,0.5); }
|
||||
.wpdo-widget-light.warn { background: #dba617; box-shadow: 0 0 8px rgba(219,166,23,0.5); }
|
||||
.wpdo-widget-light.crit { background: #dc3232; box-shadow: 0 0 8px rgba(220,50,50,0.5); }
|
||||
.wpdo-widget-light.gray { background: #c3c4c7; }
|
||||
.wpdo-widget-stat { display: flex; justify-content: space-between; padding: 0.4em 0; border-bottom: 1px solid #f0f0f1; }
|
||||
.wpdo-widget-stat:last-child { border-bottom: none; }
|
||||
.wpdo-widget-actions { margin-top: 0.8em; padding-top: 0.8em; border-top: 1px solid #c3c4c7; }
|
||||
.wpdo-widget-actions a { margin-right: 0.5em; }
|
||||
</style>
|
||||
|
||||
<p style="font-size: 1.1em; margin-bottom: 0.6em;">
|
||||
<span class="wpdo-widget-light <?php echo esc_attr( $light['class'] ); ?>"></span>
|
||||
<strong><?php echo esc_html( $light['label'] ); ?></strong>
|
||||
<?php if ( $status['streak'] > 0 ) : ?>
|
||||
·
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: days */
|
||||
esc_html__( '連續綠 %d 天', '2meet-data-optimizer' ),
|
||||
(int) $status['streak']
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<?php if ( '' !== $status['summary_msg'] ) : ?>
|
||||
<p class="description" style="margin-bottom: 0.8em;"><?php echo esc_html( $status['summary_msg'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="wpdo-widget-stats">
|
||||
<div class="wpdo-widget-stat">
|
||||
<span><?php esc_html_e( '最近快照', '2meet-data-optimizer' ); ?></span>
|
||||
<span><strong><?php echo esc_html( $status['last_snapshot'] ); ?></strong></span>
|
||||
</div>
|
||||
<div class="wpdo-widget-stat">
|
||||
<span><?php esc_html_e( '最大 zone 表', '2meet-data-optimizer' ); ?></span>
|
||||
<span><strong><?php echo esc_html( $status['largest_zone'] ); ?></strong></span>
|
||||
</div>
|
||||
<div class="wpdo-widget-stat">
|
||||
<span><?php esc_html_e( '未處理衝突', '2meet-data-optimizer' ); ?></span>
|
||||
<span>
|
||||
<strong><?php echo esc_html( (string) (int) $status['conflicts'] ); ?></strong>
|
||||
<?php if ( (int) $status['conflicts'] > 0 ) : ?>
|
||||
<a href="<?php echo esc_url( add_query_arg( 'tab', 'conflicts', $page_url ) ); ?>"
|
||||
style="margin-left:0.5em;"><?php esc_html_e( '查看', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wpdo-widget-stat">
|
||||
<span><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></span>
|
||||
<span><strong><?php echo esc_html( $status['postmeta_human'] ); ?></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// v2.5.0 M16: actionable module suggestions count.
|
||||
$actionable_count = 0;
|
||||
if ( class_exists( 'TMDO_Module_Detector' ) ) {
|
||||
$cached = TMDO_Module_Detector::get_cached();
|
||||
if ( is_array( $cached ) && isset( $cached['results'] ) ) {
|
||||
foreach ( (array) $cached['results'] as $r ) {
|
||||
// v2.5.0 polish: align threshold with admin tab's get_actionable() default (0.5).
|
||||
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) && (float) ( $r['confidence'] ?? 0 ) >= 0.5 ) {
|
||||
++$actionable_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( $actionable_count > 0 ) :
|
||||
$ms_url = add_query_arg( 'tab', 'module-suggestions', $page_url );
|
||||
?>
|
||||
<p style="margin: 0.6em 0; padding: 0.5em 0.7em; background: #fff8e5; border-left: 3px solid #dba617; border-radius: 3px;">
|
||||
🤖
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: count */
|
||||
esc_html__( '偵測到 %d 個建議啟用的 module —', '2meet-data-optimizer' ),
|
||||
(int) $actionable_count
|
||||
);
|
||||
?>
|
||||
<a href="<?php echo esc_url( $ms_url ); ?>"><?php esc_html_e( '看建議', '2meet-data-optimizer' ); ?></a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// v2.8.1: surface user-meta migration residue → CTA to one-click wizard.
|
||||
$attn = class_exists( 'TMDO_Migration_Orchestrator' )
|
||||
? TMDO_Migration_Orchestrator::needs_attention()
|
||||
: array( 'needs' => false );
|
||||
|
||||
if ( ! empty( $attn['needs'] ) && 'running' !== ( $attn['job_state'] ?? '' ) ) :
|
||||
$wizard_url = add_query_arg( 'tab', 'migration-wizard', $page_url );
|
||||
?>
|
||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #fef0f0; border-left: 3px solid #dc3232; border-radius: 3px;">
|
||||
🔴
|
||||
<?php
|
||||
/* translators: 1: EAV row count, 2: group count, 3: ratio. */
|
||||
$msg = __( '偵測到 <strong>%1$s</strong> 行 wp_usermeta EAV 殘留橫跨 <strong>%2$s</strong> 個 entity group(當前 ratio 1:<strong>%3$s</strong>)—', '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'] )
|
||||
);
|
||||
?>
|
||||
<a href="<?php echo esc_url( $wizard_url ); ?>"><strong><?php esc_html_e( 'User 遷移精靈 →', '2meet-data-optimizer' ); ?></strong></a>
|
||||
</p>
|
||||
<?php elseif ( 'running' === ( $attn['job_state'] ?? '' ) ) : ?>
|
||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #e5f5fa; border-left: 3px solid #00a0d2; border-radius: 3px;">
|
||||
⏳ <?php esc_html_e( 'User 遷移精靈正在執行中 —', '2meet-data-optimizer' ); ?>
|
||||
<a href="<?php echo esc_url( add_query_arg( 'tab', 'migration-wizard', $page_url ) ); ?>"><?php esc_html_e( '查看進度', '2meet-data-optimizer' ); ?></a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// v2.9.0 Phase 0: wp_postmeta garbage CTA (transients/_wp_old_date/stale _edit_lock).
|
||||
$gc = class_exists( 'TMDO_Postmeta_Cleaner' )
|
||||
? TMDO_Postmeta_Cleaner::count_garbage( TMDO_Postmeta_Cleaner::TARGET_ALL )
|
||||
: array( 'total' => 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'] )
|
||||
);
|
||||
?>
|
||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: #f6f7f7; border-left: 3px solid #2271b1; border-radius: 3px;">
|
||||
🧹
|
||||
<?php
|
||||
/* translators: 1: total rows, 2: transients, 3: wp_old_date, 4: edit_locks */
|
||||
$gc_msg = __( 'wp_postmeta 偵測到 <strong>%1$s</strong> 行可清理垃圾(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'] ) )
|
||||
);
|
||||
?>
|
||||
<a href="<?php echo esc_url( $cleanup_url ); ?>"
|
||||
onclick="return confirm(<?php echo wp_json_encode( $confirm_msg ); ?>);">
|
||||
<strong><?php esc_html_e( '一鍵清理 →', '2meet-data-optimizer' ); ?></strong>
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// v2.9.4: Post Entity diagnostics (independent of user-side needs_attention()).
|
||||
// Shows wp_posts:wp_postmeta ratio, post mode, and total EAV residue
|
||||
// across all 7 entity groups. Read-only summary; full wizard ships v2.9.5.
|
||||
$post_diag = class_exists( 'TMDO_Post_Migration' ) ? TMDO_Post_Migration::diagnose() : null;
|
||||
if ( null !== $post_diag && $post_diag['posts'] > 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:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>,mode=<strong>%3$s</strong>)—', '2meet-data-optimizer' ),
|
||||
'%1$s',
|
||||
'%2$s',
|
||||
'%3$s'
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: 1: ratio */
|
||||
__( 'Post Entity:無 EAV 殘留(ratio 1:<strong>%1$s</strong>,mode=<strong>%2$s</strong>)', '2meet-data-optimizer' ),
|
||||
'%1$s',
|
||||
'%2$s'
|
||||
);
|
||||
?>
|
||||
<p style="margin: 0.6em 0; padding: 0.6em 0.8em; background: <?php echo esc_attr( $post_bg ); ?>; border-left: 3px solid <?php echo esc_attr( $post_color ); ?>; border-radius: 3px;">
|
||||
📦
|
||||
<?php
|
||||
if ( $total_eav > 0 ) {
|
||||
printf(
|
||||
wp_kses(
|
||||
/* translators: 1: total EAV rows, 2: ratio, 3: mode */
|
||||
__( 'Post Entity:偵測到 <strong>%1$s</strong> 行 wp_postmeta EAV 殘留(當前 ratio 1:<strong>%2$s</strong>,mode=<strong>%3$s</strong>)— 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:<strong>%1$s</strong>,mode=<strong>%2$s</strong>)', '2meet-data-optimizer' ),
|
||||
array( 'strong' => array() )
|
||||
),
|
||||
esc_html( (string) $post_diag['ratio'] ),
|
||||
esc_html( $post_diag['mode'] )
|
||||
);
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="wpdo-widget-actions">
|
||||
<a class="button button-small button-primary" href="<?php echo esc_url( $run_health_url ); ?>">
|
||||
<?php esc_html_e( '跑健康檢查', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<a class="button button-small" href="<?php echo esc_url( $bridge_url ); ?>">
|
||||
<?php esc_html_e( 'Entity Bridge', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<a class="button button-small" href="<?php echo esc_url( $create_snap_url ); ?>">
|
||||
<?php esc_html_e( '建立快照', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
<a href="<?php echo esc_url( $page_url ); ?>" style="float: right; padding-top: 4px;">
|
||||
<?php esc_html_e( '完整儀表板 →', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── private ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute widget status. Cached via transient.
|
||||
*
|
||||
* @return array {level:string, summary_msg:string, streak:int,
|
||||
* last_snapshot:string, largest_zone:string,
|
||||
* conflicts:int, postmeta_human:string}
|
||||
*/
|
||||
private static function compute_status(): array {
|
||||
$cached = get_transient( 'wpdo_dashboard_widget_status' );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
global $wpdb;
|
||||
|
||||
// Health level from last cron run, falling back to "no data".
|
||||
$last = class_exists( 'TMDO_Health_Cron' ) ? TMDO_Health_Cron::get_last_run() : null;
|
||||
$level = 'unknown';
|
||||
$msg = '';
|
||||
$streak = 0;
|
||||
if ( is_array( $last ) ) {
|
||||
$crit = (int) ( $last['critical_count'] ?? 0 );
|
||||
$rec = (int) ( $last['recommended_count'] ?? 0 );
|
||||
if ( $crit > 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Export — Report export endpoints (v2.5.0 M14).
|
||||
*
|
||||
* Three export types:
|
||||
* 1. health — wp_wpdo_audit op='health_check_daily' over last N days
|
||||
* 2. snapshots — wp_wpdo_snapshots metadata (no inline_blob payload)
|
||||
* 3. monthly — wpdo_monthly_summary_history (latest + up to 12 archives)
|
||||
*
|
||||
* Triggered via admin GET:tools.php?page=wp-data-optimizer&wpdo_export=health&format=csv&days=30
|
||||
* Capability: manage_options + nonce.
|
||||
*
|
||||
* Filenames:wpdo-{type}-{site_slug}-{YYYYMMDD}.{csv|json}
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless static export handler.
|
||||
*/
|
||||
class TMDO_Export {
|
||||
|
||||
public const NONCE = 'wpdo_export';
|
||||
|
||||
/**
|
||||
* Hook into admin_init to handle export requests.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_init', array( __CLASS__, 'maybe_handle' ), 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect + serve an export request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_handle(): void {
|
||||
if ( ! isset( $_GET['wpdo_export'] ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
if ( ! check_admin_referer( self::NONCE ) ) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key( wp_unslash( (string) $_GET['wpdo_export'] ) );
|
||||
$format = sanitize_key( wp_unslash( (string) ( $_GET['format'] ?? 'csv' ) ) );
|
||||
$days = isset( $_GET['days'] ) ? max( 1, min( 365, (int) $_GET['days'] ) ) : 30;
|
||||
if ( ! in_array( $format, array( 'csv', 'json' ), true ) ) {
|
||||
$format = 'csv';
|
||||
}
|
||||
switch ( $type ) {
|
||||
case 'health':
|
||||
self::send_health( $format, $days );
|
||||
break;
|
||||
case 'snapshots':
|
||||
self::send_snapshots( $format );
|
||||
break;
|
||||
case 'monthly':
|
||||
self::send_monthly( $format );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a download URL with nonce.
|
||||
*
|
||||
* @param string $type health|snapshots|monthly.
|
||||
* @param string $format csv|json.
|
||||
* @param int $days For health only.
|
||||
* @return string
|
||||
*/
|
||||
public static function url( string $type, string $format = 'csv', int $days = 30 ): string {
|
||||
$args = array(
|
||||
'page' => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Help_Tabs — Contextual help tabs (v2.3.0 M8).
|
||||
*
|
||||
* Registers `add_help_tab()` content on the WPDO admin page. Each tab gets a
|
||||
* dedicated help panel that explains:
|
||||
* - what this tab is for
|
||||
* - what to do here as a first-time user
|
||||
* - links to Entity Bridge, Snapshots, Doctor when relevant
|
||||
*
|
||||
* Hooked on `load-tools_page_wp-data-optimizer` so help tabs only appear on
|
||||
* our admin page (not site-wide).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Help tab registrar — stateless static API.
|
||||
*/
|
||||
class TMDO_Help_Tabs {
|
||||
|
||||
/**
|
||||
* Hook into admin page load.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'load-tools_page_wp-data-optimizer', array( __CLASS__, 'add_tabs' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register help tabs based on current `tab` GET param.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add_tabs(): void {
|
||||
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||
if ( null === $screen ) {
|
||||
return;
|
||||
}
|
||||
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
// Always-on overview tab.
|
||||
$screen->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(
|
||||
'<p><strong>' . esc_html__( '更多資源', '2meet-data-optimizer' ) . '</strong></p>'
|
||||
. '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ) . '">' . esc_html__( 'Entity Bridge — 主維運入口', '2meet-data-optimizer' ) . '</a></p>'
|
||||
. '<p><a href="' . esc_url( admin_url( 'site-health.php' ) ) . '">' . esc_html__( 'WP Site Health', '2meet-data-optimizer' ) . '</a></p>'
|
||||
. '<p><code>wp wpdo doctor</code><br/><code>wp wpdo mode-audit</code><br/><code>wp wpdo snapshot create</code></p>'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── content templates ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Overview help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_overview(): string {
|
||||
return '<p>' . esc_html__( 'WP Data Optimizer 是反 EAV(meta 爆炸)的解方。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '核心概念:把 wp_postmeta 的高頻欄位(Hot)、TTL 暫存(Warm)、低頻欄位(Cold)、歷史資料(Archive)拆到 4 種專用表,讀寫快很多、autoload 不再爆。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p><strong>' . esc_html__( '建議第一步:', '2meet-data-optimizer' ) . '</strong> ' . esc_html__( '逛一遍儀表板了解現況 → 看 Entity Bridge tab 各 entity 健康卡片 → 從 1 個 entity 開始用 Migration Wizard 漸進升級 mode。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_dashboard(): string {
|
||||
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Archive 統計 — 壓縮率、依 post_type 拆分', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'REST API rate limit — 429 事件 + Top 10 受限 post', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_zones(): string {
|
||||
return '<p>' . esc_html__( '4 個 Zone 對應不同存取頻率與保留需求:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li><strong>Hot</strong> — ' . esc_html__( '高頻索引欄位,如 listing 的 price / location。獨立 column + index,WP_Query 可 JOIN。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Warm</strong> — ' . esc_html__( 'TTL 暫存(如 view count、cache stats)。固定表 wp_wpdo_warm 含 expires_at。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Cold</strong> — ' . esc_html__( '低頻 meta(settings / preferences)。讀寫透過 interceptor 攔截後保持 EAV 形式。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Archive</strong> — ' . esc_html__( 'Trashed / 90+ 天舊資料。可 gzip 壓縮。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p>' . esc_html__( '不確定要哪種 → 用 Classifier,它會看 access pattern 給建議。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifier help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_classifier(): string {
|
||||
return '<p>' . esc_html__( 'Classifier 分析 wp_postmeta 給每個 meta_key 一個 zone 建議:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li><strong>Confidence</strong> — ' . esc_html__( '0.0~1.0,越高代表分類越確定。≥ 0.8 可放心採納,< 0.5 建議 manual review。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Reasons</strong> — ' . esc_html__( '說明為什麼建議這個 zone(access frequency / row count / TTL hints)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>Already-assigned</strong> — ' . esc_html__( '已透過 Schema_Registry 註冊的 meta_key 數量。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_snapshots(): string {
|
||||
return '<p>' . esc_html__( '快照保留政策(v2.2.0):', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( '預設 30 天 TTL,可在 wp wpdo snapshot create 時用 --retention-days 覆蓋。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'pre_uninstall / pre_v2_upgrade triggers 受 size-cap 保護(不會被自動 evict)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '檔案存於 wp-content/uploads/wpdo-backups/,含 .htaccess deny all + 每個檔 sha256 校驗。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '小於 5MB 自動 inline 到 wp_wpdo_snapshots.inline_blob,方便 wp db export 時跟著走。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p><strong>' . esc_html__( '災難還原 drill', '2meet-data-optimizer' ) . '</strong>:'
|
||||
. esc_html__( '建議每月做一次 dry-run 還原驗證 — wp wpdo snapshot restore <id>(不加 --apply)即可預覽會還原什麼。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflicts help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_conflicts(): string {
|
||||
return '<p>' . esc_html__( 'Hook 衝突偵測:當多個 plugin 在同一 WordPress 的 metadata filter 上掛 callback 時,可能造成資料寫入順序不確定 / 重複處理。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_doctor(): string {
|
||||
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ol style="padding-left: 1.5em;">'
|
||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>hook_conflicts</strong> — ' . esc_html__( '同上 conflicts tab', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>autoload_bloat</strong> — ' . esc_html__( 'wp_options autoload 大小 > 5MB 警告', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ol>'
|
||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_logs(): string {
|
||||
return '<p>' . esc_html__( '日誌讀取:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul style="list-style: disc; padding-left: 1.5em;">'
|
||||
. '<li>' . esc_html__( '每筆對應 wp_wpdo_errors 一行:module / zone / hook / message / timestamp。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '預設保留 90 天(wpdo_errors_gc daily cron 自動清)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( '看 message 開頭 [WARN] 是 warning level(不影響運作但需注意)。', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>';
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API help content.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function content_rest_api(): string {
|
||||
return '<p>' . esc_html__( 'REST API 提供 zone 操作 + diagnostics endpoints。', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<p>' . esc_html__( '所有 endpoint 用 X-WP-Nonce 認證;rate limit 預設 30/min。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: postmeta inventory for setup wizard
|
||||
/**
|
||||
* TMDO_Setup_Wizard — First-run onboarding wizard (v2.3.0 M5).
|
||||
*
|
||||
* 5 steps:
|
||||
* 1. Welcome + anti-EAV concept (4-zone diagram, when needed, when not).
|
||||
* 2. Baseline diagnostic — auto-runs Site Health, postmeta count, top
|
||||
* meta_key consumers, predicted optimization upside.
|
||||
* 3. Recommendation — checks Classifier suggestions, presents pre-checked
|
||||
* modules with "OK" / "skip" choice (no auto-enable; explicit consent).
|
||||
* 4. First snapshot + daily health check (already scheduled by Core, just
|
||||
* confirms here).
|
||||
* 5. Done — sets wpdo_setup_wizard_completed=1, wpdo_first_run_at timestamp.
|
||||
*
|
||||
* Trigger: admin loads any wp-admin page AND wpdo_setup_wizard_completed != '1'.
|
||||
* Existing-user shielding: if any module is not idle (= already migrating),
|
||||
* we mark wizard completed immediately to avoid ambushing experienced admins.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup wizard — stateless static API.
|
||||
*/
|
||||
class TMDO_Setup_Wizard {
|
||||
|
||||
public const OPT_COMPLETED = 'wpdo_setup_wizard_completed';
|
||||
public const OPT_FIRST_RUN = 'wpdo_first_run_at';
|
||||
public const QUERY_PARAM = 'wpdo_wizard';
|
||||
public const NONCE_NAME = 'wpdo_wizard_nonce';
|
||||
public const TOTAL_STEPS = 5;
|
||||
|
||||
/**
|
||||
* Bootstrap hooks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_init', array( __CLASS__, 'maybe_redirect_to_wizard' ) );
|
||||
add_action( 'admin_menu', array( __CLASS__, 'register_menu_page' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_step', array( __CLASS__, 'handle_step_submit' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_dismiss', array( __CLASS__, 'handle_dismiss' ) );
|
||||
add_action( 'admin_post_wpdo_wizard_reset', array( __CLASS__, 'handle_reset' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* On first activation, decide: run wizard, or shield (already configured).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_redirect_to_wizard(): void {
|
||||
// Don't redirect on AJAX / cron / auto-saves / wp-cli.
|
||||
if ( wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
if ( '1' === get_option( self::OPT_COMPLETED ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shield existing users: if any module is not idle, the admin already knows
|
||||
// what they're doing. Mark wizard completed silently.
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
$flags = TMDO_Feature_Flags::all();
|
||||
foreach ( $flags as $state ) {
|
||||
if ( 'idle' !== $state ) {
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't redirect if already on the wizard page or another WPDO admin page.
|
||||
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( in_array( $page, array( 'wpdo-setup-wizard', '2meet-data-optimizer' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only redirect from the WordPress dashboard top page (avoid plugin install + mu-plugins flow).
|
||||
global $pagenow;
|
||||
if ( 'index.php' !== $pagenow ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the wizard as a visible submenu item under Tools > 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 );
|
||||
?>
|
||||
<div class="wrap" style="max-width: 800px;">
|
||||
<p>
|
||||
<a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||
← <?php esc_html_e( '返回 WP Data Optimizer', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<h1><?php esc_html_e( 'WP Data Optimizer — 設定嚮導', '2meet-data-optimizer' ); ?></h1>
|
||||
|
||||
<?php if ( $completed ) : ?>
|
||||
<div style="background:#f0f6fc; border-left:4px solid #2271b1; padding:0.75em 1em; margin-bottom:1em; display:flex; align-items:center; gap:1em;">
|
||||
<span>✅ <?php esc_html_e( '嚮導已完成。你可以重新瀏覽任何步驟,或重設為全新執行。', '2meet-data-optimizer' ); ?></span>
|
||||
<a class="button button-secondary"
|
||||
href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_reset' ), self::NONCE_NAME ) ); ?>"
|
||||
style="white-space:nowrap;">
|
||||
<?php esc_html_e( '重新執行精靈', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '第', '2meet-data-optimizer' ); ?>
|
||||
<strong><?php echo (int) $step; ?></strong> /
|
||||
<strong><?php echo (int) self::TOTAL_STEPS; ?></strong>
|
||||
<?php esc_html_e( '步', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<div style="background: #f0f0f1; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 2em;">
|
||||
<div style="background: #2271b1; height: 100%; width: <?php echo (int) ( $step / self::TOTAL_STEPS * 100 ); ?>%; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: none; padding: 2em;">
|
||||
<?php call_user_func( array( __CLASS__, 'render_step_' . $step ) ); ?>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 2em;">
|
||||
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpdo_wizard_dismiss' ), self::NONCE_NAME ) ); ?>"
|
||||
style="color: #888; font-size: 0.9em;">
|
||||
<?php esc_html_e( '我熟悉了,跳過嚮導', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── steps ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render wizard step 1: Welcome.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_1(): void {
|
||||
?>
|
||||
<h2><?php esc_html_e( '👋 歡迎使用 WP Data Optimizer', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( 'WPDO 解決的是 wp_postmeta 表「meta 爆炸」問題:當 postmeta 累積到數百萬行時,autoload 變大、JOIN 變慢、整站變慢。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<h3><?php esc_html_e( '4 個 Zone 是什麼?', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat" style="margin-bottom: 1em;">
|
||||
<thead>
|
||||
<tr><th>Zone</th><th><?php esc_html_e( '用途', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '舉例', '2meet-data-optimizer' ); ?></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Hot</strong></td><td><?php esc_html_e( '高頻 + 索引欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'price, location, vendor_id', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Warm</strong></td><td><?php esc_html_e( 'TTL 暫存(會過期)', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'view_count, last_seen', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Cold</strong></td><td><?php esc_html_e( '低頻設定型欄位', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( 'preferences, settings', '2meet-data-optimizer' ); ?></td></tr>
|
||||
<tr><td><strong>Archive</strong></td><td><?php esc_html_e( '舊資料 / 已 trashed', '2meet-data-optimizer' ); ?></td><td><?php esc_html_e( '90+ 天前的記錄', '2meet-data-optimizer' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3><?php esc_html_e( '何時需要 WPDO?', '2meet-data-optimizer' ); ?></h3>
|
||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
||||
<li><?php esc_html_e( '✅ wp_postmeta 行數 > 100k 開始考慮', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '✅ 行數 > 1M 強烈建議啟用', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '⚠️ 小站台(< 10k 行)可以裝著但別啟用 module', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="2">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:跑健診 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 2: Baseline diagnostic.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_2(): void {
|
||||
global $wpdb;
|
||||
$pm_count = (int) $wpdb->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
|
||||
?>
|
||||
<h2><?php esc_html_e( '🔬 Baseline 健診', '2meet-data-optimizer' ); ?></h2>
|
||||
<table class="widefat striped" style="max-width: 600px; margin-bottom: 1em;">
|
||||
<tbody>
|
||||
<tr><th><?php esc_html_e( 'wp_postmeta 行數', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( number_format_i18n( $pm_count ) ); ?></strong></td></tr>
|
||||
<tr><th><?php esc_html_e( 'autoload 大小', '2meet-data-optimizer' ); ?></th><td><strong><?php echo esc_html( size_format( $autoload_bytes, 1 ) ); ?></strong></td></tr>
|
||||
<tr><th><?php esc_html_e( '建議啟用 WPDO?', '2meet-data-optimizer' ); ?></th>
|
||||
<td>
|
||||
<?php if ( $pm_count > 1_000_000 ) : ?>
|
||||
<span style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '強烈建議', '2meet-data-optimizer' ); ?></span>
|
||||
<?php elseif ( $pm_count > 100_000 ) : ?>
|
||||
<span style="color: #dba617; font-weight: bold;">🟡 <?php esc_html_e( '可以考慮', '2meet-data-optimizer' ); ?></span>
|
||||
<?php else : ?>
|
||||
<span style="color: #8c8f94;">⏸ <?php esc_html_e( '尚不必', '2meet-data-optimizer' ); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3><?php esc_html_e( 'Top 10 meta_key(依行數)', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<thead><tr><th>meta_key</th><th><?php esc_html_e( '行數', '2meet-data-optimizer' ); ?></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ( (array) $top_keys as $k ) : ?>
|
||||
<tr><td><code><?php echo esc_html( (string) $k['meta_key'] ); ?></code></td><td><?php echo esc_html( number_format_i18n( (int) $k['c'] ) ); ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="3">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:看建議 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 3: Recommendations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_3(): void {
|
||||
?>
|
||||
<h2><?php esc_html_e( '🧭 智能推薦', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( '系統依你的環境(plugin / post_type / 行數)自動偵測哪些 module 適合啟用。每筆建議含 confidence + reasons。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<?php
|
||||
$actionable = array();
|
||||
if ( class_exists( 'TMDO_Module_Detector' ) ) {
|
||||
$actionable = TMDO_Module_Detector::get_actionable( 0.5 );
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ( empty( $actionable ) ) : ?>
|
||||
<div class="notice notice-info inline" style="padding: 1em;">
|
||||
<p><?php esc_html_e( '目前環境暫無高 confidence 的 module 建議。可隨時前往「模組建議」tab 重新檢查。', '2meet-data-optimizer' ); ?></p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h3>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: count */
|
||||
esc_html__( '✅ 偵測到 %d 個建議啟用的 module', '2meet-data-optimizer' ),
|
||||
count( $actionable )
|
||||
);
|
||||
?>
|
||||
</h3>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Module', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Confidence', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( '說明', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $actionable as $module => $r ) : ?>
|
||||
<tr>
|
||||
<td><strong><code><?php echo esc_html( (string) $module ); ?></code></strong></td>
|
||||
<td><?php echo esc_html( sprintf( '%.2f', (float) $r['confidence'] ) ); ?></td>
|
||||
<td>
|
||||
<em><?php echo esc_html( (string) $r['description'] ); ?></em><br/>
|
||||
<small><?php echo esc_html( implode( ' · ', (array) $r['reasons'] ) ); ?></small>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="description"><?php esc_html_e( '完成 wizard 後,到「模組建議」tab 一鍵啟用(推進到 dual_write,FSM Guard 確保不越級)。', '2meet-data-optimizer' ); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e( '黃金法則', '2meet-data-optimizer' ); ?></h3>
|
||||
<ul style="list-style: disc; padding-left: 1.5em;">
|
||||
<li><?php esc_html_e( '一次只推進 1 個 module,每階段觀察至少 24-48 小時。', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '進 cutover 前一定要有 snapshot(FSM Guard 自動觸發)。', '2meet-data-optimizer' ); ?></li>
|
||||
<li><?php esc_html_e( '出事第一件事:rewind 該 module 到 idle(emergency 流程)。', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="4">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:建第一個 snapshot →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 4: Snapshot + health check.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_4(): void {
|
||||
// On entry, take the first snapshot if not already done in this wizard run.
|
||||
$snapshot_taken = isset( $_GET['snap_done'] ) ? '1' === $_GET['snap_done'] : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
?>
|
||||
<h2><?php esc_html_e( '📸 第一個 Snapshot + Daily Health Check', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<h3><?php esc_html_e( '📦 Snapshot', '2meet-data-optimizer' ); ?></h3>
|
||||
<?php if ( $snapshot_taken ) : ?>
|
||||
<p style="color: #46b450; font-weight: bold;">✅ <?php esc_html_e( '快照已建立。可隨時於「備份快照」tab 查看與管理。', '2meet-data-optimizer' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( '我們會建立一個 baseline snapshot,命名為 wizard_baseline,保留 365 天。即使你日後沒做任何 destructive 操作,這也是一個 known-good 還原點。', '2meet-data-optimizer' ); ?></p>
|
||||
<p>
|
||||
<a class="button button-secondary"
|
||||
href="<?php echo esc_url( wp_nonce_url( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&action=take_snapshot' ), self::NONCE_NAME ) ); ?>">
|
||||
<?php esc_html_e( '建立 baseline snapshot', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
// Take snapshot if action requested.
|
||||
if ( isset( $_GET['action'] ) && 'take_snapshot' === $_GET['action'] && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
$result = TMDO_Snapshot_Manager::create(
|
||||
'manual',
|
||||
array(),
|
||||
array(
|
||||
'notes' => 'wizard_baseline',
|
||||
'retention_days' => 365,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $result['ok'] ) ) {
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
|
||||
exit;
|
||||
}
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
|
||||
}
|
||||
?>
|
||||
<?php endif; ?>
|
||||
|
||||
<h3><?php esc_html_e( '🔄 Daily Health Check', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php
|
||||
$next = wp_next_scheduled( 'wpdo_daily_health_check' );
|
||||
if ( $next ) {
|
||||
printf(
|
||||
/* translators: %s: human-readable timestamp */
|
||||
esc_html__( 'Daily cron 已排程,下次執行:%s(UTC)', '2meet-data-optimizer' ),
|
||||
'<code>' . esc_html( gmdate( 'Y-m-d H:i:s', $next ) ) . '</code>'
|
||||
);
|
||||
} else {
|
||||
esc_html_e( '⚠️ Daily cron 未排程;請重新啟用外掛。', '2meet-data-optimizer' );
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<p class="description"><?php esc_html_e( 'Cron 會跑 7 項 Site Health 檢查;critical 警告寫入 admin notice + audit log。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<form method="get" action="<?php echo esc_url( admin_url( 'tools.php' ) ); ?>" style="margin-top: 2em;">
|
||||
<input type="hidden" name="page" value="wpdo-setup-wizard">
|
||||
<input type="hidden" name="step" value="5">
|
||||
<button class="button button-primary button-hero"><?php esc_html_e( '下一步:完成 →', '2meet-data-optimizer' ); ?></button>
|
||||
</form>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render wizard step 5: Done.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function render_step_5(): void {
|
||||
// Mark complete on render of step 5.
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
if ( ! get_option( self::OPT_FIRST_RUN ) ) {
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
}
|
||||
?>
|
||||
<h2><?php esc_html_e( '🎉 完成!', '2meet-data-optimizer' ); ?></h2>
|
||||
<p><?php esc_html_e( 'Setup wizard 已結束。下一步建議:', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<ul style="list-style: disc; padding-left: 1.5em; line-height: 1.8;">
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=entity-bridge' ) ); ?>"><?php esc_html_e( '🌉 看 Entity Bridge 健康卡片', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( 'user / post / term / comment 4 entity 即時健康+模式狀態', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=migration-wizard' ) ); ?>"><?php esc_html_e( '🪄 開 User / Post 遷移精靈', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '一鍵推進 disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=classifier' ) ); ?>"><?php esc_html_e( '🔍 跑 Classifier', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '看推薦 zone 配置', '2meet-data-optimizer' ); ?></li>
|
||||
<li><a href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>"><?php esc_html_e( '🏥 WP Site Health', '2meet-data-optimizer' ); ?></a> — <?php esc_html_e( '7 個 WPDO 健康檢查', '2meet-data-optimizer' ); ?></li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: 2em;">
|
||||
<a class="button button-primary button-hero" href="<?php echo esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ); ?>">
|
||||
<?php esc_html_e( '前往 WPDO 儀表板', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ─── form / dismiss handlers ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle wizard step form submit.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_step_submit(): void {
|
||||
// v2.13.3: capability check first (matches handle_dismiss / handle_reset
|
||||
// pattern; fixes L-AUTH-1). Reserved for future POST-based steps —
|
||||
// currently steps use GET nav so body is a no-op redirect.
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
wp_die( esc_html__( 'Insufficient permissions.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard dismiss action.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_dismiss(): void {
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
if ( TMDO_Capability::current_user_can_admin() ) {
|
||||
update_option( self::OPT_COMPLETED, '1', false );
|
||||
update_option( self::OPT_FIRST_RUN, gmdate( 'Y-m-d H:i:s' ), false );
|
||||
}
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wp-data-optimizer' ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wizard reset — clears OPT_COMPLETED so wizard reruns from step 1.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_reset(): void {
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
if ( TMDO_Capability::current_user_can_admin() ) {
|
||||
update_option( self::OPT_COMPLETED, '0', false );
|
||||
}
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=1' ) );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||
/**
|
||||
* Comment Stress Test template (v2.13.1).
|
||||
*
|
||||
* Full async + polling UI mirroring Term / Post / User Stress Test:
|
||||
* 1. 設定並啟動測試(form: post_id / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Variables in scope from render_comment_stress_test():
|
||||
* $state — TMDO_Comment_Stress_Tester::get_progress(false) output
|
||||
* $test_comment_count — int, comments matching @wpdo-stress.local email
|
||||
* $posts — array<post_id, label> 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}" );
|
||||
?>
|
||||
|
||||
<div class="wpdo-comment-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Comment Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 comment 並 seed 對應 hp_review 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 comment,評估 comment entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 comment 均以 @wpdo-stress.local 為 email 後綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Comment Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'wp_comments', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_comments ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'wp_commentmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_commentmeta ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) ( $total_comments > 0 ? round( $total_commentmeta / $total_comments, 2 ) : 0 ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>wpdo_comment_hp_review</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_review ) ); ?></strong> rows</td>
|
||||
<td><code>wpdo_comment_misc</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 comment 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-cst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Comment Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Comment Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_commentmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: open code, 2: close code */
|
||||
esc_html__( 'Comment mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Comment entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_comment + Hook Bus,會被攔截短路 wp_commentmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-cst-post-id"><?php esc_html_e( '目標 Post', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-cst-post-id" class="regular-text">
|
||||
<?php if ( empty( $posts ) ) : ?>
|
||||
<option value=""><?php esc_html_e( '— 沒有可用的 publish post —', '2meet-data-optimizer' ); ?></option>
|
||||
<?php else : ?>
|
||||
<?php foreach ( $posts as $pid => $label ) : ?>
|
||||
<option value="<?php echo esc_attr( (string) $pid ); ?>">
|
||||
<?php echo esc_html( $label ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '所有測試 comment 會 attach 到此 post。建議用 hp_listing post — seed 後 hp_rating 走 hp_review 群組。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-cst-target"><?php esc_html_e( '要建立的 comment 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-cst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-cst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-cst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_comment + update_comment_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-cst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-cst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000;Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-cst-start" <?php disabled( $is_running || empty( $posts ) ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-cst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test comment 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-cst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_comment_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 email 後綴 @wpdo-stress.local 的 comment,連同 wp_commentmeta + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-cst-cleanup" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 comment', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-cst-rerun-bench" <?php disabled( $is_running || 0 === $test_comment_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-cst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-cst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||
· <code id="wpdo-cst-pg-post-id">post #<?php echo esc_html( (string) ( $state['post_id'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-cst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-cst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-cst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-cst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-cst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> comments/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-cst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-cst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-cst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
/**
|
||||
* One-click User Entity Migration Wizard.
|
||||
*
|
||||
* Variables in scope (from class-tmdo-admin.php::render_migration_wizard):
|
||||
* $preflight array TMDO_Migration_Orchestrator::preflight() snapshot.
|
||||
* $status array TMDO_Migration_Orchestrator::get_status() snapshot.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.8.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$ratio = $preflight['ratio'] ?? 0;
|
||||
$users_count = $preflight['users'] ?? 0;
|
||||
$usermeta_count = $preflight['usermeta'] ?? 0;
|
||||
$eav_residue = $preflight['eav_rows'] ?? 0;
|
||||
$current_mode = $preflight['mode'] ?? 'unknown';
|
||||
$strategy = $preflight['estimated_strategy'] ?? 'sync';
|
||||
$est_sec = $preflight['estimated_sec'] ?? 0;
|
||||
$groups = $preflight['groups'] ?? array();
|
||||
$state = $status['state'] ?? 'idle';
|
||||
$active = in_array( $state, array( 'running', 'paused' ), true );
|
||||
$completed = 'completed' === $state;
|
||||
$failed = 'failed' === $state;
|
||||
?>
|
||||
<div class="wpdo-migration-wizard" data-state="<?php echo esc_attr( $state ); ?>">
|
||||
|
||||
<header class="wpdo-mw-header">
|
||||
<h2><?php esc_html_e( 'User Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '把 wp_usermeta 中已註冊為 entity field 的 legacy keys 一次遷移到 flat tables。流程包含自動備份、demote、bulk SQL backfill、shadow_read 驗證、aeav_only cutover、以及最終的 EAV 殘留清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Pre-flight diagnostic panel ─────────────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-preflight" id="wpdo-mw-preflight">
|
||||
<h3><?php esc_html_e( '當前狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-metrics">
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_users', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-users"><?php echo esc_html( number_format_i18n( $users_count ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'wp_usermeta', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-usermeta"><?php echo esc_html( number_format_i18n( $usermeta_count ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric wpdo-mw-metric-primary">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( '當前 ratio', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-ratio">1:<?php echo esc_html( (string) $ratio ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value" id="wpdo-mw-residue"><?php echo esc_html( number_format_i18n( $eav_residue ) ); ?></span>
|
||||
</div>
|
||||
<div class="wpdo-mw-metric">
|
||||
<span class="wpdo-mw-metric-label"><?php esc_html_e( 'user mode', '2meet-data-optimizer' ); ?></span>
|
||||
<span class="wpdo-mw-metric-value wpdo-mw-mode wpdo-mw-mode-<?php echo esc_attr( $current_mode ); ?>"><?php echo esc_html( $current_mode ); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="wpdo-mw-groups">
|
||||
<summary><?php esc_html_e( '各 entity group 詳細狀態', '2meet-data-optimizer' ); ?></summary>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'EAV 殘留', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Backfill 策略', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $groups as $name => $g ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $name ); ?></code></td>
|
||||
<td><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
|
||||
<td><?php echo esc_html( (string) $g['flat_rows'] ); ?></td>
|
||||
<td>
|
||||
<?php if ( $g['residue'] > 0 ) : ?>
|
||||
<strong><?php echo esc_html( (string) $g['residue'] ); ?></strong>
|
||||
<?php else : ?>
|
||||
<span class="wpdo-mw-zero">0</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><code><?php echo esc_html( $g['strategy'] ); ?></code></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
||||
<?php echo $active || $completed ? 'hidden' : ''; ?>>
|
||||
|
||||
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
||||
<div class="wpdo-mw-nothing-todo">
|
||||
<h3>✅ <?php esc_html_e( '無事可做', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( '所有 entity group 已 aeav_only / 0 EAV 殘留。', '2meet-data-optimizer' ); ?></p>
|
||||
<p>
|
||||
<?php
|
||||
echo esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: ratio */
|
||||
__( '當前 ratio:1:%s', '2meet-data-optimizer' ),
|
||||
(string) $ratio
|
||||
)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<h3><?php esc_html_e( '執行一鍵遷移', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-estimate">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: rows count, 2: strategy, 3: estimated seconds */
|
||||
esc_html__( '將遷移 %1$s 行 EAV 殘留,預估策略:%2$s(約 %3$s 秒)。', '2meet-data-optimizer' ),
|
||||
'<strong>' . esc_html( number_format_i18n( $eav_residue ) ) . '</strong>',
|
||||
'<code>' . esc_html( $strategy ) . '</code>',
|
||||
'<strong>' . esc_html( (string) $est_sec ) . '</strong>'
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<fieldset class="wpdo-mw-options">
|
||||
<legend><?php esc_html_e( '進階選項', '2meet-data-optimizer' ); ?></legend>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-backup" checked>
|
||||
<?php esc_html_e( '自動備份 wp_usermeta 至 uploads/wpdo-backups/(推薦)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-strict" checked>
|
||||
<?php esc_html_e( '嚴格驗證(500 users / 10% 抽樣)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-24h">
|
||||
<?php esc_html_e( '24 小時 shadow_read 觀察視窗(生產環境用,會暫停 24h 才繼續)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-async">
|
||||
<?php esc_html_e( '強制 async(cron 推進,不阻塞 HTTP 請求)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-opt-dryrun">
|
||||
<?php esc_html_e( 'Dry-run(不寫入、不刪除,僅模擬流程)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="wpdo-mw-confirm">
|
||||
<label>
|
||||
<input type="checkbox" id="wpdo-mw-confirm-backup" required>
|
||||
<?php esc_html_e( '我已了解:流程將從 wp_usermeta 刪除已註冊欄位的 EAV 行,過程不可逆(自動備份提供救援路徑)。', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button button-primary button-hero" id="wpdo-mw-start" disabled>
|
||||
🚀 <?php esc_html_e( '開始一鍵遷移', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- Live progress panel (running state) ───────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
||||
<?php echo $active || $failed ? '' : 'hidden'; ?>>
|
||||
|
||||
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<div class="wpdo-mw-progress-bar">
|
||||
<div class="wpdo-mw-progress-fill" id="wpdo-mw-progress-fill"
|
||||
style="width:<?php echo esc_attr( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%"></div>
|
||||
<span class="wpdo-mw-progress-pct" id="wpdo-mw-progress-pct"><?php echo esc_html( (string) ( $status['overall_progress'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-current">
|
||||
<span class="wpdo-mw-current-phase">
|
||||
<?php esc_html_e( '當前階段:', '2meet-data-optimizer' ); ?>
|
||||
<code id="wpdo-mw-current-phase-name"><?php echo esc_html( $status['phase'] ?? 'idle' ); ?></code>
|
||||
</span>
|
||||
<span class="wpdo-mw-elapsed" id="wpdo-mw-elapsed">0.0s</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-live-ratio">
|
||||
<span><?php esc_html_e( 'Live ratio:', '2meet-data-optimizer' ); ?></span>
|
||||
<span id="wpdo-mw-live-ratio-text">
|
||||
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_start'] ?? $ratio ) ); ?>
|
||||
→
|
||||
1:<?php echo esc_html( (string) ( $status['metrics']['ratio_now'] ?? $ratio ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-log-wrap">
|
||||
<h4><?php esc_html_e( 'Log', '2meet-data-optimizer' ); ?></h4>
|
||||
<pre class="wpdo-mw-log" id="wpdo-mw-log"><?php echo esc_html( implode( "\n", (array) ( $status['log'] ?? array() ) ) ); ?></pre>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-mw-actions">
|
||||
<button type="button" class="button" id="wpdo-mw-cancel"><?php esc_html_e( '取消', '2meet-data-optimizer' ); ?></button>
|
||||
<button type="button" class="button button-primary" id="wpdo-mw-resume" hidden><?php esc_html_e( 'Resume', '2meet-data-optimizer' ); ?></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Completed state ───────────────────────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
||||
<?php echo $completed ? '' : 'hidden'; ?>>
|
||||
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
||||
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
/**
|
||||
* Post Migration Wizard template (v2.10.0).
|
||||
*
|
||||
* Variables in scope from render_post_migration_wizard():
|
||||
* $diagnose — TMDO_Post_Migration::diagnose() output
|
||||
* $garbage — TMDO_Postmeta_Cleaner::count_garbage('all') output
|
||||
*
|
||||
* Sync execution model — each action button POSTs back to this page with a
|
||||
* query arg + nonce, the action runs to completion in one request, then
|
||||
* redirects with `wpdo_msg=*` for the success/failure banner.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-migration-wizard' );
|
||||
|
||||
$total_eav = 0;
|
||||
foreach ( $diagnose['groups'] as $g ) {
|
||||
$total_eav += (int) $g['eav_rows'];
|
||||
}
|
||||
$total_flat = 0;
|
||||
foreach ( $diagnose['groups'] as $g ) {
|
||||
$total_flat += (int) $g['flat_rows'];
|
||||
}
|
||||
|
||||
$mode_label = array(
|
||||
'disabled' => __( '🔵 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;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<style>
|
||||
.wpdo-post-wizard h2 { margin-top: 1.4em; }
|
||||
.wpdo-post-wizard .wpdo-card {
|
||||
background: #fff;
|
||||
border: 1px solid #c3c4c7;
|
||||
border-radius: 4px;
|
||||
padding: 1em 1.4em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stat {
|
||||
background: #f6f7f7;
|
||||
border-left: 4px solid #2271b1;
|
||||
padding: 0.8em 1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-stat-label { font-size: 0.85em; color: #50575e; }
|
||||
.wpdo-post-wizard .wpdo-stat-value { font-size: 1.6em; font-weight: 600; }
|
||||
.wpdo-post-wizard .wpdo-mode-banner {
|
||||
padding: 0.6em 1em;
|
||||
border-radius: 3px;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
.wpdo-post-wizard .wpdo-mode-disabled { background: #e5f5fa; border-left: 4px solid #00a0d2; }
|
||||
.wpdo-post-wizard .wpdo-mode-dual_write { background: #fffbe5; border-left: 4px solid #dba617; }
|
||||
.wpdo-post-wizard .wpdo-mode-shadow_read { background: #fff4e5; border-left: 4px solid #d54e21; }
|
||||
.wpdo-post-wizard .wpdo-mode-aeav_only { background: #ecf7ed; border-left: 4px solid #46b450; }
|
||||
.wpdo-post-wizard table.widefat td.right { text-align: right; }
|
||||
.wpdo-post-wizard .actions {
|
||||
margin-top: 1.6em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #dcdcde;
|
||||
}
|
||||
.wpdo-post-wizard .actions .button { margin-right: 0.4em; margin-bottom: 0.4em; }
|
||||
</style>
|
||||
|
||||
<div class="wpdo-post-wizard">
|
||||
|
||||
<?php if ( $msg_raw ) : ?>
|
||||
<?php
|
||||
$msg_class = str_starts_with( $msg_raw, 'err' ) ? 'notice-error' : 'notice-success';
|
||||
$msg_text = $wpdo_format_msg( $msg_raw );
|
||||
?>
|
||||
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
|
||||
<p><?php echo esc_html( $msg_text ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e( 'Post Entity 遷移精靈', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '把 wp_postmeta 中的 47 個 managed keys 遷移到 7 張 flat 表(wp_wpdo_post_*)。每個按鈕只執行單一階段;按順序執行可達成完整 EAV → flat 切換。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Mode banner -->
|
||||
<div class="wpdo-mode-banner wpdo-mode-<?php echo esc_attr( $diagnose['mode'] ); ?>">
|
||||
<strong><?php esc_html_e( '當前 post mode:', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php echo esc_html( $mode_label[ $diagnose['mode'] ] ?? $diagnose['mode'] ); ?>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="wpdo-stats">
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value">1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></div>
|
||||
</div>
|
||||
<div class="wpdo-stat">
|
||||
<div class="wpdo-stat-label"><?php esc_html_e( 'Total flat rows', '2meet-data-optimizer' ); ?></div>
|
||||
<div class="wpdo-stat-value"><?php echo esc_html( number_format_i18n( $total_flat ) ); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group breakdown -->
|
||||
<h3><?php esc_html_e( '7 個 group 詳細狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Group', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'Keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'EAV rows', '2meet-data-optimizer' ); ?></th>
|
||||
<th class="right"><?php esc_html_e( 'Flat rows', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $diagnose['groups'] as $name => $g ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $name ); ?></code></td>
|
||||
<td><?php echo esc_html( $g['post_type'] ?: '(any)' ); ?></td>
|
||||
<td class="right"><?php echo esc_html( (string) count( $g['keys'] ) ); ?></td>
|
||||
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['eav_rows'] ) ); ?></td>
|
||||
<td class="right"><?php echo esc_html( number_format_i18n( (int) $g['flat_rows'] ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Phase actions -->
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 1:清 wp_postmeta 垃圾(v2.9.0 phase 0)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: total garbage rows, 2: transients, 3: wp_old_date, 4: edit_locks */
|
||||
esc_html__( '偵測到 %1$s 行可清理垃圾(transients %2$s + _wp_old_date %3$s + 過期 _edit_lock %4$s)。', '2meet-data-optimizer' ),
|
||||
esc_html( number_format_i18n( (int) $garbage['total'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['transients'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['wp_old_date'] ) ),
|
||||
esc_html( number_format_i18n( (int) $garbage['edit_locks'] ) )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $cleanup_garbage_url ); ?>"
|
||||
class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 2:把 wp_postmeta 既有資料 backfill 至 flat 表(v2.9.3)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'Idempotent — 重跑安全。每個 group 跑一次 bulk SQL pivot。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $backfill_all_url ); ?>" class="button button-primary"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要對 7 個 group 跑 backfill?此動作 idempotent,可重跑。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '把 wp_postmeta backfill 到 7 張 flat 表', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 3:把 legacy wpdo_hot_hp_listing 抄到 flat 表(v2.9.5)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( '非破壞 — legacy hot 表保留作為 v3.0.0 rollback safety net。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $cutover_legacy_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要 copy wpdo_hot_hp_listing 到 wp_wpdo_post_hp_listing_core?', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( 'Copy legacy hot table', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 4:升級 mode 至 dual_write', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'wp_postmeta 與 flat 表同時寫入。讀仍走 wp_postmeta(生產 safe)。建議至少觀察 24h 後再升級下一階。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $promote_dual_write_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 dual_write?此後 update_post_meta() 會雙寫。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '升級 mode → dual_write', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card">
|
||||
<h3><?php esc_html_e( '步驟 5:升級 mode 至 aeav_only(最終 cutover)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p><?php esc_html_e( 'flat 表成為 source-of-truth,讀寫都走 flat。完成後可 wp wpdo post-cleanup --confirm 清掉 wp_postmeta 已遷移 keys。', '2meet-data-optimizer' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( $promote_aeav_url ); ?>" class="button"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要升級 post mode 至 aeav_only?此後讀路徑切換到 flat 表,wp_postmeta 變成 backup。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '升級 mode → aeav_only', '2meet-data-optimizer' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpdo-card" style="background: #f6f7f7;">
|
||||
<h3><?php esc_html_e( '回退路徑(rollback)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '若任一步驟出問題,可下降 mode:', '2meet-data-optimizer' ); ?>
|
||||
<code>wp option update wpdo_bridge_modes '{"post":"disabled",...}'</code>
|
||||
<?php esc_html_e( '。Legacy wpdo_hot_* 表完整保留 — 直到 v3.0.0 才考慮 DROP。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
/**
|
||||
* Post Stress Test template (v2.11.4).
|
||||
*
|
||||
* Full async + polling UI mirroring User Stress Test:
|
||||
* 1. 設定並啟動測試(form: post_type / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Backward-compat: legacy GET ?wpdo_post_stress_create / cleanup / bench
|
||||
* handlers in admin still work for bookmarked URLs; the new UI uses REST.
|
||||
*
|
||||
* Variables in scope from render_post_stress_test():
|
||||
* $test_post_count — int, posts matching TMDO_STRESS_TEST_ prefix
|
||||
* $diagnose — TMDO_Post_Migration::diagnose() output
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=post-stress-test' );
|
||||
|
||||
$cleanup_url = wp_nonce_url(
|
||||
add_query_arg( array( 'wpdo_post_stress_cleanup' => '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 ),
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="wpdo-post-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Post Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 post 並填滿對應 wp_postmeta + flat 表。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 post,評估 post entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 post 均以 TMDO_STRESS_TEST_ 為 post_title 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<?php if ( '' !== $msg_text ) : ?>
|
||||
<div class="notice <?php echo esc_attr( $msg_class ); ?> is-dismissible">
|
||||
<p><?php echo esc_html( $msg_text ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<?php
|
||||
$current_post_mode = (string) ( $diagnose['mode'] ?? 'disabled' );
|
||||
$mode_color = 'aeav_only' === $current_post_mode ? '#28a745' : '#dc3545';
|
||||
$mode_optimal = 'aeav_only' === $current_post_mode;
|
||||
$settings_tab_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=settings' );
|
||||
?>
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Post Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Posts', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['posts'] ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Postmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $diagnose['postmeta'] ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) $diagnose['ratio'] ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_post_mode ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 post 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-pst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_post_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Post Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Post Mode = %s — 此模式下壓力測試結果不會展示反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_post_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_postmeta 真實減量?必須同時滿足兩個條件:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: settings tab anchor open, 2: settings tab anchor close */
|
||||
esc_html__( 'Post mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Post entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_tab_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_post + Hook Bus,會被攔截短路 wp_postmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;color:#0073aa;font-weight:600;"><?php esc_html_e( '📐 模式 × 寫入路徑 → 預期結果矩陣', '2meet-data-optimizer' ); ?></summary>
|
||||
<table class="widefat" style="margin-top:8px;background:#fff;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="background:#f0f0f0;">
|
||||
<th><?php esc_html_e( 'Post Mode', '2meet-data-optimizer' ); ?></th>
|
||||
<th>⚡ Fast</th>
|
||||
<th>🐢 Realistic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr<?php echo 'disabled' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>disabled</code></td>
|
||||
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'dual_write' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>dual_write</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'shadow_read' === $current_post_mode ? ' style="background:#fff8e1;"' : ''; ?>>
|
||||
<td><code>shadow_read</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
||||
</tr>
|
||||
<tr<?php echo 'aeav_only' === $current_post_mode ? ' style="background:#e8f5e9;font-weight:600;"' : ''; ?>>
|
||||
<td><code>aeav_only</code></td>
|
||||
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
||||
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="description" style="margin-top:6px;">
|
||||
<?php esc_html_e( '範例以 nav_menu_item(5 keys/post)為基準。Fast 模式直接 $wpdb->insert,故意繞過 Hook Bus → 即使 mode=aeav_only 也會寫滿 wp_postmeta(用途:快速灌 fixture 給 Query Router benchmark)。驗證反 EAV 優化效果一律用 Realistic。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-pst-post-type"><?php esc_html_e( 'Post Type', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-pst-post-type" class="regular-text">
|
||||
<?php foreach ( $post_type_options as $pt => $info ) : ?>
|
||||
<?php
|
||||
list( $label, $key_count ) = $info;
|
||||
?>
|
||||
<option value="<?php echo esc_attr( $pt ); ?>" data-keys="<?php echo esc_attr( (string) $key_count ); ?>">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: post_type slug, 2: human label, 3: number of seeded keys */
|
||||
esc_html__( '%1$s — %2$s · %3$d keys/post', '2meet-data-optimizer' ),
|
||||
esc_html( $pt ),
|
||||
esc_html( $label ),
|
||||
(int) $key_count
|
||||
);
|
||||
?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '每筆 post 會 seed 該 post_type 對應 entity group 的標準 meta keys(v2.9.1 entity registry)。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-pst-target"><?php esc_html_e( '要建立的 post 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-pst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-pst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-pst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_post + update_post_meta(較慢,模擬生產路徑 + 觸發 Hook Bus → mode=dual_write+ 時 flat 表自動填入)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-pst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-pst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批執行有 8 秒 wall-clock 上限(避免 nginx 504)。Fast 建議 200-1000;Realistic 建議 5-20(每 post ~100-300ms)。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-pst-start" <?php disabled( $is_running ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-pst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test post 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-pst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_post_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 post_title 前綴 TMDO_STRESS_TEST_ 的 post,連同 wp_postmeta + 7 張 flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-pst-cleanup" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 post', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-pst-rerun-bench" <?php disabled( $is_running || 0 === $test_post_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '針對最後一次啟動所選的 post_type 重新量測寫入指標(保留)+ DB 容量 + 查詢效能。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress section (live) -->
|
||||
<div id="wpdo-pst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-pst-pg-status"><?php echo esc_html( $wpdo_pst_stat ); ?></strong>
|
||||
· <code id="wpdo-pst-pg-post-type"><?php echo esc_html( (string) ( $state['post_type'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-pst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-pst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-pst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-pst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-pst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> posts/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-pst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-pst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-pst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 7-group seed map info -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '4. TMDO_Post_Stress_Tester seed map(7 個 post_type)', '2meet-data-optimizer' ); ?></h3>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '每個 stress post 自動 seed 對應 group 的 canonical meta keys(v2.9.1 entity registry 定義)。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<table class="widefat striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'post_type', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Seeded keys', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'Flat 表', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>product</code></td>
|
||||
<td>_price, _regular_price, _stock, _stock_status, _sku</td>
|
||||
<td><code>wp_wpdo_post_wc_product</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_listing</code></td>
|
||||
<td>hp_price, hp_status, hp_featured, hp_verified, hp_vendor, hp_view_count, hp_expired_time</td>
|
||||
<td><code>wp_wpdo_post_hp_listing_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_request</code></td>
|
||||
<td>hp_status, hp_user, hp_budget, hp_view_count, hp_expired_time</td>
|
||||
<td><code>wp_wpdo_post_hp_request_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>hp_vendor</code></td>
|
||||
<td>hp_user, hp_verified, hp_hourly_rate, hp_rating_count, hp_rating</td>
|
||||
<td><code>wp_wpdo_post_hp_vendor_core</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>attachment</code></td>
|
||||
<td>_wp_attached_file, _wp_attachment_image_alt</td>
|
||||
<td><code>wp_wpdo_post_attachment</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nav_menu_item</code></td>
|
||||
<td>_menu_item_type, _menu_item_object_id, _menu_item_object, _menu_item_target, _menu_item_url</td>
|
||||
<td><code>wp_wpdo_post_nav_menu_item</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>post</code></td>
|
||||
<td>_thumbnail_id, _edit_last</td>
|
||||
<td><code>wp_wpdo_post_wp_core</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform admin UI: stress test SQL example display
|
||||
/**
|
||||
* Term Stress Test template (v2.13.0).
|
||||
*
|
||||
* Full async + polling UI mirroring Post / User Stress Test:
|
||||
* 1. 設定並啟動測試(form: taxonomy / target / mode / batch_size + Start/Cancel)
|
||||
* 2. 即時進度(progress bar + processed/target/rate/ETA/peak memory)
|
||||
* 3. Benchmark 報告(write metrics + DB sizes + query performance)
|
||||
*
|
||||
* Variables in scope from render_term_stress_test():
|
||||
* $state — TMDO_Term_Stress_Tester::get_progress(false) output
|
||||
* $test_term_count — int, terms matching wpdo-stress- slug prefix
|
||||
* $taxonomies — array<slug,label> 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}" );
|
||||
?>
|
||||
|
||||
<div class="wpdo-term-stress-test-tab">
|
||||
<h2><?php esc_html_e( 'Term Entity 壓力測試 & Benchmark', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<div style="margin:14px 0;padding:12px 14px;background:#f8d7da;color:#721c24;border-left:4px solid #dc3545;border-radius:4px;font-size:13px;line-height:1.6;">
|
||||
⚠️ <strong><?php esc_html_e( '此工具僅供開發 / 測試環境使用。', '2meet-data-optimizer' ); ?></strong>
|
||||
<?php esc_html_e( '會建立大量測試 term 並 seed 對應 hp_taxonomy 群組 keys。請勿在生產環境執行。', '2meet-data-optimizer' ); ?>
|
||||
</div>
|
||||
|
||||
<p class="description">
|
||||
<?php esc_html_e( '透過自動產生大量測試 term,評估 term entity 反 EAV 系統在不同規模下的寫入吞吐與查詢效能。所有測試 term 均以 wpdo-stress- 為 slug 前綴,可一鍵清除。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Diagnose snapshot -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-top:20px;">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '當前 Term Entity 狀態', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped">
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'wp_terms', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_terms ) ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'wp_termmeta', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $total_termmeta ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php esc_html_e( 'Mode', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong style="color:<?php echo esc_attr( $mode_color ); ?>;"><?php echo esc_html( $current_mode ); ?></strong></td>
|
||||
<td><?php esc_html_e( 'Ratio', '2meet-data-optimizer' ); ?></td>
|
||||
<td><strong>1:<?php echo esc_html( (string) ( $total_terms > 0 ? round( $total_termmeta / $total_terms, 2 ) : 0 ) ); ?></strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>wpdo_term_hp_taxonomy</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_hp_taxonomy ) ); ?></strong> rows</td>
|
||||
<td><code>wpdo_term_misc</code></td>
|
||||
<td><strong><?php echo esc_html( number_format_i18n( $flat_misc ) ); ?></strong> rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><?php esc_html_e( 'Stress 測試 term 數', '2meet-data-optimizer' ); ?></td>
|
||||
<td colspan="2"><strong id="wpdo-tst-count" style="color:#dc3545;font-size:18px;"><?php echo esc_html( number_format_i18n( $test_term_count ) ); ?></strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 驗證反 EAV 優化指南 -->
|
||||
<div class="wpdo-card" style="padding:20px;background:<?php echo $mode_optimal ? '#e8f5e9' : '#fff3cd'; ?>;border-left:4px solid <?php echo esc_attr( $mode_color ); ?>;border-radius:6px;margin-top:16px;font-size:13px;line-height:1.7;">
|
||||
<h3 style="margin-top:0;font-size:14px;">
|
||||
<?php if ( $mode_optimal ) : ?>
|
||||
✅ <?php esc_html_e( 'Term Mode = aeav_only — 已具備驗證優化的條件', '2meet-data-optimizer' ); ?>
|
||||
<?php else : ?>
|
||||
⚠️
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: current mode */
|
||||
esc_html__( 'Term Mode = %s — 此模式下壓力測試結果不會展示完整反 EAV 優化效果', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:2px 6px;border-radius:3px;">' . esc_html( $current_mode ) . '</code>'
|
||||
);
|
||||
?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<p style="margin:8px 0 0 0;">
|
||||
<strong><?php esc_html_e( '想看 wp_termmeta 真實減量?必須兩條件同時滿足:', '2meet-data-optimizer' ); ?></strong>
|
||||
</p>
|
||||
<ol style="margin:6px 0 8px 22px;padding:0;">
|
||||
<li>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: open code, 2: close code */
|
||||
esc_html__( 'Term mode 設為 %1$saeav_only%2$s(前往設定 tab → Entity Bridge → Term entity)', '2meet-data-optimizer' ),
|
||||
'<code style="background:#fff;padding:1px 5px;border-radius:3px;">',
|
||||
'</code>'
|
||||
);
|
||||
?>
|
||||
<?php if ( ! $mode_optimal ) : ?>
|
||||
<a href="<?php echo esc_url( $settings_url ); ?>" class="button button-small" style="margin-left:8px;">→ <?php esc_html_e( '前往設定', '2meet-data-optimizer' ); ?></a>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( '寫入模式選 🐢 Realistic(走 wp_insert_term + Hook Bus,會被攔截短路 wp_termmeta)', '2meet-data-optimizer' ); ?>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:20px;">
|
||||
|
||||
<!-- Left: Configure & Start -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '1. 設定並啟動測試', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
<table class="form-table" style="margin-top:0;">
|
||||
<tr>
|
||||
<th style="width:35%;"><label for="wpdo-tst-taxonomy"><?php esc_html_e( 'Taxonomy', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<select id="wpdo-tst-taxonomy" class="regular-text">
|
||||
<?php foreach ( $taxonomies as $slug => $label ) : ?>
|
||||
<option value="<?php echo esc_attr( $slug ); ?>" <?php selected( $slug, 'listing_category' ); ?>>
|
||||
<?php echo esc_html( $slug . ' — ' . $label ); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e( '建議用 HivePress 自訂 taxonomy(listing_category / listing_tag 等)— seed 後 hp_sort_order/hp_default/hp_icon 三 keys 走 hp_taxonomy 群組。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-tst-target"><?php esc_html_e( '要建立的 term 數', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-tst-target" min="1" max="100000" value="500" class="regular-text" />
|
||||
<p class="description"><?php esc_html_e( '常用:100 / 500 / 1,000 / 10,000(上限 100,000)', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label><?php esc_html_e( '寫入模式', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<label style="display:block;margin-bottom:6px;">
|
||||
<input type="radio" name="wpdo-tst-mode" value="fast" checked />
|
||||
<strong>Fast</strong> — <?php esc_html_e( '直接 $wpdb->insert,跳過 WP filter chain(最快,但不測 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
<label style="display:block;">
|
||||
<input type="radio" name="wpdo-tst-mode" value="realistic" />
|
||||
<strong>Realistic</strong> — <?php esc_html_e( '走 wp_insert_term + update_term_meta(較慢,模擬生產路徑 + 觸發 Hook Bus)', '2meet-data-optimizer' ); ?>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="wpdo-tst-batch"><?php esc_html_e( '批次大小', '2meet-data-optimizer' ); ?></label></th>
|
||||
<td>
|
||||
<input type="number" id="wpdo-tst-batch" min="1" max="1000" value="200" class="small-text" />
|
||||
<p class="description"><?php esc_html_e( '每批 8 秒 wall-clock 上限。Fast 建議 200-1000;Realistic 建議 10-30。', '2meet-data-optimizer' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<button type="button" class="button button-primary button-large" id="wpdo-tst-start" <?php disabled( $is_running ); ?>>
|
||||
<?php esc_html_e( '🚀 啟動壓力測試', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="wpdo-tst-cancel" <?php disabled( ! $is_running ); ?>>
|
||||
<?php esc_html_e( '⏹ 取消', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cleanup + Re-run benchmark -->
|
||||
<div class="wpdo-card" style="padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '清除測試資料', '2meet-data-optimizer' ); ?></h3>
|
||||
<p>
|
||||
<?php esc_html_e( '目前 stress test term 數:', '2meet-data-optimizer' ); ?>
|
||||
<strong id="wpdo-tst-count-mirror" style="font-size:18px;color:#dc3545;">
|
||||
<?php echo esc_html( number_format_i18n( $test_term_count ) ); ?>
|
||||
</strong>
|
||||
</p>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '一鍵清除所有 slug 前綴 wpdo-stress- 的 term,連同 wp_termmeta + wp_term_taxonomy + flat 表的對應 row。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="button button-secondary" id="wpdo-tst-cleanup" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
||||
<?php esc_html_e( '🗑 清除全部測試 term', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<hr style="margin:18px 0;" />
|
||||
|
||||
<p>
|
||||
<button type="button" class="button" id="wpdo-tst-rerun-bench" <?php disabled( $is_running || 0 === $test_term_count ); ?>>
|
||||
<?php esc_html_e( '📊 重跑 Benchmark(不新增資料)', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-tst-progress-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo $is_running ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:13px;">
|
||||
<span>
|
||||
<strong id="wpdo-tst-pg-status"><?php echo esc_html( $status_str ); ?></strong>
|
||||
· <code id="wpdo-tst-pg-taxonomy"><?php echo esc_html( (string) ( $state['taxonomy'] ?? '' ) ); ?></code>
|
||||
· <span id="wpdo-tst-pg-mode"><?php echo esc_html( (string) ( $state['mode'] ?? '' ) ); ?></span> mode
|
||||
</span>
|
||||
<span id="wpdo-tst-pg-pct" style="font-weight:600;"><?php echo esc_html( (string) ( $state['pct'] ?? 0 ) ); ?>%</span>
|
||||
</div>
|
||||
<div style="height:14px;background:#e0e0e0;border-radius:7px;overflow:hidden;">
|
||||
<div id="wpdo-tst-pg-bar" style="height:100%;background:linear-gradient(90deg,#28a745,#20c997);width:<?php echo esc_attr( (string) ( $state['pct'] ?? 0 ) ); ?>%;transition:width .4s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<table style="width:100%;font-size:13px;margin-top:10px;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已建立', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-processed"><?php echo esc_html( (string) ( $state['processed'] ?? 0 ) ); ?></span> / <span id="wpdo-tst-pg-target"><?php echo esc_html( (string) ( $state['target'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '速率', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;font-weight:600;"><span id="wpdo-tst-pg-rate"><?php echo esc_html( (string) ( $state['rate_per_sec'] ?? 0 ) ); ?></span> terms/sec</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '已耗時', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-elapsed"><?php echo esc_html( (string) ( $state['elapsed_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '預估剩餘', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-eta"><?php echo esc_html( (string) ( $state['eta_sec'] ?? 0 ) ); ?></span> 秒</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( '完成批次', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-batches"><?php echo esc_html( (string) ( $state['batches_done'] ?? 0 ) ); ?></span></td>
|
||||
<td style="padding:6px;color:#666;"><?php esc_html_e( 'PHP Peak Mem', '2meet-data-optimizer' ); ?></td>
|
||||
<td style="padding:6px;"><span id="wpdo-tst-pg-mem"><?php echo esc_html( (string) round( ( (int) ( $state['peak_memory'] ?? 0 ) ) / 1048576, 1 ) ); ?></span> MB</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-tst-bench-card" class="wpdo-card" style="margin-top:20px;padding:20px;background:#fff;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);<?php echo ! empty( $state['benchmark'] ) ? '' : 'display:none;'; ?>">
|
||||
<h3 style="margin-top:0;"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-tst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,561 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
||||
/**
|
||||
* TMDO_CLI_Member — Member flat-table CLI subcommands.
|
||||
*
|
||||
* Adds the following subcommands under `wp wpdo`:
|
||||
*
|
||||
* wp wpdo member-audit — Mode, row counts, shadow diff rate.
|
||||
* wp wpdo member-backfill — Backfill usermeta → flat table.
|
||||
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
||||
* wp wpdo member-cutover — Switch user mode to aeav_only.
|
||||
* wp wpdo member-points-check — Show balance + recent ledger for a user.
|
||||
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
|
||||
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.5.5
|
||||
*/
|
||||
|
||||
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 accept ($args, $assoc_args) by contract; many subcommands only need one of them.
|
||||
|
||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member flat-table CLI subcommands. Loaded only in WP_CLI context.
|
||||
*/
|
||||
class TMDO_CLI_Member {
|
||||
|
||||
/**
|
||||
* Valid group names managed by this CLI.
|
||||
*
|
||||
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
|
||||
* the legacy WP/WC/HP wp_usermeta keys.
|
||||
*/
|
||||
private const VALID_GROUPS = array(
|
||||
'membership',
|
||||
'activity',
|
||||
'profile',
|
||||
'sso',
|
||||
'core_profile',
|
||||
'social',
|
||||
'commerce',
|
||||
'hp_user',
|
||||
'admin_prefs',
|
||||
);
|
||||
|
||||
// ── member-audit ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show current user bridge mode, flat-table row counts, and shadow diff rate.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp wpdo member-audit
|
||||
*
|
||||
* @param array $args Positional arguments (unused).
|
||||
* @param array $assoc_args Named arguments (unused).
|
||||
*/
|
||||
public function member_audit( $args, $assoc_args ): void {
|
||||
global $wpdb;
|
||||
|
||||
$mode = TMDO_Mode_Manager::get( 'user' );
|
||||
WP_CLI::log( "User bridge mode: {$mode}" );
|
||||
WP_CLI::log( '' );
|
||||
|
||||
$items = array();
|
||||
foreach ( self::VALID_GROUPS as $group ) {
|
||||
$table = $wpdb->prefix . 'wpdo_user_' . $group;
|
||||
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
$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=<group>]
|
||||
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
|
||||
*
|
||||
* [--batch-size=<n>]
|
||||
* : 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=<n>]
|
||||
* : Max rows to display (default 20).
|
||||
*
|
||||
* [--format=<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
|
||||
*
|
||||
* <user_id>
|
||||
* : WordPress user ID.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : 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 <user_id>' );
|
||||
}
|
||||
|
||||
$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=<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=<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=<id>]
|
||||
* : WordPress local user ID to force-logout.
|
||||
*
|
||||
* [--global-user-id=<uuid>]
|
||||
* : 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=<id> or --global-user-id=<uuid>.' );
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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' ) );
|
||||
@@ -0,0 +1,844 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
||||
/**
|
||||
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
|
||||
*
|
||||
* Adds the following subcommands under `wp wpdo`:
|
||||
*
|
||||
* wp wpdo postmeta-cleanup — Clean wp_postmeta garbage (transients,
|
||||
* _wp_old_date, stale _edit_lock).
|
||||
*
|
||||
* Mirrors TMDO_CLI_Member's design: thin wrapper around core class
|
||||
* (TMDO_Postmeta_Cleaner). Future post-entity subcommands (post-audit,
|
||||
* post-backfill, post-cutover) will live in this same file.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
|
||||
|
||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post entity CLI subcommands. Loaded only in WP_CLI context.
|
||||
*/
|
||||
class TMDO_CLI_Post {
|
||||
|
||||
// ── postmeta-cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clean wp_postmeta garbage rows (transients, _wp_old_date, stale _edit_lock).
|
||||
*
|
||||
* Phase 0 of v2.9.0 Post Entity migration. Runs before any Entity Bridge
|
||||
* work so subsequent ratio measurements reflect real data, not garbage.
|
||||
*
|
||||
* SAFETY: by default this command refuses to run. Pass --dry-run to
|
||||
* preview row counts without deleting, or --confirm to actually delete.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--target=<target>]
|
||||
* : 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=<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=<name>.' );
|
||||
}
|
||||
|
||||
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_<name>` / `_transient_timeout_<name>`
|
||||
* 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_<post_type>` zone table
|
||||
* into the new `wp_wpdo_post_<group>` 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>
|
||||
* : 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>
|
||||
* : Post type to filter by (e.g. product, hp_listing).
|
||||
*
|
||||
* --meta-key=<key>
|
||||
* : Meta key to query (must be registered in entity registry for $post_type).
|
||||
*
|
||||
* [--compare=<op>]
|
||||
* : Comparison operator. Default: =.
|
||||
* ---
|
||||
* default: =
|
||||
* options:
|
||||
* - "="
|
||||
* - "!="
|
||||
* - "<"
|
||||
* - "<="
|
||||
* - ">"
|
||||
* - ">="
|
||||
* - "LIKE"
|
||||
* ---
|
||||
*
|
||||
* --value=<value>
|
||||
* : Value to compare against.
|
||||
*
|
||||
* [--samples=<n>]
|
||||
* : 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=<n>]
|
||||
* : Aggregation window in hours. Default: 24.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : 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' ) );
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,795 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_CLI_V2 — v2.0.0 CLI subcommands.
|
||||
*
|
||||
* Adds the following subcommands under `wp wpdo`:
|
||||
*
|
||||
* wp wpdo bridge-status — Hook Bus enabled? Conflict snapshot.
|
||||
* wp wpdo bridge-set <on|off> — Toggle TMDO_Hook_Bus_Bridge.
|
||||
* wp wpdo mode-audit — Per-module state + shadow flags.
|
||||
* wp wpdo mode-set <module> <state> — Set FSM state.
|
||||
* wp wpdo shadow-enable <module> — Enable shadow_read_only sub-flag.
|
||||
* wp wpdo shadow-disable <module> — Disable shadow_read_only.
|
||||
* wp wpdo conflict-scan — Run conflict monitor + emit JSON.
|
||||
* wp wpdo lint --plugin=<path> — 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=<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
|
||||
*
|
||||
* <state>
|
||||
* : 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 <on|off>' );
|
||||
}
|
||||
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>
|
||||
* : Module name (e.g. hot_hp_listing)
|
||||
*
|
||||
* <state>
|
||||
* : 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 <module> <state> — 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 <module>' );
|
||||
}
|
||||
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 <module>' );
|
||||
}
|
||||
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=<path>
|
||||
* : Absolute path to plugin directory.
|
||||
*
|
||||
* [--strict]
|
||||
* : Exit non-zero on any finding.
|
||||
*
|
||||
* [--max-autoload=<n>]
|
||||
* : 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=<path> 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<int, array{rule:string, file:string, line:int, message:string}>
|
||||
*/
|
||||
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.<rule> 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=<trigger>]
|
||||
* : One of manual|pre_fsm_transition|pre_v2_upgrade|scheduled|pre_uninstall.
|
||||
* ---
|
||||
* default: manual
|
||||
* ---
|
||||
*
|
||||
* [--scope-tables=<csv>]
|
||||
* : Comma-separated table list to dump. Empty = all WPDO tables.
|
||||
*
|
||||
* [--scope-entities=<csv>]
|
||||
* : Comma-separated entity list (post,user,term,comment) — adds wp_*meta to dump.
|
||||
*
|
||||
* [--notes=<text>]
|
||||
* : Free-form note for the catalog.
|
||||
*
|
||||
* [--retention-days=<n>]
|
||||
* : 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=<trigger>]
|
||||
* : Filter by trigger type.
|
||||
*
|
||||
* [--limit=<n>]
|
||||
* : Default 50.
|
||||
*
|
||||
* [--format=<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_id>
|
||||
* : 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 <snapshot_id> [--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=<n>]
|
||||
* : Older-than threshold (informational; actual TTL stored in row).
|
||||
* ---
|
||||
* default: 30
|
||||
* ---
|
||||
*
|
||||
* [--size-cap-mb=<n>]
|
||||
* : 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_id>
|
||||
* : 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 <snapshot_id>' );
|
||||
}
|
||||
$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<int,string>
|
||||
*/
|
||||
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=<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=<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' ) );
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+2840
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Comment - Comment 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Comment implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Post - Post 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Post implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'post';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Term - Term 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Term implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'term';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_User - User 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_User implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Entity Adapter 合約介面
|
||||
*
|
||||
* 所有實體適配器(Post/User/Term/Comment)必須實作此介面
|
||||
* 將不同實體的行為標準化,供 Hook Bus 統一呼叫
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
// Back-compat: define WPDO_ marker first so TMDO_ can extend it.
|
||||
// PHP class_alias() doesn't work on interfaces; extending is the only option.
|
||||
// Any class implementing TMDO_Entity_Adapter_Interface automatically satisfies
|
||||
// `instanceof WPDO_Entity_Adapter_Interface` via interface inheritance.
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) ) {
|
||||
interface WPDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
|
||||
interface TMDO_Entity_Adapter_Interface extends WPDO_Entity_Adapter_Interface {
|
||||
|
||||
/**
|
||||
* 實體類型識別子
|
||||
* 必須回傳 WordPress metadata type 之一:post|user|term|comment
|
||||
*/
|
||||
public function get_entity_type(): string;
|
||||
|
||||
/**
|
||||
* 原生 meta 表完整名稱(含 wp_ 前綴)
|
||||
* 例:wp_postmeta / wp_usermeta
|
||||
*/
|
||||
public function get_native_meta_table(): string;
|
||||
|
||||
/**
|
||||
* 原生 meta 表中指向實體的欄位名稱
|
||||
* 例:post_id / user_id / term_id / comment_id
|
||||
*/
|
||||
public function get_entity_id_column(): string;
|
||||
|
||||
/**
|
||||
* 對應的主實體表(用於 JOIN)
|
||||
* 例:wp_posts / wp_users / wp_terms / wp_comments
|
||||
*/
|
||||
public function get_primary_table(): string;
|
||||
|
||||
/**
|
||||
* 主實體表的主鍵欄位
|
||||
* 例:ID (post/user) / term_id / comment_ID
|
||||
*/
|
||||
public function get_primary_id_column(): string;
|
||||
|
||||
/**
|
||||
* 快取群組名稱(供 wp_cache_* 使用)
|
||||
*/
|
||||
public function get_cache_group(): string;
|
||||
|
||||
/**
|
||||
* 實體刪除時會觸發的 WordPress hook
|
||||
* 用於自動清理 UAE 資料表中對應列
|
||||
*/
|
||||
public function get_delete_hook(): string;
|
||||
|
||||
/**
|
||||
* 擴充原生查詢物件以支援 UAE meta_query
|
||||
*
|
||||
* @param mixed $query_object WP_Query / WP_User_Query / WP_Comment_Query 等
|
||||
*/
|
||||
public function extend_native_query( $query_object ): void;
|
||||
|
||||
/**
|
||||
* 取得所有已知的 ID(用於遷移時的迭代)
|
||||
* 採用 cursor-based 分頁
|
||||
*
|
||||
* @param int $after_id 取大於此 ID 的筆
|
||||
* @param int $limit 取多少筆
|
||||
* @return array<int>
|
||||
*/
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
||||
/**
|
||||
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
||||
*
|
||||
* Reads:
|
||||
* - Current state via TMDO_Feature_Flags::get( $module )
|
||||
* - Time-in-current-state via wp_options.wpdo_fsm_state_entered (set by
|
||||
* TMDO_FSM_Guard::record_entry() since v2.2.0 M2)
|
||||
* - shadow_diffs ratio per module from wp_wpdo_shadow_diffs (last 24h)
|
||||
*
|
||||
* Emits one of:
|
||||
* - PROMOTE — current state has cooked long enough + low divergence; safe to advance
|
||||
* - WAIT — needs more soak time, returns days remaining
|
||||
* - REVIEW — divergence too high; manual investigation needed before promoting
|
||||
* - ROLLBACK — error budget exceeded; suggest rewind to idle
|
||||
* - HOLD — module already idle or complete; nothing to do
|
||||
*
|
||||
* Designed to be advice — never auto-acts. Surfaces in Migration tab as a
|
||||
* panel beside each module row.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM advisor — stateless static API with structured `advise()` return.
|
||||
*/
|
||||
class TMDO_FSM_Advisor {
|
||||
|
||||
/** Minimum soak days per state before advisor allows promotion. */
|
||||
public const MIN_SOAK_DAYS = array(
|
||||
'idle' => 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<string,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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_FSM_Automator — Opt-in automatic execution of FSM_Advisor PROMOTE
|
||||
* recommendations (v2.5.0 M13).
|
||||
*
|
||||
* Default OFF. When admin enables, runs daily at 04:30 UTC. For each module:
|
||||
* - Calls TMDO_FSM_Advisor::advise()
|
||||
* - Only auto-promotes when ALL 4 conditions true:
|
||||
* 1. wpdo_automator_enabled = 1
|
||||
* 2. module not in wpdo_automator_blacklist
|
||||
* 3. TMDO_Health_Cron::get_last_run() critical_count = 0 in last 7 days
|
||||
* (cool-off — system is healthy)
|
||||
* 4. ≥ 24h since this module's last automated promotion
|
||||
* - Destructive transitions (cutover→cleanup, cleanup→complete) are
|
||||
* NEVER automated — admin must manually invoke.
|
||||
*
|
||||
* Audit trail: every automator action writes wp_wpdo_audit op='automator_promoted'
|
||||
* + fires action `wpdo/automator_promoted` for downstream subscribers.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automator — stateless static API.
|
||||
*/
|
||||
class TMDO_FSM_Automator {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_automator_enabled';
|
||||
public const OPT_BLACKLIST = 'wpdo_automator_blacklist';
|
||||
public const OPT_LAST_ACTION = 'wpdo_automator_last_action';
|
||||
|
||||
/** Cool-off window after critical health alert. */
|
||||
private const COOL_OFF_DAYS = 7;
|
||||
|
||||
/** Minimum interval between automated promotions of the same module. */
|
||||
private const MIN_INTERVAL_HOURS = 24;
|
||||
|
||||
/** Transitions that are never auto-executed (destructive). */
|
||||
private const FORBIDDEN_TRANSITIONS = array(
|
||||
array( 'verify', 'cutover' ), // cutover starts reading from custom — needs manual sign-off.
|
||||
array( 'cutover', 'cleanup' ), // cleanup purges wp_*meta — irreversible without snapshot.
|
||||
array( 'cleanup', 'complete' ), // complete = no fallback path.
|
||||
);
|
||||
|
||||
/**
|
||||
* Cron handler.
|
||||
*
|
||||
* @return array {ok:bool, executed:int, skipped:int, errors:array}
|
||||
*/
|
||||
public static function run(): array {
|
||||
$result = array(
|
||||
'ok' => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
|
||||
*
|
||||
* For each registered module (see TMDO_Module_Rules), evaluates:
|
||||
* 1. Are required plugins active (Compatibility::is_*_active)?
|
||||
* 2. Does the required post_type exist + meet min_post_count?
|
||||
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
|
||||
* 4. Is the module already in a non-idle state (no point recommending)?
|
||||
*
|
||||
* Output per module: {available, confidence, recommendation, reasons,
|
||||
* blockers, current_state, suggested_action}.
|
||||
*
|
||||
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
|
||||
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
|
||||
* reads from there for fast widget rendering.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection engine — stateless static API.
|
||||
*/
|
||||
class TMDO_Module_Detector {
|
||||
|
||||
/** WP options key holding cached results. */
|
||||
public const OPTION_CACHE = 'wpdo_module_suggestions';
|
||||
|
||||
/** Transient key for short-term cache (1 hour). */
|
||||
private const TRANSIENT = 'wpdo_module_detector_results';
|
||||
|
||||
/** TTL for transient cache. */
|
||||
private const TRANSIENT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Detect every registered module. Cached for 1 hour via transient.
|
||||
*
|
||||
* @param bool $force_refresh Bypass transient cache.
|
||||
* @return array<string,array> 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<string,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<int,string> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Rules — Declarative trigger conditions per module (v2.5.0 M16).
|
||||
*
|
||||
* Separates "what makes this module worth enabling" from the detection
|
||||
* machinery (Module_Detector consumes these rules). Each rule declares:
|
||||
* - compat_required:list of plugins that must be active (any of)
|
||||
* - post_type_required:post_type must exist + have rows
|
||||
* - min_post_count:threshold for post_type row count
|
||||
* - min_trash_count:(archive only) trashed post count threshold
|
||||
* - consult_classifier + min_classifier_confidence:(zone modules only)
|
||||
* - description:human reason shown in UI
|
||||
*
|
||||
* Extensible via filter `wpdo/module_rules` — third-party plugins can register
|
||||
* their own modules and rules without forking this file.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static rules registry.
|
||||
*/
|
||||
class TMDO_Module_Rules {
|
||||
|
||||
/**
|
||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
||||
*/
|
||||
private const DEFAULT_RULES = array(
|
||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
||||
'reviews' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_review',
|
||||
'min_post_count' => 100,
|
||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
||||
),
|
||||
'messages' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_message_thread',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
||||
),
|
||||
'favorites' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_favorite',
|
||||
'min_post_count' => 200,
|
||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
'memberships' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_membership',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
||||
),
|
||||
'statistics' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 500,
|
||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
||||
),
|
||||
'requests' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_request',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
||||
),
|
||||
'listing_meta' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
||||
),
|
||||
'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<string,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<int,string>
|
||||
*/
|
||||
public static function known_modules(): array {
|
||||
return array_keys( self::all() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Backward-compat interface alias.
|
||||
*
|
||||
* PHP class_alias() does NOT support interfaces. This file declares
|
||||
* `WPDO_Entity_Adapter_Interface` as a child of `TMDO_Entity_Adapter_Interface`
|
||||
* (no new methods), which allows sister plugins that type-hint against the old
|
||||
* name to keep working: the four TMDO adapters implement this interface, so
|
||||
* `instanceof WPDO_Entity_Adapter_Interface` returns true for all of them.
|
||||
*
|
||||
* Loaded by `class-tmdo-back-compat.php` after the real interface is loaded.
|
||||
*
|
||||
* @package TMDO
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias interface. Any class implementing
|
||||
* `WPDO_Entity_Adapter_Interface` automatically satisfies
|
||||
* `TMDO_Entity_Adapter_Interface` and vice-versa.
|
||||
*
|
||||
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
|
||||
*/
|
||||
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* Backward-compat trait alias.
|
||||
*
|
||||
* PHP class_alias() does NOT support traits or interfaces. This file declares
|
||||
* a thin wrapper trait `WPDO_Anti_EAV_Aware` that simply `use`s the new
|
||||
* `TMDO_Anti_EAV_Aware` trait, preserving binary compatibility for sister
|
||||
* plugins (2meet-brandcards, hub-core extensions, etc.) that still reference
|
||||
* the old trait name.
|
||||
*
|
||||
* Loaded by `class-tmdo-back-compat.php` after the new trait is loaded.
|
||||
*
|
||||
* @package TMDO
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! trait_exists( 'WPDO_Anti_EAV_Aware', false ) && trait_exists( 'TMDO_Anti_EAV_Aware', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias trait. Sister plugins using `use WPDO_Anti_EAV_Aware;`
|
||||
* inherit all methods from `TMDO_Anti_EAV_Aware` transparently.
|
||||
*
|
||||
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
|
||||
*/
|
||||
trait WPDO_Anti_EAV_Aware {
|
||||
use TMDO_Anti_EAV_Aware;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias interface. PHP allows interface inheritance, so
|
||||
* extending the new interface gives implementing classes the same
|
||||
* obligations under the old name.
|
||||
*/
|
||||
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_API — public facade for partner plugins.
|
||||
*
|
||||
* Provides the canonical, stable API contract for reading/writing entity
|
||||
* fields managed by WP Data Optimizer. Partner plugins MUST use this API
|
||||
* instead of direct `get_post_meta()`, `get_user_meta()`, etc., when the
|
||||
* field is registered to WPDO.
|
||||
*
|
||||
* Part A.4 of the Anti-EAV Playbook (plan file).
|
||||
*
|
||||
* Usage:
|
||||
* TMDO_API::get_field( $post_id, 'hp_price' ); // post entity
|
||||
* TMDO_API::get_entity( 'user', $uid, 'points' ); // any entity
|
||||
* TMDO_API::set_field( $post_id, 'hp_price', '100.00' );
|
||||
* TMDO_API::query( [ 'post_type' => '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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Back-compat aliases — 對外保留 WPDO_* 類別 / 常數,讓 hub-core / spoke-sso /
|
||||
* 其他 sister plugins 在升級到 2meet-data-optimizer 後不需修改任何 class_exists()
|
||||
* / new WPDO_X() / WPDO_API::xxx() 呼叫。
|
||||
*
|
||||
* Phase 1 起穩定。v0.2.0 後可加 _doing_it_wrong() deprecation notice。
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 公開合約類別(Phase 1 凍結,不可移除)─────────────────────────────
|
||||
$tmdo_class_aliases = array(
|
||||
'TMDO_API' => '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 );
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Object Cache integration layer for Zone C (Cold) data.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object Cache integration layer for Zone C (Cold) data.
|
||||
*
|
||||
* Provides a unified API for cache operations across all cold post types,
|
||||
* with bulk prefetch support for reducing DB queries on archive pages.
|
||||
*
|
||||
* This class sits on top of TMDO_Zone_Cold and adds:
|
||||
* - Bulk prefetch for multiple posts (archive/search result pages)
|
||||
* - Cache warming on post save
|
||||
* - Cache statistics for admin dashboard
|
||||
* - Group-level cache flush
|
||||
*/
|
||||
class TMDO_Cache_Layer {
|
||||
|
||||
/**
|
||||
* Prefetch cold data for multiple posts into Object Cache.
|
||||
*
|
||||
* Call this early on archive/search pages to batch-load cold blobs
|
||||
* in a single query instead of N+1 queries per post.
|
||||
*
|
||||
* @param int[] $post_ids Array of post IDs to prefetch.
|
||||
* @param string $post_type Post type.
|
||||
*/
|
||||
public static function prefetch( array $post_ids, string $post_type ): void {
|
||||
if ( empty( $post_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Capability — Multisite-aware admin capability check (v2.14.0).
|
||||
*
|
||||
* Centralizes the "can this user manage WPDO?" decision. Behaviour:
|
||||
*
|
||||
* - Single-site: require `manage_options` (site admin)
|
||||
* - Multisite per-site: require `manage_options` (site admin on that site)
|
||||
* - Multisite network: super admin always passes; site admin still passes
|
||||
* on their own site if they hold `manage_options`
|
||||
*
|
||||
* Pre-v2.14.0 the codebase called `current_user_can('manage_options')` in 17
|
||||
* places. On a network-admin context page, super admins do NOT automatically
|
||||
* have `manage_options` — WordPress maps it to `do_not_allow` for non-super
|
||||
* admins and lets `is_super_admin()` carry the decision separately. By
|
||||
* routing through this helper, the plugin works identically on single-site
|
||||
* and multisite without per-call-site if/else.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.14.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper class — drop-in replacement for `current_user_can('manage_options')`.
|
||||
*/
|
||||
final class TMDO_Capability {
|
||||
|
||||
/**
|
||||
* Whether the current user can manage WP Data Optimizer.
|
||||
*
|
||||
* Super admins always pass on multisite; site admins pass on their site.
|
||||
* On single-site WordPress this is functionally identical to
|
||||
* `current_user_can('manage_options')`.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function current_user_can_admin(): bool {
|
||||
if ( function_exists( 'is_multisite' ) && is_multisite()
|
||||
&& function_exists( 'is_super_admin' ) && is_super_admin()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return current_user_can( 'manage_options' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||
/**
|
||||
* TMDO_Comment_Stress_Tester — Async stress fixture generator for comment entity (v2.13.1).
|
||||
*
|
||||
* Mirrors TMDO_Term_Stress_Tester (v2.13.0) but adapted for comment entity:
|
||||
*
|
||||
* - Source: $wpdb->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<string,mixed>|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<string,mixed>
|
||||
*/
|
||||
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' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Commentmeta_Cleaner — wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes four classes of low-value rows from wp_commentmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - wxr_import — `_wxr_import_*` rows from WP importer (pure tracking)
|
||||
* On dev10 alone: 211 rows = 75% of all commentmeta
|
||||
* - demo_data — `_2meet_demo_*` rows used to mark demo content
|
||||
* - transients — `_transient_*` and `_transient_timeout_*` rows
|
||||
* - orphan_post_meta — Stray post-meta keys mistakenly written to
|
||||
* commentmeta (e.g., `_hp_price`, `_hp_status`,
|
||||
* `_thumbnail_id`). These are bugs / typos / earlier
|
||||
* plugin defects — never read from commentmeta.
|
||||
*
|
||||
* Mirrors TMDO_Postmeta_Cleaner / TMDO_Termmeta_Cleaner pattern.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*/
|
||||
class TMDO_Commentmeta_Cleaner {
|
||||
|
||||
public const TARGET_WXR_IMPORT = 'wxr_import';
|
||||
public const TARGET_DEMO_DATA = 'demo_data';
|
||||
public const TARGET_TRANSIENTS = 'transients';
|
||||
public const TARGET_ORPHAN_POST_META = 'orphan_post_meta';
|
||||
public const TARGET_ALL = 'all';
|
||||
|
||||
public const VALID_TARGETS = array(
|
||||
self::TARGET_WXR_IMPORT,
|
||||
self::TARGET_DEMO_DATA,
|
||||
self::TARGET_TRANSIENTS,
|
||||
self::TARGET_ORPHAN_POST_META,
|
||||
self::TARGET_ALL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Known post-meta keys that have been observed mis-written to commentmeta.
|
||||
* Conservative list — only includes keys that:
|
||||
* 1. Are clearly post-domain (start with `_hp_*` / `_thumbnail_id` / etc.)
|
||||
* 2. Have NO known consumer reading from get_comment_meta() with that key
|
||||
* 3. Were observed in real production data (dev10) as orphan rows
|
||||
*/
|
||||
private const ORPHAN_POST_META_KEYS = array(
|
||||
'_hp_price',
|
||||
'_hp_status',
|
||||
'_hp_featured',
|
||||
'_hp_verified',
|
||||
'_hp_view_count',
|
||||
'_thumbnail_id',
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
);
|
||||
|
||||
/**
|
||||
* Count rows that would be cleaned 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 count_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$counts = array(
|
||||
'wxr_import' => 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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin coexistence manager for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin coexistence manager.
|
||||
*
|
||||
* Detects active plugins and determines whether WPDO should register
|
||||
* its interceptors or defer to existing plugins.
|
||||
*/
|
||||
class TMDO_Compatibility {
|
||||
|
||||
/**
|
||||
* Cached detection results.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static array $detected = array();
|
||||
|
||||
/**
|
||||
* Run all compatibility checks. Called once during TMDO_Core::run().
|
||||
*
|
||||
* @return array Detection results.
|
||||
*/
|
||||
public static function check(): array {
|
||||
if ( ! empty( self::$detected ) ) {
|
||||
return self::$detected;
|
||||
}
|
||||
|
||||
self::$detected = array(
|
||||
'hpct_active' => self::is_hpct_active(),
|
||||
'hpct_imported' => self::is_hpct_imported(),
|
||||
'hivepress' => self::is_hivepress_active(),
|
||||
'woocommerce' => self::is_woocommerce_active(),
|
||||
);
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Conflict_Monitor — production-time conflict detection for WPDO.
|
||||
*
|
||||
* Wraps TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() and the UAE-port
|
||||
* TMDO_Conflict_Detector (UAEPG cross-plugin scan) into a single facade with:
|
||||
*
|
||||
* - admin_init scan + admin_notices warning when conflicts detected
|
||||
* - admin_bar warning chip when conflicts > 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<int, array{type:string, hook?:string, priority?:int, callback?:string, entity_type?:string, meta_key?:string}>|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<int, 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<int, 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(
|
||||
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||||
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<int,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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
<?php
|
||||
/**
|
||||
* Main loader for WP Data Optimizer components.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loader — bootstraps all WPDO components.
|
||||
*
|
||||
* Loaded at plugins_loaded priority 4 (before HPCT at priority 5).
|
||||
*/
|
||||
class TMDO_Core {
|
||||
|
||||
/**
|
||||
* Boot the plugin.
|
||||
*/
|
||||
public function run(): void {
|
||||
// Schema upgrade check.
|
||||
TMDO_Installer::maybe_upgrade();
|
||||
|
||||
// Run compatibility checks.
|
||||
$compat = TMDO_Compatibility::check();
|
||||
|
||||
// Fire the schema registry hook so integrations can register their fields.
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
|
||||
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
|
||||
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
|
||||
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
|
||||
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
|
||||
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
|
||||
// arrive too late and the do_action below misses them — observed via
|
||||
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
|
||||
// register() is side-effect-free aside from add_action(), so pre-registering
|
||||
// is provably safe (no hidden dependency on later-state global init).
|
||||
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
|
||||
// via its own bridge class. Other partners still pre-registered here pending
|
||||
// their own decentralized migrations (R1 continuation in v2.2.x).
|
||||
$partner_integrations = array(
|
||||
'TMDO_Infocards',
|
||||
'TMDO_Bookings',
|
||||
'TMDO_Quotation',
|
||||
'TMDO_Mobile_Bridge',
|
||||
'TMDO_Collab',
|
||||
'TMDO_Playlist',
|
||||
'TMDO_WooCommerce',
|
||||
'TMDO_Member_Fields',
|
||||
'TMDO_Post_Fields',
|
||||
'TMDO_Hivepress_Term_Comment_Fields',
|
||||
);
|
||||
foreach ( $partner_integrations as $cls ) {
|
||||
if ( class_exists( $cls ) ) {
|
||||
call_user_func( array( $cls, 'register' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: wpdo_register_fields
|
||||
*
|
||||
* Plugins register their meta_key → zone mappings here.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry The schema registry instance.
|
||||
*/
|
||||
do_action( 'wpdo_register_fields', $registry );
|
||||
|
||||
// v2.1.3 R2 safety net: re-fire on init:1 to catch partners that registered
|
||||
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
||||
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
||||
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
||||
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
|
||||
//
|
||||
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
||||
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
||||
// fields. TMDO_Entity_Registry::register_group() has internal dedup. After all
|
||||
// registrations land, kick `TMDO_Schema_Manager::process_pending_migrations()` once
|
||||
// per request to materialize any newly-defined entity flat tables (idempotent via
|
||||
// schema_hash compare — no DDL when unchanged).
|
||||
if ( ! did_action( 'wpdo_late_bind_setup' ) ) {
|
||||
add_action(
|
||||
'init',
|
||||
static function () use ( $registry ) {
|
||||
do_action( 'wpdo_register_fields', $registry );
|
||||
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
||||
}
|
||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||
TMDO_Custom_Table_Registry::instance()->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_<taxonomy> 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'
|
||||
);
|
||||
?>
|
||||
<div class="notice notice-warning">
|
||||
<p>
|
||||
<strong>WP Data Optimizer:</strong>
|
||||
<?php echo esc_html( $alert ); ?>
|
||||
<a href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( '關閉', '2meet-data-optimizer' ); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: Clean up expired Zone B (warm) entries.
|
||||
*/
|
||||
public function cleanup_warm_zone(): void {
|
||||
TMDO_Zone_Warm::purge_expired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the post shadow verifier cron event when post mode crosses
|
||||
* the shadow_read threshold (v2.10.3).
|
||||
*
|
||||
* Fires on `wpdo_bridge_mode_changed` action emitted by Mode_Manager::set().
|
||||
*
|
||||
* @param string $entity_type Entity type that changed.
|
||||
* @param string $new_mode New mode value.
|
||||
* @param string $old_mode Previous mode value.
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_toggle_post_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||
if ( 'post' !== $entity_type || ! class_exists( 'TMDO_Post_Shadow_Verifier' ) ) {
|
||||
return;
|
||||
}
|
||||
$hook = TMDO_Post_Shadow_Verifier::CRON_HOOK;
|
||||
if ( 'shadow_read' === $new_mode && ! wp_next_scheduled( $hook ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
|
||||
} elseif ( 'shadow_read' !== $new_mode ) {
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the term/comment shadow verifier cron when EITHER term or
|
||||
* comment mode crosses the shadow_read threshold (v2.12.5).
|
||||
*
|
||||
* @param string $entity_type Entity that changed (term/comment/user/post).
|
||||
* @param string $new_mode New mode.
|
||||
* @param string $old_mode Previous mode (unused).
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_toggle_term_comment_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||
unset( $old_mode );
|
||||
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
$hook = TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK;
|
||||
|
||||
// Recompute "should run" based on BOTH entities' current modes.
|
||||
$should_run = 'shadow_read' === $new_mode
|
||||
|| 'shadow_read' === TMDO_Mode_Manager::get( 'term' )
|
||||
|| 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
|
||||
|
||||
if ( $should_run && ! wp_next_scheduled( $hook ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
|
||||
} elseif ( ! $should_run ) {
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: Archive stale postmeta for trashed posts.
|
||||
*/
|
||||
public function sweep_archive_zone(): void {
|
||||
TMDO_Zone_Archive::sweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render admin notice when hp-custom-tables is detected but not imported.
|
||||
*/
|
||||
public function render_hpct_notice(): void {
|
||||
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
|
||||
?>
|
||||
<div class="notice notice-warning is-dismissible">
|
||||
<p>
|
||||
<strong>WP Data Optimizer:</strong>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: URL to import page */
|
||||
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
|
||||
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* WP Cron handler: process one backfill batch for an entity/group pair.
|
||||
*
|
||||
* Re-schedules itself (via wp_schedule_single_event) until migration is done.
|
||||
*
|
||||
* @param string $entity_type Entity type (user|term|comment).
|
||||
* @param string $group_name Registered group name.
|
||||
*/
|
||||
public function run_entity_backfill_batch( string $entity_type, string $group_name ): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( $entity_type, $group_name );
|
||||
|
||||
if ( ! $result['done'] ) {
|
||||
// Re-schedule next batch (5 s delay to avoid overwhelming DB).
|
||||
wp_schedule_single_event( time() + 5, 'wpdo_entity_backfill_batch', array( $entity_type, $group_name ) );
|
||||
}
|
||||
|
||||
TMDO_Logger::info(
|
||||
'entity_backfill_batch',
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
'migrated' => $result['migrated'],
|
||||
'total' => $result['total'],
|
||||
'done' => $result['done'],
|
||||
'status' => $result['status'],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Crypto — AES-256-GCM authenticated encryption for sensitive option values.
|
||||
*
|
||||
* Derives a site-specific key from AUTH_KEY + SECURE_AUTH_SALT so ciphertext
|
||||
* is useless outside this WordPress installation.
|
||||
*
|
||||
* Storage formats:
|
||||
* - "enc:v2:<base64(iv12 . tag16 . ciphertext)>" (current — AES-256-GCM, AEAD)
|
||||
* - "enc:v1:<base64(iv16 . ciphertext)>" (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<string,string>
|
||||
* }
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Table Registry — third-party plugin custom-table awareness.
|
||||
*
|
||||
* Allows partner plugins (2meet-courses, 2meet-bookings, 2meet-infocards, etc.)
|
||||
* to register their own custom tables so WPDO can include them in:
|
||||
* - `wp wpdo doctor` health checks
|
||||
* - `wp wpdo benchmark --custom-tables`
|
||||
* - Backup/cleanup tooling
|
||||
* - Site monitor metrics
|
||||
*
|
||||
* Solves audit finding R-3 (Custom Table Provider missing).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry for partner plugin custom tables.
|
||||
*
|
||||
* Usage (from a partner plugin):
|
||||
* add_action( 'wpdo_register_custom_tables', function ( TMDO_Custom_Table_Registry $r ) {
|
||||
* $r->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<string, array{provider:string, table_name:string, primary_key:string, post_type_link:?string, doctor_callback:?callable, benchmark_callback:?callable, expected_columns:array, indexes: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<string, array<string>> 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<string,string>) Column => SQL type for schema drift detection.
|
||||
* - indexes (array<string,string[]>) 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<string, array>
|
||||
*/
|
||||
public function all(): array {
|
||||
return $this->tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables for a specific provider.
|
||||
*
|
||||
* @param string $provider Plugin slug.
|
||||
* @return array<string, 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<string, 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<string, array{provider:string, table:string, ok:bool, message:string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* Database abstraction layer for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDO_DB — Database abstraction layer for MySQL / SQLite dual support.
|
||||
*
|
||||
* Provides unified transaction interface, SQL dialect helpers, and
|
||||
* table name resolution. Absorbs and extends the FCB_DB pattern.
|
||||
*/
|
||||
class TMDO_DB {
|
||||
|
||||
/**
|
||||
* Check if the current database engine is SQLite.
|
||||
*/
|
||||
public static function is_sqlite(): bool {
|
||||
return TMDO_IS_SQLITE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current database engine is MySQL.
|
||||
*/
|
||||
public static function is_mysql(): bool {
|
||||
return TMDO_IS_MYSQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a database transaction.
|
||||
*
|
||||
* SQLite: BEGIN IMMEDIATE (write-lock, prevents concurrent writes).
|
||||
* MySQL: START TRANSACTION (row-level locking via InnoDB).
|
||||
*/
|
||||
public static function begin(): void {
|
||||
global $wpdb;
|
||||
$wpdb->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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
/**
|
||||
* Feature flags manager for WPDO module lifecycle states.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flags for each WPDO module.
|
||||
*
|
||||
* Option key: wpdo_features (serialized array)
|
||||
*
|
||||
* 7-state lifecycle per module (extends HPCT's 4-state model):
|
||||
* idle → dual_write → backfill → verify → cutover → cleanup → complete
|
||||
*
|
||||
* Any state can roll back to idle.
|
||||
*/
|
||||
class TMDO_Feature_Flags {
|
||||
|
||||
/** Option name storing all module states. */
|
||||
private const OPTION_KEY = 'wpdo_features';
|
||||
|
||||
/**
|
||||
* All known HPCT-inherited modules.
|
||||
* These modules operate on existing hpct_* tables.
|
||||
*/
|
||||
public const HPCT_MODULES = array(
|
||||
'reviews',
|
||||
'messages',
|
||||
'favorites',
|
||||
'memberships',
|
||||
'statistics',
|
||||
'requests',
|
||||
'listing_meta',
|
||||
'wc_orders',
|
||||
'latepoint',
|
||||
);
|
||||
|
||||
/**
|
||||
* Zone-based modules (new in WPDO).
|
||||
* These modules operate on wpdo_* tables.
|
||||
*/
|
||||
public const ZONE_MODULES = array(
|
||||
'hot_hp_listing',
|
||||
'hot_hp_vendor',
|
||||
'warm',
|
||||
'cold_hp_listing',
|
||||
'cold_hp_vendor',
|
||||
'archive',
|
||||
);
|
||||
|
||||
/** Valid lifecycle states. */
|
||||
public const VALID_STATES = array(
|
||||
'idle',
|
||||
'dual_write',
|
||||
'backfill',
|
||||
'verify',
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which interceptors should capture writes (dual-write active).
|
||||
*/
|
||||
public const WRITE_ACTIVE_STATES = array(
|
||||
'dual_write',
|
||||
'backfill',
|
||||
'verify',
|
||||
'cutover',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which reads come from the custom/zone table.
|
||||
*/
|
||||
public const READ_CUSTOM_STATES = array(
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which query interceptors are active.
|
||||
*/
|
||||
public const QUERY_ACTIVE_STATES = array(
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the state of a single module.
|
||||
*
|
||||
* @param string $module Module name.
|
||||
* @return string One of VALID_STATES, defaults to 'idle'.
|
||||
*/
|
||||
public static function get( string $module ): string {
|
||||
$flags = self::all();
|
||||
return $flags[ $module ] ?? 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of a module.
|
||||
*
|
||||
* V2.2.0 M2: gated by TMDO_FSM_Guard — invalid transitions are blocked
|
||||
* (returns false), destructive transitions automatically snapshot first.
|
||||
* Filter `wpdo/fsm_guard/bypass` allows CLI/tests to override.
|
||||
*
|
||||
* @param string $module Module name.
|
||||
* @param string $state One of VALID_STATES.
|
||||
* @return bool|WP_Error true on success, WP_Error on guard rejection,
|
||||
* false on invalid state name.
|
||||
*/
|
||||
public static function set( string $module, string $state ) {
|
||||
if ( ! in_array( $state, self::VALID_STATES, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// FSM Guard: validate transition + auto-snapshot if destructive.
|
||||
// Defensive: only invoke when class is loaded AND not explicitly
|
||||
// disabled (raw-PHP unit harness sets TMDO_FSM_GUARD_DISABLED).
|
||||
// Dedicated FSMGuardTest unsets the constant before its own assertions.
|
||||
$guard_active = class_exists( 'TMDO_FSM_Guard' )
|
||||
&& ! ( defined( 'TMDO_FSM_GUARD_DISABLED' ) && TMDO_FSM_GUARD_DISABLED );
|
||||
if ( $guard_active ) {
|
||||
$check = TMDO_FSM_Guard::before_transition( $module, $state );
|
||||
if ( is_wp_error( $check ) ) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
$flags = self::all();
|
||||
$flags[ $module ] = $state;
|
||||
update_option( self::OPTION_KEY, $flags );
|
||||
self::$cache = null; // Invalidate request-level cache.
|
||||
|
||||
// Record entry timestamp (M2) for downstream verify-gate / wash-period checks.
|
||||
if ( $guard_active ) {
|
||||
TMDO_FSM_Guard::record_entry( $module, $state );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── shadow_read_only sub-flag (PR-4) ────────────────────────────────────
|
||||
// Orthogonal to the main 7-state FSM. When enabled for a module that is in
|
||||
// the `verify` state, every read fans out to BOTH postmeta and the zone
|
||||
// table; divergence is logged to wp_wpdo_shadow_diffs without affecting
|
||||
// the served value. Lets ops bake confidence before cutover.
|
||||
|
||||
/** Option key storing per-module shadow_read_only flags. */
|
||||
private const SHADOW_OPTION_KEY = 'wpdo_features_shadow';
|
||||
|
||||
/**
|
||||
* Request-level cache for shadow flags.
|
||||
*
|
||||
* @var array<string,bool>|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<string,bool>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Hook_Bus_Bridge — feature-flagged Hook Bus integration layer.
|
||||
*
|
||||
* PR-3 introduces a unified Hook Bus that will eventually replace the per-
|
||||
* interceptor `add_filter()` registrations. To avoid breaking production
|
||||
* during the transition, the unified bus is opt-in via the
|
||||
* `wpdo_hook_bus_enabled` option (default: false).
|
||||
*
|
||||
* When enabled, the Hook Bus takes over `update_post_metadata` /
|
||||
* `get_post_metadata` at priority 8, dispatching to handlers (via
|
||||
* TMDO_Adapter_Post + TMDO_Entity_Registry). When disabled, the existing
|
||||
* 9 interceptors continue to operate at priority 10 untouched.
|
||||
*
|
||||
* Use `TMDO_Hook_Bus_Bridge::is_enabled()` to gate any code path that
|
||||
* should defer to the unified bus.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge / feature-flag layer between the legacy interceptors and the unified
|
||||
* TMDO_Hook_Bus introduced by the UAE port.
|
||||
*/
|
||||
final class TMDO_Hook_Bus_Bridge {
|
||||
|
||||
/** Option name for the feature flag. */
|
||||
public const OPTION = 'wpdo_hook_bus_enabled';
|
||||
|
||||
/**
|
||||
* Request-level cache for is_enabled() checks.
|
||||
*
|
||||
* @var bool|null
|
||||
*/
|
||||
private static ?bool $cache = null;
|
||||
|
||||
/**
|
||||
* Whether the unified Hook Bus is enabled for this site.
|
||||
*
|
||||
* Default: false (legacy interceptors at priority 10 remain active).
|
||||
* To enable: `update_option( 'wpdo_hook_bus_enabled', '1' )` or
|
||||
* `wp option update wpdo_hook_bus_enabled 1`.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
if ( null !== self::$cache ) {
|
||||
return self::$cache;
|
||||
}
|
||||
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
|
||||
self::$cache = ( '1' === (string) $value || true === $value );
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset request cache — for tests and runtime mode toggles.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_cache(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the unified Hook Bus when enabled.
|
||||
*
|
||||
* Called from TMDO_Core::run() after legacy interceptors have a chance to
|
||||
* register. The Hook Bus will silently skip startup if disabled.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_init_hook_bus(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_Hook_Bus' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the unified bus. Only Adapter_Post handlers will be
|
||||
// registered by default (PR-3 scope); other entity adapters land in PR-5.
|
||||
TMDO_Hook_Bus::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of WPDO interceptors that are designed to coexist on the same
|
||||
* hook + priority. They each filter on a disjoint meta_key prefix, so
|
||||
* "multiple callbacks per hook" does not imply a real conflict.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const COEXIST_WHITELIST = array(
|
||||
'TMDO_Reviews_Interceptor',
|
||||
'TMDO_Messages_Interceptor',
|
||||
'TMDO_Favorites_Interceptor',
|
||||
'TMDO_Memberships_Interceptor',
|
||||
'TMDO_Statistics_Interceptor',
|
||||
'TMDO_Requests_Interceptor',
|
||||
'TMDO_Listing_Meta_Interceptor',
|
||||
'TMDO_WC_Orders_Interceptor',
|
||||
'TMDO_LatePoint_Interceptor',
|
||||
'TMDO_Sync_Bridge',
|
||||
'TMDO_Cache_Layer',
|
||||
'TMDO_Listing_Stats',
|
||||
'TMDO_Hook_Bus',
|
||||
// v2.11.5+: HivePress per-post transient cache rerouter. Filters at
|
||||
// priority 9 (before Hook Bus at 10) and matches only `_transient_hp_*`
|
||||
// meta_keys — fully disjoint from any other interceptor.
|
||||
'TMDO_Hivepress_Transient_Filter',
|
||||
// v2.12.1+: Term + Comment garbage write-time filter. Filters
|
||||
// add/update_term_metadata + add/update_comment_metadata at priority 9
|
||||
// and matches only `_wxr_import_*` / `_2meet_demo_*` / 8 orphan
|
||||
// post-meta keys — fully disjoint from any other interceptor.
|
||||
'TMDO_Term_Comment_Garbage_Filter',
|
||||
// v2.12.3+: WC term count cache rerouter. Filters at priority 9 and
|
||||
// matches only `product_count_*` term meta keys — fully disjoint
|
||||
// from any other interceptor.
|
||||
'TMDO_WC_Term_Count_Filter',
|
||||
// v2.12.4+: Term + Comment misc bucket (catch-all). Filters at
|
||||
// priority 99 (LAST), only handles writes where every other filter
|
||||
// returned null. Disjoint by design.
|
||||
'TMDO_Term_Comment_Misc_Bucket',
|
||||
// v2.13.0+: Term Stress Tester. Bulk fixture generator that doesn't
|
||||
// register any metadata filter — listed here so admin Conflict Detector
|
||||
// recognizes it as a known WPDO class even though it's filter-disjoint.
|
||||
'TMDO_Term_Stress_Tester',
|
||||
// v2.13.1+: Comment Stress Tester. Same role for comment entity —
|
||||
// bulk fixture generator with no metadata filter registration.
|
||||
'TMDO_Comment_Stress_Tester',
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect interceptor priority overlap that risks duplicate writes.
|
||||
*
|
||||
* Inspects the metadata filters and reports cases where two non-whitelisted
|
||||
* WPDO callbacks compete on the same hook. Whitelisted interceptors (the 9
|
||||
* HPCT-inherited modules + Sync_Bridge) are designed to coexist by
|
||||
* filtering on disjoint meta_key prefixes — they are NOT real conflicts.
|
||||
*
|
||||
* Real conflicts: a third-party plugin registers `TMDO_Custom_*` AND
|
||||
* collides with an existing whitelisted interceptor on the same priority.
|
||||
*
|
||||
* Used by the admin Conflict Detector tab + `wp wpdo conflict-scan`.
|
||||
*
|
||||
* @return array<int, array{hook:string, priority:int, callback:string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Error logging for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight error logger — writes to wpdo_errors table and PHP error_log.
|
||||
*/
|
||||
class TMDO_Logger {
|
||||
|
||||
/**
|
||||
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
||||
*
|
||||
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
|
||||
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
||||
* many times per request — keeping them out of the DB error table.
|
||||
*
|
||||
* @param string $event Event name (e.g. 'bridge_mode_changed').
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function info( string $event, array $context = array() ): void {
|
||||
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a NOTICE-level event — alias of info() for callers that want a stronger
|
||||
* level intent. Same routing (error_log only).
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function notice( string $event, array $context = array() ): void {
|
||||
self::info( $event, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a WARNING-level event — error_log + wpdo_errors row (for admin visibility).
|
||||
*
|
||||
* Use for "anomaly worth attention but not breaking" (e.g. invalid filter
|
||||
* return, unexpected fallback path). The event_name becomes the `hook` column;
|
||||
* message gets a `[WARN]` prefix to distinguish from hard errors.
|
||||
*
|
||||
* @param string $event Event name (e.g. 'wpdo_route_decision_invalid_return').
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function warning( string $event, array $context = array() ): void {
|
||||
error_log( sprintf( '[WPDO][WARN][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
self::error( 'engine', $event, '[WARN] ' . $event, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a DEBUG-level event — only when WP_DEBUG is true; routes to error_log.
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function debug( string $event, array $context = array() ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::info( $event, $context );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a module error.
|
||||
*
|
||||
* @param string $module Module name (e.g. 'reviews', 'hot_hp_listing').
|
||||
* @param string $hook The hook or method where the error occurred.
|
||||
* @param string $message Human-readable error message.
|
||||
* @param array $context Optional extra context (will be JSON-encoded).
|
||||
* @param string $zone Optional zone identifier (hot/warm/cold/archive).
|
||||
*/
|
||||
public static function error( string $module, string $hook, string $message, array $context = array(), string $zone = '' ): void {
|
||||
global $wpdb;
|
||||
|
||||
error_log( sprintf( '[WPDO][%s][%s] %s', $module, $hook, $message ) );
|
||||
|
||||
$table = TMDO_DB::table( 'wpdo_errors' );
|
||||
$wpdb->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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
|
||||
/**
|
||||
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
|
||||
*
|
||||
* Companion to TMDO_Post_Migration. Used during shadow_read mode to verify
|
||||
* the flat tables stay in sync with wp_postmeta. Each sample picks a random
|
||||
* post + key, fetches both values (flat row vs wp_postmeta row), and counts
|
||||
* matches / diffs / missing rows. Divergent values are written to the
|
||||
* shared `wpdo_shadow_diffs` table via `TMDO_Shadow_Diff_Logger` (which is
|
||||
* already entity_type-aware — pass 'post' to differentiate from user diffs).
|
||||
*
|
||||
* Run pattern:
|
||||
* - Manual: `wp wpdo post-shadow-report`
|
||||
* - Cron: wpdo_post_shadow_verify event registered when post mode is
|
||||
* shadow_read; auto-unregistered when mode changes to anything else
|
||||
*
|
||||
* 🔒 Frozen contract: never touches user-side flat tables, never modifies
|
||||
* wp_postmeta or wp_posts (read-only verifier).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.10.3
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Verifier is read-only.
|
||||
|
||||
/**
|
||||
* Sample-and-compare verifier for post entity flat tables.
|
||||
*/
|
||||
final class TMDO_Post_Shadow_Verifier {
|
||||
|
||||
/**
|
||||
* Cron event hook fired hourly when post mode is shadow_read.
|
||||
*/
|
||||
public const CRON_HOOK = 'wpdo_post_shadow_verify';
|
||||
|
||||
/**
|
||||
* Default sample size per cron tick (capped to available post count).
|
||||
*/
|
||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Run a sample-compare pass.
|
||||
*
|
||||
* Picks $sample_size random posts of $post_type, fetches their flat row
|
||||
* + their wp_postmeta values for each $keys entry, and counts matches.
|
||||
* Divergences are recorded via TMDO_Shadow_Diff_Logger when the class
|
||||
* is available.
|
||||
*
|
||||
* @param string $post_type WP post_type to sample.
|
||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
||||
* @param string $flat_table Fully qualified flat table name.
|
||||
* @param string[] $keys Meta keys to compare (each post checks all).
|
||||
* @param int $sample_size Number of posts to sample (capped to DB count).
|
||||
* @return array{
|
||||
* sampled:int,
|
||||
* matched:int,
|
||||
* diffs:int,
|
||||
* missing_flat:int,
|
||||
* missing_postmeta:int,
|
||||
* group:string,
|
||||
* post_type:string,
|
||||
* }
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function sample_compare(
|
||||
string $post_type,
|
||||
string $group_name,
|
||||
string $flat_table,
|
||||
array $keys,
|
||||
int $sample_size = self::DEFAULT_SAMPLE_SIZE
|
||||
): array {
|
||||
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;
|
||||
|
||||
$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<string,int>}
|
||||
*/
|
||||
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' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Postmeta_Cleaner — wp_postmeta garbage cleanup (v2.9.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes three classes of low-value rows from wp_postmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - transients — stale `_transient_*` and `_transient_timeout_*` rows
|
||||
* (often left by HivePress model version cache)
|
||||
* - wp_old_date — WP core internal record of post date changes (no app value)
|
||||
* - edit_locks — `_edit_lock` rows whose lock timestamp is > 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 "<unix_ts>:<user_id>"; 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Safe_Unserialize — Object-injection-safe replacement for
|
||||
* `maybe_unserialize()` (v2.13.3 — fixes L-DESER-1).
|
||||
*
|
||||
* `maybe_unserialize()` calls `unserialize()` with default options, which
|
||||
* materializes objects and triggers __wakeup / __destruct magic methods.
|
||||
* Attacker-planted serialized payloads in DB-stored values (postmeta,
|
||||
* usermeta, options) become RCE vectors when any vendor library exposes
|
||||
* a usable gadget chain.
|
||||
*
|
||||
* This wrapper passes `['allowed_classes' => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
/**
|
||||
* Central registry for zone field configurations.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central registry for zone field configurations.
|
||||
*
|
||||
* Plugins register their meta_key → zone mappings here.
|
||||
* The registry drives table creation, migration, interceptors, and query routing.
|
||||
*
|
||||
* Usage:
|
||||
* $registry = TMDO_Schema_Registry::instance();
|
||||
* $registry->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<string, array>
|
||||
*/
|
||||
private array $fields = array();
|
||||
|
||||
/**
|
||||
* Zone → post_type → columns map for Zone A (hot) table creation.
|
||||
*
|
||||
* @var array<string, array<string, array>>
|
||||
*/
|
||||
private array $hot_columns = array();
|
||||
|
||||
/**
|
||||
* Zone C (cold) fields grouped by post_type.
|
||||
*
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
private array $cold_fields = array();
|
||||
|
||||
/**
|
||||
* Zone B (warm) fields with TTL config.
|
||||
*
|
||||
* @var array<string, 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<string, string> 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<string, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
/**
|
||||
* SQLite information schema compatibility for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the SQLite info_schema emulation table so dbDelta does not
|
||||
* re-run CREATE TABLE on every page load.
|
||||
*
|
||||
* The SQLite drop-in reads _wp_sqlite_mysql_information_schema_columns
|
||||
* to know which columns already exist. We must insert a row for each
|
||||
* column in each WPDO table immediately after dbDelta().
|
||||
*
|
||||
* Columns are inserted via INSERT OR IGNORE so the method is idempotent.
|
||||
*/
|
||||
class TMDO_SQLite_Compat {
|
||||
|
||||
/**
|
||||
* Patch all WPDO system tables into the SQLite info_schema.
|
||||
*/
|
||||
public static function patch_all(): void {
|
||||
if ( ! TMDO_IS_SQLITE ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$p = $wpdb->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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Backfill — Pivot-style backfill from wp_*meta to flat
|
||||
* tables for term + comment entities (v2.12.6 Phase 6).
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* Phase 1–5 cover write-time interception (anything written via the
|
||||
* metadata API after v2.12.x activation goes to flat tables / wp_options /
|
||||
* misc bucket / drop). But **historical** wp_termmeta / wp_commentmeta rows
|
||||
* predating v2.12.x are still in those tables — they were never intercepted
|
||||
* because they were already there.
|
||||
*
|
||||
* Phase 5's shadow_read verifier surfaces these as `missing_flat` drift
|
||||
* (e.g. dev10 baseline: 42 term + 23 comment = 65 missing_flat rows).
|
||||
* Without backfill, promoting to aeav_only would silently drop reads of
|
||||
* those historical values (the read path goes flat → empty → fall-through
|
||||
* to wp_termmeta DOES still work for now, but only because we left the
|
||||
* fall-through in place; in v3.0.0 we DROP the source tables).
|
||||
*
|
||||
* Strategy
|
||||
* --------
|
||||
* One-shot SQL pivot per group, mirroring the user-side backfill pattern
|
||||
* (v2.5.5 user_membership backfill, etc.):
|
||||
*
|
||||
* INSERT INTO wp_wpdo_term_hp_taxonomy (term_id, hp_sort_order, hp_default, hp_icon)
|
||||
* SELECT t.term_id,
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_sort_order' THEN tm.meta_value END),
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_default' THEN tm.meta_value END),
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_icon' THEN tm.meta_value END)
|
||||
* FROM wp_terms t
|
||||
* INNER JOIN wp_termmeta tm ON tm.term_id = t.term_id
|
||||
* WHERE tm.meta_key IN (...registered keys...)
|
||||
* GROUP BY t.term_id
|
||||
* ON DUPLICATE KEY UPDATE
|
||||
* hp_sort_order = COALESCE(VALUES(hp_sort_order), hp_sort_order),
|
||||
* ...
|
||||
*
|
||||
* COALESCE preserves any value already in the flat table when the wp_*meta
|
||||
* side has NULL for that key — important for partial-coverage backfills
|
||||
* where some keys are present and others aren't on a given entity.
|
||||
*
|
||||
* 🔒 Frozen contract: never touches user / post entities; only term/comment.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.6
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Pivot SQL: keys + table from registry, parameterized via prepare placeholders for keys list. One-shot ops command.
|
||||
|
||||
/**
|
||||
* Pivot-style backfill from wp_termmeta / wp_commentmeta to flat tables.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Backfill {
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string}>
|
||||
*/
|
||||
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<string, array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool}>
|
||||
*/
|
||||
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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Shadow_Verifier — Sample-and-compare flat vs wp_*meta
|
||||
* for term + comment entities (v2.12.5 Phase 5).
|
||||
*
|
||||
* Mirror of TMDO_Post_Shadow_Verifier (v2.10.3). Used during shadow_read mode
|
||||
* to verify the flat tables stay in sync with wp_termmeta / wp_commentmeta.
|
||||
* Each sample picks a random entity + key, fetches both values, counts
|
||||
* matches / diffs / missing rows. Divergent values are written to
|
||||
* `wpdo_shadow_diffs` via TMDO_Shadow_Diff_Logger (entity_type-aware).
|
||||
*
|
||||
* Run pattern:
|
||||
* - Manual: `wp wpdo term-comment-shadow-report`
|
||||
* - Cron: wpdo_term_comment_shadow_verify event registered when EITHER
|
||||
* term or comment mode is shadow_read; auto-unregistered when
|
||||
* both modes are not shadow_read
|
||||
*
|
||||
* 🔒 Frozen contract: read-only verifier. Never modifies wp_termmeta /
|
||||
* wp_commentmeta or flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Read-only.
|
||||
|
||||
/**
|
||||
* Sample-and-compare verifier for term + comment entity flat tables.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
|
||||
/** Cron event hook fired hourly when at least one of term/comment modes is shadow_read. */
|
||||
public const CRON_HOOK = 'wpdo_term_comment_shadow_verify';
|
||||
|
||||
/** Default sample size per entity per cron tick. */
|
||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Keep in sync with
|
||||
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
||||
*/
|
||||
private const GROUP_MAP = array(
|
||||
'hp_taxonomy' => array(
|
||||
'entity_type' => 'term',
|
||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
||||
),
|
||||
'hp_review' => array(
|
||||
'entity_type' => 'comment',
|
||||
'table_suffix' => 'wpdo_comment_hp_review',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Run a sample-compare pass for a single entity group.
|
||||
*
|
||||
* @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<string,int>}
|
||||
*/
|
||||
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<string,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||
/**
|
||||
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
|
||||
*
|
||||
* Mirrors TMDO_Post_Stress_Tester (v2.11.4) for the term entity. Provides:
|
||||
*
|
||||
* - State machine: start / cancel / get_progress / pump_if_due / run_batch / finalize
|
||||
* - Fast mode: bulk INSERT to wp_terms + wp_term_taxonomy + wp_termmeta + flat
|
||||
* - Realistic mode: wp_insert_term + update_term_meta (Hook Bus auto-routes)
|
||||
* - Cron pump for nginx-safe long runs
|
||||
* - Per-batch wall-clock deadline (BATCH_DEADLINE_SEC)
|
||||
* - Benchmark on completion: write metrics + DB sizes + query performance
|
||||
*
|
||||
* Test terms use slug prefix `wpdo-stress-` for unambiguous identification —
|
||||
* cleanup() deletes only those + all matching meta + flat rows.
|
||||
*
|
||||
* 🔒 v2.13.x frozen contract: never touches user / post / comment entities or
|
||||
* their flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.13.0
|
||||
*/
|
||||
|
||||
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->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<string,mixed>|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<string,mixed>
|
||||
*/
|
||||
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' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Termmeta_Cleaner — wp_termmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes three classes of low-value rows from wp_termmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - wxr_import — `_wxr_import_*` rows left by WordPress importer (never
|
||||
* read after import, pure tracking residue)
|
||||
* - demo_data — `_2meet_demo_*` rows used to mark demo content
|
||||
* (can be re-seeded; safe to drop)
|
||||
* - transients — `_transient_*` and `_transient_timeout_*` rows
|
||||
* (HivePress / WC term cache layer; auto-rebuilt)
|
||||
*
|
||||
* Mirrors TMDO_Postmeta_Cleaner pattern. Runs before any term Entity Bridge
|
||||
* migration so subsequent ratio measurements reflect real data, not garbage.
|
||||
* Pure DB layer — no Hook Bus / Entity Bridge coupling.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wp_termmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*/
|
||||
class TMDO_Termmeta_Cleaner {
|
||||
|
||||
public const TARGET_WXR_IMPORT = 'wxr_import';
|
||||
public const TARGET_DEMO_DATA = 'demo_data';
|
||||
public const TARGET_TRANSIENTS = 'transients';
|
||||
public const TARGET_ALL = 'all';
|
||||
|
||||
public const VALID_TARGETS = array(
|
||||
self::TARGET_WXR_IMPORT,
|
||||
self::TARGET_DEMO_DATA,
|
||||
self::TARGET_TRANSIENTS,
|
||||
self::TARGET_ALL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Count rows that would be cleaned 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 count_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$counts = array(
|
||||
'wxr_import' => 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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_V2_Upgrader — atomic v1.3.x → v2.0.0 upgrade orchestrator (PR-7).
|
||||
*
|
||||
* Implements the Part F upgrade strategy from the master plan:
|
||||
* 1. Pre-flight: PHP / WP / MySQL versions, free disk, no in-flight migrations
|
||||
* 2. Backup wpdo_features option (rollback safety net)
|
||||
* 3. dbDelta v2 tables (idempotent)
|
||||
* 4. ALTER existing tables (skip if already migrated)
|
||||
* 5. UAE import (when wp_uae_* present, otherwise no-op)
|
||||
* 6. migrate_feature_flags_v2 — seed entity module states
|
||||
* 7. update wpdo_db_version → 2.0.0
|
||||
* 8. safely_deactivate_uae_plugin (when present)
|
||||
* 9. Schedule cron to remove the UAE plugin directory (5s)
|
||||
* 10. fire wpdo_v2_upgraded action
|
||||
*
|
||||
* Any throw → catch → write wpdo_v2_upgrade_error → restore features → admin
|
||||
* notice. New tables are NOT dropped on failure (idempotent retry).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic upgrade orchestrator.
|
||||
*/
|
||||
final class TMDO_V2_Upgrader {
|
||||
|
||||
/** Target schema version after successful upgrade. */
|
||||
public const TARGET_VERSION = '2.0.0';
|
||||
|
||||
/** Min PHP / WP / MySQL versions enforced by pre-flight. */
|
||||
public const MIN_PHP_VERSION = '8.1';
|
||||
public const MIN_WP_VERSION = '6.0';
|
||||
public const MIN_MYSQL_VERSION = '5.7';
|
||||
|
||||
/** Free-disk threshold in MB. */
|
||||
public const MIN_FREE_DISK_MB = 100;
|
||||
|
||||
/**
|
||||
* Pre-flight check — returns map of check_name => bool.
|
||||
*
|
||||
* @param array $opts Optional overrides (mostly for tests).
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone Classifier for wp_postmeta analysis and zone assignment suggestions.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone Classifier — analyzes wp_postmeta and suggests zone assignments.
|
||||
*
|
||||
* Examines meta_key usage patterns across the database to recommend
|
||||
* which zone each key should belong to:
|
||||
*
|
||||
* Hot (A) — Used in WP_Query meta_query (search/filter); numeric or short values
|
||||
* Warm (B) — Transient/computed data; infrequently updated
|
||||
* Cold (C) — Display data read often but never queried; long text/JSON blobs
|
||||
* Archive (D) — Belongs to trashed/old posts; rarely if ever accessed
|
||||
*
|
||||
* Signals analyzed:
|
||||
* - Value length distribution (short = hot candidate, long = cold candidate)
|
||||
* - Post status distribution (trash/draft heavy = archive candidate)
|
||||
* - Key prefix patterns (hp_, _hp_, _transient_ etc.)
|
||||
* - Whether the key appears in meta_query (via slow query log or heuristics)
|
||||
* - Distinct value cardinality (low = likely enum/flag = hot)
|
||||
*
|
||||
* Used by Admin UI and WP-CLI `wp wpdo analyze` command.
|
||||
*/
|
||||
class TMDO_Zone_Classifier {
|
||||
|
||||
/**
|
||||
* Analyze postmeta for a specific post type and return zone suggestions.
|
||||
*
|
||||
* @param string $post_type Post type to analyze.
|
||||
* @param int $sample_size Number of rows to sample per meta_key.
|
||||
* @return array Array of suggestions, each with: meta_key, suggested_zone, confidence, reasons.
|
||||
*/
|
||||
public static function analyze( string $post_type, int $sample_size = 100 ): array {
|
||||
global $wpdb;
|
||||
|
||||
// Return cached result if available (TTL: 1 hour).
|
||||
$transient_key = 'wpdo_classifier_' . sanitize_key( $post_type );
|
||||
$cached = get_transient( $transient_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Get all distinct meta_keys for this post type.
|
||||
$keys = $wpdb->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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Health_Cron — Daily health probe (v2.3.0 M6).
|
||||
*
|
||||
* Runs daily at 03:30 UTC. Aggregates results from:
|
||||
* - TMDO_Site_Health 7 tests (schema_drift, error_budget, hook_conflicts,
|
||||
* autoload_bloat, postmeta_explosion, orphan_zone_rows, missing_snapshot)
|
||||
* - TMDO_Conflict_Monitor::get_summary()
|
||||
* - shadow_diffs ratio per module in `verify` state
|
||||
* - autoload size measurement
|
||||
*
|
||||
* Outputs:
|
||||
* 1. Single audit log entry: op='health_check_daily' with full payload
|
||||
* (so admin can read history via Logs tab + `wp wpdo audit` future CLI).
|
||||
* 2. wpdo_health_alert option set when any critical found (existing
|
||||
* TMDO_Core::render_health_alert_notice consumes this).
|
||||
* 3. wpdo_health_last_run option for SOP runbook "is health green?" question.
|
||||
* 4. action 'wpdo/health_alert_critical' fired on critical (v2.4.0 email
|
||||
* notifier subscribes here; consumers get the full result array).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily probe — stateless static API.
|
||||
*/
|
||||
class TMDO_Health_Cron {
|
||||
|
||||
/** Option key for the most recent run summary. */
|
||||
public const OPTION_LAST_RUN = 'wpdo_health_last_run';
|
||||
|
||||
/** Option key for active critical alert (consumed by Core notice). */
|
||||
public const OPTION_ALERT = 'wpdo_health_alert';
|
||||
|
||||
/**
|
||||
* Run the daily probe. Idempotent — safe to invoke ad-hoc.
|
||||
*
|
||||
* @return array {ok:bool, summary:array, critical_count:int, recommended_count:int, ts:string}
|
||||
*/
|
||||
public static function run(): array {
|
||||
$started_at = microtime( true );
|
||||
$results = self::run_site_health_tests();
|
||||
$conflict = self::summarize_conflicts();
|
||||
$shadow = self::summarize_shadow_diffs();
|
||||
$autoload = self::measure_autoload_size();
|
||||
|
||||
$critical_count = 0;
|
||||
$recommended_count = 0;
|
||||
foreach ( $results as $r ) {
|
||||
$status = (string) ( $r['status'] ?? 'good' );
|
||||
if ( 'critical' === $status ) {
|
||||
++$critical_count;
|
||||
} elseif ( 'recommended' === $status ) {
|
||||
++$recommended_count;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = array(
|
||||
'tests' => $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<string,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<string,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Monthly_Summary — 30-day rollup report (v2.4.0 M11).
|
||||
*
|
||||
* Generates a monthly executive summary covering:
|
||||
* - Health success / fail / rate-limit ratios over last 30 days
|
||||
* - Zone size growth (deltas from 30 days ago snapshot if available)
|
||||
* - Per-module FSM trajectory (who promoted, who rolled back)
|
||||
* - Snapshot retention overview (count, oldest, newest)
|
||||
*
|
||||
* Hooks the existing `wpdo_health_snapshot_monthly` action (Core line ~489)
|
||||
* so it runs once per month at 03:00 UTC on the 1st. Output:
|
||||
* 1. Persisted to wp_wpdo_audit (op='monthly_summary').
|
||||
* 2. Stored as wp_options.wpdo_monthly_summary_latest (autoload=no).
|
||||
* 3. Rendered on the admin Doctor tab "📅 Monthly Summary" sub-section.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly summary aggregator.
|
||||
*/
|
||||
class TMDO_Monthly_Summary {
|
||||
|
||||
public const OPTION_LATEST = 'wpdo_monthly_summary_latest';
|
||||
|
||||
/**
|
||||
* Hook into Core's existing monthly cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_health_snapshot_monthly', array( __CLASS__, 'generate' ), 20 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + persist the monthly summary.
|
||||
*
|
||||
* @return array Summary structure.
|
||||
*/
|
||||
public static function generate(): array {
|
||||
$started_at = microtime( true );
|
||||
$summary = array(
|
||||
'period_start' => 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<string,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
||||
/**
|
||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||
*
|
||||
* Registers 7 tests under Tools → Site Health → Status:
|
||||
* 1. wpdo_schema_drift (critical)
|
||||
* 2. wpdo_error_budget (recommended)
|
||||
* 3. wpdo_hook_conflicts (recommended)
|
||||
* 4. wpdo_autoload_bloat (recommended)
|
||||
* 5. wpdo_postmeta_explosion (recommended)
|
||||
* 6. wpdo_orphan_zone_rows (recommended)
|
||||
* 7. wpdo_missing_snapshot (critical)
|
||||
*
|
||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site Health test suite. Hook on `init` admin context.
|
||||
*/
|
||||
class TMDO_Site_Health {
|
||||
|
||||
/** Transient cache TTL for individual checks. */
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/** Test name → callable suffix mapping. */
|
||||
private const 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',
|
||||
);
|
||||
|
||||
/**
|
||||
* 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' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'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' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'severity' => $severity,
|
||||
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Site_Metrics_Collector — daily EAV health snapshot writer.
|
||||
*
|
||||
* Writes structured rows to `wpdo_site_metrics` once per day (via
|
||||
* `wpdo_collect_site_metrics` cron action, scheduled at 05:00 UTC).
|
||||
*
|
||||
* Metric keys written per run:
|
||||
* eav.postmeta_rows / eav.usermeta_rows / eav.termmeta_rows / eav.commentmeta_rows
|
||||
* flat.hot_rows / flat.cold_rows / flat.warm_rows
|
||||
* custom_tables.total_rows / custom_tables.table_count
|
||||
* errors.last_24h / shadow_diffs.last_24h
|
||||
*
|
||||
* Monthly Summary (`TMDO_Monthly_Summary`) reads these rows for the
|
||||
* `zone_growth` section instead of querying live tables, so the monthly
|
||||
* rollup is fast even on large databases.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.2
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects site-wide EAV health metrics and writes to wpdo_site_metrics.
|
||||
*/
|
||||
class TMDO_Site_Metrics_Collector {
|
||||
|
||||
/**
|
||||
* Cron hook name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CRON_HOOK = 'wpdo_collect_site_metrics';
|
||||
|
||||
/**
|
||||
* How many days of daily rows to retain before pruning.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Register the cron handler and return the instance for chaining.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( self::CRON_HOOK, array( __CLASS__, 'collect' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all site metrics and persist them to wpdo_site_metrics.
|
||||
*
|
||||
* Called by the daily cron. Safe to call manually (e.g., via WP-CLI).
|
||||
*
|
||||
* @param bool $dry_run When true, collect but do not write to the DB.
|
||||
* @return array<string,int> 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<string,int> 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<array{collected_at:string,value:int}> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
|
||||
*
|
||||
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
|
||||
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
|
||||
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
|
||||
*
|
||||
* 設計決定:
|
||||
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
|
||||
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
|
||||
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
|
||||
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
|
||||
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.3.0
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Audit_Logger {
|
||||
|
||||
public const TABLE_SUFFIX = 'audit';
|
||||
|
||||
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
|
||||
public const MAX_ROWS = 500_000;
|
||||
|
||||
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
|
||||
public const DEFAULT_RETENTION_DAYS = 365;
|
||||
|
||||
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
|
||||
|
||||
public const SOURCE_REST = 'rest';
|
||||
public const SOURCE_CLI = 'cli';
|
||||
public const SOURCE_FE_EDITOR = 'fe-editor';
|
||||
public const SOURCE_INTERNAL = 'internal';
|
||||
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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<int, array<string, mixed>>
|
||||
*/
|
||||
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() . '`' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Auto_Promoter — shadow_read 穩定期後自動升級到 aeav_only。
|
||||
*
|
||||
* 僅針對 `shadow_read` 做自動升級(升級到 aeav_only),其他 mode 不自動動。
|
||||
* 判斷條件(皆滿足才觸發):
|
||||
* 1. 當前 mode = shadow_read
|
||||
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
||||
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
|
||||
*
|
||||
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
||||
* 若 diff 出現,規則永不觸發;人工介入即可。
|
||||
*
|
||||
* 可由 option 或 filter 調整:
|
||||
* option `wpdo_auto_promote_enabled` — 全域開關(bool,預設 false)
|
||||
* option `wpdo_auto_promote_min_days` — 停留天數門檻(int,預設 7)
|
||||
* filter `wpdo_auto_promote_should_run` — per-entity 覆寫(bool default)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.5.0
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Auto_Promoter {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_auto_promote_enabled';
|
||||
public const OPT_MIN_DAYS = 'wpdo_auto_promote_min_days';
|
||||
public const DEFAULT_MIN_DAYS = 7;
|
||||
|
||||
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
||||
|
||||
public static function init(): void {
|
||||
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
||||
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次啟用時排入 daily cron。Option 關閉時會停掉,開啟時重排。
|
||||
*/
|
||||
public static function maybe_schedule(): void {
|
||||
$enabled = (bool) get_option( self::OPT_ENABLED, false );
|
||||
$scheduled = wp_next_scheduled( self::CRON_HOOK );
|
||||
|
||||
if ( $enabled && ! $scheduled ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
|
||||
} elseif ( ! $enabled && $scheduled ) {
|
||||
wp_unschedule_event( $scheduled, self::CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍歷所有 entity,符合條件者升級為 aeav_only。
|
||||
*
|
||||
* @return array<string, string> 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Cache_Orchestrator - 多層快取協調器
|
||||
*
|
||||
* 層級:
|
||||
* L1: PHP Process Memory(同一請求內,靜態陣列)
|
||||
* L2: WP Object Cache(搭配 Redis/Memcached 外掛時為跨請求)
|
||||
* L3: Transient(fallback,資料庫持久化)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Cache_Orchestrator {
|
||||
|
||||
/** @var array<string, mixed> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Conflict_Detector — 偵測 UAE 與 UAEPG 同欄位衝突
|
||||
*
|
||||
* 背景:
|
||||
* UAEPG(PostgreSQL sidecar)以 metadata filter priority 5 攔截,
|
||||
* UAE 在 priority 10 攔截。若同一 meta_key 被雙方都登錄,
|
||||
* UAEPG 會先短路 return,UAE 寫入永遠不執行,資料靜默遺失。
|
||||
*
|
||||
* 本類別在欄位登錄階段完成後(init:25)掃描雙方 Registry,
|
||||
* 找出 overlap 並:
|
||||
* 1. 寫入 error log(供 CI / 監控系統消化)
|
||||
* 2. 在管理後台顯示紅色 admin_notices
|
||||
* 3. 提供 TMDO_Conflict_Detector::get_conflicts() 與 CLI 指令 wp uae conflicts 查詢
|
||||
*
|
||||
* 結果 cache 在 static property 內,單 request 內不重複掃描。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.1.2
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Conflict_Detector {
|
||||
|
||||
/**
|
||||
* @var array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>|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<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>
|
||||
*/
|
||||
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(
|
||||
'<div class="notice notice-error"><p><strong>%s</strong></p><ul style="list-style:disc;margin-left:20px;"><li>%s</li></ul>%s<p>%s <code>wp uae conflicts</code></p></div>',
|
||||
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( '</li><li>', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
|
||||
$extra ? '<p><em>' . esc_html( $extra ) . '</em></p>' : '',
|
||||
esc_html__( '查看完整清單:', 'uae' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Health — Entity Bridge 健康狀態聚合器
|
||||
*
|
||||
* 收集每個 entity type (user/term/comment) 的即時健康快照:
|
||||
* - 模式與已停留天數
|
||||
* - 每個群組的覆蓋率(flat table rows vs EAV rows)
|
||||
* - Shadow diff 計數
|
||||
* - 遷移進度(斷點位置、已搬移筆數)
|
||||
* - Auto-promote 資格評估
|
||||
* - 操作建議
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.6
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Health {
|
||||
|
||||
/** Entity types managed by this class (post is handled by Feature_Flags FSM). */
|
||||
public const MANAGED_TYPES = array( 'user', 'term', 'comment' );
|
||||
|
||||
/**
|
||||
* 取得全部 entity 的健康快照(批次,供 REST polling 用)。
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
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 ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Migration_Engine - 遷移引擎
|
||||
*
|
||||
* 負責將原生 wp_*meta 表中的 EAV 資料搬移至 UAE 扁平化表
|
||||
*
|
||||
* 特性:
|
||||
* - Cursor-based 分頁(避免 OFFSET 效能問題)
|
||||
* - 斷點續傳(記錄 last_id)
|
||||
* - 批次處理(可配置 batch size)
|
||||
* - Transaction 安全
|
||||
* - 統計報告
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Migration_Engine {
|
||||
|
||||
/** 預設批次大小 */
|
||||
public const DEFAULT_BATCH_SIZE = 500;
|
||||
|
||||
/** 批次間延遲(毫秒)*/
|
||||
public const DEFAULT_SLEEP_MS = 100;
|
||||
|
||||
/**
|
||||
* 遷移一個實體類型下的一個群組
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param string $group_name
|
||||
* @param array $options {
|
||||
* @type int $batch_size 每批筆數
|
||||
* @type int $sleep_ms 批次間延遲毫秒
|
||||
* @type bool $resume 是否從斷點續傳
|
||||
* @type bool $dry_run 乾跑(不實際寫入)
|
||||
* }
|
||||
* @return array 統計報告
|
||||
*/
|
||||
public static function migrate_group(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
array $options = array()
|
||||
): array {
|
||||
|
||||
$defaults = array(
|
||||
'batch_size' => 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Registry - 全域欄位與適配器登錄中心
|
||||
*
|
||||
* 提供:
|
||||
* - 適配器註冊(post/user/term/comment)
|
||||
* - 欄位群組註冊(Schema 定義)
|
||||
* - 欄位查詢 API(meta_key → field_def)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Registry {
|
||||
|
||||
/** @var array<string, TMDO_Entity_Adapter_Interface> 實體類型 → 適配器實例 */
|
||||
private static array $adapters = array();
|
||||
|
||||
/** @var array<string, array<string, array>> 實體類型 → 群組名 → 欄位定義陣列 */
|
||||
private static array $groups = array();
|
||||
|
||||
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
|
||||
private static array $field_index = array();
|
||||
|
||||
/** @var array<int, array{type:string, group:string, fields: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<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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 ] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
|
||||
*
|
||||
* 所有 WordPress 的 meta 操作皆通過此匯流排:
|
||||
* - {type}_metadata 系列 filter(add/get/update/delete)
|
||||
* - 實體刪除 action
|
||||
* - 原生查詢擴充 hook
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Hook_Bus {
|
||||
|
||||
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
|
||||
private static array $internal_ops = array();
|
||||
|
||||
public static function init(): void {
|
||||
|
||||
// 取得所有已註冊的適配器
|
||||
$adapters = TMDO_Entity_Registry::get_all_adapters();
|
||||
|
||||
foreach ( $adapters as $type => $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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
|
||||
*
|
||||
* 4 種運作模式(每個 entity type 可獨立設定):
|
||||
*
|
||||
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
|
||||
* Kill-switch,緊急關閉用。
|
||||
*
|
||||
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
|
||||
* 讀取:走 wp_*meta(原生)
|
||||
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
|
||||
*
|
||||
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
|
||||
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
|
||||
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
|
||||
*
|
||||
* aeav_only — 寫入:只寫 UAE flat table
|
||||
* 讀取:只讀 UAE flat table
|
||||
* 用途:完成遷移後的最終模式,效能最佳
|
||||
*
|
||||
* 合法轉換路徑(安全性):
|
||||
*
|
||||
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
|
||||
* ↑ ↑
|
||||
* └──── aeav_only 可以直接降級到任一前向狀態
|
||||
*
|
||||
* 禁止:disabled → shadow_read 或 disabled → aeav_only
|
||||
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Mode_Manager {
|
||||
|
||||
public const MODE_DISABLED = 'disabled';
|
||||
public const MODE_DUAL_WRITE = 'dual_write';
|
||||
public const MODE_SHADOW_READ = 'shadow_read';
|
||||
public const MODE_AEAV_ONLY = 'aeav_only';
|
||||
|
||||
public const ALL_MODES = array(
|
||||
self::MODE_DISABLED,
|
||||
self::MODE_DUAL_WRITE,
|
||||
self::MODE_SHADOW_READ,
|
||||
self::MODE_AEAV_ONLY,
|
||||
);
|
||||
|
||||
/** wp_options key holding array<entity_type, mode> */
|
||||
private const OPT_KEY = 'wpdo_bridge_modes';
|
||||
|
||||
/**
|
||||
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
|
||||
* Shape: array<string entity_type, int timestamp>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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<string, true|\WP_Error> 逐 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Query_Compiler - 跨實體查詢編譯器
|
||||
*
|
||||
* 將 wpdo_meta_query 編譯為高效 SQL:
|
||||
* - 單次 JOIN UAE 表(而非原生 meta_query 多次 JOIN wp_*meta)
|
||||
* - 型別正確的比較(避免 CAST 開銷)
|
||||
* - 命中索引
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Query_Compiler {
|
||||
|
||||
/** @var array 合法的 SQL 比較運算子 */
|
||||
private const VALID_OPERATORS = array(
|
||||
'=',
|
||||
'!=',
|
||||
'<>',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'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<string>, where: array<string>}
|
||||
*/
|
||||
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 ) . "'",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Schema_Manager - 動態 DDL 引擎
|
||||
*
|
||||
* 功能:
|
||||
* - 根據欄位定義動態建立/升級扁平化資料表
|
||||
* - WordPress 型別 → MySQL 型別映射
|
||||
* - 自動索引策略(B-Tree、全文、唯一)
|
||||
* - Schema 版本控制(hash 比對)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Schema_Manager {
|
||||
|
||||
/**
|
||||
* WordPress 邏輯型別 → MySQL 實體型別映射
|
||||
*/
|
||||
private static array $type_map = array(
|
||||
'text' => '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 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Shadow_Diff_Logger - Shadow-read 差異偵測與記錄
|
||||
*
|
||||
* 當 bridge 為 shadow_read 模式時,每次讀取都會:
|
||||
* 1. 從 UAE flat table 取值
|
||||
* 2. 從 wp_*meta 原生表取值
|
||||
* 3. 比對
|
||||
* 4. 若不一致 → 記錄到 wp_wpdo_uni_shadow_diffs 表
|
||||
*
|
||||
* 這個表是**持久化**的(不像 AEAV 原本用 transient — 會被 flush 掉),
|
||||
* 因為驗證期可能跨多日,不能丟失。
|
||||
*
|
||||
* 寫入有 rate limit(同 entity+key 在 5 分鐘內只記一筆),防止熱門欄位寫爆。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Shadow_Diff_Logger {
|
||||
|
||||
/** 保留最近 N 筆 diffs(超過就 rolling delete)。防止表爆炸。 */
|
||||
public const MAX_ROWS = 5000;
|
||||
|
||||
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
||||
public const RATELIMIT_WINDOW = 300;
|
||||
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->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() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Type_Caster - 型別轉換器
|
||||
*
|
||||
* 負責:
|
||||
* - PHP → DB 序列化(寫入)
|
||||
* - DB → PHP 反序列化(讀取)
|
||||
* - wpdb format string 生成
|
||||
* - 型別驗證與清理
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Type_Caster {
|
||||
|
||||
/**
|
||||
* PHP → DB 轉換(寫入)
|
||||
*/
|
||||
public static function to_db( $value, array $field_def ) {
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
|
||||
// null 值處理
|
||||
if ( $value === null ) {
|
||||
if ( ! empty( $field_def['required'] ) ) {
|
||||
return $field_def['default'] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'integer':
|
||||
return (int) $value;
|
||||
|
||||
case 'decimal':
|
||||
return (float) $value;
|
||||
|
||||
case 'boolean':
|
||||
return self::to_bool( $value ) ? 1 : 0;
|
||||
|
||||
case 'date':
|
||||
return self::format_date( $value, 'Y-m-d' );
|
||||
|
||||
case 'datetime':
|
||||
case 'timestamp':
|
||||
return self::format_date( $value, 'Y-m-d H:i:s' );
|
||||
|
||||
case 'json':
|
||||
if ( is_string( $value ) ) {
|
||||
// 若已是 JSON 字串則直接儲存(驗證後)
|
||||
$decoded = json_decode( $value, true );
|
||||
if ( json_last_error() === JSON_ERROR_NONE ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||||
|
||||
case 'enum':
|
||||
$options = $field_def['options'] ?? array();
|
||||
$str = (string) $value;
|
||||
return ( ! empty( $options ) && in_array( $str, $options, true ) ) ? $str : ( $field_def['default'] ?? '' );
|
||||
|
||||
case 'binary':
|
||||
return $value;
|
||||
|
||||
case 'text':
|
||||
return (string) $value;
|
||||
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
|
||||
default:
|
||||
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
|
||||
return maybe_serialize( $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB → PHP 轉換(讀取)
|
||||
*/
|
||||
public static function from_db( $value, array $field_def ) {
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
|
||||
if ( $value === null ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'integer':
|
||||
return (int) $value;
|
||||
|
||||
case 'decimal':
|
||||
return (float) $value;
|
||||
|
||||
case 'boolean':
|
||||
return (bool) (int) $value;
|
||||
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
case 'timestamp':
|
||||
return (string) $value;
|
||||
|
||||
case 'json':
|
||||
$decoded = json_decode( (string) $value, true );
|
||||
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
|
||||
|
||||
case 'enum':
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
|
||||
default:
|
||||
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
|
||||
return TMDO_Safe_Unserialize::run( $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* wpdb format 字串(%s / %d / %f)
|
||||
*/
|
||||
public static function get_wpdb_format( string $type ): string {
|
||||
return match ( $type ) {
|
||||
'integer', 'boolean' => '%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 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_CSV_Writer — Lightweight CSV writer with UTF-8 BOM (v2.5.0 M14).
|
||||
*
|
||||
* Excel reads UTF-8 CSV correctly only when the file starts with EF BB BF
|
||||
* BOM. This writer always emits BOM, escapes embedded `"` and wraps fields
|
||||
* containing `,` / newline / `"`.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder API: build_csv_string( $headers, $rows ): string.
|
||||
*/
|
||||
class TMDO_CSV_Writer {
|
||||
|
||||
/** UTF-8 BOM bytes. */
|
||||
public const BOM = "\xEF\xBB\xBF";
|
||||
|
||||
/**
|
||||
* Build a complete CSV string with BOM + header + rows.
|
||||
*
|
||||
* @param array $headers Header column names.
|
||||
* @param array $rows Each row is an associative array keyed by header.
|
||||
* @return string CSV body ready for Content-Disposition: attachment.
|
||||
*/
|
||||
public static function build( array $headers, array $rows ): string {
|
||||
$out = self::BOM;
|
||||
$out .= self::row_to_csv( $headers );
|
||||
foreach ( $rows as $row ) {
|
||||
$line = array();
|
||||
foreach ( $headers as $h ) {
|
||||
$v = $row[ $h ] ?? '';
|
||||
if ( is_array( $v ) || is_object( $v ) ) {
|
||||
$v = wp_json_encode( $v );
|
||||
}
|
||||
$line[] = (string) $v;
|
||||
}
|
||||
$out .= self::row_to_csv( $line );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one row of fields with proper escaping.
|
||||
*
|
||||
* @param array $fields Field values.
|
||||
* @return string CSV row including trailing CRLF.
|
||||
*/
|
||||
private static function row_to_csv( array $fields ): string {
|
||||
$escaped = array();
|
||||
foreach ( $fields as $f ) {
|
||||
$s = self::sanitize_cell( (string) $f );
|
||||
$needs_quote = ( str_contains( $s, ',' ) || str_contains( $s, '"' ) || str_contains( $s, "\n" ) || str_contains( $s, "\r" ) );
|
||||
if ( $needs_quote ) {
|
||||
$s = '"' . str_replace( '"', '""', $s ) . '"';
|
||||
}
|
||||
$escaped[] = $s;
|
||||
}
|
||||
return implode( ',', $escaped ) . "\r\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralise CSV formula-injection characters (=, +, -, @, TAB, CR).
|
||||
*
|
||||
* Excel/Sheets treat cells starting with these as formulas. Prefixing with
|
||||
* a literal single-quote forces text interpretation without altering the
|
||||
* visual output for normal users.
|
||||
*
|
||||
* @param string $s Raw cell value.
|
||||
* @return string Safe cell value.
|
||||
*/
|
||||
private static function sanitize_cell( string $s ): string {
|
||||
if ( '' !== $s && preg_match( '/^[=+\-@\t\r]/', $s ) ) {
|
||||
return "'" . $s;
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Demo_Entity_Counter — production proof-of-life for entity adapters.
|
||||
*
|
||||
* Task D follow-up to PR-5: demonstrates that the entity adapter framework
|
||||
* actually works end-to-end (not just stubs). Implements a "counter" pattern
|
||||
* shared across post / user / term / comment entities — a common gamification
|
||||
* primitive (user points, listing views, comment helpful_count, term usage).
|
||||
*
|
||||
* Storage:
|
||||
* wp_wpdo_demo_entity_counters (entity_type, entity_id, counter_key, counter_value, updated_at)
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Plugin or test invokes ::set( 'user', 42, 'points', 50 )
|
||||
* - This writes to BOTH wp_usermeta (native, preserved) AND wpdo_demo table
|
||||
* - Reads come from wpdo_demo when feature flag entity_demo_counter == 'cutover',
|
||||
* otherwise fall through to native usermeta (transparent fallback)
|
||||
*
|
||||
* @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.
|
||||
|
||||
/**
|
||||
* Demo entity counter — proves 4-entity adapter framework works end-to-end.
|
||||
*/
|
||||
final class TMDO_Demo_Entity_Counter {
|
||||
|
||||
/** Feature flag module name. */
|
||||
public const MODULE = 'entity_demo_counter';
|
||||
|
||||
/** Custom table holding all counters. */
|
||||
public const TABLE = 'wpdo_demo_entity_counters';
|
||||
|
||||
/**
|
||||
* Idempotent install of the demo table.
|
||||
*
|
||||
* Called from TMDO_Installer::install_v2_tables() OR manually for demos.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function install_table(): void {
|
||||
global $wpdb;
|
||||
if ( ! function_exists( 'dbDelta' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
}
|
||||
|
||||
$charset = $wpdb->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<int, array{entity_id:int, counter_value:int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
/**
|
||||
* Member entity field registration for WP Data Optimizer.
|
||||
*
|
||||
* Registers four user entity groups designed for 千萬 (10M) member scale.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_usermeta EAV.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
||||
*
|
||||
* Groups → flat tables:
|
||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
||||
*
|
||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||
*/
|
||||
final class TMDO_Member_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all user entity groups.
|
||||
*
|
||||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||||
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
||||
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
||||
*
|
||||
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
||||
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
||||
* comment_shortcuts, admin_color, use_ssl, show_admin_bar_front,
|
||||
* dismissed_wp_pointers). Without this group, fresh users always show
|
||||
* ratio 1:9 regardless of any other optimization.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_membership_group();
|
||||
self::register_activity_group();
|
||||
self::register_profile_group();
|
||||
self::register_sso_group();
|
||||
self::register_core_profile_group();
|
||||
self::register_social_group();
|
||||
self::register_commerce_group();
|
||||
self::register_hp_user_group();
|
||||
self::register_admin_prefs_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tier level, points balance, expiry — primary search target.
|
||||
*
|
||||
* Extra composite indexes (idx_level_expires, idx_expires_level, idx_points_bal)
|
||||
* are applied by TMDO_Installer::install_member_indexes() after table creation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_membership_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'membership',
|
||||
array(
|
||||
array(
|
||||
'key' => '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',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
/**
|
||||
* Atomic points ledger manager for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages member points with atomic DB transactions.
|
||||
*
|
||||
* All balance mutations go through _transact(), which wraps
|
||||
* SELECT … FOR UPDATE + UPDATE membership + INSERT ledger inside a single
|
||||
* InnoDB transaction. This serialises concurrent debits and prevents the
|
||||
* classic double-spend race condition (two requests each read balance=100,
|
||||
* each deduct 60, each write balance=40).
|
||||
*
|
||||
* Table layout:
|
||||
* wp_wpdo_user_membership.points_balance — current snapshot balance
|
||||
* wp_wpdo_user_points_ledger — append-only journal
|
||||
*/
|
||||
final class TMDO_Points_Manager {
|
||||
|
||||
/**
|
||||
* Credit points to a user (positive delta).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Points to add (must be > 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<int, array{id:int, delta:int, balance_after:int, reason:string, ref_id:?int, ref_type:?string, created_at:string}>
|
||||
*/
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
/**
|
||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||
*
|
||||
* Registers seven post entity groups designed to absorb the bulk of
|
||||
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
||||
*
|
||||
* Groups → flat tables (post_type targets):
|
||||
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
||||
* attachment → wp_wpdo_post_attachment (attachment)
|
||||
* wc_product → wp_wpdo_post_wc_product (product)
|
||||
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
|
||||
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
|
||||
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
|
||||
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
||||
*
|
||||
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
||||
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
||||
* Schema_Manager materializes the seven flat tables in init:1
|
||||
* via process_pending_migrations() (idempotent via schema_hash compare).
|
||||
*
|
||||
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
||||
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
|
||||
* as user entity bridge.
|
||||
*
|
||||
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
|
||||
* with entity_type='user'. User entity registration is owned exclusively
|
||||
* by TMDO_Member_Fields.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers seven post entity groups.
|
||||
*/
|
||||
final class TMDO_Post_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all post entity groups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_wp_core_group();
|
||||
self::register_attachment_group();
|
||||
self::register_wc_product_group();
|
||||
self::register_hp_listing_core_group();
|
||||
self::register_hp_request_core_group();
|
||||
self::register_hp_vendor_core_group();
|
||||
self::register_nav_menu_item_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* WP core post meta keys present across post_types.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wp_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wp_core',
|
||||
array(
|
||||
array(
|
||||
'key' => '_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)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Garbage_Filter — Block known-garbage writes to
|
||||
* wp_termmeta / wp_commentmeta at the metadata filter layer (v2.12.1 Phase 1).
|
||||
*
|
||||
* Phase 0 (v2.12.0) provided cleanup CLI to delete historical garbage. This
|
||||
* filter prevents the same garbage from accumulating again by intercepting
|
||||
* writes via WordPress metadata filters (`add_term_metadata`, etc.) and
|
||||
* silently dropping them — short-circuiting the database INSERT entirely.
|
||||
*
|
||||
* Targets (must align with TMDO_Termmeta_Cleaner / TMDO_Commentmeta_Cleaner):
|
||||
*
|
||||
* wp_termmeta + wp_commentmeta:
|
||||
* - meta_key matching `_wxr_import_*` (WordPress importer residue —
|
||||
* written once during WXR import, never read afterward)
|
||||
* - meta_key matching `_2meet_demo_*` (project-specific demo markers,
|
||||
* safe to drop and re-seed)
|
||||
*
|
||||
* wp_commentmeta only (orphan post-meta keys):
|
||||
* - 8 hardcoded keys from TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS
|
||||
* — these are bugs / typos writing post-domain meta to comment table
|
||||
*
|
||||
* Read paths are NOT filtered. Existing rows in wp_*meta still resolve normally
|
||||
* via standard WP metadata API; once cleanup CLI runs, reads naturally return
|
||||
* empty. This minimizes risk of breaking any reader code that still expects
|
||||
* the keys (none should, but defense in depth).
|
||||
*
|
||||
* Init: hooks registered on `init` priority 5 from TMDO_Core (after
|
||||
* HivePress's plugins_loaded:5 boot, before main entity bridge filters at 10).
|
||||
*
|
||||
* Lessons applied from v2.11.7 — this class is added to
|
||||
* TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST so `wp wpdo conflict-scan` does not
|
||||
* report a false positive when both Hook Bus and this filter run on the same
|
||||
* `add_term_metadata` / `add_comment_metadata` hook.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block known-garbage writes to wp_termmeta / wp_commentmeta.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Garbage_Filter {
|
||||
|
||||
/** Option key for admin toggle. */
|
||||
public const OPT_ENABLED = 'wpdo_term_comment_garbage_filter_enabled';
|
||||
|
||||
/** Telemetry option: count of garbage writes dropped (rolling 24h cumulative). */
|
||||
public const OPT_DROPPED_COUNT = 'wpdo_term_comment_garbage_drops_24h';
|
||||
|
||||
/** Telemetry option: timestamp of the last reset of the 24h counter. */
|
||||
public const OPT_DROPPED_RESET_AT = 'wpdo_term_comment_garbage_drops_reset_at';
|
||||
|
||||
/**
|
||||
* Pattern prefixes that trigger a drop on writes to BOTH wp_termmeta and
|
||||
* wp_commentmeta. Aligned with cleanup CLI `--target` buckets.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const SHARED_DROP_PREFIXES = array(
|
||||
'_wxr_import_',
|
||||
'_2meet_demo_',
|
||||
);
|
||||
|
||||
/**
|
||||
* Exact meta_keys that are dropped only for wp_commentmeta writes
|
||||
* (post-domain keys mistakenly written to comment table — always a bug).
|
||||
*
|
||||
* Must stay in sync with TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const COMMENT_ONLY_ORPHAN_KEYS = array(
|
||||
'_hp_price',
|
||||
'_hp_status',
|
||||
'_hp_featured',
|
||||
'_hp_verified',
|
||||
'_hp_view_count',
|
||||
'_thumbnail_id',
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register filters. Called from TMDO_Core::run() on init:5.
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Term meta writes — priority 9 (before Hook Bus at 10).
|
||||
add_filter( 'add_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
add_filter( 'update_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
|
||||
// Comment meta writes.
|
||||
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the admin toggle. Defaults to enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the shared drop rules
|
||||
* (applicable to both term and comment meta).
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_shared_garbage_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( self::SHARED_DROP_PREFIXES as $prefix ) {
|
||||
if ( str_starts_with( $meta_key, $prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the comment-only orphan post-meta drop.
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_comment_orphan_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
return in_array( $meta_key, self::COMMENT_ONLY_ORPHAN_KEYS, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_term_metadata / update_term_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow (write to wp_termmeta).
|
||||
* Returns true → short-circuit; WP treats as success without DB write.
|
||||
*
|
||||
* @param mixed $check Filter accumulator (null at our priority).
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_comment_metadata / update_comment_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow.
|
||||
* Returns true → short-circuit (silent drop).
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) || self::is_comment_orphan_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the rolling 24h drop counter.
|
||||
*
|
||||
* Auto-resets every 24h based on a stored timestamp; this avoids
|
||||
* unbounded growth and gives the admin status panel a meaningful
|
||||
* "drops in last day" indicator.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function increment_drop_counter(): void {
|
||||
$now = time();
|
||||
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
|
||||
|
||||
if ( 0 === $reset_at || ( $now - $reset_at ) >= 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 );
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user