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:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -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;
}
+266
View File
@@ -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 {}
}
+183
View File
@@ -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';
}
}
+182
View File
@@ -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 );
+236
View File
@@ -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 );
}
}
+49
View File
@@ -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' ) );
}
}
+277
View File
@@ -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
}
+133
View File
@@ -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 );
}
}
+245
View File
@@ -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 )
);
}
}
+840
View File
@@ -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 ); ?>
&nbsp;<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'],
)
);
}
}
+370
View File
@@ -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;
}
}
+210
View File
@@ -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
}
}
+346
View File
@@ -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',
};
}
}
+232
View File
@@ -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
+160
View File
@@ -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
+229
View File
@@ -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
+91
View File
@@ -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;
}
}
+287
View File
@@ -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;
}
}
+359
View File
@@ -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;
}
}
+880
View File
@@ -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' ) );
}
}
+213
View File
@@ -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
+301
View File
@@ -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' );
}
}
}
+396
View File
@@ -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
);
}
}
+322
View File
@@ -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 ] );
}
}
+658
View File
@@ -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 );
}
}
+380
View File
@@ -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() );
}
}
+165
View File
@@ -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 );
}
}
+83
View File
@@ -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 );
}
}
@@ -0,0 +1,412 @@
<?php
/**
* TMDO_Term_Comment_Misc_Bucket — Catch-all flat storage for unregistered
* term + comment meta keys (v2.12.4 Phase 4).
*
* The "missing piece" that makes wp_termmeta / wp_commentmeta completely
* avoidable. After Phase 0–3:
*
* - Phase 0 (v2.12.0): cleanup CLI removes historical garbage
* - Phase 1 (v2.12.1): garbage filter blocks new garbage writes
* - Phase 2 (v2.12.2): entity registry routes registered HivePress fields
* to wp_wpdo_term_hp_taxonomy / wp_wpdo_comment_hp_review
* - Phase 3 (v2.12.3): WC term count filter routes product_count_* → wp_options
*
* What's left? **Unregistered keys** — anything written to term/comment meta
* that doesn't match any pattern (e.g., `note_group` on dev10, or any future
* plugin's custom key). Those still land in wp_termmeta / wp_commentmeta.
*
* This class is the catch-all. Filter priority 99 (LAST in the chain) means
* we only handle writes where every other filter has returned null (i.e., no
* match). Two new flat tables provide structurally identical K/V storage
* with explicit ownership.
*
* Filter priority chain (term + comment metadata writes):
*
* priority 9: garbage filter (drop _wxr_ / _demo_ / orphan)
* priority 9: WC term count filter (route product_count_* → wp_options)
* priority 10: Hook Bus / entity (route registered keys → flat tables)
* priority 99: THIS misc bucket (catch-all → wp_wpdo_*_misc)
*
* The chain works because filter callbacks preserve `$check` / `$pre`
* (the accumulator) when they don't match, so we can detect "nobody handled
* this" by checking `$check === null` at our priority.
*
* Storage:
*
* CREATE TABLE wp_wpdo_term_misc (
* term_id BIGINT(20) UNSIGNED NOT NULL,
* meta_key VARCHAR(191) NOT NULL,
* meta_value LONGTEXT,
* updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
* PRIMARY KEY (term_id, meta_key),
* KEY meta_key (meta_key)
* );
* -- (and corresponding wp_wpdo_comment_misc with comment_id)
*
* No reverse-EAV optimization (still K/V storage), but achieves the literal
* goal of "wp_termmeta / wp_commentmeta zero writes" — making them DROPpable
* in v3.0.0.
*
* 🔒 Lessons applied (v2.11.7): added to TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST.
*
* @package WP_Data_Optimizer
* @since 2.12.4
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Catch-all storage for unregistered term + comment meta keys.
*/
final class TMDO_Term_Comment_Misc_Bucket {
/** Option toggle key. */
public const OPT_ENABLED = 'wpdo_term_comment_misc_bucket_enabled';
/** Filter priority — runs LAST in the chain (after Hook Bus at 10). */
public const FILTER_PRIORITY = 99;
/**
* Register the four metadata filters on `init` priority 5.
*
* @return void
*/
public static function init(): void {
if ( ! self::is_enabled() ) {
return;
}
// Term metadata.
add_filter( 'get_term_metadata', array( self::class, 'on_term_read' ), self::FILTER_PRIORITY, 4 );
add_filter( 'add_term_metadata', array( self::class, 'on_term_add' ), self::FILTER_PRIORITY, 5 );
add_filter( 'update_term_metadata', array( self::class, 'on_term_update' ), self::FILTER_PRIORITY, 5 );
add_filter( 'delete_term_metadata', array( self::class, 'on_term_delete' ), self::FILTER_PRIORITY, 5 );
// Comment metadata.
add_filter( 'get_comment_metadata', array( self::class, 'on_comment_read' ), self::FILTER_PRIORITY, 4 );
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_add' ), self::FILTER_PRIORITY, 5 );
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_update' ), self::FILTER_PRIORITY, 5 );
add_filter( 'delete_comment_metadata', array( self::class, 'on_comment_delete' ), self::FILTER_PRIORITY, 5 );
}
/**
* Check the admin toggle. Defaults to enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
}
/**
* Term-side fully-qualified misc table name.
*
* @return string
*/
public static function term_table(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_term_misc';
}
/**
* Comment-side fully-qualified misc table name.
*
* @return string
*/
public static function comment_table(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_comment_misc';
}
// ── Term metadata callbacks ───────────────────────────────────────────────
/**
* Filter callback: get_term_metadata.
*
* @param mixed $pre Filter accumulator.
* @param int $object_id Term ID.
* @param string $meta_key Meta key being read.
* @param bool $single Whether single value was requested.
* @return mixed
*/
public static function on_term_read( $pre, $object_id, $meta_key, $single ) {
unset( $single );
// Only handle when no earlier filter has resolved this read.
if ( null !== $pre ) {
return $pre;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $pre;
}
$value = self::read( self::term_table(), 'term_id', (int) $object_id, $meta_key );
if ( null === $value ) {
return $pre;
}
return array( $value );
}
/**
* Filter callback: add_term_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Term ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value.
* @param bool $unique Unique flag (unused).
* @return mixed
*/
public static function on_term_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
unset( $unique );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
return true;
}
/**
* Filter callback: update_term_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Term ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value.
* @param mixed $prev_value Previous value (unused).
* @return mixed
*/
public static function on_term_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
unset( $prev_value );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
return true;
}
/**
* Filter callback: delete_term_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Term ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value-scoped delete (unused).
* @param bool $delete_all Delete-all flag (unused).
* @return mixed
*/
public static function on_term_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
unset( $meta_value, $delete_all );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::delete_row( self::term_table(), 'term_id', (int) $object_id, $meta_key );
return true;
}
// ── Comment metadata callbacks ────────────────────────────────────────────
/**
* Filter callback: get_comment_metadata.
*
* @param mixed $pre Filter accumulator.
* @param int $object_id Comment ID.
* @param string $meta_key Meta key.
* @param bool $single Single flag (unused).
* @return mixed
*/
public static function on_comment_read( $pre, $object_id, $meta_key, $single ) {
unset( $single );
if ( null !== $pre ) {
return $pre;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $pre;
}
$value = self::read( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
if ( null === $value ) {
return $pre;
}
return array( $value );
}
/**
* Filter callback: add_comment_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Comment ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value.
* @param bool $unique Unique flag (unused).
* @return mixed
*/
public static function on_comment_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
unset( $unique );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
return true;
}
/**
* Filter callback: update_comment_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Comment ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value.
* @param mixed $prev_value Previous value (unused).
* @return mixed
*/
public static function on_comment_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
unset( $prev_value );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
return true;
}
/**
* Filter callback: delete_comment_metadata.
*
* @param mixed $check Filter accumulator.
* @param int $object_id Comment ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value-scoped (unused).
* @param bool $delete_all Delete-all flag (unused).
* @return mixed
*/
public static function on_comment_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
unset( $meta_value, $delete_all );
if ( null !== $check ) {
return $check;
}
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
return $check;
}
self::delete_row( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
return true;
}
// ── Internal storage helpers ──────────────────────────────────────────────
/**
* Read a value from a misc bucket table.
*
* Returns the raw stored value (string), or null when no row exists.
* Caller must wrap in array for the get_*_metadata filter contract.
*
* @param string $table Fully-qualified table name.
* @param string $id_column 'term_id' or 'comment_id'.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @return string|null
*/
private static function read( string $table, string $id_column, int $object_id, string $meta_key ): ?string {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$object_id,
$meta_key
)
);
// phpcs:enable
return ( null === $value ) ? null : (string) $value;
}
/**
* Write (INSERT or UPDATE) a value to a misc bucket table.
*
* Uses ON DUPLICATE KEY UPDATE — the (id, meta_key) primary key
* means each (entity, meta_key) pair has a single canonical row.
*
* @param string $table Fully-qualified table name.
* @param string $id_column 'term_id' or 'comment_id'.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value to store. Non-scalar types are serialized.
* @return void
*/
private static function write( string $table, string $id_column, int $object_id, string $meta_key, $meta_value ): void {
global $wpdb;
// Match WP convention: serialize arrays/objects, scalar values stored as-is.
$serialized = is_scalar( $meta_value ) || null === $meta_value
? (string) $meta_value
: maybe_serialize( $meta_value );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `{$table}` (`{$id_column}`, meta_key, meta_value) VALUES (%d, %s, %s)
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
$object_id,
$meta_key,
$serialized
)
);
// phpcs:enable
}
/**
* Delete the row matching (object_id, meta_key) from a misc bucket table.
*
* @param string $table Fully-qualified table name.
* @param string $id_column 'term_id' or 'comment_id'.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @return void
*/
private static function delete_row( string $table, string $id_column, int $object_id, string $meta_key ): void {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$object_id,
$meta_key
)
);
// phpcs:enable
}
/**
* Count rows in a misc bucket table (for admin status panel).
*
* @param string $entity_type 'term' or 'comment'.
* @return int
*/
public static function count_rows( string $entity_type ): int {
global $wpdb;
$table = 'comment' === $entity_type ? self::comment_table() : self::term_table();
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return 0;
}
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
// phpcs:enable
}
}
@@ -0,0 +1,195 @@
<?php
/**
* Base class for all WPDO interceptors.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Base class for all WPDO interceptors.
*
* Ported from HPCT_Interceptor_Base with WPDO enhancements:
* - Uses TMDO_Feature_Flags (7-state) instead of HPCT 4-state
* - Uses TMDO_Logger instead of HPCT_Logger
* - Adds zone awareness for future Zone-based interceptors
*/
abstract class TMDO_Interceptor_Base {
/**
* Module name — set in each subclass.
*
* @var string
*/
protected string $module = '';
/**
* Whether to send admin email on first error.
*
* @var bool
*/
protected bool $email_on_error = true;
/**
* Return true when the module is fully enabled (reads from custom table).
*
* @return bool True if module is in complete state.
*/
protected function is_enabled(): bool {
return TMDO_Feature_Flags::is_complete( $this->module );
}
/**
* Return true when dual-write is active (writes go to both native + custom).
*
* @return bool True if module is in a write-active state.
*/
protected function is_write_active(): bool {
return TMDO_Feature_Flags::is_write_active( $this->module );
}
/**
* Return true when the module should intercept reads or writes.
*
* @return bool True if module is enabled or write-active.
*/
protected function is_active(): bool {
return $this->is_enabled() || $this->is_write_active();
}
/**
* Execute a custom-table callable safely.
*
* If $custom throws, log and return $native_fallback().
* After 3 consecutive errors in a request, disable the module.
*
* @param callable $custom Custom table read/write callable.
* @param callable $native_fallback Original WordPress callable.
* @param string $hook Hook name for logging context.
* @return mixed
* @throws \RuntimeException When the custom callable returns a WP_Error.
*/
protected function intercept( callable $custom, callable $native_fallback, string $hook = '' ): mixed {
if ( ! $this->is_enabled() ) {
return $native_fallback();
}
try {
$result = $custom();
if ( is_wp_error( $result ) ) {
throw new \RuntimeException( $result->get_error_message() );
}
return $result;
} catch ( \Throwable $e ) {
$this->handle_error(
$hook ?: 'intercept',
$e->getMessage(),
array(
'exception' => get_class( $e ),
'file' => $e->getFile(),
'line' => $e->getLine(),
)
);
return $native_fallback();
}
}
/**
* Dual-write: call native first, then sync to custom table.
* Custom failure is non-fatal.
*
* @param callable $native Original write callable (always executed).
* @param callable $custom Custom table write callable.
* @param string $hook Hook name for logging.
* @return mixed Return value of $native.
*/
protected function dual_write( callable $native, callable $custom, string $hook = '' ): mixed {
$result = $native();
if ( $this->is_write_active() || $this->is_enabled() ) {
try {
$custom( $result );
} catch ( \Throwable $e ) {
$this->handle_error(
$hook ?: 'dual_write',
$e->getMessage(),
array(
'exception' => get_class( $e ),
)
);
}
}
return $result;
}
/**
* Register all hooks. Called by TMDO_Core.
*
* @return void
*/
abstract public function register_hooks(): void;
// ── Private helpers ───────────────────────────────────────────────────
/**
* Per-request consecutive error counter, keyed by module.
*
* @var array
*/
private static array $error_counts = array();
/**
* Handles an interceptor error and auto-disables the module after 3 consecutive errors.
*
* @param string $hook Hook name for logging context.
* @param string $message Error message.
* @param array $context Additional context data.
* @return void
*/
private function handle_error( string $hook, string $message, array $context = array() ): void {
TMDO_Logger::error( $this->module, $hook, $message, $context );
self::$error_counts[ $this->module ] = ( self::$error_counts[ $this->module ] ?? 0 ) + 1;
if ( self::$error_counts[ $this->module ] >= 3 ) {
TMDO_Feature_Flags::reset( $this->module );
TMDO_Logger::error( $this->module, $hook, 'Module auto-disabled after 3 consecutive errors.' );
if ( $this->email_on_error ) {
$this->notify_admin( $message );
$this->email_on_error = false;
}
}
}
/**
* Sends an admin notification email when a module is auto-disabled.
*
* @param string $message Error message to include in the notification.
* @return void
*/
private function notify_admin( string $message ): void {
$admin_email = get_option( 'admin_email' );
if ( ! $admin_email ) {
return;
}
wp_mail(
$admin_email,
sprintf( '[WPDO] Module "%s" auto-disabled', $this->module ),
sprintf(
"The WPDO module \"%s\" has been automatically disabled due to repeated errors.\n\nLast error: %s\n\nPlease review the error log at Tools > WP Data Optimizer > Logs.",
$this->module,
$message
)
);
}
}
@@ -0,0 +1,400 @@
<?php
/**
* Zone-aware dual-write dispatcher for WordPress metadata API.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone-aware dual-write dispatcher.
*
* Hooks into WordPress metadata API (add/update/delete_post_metadata)
* and routes writes to the appropriate zone handler based on Schema Registry.
*
* Read path: intercepts get_post_metadata and routes to the correct zone
* when the module is in a read-custom state.
*
* This bridge handles ONLY zone-registered fields (hot/warm/cold).
* Archive zone is not intercepted here — it is triggered by cron/manual sweep.
* HPCT-inherited modules have their own dedicated interceptors.
*/
class TMDO_Sync_Bridge {
/**
* Prevent recursion when we call native meta functions internally.
*
* @var bool
*/
private static bool $bypassing = false;
/**
* Request-level cache for Schema Registry field lookups (post_type:meta_key => field|false).
*
* @var array
*/
private static array $field_cache = array();
/**
* Register all metadata hooks.
*/
public function register_hooks(): void {
add_filter( 'get_post_metadata', array( $this, 'intercept_get' ), 10, 5 );
add_filter( 'update_post_metadata', array( $this, 'intercept_update' ), 10, 5 );
add_filter( 'add_post_metadata', array( $this, 'intercept_add' ), 10, 5 );
add_action( 'deleted_post_meta', array( $this, 'intercept_delete' ), 10, 4 );
add_action( 'before_delete_post', array( $this, 'cleanup_post' ), 10, 1 );
}
/**
* Intercept get_post_meta — read from zone table when module is in read-custom state.
*
* @param mixed $value Existing filtered value (null by default).
* @param int $post_id Post ID.
* @param string $meta_key Meta key (empty = get all).
* @param bool $single Whether to return single value.
* @param string $meta_type Meta type (always 'post').
* @return mixed
*/
public function intercept_get( $value, int $post_id, string $meta_key, bool $single, string $meta_type = 'post' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by get_post_metadata filter signature.
if ( self::$bypassing || empty( $meta_key ) || $post_id <= 0 ) {
return $value;
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return $value;
}
$field = $this->get_field_cached( $post_type, $meta_key );
if ( ! $field ) {
return $value;
}
$module = $this->get_zone_module( $field['zone'], $post_type );
if ( ! TMDO_Feature_Flags::is_read_custom( $module ) ) {
return $value;
}
try {
$zone_value = $this->read_from_zone( $field, $post_id, $post_type, $meta_key );
if ( null === $zone_value ) {
return $value;
}
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- This is an explanatory comment, not commented-out code.
// Wrap in array: WP unwraps $check[0] for $single=true, casts (array)$check for $single=false.
return array( $zone_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $module, 'get_post_metadata', $e->getMessage() );
return $value;
}
}
/**
* Intercept update_post_meta — dual-write to zone table.
*
* @param null|bool $check Whether to short-circuit (null = proceed).
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $prev_value Previous value.
* @return null|bool
*/
public function intercept_update( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( self::$bypassing || $post_id <= 0 ) {
return $check;
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return $check;
}
// v2.9.2 Entity Bridge guard: when post mode is dual_write or higher
// AND the key is registered in the new Entity Registry, the unified
// Hook Bus is the source of truth — Sync_Bridge must not also write
// to the legacy zone table to avoid duplicate flat writes.
if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
return $check;
}
$field = $this->get_field_cached( $post_type, $meta_key );
if ( ! $field ) {
return $check;
}
$module = $this->get_zone_module( $field['zone'], $post_type );
if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
return $check;
}
// Write to zone table (non-fatal on failure).
try {
$this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $module, 'update_post_metadata', $e->getMessage() );
}
// Return null — let WordPress proceed with native postmeta write.
// In cleanup/complete states, we could skip native write, but for safety
// we always allow it during zone migration lifecycle.
return $check;
}
/**
* Whether the given post meta_key is now owned by the Entity Bridge,
* meaning Sync_Bridge should defer to the unified Hook Bus and skip its
* zone write to avoid duplicate flat writes.
*
* Returns true only when ALL of:
* - TMDO_Mode_Manager and TMDO_Entity_Registry classes exist
* - post mode is dual_write or higher (writes_to_flat returns true)
* - the key is registered for entity_type=post
*
* Default post mode is `disabled`, so this returns false in all
* environments that have not opted in to Entity Bridge — keeping the
* legacy Sync_Bridge → zone path unchanged.
*
* @param string $meta_key Meta key being written.
* @return bool
* @since 2.9.2
*/
private static function is_owned_by_entity_bridge( string $meta_key ): bool {
if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) ) {
return false;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) ) {
return false;
}
return null !== TMDO_Entity_Registry::get_field( 'post', $meta_key );
}
/**
* Intercept add_post_meta — dual-write to zone table.
*
* @param mixed $check Whether to short-circuit.
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $unique Whether the meta key should be unique. Not used directly.
* @return mixed Filtered check value.
*/
public function intercept_add( $check, int $post_id, string $meta_key, $meta_value, $unique ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by add_post_metadata filter signature.
if ( self::$bypassing || $post_id <= 0 ) {
return $check;
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return $check;
}
// v2.9.2 Entity Bridge guard — see is_owned_by_entity_bridge() docblock.
if ( self::is_owned_by_entity_bridge( $meta_key ) ) {
return $check;
}
$field = $this->get_field_cached( $post_type, $meta_key );
if ( ! $field ) {
return $check;
}
$module = $this->get_zone_module( $field['zone'], $post_type );
if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
return $check;
}
try {
$this->write_to_zone( $field, $post_id, $post_type, $meta_key, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $module, 'add_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* After a postmeta is deleted, remove from zone table too.
*
* Hooked to 'deleted_post_meta' (fires after native delete completes).
*
* @param int[] $meta_ids Array of deleted meta IDs.
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value. Not used directly.
* @return void
*/
public function intercept_delete( $meta_ids, int $post_id, string $meta_key, $meta_value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by deleted_post_meta action signature.
if ( self::$bypassing || $post_id <= 0 ) {
return;
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return;
}
$field = $this->get_field_cached( $post_type, $meta_key );
if ( ! $field ) {
return;
}
$module = $this->get_zone_module( $field['zone'], $post_type );
if ( ! TMDO_Feature_Flags::is_write_active( $module ) ) {
return;
}
try {
$this->delete_from_zone( $field, $post_id, $post_type, $meta_key );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $module, 'deleted_post_meta', $e->getMessage() );
}
}
/**
* When a post is permanently deleted, clean up all zone data.
*
* @param int $post_id Post ID being deleted.
* @return void
*/
public function cleanup_post( int $post_id ): void {
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return;
}
$registry = TMDO_Schema_Registry::instance();
// Clean Hot zone.
if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) {
try {
TMDO_Zone_Hot::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
TMDO_Logger::error( 'hot_' . $post_type, 'before_delete_post', $e->getMessage() );
}
}
// Clean Cold zone.
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) {
try {
TMDO_Zone_Cold::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
TMDO_Logger::error( 'cold_' . $post_type, 'before_delete_post', $e->getMessage() );
}
}
// Clean Warm zone.
try {
TMDO_Zone_Warm::delete_all( $post_id );
} catch ( \Throwable $e ) {
TMDO_Logger::error( 'warm', 'before_delete_post', $e->getMessage() );
}
// Clean Archive zone.
try {
TMDO_Zone_Archive::delete( $post_id );
} catch ( \Throwable $e ) {
TMDO_Logger::error( 'archive', 'before_delete_post', $e->getMessage() );
}
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get a registered field with a request-level static cache.
*
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return array|null Field definition, or null if not registered.
*/
private function get_field_cached( string $post_type, string $meta_key ): ?array {
$cache_key = $post_type . ':' . $meta_key;
if ( ! array_key_exists( $cache_key, self::$field_cache ) ) {
self::$field_cache[ $cache_key ] = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key );
}
return self::$field_cache[ $cache_key ];
}
/**
* Derive module name from zone + post_type for feature flag lookups.
*
* @param string $zone Zone identifier (hot, cold, warm, archive).
* @param string $post_type Post type.
* @return string Module name.
*/
private function get_zone_module( string $zone, string $post_type ): string {
return match ( $zone ) {
'hot' => 'hot_' . sanitize_key( $post_type ),
'cold' => 'cold_' . sanitize_key( $post_type ),
'warm' => 'warm',
'archive' => 'archive',
default => 'unknown',
};
}
/**
* Read a value from the appropriate zone.
*
* @param array $field Field definition from Schema Registry.
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return mixed Value from zone or null.
*/
private function read_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): mixed {
return match ( $field['zone'] ) {
'hot' => TMDO_Zone_Hot::get( $post_id, $post_type, $field['column'] ),
'cold' => TMDO_Zone_Cold::get( $post_id, $post_type, $meta_key ),
'warm' => TMDO_Zone_Warm::get( $post_id, $meta_key ),
default => null,
};
}
/**
* Write a value to the appropriate zone.
*
* @param array $field Field definition from Schema Registry.
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param mixed $value Value to write.
* @return void
*/
private function write_to_zone( array $field, int $post_id, string $post_type, string $meta_key, mixed $value ): void {
match ( $field['zone'] ) {
'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], $value ),
'cold' => TMDO_Zone_Cold::set( $post_id, $post_type, $meta_key, $value ),
'warm' => TMDO_Zone_Warm::set(
$post_id,
$meta_key,
is_string( $value ) ? $value : wp_json_encode( $value ),
$field['ttl'] ?? null
),
default => null,
};
}
/**
* Delete a value from the appropriate zone.
*
* @param array $field Field definition from Schema Registry.
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return void
*/
private function delete_from_zone( array $field, int $post_id, string $post_type, string $meta_key ): void {
match ( $field['zone'] ) {
'hot' => TMDO_Zone_Hot::set( $post_id, $post_type, $field['column'], null ),
'cold' => TMDO_Zone_Cold::remove( $post_id, $post_type, $meta_key ),
'warm' => TMDO_Zone_Warm::delete( $post_id, $meta_key ),
default => null,
};
}
}
@@ -0,0 +1,168 @@
<?php
/**
* Zone D (Archive) migration for postmeta from trashed/old posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone D (Archive) migration — postmeta from trashed/old posts → wpdo_archive.
*
* Unlike other zone migrations that copy specific registered meta_keys,
* the archive migration sweeps ALL postmeta for posts that meet archival criteria:
* - Post is in 'trash' status
* - Post was last modified more than $days ago (default: 90 days)
*
* Data is optionally gzip-compressed before storage.
*/
class TMDO_Archive_Migration extends TMDO_Migration_Base {
/**
* Minimum age in days for archival.
*
* @var int
*/
private int $days;
/**
* Whether to gzip-compress archived values.
*
* @var bool
*/
private bool $compress;
/**
* Constructor.
*
* @param int $days Minimum age in days for archival.
* @param bool $compress Whether to gzip-compress archived values.
*/
public function __construct( int $days = 90, bool $compress = true ) {
$this->days = $days;
$this->compress = $compress;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'archive';
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'archive';
}
/**
* Returns the total number of archive-eligible postmeta rows.
*
* @return int Total row count.
*/
protected function count_source(): int {
global $wpdb;
$cutoff = $this->get_cutoff();
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s",
$cutoff
)
);
}
/**
* Migrates one batch of eligible postmeta rows to the archive table.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$cutoff = $this->get_cutoff();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
ORDER BY pm.meta_id ASC
LIMIT %d OFFSET %d",
$cutoff,
self::BATCH_SIZE,
$offset
),
ARRAY_A
);
if ( empty( $rows ) ) {
return 0;
}
$entries = array();
foreach ( $rows as $row ) {
$entries[] = array(
'post_id' => $row['post_id'],
'post_type' => $row['post_type'],
'meta_key' => $row['meta_key'],
'meta_value' => $row['meta_value'],
'meta_id' => $row['meta_id'],
);
}
TMDO_Zone_Archive::archive_batch( $entries, $this->compress );
return count( $rows );
}
/**
* Verifies that archive table has rows when eligible source rows exist.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Archive::table();
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Archive::table()
// Archive may have more rows than current source (trashed posts may have
// been permanently deleted after archival). So we just check target > 0
// when source is 0, or target >= some threshold.
if ( 0 === $source ) {
return true;
}
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Returns the cutoff datetime string for archival eligibility.
*
* @return string MySQL datetime string.
*/
private function get_cutoff(): string {
return gmdate( 'Y-m-d H:i:s', time() - ( $this->days * DAY_IN_SECONDS ) );
}
}
@@ -0,0 +1,166 @@
<?php
/**
* Zone C (Cold) migration for postmeta to JSON blob cold table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone C (Cold) migration — postmeta → JSON blob cold table.
*
* For each post, gathers all registered cold meta_keys and stores them
* as a single JSON blob in wpdo_cold_{post_type}.
*/
class TMDO_Cold_Migration extends TMDO_Migration_Base {
/**
* Post type being migrated.
*
* @var string
*/
private string $post_type;
/**
* Constructor.
*
* @param string $post_type Post type slug to migrate.
*/
public function __construct( string $post_type ) {
$this->post_type = $post_type;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'cold_' . sanitize_key( $this->post_type );
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'cold';
}
/**
* Returns the total number of posts to migrate.
*
* @return int Total post count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( array( $this->post_type ), $meta_keys );
// Count distinct posts that have at least one cold field.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of posts to the cold table.
*
* @param int $offset Starting post offset.
* @return int Number of posts processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// Get batch of distinct post IDs.
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
ORDER BY pm.post_id ASC
LIMIT %d OFFSET %d",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Ensure cold table exists before any write (even if this batch is empty).
TMDO_Zone_Cold::ensure_table( $this->post_type );
if ( empty( $post_ids ) ) {
return 0;
}
// Fetch all cold meta for these posts.
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Group by post_id.
$grouped = array();
foreach ( $meta_rows ?: array() as $row ) {
$grouped[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value'];
}
// Write each post's cold data as a JSON blob.
foreach ( $grouped as $pid => $data ) {
TMDO_Zone_Cold::set_many( (int) $pid, $this->post_type, $data );
}
return count( $post_ids );
}
/**
* Verifies that the cold table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Cold::table( $this->post_type );
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Cold::table()
return $target >= $source;
}
}
@@ -0,0 +1,191 @@
<?php
/**
* Zone A (Hot) migration for postmeta to flat-column hot table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone A (Hot) migration — postmeta → flat-column hot table.
*
* Reads registered hot fields from Schema Registry for a specific post type,
* bulk-copies their postmeta values into wpdo_hot_{post_type}.
*/
class TMDO_Hot_Migration extends TMDO_Migration_Base {
/**
* Post type being migrated.
*
* @var string
*/
private string $post_type;
/**
* Constructor.
*
* @param string $post_type Post type slug to migrate.
*/
public function __construct( string $post_type ) {
$this->post_type = $post_type;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'hot_' . sanitize_key( $this->post_type );
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'hot';
}
/**
* Returns the total number of posts to migrate.
*
* @return int Total post count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( array( $this->post_type ), $meta_keys );
// Count distinct posts that have at least one hot field.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders built from array_fill with %s; $wpdb->postmeta/$wpdb->posts are core properties.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of posts to the hot table.
*
* @param int $offset Starting post offset.
* @return int Number of posts processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $this->post_type );
$key_map = $this->build_key_to_column_map();
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// Get batch of distinct post IDs.
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders built from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
ORDER BY pm.post_id ASC
LIMIT %d OFFSET %d",
...$args
)
);
if ( empty( $post_ids ) ) {
return 0;
}
// Ensure hot table exists.
TMDO_Zone_Hot::ensure_table( $this->post_type );
// For each post, gather all hot meta and upsert.
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Group by post_id.
$grouped = array();
foreach ( $meta_rows ?: array() as $row ) {
$col = $key_map[ $row['meta_key'] ] ?? null;
if ( $col ) {
$grouped[ $row['post_id'] ][ $col ] = $row['meta_value'];
}
}
// Upsert each post's hot data.
foreach ( $grouped as $pid => $data ) {
TMDO_Zone_Hot::set_many( (int) $pid, $this->post_type, $data );
}
return count( $post_ids );
}
/**
* Verifies that the hot table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Hot::table( $this->post_type );
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Hot::table()
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get all registered hot meta_keys for this post type.
*/
private function get_meta_keys(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
return array_column( $fields, 'meta_key' );
}
/**
* Build meta_key → column_name map.
*/
private function build_key_to_column_map(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
$map = array();
foreach ( $fields as $field ) {
$map[ $field['meta_key'] ] = $field['column'];
}
return $map;
}
}
@@ -0,0 +1,287 @@
<?php
/**
* Abstract base for all WPDO data migrations.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract base for all WPDO data migrations.
*
* Each subclass implements:
* - get_module() — module name (TMDO_Feature_Flags key)
* - get_zone() — zone identifier ('' for HPCT modules, 'hot'/'warm'/'cold'/'archive' for zone modules)
* - count_source() — total number of native rows to migrate
* - migrate_batch() — migrate one batch starting at $offset, returns rows processed
* - verify_counts() — returns true when custom table count >= native count
*
* Migration record is tracked in wpdo_migrations table.
* Batch size: 500 rows. Timeout: 28 seconds per run.
*/
abstract class TMDO_Migration_Base {
protected const BATCH_SIZE = 500;
protected const TIMEOUT = 28; // Seconds.
/**
* Returns the module identifier for this migration.
*
* @return string Module name.
*/
abstract public function get_module(): string;
/**
* Zone identifier. Empty string for HPCT-inherited modules.
*/
public function get_zone(): string {
return '';
}
/**
* Returns the total number of source rows to migrate.
*
* @return int Total row count.
*/
abstract protected function count_source(): int;
/**
* Migrate one batch of rows.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed in this batch.
*/
abstract protected function migrate_batch( int $offset ): int;
/**
* Verify that the custom table count is >= native count.
*/
abstract public function verify_counts(): bool;
// ── Migration record helpers ──────────────────────────────────────────
/**
* Retrieves the current migration record from the database.
*
* @return array|null Migration record array, or null if not found.
*/
public function get_record(): ?array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE module = %s", $this->get_module() ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
ARRAY_A
);
return $row ?: null;
}
/**
* Initialize or reset the migration record.
*/
public function init_record(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
$total = $this->count_source();
$existing = $this->get_record();
if ( $existing ) {
$wpdb->update(
$table,
array(
'state' => 'backfill',
'zone' => $this->get_zone(),
'total_rows' => $total,
'processed_rows' => 0,
'last_offset' => 0,
'error_count' => 0,
'started_at' => $now,
'completed_at' => null,
'updated_at' => $now,
),
array( 'module' => $this->get_module() ),
array( '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ),
array( '%s' )
);
} else {
$wpdb->insert(
$table,
array(
'module' => $this->get_module(),
'zone' => $this->get_zone(),
'state' => 'backfill',
'total_rows' => $total,
'processed_rows' => 0,
'last_offset' => 0,
'error_count' => 0,
'started_at' => $now,
'created_at' => $now,
'updated_at' => $now,
),
array( '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
);
}
}
/**
* Resume an existing migration (does NOT reset processed_rows).
*/
public function resume_record(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$wpdb->update(
$table,
array(
'state' => 'backfill',
'updated_at' => TMDO_DB::now(),
),
array( 'module' => $this->get_module() ),
array( '%s', '%s' ),
array( '%s' )
);
}
/**
* Update progress after each batch.
*
* @param int $processed Number of rows processed in this batch.
* @param int $last_offset Last row offset processed.
* @return void
*/
public function update_progress( int $processed, int $last_offset ): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET
processed_rows = processed_rows + %d,
last_offset = %d,
updated_at = %s
WHERE module = %s",
$processed,
$last_offset,
TMDO_DB::now(),
$this->get_module()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Mark migration as completed (state → verify).
*/
public function mark_complete(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
$wpdb->update(
$table,
array(
'state' => 'verify',
'completed_at' => $now,
'updated_at' => $now,
),
array( 'module' => $this->get_module() ),
array( '%s', '%s', '%s' ),
array( '%s' )
);
}
/**
* Increment error count.
*/
public function increment_errors(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET error_count = error_count + 1, updated_at = %s WHERE module = %s",
TMDO_DB::now(),
$this->get_module()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Run the migration in batches. Stops after ~28 seconds.
*
* @param bool $resume If true, resumes from last_offset.
* @param callable|null $progress_callback Called after each batch with (processed, total).
* @return bool True if migration completed, false if timed out (resume needed).
*/
public function run( bool $resume = false, ?callable $progress_callback = null ): bool {
// Set feature flag to dual_write before starting.
TMDO_Feature_Flags::set( $this->get_module(), 'dual_write' );
if ( $resume ) {
$record = $this->get_record();
$offset = (int) ( $record['last_offset'] ?? 0 );
$this->resume_record();
} else {
$offset = 0;
$this->init_record();
}
// Advance to backfill state.
TMDO_Feature_Flags::set( $this->get_module(), 'backfill' );
$start = microtime( true );
do {
try {
$batch_count = $this->migrate_batch( $offset );
} catch ( \Throwable $e ) {
$this->increment_errors();
TMDO_Logger::error(
$this->get_module(),
'migrate_batch',
$e->getMessage(),
array(
'offset' => $offset,
'zone' => $this->get_zone(),
)
);
$batch_count = 0;
// Check if too many errors.
$record = $this->get_record();
if ( $record && (int) $record['error_count'] >= 10 ) {
TMDO_Feature_Flags::reset( $this->get_module() );
return false;
}
}
if ( $batch_count > 0 ) {
$this->update_progress( $batch_count, $offset + $batch_count );
$offset += $batch_count;
}
if ( $progress_callback ) {
$record = $this->get_record();
$progress_callback( (int) $record['processed_rows'], (int) $record['total_rows'] );
}
if ( ( microtime( true ) - $start ) > self::TIMEOUT ) {
return false; // Timed out — next run will resume.
}
} while ( $batch_count >= self::BATCH_SIZE );
// Backfill done → move to verify.
$this->mark_complete();
TMDO_Feature_Flags::set( $this->get_module(), 'verify' );
return true;
}
}
@@ -0,0 +1,329 @@
<?php
/**
* Migration Engine for 7-state module lifecycle control.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Migration Engine — 7-state lifecycle controller.
*
* Manages the full migration lifecycle for any module:
* idle → dual_write → backfill → verify → cutover → cleanup → complete
*
* Provides:
* - State transitions with validation
* - Orchestration of backfill + verify + cutover + cleanup steps
* - Rollback to idle from any state
* - CLI and Admin integration points
*
* Each module has a corresponding TMDO_Migration_Base subclass that handles
* the actual data migration logic (batch processing, counting, verifying).
*/
class TMDO_Migration_Engine {
/**
* Valid state transitions.
* Key = current state, value = array of allowed next states.
* 'idle' is always allowed from any state (rollback).
*/
private const TRANSITIONS = array(
'idle' => array( 'dual_write' ),
'dual_write' => array( 'backfill', 'idle' ),
'backfill' => array( 'verify', 'dual_write', 'idle' ),
'verify' => array( 'cutover', 'dual_write', 'idle' ),
'cutover' => array( 'cleanup', 'idle' ),
'cleanup' => array( 'complete', 'idle' ),
'complete' => array( 'idle' ),
);
/**
* Registry of migration class instances, keyed by module name.
*
* @var array<string, TMDO_Migration_Base>
*/
private static array $migrations = array();
/**
* Register a migration class for a module.
*
* @param string $module Module identifier.
* @param TMDO_Migration_Base $migration Migration instance.
* @return void
*/
public static function register( string $module, TMDO_Migration_Base $migration ): void {
self::$migrations[ $module ] = $migration;
}
/**
* Get the migration instance for a module.
*
* @param string $module Module identifier.
* @return TMDO_Migration_Base|null Migration instance, or null if not found.
*/
public static function get_migration( string $module ): ?TMDO_Migration_Base {
return self::$migrations[ $module ] ?? null;
}
/**
* Get all registered migration instances.
*
* @return array<string, TMDO_Migration_Base>
*/
public static function all(): array {
return self::$migrations;
}
// ── State transitions ─────────────────────────────────────────────────
/**
* Check if a state transition is valid.
*
* @param string $module Module identifier.
* @param string $target_state Target state to transition to.
* @return bool True if the transition is allowed.
*/
public static function can_transition( string $module, string $target_state ): bool {
$current = TMDO_Feature_Flags::get( $module );
// Rollback to idle is always allowed.
if ( 'idle' === $target_state ) {
return true;
}
$allowed = self::TRANSITIONS[ $current ] ?? array();
return in_array( $target_state, $allowed, true );
}
/**
* Transition a module to a new state.
*
* @param string $module Module identifier.
* @param string $target_state Target state to transition to.
* @return bool True on success, false if transition is invalid.
*/
public static function transition( string $module, string $target_state ): bool {
if ( ! self::can_transition( $module, $target_state ) ) {
return false;
}
return TMDO_Feature_Flags::set( $module, $target_state );
}
// ── High-level operations ─────────────────────────────────────────────
/**
* Start or resume migration for a module.
*
* Flow: idle → dual_write → backfill (run batches)
*
* @param string $module Module name.
* @param callable|null $progress_callback Called after each batch.
* @return array{status: string, message: string}
*/
public static function migrate( string $module, ?callable $progress_callback = null ): array {
$migration = self::get_migration( $module );
if ( ! $migration ) {
return array(
'status' => 'error',
'message' => "No migration registered for module: {$module}",
);
}
$current = TMDO_Feature_Flags::get( $module );
// Already in backfill — resume.
if ( 'backfill' === $current ) {
$completed = $migration->run( true, $progress_callback );
return array(
'status' => $completed ? 'verify' : 'backfill',
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
);
}
// Already past backfill.
if ( in_array( $current, array( 'verify', 'cutover', 'cleanup', 'complete' ), true ) ) {
return array(
'status' => $current,
'message' => "Module is already in state: {$current}.",
);
}
// Start fresh: idle or dual_write → backfill.
if ( ! self::transition( $module, 'dual_write' ) && 'dual_write' !== $current ) {
return array(
'status' => 'error',
'message' => "Cannot start migration from state: {$current}",
);
}
$completed = $migration->run( false, $progress_callback );
return array(
'status' => $completed ? 'verify' : 'backfill',
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
);
}
/**
* Verify data consistency for a module.
*
* @param string $module Module identifier.
* @return array{status: string, message: string, verified: bool} Verification result.
*/
public static function verify( string $module ): array {
$migration = self::get_migration( $module );
if ( ! $migration ) {
return array(
'status' => 'error',
'message' => "No migration registered for module: {$module}",
'verified' => false,
);
}
$current = TMDO_Feature_Flags::get( $module );
if ( 'verify' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'verify' state. Current: {$current}",
'verified' => false,
);
}
$ok = $migration->verify_counts();
if ( ! $ok ) {
// Verification failed — allow retry via dual_write → backfill.
self::transition( $module, 'dual_write' );
return array(
'status' => 'dual_write',
'message' => 'Verification failed. Rolled back to dual_write for retry.',
'verified' => false,
);
}
return array(
'status' => 'verify',
'message' => 'Verification passed. Ready for cutover.',
'verified' => true,
);
}
/**
* Cutover: switch reads to the custom table.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function cutover( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'verify' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'verify' state. Current: {$current}",
);
}
self::transition( $module, 'cutover' );
return array(
'status' => 'cutover',
'message' => 'Cutover complete. Reads now come from the custom table. Run cleanup when ready.',
);
}
/**
* Rollback: return to idle state from any state.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function rollback( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'idle' === $current ) {
return array(
'status' => 'idle',
'message' => 'Module is already idle.',
);
}
TMDO_Feature_Flags::reset( $module );
return array(
'status' => 'idle',
'message' => "Module rolled back from '{$current}' to idle.",
);
}
/**
* Cleanup: stop writing to native postmeta.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function cleanup( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'cutover' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'cutover' state. Current: {$current}",
);
}
self::transition( $module, 'cleanup' );
return array(
'status' => 'cleanup',
'message' => 'Cleanup started. Native postmeta writes are stopped. Run enable to complete.',
);
}
/**
* Enable: mark migration fully complete.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function enable( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'cleanup' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'cleanup' state. Current: {$current}",
);
}
self::transition( $module, 'complete' );
return array(
'status' => 'complete',
'message' => 'Module fully enabled. All reads and writes use the custom table.',
);
}
/**
* Get comprehensive status for a module.
*
* @param string $module Module identifier.
* @return array{module: string, state: string, zone: string, record: ?array} Module status.
*/
public static function status( string $module ): array {
$migration = self::get_migration( $module );
$record = $migration ? $migration->get_record() : null;
return array(
'module' => $module,
'state' => TMDO_Feature_Flags::get( $module ),
'zone' => $migration ? $migration->get_zone() : '',
'record' => $record,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,628 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform migration tool: WPDO v1->v2 legacy post meta migration
/**
* TMDO_Post_Migration — Post entity migration core (v2.9.3).
*
* Independent of the user-side TMDO_Migration_Orchestrator. The user
* orchestrator is intentionally frozen (1105 lines, hardcoded ENTITY_TYPE='user'
* via `self::ENTITY_TYPE` const) — this class implements the equivalent
* post-side flow without touching any user code path.
*
* Phase coverage (compared to user 10-phase orchestrator):
* diagnose ✓ implemented
* backup ✗ skipped — v2.9.0 postmeta-cleanup CLI handles garbage;
* full wp_postmeta backup deferred to v2.9.4 (admin tab
* + DB hook) since wp_postmeta tends to be very large
* (29k+ rows on dev10) and requires streaming approach
* demote ✗ not applicable — post mode starts at 'disabled', no
* aeav_only state to demote from
* install_schema ✗ already done in v2.9.1 by Schema_Manager auto-create
* backfill_bulk ✓ implemented (per-group, by post_type filter)
* backfill_unserialize ✗ deferred to v2.9.4 (json groups: attachment +
* nav_menu_item have only ~200 rows on dev10)
* promote_shadow ✓ implemented (set_mode dual_write → shadow_read)
* verify_sample ✓ implemented (sample-and-compare)
* promote_aeav ✓ implemented (set_mode → aeav_only)
* cleanup ✓ implemented (DELETE managed wp_postmeta keys)
*
* 🔒 v2.9.x frozen contract: this class must NEVER touch wp_usermeta,
* wp_users, or any wp_wpdo_user_* table. All operations target wp_posts /
* wp_postmeta / wp_wpdo_post_*.
*
* @package WP_Data_Optimizer
* @since 2.9.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal migration: $flat_table goes through Schema_Manager::sanitize_column_name + TMDO_Entity_Registry; $columns_sql/$cases_sql/$update_sql composed from the same sanitized sources; user-controlled values use prepare() placeholders. Multi-statement IN clauses with array_fill('%s') trigger false positives.
/**
* Post entity migration core. Static API mirrors TMDO_Migration_Orchestrator
* for predictability, but each method is post-only.
*/
final class TMDO_Post_Migration {
private const ENTITY_TYPE = 'post';
/**
* Read-only inspection — what's the current post EAV state?
*
* @return array{
* posts:int,
* postmeta:int,
* ratio:float,
* mode:string,
* groups:array<string,array{keys:string[],eav_rows:int,flat_rows:int,post_type:string|null}>
* }
*/
public static function diagnose(): array {
global $wpdb;
$posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" );
$postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" );
$ratio = $posts_count > 0 ? round( $postmeta_count / $posts_count, 2 ) : 0.0;
$groups = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
$post_type = self::group_post_type( $group );
$eav_rows = $keys ? self::count_eav_residue( $keys, $post_type ) : 0;
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
$flat_rows = TMDO_Schema_Manager::table_exists( $flat_table )
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
: 0;
$groups[ $group ] = array(
'keys' => $keys,
'eav_rows' => $eav_rows,
'flat_rows' => $flat_rows,
'post_type' => $post_type,
);
}
return array(
'posts' => $posts_count,
'postmeta' => $postmeta_count,
'ratio' => $ratio,
'mode' => TMDO_Mode_Manager::get( self::ENTITY_TYPE ),
'groups' => $groups,
);
}
/**
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
* into the group's flat table.
*
* @param string $group_name Entity group name (e.g. 'wc_product').
* @return array{migrated:int,group:string,post_type:string|null}
* @throws InvalidArgumentException When group is not registered.
* @throws RuntimeException When the pivot SQL fails.
*/
public static function backfill_group( string $group_name ): array {
global $wpdb;
$fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group_name );
if ( empty( $fields ) ) {
throw new InvalidArgumentException(
'Unknown post entity group: ' . esc_html( $group_name )
);
}
$post_type = self::group_post_type( $group_name );
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
// Introspect target table columns so we only pivot fields that actually
// exist in the flat schema. Defends against partial schema environments
// (e.g. v2.9.3 deployed before Schema_Manager auto-create has run, or
// custom installs that intentionally pruned columns).
$existing_cols = self::get_existing_columns( $flat_table );
if ( empty( $existing_cols ) ) {
throw new RuntimeException(
'Flat table missing or has no columns: ' . esc_html( $flat_table )
);
}
// Build column list and CASE expressions.
// Skip json/textarea types from the bulk pivot — those need row-by-row
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
$columns = array();
$cases = array();
$update_parts = array();
foreach ( $fields as $field ) {
$type = $field['type'] ?? 'text';
if ( 'json' === $type ) {
continue;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( ! isset( $existing_cols[ $col ] ) ) {
continue; // Column not present in this table — skip silently.
}
$key = esc_sql( (string) $field['key'] );
$columns[] = "`{$col}`";
$cases[] = "MAX(CASE WHEN meta_key = '{$key}' THEN meta_value END) AS `{$col}`";
$update_parts[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
if ( empty( $columns ) ) {
return array(
'migrated' => 0,
'group' => $group_name,
'post_type' => $post_type,
);
}
$columns_sql = implode( ', ', $columns );
$cases_sql = implode( ', ', $cases );
$update_sql = implode( ', ', $update_parts );
$post_type_filter = $post_type ? $wpdb->prepare( 'AND p.post_type = %s', $post_type ) : '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->query(
"INSERT INTO `{$flat_table}` (post_id, {$columns_sql})
SELECT pm.post_id, {$cases_sql}
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE 1=1 {$post_type_filter}
GROUP BY pm.post_id
ON DUPLICATE KEY UPDATE {$update_sql}"
);
if ( false === $rows ) {
throw new RuntimeException(
'backfill_group SQL failed: ' . esc_html( (string) $wpdb->last_error )
);
}
// MySQL ON DUPLICATE KEY UPDATE counts changes as 2 per affected row;
// $rows = 2 * matched if pure update; for our migrate use case we just
// want to confirm the operation completed. Re-count flat table rows
// limited to $post_type for a clean migrated count.
$count_sql = $post_type
? $wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s",
$post_type
)
: "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$migrated = (int) $wpdb->get_var( $count_sql );
return array(
'migrated' => $migrated,
'group' => $group_name,
'post_type' => $post_type,
);
}
/**
* Promote post mode along the safe transition path.
*
* Path: disabled → dual_write → shadow_read → aeav_only.
* Must be invoked separately for each step (caller decides timing).
*
* @param string $target_mode One of TMDO_Mode_Manager::MODE_* constants.
* @return true|WP_Error
*/
public static function set_mode( string $target_mode ) {
return TMDO_Mode_Manager::set( self::ENTITY_TYPE, $target_mode );
}
/**
* Cleanup: DELETE managed keys from wp_postmeta. Only callable when
* post mode is aeav_only — otherwise EAV is still authoritative source.
*
* @return array{deleted:int}
* @throws RuntimeException When mode != aeav_only.
*/
public static function cleanup(): array {
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new RuntimeException(
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
global $wpdb;
$keys = self::get_managed_keys();
if ( empty( $keys ) ) {
return array( 'deleted' => 0 );
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$deleted = (int) $wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
return array( 'deleted' => $deleted );
}
/**
* Row-by-row backfill for groups containing json-typed fields (v2.10.4).
*
* Bulk SQL pivot in backfill_group() can't handle json/serialized values
* because the conversion serialize() → wp_json_encode() requires PHP-level
* processing. This method delegates to TMDO_Entity_Migration_Engine which
* already handles safe_unserialize and json encoding row-by-row.
*
* Idempotent — uses checkpoint cursor; safe to re-run.
*
* @param string $group_name Entity group name (e.g. 'attachment', 'nav_menu_item').
* @param array $options Optional: batch_size (default 500), sleep_ms (50),
* resume (true), dry_run (false).
* @return array Engine result tuple including migrated/errors/skipped.
*/
public static function backfill_group_json( string $group_name, array $options = array() ): array {
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
return array( 'error' => 'TMDO_Entity_Migration_Engine class not available' );
}
return TMDO_Entity_Migration_Engine::migrate_group(
self::ENTITY_TYPE,
$group_name,
$options
);
}
// ─────────────────────────────────────────────────────────────────────────
// Legacy zone-table cutover (v2.9.5)
// ─────────────────────────────────────────────────────────────────────────
/**
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
* into the new `wp_wpdo_post_<group>` flat table.
*
* Copies the intersection of columns (by name), excluding `id` and
* `updated_at` so the flat table manages those itself. Uses ON DUPLICATE
* KEY UPDATE so the operation is idempotent — re-running is safe.
*
* The legacy table is left UNTOUCHED — this is critical for v3.0.0 rollback
* safety. The legacy table is only DROP'd at v3.0.0 release after several
* release cycles of the new flat table being authoritative.
*
* @param string $post_type Post type the legacy table targets.
* @param string $hot_table Fully-qualified legacy table name.
* @param string $flat_table Fully-qualified target flat table name.
* @return array{copied:int,common_columns:string[],post_type:string}
* @throws RuntimeException When tables missing or copy SQL fails.
*/
public static function copy_legacy_hot_table(
string $post_type,
string $hot_table,
string $flat_table
): array {
global $wpdb;
$hot_cols = self::get_existing_columns( $hot_table );
$flat_cols = self::get_existing_columns( $flat_table );
if ( empty( $hot_cols ) ) {
throw new RuntimeException(
'Legacy hot table missing or empty: ' . esc_html( $hot_table )
);
}
if ( empty( $flat_cols ) ) {
throw new RuntimeException(
'Target flat table missing: ' . esc_html( $flat_table )
);
}
// Intersect columns by name, excluding ones the flat table manages itself.
$skip = array( 'id', 'updated_at', 'created_at' );
$common = array();
foreach ( $hot_cols as $name => $_ ) {
if ( in_array( $name, $skip, true ) ) {
continue;
}
if ( ! isset( $flat_cols[ $name ] ) ) {
continue;
}
$common[] = $name;
}
// post_id is the unique key — must always be present.
if ( ! in_array( 'post_id', $common, true ) ) {
throw new RuntimeException(
'Cannot copy: post_id column not present in both tables'
);
}
$cols_quoted = '`' . implode( '`, `', $common ) . '`';
$update_parts = array();
foreach ( $common as $col ) {
if ( 'post_id' === $col ) {
continue;
}
$update_parts[] = "`{$col}` = VALUES(`{$col}`)";
}
$update_sql = implode( ', ', $update_parts );
$rows = $wpdb->query(
"INSERT INTO `{$flat_table}` ({$cols_quoted})
SELECT {$cols_quoted} FROM `{$hot_table}`
ON DUPLICATE KEY UPDATE {$update_sql}"
);
if ( false === $rows ) {
throw new RuntimeException(
'copy_legacy_hot_table SQL failed: ' . esc_html( (string) $wpdb->last_error )
);
}
// MySQL counts UPSERT modified rows differently from inserted rows;
// re-count flat table by post_id intersection for clean number.
$copied = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$flat_table}` f
INNER JOIN `{$hot_table}` h ON h.post_id = f.post_id"
);
return array(
'copied' => $copied,
'common_columns' => $common,
'post_type' => $post_type,
);
}
/**
* Verify legacy cutover by row count + sampled value comparison.
*
* Note: only checks row count + post_id presence. Per-column value
* comparison would require knowing both tables' column types and
* applying lossy/lossless conversion rules — out of scope for v2.9.5
* (the COPY operation itself uses INSERT...SELECT which preserves
* values byte-for-byte where types match).
*
* @param string $hot_table Legacy hot table name.
* @param string $flat_table Target flat table name.
* @return array{hot_rows:int,flat_rows:int,mismatched_rows:int,ok:bool}
*/
public static function verify_legacy_cutover( string $hot_table, string $flat_table ): array {
global $wpdb;
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
// Find post_ids in hot but not in flat (i.e. failed copies).
$mismatched = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$hot_table}` h
LEFT JOIN `{$flat_table}` f ON f.post_id = h.post_id
WHERE f.post_id IS NULL"
);
return array(
'hot_rows' => $hot_rows,
'flat_rows' => $flat_rows,
'mismatched_rows' => $mismatched,
'ok' => 0 === $mismatched && $flat_rows >= $hot_rows,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Query Router benchmark (v2.10.2)
// ─────────────────────────────────────────────────────────────────────────
/**
* Compare query latency between wp_postmeta JOIN path and flat-table path
* for a single meta_key/value/compare combination.
*
* Both queries are functionally equivalent but use different storage:
* postmeta path → INNER JOIN wp_postmeta (the legacy WP behavior)
* flat path → INNER JOIN wp_wpdo_post_<group> (the v2.10.1 router target)
*
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
*
* @param string $post_type WP post_type to filter by.
* @param string $meta_key Meta key to query.
* @param string $compare Comparison operator (=, !=, <, <=, >, >=, LIKE).
* @param string $value Value to compare against.
* @param string $flat_table Fully qualified flat table name (must contain $meta_key column).
* @param int $samples Number of times to run each query (default 50).
* @return array{
* samples:int,
* postmeta_avg_ms:float,
* flat_avg_ms:float,
* speedup:float,
* postmeta_rows:int,
* flat_rows:int,
* meta_key:string,
* post_type:string,
* }
* @throws InvalidArgumentException When $samples <= 0 or compare invalid.
* @throws RuntimeException When flat table doesn't exist.
*/
public static function benchmark_query(
string $post_type,
string $meta_key,
string $compare,
string $value,
string $flat_table,
int $samples = 50
): array {
if ( $samples <= 0 ) {
$msg = 'Samples must be > 0, got ' . $samples;
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$allowed_compare = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'LIKE' );
if ( ! in_array( strtoupper( $compare ), $allowed_compare, true ) ) {
$msg = 'Invalid compare: ' . $compare . '. Allowed: ' . implode( ', ', $allowed_compare );
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// Verify flat table exists.
$existing = self::get_existing_columns( $flat_table );
if ( empty( $existing ) ) {
throw new RuntimeException(
'Benchmark target flat table missing: ' . esc_html( $flat_table )
);
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
if ( ! isset( $existing[ $col ] ) ) {
throw new RuntimeException(
'Flat table does not have column for ' . esc_html( $meta_key )
);
}
global $wpdb;
// Build both query templates (parameterized via prepare in the loop).
$pm_sql = $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value {$compare} %s",
$post_type,
$meta_key,
$value
);
$flat_sql = $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
INNER JOIN `{$flat_table}` f ON f.post_id = p.ID
WHERE p.post_type = %s AND f.`{$col}` {$compare} %s",
$post_type,
$value
);
// Warm up MySQL query cache so the first run isn't penalized.
$wpdb->get_var( $pm_sql );
$wpdb->get_var( $flat_sql );
$pm_total = 0.0;
$flat_total = 0.0;
$pm_rows = 0;
$flat_rows = 0;
for ( $i = 0; $i < $samples; $i++ ) {
$start = microtime( true );
$pm_rows = (int) $wpdb->get_var( $pm_sql );
$pm_total += microtime( true ) - $start;
$start = microtime( true );
$flat_rows = (int) $wpdb->get_var( $flat_sql );
$flat_total += microtime( true ) - $start;
}
$pm_avg_ms = ( $pm_total / $samples ) * 1000;
$flat_avg_ms = ( $flat_total / $samples ) * 1000;
$speedup = $flat_avg_ms > 0 ? ( $pm_avg_ms / $flat_avg_ms ) : 0.0;
return array(
'samples' => $samples,
'postmeta_avg_ms' => round( $pm_avg_ms, 3 ),
'flat_avg_ms' => round( $flat_avg_ms, 3 ),
'speedup' => round( $speedup, 2 ),
'postmeta_rows' => $pm_rows,
'flat_rows' => $flat_rows,
'meta_key' => $meta_key,
'post_type' => $post_type,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* All meta_keys registered for any post entity group.
*
* @return string[]
*/
public static function get_managed_keys(): array {
$keys = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) );
}
return array_values( array_unique( $keys ) );
}
/**
* Map group name to the post_type it targets. The mapping is canonical
* to v2.9.1 group definitions in TMDO_Post_Fields.
*
* Returns null for cross-post_type groups (e.g. 'wp_core').
*
* @param string $group Entity group name.
* @return string|null
*/
private static function group_post_type( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
}
/**
* Map of column_name → true for every column that exists in $table.
* Returns empty array if table missing.
*
* @param string $table Fully qualified table name.
* @return array<string,bool>
*/
private static function get_existing_columns( string $table ): array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
if ( empty( $rows ) ) {
return array();
}
$cols = array();
foreach ( $rows as $r ) {
$cols[ (string) ( $r['Field'] ?? '' ) ] = true;
}
return $cols;
}
/**
* Count wp_postmeta rows matching $keys, optionally filtered by post_type.
*
* @param string[] $keys Meta keys to match.
* @param string|null $post_type Optional post_type filter.
* @return int
*/
private static function count_eav_residue( array $keys, ?string $post_type = null ): int {
global $wpdb;
if ( empty( $keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
if ( $post_type ) {
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"SELECT COUNT(*) FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
$post_type,
...$keys
)
);
}
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
}
}
@@ -0,0 +1,140 @@
<?php
/**
* Zone B (Warm) migration for postmeta to KV warm table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B (Warm) migration — postmeta → wpdo_warm KV table with TTL.
*
* Reads registered warm fields from Schema Registry,
* copies their postmeta values into the warm table with optional TTL.
*/
class TMDO_Warm_Migration extends TMDO_Migration_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'warm';
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'warm';
}
/**
* Returns the total number of warm meta rows to migrate.
*
* @return int Total row count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", // phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Warm migration: postmeta → Zone B path.
...$meta_keys
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of warm meta rows.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$registry = TMDO_Schema_Registry::instance();
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key IN ({$placeholders})
ORDER BY meta_id ASC
LIMIT %d OFFSET %d",
...$args
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
if ( empty( $rows ) ) {
return 0;
}
foreach ( $rows as $row ) {
$field = $registry->get_warm_field( $row['meta_key'] );
$ttl = $field['ttl'] ?? null;
TMDO_Zone_Warm::set(
(int) $row['post_id'],
$row['meta_key'],
$row['meta_value'],
$ttl
);
}
return count( $rows );
}
/**
* Verifies that the warm table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Warm::table();
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Warm::table()
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get all registered warm meta_keys.
*
* @return array List of meta key strings.
*/
private function get_meta_keys(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields( 'warm' );
return array_column( $fields, 'meta_key' );
}
}
@@ -0,0 +1,249 @@
<?php // phpcs:ignore Squiz.Classes.ClassFileName.NoMatch -- Abstract class; filename intentionally uses 'abstract-class-' prefix.
/**
* TMDO_Abstract_Notifier — Base for all health-alert notifiers (v2.5.0 M15).
*
* Subclasses (Email / Slack / Telegram / Discord) inherit:
* - wpdo/health_alert_critical action subscription
* - opt-in toggle (`wpdo_{channel}_enabled`)
* - 24h-default throttle keyed by md5 fingerprint of summary
* - shared subject / body builders (overridable)
* - severity subscription (critical_only / critical_and_recommended)
*
* Subclass只 implements `actually_send( $subject, $body, $context ): bool`
* and `channel_id(): string` (used as option-key prefix).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract base — concrete subclasses below.
*/
abstract class TMDO_Abstract_Notifier {
/** Default throttle window. */
public const DEFAULT_THROTTLE_HOURS = 24;
/**
* Channel ID — used as option key prefix (e.g. 'email', 'slack').
*
* @return string
*/
abstract public static function channel_id(): string;
/**
* Channel-specific delivery. Implementations should return false on
* transient failure so caller can re-throttle attempts.
*
* @param string $subject Subject / title (Slack uses as text-prefix).
* @param string $body Body text (channel-specific markdown).
* @param array $context Optional context (recipient/webhook URL/etc.).
* @return bool
*/
abstract public static function actually_send( string $subject, string $body, array $context ): bool;
/**
* Hook subclass into `wpdo/health_alert_critical`.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo/health_alert_critical', array( static::class, 'maybe_send' ), 10, 1 );
}
/**
* Decide whether to send + throttle + dispatch.
*
* @param array $summary Summary array from Health_Cron::run().
* @return bool
*/
public static function maybe_send( array $summary ): bool {
if ( ! static::is_enabled() ) {
return false;
}
// Severity subscription check.
$severity_filter = static::severity_filter();
if ( 'critical_only' === $severity_filter && (int) ( $summary['critical_count'] ?? 0 ) === 0 ) {
return false;
}
// Channel-specific recipient/config sanity check.
$context = static::build_context();
if ( null === $context ) {
return false;
}
// Throttle.
$fingerprint = static::fingerprint( $summary );
if ( static::is_throttled( $fingerprint ) ) {
return false;
}
// Build + send.
$subject = static::build_subject( $summary );
$body = static::build_body( $summary );
$sent = static::actually_send( $subject, $body, $context );
if ( $sent ) {
static::mark_sent( $fingerprint );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
sprintf( '%s_alert_sent', static::channel_id() ),
array(
'fingerprint' => $fingerprint,
'critical' => (int) ( $summary['critical_count'] ?? 0 ),
)
);
}
}
return $sent;
}
// ─── settings accessors(optional override)────────────────────────
/**
* Whether this notification channel is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( static::option_key( 'enabled' ), '0' );
}
/**
* Throttle window in hours (clamped 1..168).
*
* @return int
*/
public static function throttle_hours(): int {
$v = (int) get_option( static::option_key( 'throttle_hours' ), self::DEFAULT_THROTTLE_HOURS );
return max( 1, min( 168, $v ) );
}
/**
* Severity subscription filter.
*
* @return string 'critical_only' or 'critical_and_recommended'
*/
public static function severity_filter(): string {
$v = (string) get_option( static::option_key( 'severity' ), 'critical_only' );
return in_array( $v, array( 'critical_only', 'critical_and_recommended' ), true ) ? $v : 'critical_only';
}
/**
* Build channel-specific context (e.g. webhook URL or recipient address).
* Return null to skip send (e.g. invalid email or empty webhook).
*
* @return array|null
*/
public static function build_context(): ?array {
return array();
}
// ─── shared subject / body builders ───────────────────────────────
/**
* Build alert subject line.
*
* @param array $summary Summary array from Health_Cron::run().
* @return string
*/
public static function build_subject( array $summary ): string {
$site = (string) get_option( 'blogname', 'WordPress' );
$crit = (int) ( $summary['critical_count'] ?? 0 );
$first = '';
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$first = $slug;
break;
}
}
return sprintf( '[%s] WPDO 警告:%d 項 critical (%s)', $site, $crit, $first );
}
/**
* Generic plain-text body. Subclasses can override to add channel-specific
* formatting (Slack mrkdwn, Discord markdown, Telegram MarkdownV2, etc.).
*
* @param array $summary Summary.
* @return string
*/
public static function build_body( array $summary ): string {
$lines = array();
$lines[] = sprintf( '站台:%s', home_url( '/' ) );
$lines[] = sprintf( '檢查時間:%s UTC', (string) ( $summary['ran_at'] ?? '?' ) );
$lines[] = sprintf(
'結果:%d critical / %d recommended',
(int) ( $summary['critical_count'] ?? 0 ),
(int) ( $summary['recommended_count'] ?? 0 )
);
$lines[] = '';
$lines[] = '失敗的檢查:';
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) {
$lines[] = sprintf(
' [%s] %s — %s',
strtoupper( (string) ( $t['status'] ?? '' ) ),
$slug,
(string) ( $t['description'] ?? '' )
);
}
}
$lines[] = '';
$lines[] = '查看詳情:' . admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
return implode( "\n", $lines );
}
// ─── private helpers ─────────────────────────────────────────────
/**
* Per-channel option key (e.g. wpdo_email_enabled, wpdo_slack_webhook).
*
* @param string $field 'enabled' / 'throttle_hours' / 'severity' / etc.
* @return string
*/
protected static function option_key( string $field ): string {
return sprintf( 'wpdo_%s_%s', static::channel_id(), $field );
}
/**
* Stable fingerprint from the alert content for deduplication.
*
* @param array $summary Summary array from Health_Cron::run().
* @return string md5 hash.
*/
protected static function fingerprint( array $summary ): string {
$relevant = array(
'critical_count' => (int) ( $summary['critical_count'] ?? 0 ),
'first_critical' => null,
);
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$relevant['first_critical'] = $slug;
break;
}
}
return md5( wp_json_encode( $relevant ) );
}
/**
* Check whether an alert with this fingerprint was recently sent.
*
* @param string $fingerprint Alert fingerprint (md5).
* @return bool
*/
protected static function is_throttled( string $fingerprint ): bool {
$key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
return false !== get_transient( $key );
}
/**
* Record that an alert was sent (sets throttle transient).
*
* @param string $fingerprint Alert fingerprint (md5).
* @return void
*/
protected static function mark_sent( string $fingerprint ): void {
$key = sprintf( 'wpdo_%s_sent_%s', static::channel_id(), $fingerprint );
set_transient( $key, time(), static::throttle_hours() * HOUR_IN_SECONDS );
}
}
@@ -0,0 +1,76 @@
<?php
/**
* TMDO_Discord_Notifier — Discord webhook channel (v2.5.0 M15).
*
* Settings: wpdo_discord_enabled, wpdo_discord_webhook,
* wpdo_discord_throttle_hours, wpdo_discord_severity.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Discord channel.
*/
class TMDO_Discord_Notifier extends TMDO_Abstract_Notifier {
/**
* Channel identifier used as option key prefix.
*
* @return string
*/
public static function channel_id(): string {
return 'discord';
}
/**
* Build Discord-specific context (webhook URL).
*
* @return array|null Null if webhook URL is missing or invalid.
*/
public static function build_context(): ?array {
$url = class_exists( 'TMDO_Crypto' )
? TMDO_Crypto::get_option( 'wpdo_discord_webhook' )
: (string) get_option( 'wpdo_discord_webhook', '' );
if ( '' === $url || strpos( $url, 'https://discord.com/api/webhooks/' ) !== 0 ) {
return null;
}
return array( 'webhook_url' => $url );
}
/**
* Send alert via Discord webhook.
*
* @param string $subject Alert subject / title.
* @param string $body Alert body text.
* @param array $context Channel context (webhook_url).
* @return bool True on HTTP 2xx response.
*/
public static function actually_send( string $subject, string $body, array $context ): bool {
$url = (string) ( $context['webhook_url'] ?? '' );
if ( '' === $url ) {
return false;
}
// Discord max content length = 2000 chars.
$content = "**{$subject}**\n```\n" . substr( $body, 0, 1800 ) . "\n```";
$payload = wp_json_encode( array( 'content' => $content ) );
$resp = wp_remote_post(
$url,
array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => $payload,
'timeout' => 5,
'blocking' => true,
)
);
if ( is_wp_error( $resp ) ) {
return false;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
// Discord returns 204 on success.
return $code >= 200 && $code < 300;
}
}
@@ -0,0 +1,238 @@
<?php
/**
* TMDO_Email_Notifier — Threshold-based email alerts (v2.4.0 M10).
*
* Subscribes to action `wpdo/health_alert_critical` (fired by Health_Cron M6
* when critical_count > 0). Sends a plain-text email to the configured
* recipient(s) with a 24h throttle key (per-alert-fingerprint) so admins
* don't get spammed.
*
* Default OFF — admin must explicitly enable via Settings tab.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Email notifier — stateless static API.
*/
class TMDO_Email_Notifier {
public const OPT_ENABLED = 'wpdo_email_alerts_enabled';
public const OPT_RECIPIENT = 'wpdo_alert_email';
public const OPT_THROTTLE_HRS = 'wpdo_alert_throttle_hours';
/** Default throttle window. */
public const DEFAULT_THROTTLE_HOURS = 24;
/**
* Register subscriber.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo/health_alert_critical', array( __CLASS__, 'maybe_send' ), 10, 1 );
}
/**
* Decide + send.
*
* @param array $summary Health summary from Health_Cron::run().
* @return bool true on send, false on skip / fail.
*/
public static function maybe_send( array $summary ): bool {
if ( ! self::is_enabled() ) {
return false;
}
$recipient = self::recipient();
if ( ! is_email( $recipient ) ) {
return false;
}
$fingerprint = self::fingerprint( $summary );
if ( self::is_throttled( $fingerprint ) ) {
return false;
}
$subject = self::build_subject( $summary );
$body = self::build_body( $summary );
$sent = wp_mail( $recipient, $subject, $body );
if ( $sent ) {
self::mark_sent( $fingerprint );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'email_alert_sent',
array(
'fingerprint' => $fingerprint,
'critical' => (int) ( $summary['critical_count'] ?? 0 ),
'recipient' => $recipient,
)
);
}
}
return (bool) $sent;
}
// ─── settings accessors ─────────────────────────────────────────────
/**
* Whether email alerts are enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
}
/**
* Alert recipient email address (falls back to admin_email).
*
* @return string
*/
public static function recipient(): string {
$v = (string) get_option( self::OPT_RECIPIENT, '' );
if ( '' === $v ) {
$v = (string) get_option( 'admin_email', '' );
}
return $v;
}
/**
* Throttle window in hours (clamped 1..168).
*
* @return int
*/
public static function throttle_hours(): int {
$v = (int) get_option( self::OPT_THROTTLE_HRS, self::DEFAULT_THROTTLE_HOURS );
return max( 1, min( 168, $v ) ); // Clamp 1h..1week.
}
// ─── private helpers ──────────────────────────────────────────────
/**
* Stable fingerprint from the alert content (so identical incidents
* dedupe within the throttle window).
*
* @param array $summary Summary array.
* @return string md5 hash.
*/
private static function fingerprint( array $summary ): string {
$relevant = array(
'critical_count' => (int) ( $summary['critical_count'] ?? 0 ),
'first_critical' => null,
);
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$relevant['first_critical'] = $slug;
break;
}
}
return md5( wp_json_encode( $relevant ) );
}
/**
* Check whether an alert with this fingerprint was recently sent.
*
* @param string $fingerprint Alert fingerprint (md5).
* @return bool
*/
private static function is_throttled( string $fingerprint ): bool {
$key = 'wpdo_alert_sent_' . $fingerprint;
return false !== get_transient( $key );
}
/**
* Record that an alert was sent (sets throttle transient).
*
* @param string $fingerprint Alert fingerprint (md5).
* @return void
*/
private static function mark_sent( string $fingerprint ): void {
$key = 'wpdo_alert_sent_' . $fingerprint;
set_transient( $key, time(), self::throttle_hours() * HOUR_IN_SECONDS );
}
/**
* Build subject. Site name + critical count + first slug.
*
* @param array $summary Summary array.
* @return string
*/
private static function build_subject( array $summary ): string {
$site = (string) get_option( 'blogname', 'WordPress' );
$crit = (int) ( $summary['critical_count'] ?? 0 );
$first = '';
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( 'critical' === ( $t['status'] ?? '' ) ) {
$first = $slug;
break;
}
}
return sprintf(
/* translators: 1: site name, 2: critical count, 3: first critical test slug */
__( '[%1$s] WPDO 警告:%2$d 項 critical (%3$s)', '2meet-data-optimizer' ),
$site,
$crit,
$first
);
}
/**
* Build plain-text body.
*
* @param array $summary Summary array.
* @return string
*/
private static function build_body( array $summary ): string {
$site_url = home_url( '/' );
$health_url = admin_url( 'site-health.php' );
$wpdo_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=doctor' );
$lines = array();
$lines[] = __( 'WP Data Optimizer 自動健康檢查發現 critical 警告。', '2meet-data-optimizer' );
$lines[] = '';
$lines[] = sprintf( '站台:%s', $site_url );
$lines[] = sprintf(
/* translators: %s: timestamp */
__( '檢查時間:%s UTC', '2meet-data-optimizer' ),
(string) ( $summary['ran_at'] ?? '?' )
);
$lines[] = sprintf(
/* translators: 1: critical count, 2: recommended count */
__( '結果:%1$d critical / %2$d recommended', '2meet-data-optimizer' ),
(int) ( $summary['critical_count'] ?? 0 ),
(int) ( $summary['recommended_count'] ?? 0 )
);
$lines[] = '';
$lines[] = __( '失敗的檢查:', '2meet-data-optimizer' );
foreach ( (array) ( $summary['tests'] ?? array() ) as $slug => $t ) {
if ( in_array( ( $t['status'] ?? '' ), array( 'critical', 'recommended' ), true ) ) {
$lines[] = sprintf(
' [%s] %s — %s',
strtoupper( (string) $t['status'] ),
$slug,
(string) ( $t['description'] ?? '' )
);
}
}
$lines[] = '';
$lines[] = __( '建議行動:', '2meet-data-optimizer' );
$lines[] = ' · ' . sprintf(
/* translators: %s: WPDO Doctor admin URL */
__( '立即查看 WPDO Doctor:%s', '2meet-data-optimizer' ),
$wpdo_url
);
$lines[] = ' · ' . sprintf(
/* translators: %s: WP Site Health admin URL */
__( '或 WP Site Health:%s', '2meet-data-optimizer' ),
$health_url
);
$lines[] = '';
$lines[] = sprintf(
/* translators: %d: hours */
__( '註:相同警告在 %d 小時內不會重發;前往設定可調整 throttle / 收件人 / 關閉。', '2meet-data-optimizer' ),
self::throttle_hours()
);
return implode( "\n", $lines );
}
}
@@ -0,0 +1,78 @@
<?php
/**
* TMDO_Slack_Notifier — Slack incoming webhook channel (v2.5.0 M15).
*
* Settings: wpdo_slack_enabled, wpdo_slack_webhook, wpdo_slack_throttle_hours,
* wpdo_slack_severity. Webhook URL must start with https://hooks.slack.com/.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Slack channel.
*/
class TMDO_Slack_Notifier extends TMDO_Abstract_Notifier {
/**
* Channel identifier used as option key prefix.
*
* @return string
*/
public static function channel_id(): string {
return 'slack';
}
/**
* Build Slack-specific context (webhook URL).
*
* @return array|null Null if webhook URL is missing or invalid.
*/
public static function build_context(): ?array {
$url = class_exists( 'TMDO_Crypto' )
? TMDO_Crypto::get_option( 'wpdo_slack_webhook' )
: (string) get_option( 'wpdo_slack_webhook', '' );
if ( '' === $url || strpos( $url, 'https://hooks.slack.com/' ) !== 0 ) {
return null;
}
return array( 'webhook_url' => $url );
}
/**
* Send alert via Slack incoming webhook.
*
* @param string $subject Alert subject / title.
* @param string $body Alert body text.
* @param array $context Channel context (webhook_url).
* @return bool True on HTTP 2xx response.
*/
public static function actually_send( string $subject, string $body, array $context ): bool {
$url = (string) ( $context['webhook_url'] ?? '' );
if ( '' === $url ) {
return false;
}
// Slack mrkdwn — bold subject + plaintext body in code block for readability.
$payload = wp_json_encode(
array(
'text' => "*{$subject}*\n```\n{$body}\n```",
)
);
$resp = wp_remote_post(
$url,
array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => $payload,
'timeout' => 5,
'blocking' => true,
)
);
if ( is_wp_error( $resp ) ) {
return false;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
return $code >= 200 && $code < 300;
}
}
@@ -0,0 +1,95 @@
<?php
/**
* TMDO_Telegram_Notifier — Telegram bot channel (v2.5.0 M15).
*
* Settings: wpdo_telegram_enabled, wpdo_telegram_bot_token,
* wpdo_telegram_chat_id, wpdo_telegram_throttle_hours, wpdo_telegram_severity.
*
* Bot token is sensitive: stored as-is in wp_options for now (admin-only).
* Future hardening: integrate TMDO_Crypto encryption (note in v2.5+).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Telegram channel.
*/
class TMDO_Telegram_Notifier extends TMDO_Abstract_Notifier {
/**
* Channel identifier used as option key prefix.
*
* @return string
*/
public static function channel_id(): string {
return 'telegram';
}
/**
* Build Telegram-specific context (bot token + chat ID).
*
* @return array|null Null if token or chat ID is missing or invalid.
*/
public static function build_context(): ?array {
$token = class_exists( 'TMDO_Crypto' )
? TMDO_Crypto::get_option( 'wpdo_telegram_bot_token' )
: (string) get_option( 'wpdo_telegram_bot_token', '' );
$chat = (string) get_option( 'wpdo_telegram_chat_id', '' );
if ( '' === $token || '' === $chat ) {
return null;
}
// Bot tokens are formatted as 123456:ABC-DEF...; minimal validation.
if ( ! preg_match( '/^\d+:[A-Za-z0-9_\-]{20,}$/', $token ) ) {
return null;
}
return array(
'token' => $token,
'chat_id' => $chat,
);
}
/**
* Send alert via Telegram Bot API.
*
* @param string $subject Alert subject / title.
* @param string $body Alert body text.
* @param array $context Channel context (token, chat_id).
* @return bool True on HTTP 2xx response.
*/
public static function actually_send( string $subject, string $body, array $context ): bool {
$token = (string) ( $context['token'] ?? '' );
$chat = (string) ( $context['chat_id'] ?? '' );
if ( '' === $token || '' === $chat ) {
return false;
}
// Telegram MarkdownV2 has many escaped chars; use plain text mode for safety.
$text = "🚨 {$subject}\n\n{$body}";
// Telegram message limit = 4096 chars.
$text = substr( $text, 0, 4000 );
$url = sprintf( 'https://api.telegram.org/bot%s/sendMessage', rawurlencode( $token ) );
$resp = wp_remote_post(
$url,
array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode(
array(
'chat_id' => $chat,
'text' => $text,
)
),
'timeout' => 5,
'blocking' => true,
)
);
if ( is_wp_error( $resp ) ) {
return false;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
return $code >= 200 && $code < 300;
}
}
@@ -0,0 +1,320 @@
<?php
/**
* TMDO_Post_Query_Router — Post entity meta_query → flat table JOIN rewriter (v2.10.1).
*
* Companion to legacy TMDO_Query_Router (which targets zone hot tables).
* Independent class — does NOT modify the user side path or the legacy zone
* router. Both routers can coexist on the same WP_Query without conflict
* because they extract clauses on disjoint criteria:
*
* - TMDO_Query_Router → only zone-registered keys with feature_flag query_active
* - TMDO_Post_Query_Router → only entity-registered keys with mode=reads_from_flat
*
* Mode-gated: only rewrites when post mode is `shadow_read` or `aeav_only`.
* In `disabled` / `dual_write` modes the router is a no-op.
*
* Hook order:
* 1. pre_get_posts — strip registered keys from meta_query, store in
* $query->set( 'wpdo_post_clauses', $routed )
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed post_type
* 3. posts_where — append WHERE conditions targeting flat columns
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
*
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'.
*
* @package WP_Data_Optimizer
* @since 2.10.1
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal query rewriter: table names go through Schema_Manager::sanitize_column_name, column names go through esc_sql, user-controlled values use $wpdb->prepare() in build_condition().
/**
* Rewrites WP_Query meta_query clauses to JOIN against post entity flat tables.
*/
class TMDO_Post_Query_Router {
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
private const QUERY_VAR = 'wpdo_post_clauses';
/**
* Map post_type → entity group (mirror of TMDO_Post_Migration::group_post_type
* but inverted; a post_type may map to multiple groups eventually, currently
* we route to the most-specific *_core group plus the cross-cutting wp_core
* group when the key is in wp_core).
*
* @var array<string,string>
*/
private const POST_TYPE_GROUP_MAP = array(
'product' => 'wc_product',
'hp_listing' => 'hp_listing_core',
'hp_request' => 'hp_request_core',
'hp_vendor' => 'hp_vendor_core',
'attachment' => 'attachment',
'nav_menu_item' => 'nav_menu_item',
);
/**
* Register all WP_Query hooks.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
}
/**
* Strip Entity-Registry-managed meta_query keys; store routed clauses.
*
* @param \WP_Query $query Current WP_Query.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
// Mode gate: only run when post mode is reads_from_flat.
if ( ! self::is_router_active() ) {
return;
}
// is_admin guard (matches legacy TMDO_Query_Router behavior).
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
$post_types = (array) $query->get( 'post_type' );
if ( empty( $post_types ) ) {
return;
}
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
$routed = array(); // Keyed by post_type.
$remaining = array();
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
$matched = false;
foreach ( $post_types as $pt ) {
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] );
if ( ! $field ) {
continue;
}
// Confirm the field's group is appropriate for this post_type:
// either cross-cutting wp_core (any) or the post_type-specific group.
$group = $field['group'] ?? '';
$expected_grp = self::POST_TYPE_GROUP_MAP[ $pt ] ?? null;
$is_appropriate = ( 'wp_core' === $group ) || ( null !== $expected_grp && $expected_grp === $group );
if ( ! $is_appropriate ) {
continue;
}
$column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
$routed[ $pt ][] = array(
'group' => $group,
'column' => $column,
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ),
'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ),
);
$matched = true;
break;
}
if ( ! $matched ) {
$remaining[ $k ] = $clause;
}
}
if ( empty( $routed ) ) {
return;
}
// Preserve relation if surviving clauses still have it.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( self::QUERY_VAR, $routed );
}
/**
* LEFT JOIN flat tables for each routed post_type.
*
* @param string|null $join Current JOIN SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $join;
}
global $wpdb;
foreach ( $routed as $post_type => $clauses ) {
// Each routed clause carries its group; use the first clause's group
// (all clauses for a given post_type currently target one group).
$group = $clauses[0]['group'] ?? '';
if ( '' === $group ) {
continue;
}
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
// Don't double-join.
if ( false !== strpos( $join, "`{$alias}`" ) ) {
continue;
}
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
return $join;
}
/**
* Append WHERE conditions for routed clauses.
*
* @param string|null $where Current WHERE SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $where;
}
global $wpdb;
foreach ( $routed as $post_type => $clauses ) {
foreach ( $clauses as $clause ) {
$alias = self::table_alias( $post_type, $clause['group'] );
$col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
$compare = self::sanitize_compare( $clause['compare'] );
$value = $clause['value'];
$where .= self::build_condition( $col, $compare, $value );
}
}
return $where;
}
/**
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
*
* @param string|null $groupby Current GROUP BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
*
* @return bool
*/
public static function is_router_active(): bool {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return false;
}
return TMDO_Mode_Manager::reads_from_flat( 'post' );
}
/**
* Generate a deterministic JOIN alias for a (post_type, group) pair.
*
* @param string $post_type Post type slug.
* @param string $group Entity group name.
* @return string SQL alias.
*/
private static function table_alias( string $post_type, string $group ): string {
return 'wpdoflat_' . sanitize_key( $post_type ) . '_' . sanitize_key( $group );
}
/**
* Allow-list of comparison operators.
*
* @param string $compare Raw operator from clause.
* @return string Sanitized operator (defaults to '=').
*/
private static function sanitize_compare( string $compare ): string {
$allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
$compare = strtoupper( trim( $compare ) );
return in_array( $compare, $allowed, true ) ? $compare : '=';
}
/**
* Build a single WHERE condition fragment (always prepended with " AND ").
*
* @param string $col_sql Quoted column reference.
* @param string $compare Sanitized comparison operator.
* @param mixed $value Raw value (scalar or array).
* @return string
*/
private static function build_condition( string $col_sql, string $compare, $value ): string {
global $wpdb;
if ( in_array( $compare, array( 'IN', 'NOT IN' ), true ) ) {
$values = (array) $value;
if ( empty( $values ) ) {
return '';
}
$placeholders = implode( ',', array_fill( 0, count( $values ), '%s' ) );
return ' AND ' . $col_sql . ' ' . $compare . ' (' . $wpdb->prepare( $placeholders, ...$values ) . ')';
}
if ( in_array( $compare, array( 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
$values = (array) $value;
if ( count( $values ) < 2 ) {
return '';
}
return ' AND ' . $col_sql . ' ' . $compare . ' '
. $wpdb->prepare( '%s AND %s', $values[0], $values[1] );
}
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$op = 'EXISTS' === $compare ? 'IS NOT NULL' : 'IS NULL';
return ' AND ' . $col_sql . ' ' . $op;
}
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
}
}
@@ -0,0 +1,358 @@
<?php
/**
* Base class for WP_Query-level interceptors.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Base class for WP_Query-level interceptors.
*
* Intercepts meta_query conditions for specific post types and rewrites them
* to query custom tables instead of wp_postmeta, eliminating expensive
* wp_postmeta JOINs for filtered queries.
*
* Pattern:
* 1. pre_get_posts — detect meta_query clauses we can serve; strip them from
* meta_query (preventing the postmeta JOIN), store in a
* private query var.
* 2. posts_join — LEFT JOIN our custom table aliased to avoid conflicts.
* 3. posts_where — append WHERE conditions against our custom table columns.
* 4. posts_groupby — ensure GROUP BY wp_posts.ID to prevent duplicates.
*
* Each subclass declares:
* - get_module() — module name (TMDO_Feature_Flags key)
* - get_post_types() — e.g. ['hp_review']
* - get_table() — custom table name without prefix, e.g. 'hpct_reviews'
* - get_join_column() — column that maps to wp_posts.ID (default 'post_id')
* - get_meta_key_map() — [ 'meta_key' => 'column_name' ] for flat-column tables
*
* Ported from HPCT_Query_Interceptor_Base with WPDO enhancements:
* - Uses TMDO_Feature_Flags (7-state) for query-active check
* - Uses TMDO_DB::table() for table name resolution
* - Query var prefix: wpdo_qi_ (avoids collision with HPCT)
*/
abstract class TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier for feature flag lookups.
*
* @return string Module name.
*/
abstract protected function get_module(): string;
/**
* Returns the post types this interceptor handles.
*
* @return string[] Array of post type slugs.
*/
abstract protected function get_post_types(): array;
/**
* Custom table name without $wpdb->prefix.
* Passed through TMDO_DB::table() at runtime.
*/
abstract protected function get_table(): string;
/**
* Returns the column name used to join to wp_posts.ID.
*
* @return string Join column name, default 'post_id'.
*/
protected function get_join_column(): string {
return 'post_id';
}
/**
* Meta_key to custom table column map (flat-column tables only).
* Return empty array for KV tables (override posts_where instead).
*
* @return array<string, string> Map of meta_key to column name.
*/
abstract protected function get_meta_key_map(): array;
// ── Hook registration ─────────────────────────────────────────────────
/**
* Registers all WordPress query hooks.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
}
// ── Hook handlers ─────────────────────────────────────────────────────
/**
* Intercepts pre_get_posts to rewrite meta_query conditions for our custom tables.
*
* @param \WP_Query $query The WP_Query object.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
if ( ! $this->should_intercept( $query ) ) {
return;
}
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
$key_map = $this->get_meta_key_map();
$our_clauses = array();
$remaining = array();
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
if ( isset( $key_map[ $clause['key'] ] ) ) {
$our_clauses[] = array(
'column' => $key_map[ $clause['key'] ],
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
);
} else {
$remaining[ $k ] = $clause;
}
}
if ( empty( $our_clauses ) ) {
return;
}
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( $this->query_var(), $our_clauses );
}
/**
* Appends a LEFT JOIN to the custom table when this interceptor has active clauses.
*
* @param string $join Current JOIN SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
if ( ! $this->has_clauses( $query ) ) {
return $join;
}
global $wpdb;
$alias = $this->table_alias();
if ( false === strpos( $join, "`{$alias}`" ) ) {
$table = TMDO_DB::table( $this->get_table() );
$join_col = esc_sql( $this->get_join_column() );
$join .= " LEFT JOIN `{$table}` AS `{$alias}`"
. " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`)";
}
return $join;
}
/**
* Appends WHERE conditions for the custom table columns.
*
* @param string $where Current WHERE SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $where;
}
$alias = $this->table_alias();
foreach ( $clauses as $clause ) {
$col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
$compare = $this->sanitize_compare( $clause['compare'] );
$type = $clause['type'];
$value = $clause['value'];
$where .= $this->build_condition( $col, $compare, $type, $value );
}
return $where;
}
/**
* Ensures GROUP BY is set to prevent duplicates from LEFT JOINs.
*
* @param string $groupby Current GROUP BY SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
if ( ! $this->has_clauses( $query ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
// ── Internal helpers ──────────────────────────────────────────────────
/**
* Determines whether this interceptor should handle the given query.
*
* @param \WP_Query $query The WP_Query object.
* @return bool True if the query should be intercepted.
*/
protected function should_intercept( \WP_Query $query ): bool {
if ( ! TMDO_Feature_Flags::is_query_active( $this->get_module() ) ) {
return false;
}
$post_types = (array) $query->get( 'post_type' );
return ! empty( array_intersect( $post_types, $this->get_post_types() ) );
}
/**
* Returns the query var key used to store intercepted clauses.
*
* @return string Query var name.
*/
protected function query_var(): string {
return 'wpdo_qi_' . $this->get_module();
}
/**
* Returns the SQL alias for the custom table in JOIN clauses.
*
* @return string Table alias.
*/
protected function table_alias(): string {
return 'wpdo_' . $this->get_module();
}
/**
* Checks whether the query has any intercepted clauses stored.
*
* @param \WP_Query $query The WP_Query object.
* @return bool True if there are stored clauses.
*/
protected function has_clauses( \WP_Query $query ): bool {
$clauses = $query->get( $this->query_var() );
return ! empty( $clauses );
}
/**
* Retrieves stored intercepted clauses from the query.
*
* @param \WP_Query $query The WP_Query object.
* @return array[] Array of clause definitions.
*/
protected function get_clauses( \WP_Query $query ): array {
$val = $query->get( $this->query_var() );
return is_array( $val ) ? $val : array();
}
/**
* Build a single WHERE condition string.
* All values go through $wpdb->prepare().
*
* @param string $col Column reference (escaped).
* @param string $compare Comparison operator.
* @param string $type Meta type for placeholder selection.
* @param mixed $value Value(s) to compare against.
* @return string SQL WHERE condition fragment.
*/
protected function build_condition( string $col, string $compare, string $type, $value ): string {
global $wpdb;
$ph = $this->placeholder( $type );
switch ( $compare ) {
case 'IN':
case 'NOT IN':
$vals = array_values( (array) $value );
if ( empty( $vals ) ) {
return ' AND 1=0';
}
$phs = implode( ',', array_fill( 0, count( $vals ), $ph ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals );
case 'BETWEEN':
case 'NOT BETWEEN':
$vals = array_values( (array) $value );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] );
case 'EXISTS':
return " AND {$col} IS NOT NULL";
case 'NOT EXISTS':
return " AND {$col} IS NULL";
default:
// =, !=, >, >=, <, <=, LIKE, NOT LIKE
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value );
}
}
/**
* Returns the SQL placeholder string for a given meta type.
*
* @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR).
* @return string SQL placeholder (%d or %s).
*/
protected function placeholder( string $type ): string {
$int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' );
return in_array( $type, $int_types, true ) ? '%d' : '%s';
}
/**
* Sanitizes a comparison operator against an allowed list.
*
* @param string $compare Comparison operator string.
* @return string Sanitized comparison operator, defaults to '='.
*/
protected function sanitize_compare( string $compare ): string {
$allowed = array(
'=',
'!=',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
'EXISTS',
'NOT EXISTS',
);
return in_array( $compare, $allowed, true ) ? $compare : '=';
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
/**
* Zone A Query Router for flat-column hot table rewrites.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone A Query Router — rewrites meta_query to JOIN flat-column hot tables.
*
* Unlike the HPCT-inherited query interceptors (which each handle one specific
* post type and table), the Query Router dynamically handles ANY post type
* that has Zone A (hot) fields registered in the Schema Registry.
*
* Key advantage over KV tables:
* KV table: N meta_query conditions = N LEFT JOINs
* Flat table: N conditions = 1 LEFT JOIN + N WHERE clauses
*
* Pattern:
* 1. pre_get_posts — detect meta_query keys that map to hot columns;
* strip them and store in wpdo_hot_clauses query var.
* 2. posts_join — LEFT JOIN wpdo_hot_{post_type} once per post type.
* 3. posts_where — append WHERE conditions against hot table columns.
* 4. posts_groupby — ensure GROUP BY wp_posts.ID.
*/
class TMDO_Query_Router {
/**
* Query var key for storing extracted hot clauses.
*/
private const QUERY_VAR = 'wpdo_hot_clauses';
/**
* Register WP_Query filter hooks.
*/
public function register_hooks(): void {
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
}
/**
* Extract meta_query clauses that can be served by hot tables.
*
* @param \WP_Query $query The WP_Query object.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
$post_types = (array) $query->get( 'post_type' );
if ( empty( $post_types ) ) {
return;
}
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
$registry = TMDO_Schema_Registry::instance();
$hot_clauses = array(); // Keyed by post_type.
$remaining = array();
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
$matched = false;
foreach ( $post_types as $pt ) {
$field = $registry->get_field( $pt, $clause['key'] );
if ( $field && 'hot' === $field['zone'] ) {
// Check module is query-active.
$module = 'hot_' . sanitize_key( $pt );
if ( ! TMDO_Feature_Flags::is_query_active( $module ) ) {
continue;
}
$hot_clauses[ $pt ][] = array(
'column' => $field['column'],
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
);
$matched = true;
break;
}
}
if ( ! $matched ) {
$remaining[ $k ] = $clause;
}
}
if ( empty( $hot_clauses ) ) {
return;
}
// Preserve relation if there are remaining clauses.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( self::QUERY_VAR, $hot_clauses );
}
/**
* LEFT JOIN hot tables for each post type with extracted clauses.
*
* @param string $join Current JOIN SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$hot_clauses = $query->get( self::QUERY_VAR );
if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
return $join;
}
global $wpdb;
foreach ( $hot_clauses as $post_type => $clauses ) {
$alias = $this->table_alias( $post_type );
if ( false !== strpos( $join, "`{$alias}`" ) ) {
continue;
}
$table = TMDO_Zone_Hot::table( $post_type );
$join .= " LEFT JOIN `{$table}` AS `{$alias}`"
. " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
return $join;
}
/**
* Append WHERE conditions for hot table columns.
*
* @param string $where Current WHERE SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$hot_clauses = $query->get( self::QUERY_VAR );
if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
return $where;
}
foreach ( $hot_clauses as $post_type => $clauses ) {
$alias = $this->table_alias( $post_type );
foreach ( $clauses as $clause ) {
$col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
$compare = $this->sanitize_compare( $clause['compare'] );
$type = $clause['type'];
$value = $clause['value'];
$where .= $this->build_condition( $col, $compare, $type, $value );
}
}
return $where;
}
/**
* Ensure GROUP BY to prevent duplicate rows from JOINs.
*
* @param string $groupby Current GROUP BY SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$hot_clauses = $query->get( self::QUERY_VAR );
if ( empty( $hot_clauses ) || ! is_array( $hot_clauses ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Generate a unique alias for the hot table JOIN.
*
* @param string $post_type Post type slug.
* @return string SQL table alias.
*/
private function table_alias( string $post_type ): string {
return 'wpdo_hot_' . sanitize_key( $post_type );
}
/**
* Build a single WHERE condition with prepared values.
*
* @param string $col Column reference (escaped).
* @param string $compare Comparison operator.
* @param string $type Meta type for placeholder selection.
* @param mixed $value Value(s) to compare against.
* @return string SQL WHERE condition fragment.
*/
private function build_condition( string $col, string $compare, string $type, $value ): string {
global $wpdb;
$ph = $this->placeholder( $type );
switch ( $compare ) {
case 'IN':
case 'NOT IN':
$vals = array_values( (array) $value );
if ( empty( $vals ) ) {
return ' AND 1=0';
}
$phs = implode( ',', array_fill( 0, count( $vals ), $ph ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $wpdb->prepare( " AND {$col} {$compare} ({$phs})", ...$vals );
case 'BETWEEN':
case 'NOT BETWEEN':
$vals = array_values( (array) $value );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
return $wpdb->prepare( " AND {$col} {$compare} {$ph} AND {$ph}", $vals[0], $vals[1] ?? $vals[0] );
case 'EXISTS':
return " AND {$col} IS NOT NULL";
case 'NOT EXISTS':
return " AND {$col} IS NULL";
default:
// =, !=, >, >=, <, <=, LIKE, NOT LIKE
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $col/$compare/$ph are sanitized; $ph is a literal placeholder string.
return $wpdb->prepare( " AND {$col} {$compare} {$ph}", $value );
}
}
/**
* Returns the SQL placeholder string for a given meta type.
*
* @param string $type Meta type (NUMERIC, SIGNED, etc. or CHAR).
* @return string SQL placeholder (%d or %s).
*/
private function placeholder( string $type ): string {
$int_types = array( 'NUMERIC', 'SIGNED', 'UNSIGNED', 'INTEGER' );
return in_array( $type, $int_types, true ) ? '%d' : '%s';
}
/**
* Sanitizes a comparison operator against an allowed list.
*
* @param string $compare Comparison operator string.
* @return string Sanitized comparison operator, defaults to '='.
*/
private function sanitize_compare( string $compare ): string {
$allowed = array(
'=',
'!=',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
'EXISTS',
'NOT EXISTS',
);
return in_array( $compare, $allowed, true ) ? $compare : '=';
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* TMDO_FSM_Guard — Safety rails for the 7-state FSM (v2.2.0 M2).
*
* Hooks `TMDO_Feature_Flags::set()` to:
* 1. Validate that the requested transition is in the allowed graph
* (idle → dual_write → backfill → verify → cutover → cleanup → complete,
* any state → idle for emergency rewind).
* 2. Take an automatic snapshot before destructive transitions
* (cutover → cleanup, cleanup → complete, any active state → idle).
* 3. Allow override via filter `wpdo/fsm_guard/bypass` (CLI `--force`).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Stateless guard: check + snapshot, return WP_Error on block.
*/
class TMDO_FSM_Guard {
/**
* Allowed forward transitions. Maps from-state → list of valid to-states.
* Backward to `idle` is allowed from anywhere (emergency rewind).
*/
public const FORWARD_GRAPH = array(
'idle' => array( 'dual_write' ),
'dual_write' => array( 'backfill', 'verify' ),
'backfill' => array( 'verify' ),
'verify' => array( 'cutover' ),
'cutover' => array( 'cleanup' ),
'cleanup' => array( 'complete' ),
'complete' => array(), // terminal — only rewind to idle.
);
/**
* Transitions that destroy data on completion (write-only-custom or purge).
* These trigger an automatic pre-transition snapshot.
*/
public const DESTRUCTIVE_TRANSITIONS = array(
'cutover' => array( 'cleanup' ), // cleanup purges wp_*meta.
'cleanup' => array( 'complete' ), // complete = no fallback to wp_*meta.
);
/**
* "Active" states (writes hit custom tables). Reverting these to idle
* potentially loses data and warrants a snapshot.
*/
public const ACTIVE_STATES = array(
'dual_write',
'backfill',
'verify',
'cutover',
'cleanup',
'complete',
);
/**
* Check whether a transition is allowed. Returns true on allow,
* WP_Error on block.
*
* @param string $module Module name (must be a known module).
* @param string $from Current state.
* @param string $to Target state.
* @return true|WP_Error
*/
public static function can_transition( string $module, string $from, string $to ) {
// Allow filter-based bypass (CLI --force, integration tests, manual override).
$bypass = (bool) apply_filters( 'wpdo/fsm_guard/bypass', false, $module, $from, $to );
if ( $bypass ) {
return true;
}
// No-op transition.
if ( $from === $to ) {
return true;
}
// Backward to idle is always allowed (emergency rewind).
if ( 'idle' === $to ) {
return true;
}
// Validate against forward graph.
$allowed = self::FORWARD_GRAPH[ $from ] ?? array();
if ( ! in_array( $to, $allowed, true ) ) {
return new WP_Error(
'wpdo_fsm_invalid_transition',
sprintf(
/* translators: 1: module, 2: from-state, 3: to-state, 4: list of allowed states */
__( 'Cannot transition module %1$s from %2$s to %3$s. Allowed forward states from %2$s: %4$s. Pass --force or set the wpdo/fsm_guard/bypass filter to override.', '2meet-data-optimizer' ),
$module,
$from,
$to,
empty( $allowed ) ? __( '(none — terminal state)', '2meet-data-optimizer' ) : implode( ', ', $allowed )
),
array(
'module' => $module,
'from' => $from,
'to' => $to,
'allowed' => $allowed,
)
);
}
return true;
}
/**
* Decide whether a transition is destructive (warrants auto-snapshot).
*
* @param string $from Current state.
* @param string $to Target state.
* @return bool
*/
public static function is_destructive( string $from, string $to ): bool {
// Cleanup/complete transitions purge data.
if ( isset( self::DESTRUCTIVE_TRANSITIONS[ $from ] )
&& in_array( $to, self::DESTRUCTIVE_TRANSITIONS[ $from ], true ) ) {
return true;
}
// Reverting an active module to idle abandons custom-table data.
if ( 'idle' === $to && in_array( $from, self::ACTIVE_STATES, true ) ) {
return true;
}
return false;
}
/**
* Take an auto-snapshot for the pending FSM transition.
* Called from Feature_Flags::set() before update_option.
*
* @param string $module Module name.
* @param string $from Current state.
* @param string $to Target state.
* @return string|null Snapshot ID on success; null on skip/failure.
*/
public static function maybe_snapshot( string $module, string $from, string $to ): ?string {
if ( ! self::is_destructive( $from, $to ) ) {
return null;
}
// Check if Snapshot_Manager is loaded — defensive (in case unit tests skip include).
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return null;
}
// Allow filter to suppress (e.g. CLI passed --skip-snapshot for emergency rewind).
$skip = (bool) apply_filters( 'wpdo/fsm_guard/skip_snapshot', false, $module, $from, $to );
if ( $skip ) {
return null;
}
$result = TMDO_Snapshot_Manager::create(
'pre_fsm_transition',
array(),
array(
'notes' => sprintf( '[%s] %s → %s', $module, $from, $to ),
'retention_days' => 90, // Keep FSM-rewind snapshots longer.
)
);
return ! empty( $result['ok'] ) ? (string) $result['snapshot_id'] : null;
}
/**
* Convenience: check + snapshot + record entry time. Called from
* Feature_Flags::set(). Returns WP_Error to block, true to allow.
*
* @param string $module Module identifier.
* @param string $to Target state.
* @return true|WP_Error
*/
public static function before_transition( string $module, string $to ) {
$from = TMDO_Feature_Flags::get( $module );
$check = self::can_transition( $module, $from, $to );
if ( is_wp_error( $check ) ) {
return $check;
}
self::maybe_snapshot( $module, $from, $to );
return true;
}
/**
* Record state entry time after a successful transition. Stored in
* wp_options key `wpdo_fsm_state_entered` for verify-gate / wash-period
* checks (v2.3.0 M5 will consume this).
*
* @param string $module Module identifier.
* @param string $to New state.
* @return void
*/
public static function record_entry( string $module, string $to ): void {
$key = 'wpdo_fsm_state_entered';
$data = get_option( $key, array() );
if ( ! is_array( $data ) ) {
$data = array();
}
$data[ $module ] = array(
'state' => $to,
'entered_at' => current_time( 'mysql', true ),
);
update_option( $key, $data, false );
}
/**
* Read the entry timestamp for a module's current state.
*
* @param string $module Module identifier.
* @return string|null UTC mysql datetime, or null when unrecorded.
*/
public static function get_entry_time( string $module ): ?string {
$data = get_option( 'wpdo_fsm_state_entered', array() );
return is_array( $data ) && isset( $data[ $module ]['entered_at'] ) ? (string) $data[ $module ]['entered_at'] : null;
}
}
@@ -0,0 +1,431 @@
<?php
/**
* TMDO_Snapshot_Manager — Facade for WPDO backup/restore (v2.2.0 M1).
*
* Catalog row lives in `wp_wpdo_snapshots`. Payload lives either inline
* (≤5MB) or as a gzipped SQL dump under wp-content/uploads/wpdo-backups/.
* Triggered by FSM transitions, V2 upgrades, scheduled prune drills, or
* explicit admin/CLI calls.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Backup catalog facade. All public methods return arrays describing the
* outcome (never throw on expected failure paths — log + return error key).
*/
class TMDO_Snapshot_Manager {
/** Table slug (without prefix). */
public const TABLE_SLUG = 'wpdo_snapshots';
/** Subdirectory under wp-content/uploads/. */
public const BACKUP_DIR_NAME = 'wpdo-backups';
/** Inline-vs-file storage threshold (5 MB). */
public const INLINE_THRESHOLD_BYTES = 5_000_000;
/** Default retention window. */
public const DEFAULT_RETENTION_DAYS = 30;
/** Default size cap for the whole backup directory (1 GB). */
public const DEFAULT_SIZE_CAP_BYTES = 1_073_741_824;
/** Allowed trigger types (must match wpdo_snapshots.trigger_type values). */
public const VALID_TRIGGERS = array(
'manual',
'pre_fsm_transition',
'pre_v2_upgrade',
'scheduled',
'pre_uninstall',
);
/**
* Create a snapshot.
*
* @param string $trigger One of VALID_TRIGGERS.
* @param array $scope {entities:[],modules:[],tables:[]} — leave empty to back up all WPDO + meta tables.
* @param array $opts {notes:string,retention_days:int,gzip:bool,inline_threshold_bytes:int}.
* @return array {ok:bool, snapshot_id?:string, error?:string, size_bytes?:int, row_count?:int}
*/
public static function create( string $trigger, array $scope = array(), array $opts = array() ): array {
global $wpdb;
if ( ! in_array( $trigger, self::VALID_TRIGGERS, true ) ) {
return array(
'ok' => false,
'error' => 'invalid_trigger',
);
}
if ( ! self::ensure_backup_dir() ) {
return array(
'ok' => false,
'error' => 'backup_dir_unwritable',
);
}
$snapshot_id = self::generate_id();
$writer = new TMDO_Snapshot_Writer( $snapshot_id, self::resolve_scope( $scope ), $opts );
try {
$result = $writer->write();
} catch ( Throwable $e ) {
TMDO_Logger::error(
'snapshots',
'create',
$e->getMessage(),
array(
'snapshot_id' => $snapshot_id,
'trigger' => $trigger,
),
''
);
return array(
'ok' => false,
'error' => 'writer_failed',
'message' => $e->getMessage(),
);
}
$retention_days = (int) ( $opts['retention_days'] ?? self::DEFAULT_RETENTION_DAYS );
$expires_at = $retention_days > 0
? gmdate( 'Y-m-d H:i:s', time() + $retention_days * DAY_IN_SECONDS )
: null;
$row = array(
'snapshot_id' => $snapshot_id,
'trigger_type' => $trigger,
'scope' => wp_json_encode( $writer->get_scope() ),
'size_bytes' => (int) $result['size_bytes'],
'row_count' => (int) $result['row_count'],
'storage' => (string) $result['storage'],
'file_path' => $result['file_path'] ?? null,
'file_sha256' => $result['sha256'] ?? null,
'inline_blob' => $result['inline_blob'] ?? null,
'fsm_states' => wp_json_encode( self::capture_fsm_states() ),
'notes' => isset( $opts['notes'] ) ? (string) $opts['notes'] : null,
'created_at' => current_time( 'mysql', true ),
'expires_at' => $expires_at,
);
$inserted = $wpdb->insert( $wpdb->prefix . self::TABLE_SLUG, $row ); // phpcs:ignore WordPress.DB
if ( false === $inserted ) {
// Insert failed — clean up the written file to avoid orphan.
if ( ! empty( $result['file_path'] ) && file_exists( $result['file_path'] ) ) {
@unlink( $result['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
return array(
'ok' => false,
'error' => 'db_insert_failed',
'message' => $wpdb->last_error,
);
}
TMDO_Logger::info(
'snapshot_created',
array(
'snapshot_id' => $snapshot_id,
'trigger' => $trigger,
'size_bytes' => $result['size_bytes'],
'row_count' => $result['row_count'],
'storage' => $result['storage'],
)
);
do_action( 'wpdo_after_snapshot_create', $snapshot_id, $trigger, $row );
return array(
'ok' => true,
'snapshot_id' => $snapshot_id,
'size_bytes' => (int) $result['size_bytes'],
'row_count' => (int) $result['row_count'],
'storage' => (string) $result['storage'],
);
}
/**
* Restore a snapshot. Default dry-run; pass false to actually replay.
*
* @param string $snapshot_id Snapshot ULID.
* @param bool $dry_run When true, return preview without applying.
* @return array {ok:bool, preview?:array, restored?:array, error?:string}
*/
public static function restore( string $snapshot_id, bool $dry_run = true ): array {
$row = self::get( $snapshot_id );
if ( null === $row ) {
return array(
'ok' => false,
'error' => 'not_found',
);
}
try {
$reader = new TMDO_Snapshot_Reader( $row );
} catch ( Throwable $e ) {
return array(
'ok' => false,
'error' => 'reader_init_failed',
'message' => $e->getMessage(),
);
}
try {
if ( $dry_run ) {
$preview = $reader->preview();
return array(
'ok' => true,
'preview' => $preview,
);
}
$applied = $reader->apply();
TMDO_Logger::warning(
'snapshot_restored',
array(
'snapshot_id' => $snapshot_id,
'tables' => $applied['tables'] ?? array(),
'rows' => $applied['rows_restored'] ?? 0,
)
);
do_action( 'wpdo_after_snapshot_restore', $snapshot_id, $applied );
return array(
'ok' => true,
'restored' => $applied,
);
} catch ( Throwable $e ) {
TMDO_Logger::error( 'snapshots', 'restore', $e->getMessage(), array( 'snapshot_id' => $snapshot_id ), '' );
return array(
'ok' => false,
'error' => 'restore_failed',
'message' => $e->getMessage(),
);
}
}
/**
* Verify integrity (sha256, file existence/readability).
*
* @param string $snapshot_id Snapshot ULID.
* @return array {ok:bool, sha256_ok?:bool, size_match?:bool, error?:string}
*/
public static function verify( string $snapshot_id ): array {
$row = self::get( $snapshot_id );
if ( null === $row ) {
return array(
'ok' => false,
'error' => 'not_found',
);
}
try {
$reader = new TMDO_Snapshot_Reader( $row );
return $reader->verify();
} catch ( Throwable $e ) {
return array(
'ok' => false,
'error' => 'verify_failed',
'message' => $e->getMessage(),
);
}
}
/**
* List recent snapshots (catalog rows only — no payload).
*
* @param int $limit Max rows.
* @param string|null $trigger_filter Only this trigger_type if set.
* @return array<int,array<string,mixed>>
*/
public static function list_recent( int $limit = 50, ?string $trigger_filter = null ): array {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_SLUG;
$limit = max( 1, min( 1000, $limit ) );
if ( null !== $trigger_filter ) {
$rows = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` WHERE trigger_type = %s ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
$trigger_filter,
$limit
),
ARRAY_A
);
} else {
$rows = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, trigger_type, size_bytes, row_count, storage, file_path, notes, created_at, expires_at FROM `{$table}` ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
$limit
),
ARRAY_A
);
}
return is_array( $rows ) ? $rows : array();
}
/**
* Prune expired or oldest-first snapshots up to a size cap.
*
* @param int $older_than_days Default retention window.
* @param int $size_cap_bytes When backup dir exceeds this, evict oldest.
* @return array {pruned:int, freed_bytes:int, errors:array}
*/
public static function prune( int $older_than_days = self::DEFAULT_RETENTION_DAYS, int $size_cap_bytes = self::DEFAULT_SIZE_CAP_BYTES ): array {
return TMDO_Snapshot_Pruner::prune( $older_than_days, $size_cap_bytes );
}
/**
* Read a single catalog row by snapshot_id (includes inline_blob).
*
* @param string $snapshot_id Snapshot ULID.
* @return array<string,mixed>|null
*/
public static function get( string $snapshot_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . self::TABLE_SLUG;
$row = $wpdb->get_row(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT * FROM `{$table}` WHERE snapshot_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
$snapshot_id
),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Delete one snapshot (catalog row + file). Idempotent.
*
* @param string $snapshot_id Snapshot ULID.
* @return bool true on row+file deletion (or row absent), false on DB error.
*/
public static function delete( string $snapshot_id ): bool {
global $wpdb;
$row = self::get( $snapshot_id );
if ( null === $row ) {
return true;
}
if ( ! empty( $row['file_path'] ) && file_exists( $row['file_path'] ) ) {
@unlink( $row['file_path'] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
$table = $wpdb->prefix . self::TABLE_SLUG;
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
return false !== $deleted;
}
/**
* Resolve absolute path to wp-content/uploads/wpdo-backups/.
*
* Multisite (v2.14.0): `wp_upload_dir()` automatically returns the
* current site's uploads basedir — i.e. `/wp-content/uploads/sites/N/`
* for sub-sites and `/wp-content/uploads/` for the main site. So the
* backup directory is naturally per-site isolated; no extra handling
* needed. When a site is deleted via Network → Sites → Delete, the
* entire `sites/N/` tree is removed by WP, taking the snapshots with it.
*
* @return string
*/
public static function backup_dir(): string {
$uploads = wp_upload_dir();
$base = $uploads['basedir'] ?? WP_CONTENT_DIR . '/uploads';
return trailingslashit( $base ) . self::BACKUP_DIR_NAME;
}
/**
* Ensure wp-content/uploads/wpdo-backups/ exists with access-denial guards.
*
* Apache: .htaccess "Deny from all" + modern "Require all denied".
* Nginx: nginx does not read .htaccess; add a location block to site config:
* location ~* /wpdo-backups/ { deny all; }
* The README-NGINX.txt placed here documents this for server admins.
*
* @return bool
*/
public static function ensure_backup_dir(): bool {
$dir = self::backup_dir();
if ( ! file_exists( $dir ) ) {
if ( ! wp_mkdir_p( $dir ) ) {
return false;
}
}
$base = trailingslashit( $dir );
// Apache — "Deny from all" (Apache 2.2) + "Require all denied" (Apache 2.4).
$htaccess = $base . '.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents(
$htaccess,
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
);
}
// Empty index.php — prevents directory listing on Apache without Options -Indexes.
$index = $base . 'index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, "<?php\n// Silence is golden.\n" );
}
// Nginx guidance file — for server admins running nginx (which ignores .htaccess).
$nginx_note = $base . 'README-NGINX.txt';
if ( ! file_exists( $nginx_note ) ) {
file_put_contents(
$nginx_note,
"IMPORTANT: This directory contains database snapshots.\n" .
"Nginx does not read .htaccess. Add the following to your site config:\n\n" .
" location ~* /wpdo-backups/ {\n deny all;\n }\n\n" .
"Without this rule the snapshot files are publicly accessible.\n"
);
}
return is_writable( $dir );
}
// ─── helpers ──────────────────────────────────────────────────────────
/**
* Generate ULID-like snapshot ID (sortable + unique). Falls back to
* uniqid when random_bytes is unavailable.
*
* @return string
*/
private static function generate_id(): string {
$ts = base_convert( (string) ( time() * 1000 ), 10, 36 );
try {
$rand = bin2hex( random_bytes( 8 ) );
} catch ( Throwable $e ) {
$rand = substr( md5( uniqid( '', true ) ), 0, 16 );
}
return 'wpdo_' . $ts . '_' . $rand;
}
/**
* Resolve scope to canonical form. Empty/malformed scope = "everything".
*
* @param array $scope User-provided scope.
* @return array {entities:array,modules:array,tables:array}
*/
private static function resolve_scope( array $scope ): array {
return array(
'entities' => isset( $scope['entities'] ) && is_array( $scope['entities'] ) ? array_values( array_map( 'strval', $scope['entities'] ) ) : array(),
'modules' => isset( $scope['modules'] ) && is_array( $scope['modules'] ) ? array_values( array_map( 'strval', $scope['modules'] ) ) : array(),
'tables' => isset( $scope['tables'] ) && is_array( $scope['tables'] ) ? array_values( array_map( 'strval', $scope['tables'] ) ) : array(),
);
}
/**
* Capture per-module FSM states at snapshot creation time (for context
* during restore — admin can decide whether to also rewind FSM).
*
* @return array<string,string>
*/
private static function capture_fsm_states(): array {
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return array();
}
$out = array();
$modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
foreach ( $modules as $m ) {
$out[ $m ] = TMDO_Feature_Flags::get( $m );
}
return $out;
}
}
@@ -0,0 +1,198 @@
<?php
/**
* TMDO_Snapshot_Pruner — Daily prune of expired/over-cap snapshots
* (v2.2.0 M1).
*
* Two pruning rules, applied in order:
* 1. Drop catalog rows with `expires_at < NOW()`.
* 2. If backup directory size still exceeds `$size_cap_bytes`, evict the
* oldest non-pre_uninstall / non-pre_v2_upgrade rows until under cap.
*
* pre_uninstall and pre_v2_upgrade snapshots are protected from size-cap
* eviction (they are emergency restore points; only TTL prune touches them).
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Prune helper. Stateless static API.
*/
class TMDO_Snapshot_Pruner {
/** Triggers exempt from size-cap eviction. */
private const PROTECTED_TRIGGERS = array(
'pre_uninstall',
'pre_v2_upgrade',
);
/**
* Run prune.
*
* @param int $older_than_days Only used for the TTL phase logging summary;
* actual TTL is enforced via wp_wpdo_snapshots.expires_at.
* @param int $size_cap_bytes Backup-dir size cap. 0 disables.
* @return array {pruned:int, freed_bytes:int, errors:array, ttl_pruned:int, sizecap_pruned:int}
*/
public static function prune( int $older_than_days, int $size_cap_bytes ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$ttl_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < UTC_TIMESTAMP()", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
ARRAY_A
);
$result = self::evict_rows( is_array( $ttl_rows ) ? $ttl_rows : array(), 'ttl' );
if ( $size_cap_bytes > 0 ) {
$current_size = self::current_dir_size();
if ( $current_size > $size_cap_bytes ) {
$over = $current_size - $size_cap_bytes;
$candidates = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT id, snapshot_id, file_path, size_bytes FROM `{$table}` WHERE trigger_type NOT IN ('" . implode( "','", array_map( 'esc_sql', self::PROTECTED_TRIGGERS ) ) . "') ORDER BY created_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.QuotedDynamicPlaceholderGeneration -- dynamic IN clause with trusted table name and string-literal enum values
100
),
ARRAY_A
);
$cap_result = self::evict_to_recover( is_array( $candidates ) ? $candidates : array(), $over );
$result['sizecap_pruned'] = $cap_result['pruned'];
$result['freed_bytes'] += $cap_result['freed_bytes'];
$result['pruned'] += $cap_result['pruned'];
$result['errors'] = array_merge( $result['errors'], $cap_result['errors'] );
} else {
$result['sizecap_pruned'] = 0;
}
} else {
$result['sizecap_pruned'] = 0;
}
TMDO_Logger::info(
'snapshot_prune',
array(
'older_than_days' => $older_than_days,
'size_cap_bytes' => $size_cap_bytes,
'pruned' => $result['pruned'],
'freed_bytes' => $result['freed_bytes'],
'ttl_pruned' => $result['ttl_pruned'] ?? 0,
'sizecap_pruned' => $result['sizecap_pruned'],
)
);
return $result;
}
/**
* Cron handler — called from class-tmdo-core.php via the wpdo_daily_health_check
* subroutine (v2.3.0) or its own scheduled hook.
*
* @return void
*/
public static function cron_run(): void {
self::prune( TMDO_Snapshot_Manager::DEFAULT_RETENTION_DAYS, TMDO_Snapshot_Manager::DEFAULT_SIZE_CAP_BYTES );
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Delete rows + their files. Returns prune accounting.
*
* @param array $rows Rows to evict.
* @param string $phase Tag for logs.
* @return array {pruned:int,freed_bytes:int,ttl_pruned?:int,errors:array}
*/
private static function evict_rows( array $rows, string $phase ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$pruned = 0;
$freed = 0;
$errors = array();
foreach ( $rows as $row ) {
$path = (string) ( $row['file_path'] ?? '' );
if ( '' !== $path && file_exists( $path ) ) {
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $ok ) {
$errors[] = "unlink failed: {$path}";
}
}
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
if ( false === $deleted ) {
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
continue;
}
++$pruned;
$freed += (int) $row['size_bytes'];
}
$out = array(
'pruned' => $pruned,
'freed_bytes' => $freed,
'errors' => $errors,
);
if ( 'ttl' === $phase ) {
$out['ttl_pruned'] = $pruned;
}
return $out;
}
/**
* Evict from candidates until we've freed `$target_bytes` (or run out).
*
* @param array $candidates Sorted oldest-first.
* @param int $target_bytes Bytes to free.
* @return array {pruned:int,freed_bytes:int,errors:array}
*/
private static function evict_to_recover( array $candidates, int $target_bytes ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$pruned = 0;
$freed = 0;
$errors = array();
foreach ( $candidates as $row ) {
if ( $freed >= $target_bytes ) {
break;
}
$path = (string) ( $row['file_path'] ?? '' );
if ( '' !== $path && file_exists( $path ) ) {
$ok = @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $ok ) {
$errors[] = "unlink failed: {$path}";
}
}
$deleted = $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); // phpcs:ignore WordPress.DB
if ( false === $deleted ) {
$errors[] = 'db delete failed: ' . $row['snapshot_id'];
continue;
}
++$pruned;
$freed += (int) $row['size_bytes'];
}
return array(
'pruned' => $pruned,
'freed_bytes' => $freed,
'errors' => $errors,
);
}
/**
* Sum all backup directory file sizes.
*
* @return int Bytes.
*/
private static function current_dir_size(): int {
$dir = TMDO_Snapshot_Manager::backup_dir();
if ( ! is_dir( $dir ) ) {
return 0;
}
$total = 0;
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );
foreach ( $it as $file ) {
if ( $file->isFile() ) {
$total += $file->getSize();
}
}
return $total;
}
}
@@ -0,0 +1,266 @@
<?php
/**
* TMDO_Snapshot_Reader — preview, verify, and apply a stored snapshot
* (v2.2.0 M1).
*
* Loading order:
* 1. Read catalog row from wp_wpdo_snapshots (passed in by manager).
* 2. If `inline_blob`, decompress in-memory.
* 3. Else read file from disk + verify sha256 + ungzip.
* 4. Parse SQL into tables and INSERT statements.
* 5. preview() returns parse summary; apply() executes statements.
*
* Restore policy:
* - DELETE FROM <table> WHERE 1=1 before re-inserting (clean slate per table).
* Caller is responsible for FSM rewind / cache flush around this call.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Snapshot reader: integrity check + apply.
*/
class TMDO_Snapshot_Reader {
/**
* Catalog row from wp_wpdo_snapshots.
*
* @var array<string,mixed>
*/
private array $row;
/**
* Constructor.
*
* @param array $row Catalog row (must contain snapshot_id, storage, file_path, file_sha256, inline_blob, size_bytes).
* @throws InvalidArgumentException When row is missing required keys.
*/
public function __construct( array $row ) {
foreach ( array( 'snapshot_id', 'storage', 'size_bytes' ) as $required ) {
if ( ! array_key_exists( $required, $row ) ) {
throw new InvalidArgumentException( "snapshot row missing key: {$required}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
}
$this->row = $row;
}
/**
* Verify sha256 + readability.
*
* @return array {ok:bool, sha256_ok:bool, size_match:bool, error?:string}
*/
public function verify(): array {
$storage = (string) $this->row['storage'];
$expected_size = (int) $this->row['size_bytes'];
$expected_sha = (string) ( $this->row['file_sha256'] ?? '' );
if ( 'inline' === $storage ) {
$blob = (string) ( $this->row['inline_blob'] ?? '' );
$actual_size = strlen( $blob );
// inline blobs aren't sha-checked by default — just check size.
return array(
'ok' => $actual_size === $expected_size,
'sha256_ok' => true,
'size_match' => $actual_size === $expected_size,
'storage' => 'inline',
);
}
$path = (string) ( $this->row['file_path'] ?? '' );
if ( '' === $path || ! file_exists( $path ) ) {
return array(
'ok' => false,
'error' => 'file_missing',
'storage' => 'file',
);
}
if ( ! is_readable( $path ) ) {
return array(
'ok' => false,
'error' => 'file_unreadable',
'storage' => 'file',
);
}
$actual_size = (int) filesize( $path );
$actual_sha = hash_file( 'sha256', $path );
return array(
'ok' => ( $actual_size === $expected_size ) && ( $actual_sha === $expected_sha ),
'sha256_ok' => ( $actual_sha === $expected_sha ),
'size_match' => ( $actual_size === $expected_size ),
'storage' => 'file',
);
}
/**
* Parse SQL stream and return a preview of tables + row counts (no apply).
*
* @return array {tables:array<string,int>, total_rows:int, statements:int, sql_bytes:int}
* @throws RuntimeException When payload cannot be loaded.
*/
public function preview(): array {
$sql = $this->load_sql();
return $this->parse_summary( $sql );
}
/**
* Load + apply (DELETE + INSERT per table).
*
* For file-storage snapshots, sha256 is verified before execution.
* Only tables whose names pass is_safe_name() (prefix-prefixed, alphanum+_)
* are touched — arbitrary table names in the SQL file cannot be applied.
*
* @return array {tables:array<int,string>, rows_restored:int, statements_run:int}
* @throws RuntimeException When payload cannot be loaded or integrity check fails.
*/
public function apply(): array {
global $wpdb;
// Verify sha256 / size for file-storage snapshots before executing any SQL.
if ( 'inline' !== (string) ( $this->row['storage'] ?? 'file' ) ) {
$check = $this->verify();
if ( ! $check['ok'] ) {
$reason = $check['error'] ?? ( $check['sha256_ok'] ? 'size_mismatch' : 'sha256_mismatch' );
throw new RuntimeException( 'snapshot integrity check failed: ' . $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
}
$sql = $this->load_sql();
$summary = $this->parse_summary( $sql );
// Wipe target tables before inserting (clean slate).
foreach ( array_keys( $summary['tables'] ) as $table ) {
if ( $this->is_safe_name( $table ) ) {
$wpdb->query( "DELETE FROM `{$table}`" ); // phpcs:ignore WordPress.DB
}
}
$statements_run = 0;
$rows_restored = 0;
// Naive split on `;\n` — INSERT statements never contain unescaped semicolon-newline.
$lines = preg_split( '/;\s*\n/', $sql );
if ( ! is_array( $lines ) ) {
throw new RuntimeException( 'sql parse failed' );
}
foreach ( $lines as $stmt ) {
$stmt = trim( $stmt );
if ( '' === $stmt || str_starts_with( $stmt, '--' ) ) {
continue;
}
if ( ! preg_match( '/^(INSERT|SET)\b/i', $stmt ) ) {
continue;
}
$result = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB
if ( false === $result ) {
throw new RuntimeException( 'restore stmt failed: ' . substr( $stmt, 0, 80 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
if ( stripos( $stmt, 'INSERT' ) === 0 ) {
$rows_restored += (int) $result;
}
++$statements_run;
}
return array(
'tables' => array_keys( $summary['tables'] ),
'rows_restored' => $rows_restored,
'statements_run' => $statements_run,
);
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Load + ungzip the snapshot SQL payload.
*
* @return string Raw SQL.
* @throws RuntimeException When payload is missing or unreadable.
*/
private function load_sql(): string {
$storage = (string) $this->row['storage'];
if ( 'inline' === $storage ) {
$blob = (string) ( $this->row['inline_blob'] ?? '' );
if ( '' === $blob ) {
throw new RuntimeException( 'inline_blob is empty' );
}
return $this->maybe_gunzip( $blob );
}
$path = (string) ( $this->row['file_path'] ?? '' );
if ( '' === $path || ! file_exists( $path ) ) {
throw new RuntimeException( 'snapshot file missing: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
if ( str_ends_with( $path, '.gz' ) ) {
$content = '';
$handle = gzopen( $path, 'rb' );
if ( false === $handle ) {
throw new RuntimeException( 'gzopen failed: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
while ( ! gzeof( $handle ) ) {
$content .= gzread( $handle, 65536 );
}
gzclose( $handle );
return $content;
}
return (string) file_get_contents( $path );
}
/**
* Decompress if first 2 bytes are gzip magic.
*
* @param string $blob Possibly-gzipped data.
* @return string Decompressed.
*/
private function maybe_gunzip( string $blob ): string {
if ( strlen( $blob ) >= 2 && "\x1f\x8b" === substr( $blob, 0, 2 ) ) {
$out = @gzdecode( $blob ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return false === $out ? $blob : $out;
}
return $blob;
}
/**
* Walk SQL and count INSERT rows per table.
*
* @param string $sql Raw SQL.
* @return array {tables:array<string,int>, total_rows:int, statements:int, sql_bytes:int}
*/
private function parse_summary( string $sql ): array {
$tables = array();
$total = 0;
$statements = 0;
preg_match_all( '/INSERT INTO `([^`]+)` \([^)]*\) VALUES (.+?);\s*\n/s', $sql, $matches, PREG_SET_ORDER );
foreach ( $matches as $m ) {
$table = $m[1];
$body = $m[2];
// Count `(` at the start of value tuples — substring_count of `,(` + 1.
$rows = substr_count( $body, "),\n (" ) + 1;
$tables[ $table ] = ( $tables[ $table ] ?? 0 ) + $rows;
$total += $rows;
++$statements;
}
return array(
'tables' => $tables,
'total_rows' => $total,
'statements' => $statements,
'sql_bytes' => strlen( $sql ),
);
}
/**
* Match safe table names: alphanumeric + underscore, must start with $wpdb->prefix.
*
* Mirrors TMDO_Snapshot_Writer::is_safe_name() so writer and reader apply
* the same gate — an attacker-crafted SQL file cannot target tables outside
* this site's WordPress prefix.
*
* @param string $name Table name.
* @return bool
*/
private function is_safe_name( string $name ): bool {
global $wpdb;
return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
&& strpos( $name, $wpdb->prefix ) === 0;
}
}
@@ -0,0 +1,331 @@
<?php
/**
* TMDO_Snapshot_Writer — produces a SQL dump of WPDO tables (v2.2.0 M1).
*
* Strategy:
* 1. Resolve which tables to dump from the scope (default = all WPDO-owned tables).
* 2. Iterate each table in 5000-row chunks; emit `INSERT INTO ... VALUES (...)`.
* 3. Stream output to a temp file with gzip; compute sha256 and final size.
* 4. If final size ≤ INLINE_THRESHOLD_BYTES, slurp into memory + delete file
* (so small snapshots travel with `wp db export`).
* 5. Otherwise leave the file under wp-content/uploads/wpdo-backups/.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Streamed dump writer with chunking + gzip + sha256.
*/
class TMDO_Snapshot_Writer {
/** Rows per SELECT chunk. */
private const CHUNK_SIZE = 5000;
/**
* Snapshot ULID assigned by manager.
*
* @var string
*/
private string $snapshot_id;
/**
* Resolved dump scope.
*
* @var array{entities:array,modules:array,tables:array}
*/
private array $scope;
/**
* Writer options (gzip, inline_threshold_bytes, max_tables).
*
* @var array<string,mixed>
*/
private array $opts;
/**
* Constructor.
*
* @param string $snapshot_id Snapshot ULID.
* @param array $scope Resolved scope.
* @param array $opts {gzip:bool, inline_threshold_bytes:int, max_tables:int}.
*/
public function __construct( string $snapshot_id, array $scope, array $opts = array() ) {
$this->snapshot_id = $snapshot_id;
$this->scope = $scope;
$this->opts = $opts;
}
/**
* Run the dump.
*
* @return array {storage,file_path,sha256,size_bytes,row_count,inline_blob}
* @throws RuntimeException When no tables resolved or filesystem unwritable.
*/
public function write(): array {
$tables = $this->resolve_tables();
if ( empty( $tables ) ) {
throw new RuntimeException( 'snapshot scope produced 0 tables' );
}
$gzip = (bool) ( $this->opts['gzip'] ?? true );
$inline_th = (int) ( $this->opts['inline_threshold_bytes'] ?? TMDO_Snapshot_Manager::INLINE_THRESHOLD_BYTES );
$dir = TMDO_Snapshot_Manager::backup_dir();
$ext = $gzip ? '.sql.gz' : '.sql';
$path = trailingslashit( $dir ) . $this->snapshot_id . $ext;
$handle = $gzip ? gzopen( $path, 'wb6' ) : fopen( $path, 'wb' );
if ( false === $handle ) {
throw new RuntimeException( 'cannot open ' . $path . ' for write' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
$write = static function ( string $chunk ) use ( $handle, $gzip ): void {
if ( $gzip ) {
gzwrite( $handle, $chunk );
} else {
fwrite( $handle, $chunk );
}
};
$row_count = 0;
$header = $this->dump_header( $tables );
$write( $header );
global $wpdb;
foreach ( $tables as $table ) {
$row_count += $this->dump_table( $table, $write );
}
$write( "\n-- end of dump\n" );
if ( $gzip ) {
gzclose( $handle );
} else {
fclose( $handle );
}
$size = (int) filesize( $path );
$sha = hash_file( 'sha256', $path );
// Decide inline vs file storage.
if ( $size <= $inline_th ) {
$blob = file_get_contents( $path );
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return array(
'storage' => 'inline',
'file_path' => null,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => $blob,
);
}
return array(
'storage' => 'file',
'file_path' => $path,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => null,
);
}
/**
* Returns canonical scope (after manager resolution).
*
* @return array
*/
public function get_scope(): array {
return $this->scope;
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Resolve which tables to dump.
*
* Priority:
* 1. Explicit `scope.tables` if provided.
* 2. `scope.entities` → wp_postmeta / wp_usermeta / etc.
* 3. Empty scope → all WPDO-owned tables (zone + system) + wp_postmeta if classifier active.
*
* @return array<int,string> Fully-prefixed table names.
*/
private function resolve_tables(): array {
global $wpdb;
if ( ! empty( $this->scope['tables'] ) ) {
return array_values( array_filter( array_map( 'strval', $this->scope['tables'] ), array( $this, 'is_safe_name' ) ) );
}
$max = (int) ( $this->opts['max_tables'] ?? 200 );
$out = array();
// Always include WPDO system tables.
$wpdo_likes = array(
$wpdb->prefix . 'wpdo_%',
);
foreach ( $wpdo_likes as $like ) {
if ( class_exists( 'WP_SQLite_Driver' ) ) {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE %s",
$like
)
);
} else {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
$like
)
);
}
if ( is_array( $rows ) ) {
foreach ( $rows as $t ) {
if ( $this->is_safe_name( (string) $t ) ) {
$out[ $t ] = true;
}
}
}
}
// Skip wp_wpdo_snapshots itself — we don't want a dump of a dump.
unset( $out[ $wpdb->prefix . 'wpdo_snapshots' ] );
// If `entities` includes 'post', also dump wp_postmeta (mainly used for
// pre_v2_upgrade trigger so we have the pre-migration meta).
if ( ! empty( $this->scope['entities'] ) ) {
$entity_to_table = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta ?? ( $wpdb->prefix . 'termmeta' ),
'comment' => $wpdb->commentmeta,
);
foreach ( $this->scope['entities'] as $entity ) {
if ( isset( $entity_to_table[ $entity ] ) ) {
$t = $entity_to_table[ $entity ];
if ( $this->is_safe_name( $t ) ) {
$out[ $t ] = true;
}
}
}
}
$tables = array_keys( $out );
if ( count( $tables ) > $max ) {
$tables = array_slice( $tables, 0, $max );
}
sort( $tables );
return $tables;
}
/**
* Validate a table name (alphanumeric + underscore only, must start with $wpdb->prefix).
*
* @param string $name Table name.
* @return bool
*/
private function is_safe_name( string $name ): bool {
global $wpdb;
return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
&& strpos( $name, $wpdb->prefix ) === 0;
}
/**
* Dump header (timestamp + table list).
*
* @param array $tables Tables that will be dumped.
* @return string
*/
private function dump_header( array $tables ): string {
$out = "-- WPDO snapshot {$this->snapshot_id}\n";
$out .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
$out .= '-- Tables: ' . count( $tables ) . "\n";
$out .= "-- Format: SQL INSERT statements (one per row, batched by table)\n\n";
$out .= "SET NAMES utf8mb4;\n";
$out .= "SET FOREIGN_KEY_CHECKS=0;\n\n";
return $out;
}
/**
* Stream-dump one table.
*
* @param string $table Fully-prefixed table name.
* @param callable $write Stream writer.
* @return int rows dumped.
*/
private function dump_table( string $table, callable $write ): int {
global $wpdb;
$write( "-- Table {$table}\n" );
$count_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
if ( 0 === $count_total ) {
$write( "-- (empty)\n\n" );
return 0;
}
$columns = $wpdb->get_col( "DESC `{$table}`" ); // phpcs:ignore WordPress.DB
if ( ! is_array( $columns ) || empty( $columns ) ) {
return 0;
}
$col_list = '`' . implode( '`,`', $columns ) . '`';
$offset = 0;
$rows_dumped = 0;
while ( $offset < $count_total ) {
$chunk = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT * FROM `{$table}` LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
self::CHUNK_SIZE,
$offset
),
ARRAY_A
);
if ( ! is_array( $chunk ) || empty( $chunk ) ) {
break;
}
$values = array();
foreach ( $chunk as $row ) {
$row_vals = array();
foreach ( $columns as $col ) {
$row_vals[] = $this->escape_sql_value( $row[ $col ] ?? null );
}
$values[] = '(' . implode( ',', $row_vals ) . ')';
}
$write( "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n ", $values ) . ";\n" );
$rows_dumped += count( $chunk );
$offset += self::CHUNK_SIZE;
}
$write( "\n" );
return $rows_dumped;
}
/**
* Escape a single value for SQL INSERT.
*
* @param mixed $v Value.
* @return string SQL-quoted literal.
*/
private function escape_sql_value( $v ): string {
global $wpdb;
if ( null === $v ) {
return 'NULL';
}
if ( is_bool( $v ) ) {
return $v ? '1' : '0';
}
if ( is_int( $v ) || is_float( $v ) ) {
return (string) $v;
}
// String / longtext / blob — wrap with hex notation if non-utf8 bytes.
$s = (string) $v;
// Use mb_check_encoding when available; fall back to a heuristic.
$is_utf8 = function_exists( 'mb_check_encoding' ) ? mb_check_encoding( $s, 'UTF-8' ) : ( @iconv( 'UTF-8', 'UTF-8//IGNORE', $s ) === $s ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $is_utf8 ) {
return '0x' . bin2hex( $s );
}
return "'" . esc_sql( $s ) . "'";
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
/**
* WPDO Anti-EAV Aware Trait
*
* Sugar layer for partner plugins (e.g. tmos-* family) that need to consume
* WPDO's anti-EAV registration & I/O surface from a single place.
*
* Use it on the plugin main class, then implement `plugin_slug()` and override
* any of the three `on_register_*()` hooks. Call `bind_anti_eav_hooks()` once
* during boot (typically `plugins_loaded:10+`) to attach all three.
*
* Internally this just delegates to the existing public WPDO surface
* (`TMDO_Schema_Registry`, `TMDO_Custom_Table_Registry`, `TMDO_Entity_Registry`,
* `TMDO_API`). It exists to (a) give partner plugins one trait to `use`,
* (b) document the anti-EAV contract in code, (c) absorb future API drift.
*
* @package WP_Data_Optimizer
* @since 2.17.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Re-declaration guard. The mu-plugin `wpdo-policy-enforcer.php` previously
// shipped a minimal stub of this trait; v2.17.0 promotes the canonical
// definition to wp-data-optimizer. The mu-plugin now mirrors this file
// verbatim. Either load order (mu-plugin first, or WPDO first) ends with
// the same trait body in memory.
if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
trait TMDO_Anti_EAV_Aware {
/**
* Plugin slug used as provider identifier when registering fields/tables.
*
* Partner plugins SHOULD override this with their canonical slug
* (e.g. 'tmos-core', 'tmos-events'). Default returns lowercased class name
* for backward compatibility with older partner plugins that didn't define
* a slug.
*/
protected function plugin_slug(): string {
return strtolower( static::class );
}
/**
* Attach the three `wpdo_register_*` hooks to the corresponding
* `on_register_*` methods. Call once during plugin boot.
*
* Idempotent — safe to call multiple times because WP de-dupes
* identical (action, callback) pairs.
*/
protected function bind_anti_eav_hooks(): void {
add_action( 'wpdo_register_fields', array( $this, 'on_register_fields' ), 10, 1 );
add_action( 'wpdo_register_custom_tables', array( $this, 'on_register_custom_tables' ), 10, 1 );
add_action( 'wpdo_register_entity_fields', array( $this, 'on_register_entity_fields' ), 10, 0 );
}
// ── Hook handlers (override in subclass) ─────────────────────────────────
/**
* Register hot/warm/cold/archive zone field mappings.
*
* Override in subclass and call `$this->register_field( $registry, [...] )`
* for each post-meta key the plugin manages.
*
* @param TMDO_Schema_Registry $registry The shared field registry.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
// Default no-op. Override in subclass.
}
/**
* Register custom tables owned by this plugin.
*
* Override in subclass and call `$this->register_custom_table( $r, [...] )`
* for each table.
*
* @param TMDO_Custom_Table_Registry $r The shared custom-table registry.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
// Default no-op. Override in subclass.
}
/**
* Register entity field groups (user / term / comment / post metadata).
*
* Override in subclass and call `$this->register_entity_fields( $type, $group, [...] )`
* for each logical field group the plugin contributes.
*/
public function on_register_entity_fields(): void {
// Default no-op. Override in subclass.
}
// ── Sugar wrappers around WPDO public API ────────────────────────────────
/**
* Register a single zone field via TMDO_Schema_Registry.
*
* @param TMDO_Schema_Registry $registry Provided by the `wpdo_register_fields` hook.
* @param array<string,mixed> $field_def Field definition (post_type, meta_key, zone, ...).
*/
protected function register_field( TMDO_Schema_Registry $registry, array $field_def ): void {
$registry->register( $this->plugin_slug(), $field_def );
}
/**
* Register a custom table via TMDO_Custom_Table_Registry.
*
* @param TMDO_Custom_Table_Registry $r Provided by the `wpdo_register_custom_tables` hook.
* @param array<string,mixed> $table_def Table definition (table_name, primary_key, expected_columns, indexes, ...).
*/
protected function register_custom_table( TMDO_Custom_Table_Registry $r, array $table_def ): void {
$r->register( $this->plugin_slug(), $table_def );
}
/**
* Register an entity field group via TMDO_Entity_Registry.
*
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
* @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
* @param array<int,array<string,mixed>> $fields Field definitions.
*/
protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return false;
}
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields );
}
// ── Read/write convenience wrappers ──────────────────────────────────────
/**
* Read an entity field via the TMDO_API facade.
*
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
* @param int $entity_id Entity primary id.
* @param string $key Meta key.
* @param bool $single Return single value (default true).
*/
protected function get_field( string $entity_type, int $entity_id, string $key, bool $single = true ): mixed {
return TMDO_API::get_entity( $entity_type, $entity_id, $key, $single );
}
/**
* Write an entity field via the TMDO_API facade.
*
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
* @param int $entity_id Entity primary id.
* @param string $key Meta key.
* @param mixed $value Meta value.
*
* @return bool|int False on failure, otherwise the meta id (add) or true (update).
*/
protected function set_field( string $entity_type, int $entity_id, string $key, mixed $value ): bool|int {
return TMDO_API::set_entity( $entity_type, $entity_id, $key, $value );
}
/**
* Subscribe to write events on a specific meta key.
*
* Convenience wrapper around `add_action('wpdo_after_write', ...)` that
* filters callbacks to the plugin's own keys via prefix match.
*
* @param string $key_prefix Meta-key prefix to react on (e.g. 'tmos_vendor_').
* @param callable $callback `function(string $entity_type, int $entity_id, string $key, mixed $value, bool $ok, string $op, mixed $before)`.
* @param int $priority WordPress action priority (default 10).
*/
protected function on_after_write( string $key_prefix, callable $callback, int $priority = 10 ): void {
add_action(
'wpdo_after_write',
static function ( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before ) use ( $key_prefix, $callback ): void {
if ( is_string( $meta_key ) && str_starts_with( $meta_key, $key_prefix ) ) {
$callback( $entity_type, $entity_id, $meta_key, $meta_value, $ok, $op, $before );
}
},
$priority,
7
);
}
// ── Backward-compat (legacy 2meet-* mu-plugin trait surface) ─────────────
// The pre-v2.17.0 mu-plugin trait stub exposed two abstract static methods
// (`register_wpdo_fields`, `register_custom_tables`). Older partner plugins
// (e.g. 2meet-inquiries) implemented those static methods. The promoted
// trait keeps the names with non-abstract no-op defaults so existing
// classes continue to work without modification.
/**
* Legacy entry point for plugins that registered fields via a static method
* before v2.17.0. New partner plugins should override `on_register_fields()`
* (instance method) instead.
*
* @deprecated 2.17.0 Use instance method `on_register_fields()` + `bind_anti_eav_hooks()` instead.
*/
public static function register_wpdo_fields(): void {
// No-op default. Legacy classes overrode this and called the registry directly.
}
/**
* Legacy entry point for plugins that registered custom tables via a static
* method before v2.17.0.
*
* @deprecated 2.17.0 Use instance method `on_register_custom_tables()` + `bind_anti_eav_hooks()` instead.
*/
public static function register_custom_tables(): void {
// No-op default.
}
}
} // end if ( ! trait_exists )
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* Zone D (Archive) handler for historical data archival.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone D (Archive) handler — historical data archival with optional compression.
*
* Single table: wpdo_archive
* Stores old/infrequently accessed postmeta entries with optional gzip compression.
*
* Archive flow:
* 1. Identify cold/stale postmeta entries (by age, post_status, or manual selection)
* 2. Copy to wpdo_archive with original_meta_id reference
* 3. Optionally compress meta_value with gzencode()
* 4. Delete from wp_postmeta (only during cleanup phase)
*
* Retrieval: decompress on read, restore to postmeta if needed.
*/
class TMDO_Zone_Archive {
/**
* Get the archive table name.
*/
public static function table(): string {
return TMDO_DB::table( 'wpdo_archive' );
}
/**
* Archive a single postmeta entry.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param string $meta_value Meta value.
* @param int $meta_id Original meta_id from wp_postmeta.
* @param bool $compress Whether to gzip-compress the value.
*/
public static function archive( int $post_id, string $post_type, string $meta_key, string $meta_value, int $meta_id = 0, bool $compress = false ): void {
global $wpdb;
$table = self::table();
$compressed = 0;
if ( $compress && function_exists( 'gzencode' ) ) {
$meta_value = base64_encode( gzencode( $meta_value, 6 ) );
$compressed = 1;
}
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'post_type' => $post_type,
'meta_key' => $meta_key,
'meta_value' => $meta_value,
'compressed' => $compressed,
'archived_at' => TMDO_DB::now(),
'original_meta_id' => $meta_id,
),
array( '%d', '%s', '%s', '%s', '%d', '%s', '%d' )
);
}
/**
* Archive multiple postmeta entries in batch.
*
* @param array $entries Array of [post_id, post_type, meta_key, meta_value, meta_id].
* @param bool $compress Whether to compress values.
* @return void
* @throws \Throwable When a batch insert fails and the transaction is rolled back.
*/
public static function archive_batch( array $entries, bool $compress = false ): void {
TMDO_DB::begin();
try {
foreach ( $entries as $entry ) {
self::archive(
(int) $entry['post_id'],
$entry['post_type'],
$entry['meta_key'],
$entry['meta_value'],
(int) ( $entry['meta_id'] ?? 0 ),
$compress
);
}
TMDO_DB::commit();
} catch ( \Throwable $e ) {
TMDO_DB::rollback();
throw $e;
}
}
/**
* Retrieve archived values for a post.
*
* @param int $post_id Post ID.
* @param string|null $meta_key Optional specific meta_key filter.
* @return array Array of [meta_key, meta_value, archived_at, compressed].
*/
public static function get( int $post_id, ?string $meta_key = null ): array {
global $wpdb;
$table = self::table();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Archive::table() via TMDO_DB::table().
if ( $meta_key ) {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d AND meta_key = %s ORDER BY archived_at DESC",
$post_id,
$meta_key
),
ARRAY_A
);
} else {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d ORDER BY archived_at DESC",
$post_id
),
ARRAY_A
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// Decompress where needed.
// Note: must iterate $rows directly (not $rows ?: []) to allow &$row to modify the original array.
foreach ( $rows as &$row ) {
if ( (int) $row['compressed'] && function_exists( 'gzdecode' ) ) {
$decoded = base64_decode( $row['meta_value'] );
if ( false !== $decoded ) {
$decompressed = gzdecode( $decoded );
if ( false !== $decompressed ) {
$row['meta_value'] = $decompressed;
}
}
}
unset( $row['compressed'] );
}
return $rows ?: array();
}
/**
* Restore archived entries back to wp_postmeta.
*
* @param int $post_id Post ID.
* @param string|null $meta_key Optional meta_key filter (null = restore all).
* @return int Number of entries restored.
*/
public static function restore( int $post_id, ?string $meta_key = null ): int {
$entries = self::get( $post_id, $meta_key );
$count = 0;
foreach ( $entries as $entry ) {
update_post_meta( $post_id, $entry['meta_key'], $entry['meta_value'] );
++$count;
}
// Delete restored entries from archive.
if ( $count > 0 ) {
global $wpdb;
$table = self::table();
if ( $meta_key ) {
$wpdb->delete(
$table,
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
),
array( '%d', '%s' )
);
} else {
$wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) );
}
}
return $count;
}
/**
* Delete all archived entries for a post.
*
* @param int $post_id Post ID.
* @return void
*/
public static function delete( int $post_id ): void {
global $wpdb;
$wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
}
/**
* Sweep: archive stale postmeta entries by age.
*
* Finds postmeta for trashed/deleted posts older than $days and archives them.
*
* @param int $days Minimum age in days.
* @param bool $compress Whether to compress.
* @param int $limit Maximum rows per sweep.
* @return int Number of entries archived.
*/
public static function sweep( int $days = 90, bool $compress = true, int $limit = 500 ): int {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
LIMIT %d",
$cutoff,
$limit
),
ARRAY_A
);
if ( ! $rows ) {
return 0;
}
$entries = array();
foreach ( $rows as $row ) {
$entries[] = array(
'post_id' => $row['post_id'],
'post_type' => $row['post_type'],
'meta_key' => $row['meta_key'],
'meta_value' => $row['meta_value'],
'meta_id' => $row['meta_id'],
);
}
self::archive_batch( $entries, $compress );
return count( $entries );
}
/**
* Get archive statistics.
*
* @return array{total_rows: int, compressed_rows: int, post_types: array}
*/
public static function stats(): array {
global $wpdb;
$table = self::table();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$compressed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE compressed = 1" );
$types = $wpdb->get_results(
"SELECT post_type, COUNT(*) as cnt FROM `{$table}` GROUP BY post_type ORDER BY cnt DESC",
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return array(
'total_rows' => $total,
'compressed_rows' => $compressed,
'post_types' => $types ?: array(),
);
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
/**
* Zone C (Cold) handler for JSON blob storage with Object Cache integration.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone C (Cold) handler — JSON blob storage with Object Cache integration.
*
* Each post type gets its own table: wpdo_cold_{post_type}
* All cold meta_keys for a post are stored as a single JSON blob in the `data` column.
*
* Read path: Object Cache → cold table → wp_postmeta fallback
* Write path: cold table + Object Cache invalidation
*
* Best for profile/display data that is read often but queried rarely
* (e.g. hp_description, social links, extended profile fields).
*/
class TMDO_Zone_Cold {
/**
* Get the cold table name for a post type.
*
* @param string $post_type Post type slug.
* @return string Full table name.
*/
public static function table( string $post_type ): string {
return TMDO_DB::table( 'wpdo_cold_' . sanitize_key( $post_type ) );
}
/**
* Cache group for a post type.
*
* @param string $post_type Post type slug.
* @return string Cache group name.
*/
private static function cache_group( string $post_type ): string {
return 'wpdo_cold_' . sanitize_key( $post_type );
}
/**
* Ensure the cold table exists for a post type.
*
* @param string $post_type Post type slug.
* @return void
*/
public static function ensure_table( string $post_type ): void {
global $wpdb;
$table = self::table( $post_type );
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
);
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
);
}
if ( ! $exists ) {
TMDO_Installer::create_cold_table( $post_type );
}
}
/**
* Get a single cold meta value for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return mixed|null
*/
public static function get( int $post_id, string $post_type, string $meta_key ): mixed {
$data = self::get_blob( $post_id, $post_type );
return $data[ $meta_key ] ?? null;
}
/**
* Get the full JSON blob for a post, with Object Cache layer.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @return array Decoded JSON data (meta_key => value).
*/
public static function get_blob( int $post_id, string $post_type ): array {
$group = self::cache_group( $post_type );
$cache_key = "cold_{$post_id}";
$cached = wp_cache_get( $cache_key, $group );
if ( false !== $cached ) {
return is_array( $cached ) ? $cached : array();
}
global $wpdb;
$table = self::table( $post_type );
$json = $wpdb->get_var(
$wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
$data = $json ? json_decode( $json, true ) : array();
if ( ! is_array( $data ) ) {
$data = array();
}
// Get cache TTL from registry — 粒度化策略:取所有 cold fields 的最小 TTL,
// 確保任何短 TTL 欄位(如 IG token:v2.0.4 起預期 < 5 min)能在保護期內失效,
// 而非被某個長 TTL 欄位拖拽到 1h 才更新。原本的「取第一個」策略會視註冊順序
// 而異,無法保證安全。
// 同時提供 `wpdo_cold_cache_ttl` filter 讓 partner integration 進一步覆蓋.
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type );
$ttls = array();
foreach ( $fields as $field ) {
if ( ! empty( $field['cache_ttl'] ) ) {
$ttls[] = (int) $field['cache_ttl'];
}
}
$ttl = empty( $ttls ) ? HOUR_IN_SECONDS : min( $ttls );
/**
* Filter the effective cache TTL for a cold zone post type.
*
* @since 2.0.5
* @param int $ttl Computed TTL (min of all registered fields, or HOUR_IN_SECONDS default).
* @param string $post_type Post type being cached.
* @param int $post_id Post ID (entity being read).
* @param array $fields Fields registry config for this post_type.
*/
$ttl = (int) apply_filters( 'wpdo_cold_cache_ttl', $ttl, $post_type, $post_id, $fields );
wp_cache_set( $cache_key, $data, $group, $ttl );
return $data;
}
/**
* Set a single cold meta value for a post.
* Merges into the existing JSON blob.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param mixed $value Value to store.
*/
public static function set( int $post_id, string $post_type, string $meta_key, mixed $value ): void {
$data = self::get_blob_raw( $post_id, $post_type );
$data[ $meta_key ] = $value;
self::save_blob( $post_id, $post_type, $data );
}
/**
* Set multiple cold meta values at once.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param array $values meta_key => value pairs.
*/
public static function set_many( int $post_id, string $post_type, array $values ): void {
$data = self::get_blob_raw( $post_id, $post_type );
$data = array_merge( $data, $values );
self::save_blob( $post_id, $post_type, $data );
}
/**
* Remove a key from the cold blob.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @param string $meta_key Meta key to remove.
* @return void
*/
public static function remove( int $post_id, string $post_type, string $meta_key ): void {
$data = self::get_blob_raw( $post_id, $post_type );
unset( $data[ $meta_key ] );
self::save_blob( $post_id, $post_type, $data );
}
/**
* Delete the entire cold row for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return void
*/
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get raw blob from DB (no cache).
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return array Decoded JSON data array.
*/
private static function get_blob_raw( int $post_id, string $post_type ): array {
global $wpdb;
$table = self::table( $post_type );
$json = $wpdb->get_var(
$wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
$data = $json ? json_decode( $json, true ) : array();
return is_array( $data ) ? $data : array();
}
/**
* Save the JSON blob and invalidate cache.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @param array $data Key-value pairs to store as JSON.
* @return void
*/
private static function save_blob( int $post_id, string $post_type, array $data ): void {
global $wpdb;
$table = self::table( $post_type );
$now = TMDO_DB::now();
$json = wp_json_encode( $data );
$existing = $wpdb->get_var(
$wpdb->prepare( "SELECT id FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
if ( $existing ) {
$wpdb->update(
$table,
array(
'data' => $json,
'updated_at' => $now,
),
array( 'post_id' => $post_id ),
array( '%s', '%s' ),
array( '%d' )
);
} else {
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'data' => $json,
'updated_at' => $now,
),
array( '%d', '%s', '%s' )
);
}
// Invalidate object cache.
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* Zone A (Hot) handler for flat-column custom tables.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone A (Hot) handler — flat-column custom tables for search/filter fields.
*
* Each post type gets its own table: wpdo_hot_{post_type}
* Columns are defined by the Schema Registry and created via TMDO_Installer.
*
* Key advantage over HPCT's KV table (hpct_listing_meta):
* KV table: N meta_query conditions = N LEFT JOINs
* Flat table: N conditions = 1 LEFT JOIN + N WHERE clauses
*/
class TMDO_Zone_Hot {
/**
* Get the table name for a post type.
*
* @param string $post_type Post type slug.
* @return string Full table name.
*/
public static function table( string $post_type ): string {
return TMDO_DB::table( 'wpdo_hot_' . sanitize_key( $post_type ) );
}
/**
* Ensure the hot table exists for a post type.
* Creates it dynamically from Schema Registry if missing.
*
* @param string $post_type Post type slug.
* @return void
*/
public static function ensure_table( string $post_type ): void {
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
if ( empty( $columns ) ) {
return;
}
global $wpdb;
$table = self::table( $post_type );
// Quick existence check.
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
);
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
);
}
if ( ! $exists ) {
TMDO_Installer::create_hot_table( $post_type, $columns );
return;
}
// v2.1.2 critical fix: ensure existing tables have all columns declared
// by Schema_Registry. Partner plugins registering new hot fields after
// the initial migration would otherwise silently fall back to postmeta.
//
// v2.1.3 optimization: schema fingerprint stored in option. When the hash
// of declared columns matches the stored hash, skip SHOW COLUMNS entirely.
// Only diff + ALTER fires when fingerprint actually changed (drift detected).
// This avoids per-request SHOW COLUMNS overhead on stable production sites.
static $checked = array();
if ( isset( $checked[ $post_type ] ) ) {
return;
}
$fingerprint = self::compute_fingerprint( $columns );
$opt_key = 'wpdo_hot_fp_' . $post_type;
$stored_fp = (string) get_option( $opt_key, '' );
if ( $stored_fp === $fingerprint ) {
// Schema unchanged since last successful ensure → no need to SHOW COLUMNS.
$checked[ $post_type ] = true;
return;
}
// Fingerprint mismatch (or first run) → run diff + ALTER, then store new hash.
TMDO_Installer::ensure_hot_columns( $post_type, $columns );
update_option( $opt_key, $fingerprint, false );
$checked[ $post_type ] = true;
}
/**
* Stable hash of declared column definitions. Used to short-circuit SHOW COLUMNS
* when Schema_Registry hasn't changed since the last successful ensure.
*
* @param array $columns column_name => sql_type map.
* @return string Short SHA-1 prefix (10 chars — collision-safe at our scale).
*
* @since 2.1.3
*/
private static function compute_fingerprint( array $columns ): string {
ksort( $columns );
return substr( sha1( wp_json_encode( $columns ) ?: '' ), 0, 10 );
}
/**
* Read a single field value from the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $column Column name in hot table.
* @return mixed|null Value or null if not found.
*/
public static function get( int $post_id, string $post_type, string $column ): mixed {
global $wpdb;
$table = self::table( $post_type );
$column = sanitize_key( $column );
return $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$column}` FROM `{$table}` WHERE post_id = %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column from sanitize_key(); table from TMDO_DB::table().
$post_id
)
);
}
/**
* Read all hot fields for a post as an associative array.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @return array|null Column => value pairs, or null.
*/
public static function get_row( int $post_id, string $post_type ): ?array {
global $wpdb;
$table = self::table( $post_type );
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Hot::table() via TMDO_DB::table().
ARRAY_A
);
return $row ?: null;
}
/**
* Upsert a single field into the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $column Column name.
* @param mixed $value Value to set.
*/
public static function set( int $post_id, string $post_type, string $column, mixed $value ): void {
$table = self::table( $post_type );
$column = sanitize_key( $column );
$now = TMDO_DB::now();
TMDO_DB::upsert(
$table,
array(
'post_id' => $post_id,
$column => $value,
'updated_at' => $now,
),
array( $column, 'updated_at' ),
'post_id'
);
}
/**
* Upsert multiple fields at once for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param array $data Column => value pairs.
*/
public static function set_many( int $post_id, string $post_type, array $data ): void {
$table = self::table( $post_type );
$now = TMDO_DB::now();
$data['post_id'] = $post_id;
$data['updated_at'] = $now;
$update_cols = array_diff( array_keys( $data ), array( 'post_id' ) );
TMDO_DB::upsert( $table, $data, $update_cols, 'post_id' );
}
/**
* Delete a post's row from the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return void
*/
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
}
/**
* Get all post types that have hot zone tables registered.
*
* @return string[]
*/
public static function get_post_types(): array {
return TMDO_Schema_Registry::instance()->get_hot_post_types();
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
/**
* Zone B (Warm) handler for KV table with optional TTL.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B (Warm) handler — KV table with optional TTL auto-cleanup.
*
* Single table: wpdo_warm
* Structure: post_id + meta_key + meta_value + expires_at
*
* Use for transient-like data that benefits from DB-backed persistence
* but doesn't need to live forever (e.g. cached computations, temporary flags).
*
* Expired entries are cleaned up by the wpdo_warm_cleanup cron (hourly).
*/
class TMDO_Zone_Warm {
/**
* Get the warm table name.
*/
public static function table(): string {
return TMDO_DB::table( 'wpdo_warm' );
}
/**
* Read a value from the warm zone.
*
* Returns null if not found or if expired.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @return string|null
*/
public static function get( int $post_id, string $meta_key ): ?string {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}`
WHERE post_id = %d AND meta_key = %s
AND (expires_at IS NULL OR expires_at > %s)
LIMIT 1",
$post_id,
$meta_key,
$now
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $val;
}
/**
* Read all warm values for a post.
*
* @param int $post_id Post ID.
* @return array<string, string> meta_key => meta_value pairs.
*/
public static function get_all( int $post_id ): array {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value FROM `{$table}`
WHERE post_id = %d AND (expires_at IS NULL OR expires_at > %s)",
$post_id,
$now
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = array();
foreach ( $rows ?: array() as $row ) {
$result[ $row['meta_key'] ] = $row['meta_value'];
}
return $result;
}
/**
* Set a value in the warm zone with optional TTL.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $value Value to store.
* @param int|null $ttl TTL in seconds. Null = no expiry.
*/
public static function set( int $post_id, string $meta_key, string $value, ?int $ttl = null ): void {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
$expires_at = null;
if ( $ttl && $ttl > 0 ) {
$expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE post_id = %d AND meta_key = %s LIMIT 1",
$post_id,
$meta_key
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( $existing ) {
$update_data = array( 'meta_value' => $value );
$update_format = array( '%s' );
if ( null !== $expires_at ) {
$update_data['expires_at'] = $expires_at;
$update_format[] = '%s';
}
$wpdb->update( $table, $update_data, array( 'id' => (int) $existing ), $update_format, array( '%d' ) );
} else {
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
'meta_value' => $value,
'expires_at' => $expires_at,
'created_at' => $now,
),
array( '%d', '%s', '%s', $expires_at ? '%s' : null, '%s' )
);
}
}
/**
* Delete a specific key from the warm zone.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key to delete.
* @return void
*/
public static function delete( int $post_id, string $meta_key ): void {
global $wpdb;
$wpdb->delete(
self::table(),
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
),
array( '%d', '%s' )
);
}
/**
* Delete all warm entries for a post.
*
* @param int $post_id Post ID.
* @return void
*/
public static function delete_all( int $post_id ): void {
global $wpdb;
$wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
}
/**
* Purge all expired entries (called by cron).
*
* @return int Number of rows deleted.
*/
public static function purge_expired(): int {
global $wpdb;
$table = self::table();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
return (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < %s",
TMDO_DB::now()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}