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:
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Comment - Comment 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Comment implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->commentmeta;
|
||||
}
|
||||
|
||||
public function get_entity_id_column(): string {
|
||||
return 'comment_id';
|
||||
}
|
||||
|
||||
public function get_primary_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->comments;
|
||||
}
|
||||
|
||||
public function get_primary_id_column(): string {
|
||||
return 'comment_ID';
|
||||
}
|
||||
|
||||
public function get_cache_group(): string {
|
||||
return 'wpdo_comment_profile';
|
||||
}
|
||||
|
||||
public function get_delete_hook(): string {
|
||||
return 'delete_comment';
|
||||
}
|
||||
|
||||
public function extend_native_query( $query_object ): void {
|
||||
if ( ! ( $query_object instanceof WP_Comment_Query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wpdo_query = $query_object->query_vars['wpdo_meta_query'] ?? null;
|
||||
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// WP_Comment_Query 使用 comments_clauses filter
|
||||
add_filter(
|
||||
'comments_clauses',
|
||||
function ( $clauses ) use ( $wpdo_query ) {
|
||||
$compiled = TMDO_Query_Compiler::compile( 'comment', $wpdo_query );
|
||||
|
||||
if ( ! empty( $compiled['joins'] ) ) {
|
||||
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $compiled['where'] ) ) {
|
||||
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
|
||||
}
|
||||
|
||||
return $clauses;
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'intval',
|
||||
$wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT comment_ID FROM `{$wpdb->comments}` WHERE comment_ID > %d ORDER BY comment_ID ASC LIMIT %d",
|
||||
$after_id,
|
||||
$limit
|
||||
)
|
||||
) ?: array()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊 pre_get_comments hook(unit test 下 add_action 未載入時跳過)
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action(
|
||||
'pre_get_comments',
|
||||
function ( $query ) {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( 'comment' );
|
||||
if ( $adapter ) {
|
||||
$adapter->extend_native_query( $query );
|
||||
}
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Post - Post 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Post implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'post';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->postmeta;
|
||||
}
|
||||
|
||||
public function get_entity_id_column(): string {
|
||||
return 'post_id';
|
||||
}
|
||||
|
||||
public function get_primary_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->posts;
|
||||
}
|
||||
|
||||
public function get_primary_id_column(): string {
|
||||
return 'ID';
|
||||
}
|
||||
|
||||
public function get_cache_group(): string {
|
||||
return 'wpdo_post_profile';
|
||||
}
|
||||
|
||||
public function get_delete_hook(): string {
|
||||
return 'before_delete_post';
|
||||
}
|
||||
|
||||
/**
|
||||
* 擴充 WP_Query 支援 wpdo_meta_query / wpdo_orderby
|
||||
*/
|
||||
public function extend_native_query( $query_object ): void {
|
||||
if ( ! ( $query_object instanceof WP_Query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wpdo_query = $query_object->get( 'wpdo_meta_query' );
|
||||
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
TMDO_Query_Compiler::inject_into_wp_query( $query_object, $wpdo_query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-based 取得 post IDs
|
||||
*/
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'intval',
|
||||
$wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT ID FROM `{$wpdb->posts}` WHERE ID > %d ORDER BY ID ASC LIMIT %d",
|
||||
$after_id,
|
||||
$limit
|
||||
)
|
||||
) ?: array()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊 pre_get_posts 以啟用 wpdo_meta_query(unit test 下 add_action 未載入時跳過)
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action(
|
||||
'pre_get_posts',
|
||||
function ( $query ) {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( 'post' );
|
||||
if ( $adapter ) {
|
||||
$adapter->extend_native_query( $query );
|
||||
}
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_Term - Term 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_Term implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'term';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->termmeta;
|
||||
}
|
||||
|
||||
public function get_entity_id_column(): string {
|
||||
return 'term_id';
|
||||
}
|
||||
|
||||
public function get_primary_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->terms;
|
||||
}
|
||||
|
||||
public function get_primary_id_column(): string {
|
||||
return 'term_id';
|
||||
}
|
||||
|
||||
public function get_cache_group(): string {
|
||||
return 'wpdo_term_profile';
|
||||
}
|
||||
|
||||
public function get_delete_hook(): string {
|
||||
return 'delete_term';
|
||||
}
|
||||
|
||||
public function extend_native_query( $query_object ): void {
|
||||
// Term 查詢使用 terms_clauses filter,不使用 $query_object
|
||||
}
|
||||
|
||||
/**
|
||||
* Term 特有:透過 terms_clauses filter 注入
|
||||
*/
|
||||
public function extend_term_clauses( array $clauses, array $taxonomies, array $args ): array {
|
||||
if ( empty( $args['wpdo_meta_query'] ) || ! is_array( $args['wpdo_meta_query'] ) ) {
|
||||
return $clauses;
|
||||
}
|
||||
return TMDO_Query_Compiler::inject_into_terms_clauses( $clauses, $args['wpdo_meta_query'] );
|
||||
}
|
||||
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'intval',
|
||||
$wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT term_id FROM `{$wpdb->terms}` WHERE term_id > %d ORDER BY term_id ASC LIMIT %d",
|
||||
$after_id,
|
||||
$limit
|
||||
)
|
||||
) ?: array()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊 terms_clauses filter(unit test 下 add_filter 未載入時跳過)
|
||||
if ( function_exists( 'add_filter' ) ) {
|
||||
add_filter(
|
||||
'terms_clauses',
|
||||
function ( $clauses, $taxonomies, $args ) {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( 'term' );
|
||||
if ( $adapter instanceof TMDO_Adapter_Term ) {
|
||||
return $adapter->extend_term_clauses( $clauses, $taxonomies, $args );
|
||||
}
|
||||
return $clauses;
|
||||
},
|
||||
10,
|
||||
3
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Adapter_User - User 實體適配器
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Adapter_User implements TMDO_Entity_Adapter_Interface {
|
||||
|
||||
public function get_entity_type(): string {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
public function get_native_meta_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->usermeta;
|
||||
}
|
||||
|
||||
public function get_entity_id_column(): string {
|
||||
return 'user_id';
|
||||
}
|
||||
|
||||
public function get_primary_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->users;
|
||||
}
|
||||
|
||||
public function get_primary_id_column(): string {
|
||||
return 'ID';
|
||||
}
|
||||
|
||||
public function get_cache_group(): string {
|
||||
return 'wpdo_user_profile';
|
||||
}
|
||||
|
||||
public function get_delete_hook(): string {
|
||||
return 'delete_user';
|
||||
}
|
||||
|
||||
public function extend_native_query( $query_object ): void {
|
||||
if ( ! ( $query_object instanceof WP_User_Query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wpdo_query = $query_object->get( 'wpdo_meta_query' );
|
||||
if ( empty( $wpdo_query ) || ! is_array( $wpdo_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
TMDO_Query_Compiler::inject_into_user_query( $query_object, $wpdo_query );
|
||||
}
|
||||
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array {
|
||||
global $wpdb;
|
||||
|
||||
return array_map(
|
||||
'intval',
|
||||
$wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT ID FROM `{$wpdb->users}` WHERE ID > %d ORDER BY ID ASC LIMIT %d",
|
||||
$after_id,
|
||||
$limit
|
||||
)
|
||||
) ?: array()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊 pre_user_query(unit test 下 add_action 未載入時跳過)
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action(
|
||||
'pre_user_query',
|
||||
function ( $query ) {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( 'user' );
|
||||
if ( $adapter ) {
|
||||
$adapter->extend_native_query( $query );
|
||||
}
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* Entity Adapter 合約介面
|
||||
*
|
||||
* 所有實體適配器(Post/User/Term/Comment)必須實作此介面
|
||||
* 將不同實體的行為標準化,供 Hook Bus 統一呼叫
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
// Back-compat: define WPDO_ marker first so TMDO_ can extend it.
|
||||
// PHP class_alias() doesn't work on interfaces; extending is the only option.
|
||||
// Any class implementing TMDO_Entity_Adapter_Interface automatically satisfies
|
||||
// `instanceof WPDO_Entity_Adapter_Interface` via interface inheritance.
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) ) {
|
||||
interface WPDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
|
||||
interface TMDO_Entity_Adapter_Interface extends WPDO_Entity_Adapter_Interface {
|
||||
|
||||
/**
|
||||
* 實體類型識別子
|
||||
* 必須回傳 WordPress metadata type 之一:post|user|term|comment
|
||||
*/
|
||||
public function get_entity_type(): string;
|
||||
|
||||
/**
|
||||
* 原生 meta 表完整名稱(含 wp_ 前綴)
|
||||
* 例:wp_postmeta / wp_usermeta
|
||||
*/
|
||||
public function get_native_meta_table(): string;
|
||||
|
||||
/**
|
||||
* 原生 meta 表中指向實體的欄位名稱
|
||||
* 例:post_id / user_id / term_id / comment_id
|
||||
*/
|
||||
public function get_entity_id_column(): string;
|
||||
|
||||
/**
|
||||
* 對應的主實體表(用於 JOIN)
|
||||
* 例:wp_posts / wp_users / wp_terms / wp_comments
|
||||
*/
|
||||
public function get_primary_table(): string;
|
||||
|
||||
/**
|
||||
* 主實體表的主鍵欄位
|
||||
* 例:ID (post/user) / term_id / comment_ID
|
||||
*/
|
||||
public function get_primary_id_column(): string;
|
||||
|
||||
/**
|
||||
* 快取群組名稱(供 wp_cache_* 使用)
|
||||
*/
|
||||
public function get_cache_group(): string;
|
||||
|
||||
/**
|
||||
* 實體刪除時會觸發的 WordPress hook
|
||||
* 用於自動清理 UAE 資料表中對應列
|
||||
*/
|
||||
public function get_delete_hook(): string;
|
||||
|
||||
/**
|
||||
* 擴充原生查詢物件以支援 UAE meta_query
|
||||
*
|
||||
* @param mixed $query_object WP_Query / WP_User_Query / WP_Comment_Query 等
|
||||
*/
|
||||
public function extend_native_query( $query_object ): void;
|
||||
|
||||
/**
|
||||
* 取得所有已知的 ID(用於遷移時的迭代)
|
||||
* 採用 cursor-based 分頁
|
||||
*
|
||||
* @param int $after_id 取大於此 ID 的筆
|
||||
* @param int $limit 取多少筆
|
||||
* @return array<int>
|
||||
*/
|
||||
public function get_entity_ids_after( int $after_id, int $limit ): array;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
||||
/**
|
||||
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
||||
*
|
||||
* Reads:
|
||||
* - Current state via TMDO_Feature_Flags::get( $module )
|
||||
* - Time-in-current-state via wp_options.wpdo_fsm_state_entered (set by
|
||||
* TMDO_FSM_Guard::record_entry() since v2.2.0 M2)
|
||||
* - shadow_diffs ratio per module from wp_wpdo_shadow_diffs (last 24h)
|
||||
*
|
||||
* Emits one of:
|
||||
* - PROMOTE — current state has cooked long enough + low divergence; safe to advance
|
||||
* - WAIT — needs more soak time, returns days remaining
|
||||
* - REVIEW — divergence too high; manual investigation needed before promoting
|
||||
* - ROLLBACK — error budget exceeded; suggest rewind to idle
|
||||
* - HOLD — module already idle or complete; nothing to do
|
||||
*
|
||||
* Designed to be advice — never auto-acts. Surfaces in Migration tab as a
|
||||
* panel beside each module row.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM advisor — stateless static API with structured `advise()` return.
|
||||
*/
|
||||
class TMDO_FSM_Advisor {
|
||||
|
||||
/** Minimum soak days per state before advisor allows promotion. */
|
||||
public const MIN_SOAK_DAYS = array(
|
||||
'idle' => 0,
|
||||
'dual_write' => 1,
|
||||
'backfill' => 1,
|
||||
'verify' => 7,
|
||||
'cutover' => 1,
|
||||
'cleanup' => 3,
|
||||
'complete' => 0, // terminal — never promote.
|
||||
);
|
||||
|
||||
/** Max divergence ratio in `verify` state before advisor refuses to promote. */
|
||||
public const VERIFY_MAX_DIVERGENCE_RATIO = 0.001; // 0.1%
|
||||
|
||||
/**
|
||||
* Build advice for a single module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array {action:string, next_state?:string, days_remaining?:int,
|
||||
* reason:string, level:'info'|'warn'|'critical', metrics:array}
|
||||
*/
|
||||
public static function advise( string $module ): array {
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::stub( 'unavailable', 'Feature_Flags 未載入' );
|
||||
}
|
||||
$state = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
// Terminal cases.
|
||||
if ( 'idle' === $state ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( 'Module 處於 idle,無需建議。如要啟用請先讀 SOP。', '2meet-data-optimizer' ),
|
||||
'metrics' => array( 'state' => $state ),
|
||||
);
|
||||
}
|
||||
if ( 'complete' === $state ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( 'Module 已 complete,反 EAV 完成。', '2meet-data-optimizer' ),
|
||||
'metrics' => array( 'state' => $state ),
|
||||
);
|
||||
}
|
||||
|
||||
$days_in_state = self::days_in_state( $module );
|
||||
$min_soak = self::MIN_SOAK_DAYS[ $state ] ?? 1;
|
||||
|
||||
$metrics = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $days_in_state,
|
||||
'min_soak_days' => $min_soak,
|
||||
);
|
||||
|
||||
// Verify state requires divergence ratio check.
|
||||
if ( 'verify' === $state ) {
|
||||
$div = self::shadow_diff_ratio( $module );
|
||||
$metrics['shadow_diff_ratio_24h'] = $div['ratio'];
|
||||
$metrics['shadow_diff_count_24h'] = $div['count'];
|
||||
$metrics['shadow_diff_total_24h'] = $div['total'];
|
||||
|
||||
if ( $div['count'] > 0 && $div['ratio'] > self::VERIFY_MAX_DIVERGENCE_RATIO ) {
|
||||
return array(
|
||||
'action' => 'REVIEW',
|
||||
'next_state' => null,
|
||||
'level' => 'warn',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: ratio, 2: max allowed */
|
||||
__( 'Verify 期間 shadow_diffs 比率 %1$.2f%% 超過上限 %2$.2f%%;建議手動 review wp_wpdo_shadow_diffs 確認分歧來源後再決定。', '2meet-data-optimizer' ),
|
||||
$div['ratio'] * 100,
|
||||
self::VERIFY_MAX_DIVERGENCE_RATIO * 100
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Soak time check.
|
||||
if ( null !== $days_in_state && $days_in_state < $min_soak ) {
|
||||
$days_remaining = $min_soak - $days_in_state;
|
||||
return array(
|
||||
'action' => 'WAIT',
|
||||
'days_remaining' => $days_remaining,
|
||||
'level' => 'info',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: days left, 2: state name, 3: min days */
|
||||
__( '再等 %1$d 天即可推進。當前 %2$s 狀態需 ≥ %3$d 天 soak。', '2meet-data-optimizer' ),
|
||||
$days_remaining,
|
||||
$state,
|
||||
$min_soak
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
|
||||
// Promotion candidate.
|
||||
$next = self::next_state( $state );
|
||||
if ( null === $next ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( '當前狀態為 terminal — 無下一步建議。', '2meet-data-optimizer' ),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'action' => 'PROMOTE',
|
||||
'next_state' => $next,
|
||||
'level' => 'info',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: from, 2: to, 3: days */
|
||||
__( '可推進:%1$s → %2$s(已 soak %3$d 天)。', '2meet-data-optimizer' ),
|
||||
$state,
|
||||
$next,
|
||||
$days_in_state
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build advice for every known module. Returns map module → advice.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function advise_all(): array {
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return array();
|
||||
}
|
||||
$out = array();
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $_state ) {
|
||||
$out[ $module ] = self::advise( $module );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ─── private ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return days since the module entered its current state.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return int|null Days since entering current state, or null if unknown.
|
||||
*/
|
||||
private static function days_in_state( string $module ): ?int {
|
||||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||||
if ( ! isset( $entered[ $module ]['entered_at'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' );
|
||||
if ( false === $ts || $ts <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
return (int) floor( ( time() - $ts ) / DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shadow-diff divergence ratio for a module over the last 24 hours.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array {ratio:float, count:int, total:int}
|
||||
*/
|
||||
private static function shadow_diff_ratio( string $module ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- $module reserved for per-module entity_type routing (future)
|
||||
global $wpdb;
|
||||
$out = array(
|
||||
'ratio' => 0.0,
|
||||
'count' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return $out;
|
||||
}
|
||||
// Module → entity_type mapping is loose — most HPCT modules map to 'post'.
|
||||
// Use entity_type='post' as default proxy; downstream `level=warn` is
|
||||
// honest about uncertainty.
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = %s AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is $wpdb->prefix . 'wpdo_shadow_diffs' (no user input)
|
||||
'post'
|
||||
)
|
||||
);
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no variables; table name is a WP core property
|
||||
"SELECT COUNT(*) FROM `{$wpdb->postmeta}` WHERE meta_id > 0"
|
||||
);
|
||||
$out['count'] = $count;
|
||||
$out['total'] = max( 1, $total );
|
||||
$out['ratio'] = $count / $out['total'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward graph (mirrors TMDO_FSM_Guard::FORWARD_GRAPH but without the
|
||||
* cycle check — advisor only suggests the canonical next step).
|
||||
*
|
||||
* @param string $state Current state.
|
||||
* @return string|null Next state, or null when terminal.
|
||||
*/
|
||||
private static function next_state( string $state ): ?string {
|
||||
$map = array(
|
||||
'idle' => 'dual_write',
|
||||
'dual_write' => 'backfill',
|
||||
'backfill' => 'verify',
|
||||
'verify' => 'cutover',
|
||||
'cutover' => 'cleanup',
|
||||
'cleanup' => 'complete',
|
||||
'complete' => null,
|
||||
);
|
||||
return $map[ $state ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub advice when prerequisites are missing.
|
||||
*
|
||||
* @param string $action Reason code.
|
||||
* @param string $reason Human reason.
|
||||
* @return array
|
||||
*/
|
||||
private static function stub( string $action, string $reason ): array {
|
||||
return array(
|
||||
'action' => strtoupper( $action ),
|
||||
'level' => 'info',
|
||||
'reason' => $reason,
|
||||
'metrics' => array(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_FSM_Automator — Opt-in automatic execution of FSM_Advisor PROMOTE
|
||||
* recommendations (v2.5.0 M13).
|
||||
*
|
||||
* Default OFF. When admin enables, runs daily at 04:30 UTC. For each module:
|
||||
* - Calls TMDO_FSM_Advisor::advise()
|
||||
* - Only auto-promotes when ALL 4 conditions true:
|
||||
* 1. wpdo_automator_enabled = 1
|
||||
* 2. module not in wpdo_automator_blacklist
|
||||
* 3. TMDO_Health_Cron::get_last_run() critical_count = 0 in last 7 days
|
||||
* (cool-off — system is healthy)
|
||||
* 4. ≥ 24h since this module's last automated promotion
|
||||
* - Destructive transitions (cutover→cleanup, cleanup→complete) are
|
||||
* NEVER automated — admin must manually invoke.
|
||||
*
|
||||
* Audit trail: every automator action writes wp_wpdo_audit op='automator_promoted'
|
||||
* + fires action `wpdo/automator_promoted` for downstream subscribers.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automator — stateless static API.
|
||||
*/
|
||||
class TMDO_FSM_Automator {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_automator_enabled';
|
||||
public const OPT_BLACKLIST = 'wpdo_automator_blacklist';
|
||||
public const OPT_LAST_ACTION = 'wpdo_automator_last_action';
|
||||
|
||||
/** Cool-off window after critical health alert. */
|
||||
private const COOL_OFF_DAYS = 7;
|
||||
|
||||
/** Minimum interval between automated promotions of the same module. */
|
||||
private const MIN_INTERVAL_HOURS = 24;
|
||||
|
||||
/** Transitions that are never auto-executed (destructive). */
|
||||
private const FORBIDDEN_TRANSITIONS = array(
|
||||
array( 'verify', 'cutover' ), // cutover starts reading from custom — needs manual sign-off.
|
||||
array( 'cutover', 'cleanup' ), // cleanup purges wp_*meta — irreversible without snapshot.
|
||||
array( 'cleanup', 'complete' ), // complete = no fallback path.
|
||||
);
|
||||
|
||||
/**
|
||||
* Cron handler.
|
||||
*
|
||||
* @return array {ok:bool, executed:int, skipped:int, errors:array}
|
||||
*/
|
||||
public static function run(): array {
|
||||
$result = array(
|
||||
'ok' => true,
|
||||
'executed' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => array(),
|
||||
'actions' => array(),
|
||||
);
|
||||
|
||||
// Pre-flight: enabled?
|
||||
if ( ! self::is_enabled() ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'automator disabled';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Pre-flight: cool-off?
|
||||
if ( ! self::cool_off_clear() ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'cool-off active (critical health in last ' . self::COOL_OFF_DAYS . ' days)';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Iterate modules.
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) || ! class_exists( 'TMDO_FSM_Advisor' ) ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'dependencies missing';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$blacklist = (array) get_option( self::OPT_BLACKLIST, array() );
|
||||
$last_actions = (array) get_option( self::OPT_LAST_ACTION, array() );
|
||||
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $current_state ) {
|
||||
// Blacklisted?
|
||||
if ( in_array( $module, $blacklist, true ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Get advisor recommendation.
|
||||
$advice = TMDO_FSM_Advisor::advise( $module );
|
||||
if ( 'PROMOTE' !== ( $advice['action'] ?? '' ) || 'info' !== ( $advice['level'] ?? '' ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
$next_state = (string) ( $advice['next_state'] ?? '' );
|
||||
if ( '' === $next_state ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Forbidden destructive transition?
|
||||
if ( self::is_forbidden_transition( $current_state, $next_state ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Recent action?
|
||||
if ( ! self::interval_clear( $module, $last_actions ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// All checks passed — execute.
|
||||
$set_result = TMDO_Feature_Flags::set( $module, $next_state );
|
||||
if ( true === $set_result ) {
|
||||
++$result['executed'];
|
||||
$result['actions'][] = array(
|
||||
'module' => $module,
|
||||
'from' => $current_state,
|
||||
'to' => $next_state,
|
||||
);
|
||||
$last_actions[ $module ] = gmdate( 'Y-m-d H:i:s' );
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'automator_promoted',
|
||||
array(
|
||||
'module' => $module,
|
||||
'from' => $current_state,
|
||||
'to' => $next_state,
|
||||
)
|
||||
);
|
||||
}
|
||||
do_action( 'wpdo/automator_promoted', $module, $current_state, $next_state );
|
||||
} else {
|
||||
$result['errors'][] = "{$module}: " . ( $set_result instanceof \WP_Error ? $set_result->get_error_code() : 'unknown' );
|
||||
}
|
||||
}
|
||||
|
||||
update_option( self::OPT_LAST_ACTION, $last_actions, false );
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ─── settings accessors ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return whether the automator is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of blacklisted module slugs.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function blacklist(): array {
|
||||
return (array) get_option( self::OPT_BLACKLIST, array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the map of module → last automated action timestamp.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function last_actions(): array {
|
||||
return (array) get_option( self::OPT_LAST_ACTION, array() );
|
||||
}
|
||||
|
||||
// ─── private helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cool-off: false when there's been a critical health alert in last N days.
|
||||
*
|
||||
* @return bool true = OK to act, false = blocked.
|
||||
*/
|
||||
private static function cool_off_clear(): bool {
|
||||
if ( ! class_exists( 'TMDO_Health_Cron' ) ) {
|
||||
return true; // No data — assume OK.
|
||||
}
|
||||
$last = TMDO_Health_Cron::get_last_run();
|
||||
if ( ! is_array( $last ) ) {
|
||||
return true;
|
||||
}
|
||||
$crit = (int) ( $last['critical_count'] ?? 0 );
|
||||
if ( 0 === $crit ) {
|
||||
return true;
|
||||
}
|
||||
$ts = isset( $last['ran_at'] ) ? strtotime( (string) $last['ran_at'] . ' UTC' ) : 0;
|
||||
if ( $ts <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
// Critical alert exists; cool-off if within window.
|
||||
return ( time() - $ts ) > ( self::COOL_OFF_DAYS * DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-module interval: ≥ 24h since last automated action on this module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param array $last_actions Map of module → timestamp.
|
||||
* @return bool true = OK to act.
|
||||
*/
|
||||
private static function interval_clear( string $module, array $last_actions ): bool {
|
||||
$last = (string) ( $last_actions[ $module ] ?? '' );
|
||||
if ( '' === $last ) {
|
||||
return true;
|
||||
}
|
||||
$ts = strtotime( $last . ' UTC' );
|
||||
if ( $ts <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
return ( time() - $ts ) >= ( self::MIN_INTERVAL_HOURS * HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if (from, to) is in FORBIDDEN_TRANSITIONS.
|
||||
*
|
||||
* @param string $from Current state.
|
||||
* @param string $to Target state.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_forbidden_transition( string $from, string $to ): bool {
|
||||
foreach ( self::FORBIDDEN_TRANSITIONS as $pair ) {
|
||||
if ( $pair[0] === $from && $pair[1] === $to ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
|
||||
*
|
||||
* For each registered module (see TMDO_Module_Rules), evaluates:
|
||||
* 1. Are required plugins active (Compatibility::is_*_active)?
|
||||
* 2. Does the required post_type exist + meet min_post_count?
|
||||
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
|
||||
* 4. Is the module already in a non-idle state (no point recommending)?
|
||||
*
|
||||
* Output per module: {available, confidence, recommendation, reasons,
|
||||
* blockers, current_state, suggested_action}.
|
||||
*
|
||||
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
|
||||
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
|
||||
* reads from there for fast widget rendering.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection engine — stateless static API.
|
||||
*/
|
||||
class TMDO_Module_Detector {
|
||||
|
||||
/** WP options key holding cached results. */
|
||||
public const OPTION_CACHE = 'wpdo_module_suggestions';
|
||||
|
||||
/** Transient key for short-term cache (1 hour). */
|
||||
private const TRANSIENT = 'wpdo_module_detector_results';
|
||||
|
||||
/** TTL for transient cache. */
|
||||
private const TRANSIENT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Detect every registered module. Cached for 1 hour via transient.
|
||||
*
|
||||
* @param bool $force_refresh Bypass transient cache.
|
||||
* @return array<string,array> Module slug → result.
|
||||
*/
|
||||
public static function detect_all( bool $force_refresh = false ): array {
|
||||
if ( ! $force_refresh ) {
|
||||
$cached = get_transient( self::TRANSIENT );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
$out = array();
|
||||
foreach ( TMDO_Module_Rules::known_modules() as $module ) {
|
||||
$out[ $module ] = self::detect_one( $module );
|
||||
}
|
||||
set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL );
|
||||
// Persist to wp_options for the dashboard widget (no autoload).
|
||||
update_option(
|
||||
self::OPTION_CACHE,
|
||||
array(
|
||||
'results' => $out,
|
||||
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
),
|
||||
false
|
||||
);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array See class doc for shape.
|
||||
*/
|
||||
public static function detect_one( string $module ): array {
|
||||
$rule = TMDO_Module_Rules::for_module( $module );
|
||||
if ( null === $rule ) {
|
||||
return self::stub( $module, 'no_rule' );
|
||||
}
|
||||
|
||||
$reasons = array();
|
||||
$blockers = array();
|
||||
$confidence = 0.0;
|
||||
$current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle';
|
||||
|
||||
// 0. Already non-idle? Block recommendation.
|
||||
if ( 'idle' !== $current_state ) {
|
||||
$blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state );
|
||||
return self::build(
|
||||
$module,
|
||||
false,
|
||||
0.0,
|
||||
'skip',
|
||||
$reasons,
|
||||
$blockers,
|
||||
$current_state
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Plugin compatibility check.
|
||||
$compat_required = (array) ( $rule['compat_required'] ?? array() );
|
||||
if ( ! empty( $compat_required ) ) {
|
||||
$missing = self::check_compat( $compat_required );
|
||||
if ( ! empty( $missing ) ) {
|
||||
$blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing );
|
||||
return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required );
|
||||
$confidence += 0.3;
|
||||
}
|
||||
|
||||
// 2. Required post_type + row count.
|
||||
$post_type = (string) ( $rule['post_type_required'] ?? '' );
|
||||
$min_count = (int) ( $rule['min_post_count'] ?? 0 );
|
||||
if ( '' !== $post_type ) {
|
||||
$count = self::post_type_count( $post_type );
|
||||
if ( $count < $min_count ) {
|
||||
$blockers[] = sprintf(
|
||||
'⚠️ %s post_type 只有 %s 行(需要 ≥ %s)',
|
||||
$post_type,
|
||||
number_format_i18n( $count ),
|
||||
number_format_i18n( $min_count )
|
||||
);
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf(
|
||||
'✅ %s post_type 有 %s 行(門檻 %s)',
|
||||
$post_type,
|
||||
number_format_i18n( $count ),
|
||||
number_format_i18n( $min_count )
|
||||
);
|
||||
$confidence += 0.4;
|
||||
}
|
||||
|
||||
// 3. Archive-specific: trashed count.
|
||||
if ( 'archive' === $module ) {
|
||||
$min_trash = (int) ( $rule['min_trash_count'] ?? 0 );
|
||||
$trash_count = self::trashed_post_count();
|
||||
if ( $trash_count < $min_trash ) {
|
||||
$blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d)', $trash_count, $min_trash );
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count );
|
||||
$confidence += 0.3;
|
||||
}
|
||||
|
||||
// 4. Classifier consultation (zone modules).
|
||||
if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) {
|
||||
$min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 );
|
||||
$cls_score = self::classifier_score( $post_type, $module );
|
||||
if ( $cls_score < $min_conf ) {
|
||||
$blockers[] = sprintf(
|
||||
'⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f)',
|
||||
$post_type,
|
||||
self::module_to_zone( $module ),
|
||||
$cls_score,
|
||||
$min_conf
|
||||
);
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f)', $cls_score, $min_conf );
|
||||
$confidence = min( 1.0, $confidence + $cls_score * 0.3 );
|
||||
}
|
||||
|
||||
// 5. Priority bonus: warm/archive recommended_first.
|
||||
// v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like
|
||||
// `warm` (no compat / no post_type gate) crosses the actionable
|
||||
// threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget
|
||||
// can surface it without admin chasing config.
|
||||
if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) {
|
||||
$confidence = min( 1.0, $confidence + 0.5 );
|
||||
$reasons[] = '⭐ 入門首選(低風險)';
|
||||
}
|
||||
|
||||
// Cap confidence.
|
||||
$confidence = min( 1.0, max( 0.0, $confidence ) );
|
||||
|
||||
return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter detect_all() down to actionable enable-recommendations
|
||||
* above a confidence threshold.
|
||||
*
|
||||
* @param float $min_confidence Threshold (0..1, default 0.5).
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function get_actionable( float $min_confidence = 0.5 ): array {
|
||||
$all = self::detect_all();
|
||||
$out = array();
|
||||
foreach ( $all as $module => $r ) {
|
||||
if ( ! empty( $r['available'] )
|
||||
&& 'enable' === ( $r['recommendation'] ?? '' )
|
||||
&& (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) {
|
||||
$out[ $module ] = $r;
|
||||
}
|
||||
}
|
||||
// Sort by confidence desc.
|
||||
uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] );
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached results from wp_options (for dashboard widget — no live query).
|
||||
*
|
||||
* @return array {results:array, generated_at:string}|null
|
||||
*/
|
||||
public static function get_cached(): ?array {
|
||||
$v = get_option( self::OPTION_CACHE );
|
||||
return is_array( $v ) ? $v : null;
|
||||
}
|
||||
|
||||
// ─── private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a result envelope.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param bool $available Whether module passes all checks.
|
||||
* @param float $confidence 0..1.
|
||||
* @param string $recommendation 'enable'|'wait'|'skip'.
|
||||
* @param array $reasons Positive findings.
|
||||
* @param array $blockers Negative findings.
|
||||
* @param string $current_state Current FSM state.
|
||||
* @return array
|
||||
*/
|
||||
private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array {
|
||||
$rule = TMDO_Module_Rules::for_module( $module ) ?? array();
|
||||
return array(
|
||||
'module' => $module,
|
||||
'available' => $available,
|
||||
'confidence' => round( $confidence, 2 ),
|
||||
'recommendation' => $recommendation,
|
||||
'reasons' => $reasons,
|
||||
'blockers' => $blockers,
|
||||
'description' => (string) ( $rule['description'] ?? '' ),
|
||||
'current_state' => $current_state,
|
||||
'suggested_action' => $available && 'enable' === $recommendation
|
||||
? array(
|
||||
'type' => 'set_state',
|
||||
'module' => $module,
|
||||
'to_state' => 'dual_write',
|
||||
'cli' => "wp wpdo mode-set {$module} dual_write",
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub result envelope for modules that cannot be evaluated.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param string $reason Reason code or human message.
|
||||
* @return array
|
||||
*/
|
||||
private static function stub( string $module, string $reason ): array {
|
||||
return array(
|
||||
'module' => $module,
|
||||
'available' => false,
|
||||
'confidence' => 0.0,
|
||||
'recommendation' => 'skip',
|
||||
'reasons' => array(),
|
||||
'blockers' => array( $reason ),
|
||||
'description' => '',
|
||||
'current_state' => 'idle',
|
||||
'suggested_action' => null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which required plugins are missing.
|
||||
*
|
||||
* @param array $required Plugins required.
|
||||
* @return array<int,string> Missing plugin slugs.
|
||||
*/
|
||||
private static function check_compat( array $required ): array {
|
||||
if ( ! class_exists( 'TMDO_Compatibility' ) ) {
|
||||
return $required;
|
||||
}
|
||||
$missing = array();
|
||||
foreach ( $required as $plugin ) {
|
||||
$active = match ( $plugin ) {
|
||||
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
||||
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||
'hpct' => TMDO_Compatibility::is_hpct_active(),
|
||||
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
||||
default => false,
|
||||
};
|
||||
if ( ! $active ) {
|
||||
$missing[] = $plugin;
|
||||
}
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the published post count for a given post type.
|
||||
*
|
||||
* @param string $post_type Post type slug.
|
||||
* @return int Row count.
|
||||
*/
|
||||
private static function post_type_count( string $post_type ): int {
|
||||
global $wpdb;
|
||||
$cached_key = 'wpdo_pt_count_' . md5( $post_type );
|
||||
$cached = get_transient( $cached_key );
|
||||
if ( false !== $cached ) {
|
||||
return (int) $cached;
|
||||
}
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
"SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')",
|
||||
$post_type
|
||||
)
|
||||
);
|
||||
set_transient( $cached_key, $count, HOUR_IN_SECONDS );
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total trashed posts (all post types).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function trashed_post_count(): int {
|
||||
global $wpdb;
|
||||
$cached = get_transient( 'wpdo_trash_count' );
|
||||
if ( false !== $cached ) {
|
||||
return (int) $cached;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB
|
||||
set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS );
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map module → target zone for Classifier consultation.
|
||||
*
|
||||
* @param string $module Module slug (e.g. hot_hp_listing).
|
||||
* @return string Zone (hot|cold|warm|archive).
|
||||
*/
|
||||
private static function module_to_zone( string $module ): string {
|
||||
if ( str_starts_with( $module, 'hot_' ) ) {
|
||||
return 'hot';
|
||||
}
|
||||
if ( str_starts_with( $module, 'cold_' ) ) {
|
||||
return 'cold';
|
||||
}
|
||||
return $module; // 'warm' / 'archive'.
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifier confidence aggregate for a (post_type, target_zone).
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $module Module slug → mapped to zone.
|
||||
* @return float 0..1 average confidence of meta_keys whose suggested_zone matches.
|
||||
*/
|
||||
private static function classifier_score( string $post_type, string $module ): float {
|
||||
if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$zone = self::module_to_zone( $module );
|
||||
try {
|
||||
$results = TMDO_Zone_Classifier::analyze( $post_type, 50 );
|
||||
} catch ( Throwable $e ) {
|
||||
return 0.0;
|
||||
}
|
||||
if ( ! is_array( $results ) || empty( $results ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$total = 0.0;
|
||||
$count = 0;
|
||||
foreach ( $results as $field ) {
|
||||
if ( ( $field['suggested_zone'] ?? '' ) === $zone ) {
|
||||
$total += (float) ( $field['confidence'] ?? 0 );
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
return $count > 0 ? $total / $count : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Rules — Declarative trigger conditions per module (v2.5.0 M16).
|
||||
*
|
||||
* Separates "what makes this module worth enabling" from the detection
|
||||
* machinery (Module_Detector consumes these rules). Each rule declares:
|
||||
* - compat_required:list of plugins that must be active (any of)
|
||||
* - post_type_required:post_type must exist + have rows
|
||||
* - min_post_count:threshold for post_type row count
|
||||
* - min_trash_count:(archive only) trashed post count threshold
|
||||
* - consult_classifier + min_classifier_confidence:(zone modules only)
|
||||
* - description:human reason shown in UI
|
||||
*
|
||||
* Extensible via filter `wpdo/module_rules` — third-party plugins can register
|
||||
* their own modules and rules without forking this file.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static rules registry.
|
||||
*/
|
||||
class TMDO_Module_Rules {
|
||||
|
||||
/**
|
||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
||||
*/
|
||||
private const DEFAULT_RULES = array(
|
||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
||||
'reviews' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_review',
|
||||
'min_post_count' => 100,
|
||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
||||
),
|
||||
'messages' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_message_thread',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
||||
),
|
||||
'favorites' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_favorite',
|
||||
'min_post_count' => 200,
|
||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
'memberships' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_membership',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
||||
),
|
||||
'statistics' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 500,
|
||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
||||
),
|
||||
'requests' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_request',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
||||
),
|
||||
'listing_meta' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
||||
),
|
||||
'wc_orders' => array(
|
||||
'compat_required' => array( 'woocommerce' ),
|
||||
'post_type_required' => 'shop_order',
|
||||
'min_post_count' => 100,
|
||||
'description' => 'WooCommerce 訂單 ≥ 100 筆且未啟用 HPOS 時,建議啟用 module 把 vendor commission 搬到 wp_wpdo_wc_commissions。',
|
||||
),
|
||||
'latepoint' => array(
|
||||
'compat_required' => array( 'latepoint' ),
|
||||
'post_type_required' => null,
|
||||
'description' => 'LatePoint 預約系統啟用時建議開啟,把 booking meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
|
||||
// ─── Zone modules(任何站皆可,但仍依環境推薦)────────────────────
|
||||
'warm' => array(
|
||||
'compat_required' => array(),
|
||||
'post_type_required' => null,
|
||||
'min_post_count' => 0,
|
||||
'priority' => 'recommended_first',
|
||||
'description' => 'Warm zone 處理 view counts / TTL 暫存;任何站都可啟用,幾乎零風險。',
|
||||
),
|
||||
'archive' => array(
|
||||
'compat_required' => array(),
|
||||
'post_type_required' => null,
|
||||
'min_trash_count' => 50,
|
||||
'description' => '已 trashed posts ≥ 50 個時,啟用 archive module 可釋放 wp_postmeta 空間(自動 gzip 壓縮)。',
|
||||
),
|
||||
'hot_hp_listing' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.6,
|
||||
'description' => '使用 HivePress + listing ≥ 100 條 + Classifier confidence ≥ 0.6 時,把高頻欄位搬到 wp_wpdo_hot_hp_listing 可大幅加速 WP_Query。',
|
||||
),
|
||||
'cold_hp_listing' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.5,
|
||||
'description' => '低頻 listing meta(如 settings / preferences)搬到 cold zone,減少 hot path 的 postmeta JOIN。',
|
||||
),
|
||||
'hot_hp_vendor' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_vendor',
|
||||
'min_post_count' => 50,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.6,
|
||||
'description' => 'HivePress 商家 ≥ 50 個時,把高頻 vendor meta 搬到 hot zone 可加速 vendor 列表頁。',
|
||||
),
|
||||
'cold_hp_vendor' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_vendor',
|
||||
'min_post_count' => 50,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.5,
|
||||
'description' => '低頻 vendor meta 搬到 cold zone。',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Return all rules with filter applied.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function all(): array {
|
||||
$rules = self::DEFAULT_RULES;
|
||||
if ( function_exists( 'apply_filters' ) ) {
|
||||
$filtered = apply_filters( 'wpdo/module_rules', $rules );
|
||||
if ( is_array( $filtered ) ) {
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return rule for one module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array|null Rule or null when no rule registered.
|
||||
*/
|
||||
public static function for_module( string $module ): ?array {
|
||||
$rules = self::all();
|
||||
return $rules[ $module ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all module slugs that have rules registered.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function known_modules(): array {
|
||||
return array_keys( self::all() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Backward-compat interface alias.
|
||||
*
|
||||
* PHP class_alias() does NOT support interfaces. This file declares
|
||||
* `WPDO_Entity_Adapter_Interface` as a child of `TMDO_Entity_Adapter_Interface`
|
||||
* (no new methods), which allows sister plugins that type-hint against the old
|
||||
* name to keep working: the four TMDO adapters implement this interface, so
|
||||
* `instanceof WPDO_Entity_Adapter_Interface` returns true for all of them.
|
||||
*
|
||||
* Loaded by `class-tmdo-back-compat.php` after the real interface is loaded.
|
||||
*
|
||||
* @package TMDO
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias interface. Any class implementing
|
||||
* `WPDO_Entity_Adapter_Interface` automatically satisfies
|
||||
* `TMDO_Entity_Adapter_Interface` and vice-versa.
|
||||
*
|
||||
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
|
||||
*/
|
||||
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* Backward-compat trait alias.
|
||||
*
|
||||
* PHP class_alias() does NOT support traits or interfaces. This file declares
|
||||
* a thin wrapper trait `WPDO_Anti_EAV_Aware` that simply `use`s the new
|
||||
* `TMDO_Anti_EAV_Aware` trait, preserving binary compatibility for sister
|
||||
* plugins (2meet-brandcards, hub-core extensions, etc.) that still reference
|
||||
* the old trait name.
|
||||
*
|
||||
* Loaded by `class-tmdo-back-compat.php` after the new trait is loaded.
|
||||
*
|
||||
* @package TMDO
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! trait_exists( 'WPDO_Anti_EAV_Aware', false ) && trait_exists( 'TMDO_Anti_EAV_Aware', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias trait. Sister plugins using `use WPDO_Anti_EAV_Aware;`
|
||||
* inherit all methods from `TMDO_Anti_EAV_Aware` transparently.
|
||||
*
|
||||
* Will emit `_doing_it_wrong()` notice in v0.2.0+.
|
||||
*/
|
||||
trait WPDO_Anti_EAV_Aware {
|
||||
use TMDO_Anti_EAV_Aware;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! interface_exists( 'WPDO_Entity_Adapter_Interface', false ) && interface_exists( 'TMDO_Entity_Adapter_Interface', false ) ) {
|
||||
/**
|
||||
* Backward-compat alias interface. PHP allows interface inheritance, so
|
||||
* extending the new interface gives implementing classes the same
|
||||
* obligations under the old name.
|
||||
*/
|
||||
interface WPDO_Entity_Adapter_Interface extends TMDO_Entity_Adapter_Interface {}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_API — public facade for partner plugins.
|
||||
*
|
||||
* Provides the canonical, stable API contract for reading/writing entity
|
||||
* fields managed by WP Data Optimizer. Partner plugins MUST use this API
|
||||
* instead of direct `get_post_meta()`, `get_user_meta()`, etc., when the
|
||||
* field is registered to WPDO.
|
||||
*
|
||||
* Part A.4 of the Anti-EAV Playbook (plan file).
|
||||
*
|
||||
* Usage:
|
||||
* TMDO_API::get_field( $post_id, 'hp_price' ); // post entity
|
||||
* TMDO_API::get_entity( 'user', $uid, 'points' ); // any entity
|
||||
* TMDO_API::set_field( $post_id, 'hp_price', '100.00' );
|
||||
* TMDO_API::query( [ 'post_type' => 'hp_listing', 'meta_query' => [...] ] );
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public read/write facade. All methods are static and side-effect free
|
||||
* outside of the underlying meta API.
|
||||
*
|
||||
* Backwards compatibility: this class is the long-lived contract for
|
||||
* partner plugins. Internal implementation may change between versions
|
||||
* (e.g. switch from postmeta passthrough to Hook Bus dispatch); the
|
||||
* signatures here are guaranteed stable across the 2.x line.
|
||||
*/
|
||||
final class TMDO_API {
|
||||
|
||||
/**
|
||||
* Get a single meta field for a post.
|
||||
*
|
||||
* Internally:
|
||||
* - When the field is registered to a Zone, reads from the zone table
|
||||
* once cutover.
|
||||
* - When the field is unregistered, falls back to native get_post_meta()
|
||||
* transparently.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param bool $single Whether to return a single scalar.
|
||||
* @return mixed Meta value (or empty string when single=true and absent).
|
||||
*/
|
||||
public static function get_field( int $post_id, string $meta_key, bool $single = true ) {
|
||||
// Defer to standard WordPress metadata API. The Hook Bus / Sync_Bridge
|
||||
// transparently routes the read to the appropriate zone table when
|
||||
// the module is in a read-custom state (cutover/cleanup/complete).
|
||||
// This single line of indirection is the contract that lets us swap
|
||||
// internal storage without breaking callers.
|
||||
return get_post_meta( $post_id, $meta_key, $single );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single meta field for any entity (post/user/term/comment).
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param bool $single Whether to return a single scalar.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_entity( string $entity_type, int $entity_id, string $meta_key, bool $single = true ) {
|
||||
return match ( $entity_type ) {
|
||||
'post' => get_post_meta( $entity_id, $meta_key, $single ),
|
||||
'user' => get_user_meta( $entity_id, $meta_key, $single ),
|
||||
'term' => get_term_meta( $entity_id, $meta_key, $single ),
|
||||
'comment' => get_comment_meta( $entity_id, $meta_key, $single ),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a single meta field for a post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value New value.
|
||||
* @return bool|int Result of update_post_meta.
|
||||
*/
|
||||
public static function set_field( int $post_id, string $meta_key, $meta_value ) {
|
||||
return update_post_meta( $post_id, $meta_key, $meta_value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a meta field for any entity.
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value New value.
|
||||
* @return bool|int
|
||||
*/
|
||||
public static function set_entity( string $entity_type, int $entity_id, string $meta_key, $meta_value ) {
|
||||
return match ( $entity_type ) {
|
||||
'post' => update_post_meta( $entity_id, $meta_key, $meta_value ),
|
||||
'user' => update_user_meta( $entity_id, $meta_key, $meta_value ),
|
||||
'term' => update_term_meta( $entity_id, $meta_key, $meta_value ),
|
||||
'comment' => update_comment_meta( $entity_id, $meta_key, $meta_value ),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a meta_query — currently a thin wrapper around WP_Query.
|
||||
*
|
||||
* Future: when v2.0.0 entity adapters are fully wired, this method will
|
||||
* compile cross-entity queries via TMDO_Query_Compiler.
|
||||
*
|
||||
* @param array $args WP_Query-compatible arguments.
|
||||
* @return WP_Query
|
||||
*/
|
||||
public static function query( array $args ): WP_Query {
|
||||
return new WP_Query( $args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a field is registered to WPDO (zone or entity).
|
||||
*
|
||||
* Useful for partner plugins to choose between TMDO_API and native APIs.
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return bool True when the field is registered.
|
||||
*/
|
||||
public static function is_field_registered( string $entity_type, string $meta_key ): bool {
|
||||
// Zone path (post entity only): TMDO_Schema_Registry.
|
||||
if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) ) {
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
// We can't filter without a post_type — search across all hot/cold/warm.
|
||||
foreach ( $registry->all() as $field ) {
|
||||
if ( ( $field['meta_key'] ?? '' ) === $meta_key ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entity path: TMDO_Entity_Registry (PR-1 ported).
|
||||
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return TMDO_Entity_Registry::is_managed( $entity_type, $meta_key );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace the storage backend currently serving a field.
|
||||
*
|
||||
* Returns one of: 'postmeta' (native fallback), 'zone_hot', 'zone_warm',
|
||||
* 'zone_cold', 'zone_archive', 'entity_table', 'unregistered'.
|
||||
*
|
||||
* Useful for `wp wpdo doctor` and Conflict Detector reports.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param string $post_type Post type, for zone routing (post entity only).
|
||||
* @return string Storage label.
|
||||
*/
|
||||
public static function trace_storage( string $entity_type, string $meta_key, string $post_type = '' ): string {
|
||||
if ( 'post' === $entity_type && class_exists( 'TMDO_Schema_Registry' ) && '' !== $post_type ) {
|
||||
$field = TMDO_Schema_Registry::instance()->get_field( $post_type, $meta_key );
|
||||
if ( $field ) {
|
||||
$module = ( $field['zone'] ?? 'hot' ) . '_' . sanitize_key( $post_type );
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) && TMDO_Feature_Flags::is_read_custom( $module ) ) {
|
||||
return 'zone_' . $field['zone'];
|
||||
}
|
||||
return 'postmeta';
|
||||
}
|
||||
}
|
||||
|
||||
if ( class_exists( 'TMDO_Entity_Registry' ) && TMDO_Entity_Registry::is_managed( $entity_type, $meta_key ) ) {
|
||||
return 'entity_table';
|
||||
}
|
||||
|
||||
return 'unregistered';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Back-compat aliases — 對外保留 WPDO_* 類別 / 常數,讓 hub-core / spoke-sso /
|
||||
* 其他 sister plugins 在升級到 2meet-data-optimizer 後不需修改任何 class_exists()
|
||||
* / new WPDO_X() / WPDO_API::xxx() 呼叫。
|
||||
*
|
||||
* Phase 1 起穩定。v0.2.0 後可加 _doing_it_wrong() deprecation notice。
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── 公開合約類別(Phase 1 凍結,不可移除)─────────────────────────────
|
||||
$tmdo_class_aliases = array(
|
||||
'TMDO_API' => 'WPDO_API',
|
||||
'TMDO_Schema_Registry' => 'WPDO_Schema_Registry',
|
||||
'TMDO_Entity_Registry' => 'WPDO_Entity_Registry',
|
||||
'TMDO_Custom_Table_Registry' => 'WPDO_Custom_Table_Registry',
|
||||
'TMDO_Feature_Flags' => 'WPDO_Feature_Flags',
|
||||
'TMDO_DB' => 'WPDO_DB',
|
||||
'TMDO_Crypto' => 'WPDO_Crypto',
|
||||
'TMDO_Logger' => 'WPDO_Logger',
|
||||
'TMDO_Capability' => 'WPDO_Capability',
|
||||
'TMDO_Safe_Unserialize' => 'WPDO_Safe_Unserialize',
|
||||
// Engine / Migration / Adapters(second-tier,部分 sister plugin 已直接呼叫)
|
||||
'TMDO_Hook_Bus' => 'WPDO_Hook_Bus',
|
||||
'TMDO_Mode_Manager' => 'WPDO_Mode_Manager',
|
||||
'TMDO_Migration_Engine' => 'WPDO_Migration_Engine',
|
||||
'TMDO_Migration_Orchestrator' => 'WPDO_Migration_Orchestrator',
|
||||
'TMDO_Entity_Migration_Engine' => 'WPDO_Entity_Migration_Engine',
|
||||
'TMDO_Entity_Health' => 'WPDO_Entity_Health',
|
||||
'TMDO_Schema_Manager' => 'WPDO_Schema_Manager',
|
||||
'TMDO_Sync_Bridge' => 'WPDO_Sync_Bridge',
|
||||
'TMDO_Query_Router' => 'WPDO_Query_Router',
|
||||
'TMDO_Post_Query_Router' => 'WPDO_Post_Query_Router',
|
||||
'TMDO_Hook_Bus_Bridge' => 'WPDO_Hook_Bus_Bridge',
|
||||
'TMDO_Conflict_Monitor' => 'WPDO_Conflict_Monitor',
|
||||
'TMDO_Compatibility' => 'WPDO_Compatibility',
|
||||
'TMDO_Installer' => 'WPDO_Installer',
|
||||
'TMDO_Cache_Layer' => 'WPDO_Cache_Layer',
|
||||
'TMDO_Zone_Classifier' => 'WPDO_Zone_Classifier',
|
||||
'TMDO_Core' => 'WPDO_Core',
|
||||
'TMDO_REST_API' => 'WPDO_REST_API',
|
||||
'TMDO_Snapshot_Manager' => 'WPDO_Snapshot_Manager',
|
||||
'TMDO_Snapshot_Writer' => 'WPDO_Snapshot_Writer',
|
||||
'TMDO_Snapshot_Reader' => 'WPDO_Snapshot_Reader',
|
||||
'TMDO_Snapshot_Pruner' => 'WPDO_Snapshot_Pruner',
|
||||
'TMDO_FSM_Guard' => 'WPDO_FSM_Guard',
|
||||
'TMDO_Site_Health' => 'WPDO_Site_Health',
|
||||
'TMDO_Health_Cron' => 'WPDO_Health_Cron',
|
||||
'TMDO_Email_Notifier' => 'WPDO_Email_Notifier',
|
||||
'TMDO_Slack_Notifier' => 'WPDO_Slack_Notifier',
|
||||
'TMDO_Discord_Notifier' => 'WPDO_Discord_Notifier',
|
||||
'TMDO_Telegram_Notifier' => 'WPDO_Telegram_Notifier',
|
||||
'TMDO_Monthly_Summary' => 'WPDO_Monthly_Summary',
|
||||
'TMDO_Site_Metrics_Collector' => 'WPDO_Site_Metrics_Collector',
|
||||
'TMDO_FSM_Advisor' => 'WPDO_FSM_Advisor',
|
||||
'TMDO_Module_Detector' => 'WPDO_Module_Detector',
|
||||
'TMDO_FSM_Automator' => 'WPDO_FSM_Automator',
|
||||
'TMDO_Module_Rules' => 'WPDO_Module_Rules',
|
||||
'TMDO_CSV_Writer' => 'WPDO_CSV_Writer',
|
||||
'TMDO_Export' => 'WPDO_Export',
|
||||
'TMDO_CLI' => 'WPDO_CLI',
|
||||
'TMDO_CLI_V2' => 'WPDO_CLI_V2',
|
||||
'TMDO_CLI_Member' => 'WPDO_CLI_Member',
|
||||
'TMDO_CLI_Post' => 'WPDO_CLI_Post',
|
||||
'TMDO_CLI_Term_Comment' => 'WPDO_CLI_Term_Comment',
|
||||
'TMDO_Member_Fields' => 'WPDO_Member_Fields',
|
||||
'TMDO_Post_Fields' => 'WPDO_Post_Fields',
|
||||
'TMDO_Points_Manager' => 'WPDO_Points_Manager',
|
||||
'TMDO_Demo_Entity_Counter' => 'WPDO_Demo_Entity_Counter',
|
||||
'TMDO_Postmeta_Cleaner' => 'WPDO_Postmeta_Cleaner',
|
||||
'TMDO_Termmeta_Cleaner' => 'WPDO_Termmeta_Cleaner',
|
||||
'TMDO_Commentmeta_Cleaner' => 'WPDO_Commentmeta_Cleaner',
|
||||
'TMDO_Post_Stress_Tester' => 'WPDO_Post_Stress_Tester',
|
||||
'TMDO_User_Stress_Tester' => 'WPDO_User_Stress_Tester',
|
||||
'TMDO_Term_Stress_Tester' => 'WPDO_Term_Stress_Tester',
|
||||
'TMDO_Comment_Stress_Tester' => 'WPDO_Comment_Stress_Tester',
|
||||
'TMDO_Post_Shadow_Verifier' => 'WPDO_Post_Shadow_Verifier',
|
||||
'TMDO_Term_Comment_Shadow_Verifier' => 'WPDO_Term_Comment_Shadow_Verifier',
|
||||
'TMDO_Term_Comment_Backfill' => 'WPDO_Term_Comment_Backfill',
|
||||
'TMDO_Term_Comment_Misc_Bucket' => 'WPDO_Term_Comment_Misc_Bucket',
|
||||
'TMDO_Term_Comment_Garbage_Filter' => 'WPDO_Term_Comment_Garbage_Filter',
|
||||
'TMDO_Setup_Wizard' => 'WPDO_Setup_Wizard',
|
||||
'TMDO_Dashboard_Widget' => 'WPDO_Dashboard_Widget',
|
||||
'TMDO_Help_Tabs' => 'WPDO_Help_Tabs',
|
||||
'TMDO_Admin' => 'WPDO_Admin',
|
||||
'TMDO_V2_Upgrader' => 'WPDO_V2_Upgrader',
|
||||
'TMDO_SQLite_Compat' => 'WPDO_SQLite_Compat',
|
||||
'TMDO_Type_Caster' => 'WPDO_Type_Caster',
|
||||
'TMDO_Audit_Logger' => 'WPDO_Audit_Logger',
|
||||
'TMDO_Shadow_Diff_Logger' => 'WPDO_Shadow_Diff_Logger',
|
||||
'TMDO_Conflict_Detector' => 'WPDO_Conflict_Detector',
|
||||
'TMDO_Cache_Orchestrator' => 'WPDO_Cache_Orchestrator',
|
||||
'TMDO_Query_Compiler' => 'WPDO_Query_Compiler',
|
||||
'TMDO_Auto_Promoter' => 'WPDO_Auto_Promoter',
|
||||
'TMDO_Adapter_Post' => 'WPDO_Adapter_Post',
|
||||
'TMDO_Adapter_User' => 'WPDO_Adapter_User',
|
||||
'TMDO_Adapter_Term' => 'WPDO_Adapter_Term',
|
||||
'TMDO_Adapter_Comment' => 'WPDO_Adapter_Comment',
|
||||
'TMDO_Options_Manager' => 'WPDO_Options_Manager',
|
||||
'TMDO_Hot_Migration' => 'WPDO_Hot_Migration',
|
||||
'TMDO_Warm_Migration' => 'WPDO_Warm_Migration',
|
||||
'TMDO_Cold_Migration' => 'WPDO_Cold_Migration',
|
||||
'TMDO_Archive_Migration' => 'WPDO_Archive_Migration',
|
||||
'TMDO_Migration_Base' => 'WPDO_Migration_Base',
|
||||
'TMDO_Post_Migration' => 'WPDO_Post_Migration',
|
||||
'TMDO_Zone_Hot' => 'WPDO_Zone_Hot',
|
||||
'TMDO_Zone_Warm' => 'WPDO_Zone_Warm',
|
||||
'TMDO_Zone_Cold' => 'WPDO_Zone_Cold',
|
||||
'TMDO_Zone_Archive' => 'WPDO_Zone_Archive',
|
||||
'TMDO_Interceptor_Base' => 'WPDO_Interceptor_Base',
|
||||
'TMDO_Query_Interceptor_Base' => 'WPDO_Query_Interceptor_Base',
|
||||
'TMDO_Notifier' => 'WPDO_Notifier',
|
||||
);
|
||||
|
||||
foreach ( $tmdo_class_aliases as $tmdo_class => $wpdo_alias ) {
|
||||
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) {
|
||||
class_alias( $tmdo_class, $wpdo_alias );
|
||||
}
|
||||
}
|
||||
unset( $tmdo_class_aliases, $tmdo_class, $wpdo_alias );
|
||||
|
||||
// trait + interface back-compat(PHP class_alias 不支援 trait/interface,用 wrapper 解決)。
|
||||
require_once __DIR__ . '/back-compat/trait-wpdo-anti-eav-aware-alias.php';
|
||||
require_once __DIR__ . '/back-compat/interface-wpdo-entity-adapter-alias.php';
|
||||
|
||||
// ── 常數別名(部分 sister plugin 直接讀取常數)─────────────────────────
|
||||
if ( ! defined( 'WPDO_VERSION' ) ) {
|
||||
define( 'WPDO_VERSION', TMDO_VERSION );
|
||||
}
|
||||
if ( ! defined( 'WPDO_DB_VERSION' ) ) {
|
||||
define( 'WPDO_DB_VERSION', TMDO_DB_VERSION );
|
||||
}
|
||||
if ( ! defined( 'WPDO_PLUGIN_DIR' ) ) {
|
||||
define( 'WPDO_PLUGIN_DIR', TMDO_PATH );
|
||||
}
|
||||
if ( ! defined( 'WPDO_PLUGIN_URL' ) ) {
|
||||
define( 'WPDO_PLUGIN_URL', TMDO_URL );
|
||||
}
|
||||
if ( ! defined( 'WPDO_PLUGIN_FILE' ) ) {
|
||||
define( 'WPDO_PLUGIN_FILE', TMDO_FILE );
|
||||
}
|
||||
if ( ! defined( 'WPDO_TABLE_PREFIX' ) ) {
|
||||
define( 'WPDO_TABLE_PREFIX', TMDO_TABLE_PREFIX );
|
||||
}
|
||||
if ( ! defined( 'WPDO_CACHE_GROUP' ) ) {
|
||||
define( 'WPDO_CACHE_GROUP', TMDO_CACHE_GROUP );
|
||||
}
|
||||
if ( ! defined( 'WPDO_IS_SQLITE' ) ) {
|
||||
define( 'WPDO_IS_SQLITE', TMDO_IS_SQLITE );
|
||||
}
|
||||
if ( ! defined( 'WPDO_IS_MYSQL' ) ) {
|
||||
define( 'WPDO_IS_MYSQL', TMDO_IS_MYSQL );
|
||||
}
|
||||
|
||||
// ── Hook dual-fire bridge ─────────────────────────────────────────────────
|
||||
// Phase 1 過渡期:tmdo_* 與 wpdo_* hook 雙向轉發,讓既有 listener 仍能接收事件。
|
||||
foreach ( array(
|
||||
'wpdo_register_fields',
|
||||
'wpdo_register_entity_fields',
|
||||
'wpdo_register_custom_tables',
|
||||
'wpdo_after_write',
|
||||
'wpdo_after_delete',
|
||||
'wpdo_schema_updated',
|
||||
'wpdo_bridge_mode_changed',
|
||||
'wpdo_bridge_emergency_disabled',
|
||||
'wpdo_auto_promoted',
|
||||
) as $tmdo_back_compat_hook ) {
|
||||
$tmdo_new_hook = preg_replace( '/^wpdo_/', 'tmdo_', $tmdo_back_compat_hook );
|
||||
add_action(
|
||||
$tmdo_new_hook,
|
||||
static function ( ...$args ) use ( $tmdo_back_compat_hook ) {
|
||||
do_action_ref_array( $tmdo_back_compat_hook, $args );
|
||||
},
|
||||
1
|
||||
);
|
||||
}
|
||||
unset( $tmdo_back_compat_hook, $tmdo_new_hook );
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Object Cache integration layer for Zone C (Cold) data.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Object Cache integration layer for Zone C (Cold) data.
|
||||
*
|
||||
* Provides a unified API for cache operations across all cold post types,
|
||||
* with bulk prefetch support for reducing DB queries on archive pages.
|
||||
*
|
||||
* This class sits on top of TMDO_Zone_Cold and adds:
|
||||
* - Bulk prefetch for multiple posts (archive/search result pages)
|
||||
* - Cache warming on post save
|
||||
* - Cache statistics for admin dashboard
|
||||
* - Group-level cache flush
|
||||
*/
|
||||
class TMDO_Cache_Layer {
|
||||
|
||||
/**
|
||||
* Prefetch cold data for multiple posts into Object Cache.
|
||||
*
|
||||
* Call this early on archive/search pages to batch-load cold blobs
|
||||
* in a single query instead of N+1 queries per post.
|
||||
*
|
||||
* @param int[] $post_ids Array of post IDs to prefetch.
|
||||
* @param string $post_type Post type.
|
||||
*/
|
||||
public static function prefetch( array $post_ids, string $post_type ): void {
|
||||
if ( empty( $post_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||
if ( empty( $cold_keys ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
|
||||
// Check which IDs are already cached.
|
||||
$uncached = array();
|
||||
foreach ( $post_ids as $pid ) {
|
||||
$cached = wp_cache_get( "cold_{$pid}", $group );
|
||||
if ( false === $cached ) {
|
||||
$uncached[] = (int) $pid;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $uncached ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bulk fetch from cold table.
|
||||
global $wpdb;
|
||||
$table = TMDO_Zone_Cold::table( $post_type );
|
||||
$placeholders = implode( ',', array_fill( 0, count( $uncached ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table from TMDO_Zone_Cold::table(); $placeholders built from array_fill with %d.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT post_id, data FROM `{$table}` WHERE post_id IN ({$placeholders})",
|
||||
...$uncached
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||
|
||||
// Get cache TTL from registry.
|
||||
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type );
|
||||
$ttl = HOUR_IN_SECONDS;
|
||||
foreach ( $fields as $field ) {
|
||||
if ( ! empty( $field['cache_ttl'] ) ) {
|
||||
$ttl = (int) $field['cache_ttl'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Index fetched rows by post_id.
|
||||
$fetched = array();
|
||||
foreach ( $rows ?: array() as $row ) {
|
||||
$data = json_decode( $row['data'], true );
|
||||
$fetched[ (int) $row['post_id'] ] = is_array( $data ) ? $data : array();
|
||||
}
|
||||
|
||||
// Cache all results (including empty arrays for posts with no cold data).
|
||||
foreach ( $uncached as $pid ) {
|
||||
$data = $fetched[ $pid ] ?? array();
|
||||
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the cache for a single post after it's saved/updated.
|
||||
*
|
||||
* Hooked to 'save_post' to ensure fresh data is in cache.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $post_type Post type.
|
||||
*/
|
||||
public static function warm_post( int $post_id, string $post_type ): void {
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||
if ( empty( $cold_keys ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force a fresh read from DB (bypasses cache).
|
||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
wp_cache_delete( "cold_{$post_id}", $group );
|
||||
|
||||
// Re-read will populate cache.
|
||||
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all cold cache for a post type.
|
||||
*
|
||||
* Useful after bulk imports or schema changes.
|
||||
*
|
||||
* @param string $post_type Post type to flush.
|
||||
*/
|
||||
public static function flush_group( string $post_type ): void {
|
||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
|
||||
if ( function_exists( 'wp_cache_flush_group' ) ) {
|
||||
wp_cache_flush_group( $group );
|
||||
}
|
||||
// If wp_cache_flush_group is not available (older WP or no object cache),
|
||||
// individual entries will expire via TTL.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for the admin dashboard.
|
||||
*
|
||||
* @return array{groups: array, prefetch_support: bool}
|
||||
*/
|
||||
public static function get_stats(): array {
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
$cold_types = $registry->get_cold_post_types();
|
||||
$groups = array();
|
||||
|
||||
foreach ( $cold_types as $pt ) {
|
||||
$group = 'wpdo_cold_' . sanitize_key( $pt );
|
||||
$keys = $registry->get_cold_meta_keys( $pt );
|
||||
$fields = $registry->get_zone_fields_for_type( 'cold', $pt );
|
||||
$ttl = HOUR_IN_SECONDS;
|
||||
foreach ( $fields as $field ) {
|
||||
if ( ! empty( $field['cache_ttl'] ) ) {
|
||||
$ttl = (int) $field['cache_ttl'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$groups[] = array(
|
||||
'post_type' => $pt,
|
||||
'group' => $group,
|
||||
'meta_keys' => $keys,
|
||||
'ttl' => $ttl,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'groups' => $groups,
|
||||
'prefetch_support' => true,
|
||||
'flush_support' => function_exists( 'wp_cache_flush_group' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks for cache warming.
|
||||
*/
|
||||
public static function register_hooks(): void {
|
||||
add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 99, 2 );
|
||||
|
||||
// Prefetch on HivePress archive pages.
|
||||
add_action( 'loop_start', array( __CLASS__, 'on_loop_start' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm cache after post save.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @return void
|
||||
*/
|
||||
public static function on_save_post( int $post_id, \WP_Post $post ): void {
|
||||
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post->post_type );
|
||||
if ( ! empty( $cold_keys ) ) {
|
||||
self::warm_post( $post_id, $post->post_type );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch cold data at the start of a main query loop.
|
||||
*
|
||||
* @param \WP_Query $query The current WP_Query object.
|
||||
* @return void
|
||||
*/
|
||||
public static function on_loop_start( \WP_Query $query ): void {
|
||||
if ( ! $query->is_main_query() || is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$posts = $query->posts;
|
||||
if ( empty( $posts ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_type = $query->get( 'post_type' );
|
||||
if ( is_array( $post_type ) ) {
|
||||
$post_type = reset( $post_type );
|
||||
}
|
||||
|
||||
if ( ! $post_type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||
if ( empty( $cold_keys ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_ids = wp_list_pluck( $posts, 'ID' );
|
||||
self::prefetch( $post_ids, $post_type );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Capability — Multisite-aware admin capability check (v2.14.0).
|
||||
*
|
||||
* Centralizes the "can this user manage WPDO?" decision. Behaviour:
|
||||
*
|
||||
* - Single-site: require `manage_options` (site admin)
|
||||
* - Multisite per-site: require `manage_options` (site admin on that site)
|
||||
* - Multisite network: super admin always passes; site admin still passes
|
||||
* on their own site if they hold `manage_options`
|
||||
*
|
||||
* Pre-v2.14.0 the codebase called `current_user_can('manage_options')` in 17
|
||||
* places. On a network-admin context page, super admins do NOT automatically
|
||||
* have `manage_options` — WordPress maps it to `do_not_allow` for non-super
|
||||
* admins and lets `is_super_admin()` carry the decision separately. By
|
||||
* routing through this helper, the plugin works identically on single-site
|
||||
* and multisite without per-call-site if/else.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.14.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper class — drop-in replacement for `current_user_can('manage_options')`.
|
||||
*/
|
||||
final class TMDO_Capability {
|
||||
|
||||
/**
|
||||
* Whether the current user can manage WP Data Optimizer.
|
||||
*
|
||||
* Super admins always pass on multisite; site admins pass on their site.
|
||||
* On single-site WordPress this is functionally identical to
|
||||
* `current_user_can('manage_options')`.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function current_user_can_admin(): bool {
|
||||
if ( function_exists( 'is_multisite' ) && is_multisite()
|
||||
&& function_exists( 'is_super_admin' ) && is_super_admin()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return current_user_can( 'manage_options' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||
/**
|
||||
* TMDO_Comment_Stress_Tester — Async stress fixture generator for comment entity (v2.13.1).
|
||||
*
|
||||
* Mirrors TMDO_Term_Stress_Tester (v2.13.0) but adapted for comment entity:
|
||||
*
|
||||
* - Source: $wpdb->comments (comment_ID, comment_post_ID, comment_author, ...)
|
||||
* - Meta: $wpdb->commentmeta
|
||||
* - Flat: wpdo_comment_hp_review (single hp_rating key) + wpdo_comment_misc
|
||||
* - Realistic mode: wp_insert_comment() + update_comment_meta()
|
||||
* - Fast mode: bulk INSERT to wp_comments + wp_commentmeta
|
||||
*
|
||||
* Test comments are identified by `comment_author_email LIKE '%@wpdo-stress.local'`.
|
||||
* Each comment is attached to a caller-specified `comment_post_ID` (must exist).
|
||||
*
|
||||
* 🔒 v2.13.x frozen contract: never touches user / post / term entities or
|
||||
* their flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.13.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->comments / wp_commentmeta WP-managed; meta_key strings static class constants; user-controlled values use prepare() placeholders.
|
||||
|
||||
/**
|
||||
* Async bulk fixture generator for comment entity stress tests.
|
||||
*/
|
||||
final class TMDO_Comment_Stress_Tester {
|
||||
|
||||
/** Email domain marker — comments matching this are stress test fixtures. */
|
||||
public const TEST_EMAIL_DOMAIN = 'wpdo-stress.local';
|
||||
|
||||
/** Hard cap to prevent runaway calls. */
|
||||
public const MAX_COUNT = 100000;
|
||||
|
||||
// State machine constants.
|
||||
public const OPT_STATE = 'wpdo_comment_stress_test_state';
|
||||
public const CRON_HOOK = 'wpdo_comment_stress_test_batch';
|
||||
public const CANCEL_FLAG = 'wpdo_comment_stress_cancel_flag';
|
||||
public const DEFAULT_BATCH_SIZE = 200;
|
||||
public const MAX_BATCH_SIZE = 1000;
|
||||
public const MIN_BATCH_DELAY_SEC = 1;
|
||||
public const BATCH_DEADLINE_SEC = 8;
|
||||
public const MODE_FAST = 'fast';
|
||||
public const MODE_REALISTIC = 'realistic';
|
||||
|
||||
/**
|
||||
* Per-key seed map (hp_review group: just hp_rating).
|
||||
*
|
||||
* @var array<string,mixed>|null
|
||||
*/
|
||||
private static ?array $seed_map_cache = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public API — sync helpers (also used internally by state machine batches)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bulk-create N stress test comments on the given post via direct SQL
|
||||
* (Fast mode — bypasses WP filter chain).
|
||||
*
|
||||
* @param int $post_id WP post ID to attach comments to.
|
||||
* @param int $count Number of comments to insert. Capped at MAX_COUNT.
|
||||
* @return array{created:int,post_id:int,first_id:int|null,last_id:int|null}
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function create( int $post_id, int $count ): array {
|
||||
self::validate_inputs( $post_id, $count );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$first_id = null;
|
||||
$last_id = null;
|
||||
$created = 0;
|
||||
$seed_map = self::seed_map();
|
||||
$now = current_time( 'mysql' );
|
||||
$now_gmt = current_time( 'mysql', true );
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$ok = $wpdb->insert(
|
||||
$wpdb->comments,
|
||||
array(
|
||||
'comment_post_ID' => $post_id,
|
||||
'comment_author' => 'WPDO Stress ' . $suffix,
|
||||
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
|
||||
'comment_author_url' => '',
|
||||
'comment_author_IP' => '127.0.0.1',
|
||||
'comment_date' => $now,
|
||||
'comment_date_gmt' => $now_gmt,
|
||||
'comment_content' => 'Stress test comment ' . $suffix,
|
||||
'comment_karma' => 0,
|
||||
'comment_approved' => '1',
|
||||
'comment_agent' => 'wpdo-stress-tester',
|
||||
'comment_type' => 'comment',
|
||||
'comment_parent' => 0,
|
||||
'user_id' => 0,
|
||||
)
|
||||
);
|
||||
if ( ! $ok ) {
|
||||
continue;
|
||||
}
|
||||
$comment_id = (int) $wpdb->insert_id;
|
||||
if ( null === $first_id ) {
|
||||
$first_id = $comment_id;
|
||||
}
|
||||
$last_id = $comment_id;
|
||||
++$created;
|
||||
|
||||
// Direct INSERT to wp_commentmeta (bypassing Hook Bus). For aeav_only
|
||||
// mode validation use create_realistic() instead.
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
$wpdb->insert(
|
||||
$wpdb->commentmeta,
|
||||
array(
|
||||
'comment_id' => $comment_id,
|
||||
'meta_key' => $meta_key,
|
||||
'meta_value' => (string) $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'created' => $created,
|
||||
'post_id' => $post_id,
|
||||
'first_id' => $first_id,
|
||||
'last_id' => $last_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Realistic-mode counterpart — uses wp_insert_comment() +
|
||||
* update_comment_meta() so Hook Bus / entity registry / mode_manager all
|
||||
* engage on the write path.
|
||||
*
|
||||
* @param int $post_id WP post ID.
|
||||
* @param int $count Number of comments.
|
||||
* @return array{created:int,post_id:int,mode:string,first_id:int|null,last_id:int|null}
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function create_realistic( int $post_id, int $count ): array {
|
||||
self::validate_inputs( $post_id, $count );
|
||||
|
||||
$first_id = null;
|
||||
$last_id = null;
|
||||
$created = 0;
|
||||
$seed_map = self::seed_map();
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$comment_id = wp_insert_comment(
|
||||
array(
|
||||
'comment_post_ID' => $post_id,
|
||||
'comment_author' => 'WPDO Stress ' . $suffix,
|
||||
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
|
||||
'comment_content' => 'Stress test comment ' . $suffix,
|
||||
'comment_approved' => 1,
|
||||
'comment_type' => 'comment',
|
||||
)
|
||||
);
|
||||
if ( ! $comment_id ) {
|
||||
continue;
|
||||
}
|
||||
$comment_id = (int) $comment_id;
|
||||
if ( null === $first_id ) {
|
||||
$first_id = $comment_id;
|
||||
}
|
||||
$last_id = $comment_id;
|
||||
++$created;
|
||||
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
update_comment_meta( $comment_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'created' => $created,
|
||||
'post_id' => $post_id,
|
||||
'mode' => 'realistic',
|
||||
'first_id' => $first_id,
|
||||
'last_id' => $last_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count comments whose author email ends with TEST_EMAIL_DOMAIN.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function count_test_comments(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_email LIKE %s",
|
||||
'%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every stress-test comment + its commentmeta + flat table rows.
|
||||
*
|
||||
* @return array{deleted_comments:int,deleted_meta:int,deleted_flat:int}
|
||||
*/
|
||||
public static function cleanup(): array {
|
||||
global $wpdb;
|
||||
|
||||
set_transient( self::CANCEL_FLAG, 1, 600 );
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$comment_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_author_email LIKE %s",
|
||||
'%@' . $wpdb->esc_like( self::TEST_EMAIL_DOMAIN )
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $comment_ids ) ) {
|
||||
return array(
|
||||
'deleted_comments' => 0,
|
||||
'deleted_meta' => 0,
|
||||
'deleted_flat' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$id_list = implode( ',', array_map( 'absint', $comment_ids ) );
|
||||
$flat_deleted = 0;
|
||||
|
||||
foreach ( self::get_comment_flat_tables() as $tbl ) {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) );
|
||||
if ( ! $exists ) {
|
||||
continue;
|
||||
}
|
||||
$rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE comment_id IN ({$id_list})" );
|
||||
$flat_deleted += $rows;
|
||||
}
|
||||
|
||||
$meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->commentmeta} WHERE comment_id IN ({$id_list})" );
|
||||
$comment_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->comments} WHERE comment_ID IN ({$id_list})" );
|
||||
|
||||
delete_option( self::OPT_STATE );
|
||||
delete_transient( self::CANCEL_FLAG );
|
||||
|
||||
return array(
|
||||
'deleted_comments' => $comment_deleted,
|
||||
'deleted_meta' => $meta_deleted,
|
||||
'deleted_flat' => $flat_deleted,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// State machine
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start an async stress run.
|
||||
*
|
||||
* @param int $post_id Post ID to attach comments to.
|
||||
* @param int $target Total comments to create.
|
||||
* @param string $mode MODE_FAST | MODE_REALISTIC.
|
||||
* @param int $batch_size Per-batch insert count.
|
||||
* @return array{ok:bool,error?:string,state?:array}
|
||||
*/
|
||||
public static function start( int $post_id, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
|
||||
if ( $post_id < 1 || ! self::post_exists( $post_id ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'unknown_post: ' . $post_id,
|
||||
);
|
||||
}
|
||||
if ( $target < 1 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'target must be >= 1',
|
||||
);
|
||||
}
|
||||
if ( $target > self::MAX_COUNT ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'target too large (max ' . self::MAX_COUNT . ')',
|
||||
);
|
||||
}
|
||||
if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'invalid mode',
|
||||
);
|
||||
}
|
||||
$batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) );
|
||||
|
||||
$current = self::get_state();
|
||||
if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'already_running',
|
||||
'state' => $current,
|
||||
);
|
||||
}
|
||||
|
||||
$state = array(
|
||||
'job_id' => uniqid( 'cstress_', true ),
|
||||
'status' => 'running',
|
||||
'mode' => $mode,
|
||||
'post_id' => $post_id,
|
||||
'target' => $target,
|
||||
'batch_size' => $batch_size,
|
||||
'started_at' => time(),
|
||||
'processed' => 0,
|
||||
'batches_done' => 0,
|
||||
'batches_log' => array(),
|
||||
'errors' => array(),
|
||||
'peak_memory' => 0,
|
||||
'completed_at' => null,
|
||||
'last_pushed_at' => 0,
|
||||
'benchmark' => null,
|
||||
);
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
delete_transient( self::CANCEL_FLAG );
|
||||
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
wp_schedule_single_event( time(), self::CRON_HOOK );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'state' => $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running stress test.
|
||||
*
|
||||
* @return array{ok:bool,state?:array,message?:string}
|
||||
*/
|
||||
public static function cancel(): array {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) ) {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => 'no_active_job',
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( self::CANCEL_FLAG, 1, 600 );
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$state = self::get_state();
|
||||
$state['status'] = 'cancelled';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'state' => $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_state(): array {
|
||||
$state = get_option( self::OPT_STATE, array() );
|
||||
return is_array( $state ) ? $state : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress with computed pct/rate/ETA.
|
||||
*
|
||||
* @param bool $pump When true, opportunistically pump.
|
||||
* @return array
|
||||
*/
|
||||
public static function get_progress( bool $pump = true ): array {
|
||||
if ( $pump ) {
|
||||
self::pump_if_due();
|
||||
}
|
||||
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) ) {
|
||||
return array(
|
||||
'status' => 'idle',
|
||||
'processed' => 0,
|
||||
'target' => 0,
|
||||
'pct' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$target = (int) ( $state['target'] ?? 0 );
|
||||
$started = (int) ( $state['started_at'] ?? 0 );
|
||||
$ended = (int) ( $state['completed_at'] ?? 0 );
|
||||
|
||||
$now = $ended > 0 ? $ended : time();
|
||||
$elapsed = max( 1, $now - $started );
|
||||
$rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0;
|
||||
$eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0;
|
||||
$pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0;
|
||||
|
||||
return array_merge(
|
||||
$state,
|
||||
array(
|
||||
'pct' => $pct,
|
||||
'rate_per_sec' => $rate,
|
||||
'elapsed_sec' => $elapsed,
|
||||
'eta_sec' => $eta_sec,
|
||||
'test_comment_count' => self::count_test_comments(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistic batch pump (transient-locked).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function pump_if_due(): void {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
|
||||
if ( time() - $last_pushed_at < 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock_key = 'wpdo_comment_stress_pump_lock';
|
||||
if ( false !== get_transient( $lock_key ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( $lock_key, 1, 30 );
|
||||
|
||||
if ( function_exists( 'set_time_limit' ) ) {
|
||||
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
|
||||
try {
|
||||
self::run_batch();
|
||||
} finally {
|
||||
delete_transient( $lock_key );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron entrypoint.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_batch(): void {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = (int) ( $state['post_id'] ?? 0 );
|
||||
$target = (int) ( $state['target'] ?? 0 );
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
|
||||
$mode = (string) ( $state['mode'] ?? self::MODE_FAST );
|
||||
$remaining = $target - $processed;
|
||||
if ( $remaining <= 0 ) {
|
||||
self::finalize( $state );
|
||||
return;
|
||||
}
|
||||
$this_batch_size = min( $batch_size, $remaining );
|
||||
|
||||
$batch_started = microtime( true );
|
||||
try {
|
||||
if ( self::MODE_FAST === $mode ) {
|
||||
$inserted = self::run_batch_fast( $post_id, $this_batch_size );
|
||||
} else {
|
||||
$inserted = self::run_batch_realistic( $post_id, $this_batch_size );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$state['errors'][] = array(
|
||||
'time' => time(),
|
||||
'message' => $e->getMessage(),
|
||||
);
|
||||
$state['status'] = 'failed';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::error( 'comment_stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
|
||||
}
|
||||
return;
|
||||
}
|
||||
$batch_elapsed = microtime( true ) - $batch_started;
|
||||
|
||||
$latest = self::get_state();
|
||||
if ( empty( $latest ) ) {
|
||||
return;
|
||||
}
|
||||
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
|
||||
|
||||
$latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted;
|
||||
$latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1;
|
||||
$latest['batches_log'][] = array(
|
||||
'n' => $inserted,
|
||||
'duration_ms' => (int) round( $batch_elapsed * 1000 ),
|
||||
);
|
||||
if ( count( $latest['batches_log'] ) > 200 ) {
|
||||
$latest['batches_log'] = array_slice( $latest['batches_log'], -200 );
|
||||
}
|
||||
$latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) );
|
||||
$latest['last_pushed_at'] = time();
|
||||
|
||||
if ( $is_cancelled ) {
|
||||
update_option( self::OPT_STATE, $latest, false );
|
||||
return;
|
||||
}
|
||||
|
||||
update_option( self::OPT_STATE, $latest, false );
|
||||
|
||||
if ( $latest['processed'] >= $target ) {
|
||||
self::finalize( $latest );
|
||||
return;
|
||||
}
|
||||
|
||||
wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fast-mode batch.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $count Batch size.
|
||||
* @return int Inserted count.
|
||||
*/
|
||||
private static function run_batch_fast( int $post_id, int $count ): int {
|
||||
$result = self::create( $post_id, $count );
|
||||
return (int) ( $result['created'] ?? 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one realistic-mode batch with deadline + cancel check per comment.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $count Batch size.
|
||||
* @return int Inserted count.
|
||||
*/
|
||||
private static function run_batch_realistic( int $post_id, int $count ): int {
|
||||
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
|
||||
$inserted = 0;
|
||||
$seed_map = self::seed_map();
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
if ( microtime( true ) > $deadline ) {
|
||||
break;
|
||||
}
|
||||
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$comment_id = wp_insert_comment(
|
||||
array(
|
||||
'comment_post_ID' => $post_id,
|
||||
'comment_author' => 'WPDO Stress ' . $suffix,
|
||||
'comment_author_email' => 'wpdo+' . $suffix . '@' . self::TEST_EMAIL_DOMAIN,
|
||||
'comment_content' => 'Stress test comment ' . $suffix,
|
||||
'comment_approved' => 1,
|
||||
'comment_type' => 'comment',
|
||||
)
|
||||
);
|
||||
if ( ! $comment_id ) {
|
||||
continue;
|
||||
}
|
||||
++$inserted;
|
||||
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
update_comment_meta( (int) $comment_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: clear cron, run benchmark, persist completed state.
|
||||
*
|
||||
* @param array $state Pre-finalize state.
|
||||
* @return void
|
||||
*/
|
||||
private static function finalize( array $state ): void {
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$state['status'] = 'benchmarking';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
$report = self::run_benchmark( $state );
|
||||
|
||||
$state['benchmark'] = $report;
|
||||
$state['status'] = 'completed';
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run benchmark.
|
||||
*
|
||||
* @param array|null $state Optional state snapshot.
|
||||
* @return array
|
||||
*/
|
||||
public static function run_benchmark( ?array $state = null ): array {
|
||||
$state = $state ?? self::get_state();
|
||||
$post_id = (int) ( $state['post_id'] ?? 0 );
|
||||
|
||||
return array(
|
||||
'generated_at' => time(),
|
||||
'post_id' => $post_id,
|
||||
'write' => self::compute_write_metrics( $state ),
|
||||
'db_sizes' => self::measure_db_sizes(),
|
||||
'query' => self::measure_query_performance(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write throughput metrics from state.
|
||||
*
|
||||
* @param array $state State snapshot.
|
||||
* @return array
|
||||
*/
|
||||
private static function compute_write_metrics( array $state ): array {
|
||||
$started = (int) ( $state['started_at'] ?? 0 );
|
||||
$ended = (int) ( $state['completed_at'] ?? time() );
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$elapsed = max( 1, $ended - $started );
|
||||
$batches = $state['batches_log'] ?? array();
|
||||
|
||||
$durations = array_column( $batches, 'duration_ms' );
|
||||
$min_ms = ! empty( $durations ) ? min( $durations ) : 0;
|
||||
$max_ms = ! empty( $durations ) ? max( $durations ) : 0;
|
||||
$avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0;
|
||||
|
||||
return array(
|
||||
'mode' => $state['mode'] ?? '',
|
||||
'post_id' => (int) ( $state['post_id'] ?? 0 ),
|
||||
'target' => (int) ( $state['target'] ?? 0 ),
|
||||
'processed' => $processed,
|
||||
'elapsed_sec' => $elapsed,
|
||||
'rate_per_sec' => round( $processed / $elapsed, 2 ),
|
||||
'batches_done' => (int) ( $state['batches_done'] ?? 0 ),
|
||||
'batch_min_ms' => $min_ms,
|
||||
'batch_max_ms' => $max_ms,
|
||||
'batch_avg_ms' => $avg_ms,
|
||||
'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure DB sizes for comment-related tables.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function measure_db_sizes(): array {
|
||||
global $wpdb;
|
||||
|
||||
$tables = array( $wpdb->comments, $wpdb->commentmeta );
|
||||
foreach ( self::get_comment_flat_tables() as $tbl ) {
|
||||
if ( self::table_exists( $tbl ) ) {
|
||||
$tables[] = $tbl;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! self::is_mysql() ) {
|
||||
return array_map(
|
||||
static fn( $t ) => array(
|
||||
'table' => $t,
|
||||
'rows' => self::table_row_count( $t ),
|
||||
),
|
||||
$tables
|
||||
);
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
|
||||
...$tables
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( (array) $rows as $r ) {
|
||||
$dl = (int) $r['dl'];
|
||||
$il = (int) $r['il'];
|
||||
$total = $dl + $il;
|
||||
$out[] = array(
|
||||
'table' => $r['t'],
|
||||
'rows' => (int) $r['rows_count'],
|
||||
'data_mb' => round( $dl / 1048576, 2 ),
|
||||
'index_mb' => round( $il / 1048576, 2 ),
|
||||
'total_mb' => round( $total / 1048576, 2 ),
|
||||
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 3 representative queries:
|
||||
* - point: hp_rating = 5 lookup on flat
|
||||
* - range: hp_rating > 3 ORDER BY DESC on flat
|
||||
* - EAV baseline: same point query on wp_commentmeta
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function measure_query_performance(): array {
|
||||
global $wpdb;
|
||||
$flat = $wpdb->prefix . 'wpdo_comment_hp_review';
|
||||
if ( ! self::table_exists( $flat ) ) {
|
||||
return array( 'note' => 'flat_table_missing' );
|
||||
}
|
||||
|
||||
return array(
|
||||
'point_rating_5' => self::time_query(
|
||||
"SELECT comment_id FROM `{$flat}` WHERE hp_rating = 5 LIMIT 100"
|
||||
),
|
||||
'range_rating_top' => self::time_query(
|
||||
"SELECT comment_id FROM `{$flat}` WHERE hp_rating > 3 ORDER BY hp_rating DESC LIMIT 100"
|
||||
),
|
||||
'eav_baseline' => self::time_query(
|
||||
"SELECT comment_id FROM {$wpdb->commentmeta} WHERE meta_key = 'hp_rating' AND meta_value = '5' LIMIT 100"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate inputs for create() / create_realistic().
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $count Count.
|
||||
* @return void
|
||||
* @throws InvalidArgumentException When invalid.
|
||||
*/
|
||||
private static function validate_inputs( int $post_id, int $count ): void {
|
||||
if ( $post_id < 1 ) {
|
||||
throw new InvalidArgumentException( 'post_id must be >= 1.' );
|
||||
}
|
||||
if ( $count <= 0 ) {
|
||||
throw new InvalidArgumentException( 'Count must be > 0.' );
|
||||
}
|
||||
if ( $count > self::MAX_COUNT ) {
|
||||
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether $post_id exists in wp_posts.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return bool
|
||||
*/
|
||||
private static function post_exists( int $post_id ): bool {
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE ID = %d LIMIT 1", $post_id )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-built seed map for hp_review group.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function seed_map(): array {
|
||||
if ( null !== self::$seed_map_cache ) {
|
||||
return self::$seed_map_cache;
|
||||
}
|
||||
|
||||
self::$seed_map_cache = array(
|
||||
'hp_rating' => static fn( int $i ) => (string) ( ( $i % 5 ) + 1 ),
|
||||
);
|
||||
return self::$seed_map_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of all wp_wpdo_comment_* flat tables.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function get_comment_flat_tables(): array {
|
||||
global $wpdb;
|
||||
$prefix = $wpdb->prefix . 'wpdo_comment_';
|
||||
return array(
|
||||
$prefix . 'hp_review',
|
||||
$prefix . 'misc',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized table-exists probe.
|
||||
*
|
||||
* @param string $table Table name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function table_exists( string $table ): bool {
|
||||
global $wpdb;
|
||||
static $cache = array();
|
||||
if ( isset( $cache[ $table ] ) ) {
|
||||
return $cache[ $table ];
|
||||
}
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
$cache[ $table ] = ( $found === $table );
|
||||
return $cache[ $table ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap row count helper (SQLite fallback).
|
||||
*
|
||||
* @param string $table Table name.
|
||||
* @return int
|
||||
*/
|
||||
private static function table_row_count( string $table ): int {
|
||||
global $wpdb;
|
||||
if ( ! self::table_exists( $table ) ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Time a SQL query.
|
||||
*
|
||||
* @param string $sql Query.
|
||||
* @return array{duration_ms:float}
|
||||
*/
|
||||
private static function time_query( string $sql ): array {
|
||||
global $wpdb;
|
||||
$start = microtime( true );
|
||||
$wpdb->get_results( $sql );
|
||||
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
|
||||
return array( 'duration_ms' => round( $elapsed_ms, 2 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MySQL vs SQLite.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_mysql(): bool {
|
||||
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Commentmeta_Cleaner — wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes four classes of low-value rows from wp_commentmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - wxr_import — `_wxr_import_*` rows from WP importer (pure tracking)
|
||||
* On dev10 alone: 211 rows = 75% of all commentmeta
|
||||
* - demo_data — `_2meet_demo_*` rows used to mark demo content
|
||||
* - transients — `_transient_*` and `_transient_timeout_*` rows
|
||||
* - orphan_post_meta — Stray post-meta keys mistakenly written to
|
||||
* commentmeta (e.g., `_hp_price`, `_hp_status`,
|
||||
* `_thumbnail_id`). These are bugs / typos / earlier
|
||||
* plugin defects — never read from commentmeta.
|
||||
*
|
||||
* Mirrors TMDO_Postmeta_Cleaner / TMDO_Termmeta_Cleaner pattern.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wp_commentmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*/
|
||||
class TMDO_Commentmeta_Cleaner {
|
||||
|
||||
public const TARGET_WXR_IMPORT = 'wxr_import';
|
||||
public const TARGET_DEMO_DATA = 'demo_data';
|
||||
public const TARGET_TRANSIENTS = 'transients';
|
||||
public const TARGET_ORPHAN_POST_META = 'orphan_post_meta';
|
||||
public const TARGET_ALL = 'all';
|
||||
|
||||
public const VALID_TARGETS = array(
|
||||
self::TARGET_WXR_IMPORT,
|
||||
self::TARGET_DEMO_DATA,
|
||||
self::TARGET_TRANSIENTS,
|
||||
self::TARGET_ORPHAN_POST_META,
|
||||
self::TARGET_ALL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Known post-meta keys that have been observed mis-written to commentmeta.
|
||||
* Conservative list — only includes keys that:
|
||||
* 1. Are clearly post-domain (start with `_hp_*` / `_thumbnail_id` / etc.)
|
||||
* 2. Have NO known consumer reading from get_comment_meta() with that key
|
||||
* 3. Were observed in real production data (dev10) as orphan rows
|
||||
*/
|
||||
private const ORPHAN_POST_META_KEYS = array(
|
||||
'_hp_price',
|
||||
'_hp_status',
|
||||
'_hp_featured',
|
||||
'_hp_verified',
|
||||
'_hp_view_count',
|
||||
'_thumbnail_id',
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
);
|
||||
|
||||
/**
|
||||
* Count rows that would be cleaned for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{wxr_import:int, demo_data:int, transients:int, orphan_post_meta:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function count_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$counts = array(
|
||||
'wxr_import' => 0,
|
||||
'demo_data' => 0,
|
||||
'transients' => 0,
|
||||
'orphan_post_meta' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
|
||||
$counts['wxr_import'] = self::count_wxr_import();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
|
||||
$counts['demo_data'] = self::count_demo_data();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$counts['transients'] = self::count_transients();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) {
|
||||
$counts['orphan_post_meta'] = self::count_orphan_post_meta();
|
||||
}
|
||||
|
||||
$counts['total'] = $counts['wxr_import'] + $counts['demo_data']
|
||||
+ $counts['transients'] + $counts['orphan_post_meta'];
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete garbage rows for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{wxr_import:int, demo_data:int, transients:int, orphan_post_meta:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$deleted = array(
|
||||
'wxr_import' => 0,
|
||||
'demo_data' => 0,
|
||||
'transients' => 0,
|
||||
'orphan_post_meta' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
|
||||
$deleted['wxr_import'] = self::delete_wxr_import();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
|
||||
$deleted['demo_data'] = self::delete_demo_data();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$deleted['transients'] = self::delete_transients();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_ORPHAN_POST_META ) ) {
|
||||
$deleted['orphan_post_meta'] = self::delete_orphan_post_meta();
|
||||
}
|
||||
|
||||
$deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data']
|
||||
+ $deleted['transients'] + $deleted['orphan_post_meta'];
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $target selects $bucket (i.e. target=all or target=bucket).
|
||||
*
|
||||
* @param string $target Selected target.
|
||||
* @param string $bucket Bucket constant.
|
||||
* @return bool
|
||||
*/
|
||||
private static function target_includes( string $target, string $bucket ): bool {
|
||||
return self::TARGET_ALL === $target || $bucket === $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate target parameter.
|
||||
*
|
||||
* @param string $target Target to validate.
|
||||
* @return void
|
||||
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
|
||||
*/
|
||||
private static function assert_valid_target( string $target ): void {
|
||||
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
|
||||
return;
|
||||
}
|
||||
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->commentmeta is WP-managed; meta_key patterns are static class constants.
|
||||
|
||||
/**
|
||||
* Count `_wxr_import_*` rows in wp_commentmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_wxr_import(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `_wxr_import_*` rows from wp_commentmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_wxr_import(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `_2meet_demo_*` rows in wp_commentmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_demo_data(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `_2meet_demo_*` rows from wp_commentmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_demo_data(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count rows matching transient meta_key patterns in wp_commentmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows matching transient meta_key patterns from wp_commentmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count orphan post-meta keys in wp_commentmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_orphan_post_meta(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
$placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) );
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key IN ({$placeholders})",
|
||||
...self::ORPHAN_POST_META_KEYS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete orphan post-meta keys from wp_commentmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_orphan_post_meta(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->commentmeta;
|
||||
$placeholders = implode( ',', array_fill( 0, count( self::ORPHAN_POST_META_KEYS ), '%s' ) );
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE meta_key IN ({$placeholders})",
|
||||
...self::ORPHAN_POST_META_KEYS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin coexistence manager for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin coexistence manager.
|
||||
*
|
||||
* Detects active plugins and determines whether WPDO should register
|
||||
* its interceptors or defer to existing plugins.
|
||||
*/
|
||||
class TMDO_Compatibility {
|
||||
|
||||
/**
|
||||
* Cached detection results.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static array $detected = array();
|
||||
|
||||
/**
|
||||
* Run all compatibility checks. Called once during TMDO_Core::run().
|
||||
*
|
||||
* @return array Detection results.
|
||||
*/
|
||||
public static function check(): array {
|
||||
if ( ! empty( self::$detected ) ) {
|
||||
return self::$detected;
|
||||
}
|
||||
|
||||
self::$detected = array(
|
||||
'hpct_active' => self::is_hpct_active(),
|
||||
'hpct_imported' => self::is_hpct_imported(),
|
||||
'hivepress' => self::is_hivepress_active(),
|
||||
'woocommerce' => self::is_woocommerce_active(),
|
||||
);
|
||||
|
||||
return self::$detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can WPDO register its HPCT-inherited module interceptors?
|
||||
*
|
||||
* True when:
|
||||
* - hp-custom-tables is NOT active, OR
|
||||
* - hp-custom-tables IS active but already imported into WPDO.
|
||||
*/
|
||||
public static function can_register_hpct_hooks(): bool {
|
||||
$compat = self::check();
|
||||
|
||||
// HPCT not active → WPDO can freely register.
|
||||
if ( ! $compat['hpct_active'] ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HPCT is active but already imported → WPDO takes over.
|
||||
if ( $compat['hpct_imported'] ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HPCT is active and NOT imported → defer to HPCT.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
||||
*
|
||||
* Zone interceptors are always safe to register — they operate on
|
||||
* WPDO's own tables and do not conflict with HPCT.
|
||||
*/
|
||||
public static function can_register_zone_hooks(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should WPDO show an admin notice about HPCT import?
|
||||
*/
|
||||
public static function should_show_hpct_notice(): bool {
|
||||
$compat = self::check();
|
||||
return $compat['hpct_active'] && ! $compat['hpct_imported'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is HivePress core active?
|
||||
*/
|
||||
public static function is_hivepress_active(): bool {
|
||||
return class_exists( 'HivePress\Core' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Is WooCommerce active?
|
||||
*/
|
||||
public static function is_woocommerce_active(): bool {
|
||||
return class_exists( 'WooCommerce' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
||||
*
|
||||
* LatePoint exposes the OsBookingHelper class and `latepoint_*` action
|
||||
* hooks. Detect via either the helper class or the version constant.
|
||||
*/
|
||||
public static function is_latepoint_active(): bool {
|
||||
return class_exists( 'OsBookingHelper' )
|
||||
|| defined( 'LATEPOINT_VERSION' )
|
||||
|| function_exists( 'latepoint_get_settings' );
|
||||
}
|
||||
|
||||
// ── Detection Methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Is HP Custom Tables plugin active (loaded)?
|
||||
*
|
||||
* Checks for the HPCT_Loader class, which is always loaded
|
||||
* when hp-custom-tables is active.
|
||||
*/
|
||||
public static function is_hpct_active(): bool {
|
||||
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Has HPCT been imported into WPDO?
|
||||
*/
|
||||
private static function is_hpct_imported(): bool {
|
||||
return (bool) get_option( 'wpdo_hpct_imported', false );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Conflict_Monitor — production-time conflict detection for WPDO.
|
||||
*
|
||||
* Wraps TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() and the UAE-port
|
||||
* TMDO_Conflict_Detector (UAEPG cross-plugin scan) into a single facade with:
|
||||
*
|
||||
* - admin_init scan + admin_notices warning when conflicts detected
|
||||
* - admin_bar warning chip when conflicts > 0 (Part C.1 enforcer requirement)
|
||||
* - Persistent log to wpdo_audit table for ops review
|
||||
* - Single-source-of-truth `get_all_conflicts()` for CLI / REST surfaces
|
||||
*
|
||||
* This wrapper is the live monitor; the engine/class-tmdo-conflict-detector.php
|
||||
* is the deeper UAEPG-aware scanner. The split keeps WPDO callable without
|
||||
* UAEPG present.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches.
|
||||
|
||||
/**
|
||||
* Production conflict monitor with admin surface integration.
|
||||
*/
|
||||
final class TMDO_Conflict_Monitor {
|
||||
|
||||
/**
|
||||
* Cached conflict list per request.
|
||||
*
|
||||
* @var array<int, array{type:string, hook?:string, priority?:int, callback?:string, entity_type?:string, meta_key?:string}>|null
|
||||
*/
|
||||
private static ?array $cache = null;
|
||||
|
||||
/**
|
||||
* Register all hooks for production monitor surface.
|
||||
*
|
||||
* Called from TMDO_Core::run() after all interceptors register.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_hooks(): void {
|
||||
// init:30 — runs after both wpdo_register_fields (init:20) and the
|
||||
// UAE-port Conflict_Detector (init:25), so we observe a complete picture.
|
||||
add_action( 'init', array( self::class, 'scan' ), 30 );
|
||||
|
||||
if ( is_admin() ) {
|
||||
add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) );
|
||||
add_action( 'admin_bar_menu', array( self::class, 'maybe_render_admin_bar' ), 999 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a full scan and cache the result for the request.
|
||||
*
|
||||
* Aggregates findings from:
|
||||
* 1. TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() — same-hook callback overlap
|
||||
* 2. TMDO_Conflict_Detector::scan() — UAEPG cross-plugin field overlap (if present)
|
||||
*
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public static function scan(): array {
|
||||
if ( null !== self::$cache ) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$conflicts = array();
|
||||
|
||||
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
|
||||
foreach ( TMDO_Hook_Bus_Bridge::detect_intra_wpdo_conflicts() as $finding ) {
|
||||
$conflicts[] = array_merge( array( 'type' => 'hook_overlap' ), $finding );
|
||||
}
|
||||
}
|
||||
|
||||
if ( class_exists( 'TMDO_Conflict_Detector' ) ) {
|
||||
foreach ( TMDO_Conflict_Detector::scan() as $finding ) {
|
||||
$conflicts[] = array_merge( array( 'type' => 'uaepg_overlap' ), $finding );
|
||||
}
|
||||
}
|
||||
|
||||
self::$cache = $conflicts;
|
||||
|
||||
// Persist a single summary row to wpdo_audit when conflicts exist.
|
||||
if ( $conflicts ) {
|
||||
self::persist_audit_summary( $conflicts );
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached conflict list, scanning lazily if needed.
|
||||
*
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public static function get_all_conflicts(): array {
|
||||
return self::$cache ?? self::scan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict count by type.
|
||||
*
|
||||
* @return array{total:int, hook_overlap:int, uaepg_overlap:int}
|
||||
*/
|
||||
public static function get_summary(): array {
|
||||
$conflicts = self::get_all_conflicts();
|
||||
$summary = array(
|
||||
'total' => count( $conflicts ),
|
||||
'hook_overlap' => 0,
|
||||
'uaepg_overlap' => 0,
|
||||
);
|
||||
foreach ( $conflicts as $c ) {
|
||||
$type = $c['type'] ?? 'unknown';
|
||||
if ( isset( $summary[ $type ] ) ) {
|
||||
++$summary[ $type ];
|
||||
}
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset request cache. Test helper.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_cache(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an admin notice when conflicts are present.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_render_admin_notice(): void {
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
$summary = self::get_summary();
|
||||
if ( $summary['total'] === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
/* translators: %d: conflict count */
|
||||
esc_html__( 'WPDO Conflict Detector:偵測到 %d 個欄位 / hook 衝突 — 可能造成資料靜默遺失。', '2meet-data-optimizer' ),
|
||||
$summary['total']
|
||||
);
|
||||
$link = esc_url( admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ) );
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||||
esc_html( $message ),
|
||||
esc_url( $link ),
|
||||
esc_html__( '查看詳情', '2meet-data-optimizer' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a warning chip in the admin bar.
|
||||
*
|
||||
* @param mixed $wp_admin_bar WP_Admin_Bar instance.
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_render_admin_bar( $wp_admin_bar ): void {
|
||||
if ( ! is_object( $wp_admin_bar ) || ! method_exists( $wp_admin_bar, 'add_node' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
$summary = self::get_summary();
|
||||
if ( $summary['total'] === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'id' => 'wpdo-conflicts',
|
||||
'title' => sprintf(
|
||||
/* translators: %d: conflict count */
|
||||
'⚠️ ' . esc_html__( 'Anti-EAV: %d conflicts', '2meet-data-optimizer' ),
|
||||
$summary['total']
|
||||
),
|
||||
'href' => admin_url( 'tools.php?page=wp-data-optimizer&tab=conflicts' ),
|
||||
'meta' => array( 'class' => 'wpdo-conflict-warning' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a summary row to wpdo_audit (silently swallows errors when table missing).
|
||||
*
|
||||
* @param array<int,array> $conflicts All findings.
|
||||
* @return void
|
||||
*/
|
||||
private static function persist_audit_summary( array $conflicts ): void {
|
||||
try {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_audit';
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'ts' => current_time( 'mysql' ),
|
||||
'user_id' => 0,
|
||||
'entity_type' => 'system',
|
||||
'entity_id' => 0,
|
||||
'meta_key' => '',
|
||||
'op' => 'conflict_scan',
|
||||
'value_before' => null,
|
||||
'value_after' => wp_json_encode( $conflicts ),
|
||||
'source' => 'monitor',
|
||||
'trace_id' => self::generate_trace_id(),
|
||||
),
|
||||
array( '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
// wpdo_audit not yet installed (pre-v2 upgrade) — skip silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID-like trace id (v4-ish, no external deps).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function generate_trace_id(): string {
|
||||
// PHP 8.1+ random_bytes is always available.
|
||||
try {
|
||||
$bytes = random_bytes( 16 );
|
||||
} catch ( \Throwable $e ) {
|
||||
$bytes = pack( 'H*', md5( (string) microtime( true ) . wp_generate_password( 16, false ) ) );
|
||||
}
|
||||
$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 );
|
||||
$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 );
|
||||
return vsprintf(
|
||||
'%s%s-%s-%s-%s-%s%s%s',
|
||||
str_split( bin2hex( $bytes ), 4 )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
<?php
|
||||
/**
|
||||
* Main loader for WP Data Optimizer components.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loader — bootstraps all WPDO components.
|
||||
*
|
||||
* Loaded at plugins_loaded priority 4 (before HPCT at priority 5).
|
||||
*/
|
||||
class TMDO_Core {
|
||||
|
||||
/**
|
||||
* Boot the plugin.
|
||||
*/
|
||||
public function run(): void {
|
||||
// Schema upgrade check.
|
||||
TMDO_Installer::maybe_upgrade();
|
||||
|
||||
// Run compatibility checks.
|
||||
$compat = TMDO_Compatibility::check();
|
||||
|
||||
// Fire the schema registry hook so integrations can register their fields.
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
|
||||
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
|
||||
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
|
||||
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
|
||||
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
|
||||
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
|
||||
// arrive too late and the do_action below misses them — observed via
|
||||
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
|
||||
// register() is side-effect-free aside from add_action(), so pre-registering
|
||||
// is provably safe (no hidden dependency on later-state global init).
|
||||
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
|
||||
// via its own bridge class. Other partners still pre-registered here pending
|
||||
// their own decentralized migrations (R1 continuation in v2.2.x).
|
||||
$partner_integrations = array(
|
||||
'TMDO_Infocards',
|
||||
'TMDO_Bookings',
|
||||
'TMDO_Quotation',
|
||||
'TMDO_Mobile_Bridge',
|
||||
'TMDO_Collab',
|
||||
'TMDO_Playlist',
|
||||
'TMDO_WooCommerce',
|
||||
'TMDO_Member_Fields',
|
||||
'TMDO_Post_Fields',
|
||||
'TMDO_Hivepress_Term_Comment_Fields',
|
||||
);
|
||||
foreach ( $partner_integrations as $cls ) {
|
||||
if ( class_exists( $cls ) ) {
|
||||
call_user_func( array( $cls, 'register' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: wpdo_register_fields
|
||||
*
|
||||
* Plugins register their meta_key → zone mappings here.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry The schema registry instance.
|
||||
*/
|
||||
do_action( 'wpdo_register_fields', $registry );
|
||||
|
||||
// v2.1.3 R2 safety net: re-fire on init:1 to catch partners that registered
|
||||
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
||||
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
||||
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
||||
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
|
||||
//
|
||||
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
||||
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
||||
// fields. TMDO_Entity_Registry::register_group() has internal dedup. After all
|
||||
// registrations land, kick `TMDO_Schema_Manager::process_pending_migrations()` once
|
||||
// per request to materialize any newly-defined entity flat tables (idempotent via
|
||||
// schema_hash compare — no DDL when unchanged).
|
||||
if ( ! did_action( 'wpdo_late_bind_setup' ) ) {
|
||||
add_action(
|
||||
'init',
|
||||
static function () use ( $registry ) {
|
||||
do_action( 'wpdo_register_fields', $registry );
|
||||
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
||||
}
|
||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
||||
}
|
||||
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
||||
if ( ! empty( $pending ) ) {
|
||||
TMDO_Schema_Manager::process_pending_migrations();
|
||||
}
|
||||
}
|
||||
do_action( 'wpdo_late_bind_setup' );
|
||||
},
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// HivePress 整合已移至 2meet-data-optimizer-hivepress-addon。
|
||||
// AddOn bootstrap 自行偵測 + register。Core 完全 HP-agnostic。
|
||||
|
||||
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
||||
TMDO_Cache_Layer::register_hooks();
|
||||
|
||||
// Register HPCT-inherited module interceptors if allowed.
|
||||
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
|
||||
$this->register_hpct_interceptors();
|
||||
}
|
||||
|
||||
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
|
||||
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
|
||||
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
|
||||
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
|
||||
}
|
||||
|
||||
// Zone interceptors are always safe to register.
|
||||
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
||||
$this->register_zone_hooks();
|
||||
}
|
||||
|
||||
// v2.0.0: register entity adapters into Entity Registry. Adapters are
|
||||
// REGISTERED unconditionally (so TMDO_API::get_entity / Hook Bus can
|
||||
// find them), but Hook Bus only initializes when feature flag is on
|
||||
// (default off — preserves v1.3.x behaviour).
|
||||
$this->register_entity_adapters();
|
||||
|
||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||
|
||||
// v2.0.0: fire the Custom Table Registry hook so partner plugins can register.
|
||||
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
||||
|
||||
// v2.0.0: production-time conflict monitor (admin notice + admin bar).
|
||||
if ( class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
||||
TMDO_Conflict_Monitor::register_hooks();
|
||||
}
|
||||
|
||||
// v2.0.x: partner plugin integrations have been pre-registered earlier
|
||||
// (see top of run() before the wpdo_register_fields do_action). Removing
|
||||
// the duplicate late-binding block — see commit log for time-ordering bug.
|
||||
|
||||
// Schedule cron events.
|
||||
$this->schedule_cron();
|
||||
|
||||
// Admin notice for HPCT import.
|
||||
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
|
||||
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
|
||||
}
|
||||
|
||||
// v2.6.6: async entity backfill cron handler.
|
||||
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
||||
|
||||
// v2.6.7: user stress-test batch cron handler.
|
||||
add_action( TMDO_User_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_stress_test_batch' ) );
|
||||
|
||||
// v2.11.4: post stress-test batch cron handler.
|
||||
if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
||||
add_action( TMDO_Post_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_post_stress_test_batch' ) );
|
||||
}
|
||||
|
||||
// v2.13.0: term stress-test batch cron handler.
|
||||
if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
||||
add_action( TMDO_Term_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_term_stress_test_batch' ) );
|
||||
}
|
||||
|
||||
// v2.13.1: comment stress-test batch cron handler.
|
||||
if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
||||
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
||||
}
|
||||
|
||||
// v2.11.5: HivePress per-post transient cache → wp_options reroute.
|
||||
// Filters registered on `init` so HivePress's own bootstrap (plugins_loaded:5)
|
||||
// runs first and our filter chain catches the actual cache writes happening
|
||||
// during save_post and term-cache rebuilds.
|
||||
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
||||
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
|
||||
}
|
||||
|
||||
// v2.12.1: Term + Comment garbage write-time filter
|
||||
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
|
||||
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
|
||||
add_action( 'init', array( 'TMDO_Term_Comment_Garbage_Filter', 'init' ), 5 );
|
||||
}
|
||||
|
||||
// v2.12.3: WooCommerce term count cache → wp_options reroute
|
||||
// (product_count_<taxonomy> rows out of wp_termmeta).
|
||||
if ( class_exists( 'TMDO_WC_Term_Count_Filter' ) ) {
|
||||
add_action( 'init', array( 'TMDO_WC_Term_Count_Filter', 'init' ), 5 );
|
||||
}
|
||||
|
||||
// v2.12.4: Term + Comment misc bucket (catch-all flat for unregistered keys).
|
||||
// Filters at priority 99 (LAST in chain) so all other v2.12.x filters
|
||||
// + Hook Bus get first crack; only truly unhandled keys land here.
|
||||
if ( class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
|
||||
add_action( 'init', array( 'TMDO_Term_Comment_Misc_Bucket', 'init' ), 5 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: 執行壓力測試的單一批次(v2.6.7)。
|
||||
*/
|
||||
public static function run_stress_test_batch(): void {
|
||||
if ( class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
||||
TMDO_User_Stress_Tester::run_batch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: post entity 壓力測試單一批次(v2.11.4)。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_post_stress_test_batch(): void {
|
||||
if ( class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
||||
TMDO_Post_Stress_Tester::run_batch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: term entity 壓力測試單一批次(v2.13.0)。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_term_stress_test_batch(): void {
|
||||
if ( class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
||||
TMDO_Term_Stress_Tester::run_batch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: comment entity 壓力測試單一批次(v2.13.1)。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_comment_stress_test_batch(): void {
|
||||
if ( class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
||||
TMDO_Comment_Stress_Tester::run_batch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the four entity adapters into TMDO_Entity_Registry.
|
||||
*
|
||||
* Registration is unconditional but write/read paths only activate when
|
||||
* the corresponding module is in a write-active or read-custom state
|
||||
* (feature flag controlled).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function register_entity_adapters(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
|
||||
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||
TMDO_Entity_Registry::register_adapter( 'term', new TMDO_Adapter_Term() );
|
||||
TMDO_Entity_Registry::register_adapter( 'comment', new TMDO_Adapter_Comment() );
|
||||
|
||||
/**
|
||||
* Action: wpdo_register_entity_fields
|
||||
*
|
||||
* Partner plugins register their entity meta_key → adapter mappings here.
|
||||
* Mirrors `wpdo_register_fields` but for non-postmeta entities.
|
||||
*
|
||||
* @since 2.0.0
|
||||
*/
|
||||
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default HivePress field mappings.
|
||||
*
|
||||
* These are the known HivePress meta_keys and their zone classifications.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry The schema registry instance.
|
||||
* @return void
|
||||
*/
|
||||
private function register_hivepress_defaults( TMDO_Schema_Registry $registry ): void {
|
||||
// Zone A (Hot) — Listing search/filter fields.
|
||||
$registry->register_many(
|
||||
'hivepress',
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_price',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_featured',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_featured',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_verified',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_verified',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_expired_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_expired_time',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_featured_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_featured_time',
|
||||
),
|
||||
// Vendor hot fields.
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_verified',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_verified',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_hourly_rate',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_hourly_rate',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_rating_count',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'int(11) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_rating_count',
|
||||
'indexed' => false,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Zone C (Cold) — Profile/display fields.
|
||||
$registry->register_many(
|
||||
'hivepress',
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_description',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_description',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_listing',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// ── HivePress extension fields (filled the 47% → 94% coverage gap) ──
|
||||
// Reviews extension (hp_review post type).
|
||||
$registry->register_many(
|
||||
'hivepress-reviews',
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_review',
|
||||
'meta_key' => 'hp_rating',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_rating',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_rating',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(3,2) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_rating',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_review',
|
||||
'meta_key' => 'hp_text',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_review',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Bookings extension (hp_booking post type).
|
||||
$registry->register_many(
|
||||
'hivepress-bookings',
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_start_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_start_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_end_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_end_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_status',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(20) NOT NULL DEFAULT ''",
|
||||
'column' => 'hp_status',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_booking_enabled',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_booking_enabled',
|
||||
'indexed' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Marketplace / Requests extension.
|
||||
$registry->register_many(
|
||||
'hivepress-marketplace',
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_request',
|
||||
'meta_key' => 'hp_request_category',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_request_category',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_offer',
|
||||
'meta_key' => 'hp_amount',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_amount',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_offer',
|
||||
'meta_key' => 'hp_request',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_request',
|
||||
'indexed' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register HPCT-inherited module interceptors and query interceptors.
|
||||
*/
|
||||
private function register_hpct_interceptors(): void {
|
||||
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
||||
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
|
||||
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
||||
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
|
||||
}
|
||||
|
||||
/**
|
||||
* Register zone-specific hooks.
|
||||
*/
|
||||
private function register_zone_hooks(): void {
|
||||
// Sync Bridge: Zone-aware dual-write + read routing for all zone fields.
|
||||
$sync_bridge = new TMDO_Sync_Bridge();
|
||||
$sync_bridge->register_hooks();
|
||||
|
||||
// Query Router: Zone A flat-column JOIN rewrite for WP_Query.
|
||||
$query_router = new TMDO_Query_Router();
|
||||
$query_router->register_hooks();
|
||||
|
||||
// v2.10.1 Post Entity Query Router — only registers when post mode
|
||||
// is shadow_read or aeav_only (zero overhead in disabled/dual_write).
|
||||
// is_router_active() guards against early-bind issues; once mode flips
|
||||
// to reads_from_flat the hooks fire on subsequent requests.
|
||||
if ( class_exists( 'TMDO_Post_Query_Router' ) && TMDO_Post_Query_Router::is_router_active() ) {
|
||||
$post_router = new TMDO_Post_Query_Router();
|
||||
$post_router->register_hooks();
|
||||
}
|
||||
|
||||
// Zone B: Warm cleanup cron handler.
|
||||
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
||||
|
||||
// v2.10.3: Post shadow_read verifier cron handler. Only fires when
|
||||
// post mode is shadow_read (verifier::cron_tick() is internally gated).
|
||||
if ( class_exists( 'TMDO_Post_Shadow_Verifier' ) ) {
|
||||
add_action( TMDO_Post_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Post_Shadow_Verifier', 'cron_tick' ) );
|
||||
}
|
||||
|
||||
// v2.12.5: Term + Comment shadow verifier cron handler. Only fires when
|
||||
// either term or comment mode is shadow_read (gated internally).
|
||||
if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) {
|
||||
add_action( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK, array( 'TMDO_Term_Comment_Shadow_Verifier', 'cron_tick' ) );
|
||||
}
|
||||
|
||||
// v2.10.3: react to bridge mode changes — register/unregister verifier
|
||||
// cron when post mode crosses the shadow_read threshold.
|
||||
add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_post_verifier_cron' ), 10, 3 );
|
||||
|
||||
// v2.12.5: same listener for term/comment.
|
||||
add_action( 'wpdo_bridge_mode_changed', array( $this, 'maybe_toggle_term_comment_verifier_cron' ), 10, 3 );
|
||||
|
||||
// Zone D: Archive sweep cron handler.
|
||||
add_action( 'wpdo_archive_sweep', array( $this, 'sweep_archive_zone' ) );
|
||||
|
||||
// Errors GC cron handler — keeps wp_wpdo_errors bounded (default 90 days).
|
||||
add_action( 'wpdo_errors_gc', array( $this, 'gc_errors' ) );
|
||||
|
||||
// Monthly health-snapshot cron handler.
|
||||
add_action( 'wpdo_health_snapshot_monthly', array( $this, 'monthly_health_snapshot' ) );
|
||||
|
||||
// v2.3.0 M6: daily health probe cron.
|
||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
|
||||
|
||||
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
||||
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
||||
|
||||
// v2.5.0 M13: daily FSM automator cron (opt-in).
|
||||
add_action( 'wpdo_fsm_automator_run', array( 'TMDO_FSM_Automator', 'run' ) );
|
||||
|
||||
// v2.6.2: daily site-wide EAV health metrics snapshot.
|
||||
TMDO_Site_Metrics_Collector::register();
|
||||
|
||||
// Add 'monthly' cron interval (WP only ships hourly/twicedaily/daily).
|
||||
add_filter(
|
||||
'cron_schedules',
|
||||
static function ( $schedules ) {
|
||||
if ( ! isset( $schedules['monthly'] ) ) {
|
||||
$schedules['monthly'] = array(
|
||||
'interval' => 30 * DAY_IN_SECONDS,
|
||||
'display' => __( 'Once Monthly', '2meet-data-optimizer' ),
|
||||
);
|
||||
}
|
||||
return $schedules;
|
||||
}
|
||||
);
|
||||
|
||||
// Admin notice when DB grows > 10% MoM.
|
||||
if ( is_admin() ) {
|
||||
add_action( 'admin_notices', array( $this, 'render_health_alert_notice' ) );
|
||||
}
|
||||
|
||||
// Zone B/D: Listing stats integration(已搬到 2meet-data-optimizer-hivepress-addon)。
|
||||
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||
TMDO_Listing_Stats::register_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule cron events if not already scheduled.
|
||||
*/
|
||||
private function schedule_cron(): void {
|
||||
if ( ! wp_next_scheduled( 'wpdo_warm_cleanup' ) ) {
|
||||
wp_schedule_event( time(), 'hourly', 'wpdo_warm_cleanup' );
|
||||
}
|
||||
|
||||
// v2.10.3: Post shadow_read verifier — only schedule when post mode
|
||||
// is shadow_read (auto-cleared in mode_changed listener below).
|
||||
if ( class_exists( 'TMDO_Post_Shadow_Verifier' )
|
||||
&& class_exists( 'TMDO_Mode_Manager' )
|
||||
&& 'shadow_read' === TMDO_Mode_Manager::get( 'post' )
|
||||
&& ! wp_next_scheduled( TMDO_Post_Shadow_Verifier::CRON_HOOK )
|
||||
) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Post_Shadow_Verifier::CRON_HOOK );
|
||||
}
|
||||
|
||||
// v2.12.5: Term + Comment shadow verifier — schedule when EITHER term
|
||||
// or comment mode is shadow_read.
|
||||
if ( class_exists( 'TMDO_Term_Comment_Shadow_Verifier' )
|
||||
&& class_exists( 'TMDO_Mode_Manager' )
|
||||
&& ( 'shadow_read' === TMDO_Mode_Manager::get( 'term' )
|
||||
|| 'shadow_read' === TMDO_Mode_Manager::get( 'comment' ) )
|
||||
&& ! wp_next_scheduled( TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK )
|
||||
) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK );
|
||||
}
|
||||
|
||||
if ( ! wp_next_scheduled( 'wpdo_archive_sweep' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'wpdo_archive_sweep' );
|
||||
}
|
||||
|
||||
// Daily errors GC — keep 90 days, prevents wpdo_errors growing unbounded.
|
||||
if ( ! wp_next_scheduled( 'wpdo_errors_gc' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpdo_errors_gc' );
|
||||
}
|
||||
|
||||
// Monthly health snapshot — auto-runs first of each month.
|
||||
if ( ! wp_next_scheduled( 'wpdo_health_snapshot_monthly' ) ) {
|
||||
// Schedule at 03:00 on the 1st of next month.
|
||||
$next = strtotime( 'first day of next month 03:00' );
|
||||
wp_schedule_event( $next ?: ( time() + 30 * DAY_IN_SECONDS ), 'monthly', 'wpdo_health_snapshot_monthly' );
|
||||
}
|
||||
|
||||
// v2.3.0 M6: daily health probe at 03:30 UTC (avoids hourly warm cleanup).
|
||||
if ( ! wp_next_scheduled( 'wpdo_daily_health_check' ) ) {
|
||||
$tomorrow_330 = strtotime( 'tomorrow 03:30 UTC' );
|
||||
wp_schedule_event( $tomorrow_330 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_daily_health_check' );
|
||||
}
|
||||
|
||||
// v2.3.0 M6: daily snapshot prune at 04:00 UTC.
|
||||
if ( ! wp_next_scheduled( 'wpdo_snapshot_prune_daily' ) ) {
|
||||
$tomorrow_400 = strtotime( 'tomorrow 04:00 UTC' );
|
||||
wp_schedule_event( $tomorrow_400 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_snapshot_prune_daily' );
|
||||
}
|
||||
|
||||
// v2.5.0 M13: FSM Automator daily cron at 04:30 UTC (after prune).
|
||||
if ( ! wp_next_scheduled( 'wpdo_fsm_automator_run' ) ) {
|
||||
$tomorrow_430 = strtotime( 'tomorrow 04:30 UTC' );
|
||||
wp_schedule_event( $tomorrow_430 ?: ( time() + DAY_IN_SECONDS ), 'daily', 'wpdo_fsm_automator_run' );
|
||||
}
|
||||
|
||||
// v2.6.2: daily site metrics collection at 05:00 UTC.
|
||||
if ( ! wp_next_scheduled( TMDO_Site_Metrics_Collector::CRON_HOOK ) ) {
|
||||
$tomorrow_500 = strtotime( 'tomorrow 05:00 UTC' );
|
||||
wp_schedule_event( $tomorrow_500 ?: ( time() + DAY_IN_SECONDS ), 'daily', TMDO_Site_Metrics_Collector::CRON_HOOK );
|
||||
}
|
||||
|
||||
// Zone B: Flush view counts to postmeta (hourly)(已搬到 hivepress addon)。
|
||||
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||
TMDO_Listing_Stats::schedule_cron();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: prune wpdo_errors > 90 days.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function gc_errors(): void {
|
||||
TMDO_Logger::purge( 90 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: monthly health snapshot via WP_CLI subprocess.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function monthly_health_snapshot(): void {
|
||||
// Only do anything when WP-CLI is invocable in the current request — the
|
||||
// cron runs via `wp cron event run` typically. Otherwise, dispatch via
|
||||
// shell.
|
||||
if ( ! class_exists( 'TMDO_CLI' ) || ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
||||
return;
|
||||
}
|
||||
$cli = new TMDO_CLI();
|
||||
$cli->health_snapshot( array(), array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render admin notice when DB has grown > 10% since last snapshot.
|
||||
*
|
||||
* Reads `wpdo_health_alert` option set by `health_snapshot` CLI when
|
||||
* threshold breached. Auto-dismissed after fix or next snapshot.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_health_alert_notice(): void {
|
||||
$alert = (string) get_option( 'wpdo_health_alert', '' );
|
||||
if ( '' === $alert ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
// Allow user to dismiss until next snapshot.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display.
|
||||
if ( ! empty( $_GET['wpdo_dismiss_health_alert'] ) ) {
|
||||
$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['_wpnonce'] ) ) : '';
|
||||
if ( wp_verify_nonce( $nonce, 'wpdo_dismiss_health_alert' ) ) {
|
||||
delete_option( 'wpdo_health_alert' );
|
||||
return;
|
||||
}
|
||||
}
|
||||
$dismiss_url = wp_nonce_url(
|
||||
add_query_arg( 'wpdo_dismiss_health_alert', '1' ),
|
||||
'wpdo_dismiss_health_alert'
|
||||
);
|
||||
?>
|
||||
<div class="notice notice-warning">
|
||||
<p>
|
||||
<strong>WP Data Optimizer:</strong>
|
||||
<?php echo esc_html( $alert ); ?>
|
||||
<a href="<?php echo esc_url( $dismiss_url ); ?>"><?php esc_html_e( '關閉', '2meet-data-optimizer' ); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: Clean up expired Zone B (warm) entries.
|
||||
*/
|
||||
public function cleanup_warm_zone(): void {
|
||||
TMDO_Zone_Warm::purge_expired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the post shadow verifier cron event when post mode crosses
|
||||
* the shadow_read threshold (v2.10.3).
|
||||
*
|
||||
* Fires on `wpdo_bridge_mode_changed` action emitted by Mode_Manager::set().
|
||||
*
|
||||
* @param string $entity_type Entity type that changed.
|
||||
* @param string $new_mode New mode value.
|
||||
* @param string $old_mode Previous mode value.
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_toggle_post_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||
if ( 'post' !== $entity_type || ! class_exists( 'TMDO_Post_Shadow_Verifier' ) ) {
|
||||
return;
|
||||
}
|
||||
$hook = TMDO_Post_Shadow_Verifier::CRON_HOOK;
|
||||
if ( 'shadow_read' === $new_mode && ! wp_next_scheduled( $hook ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
|
||||
} elseif ( 'shadow_read' !== $new_mode ) {
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the term/comment shadow verifier cron when EITHER term or
|
||||
* comment mode crosses the shadow_read threshold (v2.12.5).
|
||||
*
|
||||
* @param string $entity_type Entity that changed (term/comment/user/post).
|
||||
* @param string $new_mode New mode.
|
||||
* @param string $old_mode Previous mode (unused).
|
||||
* @return void
|
||||
*/
|
||||
public function maybe_toggle_term_comment_verifier_cron( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||
unset( $old_mode );
|
||||
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
$hook = TMDO_Term_Comment_Shadow_Verifier::CRON_HOOK;
|
||||
|
||||
// Recompute "should run" based on BOTH entities' current modes.
|
||||
$should_run = 'shadow_read' === $new_mode
|
||||
|| 'shadow_read' === TMDO_Mode_Manager::get( 'term' )
|
||||
|| 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
|
||||
|
||||
if ( $should_run && ! wp_next_scheduled( $hook ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', $hook );
|
||||
} elseif ( ! $should_run ) {
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: Archive stale postmeta for trashed posts.
|
||||
*/
|
||||
public function sweep_archive_zone(): void {
|
||||
TMDO_Zone_Archive::sweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render admin notice when hp-custom-tables is detected but not imported.
|
||||
*/
|
||||
public function render_hpct_notice(): void {
|
||||
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
|
||||
?>
|
||||
<div class="notice notice-warning is-dismissible">
|
||||
<p>
|
||||
<strong>WP Data Optimizer:</strong>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: URL to import page */
|
||||
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
|
||||
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* WP Cron handler: process one backfill batch for an entity/group pair.
|
||||
*
|
||||
* Re-schedules itself (via wp_schedule_single_event) until migration is done.
|
||||
*
|
||||
* @param string $entity_type Entity type (user|term|comment).
|
||||
* @param string $group_name Registered group name.
|
||||
*/
|
||||
public function run_entity_backfill_batch( string $entity_type, string $group_name ): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( $entity_type, $group_name );
|
||||
|
||||
if ( ! $result['done'] ) {
|
||||
// Re-schedule next batch (5 s delay to avoid overwhelming DB).
|
||||
wp_schedule_single_event( time() + 5, 'wpdo_entity_backfill_batch', array( $entity_type, $group_name ) );
|
||||
}
|
||||
|
||||
TMDO_Logger::info(
|
||||
'entity_backfill_batch',
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
'migrated' => $result['migrated'],
|
||||
'total' => $result['total'],
|
||||
'done' => $result['done'],
|
||||
'status' => $result['status'],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Crypto — AES-256-GCM authenticated encryption for sensitive option values.
|
||||
*
|
||||
* Derives a site-specific key from AUTH_KEY + SECURE_AUTH_SALT so ciphertext
|
||||
* is useless outside this WordPress installation.
|
||||
*
|
||||
* Storage formats:
|
||||
* - "enc:v2:<base64(iv12 . tag16 . ciphertext)>" (current — AES-256-GCM, AEAD)
|
||||
* - "enc:v1:<base64(iv16 . ciphertext)>" (legacy — AES-256-CBC; read-only)
|
||||
*
|
||||
* Backward compatibility:
|
||||
* - encrypt() always writes v2 GCM
|
||||
* - decrypt() reads BOTH v1 and v2 (transparent migration)
|
||||
* - Values without any "enc:" prefix are returned as-is (existing plaintext
|
||||
* options continue to work until re-saved or migrated explicitly)
|
||||
*
|
||||
* The v1→v2 upgrade can be triggered via:
|
||||
* - WP-CLI: `wp wpdo crypto-migrate`
|
||||
* - Auto-migration during install/upgrade (idempotent best-effort)
|
||||
*
|
||||
* Usage (unchanged from v2.6.4 contract):
|
||||
* TMDO_Crypto::set_option( 'wpdo_slack_webhook', $url ); // writes v2
|
||||
* $url = TMDO_Crypto::get_option( 'wpdo_slack_webhook' ); // reads v1 or v2
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.4 (v1 CBC)
|
||||
* @since 2.15.0 (v2 GCM, AEAD authentication)
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric authenticated encryption helper for sensitive wp_options entries.
|
||||
*/
|
||||
class TMDO_Crypto {
|
||||
|
||||
/** Ciphertext prefix v2 (AES-256-GCM, current). */
|
||||
public const PREFIX_V2 = 'enc:v2:';
|
||||
|
||||
/** Ciphertext prefix v1 (AES-256-CBC, legacy read-only). */
|
||||
public const PREFIX_V1 = 'enc:v1:';
|
||||
|
||||
/** Cipher suite v2 (AEAD — authenticated, tamper-detectable). */
|
||||
private const CIPHER_V2 = 'aes-256-gcm';
|
||||
|
||||
/** Cipher suite v1 (legacy — no authentication). */
|
||||
private const CIPHER_V1 = 'AES-256-CBC';
|
||||
|
||||
/** GCM IV length (12 bytes is the GCM standard / NIST SP 800-38D recommended). */
|
||||
private const IV_LEN_V2 = 12;
|
||||
|
||||
/** CBC IV length (legacy). */
|
||||
private const IV_LEN_V1 = 16;
|
||||
|
||||
/** GCM authentication tag length (16 bytes = 128 bits, the strongest standard). */
|
||||
private const TAG_LEN = 16;
|
||||
|
||||
/**
|
||||
* Derive a 32-byte site-specific key from WordPress auth constants.
|
||||
*
|
||||
* Stable per site → ciphertext written on this site cannot be decrypted
|
||||
* elsewhere. Both v1 and v2 use the same derived key (same secret material,
|
||||
* different cipher) so v1 ciphertext can be read after the v2 upgrade.
|
||||
*
|
||||
* Falls back to a sha256 of ABSPATH when constants are not defined
|
||||
* (unit-test environments). The fallback must be stable per request.
|
||||
*
|
||||
* @return string 32 raw bytes.
|
||||
*/
|
||||
private static function derived_key(): string {
|
||||
$salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : '';
|
||||
$salt .= defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : ABSPATH;
|
||||
return substr( hash_hmac( 'sha256', 'wpdo_notifier_secrets_v1', $salt, true ), 0, 32 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string with AES-256-GCM (v2 format).
|
||||
*
|
||||
* @param string $plaintext Value to encrypt.
|
||||
* @return string Encrypted value with "enc:v2:" prefix, or original on failure.
|
||||
*/
|
||||
public static function encrypt( string $plaintext ): string {
|
||||
if ( '' === $plaintext ) {
|
||||
return '';
|
||||
}
|
||||
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||
return $plaintext;
|
||||
}
|
||||
$iv = random_bytes( self::IV_LEN_V2 );
|
||||
$tag = '';
|
||||
// phpcs:ignore -- $tag is reference output for GCM auth tag.
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER_V2,
|
||||
self::derived_key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LEN
|
||||
);
|
||||
if ( false === $ciphertext ) {
|
||||
return $plaintext;
|
||||
}
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- intentional binary encoding.
|
||||
return self::PREFIX_V2 . base64_encode( $iv . $tag . $ciphertext );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted value (v2 GCM or v1 CBC).
|
||||
*
|
||||
* Dispatches by prefix:
|
||||
* - "enc:v2:..." → AES-256-GCM (auth-tag verified)
|
||||
* - "enc:v1:..." → AES-256-CBC (legacy, no auth)
|
||||
* - other → returned as-is (legacy plaintext)
|
||||
*
|
||||
* @param string $stored Stored option value.
|
||||
* @return string Plaintext, or original value on failure.
|
||||
*/
|
||||
public static function decrypt( string $stored ): string {
|
||||
if ( '' === $stored ) {
|
||||
return $stored;
|
||||
}
|
||||
if ( ! function_exists( 'openssl_decrypt' ) ) {
|
||||
return $stored;
|
||||
}
|
||||
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
|
||||
return self::decrypt_v2( $stored );
|
||||
}
|
||||
if ( str_starts_with( $stored, self::PREFIX_V1 ) ) {
|
||||
return self::decrypt_v1( $stored );
|
||||
}
|
||||
return $stored; // plaintext (legacy).
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt v2 GCM blob (private — dispatched by decrypt()).
|
||||
*
|
||||
* @param string $stored "enc:v2:..." string.
|
||||
* @return string Plaintext or original on auth failure / parse error.
|
||||
*/
|
||||
private static function decrypt_v2( string $stored ): string {
|
||||
$encoded = substr( $stored, strlen( self::PREFIX_V2 ) );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data.
|
||||
$raw = base64_decode( $encoded, true );
|
||||
if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V2 + self::TAG_LEN ) {
|
||||
return $stored;
|
||||
}
|
||||
$iv = substr( $raw, 0, self::IV_LEN_V2 );
|
||||
$tag = substr( $raw, self::IV_LEN_V2, self::TAG_LEN );
|
||||
$ciphertext = substr( $raw, self::IV_LEN_V2 + self::TAG_LEN );
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER_V2,
|
||||
self::derived_key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
return false === $plaintext ? $stored : $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt v1 CBC blob (private — backward compatibility path).
|
||||
*
|
||||
* NOTE: CBC has no authentication. A successful decrypt does NOT prove the
|
||||
* ciphertext is intact. Callers should treat v1 plaintext as "trusted as
|
||||
* much as the surrounding wp_options column was trusted at write time".
|
||||
* The v1→v2 migration upgrades these values to authenticated GCM.
|
||||
*
|
||||
* @param string $stored "enc:v1:..." string.
|
||||
* @return string Plaintext or original on parse error.
|
||||
*/
|
||||
private static function decrypt_v1( string $stored ): string {
|
||||
$encoded = substr( $stored, strlen( self::PREFIX_V1 ) );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own encrypted data.
|
||||
$raw = base64_decode( $encoded, true );
|
||||
if ( false === $raw || strlen( $raw ) <= self::IV_LEN_V1 ) {
|
||||
return $stored;
|
||||
}
|
||||
$iv = substr( $raw, 0, self::IV_LEN_V1 );
|
||||
$ciphertext = substr( $raw, self::IV_LEN_V1 );
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER_V1,
|
||||
self::derived_key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv
|
||||
);
|
||||
return false === $plaintext ? $stored : $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a value and save it to wp_options.
|
||||
*
|
||||
* @param string $option_name Option name.
|
||||
* @param string $plaintext Value to encrypt and store.
|
||||
* @param bool $autoload Whether to autoload (default false — secrets should never autoload).
|
||||
* @return bool Whether the option was updated.
|
||||
*/
|
||||
public static function set_option( string $option_name, string $plaintext, bool $autoload = false ): bool {
|
||||
$encrypted = self::encrypt( $plaintext );
|
||||
return update_option( $option_name, $encrypted, $autoload );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a wp_option and decrypt it.
|
||||
*
|
||||
* @param string $option_name Option name.
|
||||
* @param string $fallback Value returned when option is empty.
|
||||
* @return string Decrypted plaintext (or legacy plaintext, or fallback).
|
||||
*/
|
||||
public static function get_option( string $option_name, string $fallback = '' ): string {
|
||||
$stored = (string) get_option( $option_name, '' );
|
||||
if ( '' === $stored ) {
|
||||
return $fallback;
|
||||
}
|
||||
return self::decrypt( $stored );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored option is encrypted (v1 or v2).
|
||||
*
|
||||
* @param string $option_name Option name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_encrypted( string $option_name ): bool {
|
||||
$stored = get_option( $option_name, '' );
|
||||
if ( ! is_string( $stored ) ) {
|
||||
return false;
|
||||
}
|
||||
return str_starts_with( $stored, self::PREFIX_V2 )
|
||||
|| str_starts_with( $stored, self::PREFIX_V1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format version of a stored option ("v2", "v1", "plaintext", or "empty").
|
||||
*
|
||||
* Non-string option values (arrays / objects) are classified as
|
||||
* "plaintext" since they are not in any encrypted format. This avoids the
|
||||
* "Array to string conversion" warning when wp_options stores serialized
|
||||
* arrays (e.g. `wpdo_features`, `wpdo_bridge_modes`).
|
||||
*
|
||||
* @param string $option_name Option name.
|
||||
* @return string One of: "v2", "v1", "plaintext", "empty".
|
||||
* @since 2.15.0
|
||||
*/
|
||||
public static function format_version( string $option_name ): string {
|
||||
$stored = get_option( $option_name, '' );
|
||||
if ( '' === $stored || null === $stored ) {
|
||||
return 'empty';
|
||||
}
|
||||
if ( ! is_string( $stored ) ) {
|
||||
// Arrays, objects, ints, etc. — never encrypted.
|
||||
return 'plaintext';
|
||||
}
|
||||
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
|
||||
return 'v2';
|
||||
}
|
||||
if ( str_starts_with( $stored, self::PREFIX_V1 ) ) {
|
||||
return 'v1';
|
||||
}
|
||||
return 'plaintext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a single option from v1 (CBC) to v2 (GCM).
|
||||
*
|
||||
* Idempotent — already-v2 options are skipped. Plaintext options are NOT
|
||||
* touched (the caller decides whether to encrypt; this method only handles
|
||||
* format upgrade of already-encrypted values).
|
||||
*
|
||||
* @param string $option_name Option name.
|
||||
* @return string One of: "migrated", "already_v2", "plaintext_skipped",
|
||||
* "empty", "decrypt_failed", "encrypt_failed", "no_op".
|
||||
* @since 2.15.0
|
||||
*/
|
||||
public static function migrate_option_v1_to_v2( string $option_name ): string {
|
||||
$stored = get_option( $option_name, '' );
|
||||
if ( '' === $stored || null === $stored ) {
|
||||
return 'empty';
|
||||
}
|
||||
if ( ! is_string( $stored ) ) {
|
||||
// Arrays / objects are never v1 ciphertext.
|
||||
return 'plaintext_skipped';
|
||||
}
|
||||
if ( str_starts_with( $stored, self::PREFIX_V2 ) ) {
|
||||
return 'already_v2';
|
||||
}
|
||||
if ( ! str_starts_with( $stored, self::PREFIX_V1 ) ) {
|
||||
return 'plaintext_skipped';
|
||||
}
|
||||
// Decrypt v1.
|
||||
$plaintext = self::decrypt_v1( $stored );
|
||||
if ( $plaintext === $stored ) {
|
||||
// decrypt_v1() returns original on failure.
|
||||
return 'decrypt_failed';
|
||||
}
|
||||
// Re-encrypt as v2.
|
||||
$reencrypted = self::encrypt( $plaintext );
|
||||
if ( ! str_starts_with( $reencrypted, self::PREFIX_V2 ) ) {
|
||||
return 'encrypt_failed';
|
||||
}
|
||||
// Preserve current autoload flag (don't accidentally flip).
|
||||
$autoload = wp_cache_get( 'notoptions', 'options' ); // not used; kept here as reference for the option API contract.
|
||||
$ok = update_option( $option_name, $reencrypted, false );
|
||||
return $ok ? 'migrated' : 'no_op';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-migrate every wp_option whose name starts with `wpdo_` (or the
|
||||
* supplied prefix) from v1 CBC to v2 GCM.
|
||||
*
|
||||
* Idempotent: already-v2 / plaintext / empty options are skipped without
|
||||
* error. Returns counts so the caller can log / display progress.
|
||||
*
|
||||
* @param string $option_prefix Prefix to scan (default 'wpdo_').
|
||||
* @return array{
|
||||
* scanned:int, migrated:int, already_v2:int, plaintext:int, empty:int,
|
||||
* failed:int, errors:array<string,string>
|
||||
* }
|
||||
* @since 2.15.0
|
||||
*/
|
||||
public static function migrate_v1_to_v2( string $option_prefix = 'wpdo_' ): array {
|
||||
global $wpdb;
|
||||
$counts = array(
|
||||
'scanned' => 0,
|
||||
'migrated' => 0,
|
||||
'already_v2' => 0,
|
||||
'plaintext' => 0,
|
||||
'empty' => 0,
|
||||
'failed' => 0,
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
$option_names = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like( $option_prefix ) . '%'
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( (array) $option_names as $option_name ) {
|
||||
++$counts['scanned'];
|
||||
$result = self::migrate_option_v1_to_v2( $option_name );
|
||||
switch ( $result ) {
|
||||
case 'migrated':
|
||||
++$counts['migrated'];
|
||||
break;
|
||||
case 'already_v2':
|
||||
++$counts['already_v2'];
|
||||
break;
|
||||
case 'plaintext_skipped':
|
||||
++$counts['plaintext'];
|
||||
break;
|
||||
case 'empty':
|
||||
++$counts['empty'];
|
||||
break;
|
||||
default:
|
||||
++$counts['failed'];
|
||||
$counts['errors'][ $option_name ] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Table Registry — third-party plugin custom-table awareness.
|
||||
*
|
||||
* Allows partner plugins (2meet-courses, 2meet-bookings, 2meet-infocards, etc.)
|
||||
* to register their own custom tables so WPDO can include them in:
|
||||
* - `wp wpdo doctor` health checks
|
||||
* - `wp wpdo benchmark --custom-tables`
|
||||
* - Backup/cleanup tooling
|
||||
* - Site monitor metrics
|
||||
*
|
||||
* Solves audit finding R-3 (Custom Table Provider missing).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry for partner plugin custom tables.
|
||||
*
|
||||
* Usage (from a partner plugin):
|
||||
* add_action( 'wpdo_register_custom_tables', function ( TMDO_Custom_Table_Registry $r ) {
|
||||
* $r->register( '2meet-courses', [
|
||||
* 'table_name' => '2mc_courses', // raw, without $wpdb->prefix
|
||||
* 'primary_key' => 'id',
|
||||
* 'post_type_link' => null,
|
||||
* 'doctor_callback' => [ '2meet_Courses', 'doctor_check' ],
|
||||
* 'benchmark_callback' => [ '2meet_Courses', 'benchmark_run' ],
|
||||
* ] );
|
||||
* } );
|
||||
*/
|
||||
final class TMDO_Custom_Table_Registry {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var self|null
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Registered custom tables, keyed by `provider:table_name`.
|
||||
*
|
||||
* @var array<string, array{provider:string, table_name:string, primary_key:string, post_type_link:?string, doctor_callback:?callable, benchmark_callback:?callable, expected_columns:array, indexes:array}>
|
||||
*/
|
||||
private array $tables = array();
|
||||
|
||||
/**
|
||||
* V2.1.2: Secondary index by provider for O(1) `for_provider()` lookup.
|
||||
* Avoids array_filter scan over the full registry — matters at 100+ tables.
|
||||
*
|
||||
* @var array<string, array<string>> provider => list of full keys ('provider:table_name').
|
||||
*/
|
||||
private array $by_provider = array();
|
||||
|
||||
/**
|
||||
* Whether the registration hook has fired.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $registration_done = false;
|
||||
|
||||
/**
|
||||
* Private constructor — use instance().
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Singleton accessor.
|
||||
*/
|
||||
public static function instance(): self {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset for tests only.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `wpdo_register_custom_tables` action so partner plugins can register.
|
||||
*
|
||||
* Called by TMDO_Core::run() and again on plugins_loaded:30 (after all
|
||||
* partner plugins have had a chance to attach their listeners). Safe to
|
||||
* call multiple times — `register()` is idempotent on (provider, table_name).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function fire_registration(): void {
|
||||
$this->registration_done = true;
|
||||
|
||||
/**
|
||||
* Action: wpdo_register_custom_tables
|
||||
*
|
||||
* Partner plugins should register their custom tables here.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Registry instance.
|
||||
*/
|
||||
do_action( 'wpdo_register_custom_tables', $this );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom table from a partner plugin.
|
||||
*
|
||||
* @param string $provider Plugin slug (e.g. '2meet-courses').
|
||||
* @param array $config Table configuration:
|
||||
* - table_name (string, required) Raw table name without $wpdb->prefix.
|
||||
* - primary_key (string, default 'id') Primary key column name.
|
||||
* - post_type_link (?string) Linked post_type, or null if standalone.
|
||||
* - doctor_callback (?callable) Returns array of {ok:bool, message:string} for doctor.
|
||||
* - benchmark_callback (?callable) Returns array of {duration_ms:float, sample_size:int}.
|
||||
* - expected_columns (array<string,string>) Column => SQL type for schema drift detection.
|
||||
* - indexes (array<string,string[]>) Index name => column list for index health.
|
||||
* @return bool True on success, false on validation failure or duplicate.
|
||||
*/
|
||||
public function register( string $provider, array $config ): bool {
|
||||
$config = wp_parse_args(
|
||||
$config,
|
||||
array(
|
||||
'table_name' => '',
|
||||
'primary_key' => 'id',
|
||||
'post_type_link' => null,
|
||||
'doctor_callback' => null,
|
||||
'benchmark_callback' => null,
|
||||
'expected_columns' => array(),
|
||||
'indexes' => array(),
|
||||
)
|
||||
);
|
||||
|
||||
if ( '' === $config['table_name'] || '' === $provider ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanitize table name to a safe identifier.
|
||||
$config['table_name'] = sanitize_key( $config['table_name'] );
|
||||
if ( '' === $config['table_name'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config['provider'] = $provider;
|
||||
|
||||
$key = $provider . ':' . $config['table_name'];
|
||||
if ( isset( $this->tables[ $key ] ) ) {
|
||||
return false; // Already registered.
|
||||
}
|
||||
|
||||
$this->tables[ $key ] = $config;
|
||||
// v2.1.2: maintain secondary index by provider for O(1) lookup.
|
||||
$this->by_provider[ $provider ][] = $key;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a custom table (test or runtime cleanup).
|
||||
*
|
||||
* @param string $provider Plugin slug.
|
||||
* @param string $table_name Raw table name.
|
||||
*/
|
||||
public function unregister( string $provider, string $table_name ): bool {
|
||||
$key = $provider . ':' . sanitize_key( $table_name );
|
||||
if ( ! isset( $this->tables[ $key ] ) ) {
|
||||
return false;
|
||||
}
|
||||
unset( $this->tables[ $key ] );
|
||||
// v2.1.2: keep secondary index in sync.
|
||||
if ( isset( $this->by_provider[ $provider ] ) ) {
|
||||
$this->by_provider[ $provider ] = array_values( array_diff( $this->by_provider[ $provider ], array( $key ) ) );
|
||||
if ( empty( $this->by_provider[ $provider ] ) ) {
|
||||
unset( $this->by_provider[ $provider ] );
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tables.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function all(): array {
|
||||
return $this->tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables for a specific provider.
|
||||
*
|
||||
* @param string $provider Plugin slug.
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function for_provider( string $provider ): array {
|
||||
// v2.1.2: O(1) via secondary index instead of O(n) array_filter.
|
||||
// Drop-in equivalent — same return shape (key=full_key, value=config).
|
||||
if ( ! isset( $this->by_provider[ $provider ] ) ) {
|
||||
return array();
|
||||
}
|
||||
$out = array();
|
||||
foreach ( $this->by_provider[ $provider ] as $key ) {
|
||||
if ( isset( $this->tables[ $key ] ) ) {
|
||||
$out[ $key ] = $this->tables[ $key ];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables linked to a specific post_type.
|
||||
*
|
||||
* @param string $post_type Post type slug.
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function for_post_type( string $post_type ): array {
|
||||
return array_filter(
|
||||
$this->tables,
|
||||
static fn( $cfg ) => isset( $cfg['post_type_link'] ) && $cfg['post_type_link'] === $post_type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique provider names.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function providers(): array {
|
||||
$set = array();
|
||||
foreach ( $this->tables as $cfg ) {
|
||||
$set[ $cfg['provider'] ] = true;
|
||||
}
|
||||
return array_keys( $set );
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary stats for admin dashboard.
|
||||
*
|
||||
* @return array{tables_count:int, providers_count:int, with_doctor:int, with_benchmark:int}
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
$with_doctor = 0;
|
||||
$with_benchmark = 0;
|
||||
foreach ( $this->tables as $cfg ) {
|
||||
if ( is_callable( $cfg['doctor_callback'] ?? null ) ) {
|
||||
++$with_doctor;
|
||||
}
|
||||
if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) {
|
||||
++$with_benchmark;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'tables_count' => count( $this->tables ),
|
||||
'providers_count' => count( $this->providers() ),
|
||||
'with_doctor' => $with_doctor,
|
||||
'with_benchmark' => $with_benchmark,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all registered doctor callbacks and aggregate results.
|
||||
*
|
||||
* @return array<string, array{provider:string, table:string, ok:bool, message:string}>
|
||||
*/
|
||||
public function run_doctor_checks(): array {
|
||||
$results = array();
|
||||
foreach ( $this->tables as $key => $cfg ) {
|
||||
if ( ! is_callable( $cfg['doctor_callback'] ?? null ) ) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$result = call_user_func( $cfg['doctor_callback'], $cfg['table_name'] );
|
||||
$results[ $key ] = array(
|
||||
'provider' => $cfg['provider'],
|
||||
'table' => $cfg['table_name'],
|
||||
'ok' => (bool) ( $result['ok'] ?? false ),
|
||||
'message' => (string) ( $result['message'] ?? '' ),
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
$results[ $key ] = array(
|
||||
'provider' => $cfg['provider'],
|
||||
'table' => $cfg['table_name'],
|
||||
'ok' => false,
|
||||
'message' => 'doctor_callback threw: ' . $e->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* Database abstraction layer for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDO_DB — Database abstraction layer for MySQL / SQLite dual support.
|
||||
*
|
||||
* Provides unified transaction interface, SQL dialect helpers, and
|
||||
* table name resolution. Absorbs and extends the FCB_DB pattern.
|
||||
*/
|
||||
class TMDO_DB {
|
||||
|
||||
/**
|
||||
* Check if the current database engine is SQLite.
|
||||
*/
|
||||
public static function is_sqlite(): bool {
|
||||
return TMDO_IS_SQLITE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current database engine is MySQL.
|
||||
*/
|
||||
public static function is_mysql(): bool {
|
||||
return TMDO_IS_MYSQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a database transaction.
|
||||
*
|
||||
* SQLite: BEGIN IMMEDIATE (write-lock, prevents concurrent writes).
|
||||
* MySQL: START TRANSACTION (row-level locking via InnoDB).
|
||||
*/
|
||||
public static function begin(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( self::is_sqlite() ? 'BEGIN IMMEDIATE' : 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static SQL literals; no user input.
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the current transaction.
|
||||
*/
|
||||
public static function commit(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'COMMIT' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back the current transaction.
|
||||
*/
|
||||
public static function rollback(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'ROLLBACK' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current WordPress local time as a MySQL datetime string.
|
||||
*/
|
||||
public static function now(): string {
|
||||
return current_time( 'mysql' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full table name with wpdb prefix.
|
||||
*
|
||||
* @param string $name Table name without prefix (e.g. 'wpdo_warm').
|
||||
* @return string Fully-prefixed, sanitized table name.
|
||||
*/
|
||||
public static function table( string $name ): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . sanitize_key( $name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT IGNORE statement (MySQL) or INSERT OR IGNORE (SQLite).
|
||||
*
|
||||
* @param string $table Full table name.
|
||||
* @param array $data Column => value pairs.
|
||||
* @param array $format Optional wpdb format array ('%s', '%d', etc.).
|
||||
* @return int|false Number of rows affected, or false on error.
|
||||
*/
|
||||
public static function insert_ignore( string $table, array $data, array $format = array() ): int|false {
|
||||
global $wpdb;
|
||||
|
||||
$columns = array_keys( $data );
|
||||
$values = array_values( $data );
|
||||
$col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) );
|
||||
$placeholder = implode( ', ', $format ?: array_fill( 0, count( $values ), '%s' ) );
|
||||
|
||||
$keyword = self::is_sqlite() ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||||
|
||||
$sql = "{$keyword} INTO `{$table}` ({$col_list}) VALUES ({$placeholder})";
|
||||
|
||||
return $wpdb->query( $wpdb->prepare( $sql, ...$values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSON_EXTRACT expression compatible with both MySQL and SQLite.
|
||||
*
|
||||
* Both MySQL 5.7+ and SQLite 3.38+ support json_extract().
|
||||
*
|
||||
* @param string $column Column name containing JSON data.
|
||||
* @param string $path JSON path (e.g. '$.social_links').
|
||||
* @return string SQL expression.
|
||||
*/
|
||||
public static function json_extract( string $column, string $path ): string {
|
||||
$column = sanitize_key( $column );
|
||||
// JSON path must start with '$' and contain only word chars, dots, brackets, and digits.
|
||||
if ( ! preg_match( '/^\$[\w.\[\]0-9]*$/', $path ) ) {
|
||||
$path = '$';
|
||||
}
|
||||
return "json_extract(`{$column}`, '{$path}')";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an UPSERT statement.
|
||||
*
|
||||
* MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
|
||||
* SQLite: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
|
||||
*
|
||||
* @param string $table Full table name.
|
||||
* @param array $data Column => value pairs to insert.
|
||||
* @param array $update_columns Columns to update on conflict.
|
||||
* @param string|string[] $conflict_key Column name (string) or columns (array)
|
||||
* for ON CONFLICT (SQLite). On MySQL it
|
||||
* is informational only — ON DUPLICATE
|
||||
* KEY UPDATE matches any unique key.
|
||||
* @param array $format Optional wpdb format array.
|
||||
* @return int|false
|
||||
*/
|
||||
public static function upsert( string $table, array $data, array $update_columns, string|array $conflict_key = 'post_id', array $format = array() ): int|false {
|
||||
global $wpdb;
|
||||
|
||||
$columns = array_keys( $data );
|
||||
$values = array_values( $data );
|
||||
// Normalise composite vs single conflict key.
|
||||
$conflict_keys = is_array( $conflict_key ) ? $conflict_key : array( $conflict_key );
|
||||
$conflict_keys = array_map( 'sanitize_key', $conflict_keys );
|
||||
$conflict_list = implode(
|
||||
', ',
|
||||
array_map( static fn( $k ) => '`' . $k . '`', $conflict_keys )
|
||||
);
|
||||
|
||||
$col_list = implode( ', ', array_map( fn( $c ) => '`' . sanitize_key( $c ) . '`', $columns ) );
|
||||
|
||||
// Build per-value placeholders, using NULL literal for null values.
|
||||
$prepare_values = array();
|
||||
$placeholders = array();
|
||||
$fmt = $format ?: array_fill( 0, count( $values ), '%s' );
|
||||
foreach ( $values as $i => $v ) {
|
||||
if ( null === $v ) {
|
||||
$placeholders[] = 'NULL';
|
||||
} else {
|
||||
$placeholders[] = $fmt[ $i ] ?? '%s';
|
||||
$prepare_values[] = $v;
|
||||
}
|
||||
}
|
||||
$placeholder = implode( ', ', $placeholders );
|
||||
|
||||
// ON DUPLICATE KEY UPDATE: null columns use NULL literal, others use VALUES().
|
||||
$null_cols = array();
|
||||
foreach ( $update_columns as $c ) {
|
||||
$col_index = array_search( $c, $columns, true );
|
||||
if ( false !== $col_index && null === $values[ $col_index ] ) {
|
||||
$null_cols[] = $c;
|
||||
}
|
||||
}
|
||||
|
||||
if ( self::is_mysql() ) {
|
||||
$updates = implode(
|
||||
', ',
|
||||
array_map(
|
||||
function ( $c ) use ( $null_cols ) {
|
||||
$safe = sanitize_key( $c );
|
||||
return in_array( $c, $null_cols, true )
|
||||
? "`{$safe}` = NULL"
|
||||
: "`{$safe}` = VALUES(`{$safe}`)";
|
||||
},
|
||||
$update_columns
|
||||
)
|
||||
);
|
||||
$sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON DUPLICATE KEY UPDATE {$updates}";
|
||||
} else {
|
||||
$updates = implode(
|
||||
', ',
|
||||
array_map(
|
||||
function ( $c ) use ( $null_cols ) {
|
||||
$safe = sanitize_key( $c );
|
||||
return in_array( $c, $null_cols, true )
|
||||
? "`{$safe}` = NULL"
|
||||
: "`{$safe}` = excluded.`{$safe}`";
|
||||
},
|
||||
$update_columns
|
||||
)
|
||||
);
|
||||
$sql = "INSERT INTO `{$table}` ({$col_list}) VALUES ({$placeholder}) ON CONFLICT({$conflict_list}) DO UPDATE SET {$updates}";
|
||||
}
|
||||
|
||||
if ( empty( $prepare_values ) ) {
|
||||
return $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
|
||||
return $wpdb->query( $wpdb->prepare( $sql, ...$prepare_values ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
/**
|
||||
* Feature flags manager for WPDO module lifecycle states.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flags for each WPDO module.
|
||||
*
|
||||
* Option key: wpdo_features (serialized array)
|
||||
*
|
||||
* 7-state lifecycle per module (extends HPCT's 4-state model):
|
||||
* idle → dual_write → backfill → verify → cutover → cleanup → complete
|
||||
*
|
||||
* Any state can roll back to idle.
|
||||
*/
|
||||
class TMDO_Feature_Flags {
|
||||
|
||||
/** Option name storing all module states. */
|
||||
private const OPTION_KEY = 'wpdo_features';
|
||||
|
||||
/**
|
||||
* All known HPCT-inherited modules.
|
||||
* These modules operate on existing hpct_* tables.
|
||||
*/
|
||||
public const HPCT_MODULES = array(
|
||||
'reviews',
|
||||
'messages',
|
||||
'favorites',
|
||||
'memberships',
|
||||
'statistics',
|
||||
'requests',
|
||||
'listing_meta',
|
||||
'wc_orders',
|
||||
'latepoint',
|
||||
);
|
||||
|
||||
/**
|
||||
* Zone-based modules (new in WPDO).
|
||||
* These modules operate on wpdo_* tables.
|
||||
*/
|
||||
public const ZONE_MODULES = array(
|
||||
'hot_hp_listing',
|
||||
'hot_hp_vendor',
|
||||
'warm',
|
||||
'cold_hp_listing',
|
||||
'cold_hp_vendor',
|
||||
'archive',
|
||||
);
|
||||
|
||||
/** Valid lifecycle states. */
|
||||
public const VALID_STATES = array(
|
||||
'idle',
|
||||
'dual_write',
|
||||
'backfill',
|
||||
'verify',
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which interceptors should capture writes (dual-write active).
|
||||
*/
|
||||
public const WRITE_ACTIVE_STATES = array(
|
||||
'dual_write',
|
||||
'backfill',
|
||||
'verify',
|
||||
'cutover',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which reads come from the custom/zone table.
|
||||
*/
|
||||
public const READ_CUSTOM_STATES = array(
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* States in which query interceptors are active.
|
||||
*/
|
||||
public const QUERY_ACTIVE_STATES = array(
|
||||
'cutover',
|
||||
'cleanup',
|
||||
'complete',
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the state of a single module.
|
||||
*
|
||||
* @param string $module Module name.
|
||||
* @return string One of VALID_STATES, defaults to 'idle'.
|
||||
*/
|
||||
public static function get( string $module ): string {
|
||||
$flags = self::all();
|
||||
return $flags[ $module ] ?? 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of a module.
|
||||
*
|
||||
* V2.2.0 M2: gated by TMDO_FSM_Guard — invalid transitions are blocked
|
||||
* (returns false), destructive transitions automatically snapshot first.
|
||||
* Filter `wpdo/fsm_guard/bypass` allows CLI/tests to override.
|
||||
*
|
||||
* @param string $module Module name.
|
||||
* @param string $state One of VALID_STATES.
|
||||
* @return bool|WP_Error true on success, WP_Error on guard rejection,
|
||||
* false on invalid state name.
|
||||
*/
|
||||
public static function set( string $module, string $state ) {
|
||||
if ( ! in_array( $state, self::VALID_STATES, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// FSM Guard: validate transition + auto-snapshot if destructive.
|
||||
// Defensive: only invoke when class is loaded AND not explicitly
|
||||
// disabled (raw-PHP unit harness sets TMDO_FSM_GUARD_DISABLED).
|
||||
// Dedicated FSMGuardTest unsets the constant before its own assertions.
|
||||
$guard_active = class_exists( 'TMDO_FSM_Guard' )
|
||||
&& ! ( defined( 'TMDO_FSM_GUARD_DISABLED' ) && TMDO_FSM_GUARD_DISABLED );
|
||||
if ( $guard_active ) {
|
||||
$check = TMDO_FSM_Guard::before_transition( $module, $state );
|
||||
if ( is_wp_error( $check ) ) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
$flags = self::all();
|
||||
$flags[ $module ] = $state;
|
||||
update_option( self::OPTION_KEY, $flags );
|
||||
self::$cache = null; // Invalidate request-level cache.
|
||||
|
||||
// Record entry timestamp (M2) for downstream verify-gate / wash-period checks.
|
||||
if ( $guard_active ) {
|
||||
TMDO_FSM_Guard::record_entry( $module, $state );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── shadow_read_only sub-flag (PR-4) ────────────────────────────────────
|
||||
// Orthogonal to the main 7-state FSM. When enabled for a module that is in
|
||||
// the `verify` state, every read fans out to BOTH postmeta and the zone
|
||||
// table; divergence is logged to wp_wpdo_shadow_diffs without affecting
|
||||
// the served value. Lets ops bake confidence before cutover.
|
||||
|
||||
/** Option key storing per-module shadow_read_only flags. */
|
||||
private const SHADOW_OPTION_KEY = 'wpdo_features_shadow';
|
||||
|
||||
/**
|
||||
* Request-level cache for shadow flags.
|
||||
*
|
||||
* @var array<string,bool>|null
|
||||
*/
|
||||
private static ?array $shadow_cache = null;
|
||||
|
||||
/**
|
||||
* Enable shadow_read_only for a module (only meaningful in the verify state).
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public static function enable_shadow_read( string $module ): bool {
|
||||
$flags = self::all_shadow();
|
||||
$flags[ $module ] = true;
|
||||
update_option( self::SHADOW_OPTION_KEY, $flags, false );
|
||||
self::$shadow_cache = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable shadow_read_only for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool
|
||||
*/
|
||||
public static function disable_shadow_read( string $module ): bool {
|
||||
$flags = self::all_shadow();
|
||||
unset( $flags[ $module ] );
|
||||
update_option( self::SHADOW_OPTION_KEY, $flags, false );
|
||||
self::$shadow_cache = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether shadow_read_only is currently active for a module.
|
||||
*
|
||||
* Returns true only when:
|
||||
* - Module is in the `verify` state, AND
|
||||
* - Shadow flag has been explicitly enabled.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_shadow_read_active( string $module ): bool {
|
||||
if ( 'verify' !== self::get( $module ) ) {
|
||||
return false;
|
||||
}
|
||||
$flags = self::all_shadow();
|
||||
return ! empty( $flags[ $module ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all shadow flags.
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public static function all_shadow(): array {
|
||||
if ( null !== self::$shadow_cache ) {
|
||||
return self::$shadow_cache;
|
||||
}
|
||||
$saved = get_option( self::SHADOW_OPTION_KEY, array() );
|
||||
if ( ! is_array( $saved ) ) {
|
||||
$saved = array();
|
||||
}
|
||||
self::$shadow_cache = $saved;
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is fully complete (reads and writes on custom table only).
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool True if module state is 'complete'.
|
||||
*/
|
||||
public static function is_complete( string $module ): bool {
|
||||
return 'complete' === self::get( $module );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dual-write is active for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool True if module is in a write-active state.
|
||||
*/
|
||||
public static function is_write_active( string $module ): bool {
|
||||
return in_array( self::get( $module ), self::WRITE_ACTIVE_STATES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reads should come from the custom/zone table.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool True if module is in a read-custom state.
|
||||
*/
|
||||
public static function is_read_custom( string $module ): bool {
|
||||
return in_array( self::get( $module ), self::READ_CUSTOM_STATES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query interceptors should be active.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return bool True if module is in a query-active state.
|
||||
*/
|
||||
public static function is_query_active( string $module ): bool {
|
||||
return in_array( self::get( $module ), self::QUERY_ACTIVE_STATES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a module to idle (rollback).
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return void
|
||||
*/
|
||||
public static function reset( string $module ): void {
|
||||
self::set( $module, 'idle' );
|
||||
self::$cache = null; // Invalidate request-level cache.
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-level cache for all() to avoid repeated get_option() calls.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static ?array $cache = null;
|
||||
|
||||
/**
|
||||
* Return all module states.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function all(): array {
|
||||
if ( null !== self::$cache ) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$saved = get_option( self::OPTION_KEY, array() );
|
||||
if ( ! is_array( $saved ) ) {
|
||||
$saved = array();
|
||||
}
|
||||
|
||||
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
|
||||
$defaults = array_fill_keys( $all_modules, 'idle' );
|
||||
|
||||
self::$cache = array_merge( $defaults, $saved );
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only HPCT-inherited module states.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function hpct_modules(): array {
|
||||
$all = self::all();
|
||||
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only zone module states.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function zone_modules(): array {
|
||||
$all = self::all();
|
||||
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an HPCT feature flag status to a WPDO state.
|
||||
*
|
||||
* Used during HPCT import to translate HPCT's 4-state model
|
||||
* to WPDO's 7-state model.
|
||||
*
|
||||
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
|
||||
* @return string WPDO state.
|
||||
*/
|
||||
public static function map_hpct_status( string $hpct_status ): string {
|
||||
return match ( $hpct_status ) {
|
||||
'disabled' => 'idle',
|
||||
'migrating' => 'backfill',
|
||||
'verified' => 'cutover',
|
||||
'enabled' => 'complete',
|
||||
default => 'idle',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Hook_Bus_Bridge — feature-flagged Hook Bus integration layer.
|
||||
*
|
||||
* PR-3 introduces a unified Hook Bus that will eventually replace the per-
|
||||
* interceptor `add_filter()` registrations. To avoid breaking production
|
||||
* during the transition, the unified bus is opt-in via the
|
||||
* `wpdo_hook_bus_enabled` option (default: false).
|
||||
*
|
||||
* When enabled, the Hook Bus takes over `update_post_metadata` /
|
||||
* `get_post_metadata` at priority 8, dispatching to handlers (via
|
||||
* TMDO_Adapter_Post + TMDO_Entity_Registry). When disabled, the existing
|
||||
* 9 interceptors continue to operate at priority 10 untouched.
|
||||
*
|
||||
* Use `TMDO_Hook_Bus_Bridge::is_enabled()` to gate any code path that
|
||||
* should defer to the unified bus.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge / feature-flag layer between the legacy interceptors and the unified
|
||||
* TMDO_Hook_Bus introduced by the UAE port.
|
||||
*/
|
||||
final class TMDO_Hook_Bus_Bridge {
|
||||
|
||||
/** Option name for the feature flag. */
|
||||
public const OPTION = 'wpdo_hook_bus_enabled';
|
||||
|
||||
/**
|
||||
* Request-level cache for is_enabled() checks.
|
||||
*
|
||||
* @var bool|null
|
||||
*/
|
||||
private static ?bool $cache = null;
|
||||
|
||||
/**
|
||||
* Whether the unified Hook Bus is enabled for this site.
|
||||
*
|
||||
* Default: false (legacy interceptors at priority 10 remain active).
|
||||
* To enable: `update_option( 'wpdo_hook_bus_enabled', '1' )` or
|
||||
* `wp option update wpdo_hook_bus_enabled 1`.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
if ( null !== self::$cache ) {
|
||||
return self::$cache;
|
||||
}
|
||||
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
|
||||
self::$cache = ( '1' === (string) $value || true === $value );
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset request cache — for tests and runtime mode toggles.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_cache(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the unified Hook Bus when enabled.
|
||||
*
|
||||
* Called from TMDO_Core::run() after legacy interceptors have a chance to
|
||||
* register. The Hook Bus will silently skip startup if disabled.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_init_hook_bus(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_Hook_Bus' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the unified bus. Only Adapter_Post handlers will be
|
||||
// registered by default (PR-3 scope); other entity adapters land in PR-5.
|
||||
TMDO_Hook_Bus::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of WPDO interceptors that are designed to coexist on the same
|
||||
* hook + priority. They each filter on a disjoint meta_key prefix, so
|
||||
* "multiple callbacks per hook" does not imply a real conflict.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const COEXIST_WHITELIST = array(
|
||||
'TMDO_Reviews_Interceptor',
|
||||
'TMDO_Messages_Interceptor',
|
||||
'TMDO_Favorites_Interceptor',
|
||||
'TMDO_Memberships_Interceptor',
|
||||
'TMDO_Statistics_Interceptor',
|
||||
'TMDO_Requests_Interceptor',
|
||||
'TMDO_Listing_Meta_Interceptor',
|
||||
'TMDO_WC_Orders_Interceptor',
|
||||
'TMDO_LatePoint_Interceptor',
|
||||
'TMDO_Sync_Bridge',
|
||||
'TMDO_Cache_Layer',
|
||||
'TMDO_Listing_Stats',
|
||||
'TMDO_Hook_Bus',
|
||||
// v2.11.5+: HivePress per-post transient cache rerouter. Filters at
|
||||
// priority 9 (before Hook Bus at 10) and matches only `_transient_hp_*`
|
||||
// meta_keys — fully disjoint from any other interceptor.
|
||||
'TMDO_Hivepress_Transient_Filter',
|
||||
// v2.12.1+: Term + Comment garbage write-time filter. Filters
|
||||
// add/update_term_metadata + add/update_comment_metadata at priority 9
|
||||
// and matches only `_wxr_import_*` / `_2meet_demo_*` / 8 orphan
|
||||
// post-meta keys — fully disjoint from any other interceptor.
|
||||
'TMDO_Term_Comment_Garbage_Filter',
|
||||
// v2.12.3+: WC term count cache rerouter. Filters at priority 9 and
|
||||
// matches only `product_count_*` term meta keys — fully disjoint
|
||||
// from any other interceptor.
|
||||
'TMDO_WC_Term_Count_Filter',
|
||||
// v2.12.4+: Term + Comment misc bucket (catch-all). Filters at
|
||||
// priority 99 (LAST), only handles writes where every other filter
|
||||
// returned null. Disjoint by design.
|
||||
'TMDO_Term_Comment_Misc_Bucket',
|
||||
// v2.13.0+: Term Stress Tester. Bulk fixture generator that doesn't
|
||||
// register any metadata filter — listed here so admin Conflict Detector
|
||||
// recognizes it as a known WPDO class even though it's filter-disjoint.
|
||||
'TMDO_Term_Stress_Tester',
|
||||
// v2.13.1+: Comment Stress Tester. Same role for comment entity —
|
||||
// bulk fixture generator with no metadata filter registration.
|
||||
'TMDO_Comment_Stress_Tester',
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect interceptor priority overlap that risks duplicate writes.
|
||||
*
|
||||
* Inspects the metadata filters and reports cases where two non-whitelisted
|
||||
* WPDO callbacks compete on the same hook. Whitelisted interceptors (the 9
|
||||
* HPCT-inherited modules + Sync_Bridge) are designed to coexist by
|
||||
* filtering on disjoint meta_key prefixes — they are NOT real conflicts.
|
||||
*
|
||||
* Real conflicts: a third-party plugin registers `TMDO_Custom_*` AND
|
||||
* collides with an existing whitelisted interceptor on the same priority.
|
||||
*
|
||||
* Used by the admin Conflict Detector tab + `wp wpdo conflict-scan`.
|
||||
*
|
||||
* @return array<int, array{hook:string, priority:int, callback:string}>
|
||||
*/
|
||||
public static function detect_intra_wpdo_conflicts(): array {
|
||||
global $wp_filter;
|
||||
|
||||
$findings = array();
|
||||
$hooks_to_check = array(
|
||||
'update_post_metadata',
|
||||
'add_post_metadata',
|
||||
'delete_post_metadata',
|
||||
'get_post_metadata',
|
||||
);
|
||||
|
||||
foreach ( $hooks_to_check as $hook ) {
|
||||
if ( ! isset( $wp_filter[ $hook ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect TMDO_* callbacks AND identify which are non-whitelisted.
|
||||
$wpdo_callbacks = array();
|
||||
$non_whitelist_callbacks = array();
|
||||
$priorities = $wp_filter[ $hook ]->callbacks ?? array();
|
||||
|
||||
foreach ( $priorities as $priority => $callbacks ) {
|
||||
foreach ( $callbacks as $cb ) {
|
||||
$cb = $cb['function'] ?? null;
|
||||
if ( null === $cb ) {
|
||||
continue;
|
||||
}
|
||||
$class_name = self::callable_class_name( $cb );
|
||||
if ( null === $class_name || ! str_starts_with( $class_name, 'TMDO_' ) ) {
|
||||
continue;
|
||||
}
|
||||
$entry = array(
|
||||
'class' => $class_name,
|
||||
'priority' => (int) $priority,
|
||||
);
|
||||
$wpdo_callbacks[] = $entry;
|
||||
if ( ! in_array( $class_name, self::COEXIST_WHITELIST, true ) ) {
|
||||
$non_whitelist_callbacks[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Real conflict: any non-whitelisted WPDO callback overlapping with
|
||||
// other WPDO callbacks on the same hook.
|
||||
if ( ! empty( $non_whitelist_callbacks ) && count( $wpdo_callbacks ) > 1 ) {
|
||||
foreach ( $non_whitelist_callbacks as $cb ) {
|
||||
$findings[] = array(
|
||||
'hook' => $hook,
|
||||
'priority' => $cb['priority'],
|
||||
'callback' => $cb['class'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the class name from a callable, or return null when not class-bound.
|
||||
*
|
||||
* @param mixed $cb Any PHP callable.
|
||||
* @return string|null Fully qualified class name, or null.
|
||||
*/
|
||||
private static function callable_class_name( $cb ): ?string {
|
||||
if ( is_array( $cb ) && isset( $cb[0] ) ) {
|
||||
$obj = $cb[0];
|
||||
if ( is_object( $obj ) ) {
|
||||
return get_class( $obj );
|
||||
}
|
||||
if ( is_string( $obj ) ) {
|
||||
return $obj;
|
||||
}
|
||||
}
|
||||
if ( is_string( $cb ) && str_contains( $cb, '::' ) ) {
|
||||
return strtok( $cb, ':' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Error logging for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight error logger — writes to wpdo_errors table and PHP error_log.
|
||||
*/
|
||||
class TMDO_Logger {
|
||||
|
||||
/**
|
||||
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
||||
*
|
||||
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
|
||||
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
||||
* many times per request — keeping them out of the DB error table.
|
||||
*
|
||||
* @param string $event Event name (e.g. 'bridge_mode_changed').
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function info( string $event, array $context = array() ): void {
|
||||
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a NOTICE-level event — alias of info() for callers that want a stronger
|
||||
* level intent. Same routing (error_log only).
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function notice( string $event, array $context = array() ): void {
|
||||
self::info( $event, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a WARNING-level event — error_log + wpdo_errors row (for admin visibility).
|
||||
*
|
||||
* Use for "anomaly worth attention but not breaking" (e.g. invalid filter
|
||||
* return, unexpected fallback path). The event_name becomes the `hook` column;
|
||||
* message gets a `[WARN]` prefix to distinguish from hard errors.
|
||||
*
|
||||
* @param string $event Event name (e.g. 'wpdo_route_decision_invalid_return').
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function warning( string $event, array $context = array() ): void {
|
||||
error_log( sprintf( '[WPDO][WARN][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
self::error( 'engine', $event, '[WARN] ' . $event, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a DEBUG-level event — only when WP_DEBUG is true; routes to error_log.
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function debug( string $event, array $context = array() ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::info( $event, $context );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a module error.
|
||||
*
|
||||
* @param string $module Module name (e.g. 'reviews', 'hot_hp_listing').
|
||||
* @param string $hook The hook or method where the error occurred.
|
||||
* @param string $message Human-readable error message.
|
||||
* @param array $context Optional extra context (will be JSON-encoded).
|
||||
* @param string $zone Optional zone identifier (hot/warm/cold/archive).
|
||||
*/
|
||||
public static function error( string $module, string $hook, string $message, array $context = array(), string $zone = '' ): void {
|
||||
global $wpdb;
|
||||
|
||||
error_log( sprintf( '[WPDO][%s][%s] %s', $module, $hook, $message ) );
|
||||
|
||||
$table = TMDO_DB::table( 'wpdo_errors' );
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'module' => sanitize_key( $module ),
|
||||
'zone' => sanitize_key( $zone ),
|
||||
'hook' => substr( sanitize_text_field( $hook ), 0, 255 ),
|
||||
'message' => $message,
|
||||
'context' => $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : null,
|
||||
'created_at' => current_time( 'mysql', true ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return recent errors, optionally filtered by module.
|
||||
*
|
||||
* @param string $module Module name (empty = all modules).
|
||||
* @param int $limit Max rows to return.
|
||||
* @return array
|
||||
*/
|
||||
public static function get_recent( string $module = '', int $limit = 100 ): array {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_errors' );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
|
||||
if ( $module ) {
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` WHERE module = %s ORDER BY id DESC LIMIT %d",
|
||||
$module,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
) ?: array();
|
||||
}
|
||||
|
||||
return $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d", $limit ),
|
||||
ARRAY_A
|
||||
) ?: array();
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete errors older than a given number of days.
|
||||
*
|
||||
* @param int $days Keep errors from the last N days.
|
||||
* @return int Number of rows deleted.
|
||||
*/
|
||||
public static function purge( int $days = 30 ): int {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_errors' );
|
||||
|
||||
$now = current_time( 'mysql', true );
|
||||
|
||||
if ( TMDO_IS_SQLITE ) {
|
||||
$sql = $wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE created_at < datetime(%s, %s)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$now,
|
||||
"-{$days} days"
|
||||
);
|
||||
} else {
|
||||
$sql = $wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE created_at < DATE_SUB(%s, INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$now,
|
||||
$days
|
||||
);
|
||||
}
|
||||
|
||||
return (int) $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
|
||||
/**
|
||||
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
|
||||
*
|
||||
* Companion to TMDO_Post_Migration. Used during shadow_read mode to verify
|
||||
* the flat tables stay in sync with wp_postmeta. Each sample picks a random
|
||||
* post + key, fetches both values (flat row vs wp_postmeta row), and counts
|
||||
* matches / diffs / missing rows. Divergent values are written to the
|
||||
* shared `wpdo_shadow_diffs` table via `TMDO_Shadow_Diff_Logger` (which is
|
||||
* already entity_type-aware — pass 'post' to differentiate from user diffs).
|
||||
*
|
||||
* Run pattern:
|
||||
* - Manual: `wp wpdo post-shadow-report`
|
||||
* - Cron: wpdo_post_shadow_verify event registered when post mode is
|
||||
* shadow_read; auto-unregistered when mode changes to anything else
|
||||
*
|
||||
* 🔒 Frozen contract: never touches user-side flat tables, never modifies
|
||||
* wp_postmeta or wp_posts (read-only verifier).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.10.3
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Verifier is read-only.
|
||||
|
||||
/**
|
||||
* Sample-and-compare verifier for post entity flat tables.
|
||||
*/
|
||||
final class TMDO_Post_Shadow_Verifier {
|
||||
|
||||
/**
|
||||
* Cron event hook fired hourly when post mode is shadow_read.
|
||||
*/
|
||||
public const CRON_HOOK = 'wpdo_post_shadow_verify';
|
||||
|
||||
/**
|
||||
* Default sample size per cron tick (capped to available post count).
|
||||
*/
|
||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Run a sample-compare pass.
|
||||
*
|
||||
* Picks $sample_size random posts of $post_type, fetches their flat row
|
||||
* + their wp_postmeta values for each $keys entry, and counts matches.
|
||||
* Divergences are recorded via TMDO_Shadow_Diff_Logger when the class
|
||||
* is available.
|
||||
*
|
||||
* @param string $post_type WP post_type to sample.
|
||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
||||
* @param string $flat_table Fully qualified flat table name.
|
||||
* @param string[] $keys Meta keys to compare (each post checks all).
|
||||
* @param int $sample_size Number of posts to sample (capped to DB count).
|
||||
* @return array{
|
||||
* sampled:int,
|
||||
* matched:int,
|
||||
* diffs:int,
|
||||
* missing_flat:int,
|
||||
* missing_postmeta:int,
|
||||
* group:string,
|
||||
* post_type:string,
|
||||
* }
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function sample_compare(
|
||||
string $post_type,
|
||||
string $group_name,
|
||||
string $flat_table,
|
||||
array $keys,
|
||||
int $sample_size = self::DEFAULT_SAMPLE_SIZE
|
||||
): array {
|
||||
if ( $sample_size <= 0 ) {
|
||||
$msg = 'Sample size must be > 0';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
if ( empty( $keys ) ) {
|
||||
$msg = 'Keys array cannot be empty';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d",
|
||||
$post_type,
|
||||
$sample_size
|
||||
)
|
||||
);
|
||||
|
||||
$sampled = count( $post_ids );
|
||||
$matched = 0;
|
||||
$diffs = 0;
|
||||
$missing_flat = 0;
|
||||
$missing_postmeta = 0;
|
||||
|
||||
if ( 0 === $sampled ) {
|
||||
return array(
|
||||
'sampled' => 0,
|
||||
'matched' => 0,
|
||||
'diffs' => 0,
|
||||
'missing_flat' => 0,
|
||||
'missing_postmeta' => 0,
|
||||
'group' => $group_name,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $post_ids as $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
foreach ( $keys as $key ) {
|
||||
$col = self::sanitize_column( $key );
|
||||
$flat_val = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1",
|
||||
$post_id
|
||||
)
|
||||
);
|
||||
$pm_val = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
|
||||
$post_id,
|
||||
$key
|
||||
)
|
||||
);
|
||||
|
||||
$flat_present = null !== $flat_val && '' !== $flat_val;
|
||||
$pm_present = null !== $pm_val && '' !== $pm_val;
|
||||
|
||||
if ( ! $flat_present && ! $pm_present ) {
|
||||
// Both empty — neither side has the value, count as match
|
||||
// (key truly absent for this post).
|
||||
++$matched;
|
||||
continue;
|
||||
}
|
||||
if ( ! $flat_present && $pm_present ) {
|
||||
++$missing_flat;
|
||||
self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' );
|
||||
continue;
|
||||
}
|
||||
if ( $flat_present && ! $pm_present ) {
|
||||
++$missing_postmeta;
|
||||
self::log_diff( $post_id, $key, '(missing)', (string) $flat_val );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Loose equality — flat may have widened types (e.g. tinyint→bigint)
|
||||
// or full-precision decimals (10,2 → 18,6).
|
||||
if ( self::values_loose_equal( $pm_val, $flat_val ) ) {
|
||||
++$matched;
|
||||
} else {
|
||||
++$diffs;
|
||||
self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'sampled' => $sampled,
|
||||
'matched' => $matched,
|
||||
'diffs' => $diffs,
|
||||
'missing_flat' => $missing_flat,
|
||||
'missing_postmeta' => $missing_postmeta,
|
||||
'group' => $group_name,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: runs sample_compare for each registered group when post
|
||||
* mode is shadow_read. No-op otherwise.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function cron_tick(): void {
|
||||
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
|
||||
if ( empty( $keys ) ) {
|
||||
continue;
|
||||
}
|
||||
$post_type = self::group_post_type( $group );
|
||||
if ( null === $post_type ) {
|
||||
continue; // wp_core spans all types, skip in cron tick.
|
||||
}
|
||||
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||
|
||||
try {
|
||||
self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
||||
} catch ( \Throwable $e ) {
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::error(
|
||||
'post_shadow_verifier',
|
||||
'cron_tick',
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent diff records for entity_type='post'.
|
||||
*
|
||||
* @param int $limit Max rows.
|
||||
* @return array
|
||||
*/
|
||||
public static function recent_diffs( int $limit = 100 ): array {
|
||||
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
||||
return array();
|
||||
}
|
||||
return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate diff stats for entity_type='post' over recent N hours.
|
||||
*
|
||||
* @param int $hours Window in hours (default 24).
|
||||
* @return array{total:int,by_key:array<string,int>}
|
||||
*/
|
||||
public static function diff_stats( int $hours = 24 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return array(
|
||||
'total' => 0,
|
||||
'by_key' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
|
||||
|
||||
$total = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s",
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
|
||||
$since
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$by_key = array();
|
||||
foreach ( (array) $rows as $row ) {
|
||||
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
|
||||
}
|
||||
|
||||
return array(
|
||||
'total' => $total,
|
||||
'by_key' => $by_key,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
|
||||
*
|
||||
* @param string $key Meta key.
|
||||
* @return string
|
||||
*/
|
||||
private static function sanitize_column( string $key ): string {
|
||||
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
|
||||
return TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
}
|
||||
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map entity group → primary post_type (null = cross-cutting like wp_core).
|
||||
*
|
||||
* @param string $group Entity group name.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function group_post_type( string $group ): ?string {
|
||||
switch ( $group ) {
|
||||
case 'attachment':
|
||||
return 'attachment';
|
||||
case 'wc_product':
|
||||
return 'product';
|
||||
case 'hp_listing_core':
|
||||
return 'hp_listing';
|
||||
case 'hp_request_core':
|
||||
return 'hp_request';
|
||||
case 'hp_vendor_core':
|
||||
return 'hp_vendor';
|
||||
case 'nav_menu_item':
|
||||
return 'nav_menu_item';
|
||||
case 'wp_core':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose equality for verifier — handles widened types (decimal precision,
|
||||
* tinyint→bigint, etc.) and serialization-format differences without
|
||||
* false-positive divergence reports.
|
||||
*
|
||||
* Layers (fail-fast on first match):
|
||||
* 1. Identical strings
|
||||
* 2. Numeric loose match (handles "50" vs "50.000000")
|
||||
* 3. v2.10.5: Decoded match for serialized vs JSON values
|
||||
* (postmeta uses PHP serialize, flat tables use wp_json_encode for
|
||||
* json-typed fields — same logical data, different storage format)
|
||||
*
|
||||
* @param mixed $a Side A value.
|
||||
* @param mixed $b Side B value.
|
||||
* @return bool
|
||||
*/
|
||||
private static function values_loose_equal( $a, $b ): bool {
|
||||
// Both null/empty already filtered out by caller.
|
||||
$as = (string) $a;
|
||||
$bs = (string) $b;
|
||||
if ( $as === $bs ) {
|
||||
return true;
|
||||
}
|
||||
// Numeric loose match (handles "50" vs "50.000000").
|
||||
if ( is_numeric( $as ) && is_numeric( $bs ) ) {
|
||||
return (float) $as === (float) $bs;
|
||||
}
|
||||
// v2.10.5: try decoded comparison for serialized/JSON values.
|
||||
$a_decoded = self::decode_value( $as );
|
||||
$b_decoded = self::decode_value( $bs );
|
||||
if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort decode of a string value: try PHP unserialize (object-safe),
|
||||
* then JSON. Returns the decoded structure, or null when neither succeeds
|
||||
* (so the caller can short-circuit instead of false-positive matching
|
||||
* against a literal "null" string).
|
||||
*
|
||||
* Returns null in three cases:
|
||||
* - Both decoders failed (input is plain non-encoded string)
|
||||
* - unserialize returned NULL legitimately (input was 'N;')
|
||||
* - Empty string
|
||||
*
|
||||
* The caller distinguishes these by combining with an identical-string
|
||||
* fast-path that runs first; non-encoded strings hit equality before this
|
||||
* decoder runs.
|
||||
*
|
||||
* @param string $s Raw string value.
|
||||
* @return mixed|null Decoded value, or null on failure.
|
||||
*/
|
||||
private static function decode_value( string $s ) {
|
||||
if ( '' === $s ) {
|
||||
return null;
|
||||
}
|
||||
// PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'.
|
||||
if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices.
|
||||
$v = @unserialize( $s, array( 'allowed_classes' => false ) );
|
||||
if ( false !== $v || 'b:0;' === $s ) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
// JSON: arrays/objects start with [ or {.
|
||||
$first = $s[0] ?? '';
|
||||
if ( '[' === $first || '{' === $first ) {
|
||||
$v = json_decode( $s, true );
|
||||
if ( null !== $v ) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a divergence via the shared shadow diff logger.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $key Meta key.
|
||||
* @param string $pm_val Postmeta value (or '(missing)').
|
||||
* @param string $flat_val Flat value (or '(missing)').
|
||||
* @return void
|
||||
*/
|
||||
private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void {
|
||||
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
||||
return;
|
||||
}
|
||||
// Use the public record() method if the logger exposes one; otherwise
|
||||
// fall through silently. v2.5.x exposes compare_and_log() which does
|
||||
// internal compare; for verifier we already know they diverged so we
|
||||
// write directly to the table.
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return;
|
||||
}
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'entity_type' => 'post',
|
||||
'entity_id' => $post_id,
|
||||
'meta_key' => $key,
|
||||
'postmeta_value' => substr( $pm_val, 0, 1000 ),
|
||||
'zone_value' => substr( $flat_val, 0, 1000 ),
|
||||
'diff_hash' => md5( $pm_val . '|' . $flat_val ),
|
||||
'ts' => gmdate( 'Y-m-d H:i:s' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Postmeta_Cleaner — wp_postmeta garbage cleanup (v2.9.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes three classes of low-value rows from wp_postmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - transients — stale `_transient_*` and `_transient_timeout_*` rows
|
||||
* (often left by HivePress model version cache)
|
||||
* - wp_old_date — WP core internal record of post date changes (no app value)
|
||||
* - edit_locks — `_edit_lock` rows whose lock timestamp is > 24h old
|
||||
* (orphaned from interrupted edit sessions)
|
||||
*
|
||||
* Runs before any Entity Bridge migration so subsequent ratio measurements
|
||||
* reflect real data, not garbage. Pure DB layer — no Hook Bus / Entity Bridge
|
||||
* coupling so cleanup is safe even when post entity bridge is disabled.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wp_postmeta garbage cleanup (v2.9.0 Phase 0).
|
||||
*/
|
||||
class TMDO_Postmeta_Cleaner {
|
||||
|
||||
public const TARGET_TRANSIENTS = 'transients';
|
||||
public const TARGET_WP_OLD_DATE = 'wp_old_date';
|
||||
public const TARGET_EDIT_LOCKS = 'edit_locks';
|
||||
public const TARGET_ALL = 'all';
|
||||
|
||||
public const VALID_TARGETS = array(
|
||||
self::TARGET_TRANSIENTS,
|
||||
self::TARGET_WP_OLD_DATE,
|
||||
self::TARGET_EDIT_LOCKS,
|
||||
self::TARGET_ALL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Seconds after which an _edit_lock is considered stale.
|
||||
* WP refreshes locks on a 15-second heartbeat; 24h is intentionally
|
||||
* conservative to avoid disturbing any active edit session.
|
||||
*/
|
||||
private const EDIT_LOCK_STALE_THRESHOLD = 86400;
|
||||
|
||||
/**
|
||||
* Count rows that would be cleaned for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{transients:int, wp_old_date:int, edit_locks:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function count_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$counts = array(
|
||||
'transients' => 0,
|
||||
'wp_old_date' => 0,
|
||||
'edit_locks' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$counts['transients'] = self::count_transients();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) {
|
||||
$counts['wp_old_date'] = self::count_wp_old_date();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) {
|
||||
$counts['edit_locks'] = self::count_stale_edit_locks();
|
||||
}
|
||||
|
||||
$counts['total'] = $counts['transients'] + $counts['wp_old_date'] + $counts['edit_locks'];
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete garbage rows for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{transients:int, wp_old_date:int, edit_locks:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$deleted = array(
|
||||
'transients' => 0,
|
||||
'wp_old_date' => 0,
|
||||
'edit_locks' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$deleted['transients'] = self::delete_transients();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_WP_OLD_DATE ) ) {
|
||||
$deleted['wp_old_date'] = self::delete_wp_old_date();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_EDIT_LOCKS ) ) {
|
||||
$deleted['edit_locks'] = self::delete_stale_edit_locks();
|
||||
}
|
||||
|
||||
$deleted['total'] = $deleted['transients'] + $deleted['wp_old_date'] + $deleted['edit_locks'];
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $target selects $bucket (i.e. target=all or target=bucket).
|
||||
*
|
||||
* @param string $target Selected target.
|
||||
* @param string $bucket Bucket constant (TARGET_TRANSIENTS / WP_OLD_DATE / EDIT_LOCKS).
|
||||
* @return bool
|
||||
*/
|
||||
private static function target_includes( string $target, string $bucket ): bool {
|
||||
return self::TARGET_ALL === $target || $bucket === $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate target parameter.
|
||||
*
|
||||
* @param string $target Target to validate.
|
||||
* @return void
|
||||
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
|
||||
*/
|
||||
private static function assert_valid_target( string $target ): void {
|
||||
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
|
||||
return;
|
||||
}
|
||||
// Exception messages bubble up to PHP's error handler / WP_CLI; not user output.
|
||||
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->postmeta is WP-managed and never user input. PreparedSQL.InterpolatedNotPrepared fires on multi-line $wpdb->prepare() literals where the only interpolation is the trusted table name; user-controlled values use placeholders.
|
||||
|
||||
/**
|
||||
* Count rows matching transient meta_key patterns in wp_postmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows matching transient meta_key patterns from wp_postmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count _wp_old_date rows in wp_postmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_wp_old_date(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_wp_old_date'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete _wp_old_date rows from wp_postmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_wp_old_date(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key = '_wp_old_date'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count stale _edit_lock rows (lock_ts older than 24h) in wp_postmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_stale_edit_locks(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
$cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD;
|
||||
// _edit_lock format is "<unix_ts>:<user_id>"; SUBSTRING_INDEX extracts the timestamp.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stale _edit_lock rows (lock_ts older than 24h) from wp_postmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_stale_edit_locks(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->postmeta;
|
||||
$cutoff = time() - self::EDIT_LOCK_STALE_THRESHOLD;
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE meta_key = '_edit_lock' AND CAST(SUBSTRING_INDEX(meta_value, ':', 1) AS UNSIGNED) < %d",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Safe_Unserialize — Object-injection-safe replacement for
|
||||
* `maybe_unserialize()` (v2.13.3 — fixes L-DESER-1).
|
||||
*
|
||||
* `maybe_unserialize()` calls `unserialize()` with default options, which
|
||||
* materializes objects and triggers __wakeup / __destruct magic methods.
|
||||
* Attacker-planted serialized payloads in DB-stored values (postmeta,
|
||||
* usermeta, options) become RCE vectors when any vendor library exposes
|
||||
* a usable gadget chain.
|
||||
*
|
||||
* This wrapper passes `['allowed_classes' => false]` so PHP returns
|
||||
* `__PHP_Incomplete_Class` placeholders without ever invoking magic
|
||||
* methods on the original class. The placeholders are then walked out of
|
||||
* the result tree before returning, so they cannot leak into a flat-table
|
||||
* JSON column or downstream consumer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.13.3
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper class — drop-in replacement for `maybe_unserialize()`.
|
||||
*/
|
||||
final class TMDO_Safe_Unserialize {
|
||||
|
||||
/**
|
||||
* Object-injection-safe `maybe_unserialize()` equivalent.
|
||||
*
|
||||
* Same input/output contract as `maybe_unserialize()`:
|
||||
* - Non-string input is returned as-is.
|
||||
* - Non-serialized strings are returned as-is.
|
||||
* - Serialized arrays / scalars are unserialized with allowed_classes=false.
|
||||
* - Any object placeholders in the result are stripped to null.
|
||||
*
|
||||
* @param mixed $value Raw value (typically meta_value or option value).
|
||||
* @return mixed Unserialized array / scalar, or original string if not serialized.
|
||||
*/
|
||||
public static function run( $value ) {
|
||||
if ( ! is_string( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$trimmed = trim( $value );
|
||||
|
||||
// Cheap inline detector — does not rely on WP's is_serialized() so this
|
||||
// helper works in CLI / standalone contexts. PHP serialize tokens:
|
||||
// a (array), O (object), s (string), i (int), d (float), b (bool),
|
||||
// N; (null), C (custom class — also handled by allowed_classes=false).
|
||||
if ( 'N;' !== $trimmed ) {
|
||||
if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) {
|
||||
return $value;
|
||||
}
|
||||
if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b', 'C' ), true ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection.
|
||||
$result = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
|
||||
|
||||
// `unserialize()` returns false on parse error. The literal payload
|
||||
// `b:0;` legitimately deserializes to (bool) false, so distinguish that.
|
||||
if ( false === $result && 'b:0;' !== $trimmed ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces
|
||||
// any object marker with this stub). They must never reach a flat-table
|
||||
// JSON column or a downstream consumer that might try to access props.
|
||||
if ( is_object( $result ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_array( $result ) ) {
|
||||
array_walk_recursive(
|
||||
$result,
|
||||
static function ( &$v ) {
|
||||
if ( is_object( $v ) ) {
|
||||
$v = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
/**
|
||||
* Central registry for zone field configurations.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central registry for zone field configurations.
|
||||
*
|
||||
* Plugins register their meta_key → zone mappings here.
|
||||
* The registry drives table creation, migration, interceptors, and query routing.
|
||||
*
|
||||
* Usage:
|
||||
* $registry = TMDO_Schema_Registry::instance();
|
||||
* $registry->register( 'hivepress', [
|
||||
* 'post_type' => 'hp_listing',
|
||||
* 'meta_key' => 'hp_price',
|
||||
* 'zone' => 'hot',
|
||||
* 'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
||||
* 'column' => 'hp_price',
|
||||
* 'indexed' => true,
|
||||
* ]);
|
||||
*/
|
||||
class TMDO_Schema_Registry {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var self|null
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Registered field mappings.
|
||||
*
|
||||
* Structure: [ 'post_type:meta_key' => field_config, ... ]
|
||||
*
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $fields = array();
|
||||
|
||||
/**
|
||||
* Zone → post_type → columns map for Zone A (hot) table creation.
|
||||
*
|
||||
* @var array<string, array<string, array>>
|
||||
*/
|
||||
private array $hot_columns = array();
|
||||
|
||||
/**
|
||||
* Zone C (cold) fields grouped by post_type.
|
||||
*
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
private array $cold_fields = array();
|
||||
|
||||
/**
|
||||
* Zone B (warm) fields with TTL config.
|
||||
*
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $warm_fields = array();
|
||||
|
||||
/**
|
||||
* Private constructor — use instance() to get the singleton.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* Get the singleton instance.
|
||||
*/
|
||||
public static function instance(): self {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a field mapping.
|
||||
*
|
||||
* @param string $provider Provider name (e.g. 'hivepress', 'woocommerce').
|
||||
* @param array $config Field configuration:
|
||||
* - post_type (string) WordPress post type.
|
||||
* - meta_key (string) The wp_postmeta meta_key.
|
||||
* - zone (string) hot|warm|cold|archive
|
||||
* - data_type (string) SQL column type for Zone A (e.g. 'decimal(10,2) NOT NULL DEFAULT 0').
|
||||
* - column (string) Column name in zone table (defaults to sanitized meta_key).
|
||||
* - indexed (bool) Whether to add an index (Zone A only).
|
||||
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
||||
* - cache_group (string) Object cache group (Zone C only).
|
||||
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
||||
*/
|
||||
public function register( string $provider, array $config ): void {
|
||||
$config = wp_parse_args(
|
||||
$config,
|
||||
array(
|
||||
'post_type' => '',
|
||||
'entity_type' => '',
|
||||
'meta_key' => '',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'longtext',
|
||||
'column' => '',
|
||||
'indexed' => false,
|
||||
'ttl' => null,
|
||||
'cache_group' => '',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
'provider' => $provider,
|
||||
)
|
||||
);
|
||||
|
||||
// v2.1.2 fix: normalize post_type vs entity_type. Some partner plugins
|
||||
// (e.g. 2meet-liff) register fields against entities other than posts
|
||||
// (user / term / comment) using `entity_type` instead of `post_type`.
|
||||
// Without normalization, those fields end up with empty-string post_type,
|
||||
// polluting `get_hot_post_types()` output. Use entity_type as bucket key.
|
||||
if ( empty( $config['post_type'] ) && ! empty( $config['entity_type'] ) ) {
|
||||
$config['post_type'] = sanitize_key( $config['entity_type'] );
|
||||
}
|
||||
|
||||
if ( empty( $config['column'] ) ) {
|
||||
$config['column'] = sanitize_key( $config['meta_key'] );
|
||||
}
|
||||
|
||||
$key = $config['post_type'] . ':' . $config['meta_key'];
|
||||
$config['provider'] = $provider;
|
||||
$this->fields[ $key ] = $config;
|
||||
|
||||
// Index by zone for efficient lookup.
|
||||
switch ( $config['zone'] ) {
|
||||
case 'hot':
|
||||
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
||||
break;
|
||||
|
||||
case 'warm':
|
||||
$this->warm_fields[ $config['meta_key'] ] = $config;
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk register multiple fields.
|
||||
*
|
||||
* @param string $provider Provider name.
|
||||
* @param array $configs Array of field configs.
|
||||
*/
|
||||
public function register_many( string $provider, array $configs ): void {
|
||||
foreach ( $configs as $config ) {
|
||||
$this->register( $provider, $config );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the zone configuration for a specific field.
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return array|null Field config or null if not registered.
|
||||
*/
|
||||
public function get_field( string $post_type, string $meta_key ): ?array {
|
||||
$key = $post_type . ':' . $meta_key;
|
||||
return $this->fields[ $key ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fields for a specific zone.
|
||||
*
|
||||
* @param string $zone Zone identifier (hot/warm/cold/archive).
|
||||
* @return array Array of field configs.
|
||||
*/
|
||||
public function get_zone_fields( string $zone ): array {
|
||||
return array_filter( $this->fields, fn( $f ) => $f['zone'] === $zone );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fields for a specific zone and post type.
|
||||
*
|
||||
* @param string $zone Zone identifier.
|
||||
* @param string $post_type Post type.
|
||||
* @return array
|
||||
*/
|
||||
public function get_zone_fields_for_type( string $zone, string $post_type ): array {
|
||||
return array_filter(
|
||||
$this->fields,
|
||||
fn( $f ) => $f['zone'] === $zone && $f['post_type'] === $post_type
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hot column definitions for a post type (for table creation).
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @return array<string, string> Column name => SQL type.
|
||||
*/
|
||||
public function get_hot_columns( string $post_type ): array {
|
||||
return $this->hot_columns[ $post_type ] ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all post types that have hot zone fields.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_hot_post_types(): array {
|
||||
return array_keys( $this->hot_columns );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all post types that have cold zone fields.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function get_cold_post_types(): array {
|
||||
return array_keys( $this->cold_fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cold field meta keys for a post type.
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @return string[] Array of meta keys.
|
||||
*/
|
||||
public function get_cold_meta_keys( string $post_type ): array {
|
||||
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get warm field configuration.
|
||||
*
|
||||
* @param string $meta_key Meta key.
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_warm_field( string $meta_key ): ?array {
|
||||
return $this->warm_fields[ $meta_key ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which zone a meta_key belongs to for a given post type.
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return string|null Zone name or null if not registered.
|
||||
*/
|
||||
public function get_field_zone( string $post_type, string $meta_key ): ?string {
|
||||
$field = $this->get_field( $post_type, $meta_key );
|
||||
return $field['zone'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered fields (for admin dashboard / analysis).
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function all(): array {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for the admin dashboard.
|
||||
*
|
||||
* @return array{hot: int, warm: int, cold: int, archive: int, total: int}
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
$stats = array(
|
||||
'hot' => 0,
|
||||
'warm' => 0,
|
||||
'cold' => 0,
|
||||
'archive' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
foreach ( $this->fields as $field ) {
|
||||
if ( isset( $stats[ $field['zone'] ] ) ) {
|
||||
++$stats[ $field['zone'] ];
|
||||
}
|
||||
++$stats['total'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
/**
|
||||
* SQLite information schema compatibility for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the SQLite info_schema emulation table so dbDelta does not
|
||||
* re-run CREATE TABLE on every page load.
|
||||
*
|
||||
* The SQLite drop-in reads _wp_sqlite_mysql_information_schema_columns
|
||||
* to know which columns already exist. We must insert a row for each
|
||||
* column in each WPDO table immediately after dbDelta().
|
||||
*
|
||||
* Columns are inserted via INSERT OR IGNORE so the method is idempotent.
|
||||
*/
|
||||
class TMDO_SQLite_Compat {
|
||||
|
||||
/**
|
||||
* Patch all WPDO system tables into the SQLite info_schema.
|
||||
*/
|
||||
public static function patch_all(): void {
|
||||
if ( ! TMDO_IS_SQLITE ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$p = $wpdb->prefix;
|
||||
|
||||
$tables = self::get_system_column_definitions( $p );
|
||||
$schema_table = '_wp_sqlite_mysql_information_schema_columns';
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name.
|
||||
foreach ( $tables as $table_name => $columns ) {
|
||||
foreach ( $columns as $pos => $col ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT OR IGNORE INTO `{$schema_table}`
|
||||
(table_name, column_name, data_type, is_nullable, column_default, ordinal_position)
|
||||
VALUES (%s, %s, %s, %s, %s, %d)",
|
||||
$table_name,
|
||||
$col['name'],
|
||||
$col['type'],
|
||||
$col['nullable'] ? 'YES' : 'NO',
|
||||
$col['default'],
|
||||
$pos + 1
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a dynamic zone table (e.g. wpdo_hot_hp_listing) into the SQLite info_schema.
|
||||
*
|
||||
* @param string $table_name Full table name with prefix.
|
||||
* @param array $columns Array of column definitions.
|
||||
*/
|
||||
public static function patch_table( string $table_name, array $columns ): void {
|
||||
if ( ! TMDO_IS_SQLITE ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$schema_table = '_wp_sqlite_mysql_information_schema_columns';
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SQLite driver's fixed internal table name.
|
||||
foreach ( $columns as $pos => $col ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT OR IGNORE INTO `{$schema_table}`
|
||||
(table_name, column_name, data_type, is_nullable, column_default, ordinal_position)
|
||||
VALUES (%s, %s, %s, %s, %s, %d)",
|
||||
$table_name,
|
||||
$col['name'],
|
||||
$col['type'],
|
||||
$col['nullable'] ? 'YES' : 'NO',
|
||||
$col['default'],
|
||||
$pos + 1
|
||||
)
|
||||
);
|
||||
}
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Column definitions for WPDO system tables (migrations, errors, benchmarks)
|
||||
* and zone infrastructure tables (warm, archive).
|
||||
*
|
||||
* @param string $p Table prefix.
|
||||
* @return array Table column definitions array.
|
||||
*/
|
||||
private static function get_system_column_definitions( string $p ): array {
|
||||
return array(
|
||||
"{$p}wpdo_migrations" => array(
|
||||
array(
|
||||
'name' => 'id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'module',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'zone',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'state',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => 'idle',
|
||||
),
|
||||
array(
|
||||
'name' => 'total_rows',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'processed_rows',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'last_offset',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'error_count',
|
||||
'type' => 'int',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'started_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'completed_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'created_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
array(
|
||||
'name' => 'updated_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
),
|
||||
"{$p}wpdo_errors" => array(
|
||||
array(
|
||||
'name' => 'id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'module',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'zone',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'hook',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'message',
|
||||
'type' => 'longtext',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'context',
|
||||
'type' => 'longtext',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'created_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
),
|
||||
"{$p}wpdo_benchmarks" => array(
|
||||
array(
|
||||
'name' => 'id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'module',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'zone',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'query_type',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'native_ms',
|
||||
'type' => 'decimal',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'custom_ms',
|
||||
'type' => 'decimal',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'sample_size',
|
||||
'type' => 'int',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'created_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
),
|
||||
"{$p}wpdo_warm" => array(
|
||||
array(
|
||||
'name' => 'id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'post_id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'meta_key',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'meta_value',
|
||||
'type' => 'longtext',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'expires_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'created_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
),
|
||||
"{$p}wpdo_archive" => array(
|
||||
array(
|
||||
'name' => 'id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'post_id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'post_type',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'meta_key',
|
||||
'type' => 'varchar',
|
||||
'nullable' => false,
|
||||
'default' => '',
|
||||
),
|
||||
array(
|
||||
'name' => 'meta_value',
|
||||
'type' => 'longtext',
|
||||
'nullable' => true,
|
||||
'default' => null,
|
||||
),
|
||||
array(
|
||||
'name' => 'compressed',
|
||||
'type' => 'tinyint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
array(
|
||||
'name' => 'archived_at',
|
||||
'type' => 'datetime',
|
||||
'nullable' => false,
|
||||
'default' => '0000-00-00 00:00:00',
|
||||
),
|
||||
array(
|
||||
'name' => 'original_meta_id',
|
||||
'type' => 'bigint',
|
||||
'nullable' => false,
|
||||
'default' => '0',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Backfill — Pivot-style backfill from wp_*meta to flat
|
||||
* tables for term + comment entities (v2.12.6 Phase 6).
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* Phase 1–5 cover write-time interception (anything written via the
|
||||
* metadata API after v2.12.x activation goes to flat tables / wp_options /
|
||||
* misc bucket / drop). But **historical** wp_termmeta / wp_commentmeta rows
|
||||
* predating v2.12.x are still in those tables — they were never intercepted
|
||||
* because they were already there.
|
||||
*
|
||||
* Phase 5's shadow_read verifier surfaces these as `missing_flat` drift
|
||||
* (e.g. dev10 baseline: 42 term + 23 comment = 65 missing_flat rows).
|
||||
* Without backfill, promoting to aeav_only would silently drop reads of
|
||||
* those historical values (the read path goes flat → empty → fall-through
|
||||
* to wp_termmeta DOES still work for now, but only because we left the
|
||||
* fall-through in place; in v3.0.0 we DROP the source tables).
|
||||
*
|
||||
* Strategy
|
||||
* --------
|
||||
* One-shot SQL pivot per group, mirroring the user-side backfill pattern
|
||||
* (v2.5.5 user_membership backfill, etc.):
|
||||
*
|
||||
* INSERT INTO wp_wpdo_term_hp_taxonomy (term_id, hp_sort_order, hp_default, hp_icon)
|
||||
* SELECT t.term_id,
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_sort_order' THEN tm.meta_value END),
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_default' THEN tm.meta_value END),
|
||||
* MAX(CASE WHEN tm.meta_key = 'hp_icon' THEN tm.meta_value END)
|
||||
* FROM wp_terms t
|
||||
* INNER JOIN wp_termmeta tm ON tm.term_id = t.term_id
|
||||
* WHERE tm.meta_key IN (...registered keys...)
|
||||
* GROUP BY t.term_id
|
||||
* ON DUPLICATE KEY UPDATE
|
||||
* hp_sort_order = COALESCE(VALUES(hp_sort_order), hp_sort_order),
|
||||
* ...
|
||||
*
|
||||
* COALESCE preserves any value already in the flat table when the wp_*meta
|
||||
* side has NULL for that key — important for partial-coverage backfills
|
||||
* where some keys are present and others aren't on a given entity.
|
||||
*
|
||||
* 🔒 Frozen contract: never touches user / post entities; only term/comment.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.6
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Pivot SQL: keys + table from registry, parameterized via prepare placeholders for keys list. One-shot ops command.
|
||||
|
||||
/**
|
||||
* Pivot-style backfill from wp_termmeta / wp_commentmeta to flat tables.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Backfill {
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string}>
|
||||
*/
|
||||
private const GROUP_MAP = array(
|
||||
'hp_taxonomy' => array(
|
||||
'entity_type' => 'term',
|
||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
||||
),
|
||||
'hp_review' => array(
|
||||
'entity_type' => 'comment',
|
||||
'table_suffix' => 'wpdo_comment_hp_review',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Run backfill for every registered term + comment group.
|
||||
*
|
||||
* @param bool $dry_run When true, only count rows without writing.
|
||||
* @return array<string, array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool}>
|
||||
*/
|
||||
public static function backfill_all( bool $dry_run = false ): array {
|
||||
$results = array();
|
||||
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
|
||||
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run backfill for a single group.
|
||||
*
|
||||
* @param string $entity_type 'term' or 'comment'.
|
||||
* @param string $group_name Entity group name.
|
||||
* @param bool $dry_run When true, only count rows without writing.
|
||||
* @return array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool, error?:string}
|
||||
*/
|
||||
public static function backfill_group( string $entity_type, string $group_name, bool $dry_run = false ): array {
|
||||
$result = array(
|
||||
'group' => $group_name,
|
||||
'entity_type' => $entity_type,
|
||||
'candidates' => 0,
|
||||
'written' => 0,
|
||||
'dry_run' => $dry_run,
|
||||
);
|
||||
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
$result['error'] = 'Entity Registry unavailable';
|
||||
return $result;
|
||||
}
|
||||
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
|
||||
$result['error'] = "Unknown group: {$group_name}";
|
||||
return $result;
|
||||
}
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
|
||||
if ( empty( $keys ) ) {
|
||||
$result['error'] = "No keys registered for {$entity_type}:{$group_name}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Resolve source / meta tables + id columns per entity type.
|
||||
if ( 'term' === $entity_type ) {
|
||||
$source_table = $wpdb->terms;
|
||||
$source_id_col = 'term_id';
|
||||
$meta_table = $wpdb->termmeta;
|
||||
$meta_id_col = 'term_id';
|
||||
$flat_id_col = 'term_id';
|
||||
} elseif ( 'comment' === $entity_type ) {
|
||||
$source_table = $wpdb->comments;
|
||||
$source_id_col = 'comment_ID';
|
||||
$meta_table = $wpdb->commentmeta;
|
||||
$meta_id_col = 'comment_id';
|
||||
$flat_id_col = 'comment_id';
|
||||
} else {
|
||||
$result['error'] = "Unsupported entity_type: {$entity_type}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
|
||||
|
||||
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||
$candidate_query = $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT s.`{$source_id_col}`)
|
||||
FROM `{$source_table}` s
|
||||
INNER JOIN `{$meta_table}` m ON m.`{$meta_id_col}` = s.`{$source_id_col}`
|
||||
WHERE m.meta_key IN ({$placeholders})",
|
||||
...$keys
|
||||
);
|
||||
$result['candidates'] = (int) $wpdb->get_var( $candidate_query );
|
||||
|
||||
if ( $dry_run ) {
|
||||
return $result;
|
||||
}
|
||||
if ( 0 === $result['candidates'] ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Build SELECT clause: id + one MAX(CASE) per registered key (sanitized to flat column).
|
||||
$select_parts = array( "s.`{$source_id_col}` AS id" );
|
||||
$update_clauses = array();
|
||||
$insert_cols = array( "`{$flat_id_col}`" );
|
||||
|
||||
foreach ( $keys as $key ) {
|
||||
$col = self::sanitize_column( $key );
|
||||
$insert_cols[] = "`{$col}`";
|
||||
$select_parts[] = $wpdb->prepare(
|
||||
"MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS `{$col}`",
|
||||
$key
|
||||
);
|
||||
$update_clauses[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
|
||||
}
|
||||
|
||||
$insert_sql = sprintf(
|
||||
'INSERT INTO `%s` (%s)
|
||||
SELECT %s
|
||||
FROM `%s` s
|
||||
INNER JOIN `%s` m ON m.`%s` = s.`%s`
|
||||
WHERE m.meta_key IN (%s)
|
||||
GROUP BY s.`%s`
|
||||
ON DUPLICATE KEY UPDATE %s',
|
||||
$flat_table,
|
||||
implode( ', ', $insert_cols ),
|
||||
implode( ",\n ", $select_parts ),
|
||||
$source_table,
|
||||
$meta_table,
|
||||
$meta_id_col,
|
||||
$source_id_col,
|
||||
$placeholders,
|
||||
$source_id_col,
|
||||
implode( ', ', $update_clauses )
|
||||
);
|
||||
|
||||
$prepared = $wpdb->prepare( $insert_sql, ...$keys );
|
||||
$ok = $wpdb->query( $prepared );
|
||||
|
||||
if ( false === $ok ) {
|
||||
$result['error'] = $wpdb->last_error ?: 'Backfill query failed';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// MySQL `INSERT ... ON DUPLICATE KEY UPDATE` returns 1 per insert, 2 per
|
||||
// update — divide by 2 with floor for a meaningful "rows touched" estimate
|
||||
// only when both sides equal candidates. Easier: just report candidates
|
||||
// since that's the upper bound and ON DUPLICATE replaces.
|
||||
$result['written'] = $result['candidates'];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
|
||||
*
|
||||
* @param string $key Meta key.
|
||||
* @return string
|
||||
*/
|
||||
private static function sanitize_column( string $key ): string {
|
||||
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
|
||||
return TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
}
|
||||
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Shadow_Verifier — Sample-and-compare flat vs wp_*meta
|
||||
* for term + comment entities (v2.12.5 Phase 5).
|
||||
*
|
||||
* Mirror of TMDO_Post_Shadow_Verifier (v2.10.3). Used during shadow_read mode
|
||||
* to verify the flat tables stay in sync with wp_termmeta / wp_commentmeta.
|
||||
* Each sample picks a random entity + key, fetches both values, counts
|
||||
* matches / diffs / missing rows. Divergent values are written to
|
||||
* `wpdo_shadow_diffs` via TMDO_Shadow_Diff_Logger (entity_type-aware).
|
||||
*
|
||||
* Run pattern:
|
||||
* - Manual: `wp wpdo term-comment-shadow-report`
|
||||
* - Cron: wpdo_term_comment_shadow_verify event registered when EITHER
|
||||
* term or comment mode is shadow_read; auto-unregistered when
|
||||
* both modes are not shadow_read
|
||||
*
|
||||
* 🔒 Frozen contract: read-only verifier. Never modifies wp_termmeta /
|
||||
* wp_commentmeta or flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.5
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Read-only.
|
||||
|
||||
/**
|
||||
* Sample-and-compare verifier for term + comment entity flat tables.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
|
||||
/** Cron event hook fired hourly when at least one of term/comment modes is shadow_read. */
|
||||
public const CRON_HOOK = 'wpdo_term_comment_shadow_verify';
|
||||
|
||||
/** Default sample size per entity per cron tick. */
|
||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Keep in sync with
|
||||
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
||||
*/
|
||||
private const GROUP_MAP = array(
|
||||
'hp_taxonomy' => array(
|
||||
'entity_type' => 'term',
|
||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
||||
),
|
||||
'hp_review' => array(
|
||||
'entity_type' => 'comment',
|
||||
'table_suffix' => 'wpdo_comment_hp_review',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Run a sample-compare pass for a single entity group.
|
||||
*
|
||||
* @param string $entity_type 'term' or 'comment'.
|
||||
* @param string $group_name Entity group name.
|
||||
* @param string $flat_table Fully-qualified flat table name.
|
||||
* @param string[] $keys Meta keys to compare.
|
||||
* @param int $sample_size Number of entities to sample.
|
||||
* @return array{
|
||||
* sampled:int,
|
||||
* matched:int,
|
||||
* diffs:int,
|
||||
* missing_flat:int,
|
||||
* missing_meta:int,
|
||||
* group:string,
|
||||
* entity_type:string,
|
||||
* }
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function sample_compare(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
string $flat_table,
|
||||
array $keys,
|
||||
int $sample_size = self::DEFAULT_SAMPLE_SIZE
|
||||
): array {
|
||||
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
|
||||
$msg = 'entity_type must be term or comment';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
if ( $sample_size <= 0 ) {
|
||||
$msg = 'Sample size must be > 0';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
if ( empty( $keys ) ) {
|
||||
$msg = 'Keys array cannot be empty';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Source table + id column + meta table differ per entity type.
|
||||
if ( 'term' === $entity_type ) {
|
||||
$source_table = $wpdb->terms;
|
||||
$source_id_col = 'term_id';
|
||||
$meta_table = $wpdb->termmeta;
|
||||
$meta_id_col = 'term_id';
|
||||
$flat_id_col = 'term_id';
|
||||
} else {
|
||||
$source_table = $wpdb->comments;
|
||||
$source_id_col = 'comment_ID';
|
||||
$meta_table = $wpdb->commentmeta;
|
||||
$meta_id_col = 'comment_id';
|
||||
$flat_id_col = 'comment_id';
|
||||
}
|
||||
|
||||
$ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT `{$source_id_col}` FROM `{$source_table}` ORDER BY RAND() LIMIT %d",
|
||||
$sample_size
|
||||
)
|
||||
);
|
||||
|
||||
$sampled = count( $ids );
|
||||
$matched = 0;
|
||||
$diffs = 0;
|
||||
$missing_flat = 0;
|
||||
$missing_meta = 0;
|
||||
|
||||
if ( 0 === $sampled ) {
|
||||
return array(
|
||||
'sampled' => 0,
|
||||
'matched' => 0,
|
||||
'diffs' => 0,
|
||||
'missing_flat' => 0,
|
||||
'missing_meta' => 0,
|
||||
'group' => $group_name,
|
||||
'entity_type' => $entity_type,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ( $ids as $object_id ) {
|
||||
$object_id = (int) $object_id;
|
||||
foreach ( $keys as $key ) {
|
||||
$col = self::sanitize_column( $key );
|
||||
$flat_val = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT `{$col}` FROM `{$flat_table}` WHERE `{$flat_id_col}` = %d LIMIT 1",
|
||||
$object_id
|
||||
)
|
||||
);
|
||||
$meta_val = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$meta_table}` WHERE `{$meta_id_col}` = %d AND meta_key = %s LIMIT 1",
|
||||
$object_id,
|
||||
$key
|
||||
)
|
||||
);
|
||||
|
||||
$flat_present = null !== $flat_val && '' !== $flat_val;
|
||||
$meta_present = null !== $meta_val && '' !== $meta_val;
|
||||
|
||||
// In-memory counters only. Hook Bus auto-logs diffs to
|
||||
// wpdo_shadow_diffs on every read in shadow_read mode (via
|
||||
// TMDO_Shadow_Diff_Logger::compare_and_log) — this verifier
|
||||
// is a sample-based snapshot, not the persistent log.
|
||||
if ( ! $flat_present && ! $meta_present ) {
|
||||
++$matched;
|
||||
continue;
|
||||
}
|
||||
if ( ! $flat_present && $meta_present ) {
|
||||
++$missing_flat;
|
||||
continue;
|
||||
}
|
||||
if ( $flat_present && ! $meta_present ) {
|
||||
++$missing_meta;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( self::values_loose_equal( $meta_val, $flat_val ) ) {
|
||||
++$matched;
|
||||
} else {
|
||||
++$diffs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'sampled' => $sampled,
|
||||
'matched' => $matched,
|
||||
'diffs' => $diffs,
|
||||
'missing_flat' => $missing_flat,
|
||||
'missing_meta' => $missing_meta,
|
||||
'group' => $group_name,
|
||||
'entity_type' => $entity_type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: runs sample_compare for every group whose entity is in
|
||||
* shadow_read. No-op when neither term nor comment is shadow_read.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function cron_tick(): void {
|
||||
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
$term_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'term' );
|
||||
$comment_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
|
||||
if ( ! $term_shadow && ! $comment_shadow ) {
|
||||
return;
|
||||
}
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
||||
$entity_type = $cfg['entity_type'];
|
||||
|
||||
// Only run when the entity is in shadow_read mode.
|
||||
if ( 'term' === $entity_type && ! $term_shadow ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'comment' === $entity_type && ! $comment_shadow ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||
if ( empty( $keys ) ) {
|
||||
continue;
|
||||
}
|
||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
||||
|
||||
try {
|
||||
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
||||
} catch ( \Throwable $e ) {
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::error(
|
||||
'term_comment_shadow_verifier',
|
||||
'cron_tick',
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent diff records for term + comment entity types.
|
||||
*
|
||||
* @param int $limit Max rows.
|
||||
* @return array
|
||||
*/
|
||||
public static function recent_diffs( int $limit = 100 ): array {
|
||||
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Logger is entity-type-aware; gather both sides.
|
||||
$term_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'term' );
|
||||
$comment_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'comment' );
|
||||
|
||||
return array_merge( (array) $term_diffs, (array) $comment_diffs );
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate diff stats for term+comment over recent N hours.
|
||||
*
|
||||
* @param int $hours Window (default 24).
|
||||
* @return array{total:int,term:int,comment:int,by_key:array<string,int>}
|
||||
*/
|
||||
public static function diff_stats( int $hours = 24 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return array(
|
||||
'total' => 0,
|
||||
'term' => 0,
|
||||
'comment' => 0,
|
||||
'by_key' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
|
||||
|
||||
$term_total = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'term' AND ts >= %s",
|
||||
$since
|
||||
)
|
||||
);
|
||||
$comment_total = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'comment' AND ts >= %s",
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type IN ('term','comment') AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
|
||||
$since
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$by_key = array();
|
||||
foreach ( (array) $rows as $row ) {
|
||||
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
|
||||
}
|
||||
|
||||
return array(
|
||||
'total' => $term_total + $comment_total,
|
||||
'term' => $term_total,
|
||||
'comment' => $comment_total,
|
||||
'by_key' => $by_key,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sample_compare for ALL groups (regardless of mode). Used by
|
||||
* `wp wpdo term-comment-shadow-report` so users can see drift even
|
||||
* before promoting to shadow_read.
|
||||
*
|
||||
* @param int $sample_size Per-group sample size.
|
||||
* @return array<string,array> Map of `group_name` → sample_compare() result.
|
||||
*/
|
||||
public static function run_all( int $sample_size = self::DEFAULT_SAMPLE_SIZE ): array {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
|
||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
||||
$entity_type = $cfg['entity_type'];
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||
if ( empty( $keys ) ) {
|
||||
continue;
|
||||
}
|
||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
||||
|
||||
try {
|
||||
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
||||
} catch ( \Throwable $e ) {
|
||||
$out[ $group ] = array(
|
||||
'error' => $e->getMessage(),
|
||||
'group' => $group,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
|
||||
*
|
||||
* @param string $key Meta key.
|
||||
* @return string
|
||||
*/
|
||||
private static function sanitize_column( string $key ): string {
|
||||
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
|
||||
return TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
}
|
||||
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose equality for verifier — handles type widening and serialization
|
||||
* format differences. Layers fail-fast: identical → numeric → decoded.
|
||||
*
|
||||
* @param mixed $a Side A value.
|
||||
* @param mixed $b Side B value.
|
||||
* @return bool
|
||||
*/
|
||||
private static function values_loose_equal( $a, $b ): bool {
|
||||
// 1. Identical strings.
|
||||
if ( (string) $a === (string) $b ) {
|
||||
return true;
|
||||
}
|
||||
// 2. Numeric loose match.
|
||||
if ( is_numeric( $a ) && is_numeric( $b ) && (float) $a === (float) $b ) {
|
||||
return true;
|
||||
}
|
||||
// 3. Decoded match (serialized vs JSON).
|
||||
$decoded_a = self::decode_value( (string) $a );
|
||||
$decoded_b = self::decode_value( (string) $b );
|
||||
return $decoded_a === $decoded_b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a stored value: try unserialize → JSON decode → as-is.
|
||||
*
|
||||
* @param string $value Raw stored value.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function decode_value( string $value ) {
|
||||
// Try unserialize for postmeta-style serialized payloads.
|
||||
if ( '' !== $value ) {
|
||||
$first_two = substr( $value, 0, 2 );
|
||||
if ( 'a:' === $first_two || 'O:' === $first_two || 's:' === $first_two || 'i:' === $first_two || 'b:' === $first_two || 'd:' === $first_two ) {
|
||||
$unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors
|
||||
if ( false !== $unserialized || 'b:0;' === $value ) {
|
||||
return $unserialized;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try JSON for flat-table json-typed columns.
|
||||
if ( '' !== $value && ( '{' === $value[0] || '[' === $value[0] ) ) {
|
||||
$json = json_decode( $value, true );
|
||||
if ( null !== $json ) {
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||
/**
|
||||
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
|
||||
*
|
||||
* Mirrors TMDO_Post_Stress_Tester (v2.11.4) for the term entity. Provides:
|
||||
*
|
||||
* - State machine: start / cancel / get_progress / pump_if_due / run_batch / finalize
|
||||
* - Fast mode: bulk INSERT to wp_terms + wp_term_taxonomy + wp_termmeta + flat
|
||||
* - Realistic mode: wp_insert_term + update_term_meta (Hook Bus auto-routes)
|
||||
* - Cron pump for nginx-safe long runs
|
||||
* - Per-batch wall-clock deadline (BATCH_DEADLINE_SEC)
|
||||
* - Benchmark on completion: write metrics + DB sizes + query performance
|
||||
*
|
||||
* Test terms use slug prefix `wpdo-stress-` for unambiguous identification —
|
||||
* cleanup() deletes only those + all matching meta + flat rows.
|
||||
*
|
||||
* 🔒 v2.13.x frozen contract: never touches user / post / comment entities or
|
||||
* their flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.13.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->terms / wp_term_taxonomy / wp_termmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders.
|
||||
|
||||
/**
|
||||
* Async bulk fixture generator for term entity stress tests.
|
||||
*/
|
||||
final class TMDO_Term_Stress_Tester {
|
||||
|
||||
/** Slug prefix for stress test terms — used for cleanup matching. */
|
||||
public const TEST_TERM_PREFIX = 'wpdo-stress-';
|
||||
|
||||
/** Hard cap to prevent runaway calls. */
|
||||
public const MAX_COUNT = 100000;
|
||||
|
||||
// State machine constants (mirror post v2.11.4).
|
||||
public const OPT_STATE = 'wpdo_term_stress_test_state';
|
||||
public const CRON_HOOK = 'wpdo_term_stress_test_batch';
|
||||
public const CANCEL_FLAG = 'wpdo_term_stress_cancel_flag';
|
||||
public const DEFAULT_BATCH_SIZE = 200;
|
||||
public const MAX_BATCH_SIZE = 1000;
|
||||
public const MIN_BATCH_DELAY_SEC = 1;
|
||||
public const BATCH_DEADLINE_SEC = 8;
|
||||
public const MODE_FAST = 'fast';
|
||||
public const MODE_REALISTIC = 'realistic';
|
||||
|
||||
/**
|
||||
* Per-key seed map (single hp_taxonomy group). All keys go to
|
||||
* `wp_wpdo_term_hp_taxonomy` flat table when term mode is dual_write+.
|
||||
*
|
||||
* @var array<string,mixed>|null
|
||||
*/
|
||||
private static ?array $seed_map_cache = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public API — sync helpers (also used internally by state machine batches)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bulk-create N stress test terms in the given taxonomy via direct SQL
|
||||
* (Fast mode — bypasses WP filter chain). Use for fixture loading where
|
||||
* Hook Bus routing is not the goal.
|
||||
*
|
||||
* @param string $taxonomy WP taxonomy slug (e.g., listing_category, category).
|
||||
* @param int $count Number of terms to insert. Capped at MAX_COUNT.
|
||||
* @return array{created:int,taxonomy:string,first_id:int|null,last_id:int|null}
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function create( string $taxonomy, int $count ): array {
|
||||
self::validate_inputs( $taxonomy, $count );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$first_id = null;
|
||||
$last_id = null;
|
||||
$created = 0;
|
||||
|
||||
$seed_map = self::seed_map();
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
|
||||
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
|
||||
|
||||
$ok = $wpdb->insert(
|
||||
$wpdb->terms,
|
||||
array(
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'term_group' => 0,
|
||||
)
|
||||
);
|
||||
if ( ! $ok ) {
|
||||
continue;
|
||||
}
|
||||
$term_id = (int) $wpdb->insert_id;
|
||||
|
||||
$ok2 = $wpdb->insert(
|
||||
$wpdb->prefix . 'term_taxonomy',
|
||||
array(
|
||||
'term_id' => $term_id,
|
||||
'taxonomy' => $taxonomy,
|
||||
'description' => '',
|
||||
'parent' => 0,
|
||||
'count' => 0,
|
||||
)
|
||||
);
|
||||
if ( ! $ok2 ) {
|
||||
$wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( null === $first_id ) {
|
||||
$first_id = $term_id;
|
||||
}
|
||||
$last_id = $term_id;
|
||||
++$created;
|
||||
|
||||
// Direct INSERT to wp_termmeta (bypassing Hook Bus). For aeav_only
|
||||
// mode validation, use create_realistic() instead.
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
$wpdb->insert(
|
||||
$wpdb->termmeta,
|
||||
array(
|
||||
'term_id' => $term_id,
|
||||
'meta_key' => $meta_key,
|
||||
'meta_value' => (string) $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'created' => $created,
|
||||
'taxonomy' => $taxonomy,
|
||||
'first_id' => $first_id,
|
||||
'last_id' => $last_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Realistic-mode counterpart — uses wp_insert_term() + update_term_meta()
|
||||
* so Hook Bus / entity registry / mode_manager all engage on the write
|
||||
* path. Use this for validating mode=dual_write+ behavior.
|
||||
*
|
||||
* @param string $taxonomy WP taxonomy slug.
|
||||
* @param int $count Number of terms to insert.
|
||||
* @return array{created:int,taxonomy:string,mode:string,first_id:int|null,last_id:int|null}
|
||||
* @throws InvalidArgumentException When inputs invalid.
|
||||
*/
|
||||
public static function create_realistic( string $taxonomy, int $count ): array {
|
||||
self::validate_inputs( $taxonomy, $count );
|
||||
|
||||
$first_id = null;
|
||||
$last_id = null;
|
||||
$created = 0;
|
||||
$seed_map = self::seed_map();
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
|
||||
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
|
||||
|
||||
$result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
continue;
|
||||
}
|
||||
$term_id = (int) ( $result['term_id'] ?? 0 );
|
||||
if ( 0 === $term_id ) {
|
||||
continue;
|
||||
}
|
||||
if ( null === $first_id ) {
|
||||
$first_id = $term_id;
|
||||
}
|
||||
$last_id = $term_id;
|
||||
++$created;
|
||||
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
update_term_meta( $term_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'created' => $created,
|
||||
'taxonomy' => $taxonomy,
|
||||
'mode' => 'realistic',
|
||||
'first_id' => $first_id,
|
||||
'last_id' => $last_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count terms whose slug begins with TEST_TERM_PREFIX.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function count_test_terms(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->terms} WHERE slug LIKE %s",
|
||||
$wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every stress-test term + its term_taxonomy + termmeta rows + flat
|
||||
* table rows in wpdo_term_*.
|
||||
*
|
||||
* @return array{deleted_terms:int,deleted_meta:int,deleted_flat:int}
|
||||
*/
|
||||
public static function cleanup(): array {
|
||||
global $wpdb;
|
||||
|
||||
// Cancel any in-flight job + clear pump lock.
|
||||
set_transient( self::CANCEL_FLAG, 1, 600 );
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$term_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT term_id FROM {$wpdb->terms} WHERE slug LIKE %s",
|
||||
$wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%'
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $term_ids ) ) {
|
||||
return array(
|
||||
'deleted_terms' => 0,
|
||||
'deleted_meta' => 0,
|
||||
'deleted_flat' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$id_list = implode( ',', array_map( 'absint', $term_ids ) );
|
||||
$flat_deleted = 0;
|
||||
|
||||
// Cascade flat tables first (best-effort — tables may not exist yet).
|
||||
foreach ( self::get_term_flat_tables() as $tbl ) {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl ) );
|
||||
if ( ! $exists ) {
|
||||
continue;
|
||||
}
|
||||
$rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE term_id IN ({$id_list})" );
|
||||
$flat_deleted += $rows;
|
||||
}
|
||||
|
||||
// Delete termmeta.
|
||||
$meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE term_id IN ({$id_list})" );
|
||||
|
||||
// Delete term_taxonomy.
|
||||
$wpdb->query( "DELETE FROM {$wpdb->prefix}term_taxonomy WHERE term_id IN ({$id_list})" );
|
||||
|
||||
// Delete terms.
|
||||
$term_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->terms} WHERE term_id IN ({$id_list})" );
|
||||
|
||||
// Reset state.
|
||||
delete_option( self::OPT_STATE );
|
||||
delete_transient( self::CANCEL_FLAG );
|
||||
|
||||
// Clean WP term caches per term_id (clean_term_cache works on arrays).
|
||||
clean_term_cache( array_map( 'absint', $term_ids ) );
|
||||
|
||||
return array(
|
||||
'deleted_terms' => $term_deleted,
|
||||
'deleted_meta' => $meta_deleted,
|
||||
'deleted_flat' => $flat_deleted,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// State machine
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start an async stress run. Persists state, schedules the first batch
|
||||
* via wp_schedule_single_event(). First batch is pushed by cron or by
|
||||
* pump_if_due() on next polling request.
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @param int $target Total terms to create.
|
||||
* @param string $mode MODE_FAST | MODE_REALISTIC.
|
||||
* @param int $batch_size Per-batch insert count.
|
||||
* @return array{ok:bool,error?:string,state?:array}
|
||||
*/
|
||||
public static function start( string $taxonomy, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
|
||||
if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'unknown_taxonomy: ' . $taxonomy,
|
||||
);
|
||||
}
|
||||
if ( $target < 1 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'target must be >= 1',
|
||||
);
|
||||
}
|
||||
if ( $target > self::MAX_COUNT ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'target too large (max ' . self::MAX_COUNT . ')',
|
||||
);
|
||||
}
|
||||
if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'invalid mode',
|
||||
);
|
||||
}
|
||||
$batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) );
|
||||
|
||||
$current = self::get_state();
|
||||
if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'already_running',
|
||||
'state' => $current,
|
||||
);
|
||||
}
|
||||
|
||||
$state = array(
|
||||
'job_id' => uniqid( 'tstress_', true ),
|
||||
'status' => 'running',
|
||||
'mode' => $mode,
|
||||
'taxonomy' => $taxonomy,
|
||||
'target' => $target,
|
||||
'batch_size' => $batch_size,
|
||||
'started_at' => time(),
|
||||
'processed' => 0,
|
||||
'batches_done' => 0,
|
||||
'batches_log' => array(),
|
||||
'errors' => array(),
|
||||
'peak_memory' => 0,
|
||||
'completed_at' => null,
|
||||
'last_pushed_at' => 0,
|
||||
'benchmark' => null,
|
||||
);
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
delete_transient( self::CANCEL_FLAG );
|
||||
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
wp_schedule_single_event( time(), self::CRON_HOOK );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'state' => $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running stress test.
|
||||
*
|
||||
* @return array{ok:bool,state?:array,message?:string}
|
||||
*/
|
||||
public static function cancel(): array {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) ) {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => 'no_active_job',
|
||||
);
|
||||
}
|
||||
|
||||
set_transient( self::CANCEL_FLAG, 1, 600 );
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$state = self::get_state();
|
||||
$state['status'] = 'cancelled';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'state' => $state,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_state(): array {
|
||||
$state = get_option( self::OPT_STATE, array() );
|
||||
return is_array( $state ) ? $state : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress with computed pct/rate/ETA. Opportunistically pumps
|
||||
* if cron is overdue.
|
||||
*
|
||||
* @param bool $pump When true, pump_if_due() runs.
|
||||
* @return array
|
||||
*/
|
||||
public static function get_progress( bool $pump = true ): array {
|
||||
if ( $pump ) {
|
||||
self::pump_if_due();
|
||||
}
|
||||
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) ) {
|
||||
return array(
|
||||
'status' => 'idle',
|
||||
'processed' => 0,
|
||||
'target' => 0,
|
||||
'pct' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$target = (int) ( $state['target'] ?? 0 );
|
||||
$started = (int) ( $state['started_at'] ?? 0 );
|
||||
$ended = (int) ( $state['completed_at'] ?? 0 );
|
||||
|
||||
$now = $ended > 0 ? $ended : time();
|
||||
$elapsed = max( 1, $now - $started );
|
||||
$rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0;
|
||||
$eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0;
|
||||
$pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0;
|
||||
|
||||
return array_merge(
|
||||
$state,
|
||||
array(
|
||||
'pct' => $pct,
|
||||
'rate_per_sec' => $rate,
|
||||
'elapsed_sec' => $elapsed,
|
||||
'eta_sec' => $eta_sec,
|
||||
'test_term_count' => self::count_test_terms(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistic batch pump (transient-locked).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function pump_if_due(): void {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
|
||||
if ( time() - $last_pushed_at < 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock_key = 'wpdo_term_stress_pump_lock';
|
||||
if ( false !== get_transient( $lock_key ) ) {
|
||||
return;
|
||||
}
|
||||
set_transient( $lock_key, 1, 30 );
|
||||
|
||||
if ( function_exists( 'set_time_limit' ) ) {
|
||||
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
||||
}
|
||||
|
||||
try {
|
||||
self::run_batch();
|
||||
} finally {
|
||||
delete_transient( $lock_key );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron entrypoint. Runs one batch, reschedules or finalizes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run_batch(): void {
|
||||
$state = self::get_state();
|
||||
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$taxonomy = (string) ( $state['taxonomy'] ?? '' );
|
||||
$target = (int) ( $state['target'] ?? 0 );
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
|
||||
$mode = (string) ( $state['mode'] ?? self::MODE_FAST );
|
||||
$remaining = $target - $processed;
|
||||
if ( $remaining <= 0 ) {
|
||||
self::finalize( $state );
|
||||
return;
|
||||
}
|
||||
$this_batch_size = min( $batch_size, $remaining );
|
||||
|
||||
$batch_started = microtime( true );
|
||||
try {
|
||||
if ( self::MODE_FAST === $mode ) {
|
||||
$inserted = self::run_batch_fast( $taxonomy, $this_batch_size );
|
||||
} else {
|
||||
$inserted = self::run_batch_realistic( $taxonomy, $this_batch_size );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$state['errors'][] = array(
|
||||
'time' => time(),
|
||||
'message' => $e->getMessage(),
|
||||
);
|
||||
$state['status'] = 'failed';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::error( 'term_stress_test_batch_failed', array( 'message' => $e->getMessage() ) );
|
||||
}
|
||||
return;
|
||||
}
|
||||
$batch_elapsed = microtime( true ) - $batch_started;
|
||||
|
||||
// Re-read state — cancel() may have modified status mid-batch.
|
||||
$latest = self::get_state();
|
||||
if ( empty( $latest ) ) {
|
||||
return;
|
||||
}
|
||||
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
|
||||
|
||||
$latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted;
|
||||
$latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1;
|
||||
$latest['batches_log'][] = array(
|
||||
'n' => $inserted,
|
||||
'duration_ms' => (int) round( $batch_elapsed * 1000 ),
|
||||
);
|
||||
if ( count( $latest['batches_log'] ) > 200 ) {
|
||||
$latest['batches_log'] = array_slice( $latest['batches_log'], -200 );
|
||||
}
|
||||
$latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) );
|
||||
$latest['last_pushed_at'] = time();
|
||||
|
||||
if ( $is_cancelled ) {
|
||||
update_option( self::OPT_STATE, $latest, false );
|
||||
return;
|
||||
}
|
||||
|
||||
update_option( self::OPT_STATE, $latest, false );
|
||||
|
||||
if ( $latest['processed'] >= $target ) {
|
||||
self::finalize( $latest );
|
||||
return;
|
||||
}
|
||||
|
||||
wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fast-mode batch.
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @param int $count Batch size.
|
||||
* @return int Inserted count.
|
||||
*/
|
||||
private static function run_batch_fast( string $taxonomy, int $count ): int {
|
||||
$result = self::create( $taxonomy, $count );
|
||||
return (int) ( $result['created'] ?? 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one realistic-mode batch with deadline + cancel check per term.
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @param int $count Batch size.
|
||||
* @return int Inserted count.
|
||||
*/
|
||||
private static function run_batch_realistic( string $taxonomy, int $count ): int {
|
||||
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
|
||||
$inserted = 0;
|
||||
$seed_map = self::seed_map();
|
||||
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
if ( microtime( true ) > $deadline ) {
|
||||
break;
|
||||
}
|
||||
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$suffix = wp_generate_password( 8, false );
|
||||
$name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix;
|
||||
$slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix );
|
||||
|
||||
$result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
continue;
|
||||
}
|
||||
$term_id = (int) ( $result['term_id'] ?? 0 );
|
||||
if ( 0 === $term_id ) {
|
||||
continue;
|
||||
}
|
||||
++$inserted;
|
||||
|
||||
foreach ( $seed_map as $meta_key => $value_spec ) {
|
||||
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
|
||||
update_term_meta( $term_id, $meta_key, $value );
|
||||
}
|
||||
}
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: clear cron, run benchmark, persist completed state.
|
||||
*
|
||||
* @param array $state Pre-finalize state.
|
||||
* @return void
|
||||
*/
|
||||
private static function finalize( array $state ): void {
|
||||
wp_clear_scheduled_hook( self::CRON_HOOK );
|
||||
|
||||
$state['status'] = 'benchmarking';
|
||||
$state['completed_at'] = time();
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
|
||||
$report = self::run_benchmark( $state );
|
||||
|
||||
$state['benchmark'] = $report;
|
||||
$state['status'] = 'completed';
|
||||
update_option( self::OPT_STATE, $state, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run benchmark on the current dataset.
|
||||
*
|
||||
* @param array|null $state Optional state snapshot.
|
||||
* @return array
|
||||
*/
|
||||
public static function run_benchmark( ?array $state = null ): array {
|
||||
$state = $state ?? self::get_state();
|
||||
$taxonomy = (string) ( $state['taxonomy'] ?? '' );
|
||||
|
||||
return array(
|
||||
'generated_at' => time(),
|
||||
'taxonomy' => $taxonomy,
|
||||
'write' => self::compute_write_metrics( $state ),
|
||||
'db_sizes' => self::measure_db_sizes(),
|
||||
'query' => self::measure_query_performance( $taxonomy ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write throughput metrics from state.
|
||||
*
|
||||
* @param array $state State snapshot.
|
||||
* @return array
|
||||
*/
|
||||
private static function compute_write_metrics( array $state ): array {
|
||||
$started = (int) ( $state['started_at'] ?? 0 );
|
||||
$ended = (int) ( $state['completed_at'] ?? time() );
|
||||
$processed = (int) ( $state['processed'] ?? 0 );
|
||||
$elapsed = max( 1, $ended - $started );
|
||||
$batches = $state['batches_log'] ?? array();
|
||||
|
||||
$durations = array_column( $batches, 'duration_ms' );
|
||||
$min_ms = ! empty( $durations ) ? min( $durations ) : 0;
|
||||
$max_ms = ! empty( $durations ) ? max( $durations ) : 0;
|
||||
$avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0;
|
||||
|
||||
return array(
|
||||
'mode' => $state['mode'] ?? '',
|
||||
'taxonomy' => $state['taxonomy'] ?? '',
|
||||
'target' => (int) ( $state['target'] ?? 0 ),
|
||||
'processed' => $processed,
|
||||
'elapsed_sec' => $elapsed,
|
||||
'rate_per_sec' => round( $processed / $elapsed, 2 ),
|
||||
'batches_done' => (int) ( $state['batches_done'] ?? 0 ),
|
||||
'batch_min_ms' => $min_ms,
|
||||
'batch_max_ms' => $max_ms,
|
||||
'batch_avg_ms' => $avg_ms,
|
||||
'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure DB sizes for term-related tables.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function measure_db_sizes(): array {
|
||||
global $wpdb;
|
||||
|
||||
$tables = array(
|
||||
$wpdb->terms,
|
||||
$wpdb->prefix . 'term_taxonomy',
|
||||
$wpdb->termmeta,
|
||||
);
|
||||
foreach ( self::get_term_flat_tables() as $tbl ) {
|
||||
if ( self::table_exists( $tbl ) ) {
|
||||
$tables[] = $tbl;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! self::is_mysql() ) {
|
||||
return array_map(
|
||||
static fn( $t ) => array(
|
||||
'table' => $t,
|
||||
'rows' => self::table_row_count( $t ),
|
||||
),
|
||||
$tables
|
||||
);
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
|
||||
...$tables
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( (array) $rows as $r ) {
|
||||
$dl = (int) $r['dl'];
|
||||
$il = (int) $r['il'];
|
||||
$total = $dl + $il;
|
||||
$out[] = array(
|
||||
'table' => $r['t'],
|
||||
'rows' => (int) $r['rows_count'],
|
||||
'data_mb' => round( $dl / 1048576, 2 ),
|
||||
'index_mb' => round( $il / 1048576, 2 ),
|
||||
'total_mb' => round( $total / 1048576, 2 ),
|
||||
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure 3 representative queries for term entity:
|
||||
* - point lookup on hp_default flat
|
||||
* - range scan on hp_sort_order flat
|
||||
* - EAV baseline on wp_termmeta
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @return array
|
||||
*/
|
||||
private static function measure_query_performance( string $taxonomy ): array {
|
||||
global $wpdb;
|
||||
$flat = $wpdb->prefix . 'wpdo_term_hp_taxonomy';
|
||||
if ( ! self::table_exists( $flat ) ) {
|
||||
return array( 'note' => 'flat_table_missing' );
|
||||
}
|
||||
|
||||
return array(
|
||||
'point_default' => self::time_query(
|
||||
"SELECT term_id FROM `{$flat}` WHERE hp_default = '1' LIMIT 100"
|
||||
),
|
||||
'range_sort_top' => self::time_query(
|
||||
"SELECT term_id FROM `{$flat}` WHERE hp_sort_order > 0 ORDER BY hp_sort_order DESC LIMIT 100"
|
||||
),
|
||||
'eav_baseline' => self::time_query(
|
||||
"SELECT term_id FROM {$wpdb->termmeta} WHERE meta_key = 'hp_default' AND meta_value = '1' LIMIT 100"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate inputs for create() / create_realistic().
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @param int $count Count to insert.
|
||||
* @return void
|
||||
* @throws InvalidArgumentException When invalid.
|
||||
*/
|
||||
private static function validate_inputs( string $taxonomy, int $count ): void {
|
||||
if ( '' === $taxonomy ) {
|
||||
throw new InvalidArgumentException( 'Taxonomy required.' );
|
||||
}
|
||||
if ( $count <= 0 ) {
|
||||
throw new InvalidArgumentException( 'Count must be > 0.' );
|
||||
}
|
||||
if ( $count > self::MAX_COUNT ) {
|
||||
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').';
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-built seed map for hp_taxonomy group.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function seed_map(): array {
|
||||
if ( null !== self::$seed_map_cache ) {
|
||||
return self::$seed_map_cache;
|
||||
}
|
||||
|
||||
self::$seed_map_cache = array(
|
||||
'hp_sort_order' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ),
|
||||
'hp_default' => static fn( int $i ) => 0 === $i % 50 ? '1' : '0',
|
||||
'hp_icon' => static fn( int $i ) => 'fa-icon-' . ( $i % 10 ),
|
||||
);
|
||||
return self::$seed_map_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of all wp_wpdo_term_* flat tables that cleanup should sweep.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function get_term_flat_tables(): array {
|
||||
global $wpdb;
|
||||
$prefix = $wpdb->prefix . 'wpdo_term_';
|
||||
return array(
|
||||
$prefix . 'hp_taxonomy',
|
||||
$prefix . 'misc',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized table-exists probe.
|
||||
*
|
||||
* @param string $table Table name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function table_exists( string $table ): bool {
|
||||
global $wpdb;
|
||||
static $cache = array();
|
||||
if ( isset( $cache[ $table ] ) ) {
|
||||
return $cache[ $table ];
|
||||
}
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
$cache[ $table ] = ( $found === $table );
|
||||
return $cache[ $table ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap row count helper (SQLite fallback).
|
||||
*
|
||||
* @param string $table Table name.
|
||||
* @return int
|
||||
*/
|
||||
private static function table_row_count( string $table ): int {
|
||||
global $wpdb;
|
||||
if ( ! self::table_exists( $table ) ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Time a SQL query.
|
||||
*
|
||||
* @param string $sql Query.
|
||||
* @return array{duration_ms:float}
|
||||
*/
|
||||
private static function time_query( string $sql ): array {
|
||||
global $wpdb;
|
||||
$start = microtime( true );
|
||||
$wpdb->get_results( $sql );
|
||||
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
|
||||
return array( 'duration_ms' => round( $elapsed_ms, 2 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MySQL vs SQLite.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_mysql(): bool {
|
||||
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Termmeta_Cleaner — wp_termmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*
|
||||
* Identifies and removes three classes of low-value rows from wp_termmeta
|
||||
* that bloat the table without serving any business purpose:
|
||||
*
|
||||
* - wxr_import — `_wxr_import_*` rows left by WordPress importer (never
|
||||
* read after import, pure tracking residue)
|
||||
* - demo_data — `_2meet_demo_*` rows used to mark demo content
|
||||
* (can be re-seeded; safe to drop)
|
||||
* - transients — `_transient_*` and `_transient_timeout_*` rows
|
||||
* (HivePress / WC term cache layer; auto-rebuilt)
|
||||
*
|
||||
* Mirrors TMDO_Postmeta_Cleaner pattern. Runs before any term Entity Bridge
|
||||
* migration so subsequent ratio measurements reflect real data, not garbage.
|
||||
* Pure DB layer — no Hook Bus / Entity Bridge coupling.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wp_termmeta garbage cleanup (v2.12.0 Phase 0).
|
||||
*/
|
||||
class TMDO_Termmeta_Cleaner {
|
||||
|
||||
public const TARGET_WXR_IMPORT = 'wxr_import';
|
||||
public const TARGET_DEMO_DATA = 'demo_data';
|
||||
public const TARGET_TRANSIENTS = 'transients';
|
||||
public const TARGET_ALL = 'all';
|
||||
|
||||
public const VALID_TARGETS = array(
|
||||
self::TARGET_WXR_IMPORT,
|
||||
self::TARGET_DEMO_DATA,
|
||||
self::TARGET_TRANSIENTS,
|
||||
self::TARGET_ALL,
|
||||
);
|
||||
|
||||
/**
|
||||
* Count rows that would be cleaned for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{wxr_import:int, demo_data:int, transients:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function count_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$counts = array(
|
||||
'wxr_import' => 0,
|
||||
'demo_data' => 0,
|
||||
'transients' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
|
||||
$counts['wxr_import'] = self::count_wxr_import();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
|
||||
$counts['demo_data'] = self::count_demo_data();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$counts['transients'] = self::count_transients();
|
||||
}
|
||||
|
||||
$counts['total'] = $counts['wxr_import'] + $counts['demo_data'] + $counts['transients'];
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete garbage rows for the given target.
|
||||
*
|
||||
* @param string $target One of TARGET_* constants.
|
||||
* @return array{wxr_import:int, demo_data:int, transients:int, total:int}
|
||||
* @throws InvalidArgumentException When $target is not a valid target.
|
||||
*/
|
||||
public static function delete_garbage( string $target = self::TARGET_ALL ): array {
|
||||
self::assert_valid_target( $target );
|
||||
|
||||
$deleted = array(
|
||||
'wxr_import' => 0,
|
||||
'demo_data' => 0,
|
||||
'transients' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
if ( self::target_includes( $target, self::TARGET_WXR_IMPORT ) ) {
|
||||
$deleted['wxr_import'] = self::delete_wxr_import();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_DEMO_DATA ) ) {
|
||||
$deleted['demo_data'] = self::delete_demo_data();
|
||||
}
|
||||
if ( self::target_includes( $target, self::TARGET_TRANSIENTS ) ) {
|
||||
$deleted['transients'] = self::delete_transients();
|
||||
}
|
||||
|
||||
$deleted['total'] = $deleted['wxr_import'] + $deleted['demo_data'] + $deleted['transients'];
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $target selects $bucket (i.e. target=all or target=bucket).
|
||||
*
|
||||
* @param string $target Selected target.
|
||||
* @param string $bucket Bucket constant.
|
||||
* @return bool
|
||||
*/
|
||||
private static function target_includes( string $target, string $bucket ): bool {
|
||||
return self::TARGET_ALL === $target || $bucket === $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate target parameter.
|
||||
*
|
||||
* @param string $target Target to validate.
|
||||
* @return void
|
||||
* @throws InvalidArgumentException When $target is not in VALID_TARGETS.
|
||||
*/
|
||||
private static function assert_valid_target( string $target ): void {
|
||||
if ( in_array( $target, self::VALID_TARGETS, true ) ) {
|
||||
return;
|
||||
}
|
||||
$msg = sprintf( 'Invalid target "%s". Valid: %s', $target, implode( ', ', self::VALID_TARGETS ) );
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal cleanup class: $wpdb->termmeta is WP-managed; meta_key patterns are static class constants.
|
||||
|
||||
/**
|
||||
* Count `_wxr_import_*` rows in wp_termmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_wxr_import(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `_wxr_import_*` rows from wp_termmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_wxr_import(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_wxr\\_import\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `_2meet_demo_*` rows in wp_termmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_demo_data(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `_2meet_demo_*` rows from wp_termmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_demo_data(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_2meet\\_demo\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count rows matching transient meta_key patterns in wp_termmeta.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function count_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows matching transient meta_key patterns from wp_termmeta.
|
||||
*
|
||||
* @return int Affected row count.
|
||||
*/
|
||||
private static function delete_transients(): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->termmeta;
|
||||
return (int) $wpdb->query(
|
||||
"DELETE FROM `{$table}` WHERE meta_key LIKE '\\_transient\\_%' OR meta_key LIKE '\\_transient\\_timeout\\_%'"
|
||||
);
|
||||
}
|
||||
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_V2_Upgrader — atomic v1.3.x → v2.0.0 upgrade orchestrator (PR-7).
|
||||
*
|
||||
* Implements the Part F upgrade strategy from the master plan:
|
||||
* 1. Pre-flight: PHP / WP / MySQL versions, free disk, no in-flight migrations
|
||||
* 2. Backup wpdo_features option (rollback safety net)
|
||||
* 3. dbDelta v2 tables (idempotent)
|
||||
* 4. ALTER existing tables (skip if already migrated)
|
||||
* 5. UAE import (when wp_uae_* present, otherwise no-op)
|
||||
* 6. migrate_feature_flags_v2 — seed entity module states
|
||||
* 7. update wpdo_db_version → 2.0.0
|
||||
* 8. safely_deactivate_uae_plugin (when present)
|
||||
* 9. Schedule cron to remove the UAE plugin directory (5s)
|
||||
* 10. fire wpdo_v2_upgraded action
|
||||
*
|
||||
* Any throw → catch → write wpdo_v2_upgrade_error → restore features → admin
|
||||
* notice. New tables are NOT dropped on failure (idempotent retry).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic upgrade orchestrator.
|
||||
*/
|
||||
final class TMDO_V2_Upgrader {
|
||||
|
||||
/** Target schema version after successful upgrade. */
|
||||
public const TARGET_VERSION = '2.0.0';
|
||||
|
||||
/** Min PHP / WP / MySQL versions enforced by pre-flight. */
|
||||
public const MIN_PHP_VERSION = '8.1';
|
||||
public const MIN_WP_VERSION = '6.0';
|
||||
public const MIN_MYSQL_VERSION = '5.7';
|
||||
|
||||
/** Free-disk threshold in MB. */
|
||||
public const MIN_FREE_DISK_MB = 100;
|
||||
|
||||
/**
|
||||
* Pre-flight check — returns map of check_name => bool.
|
||||
*
|
||||
* @param array $opts Optional overrides (mostly for tests).
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public static function pre_flight_check( array $opts = array() ): array {
|
||||
global $wpdb;
|
||||
|
||||
$wp_version = $opts['wp_version'] ?? ( defined( 'ABSPATH' ) ? ( get_bloginfo( 'version' ) ?: '6.0' ) : '6.0' );
|
||||
$mysql_version = $opts['mysql_version'] ?? self::detect_mysql_version();
|
||||
$disk_path = $opts['disk_path'] ?? sys_get_temp_dir();
|
||||
|
||||
$checks = array(
|
||||
'php_version' => version_compare( PHP_VERSION, self::MIN_PHP_VERSION, '>=' ),
|
||||
'wp_version' => version_compare( $wp_version, self::MIN_WP_VERSION, '>=' ),
|
||||
'mysql_version' => version_compare( $mysql_version, self::MIN_MYSQL_VERSION, '>=' ),
|
||||
'free_disk_mb' => self::detect_free_disk_mb( $disk_path ) >= self::MIN_FREE_DISK_MB,
|
||||
'features_writable' => self::is_features_option_writable(),
|
||||
'no_active_migration' => self::no_active_migration(),
|
||||
);
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the atomic upgrade. Returns true on success, false on failure.
|
||||
*
|
||||
* @return bool
|
||||
* @throws \RuntimeException When UAE importer fails (caught internally and rolled back).
|
||||
*/
|
||||
public static function upgrade_to_v2(): bool {
|
||||
// 1. Pre-flight check.
|
||||
$checks = self::pre_flight_check();
|
||||
if ( in_array( false, $checks, true ) ) {
|
||||
update_option(
|
||||
'wpdo_v2_upgrade_error',
|
||||
'Pre-flight check failed: ' . wp_json_encode( $checks ),
|
||||
false
|
||||
);
|
||||
update_option( 'wpdo_v2_upgrade_status', 'preflight_failed', false );
|
||||
return false;
|
||||
}
|
||||
|
||||
update_option( 'wpdo_v2_upgrade_started_at', time(), false );
|
||||
update_option( 'wpdo_v2_upgrade_status', 'in_progress', false );
|
||||
|
||||
$features_backup = get_option( 'wpdo_features', array() );
|
||||
update_option( 'wpdo_v2_features_backup', $features_backup, false );
|
||||
|
||||
try {
|
||||
// 4. Install v2 tables.
|
||||
TMDO_Installer::install_v2_tables();
|
||||
|
||||
// 5. UAE import (when applicable).
|
||||
if ( self::detect_uae_data() ) {
|
||||
$import_ok = self::run_uae_import();
|
||||
if ( ! $import_ok ) {
|
||||
throw new \RuntimeException( 'UAE importer failed' );
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Migrate feature flags to include entity modules.
|
||||
self::migrate_feature_flags_v2();
|
||||
|
||||
// 7. Bump db_version.
|
||||
update_option( 'wpdo_db_version', self::TARGET_VERSION, false );
|
||||
update_option( 'wpdo_v2_upgrade_status', 'complete', false );
|
||||
update_option( 'wpdo_v2_upgraded_at', time(), false );
|
||||
|
||||
// 8. Safely deactivate UAE plugin.
|
||||
self::safely_deactivate_uae_plugin();
|
||||
|
||||
// 9. Schedule plugin directory removal.
|
||||
self::schedule_uae_dir_removal();
|
||||
|
||||
// 10. Fire hook for downstream consumers.
|
||||
do_action( 'wpdo_v2_upgraded', '1.3.38', self::TARGET_VERSION );
|
||||
|
||||
return true;
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
// Rollback path: restore features option, leave new tables in place
|
||||
// (idempotent retry on next attempt).
|
||||
update_option( 'wpdo_features', $features_backup );
|
||||
update_option( 'wpdo_v2_upgrade_error', $e->getMessage(), false );
|
||||
update_option( 'wpdo_v2_upgrade_status', 'failed', false );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back from a partially-completed v2 upgrade.
|
||||
*
|
||||
* @param bool $keep_data When true, retain wpdo_uni_* tables (for retry).
|
||||
* @return bool
|
||||
*/
|
||||
public static function rollback_v2( bool $keep_data = true ): bool {
|
||||
$backup = get_option( 'wpdo_v2_features_backup' );
|
||||
if ( false !== $backup ) {
|
||||
update_option( 'wpdo_features', $backup );
|
||||
}
|
||||
update_option( 'wpdo_db_version', '1.0.0', false );
|
||||
update_option( 'wpdo_v2_upgrade_status', 'rolled_back', false );
|
||||
|
||||
if ( ! $keep_data ) {
|
||||
global $wpdb;
|
||||
foreach ( array( 'wpdo_audit', 'wpdo_shadow_diffs', 'wpdo_site_metrics', 'wpdo_uni_options' ) as $t ) {
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $t from hardcoded array, $wpdb->prefix sanitized by core.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}{$t}`" );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect whether wp_uae_* tables exist (S3 scenario).
|
||||
*/
|
||||
public static function detect_uae_data(): bool {
|
||||
global $wpdb;
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
|
||||
$wpdb->prefix . 'uae_%'
|
||||
)
|
||||
);
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the UAE → WPDO data importer. Stub for v2.0.0 — full implementation
|
||||
* lives in includes/migration/class-tmdo-uae-importer.php (PR-7 follow-up).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function run_uae_import(): bool {
|
||||
if ( class_exists( 'TMDO_UAE_Importer' ) ) {
|
||||
return TMDO_UAE_Importer::run(
|
||||
array(
|
||||
'auto' => true,
|
||||
'keep_source' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
// No UAE importer yet — return true so dev10 (no UAE data) progresses.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the feature_flags option to include entity modules.
|
||||
*/
|
||||
public static function migrate_feature_flags_v2(): void {
|
||||
$flags = get_option( 'wpdo_features', array() );
|
||||
if ( ! is_array( $flags ) ) {
|
||||
$flags = array();
|
||||
}
|
||||
foreach ( array( 'entity_user', 'entity_term', 'entity_comment', 'entity_options' ) as $module ) {
|
||||
if ( ! isset( $flags[ $module ] ) ) {
|
||||
$flags[ $module ] = 'idle';
|
||||
}
|
||||
}
|
||||
update_option( 'wpdo_features', $flags );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed MySQL / MariaDB version via @@version.
|
||||
*/
|
||||
public static function detect_mysql_version(): string {
|
||||
global $wpdb;
|
||||
try {
|
||||
$v = (string) $wpdb->get_var( 'SELECT VERSION()' );
|
||||
// Strip trailing -MariaDB or similar tags for version_compare.
|
||||
if ( preg_match( '/^([\d.]+)/', $v, $m ) ) {
|
||||
return $m[1];
|
||||
}
|
||||
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: fall through to safe default.
|
||||
// Fall through to safe default.
|
||||
}
|
||||
return '5.7';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect free disk space in MB at the given path.
|
||||
*
|
||||
* @param string $path Filesystem path (typically sys_get_temp_dir()).
|
||||
* @return int Free disk space in MB, or 0 when unreadable.
|
||||
*/
|
||||
public static function detect_free_disk_mb( string $path ): int {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- @ suppresses E_WARNING when path is on a stat-fail filesystem; explicit false-check follows.
|
||||
$free = @disk_free_space( $path );
|
||||
if ( false === $free ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) ( $free / 1024 / 1024 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify wpdo_features option is writable (autoload=no row exists or absent).
|
||||
*/
|
||||
private static function is_features_option_writable(): bool {
|
||||
$probe_key = '_wpdo_v2_writability_probe_' . wp_generate_password( 12, false );
|
||||
$ok = update_option( $probe_key, time(), false );
|
||||
delete_option( $probe_key );
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify no migration is currently in-flight (zero rows in 'in_progress' state).
|
||||
*/
|
||||
private static function no_active_migration(): bool {
|
||||
global $wpdb;
|
||||
try {
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_migrations` WHERE state = %s",
|
||||
'in_progress'
|
||||
)
|
||||
);
|
||||
return 0 === $count;
|
||||
} catch ( \Throwable $e ) {
|
||||
// Table may not exist on truly fresh installs — treat as "no active migration".
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quietly deactivate wp-universal-anti-eav plugin if it is currently active.
|
||||
*/
|
||||
private static function safely_deactivate_uae_plugin(): void {
|
||||
if ( ! function_exists( 'deactivate_plugins' ) || ! function_exists( 'is_plugin_active' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
if ( ! function_exists( 'is_plugin_active' ) ) {
|
||||
return;
|
||||
}
|
||||
$slug = 'wp-universal-anti-eav/wp-universal-anti-eav.php';
|
||||
if ( is_plugin_active( $slug ) ) {
|
||||
deactivate_plugins( $slug, true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a one-shot cron event to remove the UAE plugin directory.
|
||||
*
|
||||
* Filesystem permission failures fall back to admin notice for manual removal.
|
||||
*/
|
||||
private static function schedule_uae_dir_removal(): void {
|
||||
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'wpdo_remove_uae_plugin_dir' ) ) {
|
||||
wp_schedule_single_event( time() + 5, 'wpdo_remove_uae_plugin_dir' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone Classifier for wp_postmeta analysis and zone assignment suggestions.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone Classifier — analyzes wp_postmeta and suggests zone assignments.
|
||||
*
|
||||
* Examines meta_key usage patterns across the database to recommend
|
||||
* which zone each key should belong to:
|
||||
*
|
||||
* Hot (A) — Used in WP_Query meta_query (search/filter); numeric or short values
|
||||
* Warm (B) — Transient/computed data; infrequently updated
|
||||
* Cold (C) — Display data read often but never queried; long text/JSON blobs
|
||||
* Archive (D) — Belongs to trashed/old posts; rarely if ever accessed
|
||||
*
|
||||
* Signals analyzed:
|
||||
* - Value length distribution (short = hot candidate, long = cold candidate)
|
||||
* - Post status distribution (trash/draft heavy = archive candidate)
|
||||
* - Key prefix patterns (hp_, _hp_, _transient_ etc.)
|
||||
* - Whether the key appears in meta_query (via slow query log or heuristics)
|
||||
* - Distinct value cardinality (low = likely enum/flag = hot)
|
||||
*
|
||||
* Used by Admin UI and WP-CLI `wp wpdo analyze` command.
|
||||
*/
|
||||
class TMDO_Zone_Classifier {
|
||||
|
||||
/**
|
||||
* Analyze postmeta for a specific post type and return zone suggestions.
|
||||
*
|
||||
* @param string $post_type Post type to analyze.
|
||||
* @param int $sample_size Number of rows to sample per meta_key.
|
||||
* @return array Array of suggestions, each with: meta_key, suggested_zone, confidence, reasons.
|
||||
*/
|
||||
public static function analyze( string $post_type, int $sample_size = 100 ): array {
|
||||
global $wpdb;
|
||||
|
||||
// Return cached result if available (TTL: 1 hour).
|
||||
$transient_key = 'wpdo_classifier_' . sanitize_key( $post_type );
|
||||
$cached = get_transient( $transient_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Get all distinct meta_keys for this post type.
|
||||
$keys = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_key, COUNT(*) as row_count
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s
|
||||
GROUP BY pm.meta_key
|
||||
ORDER BY row_count DESC
|
||||
LIMIT 200",
|
||||
$post_type
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $keys ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$suggestions = array();
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
|
||||
foreach ( $keys as $key_info ) {
|
||||
$meta_key = $key_info['meta_key'];
|
||||
$row_count = (int) $key_info['row_count'];
|
||||
|
||||
// Skip WordPress internal keys.
|
||||
if ( self::is_wp_internal( $meta_key ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already registered.
|
||||
$existing = $registry->get_field( $post_type, $meta_key );
|
||||
$already_assigned = $existing ? $existing['zone'] : null;
|
||||
|
||||
// Collect signals.
|
||||
$signals = self::collect_signals( $meta_key, $post_type, $row_count, $sample_size );
|
||||
|
||||
// Score each zone.
|
||||
$scores = self::score_zones( $signals );
|
||||
|
||||
// Pick the best zone.
|
||||
arsort( $scores );
|
||||
$best_zone = array_key_first( $scores );
|
||||
$confidence = $scores[ $best_zone ];
|
||||
|
||||
$suggestions[] = array(
|
||||
'meta_key' => $meta_key,
|
||||
'row_count' => $row_count,
|
||||
'suggested_zone' => $best_zone,
|
||||
'confidence' => round( $confidence, 2 ),
|
||||
'already_assigned' => $already_assigned,
|
||||
'scores' => $scores,
|
||||
'reasons' => self::build_reasons( $signals, $best_zone ),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by confidence descending.
|
||||
usort( $suggestions, fn( $a, $b ) => $b['confidence'] <=> $a['confidence'] );
|
||||
|
||||
set_transient( $transient_key, $suggestions, HOUR_IN_SECONDS );
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick summary: count of meta_keys per suggested zone.
|
||||
*
|
||||
* @param string $post_type Post type to analyze.
|
||||
* @return array{hot: int, warm: int, cold: int, archive: int, already_assigned: int}
|
||||
*/
|
||||
public static function summary( string $post_type ): array {
|
||||
$suggestions = self::analyze( $post_type );
|
||||
$summary = array(
|
||||
'hot' => 0,
|
||||
'warm' => 0,
|
||||
'cold' => 0,
|
||||
'archive' => 0,
|
||||
'already_assigned' => 0,
|
||||
);
|
||||
|
||||
foreach ( $suggestions as $s ) {
|
||||
if ( $s['already_assigned'] ) {
|
||||
++$summary['already_assigned'];
|
||||
} else {
|
||||
++$summary[ $s['suggested_zone'] ];
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect analytical signals for a meta_key.
|
||||
*
|
||||
* @param string $meta_key Meta key to analyze.
|
||||
* @param string $post_type Post type context.
|
||||
* @param int $row_count Total rows for this meta key.
|
||||
* @param int $sample_size Number of rows sampled.
|
||||
* @return array Signal data for zone scoring.
|
||||
*/
|
||||
private static function collect_signals( string $meta_key, string $post_type, int $row_count, int $sample_size ): array {
|
||||
global $wpdb;
|
||||
|
||||
$signals = array(
|
||||
'meta_key' => $meta_key,
|
||||
'row_count' => $row_count,
|
||||
'avg_length' => 0,
|
||||
'max_length' => 0,
|
||||
'distinct_values' => 0,
|
||||
'numeric_ratio' => 0.0,
|
||||
'trash_ratio' => 0.0,
|
||||
'is_serialized' => false,
|
||||
'is_json' => false,
|
||||
'prefix' => '',
|
||||
);
|
||||
|
||||
// Prefix detection.
|
||||
if ( str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ) ) {
|
||||
$signals['prefix'] = 'hivepress';
|
||||
} elseif ( str_starts_with( $meta_key, '_transient_' ) || str_starts_with( $meta_key, '_site_transient_' ) ) {
|
||||
$signals['prefix'] = 'transient';
|
||||
} elseif ( str_starts_with( $meta_key, '_' ) ) {
|
||||
$signals['prefix'] = 'internal';
|
||||
}
|
||||
|
||||
// Sample values for analysis.
|
||||
$samples = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_value
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s
|
||||
LIMIT %d",
|
||||
$post_type,
|
||||
$meta_key,
|
||||
$sample_size
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $samples ) ) {
|
||||
$lengths = array_map( 'strlen', $samples );
|
||||
$signals['avg_length'] = (int) ( array_sum( $lengths ) / count( $lengths ) );
|
||||
$signals['max_length'] = max( $lengths );
|
||||
|
||||
$numeric_count = 0;
|
||||
foreach ( $samples as $val ) {
|
||||
if ( is_numeric( $val ) ) {
|
||||
++$numeric_count;
|
||||
}
|
||||
}
|
||||
$signals['numeric_ratio'] = $numeric_count / count( $samples );
|
||||
|
||||
// Check serialized/JSON.
|
||||
$first = $samples[0] ?? '';
|
||||
$signals['is_serialized'] = is_serialized( $first );
|
||||
$signals['is_json'] = ( str_starts_with( $first, '{' ) || str_starts_with( $first, '[' ) )
|
||||
&& null !== json_decode( $first );
|
||||
}
|
||||
|
||||
// Distinct value count.
|
||||
$signals['distinct_values'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT pm.meta_value)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s",
|
||||
$post_type,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
|
||||
// Trash ratio.
|
||||
if ( $row_count > 0 ) {
|
||||
$trash_count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s AND p.post_status = 'trash'",
|
||||
$post_type,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
$signals['trash_ratio'] = $trash_count / $row_count;
|
||||
}
|
||||
|
||||
return $signals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score each zone based on collected signals.
|
||||
*
|
||||
* @param array $signals Signal data from collect_signals().
|
||||
* @return array{hot: float, warm: float, cold: float, archive: float} Zone scores.
|
||||
*/
|
||||
private static function score_zones( array $signals ): array {
|
||||
$scores = array(
|
||||
'hot' => 0.0,
|
||||
'warm' => 0.0,
|
||||
'cold' => 0.0,
|
||||
'archive' => 0.0,
|
||||
);
|
||||
|
||||
// --- Hot signals ---
|
||||
// Short, numeric values are great for indexing.
|
||||
if ( $signals['avg_length'] < 50 ) {
|
||||
$scores['hot'] += 0.3;
|
||||
}
|
||||
if ( $signals['numeric_ratio'] > 0.8 ) {
|
||||
$scores['hot'] += 0.3;
|
||||
}
|
||||
// Low cardinality = enum/flag = good for filtering.
|
||||
if ( $signals['distinct_values'] > 0 && $signals['distinct_values'] <= 20 ) {
|
||||
$scores['hot'] += 0.2;
|
||||
}
|
||||
// HivePress prefix = likely a search field.
|
||||
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
||||
$scores['hot'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Warm signals ---
|
||||
// Transient prefix is a clear warm signal.
|
||||
if ( 'transient' === $signals['prefix'] ) {
|
||||
$scores['warm'] += 0.8;
|
||||
}
|
||||
// Internal prefix + short values.
|
||||
if ( 'internal' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
||||
$scores['warm'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Cold signals ---
|
||||
// Long text/JSON blobs are cold candidates.
|
||||
if ( $signals['avg_length'] > 200 ) {
|
||||
$scores['cold'] += 0.4;
|
||||
}
|
||||
if ( $signals['is_json'] || $signals['is_serialized'] ) {
|
||||
$scores['cold'] += 0.3;
|
||||
}
|
||||
// High cardinality + long values = display/profile data.
|
||||
if ( $signals['distinct_values'] > 50 && $signals['avg_length'] > 100 ) {
|
||||
$scores['cold'] += 0.2;
|
||||
}
|
||||
// HivePress prefix + long values = description field.
|
||||
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] > 100 ) {
|
||||
$scores['cold'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Archive signals ---
|
||||
// High trash ratio = archive candidate.
|
||||
if ( $signals['trash_ratio'] > 0.5 ) {
|
||||
$scores['archive'] += 0.6;
|
||||
} elseif ( $signals['trash_ratio'] > 0.2 ) {
|
||||
$scores['archive'] += 0.3;
|
||||
}
|
||||
|
||||
// Normalize: ensure at least one zone has a score.
|
||||
$max = max( $scores );
|
||||
if ( 0.0 === $max ) {
|
||||
// Default to cold for unknown patterns.
|
||||
$scores['cold'] = 0.1;
|
||||
}
|
||||
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build human-readable reasons for the zone suggestion.
|
||||
*
|
||||
* @param array $signals Signal data from collect_signals().
|
||||
* @param string $zone Suggested zone name.
|
||||
* @return array Array of human-readable reason strings.
|
||||
*/
|
||||
private static function build_reasons( array $signals, string $zone ): array {
|
||||
$reasons = array();
|
||||
|
||||
switch ( $zone ) {
|
||||
case 'hot':
|
||||
if ( $signals['avg_length'] < 50 ) {
|
||||
$reasons[] = sprintf( 'Short values (avg %d chars) — efficient for indexing', $signals['avg_length'] );
|
||||
}
|
||||
if ( $signals['numeric_ratio'] > 0.8 ) {
|
||||
$reasons[] = sprintf( '%.0f%% numeric values — ideal for range queries', $signals['numeric_ratio'] * 100 );
|
||||
}
|
||||
if ( $signals['distinct_values'] <= 20 ) {
|
||||
$reasons[] = sprintf( 'Low cardinality (%d distinct values) — good for filtering', $signals['distinct_values'] );
|
||||
}
|
||||
break;
|
||||
|
||||
case 'warm':
|
||||
if ( 'transient' === $signals['prefix'] ) {
|
||||
$reasons[] = 'Transient prefix detected — ephemeral data with natural TTL';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
if ( $signals['avg_length'] > 200 ) {
|
||||
$reasons[] = sprintf( 'Long values (avg %d chars) — display/profile data', $signals['avg_length'] );
|
||||
}
|
||||
if ( $signals['is_json'] ) {
|
||||
$reasons[] = 'JSON structure detected — good for blob storage';
|
||||
}
|
||||
if ( $signals['is_serialized'] ) {
|
||||
$reasons[] = 'Serialized data — good for blob storage';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'archive':
|
||||
if ( $signals['trash_ratio'] > 0.2 ) {
|
||||
$reasons[] = sprintf( '%.0f%% of entries belong to trashed posts', $signals['trash_ratio'] * 100 );
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ( empty( $reasons ) ) {
|
||||
$reasons[] = 'Default classification based on overall signal pattern';
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a meta_key is a WordPress internal key that should be skipped.
|
||||
*
|
||||
* @param string $meta_key Meta key to check.
|
||||
* @return bool True if the key is a WordPress internal key.
|
||||
*/
|
||||
private static function is_wp_internal( string $meta_key ): bool {
|
||||
$skip = array(
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
'_wp_page_template',
|
||||
'_wp_old_slug',
|
||||
'_wp_trash_meta_time',
|
||||
'_wp_trash_meta_status',
|
||||
'_wp_desired_post_slug',
|
||||
'_thumbnail_id',
|
||||
'_encloseme',
|
||||
'_pingme',
|
||||
);
|
||||
|
||||
return in_array( $meta_key, $skip, true );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Health_Cron — Daily health probe (v2.3.0 M6).
|
||||
*
|
||||
* Runs daily at 03:30 UTC. Aggregates results from:
|
||||
* - TMDO_Site_Health 7 tests (schema_drift, error_budget, hook_conflicts,
|
||||
* autoload_bloat, postmeta_explosion, orphan_zone_rows, missing_snapshot)
|
||||
* - TMDO_Conflict_Monitor::get_summary()
|
||||
* - shadow_diffs ratio per module in `verify` state
|
||||
* - autoload size measurement
|
||||
*
|
||||
* Outputs:
|
||||
* 1. Single audit log entry: op='health_check_daily' with full payload
|
||||
* (so admin can read history via Logs tab + `wp wpdo audit` future CLI).
|
||||
* 2. wpdo_health_alert option set when any critical found (existing
|
||||
* TMDO_Core::render_health_alert_notice consumes this).
|
||||
* 3. wpdo_health_last_run option for SOP runbook "is health green?" question.
|
||||
* 4. action 'wpdo/health_alert_critical' fired on critical (v2.4.0 email
|
||||
* notifier subscribes here; consumers get the full result array).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily probe — stateless static API.
|
||||
*/
|
||||
class TMDO_Health_Cron {
|
||||
|
||||
/** Option key for the most recent run summary. */
|
||||
public const OPTION_LAST_RUN = 'wpdo_health_last_run';
|
||||
|
||||
/** Option key for active critical alert (consumed by Core notice). */
|
||||
public const OPTION_ALERT = 'wpdo_health_alert';
|
||||
|
||||
/**
|
||||
* Run the daily probe. Idempotent — safe to invoke ad-hoc.
|
||||
*
|
||||
* @return array {ok:bool, summary:array, critical_count:int, recommended_count:int, ts:string}
|
||||
*/
|
||||
public static function run(): array {
|
||||
$started_at = microtime( true );
|
||||
$results = self::run_site_health_tests();
|
||||
$conflict = self::summarize_conflicts();
|
||||
$shadow = self::summarize_shadow_diffs();
|
||||
$autoload = self::measure_autoload_size();
|
||||
|
||||
$critical_count = 0;
|
||||
$recommended_count = 0;
|
||||
foreach ( $results as $r ) {
|
||||
$status = (string) ( $r['status'] ?? 'good' );
|
||||
if ( 'critical' === $status ) {
|
||||
++$critical_count;
|
||||
} elseif ( 'recommended' === $status ) {
|
||||
++$recommended_count;
|
||||
}
|
||||
}
|
||||
|
||||
$summary = array(
|
||||
'tests' => $results,
|
||||
'critical_count' => $critical_count,
|
||||
'recommended_count' => $recommended_count,
|
||||
'conflicts' => $conflict,
|
||||
'shadow_diffs' => $shadow,
|
||||
'autoload_bytes' => $autoload,
|
||||
'ran_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
'duration_ms' => (int) round( ( microtime( true ) - $started_at ) * 1000 ),
|
||||
);
|
||||
|
||||
// 1. Persist last-run snapshot (autoload=no, lightweight).
|
||||
update_option( self::OPTION_LAST_RUN, $summary, false );
|
||||
|
||||
// 2. Set / clear alert flag.
|
||||
if ( $critical_count > 0 ) {
|
||||
$first_critical = self::first_critical_test( $results );
|
||||
update_option(
|
||||
self::OPTION_ALERT,
|
||||
array(
|
||||
'level' => 'critical',
|
||||
'count' => $critical_count,
|
||||
'first' => $first_critical,
|
||||
'ran_at' => $summary['ran_at'],
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
delete_option( self::OPTION_ALERT );
|
||||
}
|
||||
|
||||
// v2.5.0 M16: refresh module suggestions cache (autoload=no).
|
||||
$module_suggestions_count = 0;
|
||||
if ( class_exists( 'TMDO_Module_Detector' ) ) {
|
||||
try {
|
||||
$detected = TMDO_Module_Detector::detect_all( true );
|
||||
foreach ( $detected as $r ) {
|
||||
if ( ! empty( $r['available'] ) && 'enable' === ( $r['recommendation'] ?? '' ) ) {
|
||||
++$module_suggestions_count;
|
||||
}
|
||||
}
|
||||
} catch ( Throwable $e ) {
|
||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
||||
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Audit log entry.
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'health_check_daily',
|
||||
array(
|
||||
'critical' => $critical_count,
|
||||
'recommended' => $recommended_count,
|
||||
'duration_ms' => $summary['duration_ms'],
|
||||
'autoload_kb' => (int) round( $autoload / 1024 ),
|
||||
'conflicts' => (int) ( $conflict['total'] ?? 0 ),
|
||||
'module_suggestions_count' => $module_suggestions_count,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Fire action for downstream subscribers (v2.4.0 email notifier).
|
||||
if ( $critical_count > 0 ) {
|
||||
do_action( 'wpdo/health_alert_critical', $summary );
|
||||
} else {
|
||||
do_action( 'wpdo/health_check_passed', $summary );
|
||||
}
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'summary' => $summary,
|
||||
'critical_count' => $critical_count,
|
||||
'recommended_count' => $recommended_count,
|
||||
'ts' => $summary['ran_at'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read most recent run (for SOP runbook + Doctor tab streak counter).
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function get_last_run(): ?array {
|
||||
$v = get_option( self::OPTION_LAST_RUN );
|
||||
return is_array( $v ) ? $v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute consecutive green days from audit log (best-effort for SOP UI).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function consecutive_green_days(): int {
|
||||
$last = self::get_last_run();
|
||||
if ( null === $last ) {
|
||||
return 0;
|
||||
}
|
||||
// If today's run is critical, streak = 0.
|
||||
if ( ( $last['critical_count'] ?? 0 ) > 0 ) {
|
||||
return 0;
|
||||
}
|
||||
// Otherwise approximate via TMDO_Logger — count distinct days with health_check_daily and 0 critical.
|
||||
// Conservative best-effort: just check today's run is green = 1 day.
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ─── private helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run all 7 Site Health tests directly (without the WP Site Health UI loop).
|
||||
*
|
||||
* @return array<string,array> Test slug → result array.
|
||||
*/
|
||||
private static function run_site_health_tests(): array {
|
||||
$out = array();
|
||||
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
||||
return $out;
|
||||
}
|
||||
$tests = array(
|
||||
'wpdo_schema_drift' => 'check_schema_drift',
|
||||
'wpdo_error_budget' => 'check_error_budget',
|
||||
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
||||
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
||||
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
||||
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
||||
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
||||
);
|
||||
foreach ( $tests as $slug => $cb ) {
|
||||
try {
|
||||
$result = call_user_func( array( 'TMDO_Site_Health', $cb ) );
|
||||
if ( is_array( $result ) ) {
|
||||
$out[ $slug ] = array(
|
||||
'status' => (string) ( $result['status'] ?? 'good' ),
|
||||
'severity' => (string) ( $result['severity'] ?? 'good' ),
|
||||
'label' => (string) ( $result['label'] ?? $slug ),
|
||||
'description' => wp_strip_all_tags( (string) ( $result['description'] ?? '' ) ),
|
||||
);
|
||||
}
|
||||
} catch ( Throwable $e ) {
|
||||
$out[ $slug ] = array(
|
||||
'status' => 'critical',
|
||||
'severity' => 'critical',
|
||||
'label' => $slug,
|
||||
'description' => 'test threw: ' . $e->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a conflict summary from TMDO_Conflict_Monitor.
|
||||
*
|
||||
* @return array {total:int, hook_overlap:int, uaepg_overlap:int}
|
||||
*/
|
||||
private static function summarize_conflicts(): array {
|
||||
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
||||
return array(
|
||||
'total' => 0,
|
||||
'hook_overlap' => 0,
|
||||
'uaepg_overlap' => 0,
|
||||
);
|
||||
}
|
||||
$summary = TMDO_Conflict_Monitor::get_summary();
|
||||
return array(
|
||||
'total' => (int) ( $summary['total'] ?? 0 ),
|
||||
'hook_overlap' => (int) ( $summary['hook_overlap'] ?? 0 ),
|
||||
'uaepg_overlap' => (int) ( $summary['uaepg_overlap'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-module shadow_diffs ratio (only for modules currently in `verify`).
|
||||
* Reads wp_wpdo_shadow_diffs and bucket-counts by entity_type.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private static function summarize_shadow_diffs(): array {
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return $out;
|
||||
}
|
||||
$rows = $wpdb->get_results( "SELECT entity_type, COUNT(*) AS cnt FROM `{$table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR) GROUP BY entity_type", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
if ( is_array( $rows ) ) {
|
||||
foreach ( $rows as $r ) {
|
||||
$out[ (string) $r['entity_type'] ] = array(
|
||||
'diffs_24h' => (int) $r['cnt'],
|
||||
);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total byte size of autoloaded options.
|
||||
*
|
||||
* @return int Bytes in autoloaded options.
|
||||
*/
|
||||
private static function measure_autoload_size(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the slug of the first critical test result, or null if none.
|
||||
*
|
||||
* @param array $results Site Health test results map.
|
||||
* @return string|null Slug of first critical test, or null.
|
||||
*/
|
||||
private static function first_critical_test( array $results ): ?string {
|
||||
foreach ( $results as $slug => $r ) {
|
||||
if ( 'critical' === ( $r['status'] ?? '' ) ) {
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Monthly_Summary — 30-day rollup report (v2.4.0 M11).
|
||||
*
|
||||
* Generates a monthly executive summary covering:
|
||||
* - Health success / fail / rate-limit ratios over last 30 days
|
||||
* - Zone size growth (deltas from 30 days ago snapshot if available)
|
||||
* - Per-module FSM trajectory (who promoted, who rolled back)
|
||||
* - Snapshot retention overview (count, oldest, newest)
|
||||
*
|
||||
* Hooks the existing `wpdo_health_snapshot_monthly` action (Core line ~489)
|
||||
* so it runs once per month at 03:00 UTC on the 1st. Output:
|
||||
* 1. Persisted to wp_wpdo_audit (op='monthly_summary').
|
||||
* 2. Stored as wp_options.wpdo_monthly_summary_latest (autoload=no).
|
||||
* 3. Rendered on the admin Doctor tab "📅 Monthly Summary" sub-section.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly summary aggregator.
|
||||
*/
|
||||
class TMDO_Monthly_Summary {
|
||||
|
||||
public const OPTION_LATEST = 'wpdo_monthly_summary_latest';
|
||||
|
||||
/**
|
||||
* Hook into Core's existing monthly cron.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_health_snapshot_monthly', array( __CLASS__, 'generate' ), 20 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + persist the monthly summary.
|
||||
*
|
||||
* @return array Summary structure.
|
||||
*/
|
||||
public static function generate(): array {
|
||||
$started_at = microtime( true );
|
||||
$summary = array(
|
||||
'period_start' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ),
|
||||
'period_end' => gmdate( 'Y-m-d H:i:s' ),
|
||||
'health' => self::aggregate_health(),
|
||||
'fsm_trajectory' => self::aggregate_fsm(),
|
||||
'snapshots' => self::aggregate_snapshots(),
|
||||
'zone_growth' => self::aggregate_zone_growth(),
|
||||
'autoload_size' => self::measure_autoload(),
|
||||
'duration_ms' => 0,
|
||||
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
);
|
||||
$summary['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 );
|
||||
|
||||
update_option( self::OPTION_LATEST, $summary, false );
|
||||
|
||||
// v2.5.0 M14: archive into history (max 12 entries).
|
||||
$history = (array) get_option( 'wpdo_monthly_summary_history', array() );
|
||||
array_unshift( $history, $summary );
|
||||
$history = array_slice( $history, 0, 12 );
|
||||
update_option( 'wpdo_monthly_summary_history', $history, false );
|
||||
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'monthly_summary',
|
||||
array(
|
||||
'health_success' => (int) ( $summary['health']['success'] ?? 0 ),
|
||||
'health_critical' => (int) ( $summary['health']['critical'] ?? 0 ),
|
||||
'fsm_promotions' => count( $summary['fsm_trajectory']['promotions'] ?? array() ),
|
||||
'fsm_rollbacks' => count( $summary['fsm_trajectory']['rollbacks'] ?? array() ),
|
||||
'snapshots_total' => (int) ( $summary['snapshots']['total'] ?? 0 ),
|
||||
'autoload_kb' => (int) round( ( $summary['autoload_size'] ?? 0 ) / 1024 ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
do_action( 'wpdo/monthly_summary_generated', $summary );
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest summary (from wp_options).
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function get_latest(): ?array {
|
||||
$v = get_option( self::OPTION_LATEST );
|
||||
return is_array( $v ) ? $v : null;
|
||||
}
|
||||
|
||||
// ─── private ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate health-check audit log entries from last 30 days.
|
||||
*
|
||||
* @return array {success:int, recommended:int, critical:int, total:int}
|
||||
*/
|
||||
private static function aggregate_health(): array {
|
||||
global $wpdb;
|
||||
$out = array(
|
||||
'success' => 0,
|
||||
'recommended' => 0,
|
||||
'critical' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
|
||||
// wp_wpdo_audit may not exist on early v1.x installs; check first.
|
||||
$audit = $wpdb->prefix . 'wpdo_audit';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$audit
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return $out;
|
||||
}
|
||||
// Health check audit rows have op='health_check_daily'.
|
||||
$rows = (array) $wpdb->get_results( "SELECT * FROM `{$audit}` WHERE op = 'health_check_daily' AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
foreach ( $rows as $r ) {
|
||||
$ctx = isset( $r['value_after'] ) ? json_decode( (string) $r['value_after'], true ) : null;
|
||||
if ( ! is_array( $ctx ) ) {
|
||||
continue;
|
||||
}
|
||||
$crit = (int) ( $ctx['critical'] ?? 0 );
|
||||
$rec = (int) ( $ctx['recommended'] ?? 0 );
|
||||
++$out['total'];
|
||||
if ( $crit > 0 ) {
|
||||
++$out['critical'];
|
||||
} elseif ( $rec > 0 ) {
|
||||
++$out['recommended'];
|
||||
} else {
|
||||
++$out['success'];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort FSM trajectory: which modules changed state in the last 30
|
||||
* days. Reads `wpdo_fsm_state_entered` + current state.
|
||||
*
|
||||
* @return array {promotions:array, rollbacks:array, stationary:array}
|
||||
*/
|
||||
private static function aggregate_fsm(): array {
|
||||
$out = array(
|
||||
'promotions' => array(),
|
||||
'rollbacks' => array(),
|
||||
'stationary' => array(),
|
||||
);
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return $out;
|
||||
}
|
||||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||||
$now = time();
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $state ) {
|
||||
$ts = isset( $entered[ $module ]['entered_at'] ) ? strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' ) : 0;
|
||||
$age_days = $ts > 0 ? (int) floor( ( $now - $ts ) / DAY_IN_SECONDS ) : null;
|
||||
|
||||
if ( $ts > 0 && ( $now - $ts ) <= ( 30 * DAY_IN_SECONDS ) ) {
|
||||
// Recently changed — bucket by direction.
|
||||
if ( 'idle' === $state ) {
|
||||
$out['rollbacks'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
} else {
|
||||
$out['promotions'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$out['stationary'][ $module ] = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $age_days,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate snapshot retention stats from the snapshots table.
|
||||
*
|
||||
* @return array {total:int, oldest:string|null, newest:string|null, total_bytes:int}
|
||||
*/
|
||||
private static function aggregate_snapshots(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_snapshots';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return array(
|
||||
'total' => 0,
|
||||
'oldest' => null,
|
||||
'newest' => null,
|
||||
'total_bytes' => 0,
|
||||
);
|
||||
}
|
||||
$row = $wpdb->get_row( "SELECT COUNT(*) AS c, MIN(created_at) AS oldest, MAX(created_at) AS newest, COALESCE(SUM(size_bytes),0) AS bytes FROM `{$table}`", ARRAY_A ); // phpcs:ignore WordPress.DB
|
||||
return array(
|
||||
'total' => (int) ( $row['c'] ?? 0 ),
|
||||
'oldest' => isset( $row['oldest'] ) ? (string) $row['oldest'] : null,
|
||||
'newest' => isset( $row['newest'] ) ? (string) $row['newest'] : null,
|
||||
'total_bytes' => (int) ( $row['bytes'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-zone row count snapshot. Compares to previous month's value when
|
||||
* available (delta_rows positive = growth).
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
private static function aggregate_zone_growth(): array {
|
||||
// Use historical site_metrics when available — avoids live COUNT(*) on large tables
|
||||
// and provides a 30-day delta (current − oldest snapshot).
|
||||
if ( class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
|
||||
$latest = TMDO_Site_Metrics_Collector::get_latest_snapshot();
|
||||
if ( ! empty( $latest ) ) {
|
||||
// Get oldest snapshot within 30 days for delta calculation.
|
||||
$keys_of_interest = array( 'eav.postmeta_rows', 'flat.hot_rows', 'flat.cold_rows', 'flat.warm_rows', 'custom_tables.total_rows' );
|
||||
$out = array();
|
||||
foreach ( $keys_of_interest as $mk ) {
|
||||
if ( ! isset( $latest[ $mk ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$history = TMDO_Site_Metrics_Collector::get_history( $mk, 30 );
|
||||
$oldest = ! empty( $history ) ? (int) $history[0]['value'] : null;
|
||||
$current = (int) $latest[ $mk ];
|
||||
$out[ $mk ] = array(
|
||||
'rows' => $current,
|
||||
'delta_rows' => null !== $oldest ? $current - $oldest : null,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: live COUNT(*) for warm + archive tables (pre-v2.6.2 installs).
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
$keys = array( 'wpdo_warm', 'wpdo_archive' );
|
||||
foreach ( $keys as $slug ) {
|
||||
$table = $wpdb->prefix . $slug;
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
continue;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
$out[ $slug ] = array(
|
||||
'rows' => $count,
|
||||
'delta_rows' => null,
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total byte size of autoloaded options.
|
||||
*
|
||||
* @return int Bytes in autoloaded options.
|
||||
*/
|
||||
private static function measure_autoload(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( "SELECT COALESCE(SUM(LENGTH(option_value)),0) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
||||
/**
|
||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||
*
|
||||
* Registers 7 tests under Tools → Site Health → Status:
|
||||
* 1. wpdo_schema_drift (critical)
|
||||
* 2. wpdo_error_budget (recommended)
|
||||
* 3. wpdo_hook_conflicts (recommended)
|
||||
* 4. wpdo_autoload_bloat (recommended)
|
||||
* 5. wpdo_postmeta_explosion (recommended)
|
||||
* 6. wpdo_orphan_zone_rows (recommended)
|
||||
* 7. wpdo_missing_snapshot (critical)
|
||||
*
|
||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site Health test suite. Hook on `init` admin context.
|
||||
*/
|
||||
class TMDO_Site_Health {
|
||||
|
||||
/** Transient cache TTL for individual checks. */
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/** Test name → callable suffix mapping. */
|
||||
private const TESTS = array(
|
||||
'wpdo_schema_drift' => 'check_schema_drift',
|
||||
'wpdo_error_budget' => 'check_error_budget',
|
||||
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
||||
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
||||
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
||||
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
||||
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook into Site Health.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the 7 tests with WP Site Health.
|
||||
*
|
||||
* @param array $tests Existing tests.
|
||||
* @return array
|
||||
*/
|
||||
public static function register_tests( array $tests ): array {
|
||||
foreach ( self::TESTS as $key => $cb_suffix ) {
|
||||
$tests['direct'][ $key ] = array(
|
||||
'label' => self::label_for( $key ),
|
||||
'test' => array( __CLASS__, $cb_suffix ),
|
||||
);
|
||||
}
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for each test.
|
||||
*
|
||||
* @param string $key Test slug.
|
||||
* @return string
|
||||
*/
|
||||
private static function label_for( string $key ): string {
|
||||
$map = array(
|
||||
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
||||
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
||||
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
||||
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
||||
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
||||
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
||||
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
||||
);
|
||||
return $map[ $key ] ?? $key;
|
||||
}
|
||||
|
||||
// ─── Test 1: Schema drift ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify all expected v2 tables exist.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_schema_drift(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_schema_drift',
|
||||
static function () {
|
||||
if ( ! class_exists( 'TMDO_Installer' ) ) {
|
||||
return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) {
|
||||
return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$status = TMDO_Installer::v2_tables_status();
|
||||
$missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) );
|
||||
if ( empty( $missing ) ) {
|
||||
return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO v2 tables missing', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: comma-separated list of missing table names */
|
||||
__( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ),
|
||||
implode( ', ', $missing )
|
||||
),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_schema_drift', $result );
|
||||
}
|
||||
|
||||
// ─── Test 2: Error budget (last 7 days) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Count errors in wp_wpdo_errors over the last 7 days.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_error_budget(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_error_budget',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_errors';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 );
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count <= $threshold ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: %d: error count */
|
||||
__( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO error budget exceeded', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: error count, 2: threshold */
|
||||
__( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ),
|
||||
$count,
|
||||
$threshold
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_error_budget', $result );
|
||||
}
|
||||
|
||||
// ─── Test 3: Hook conflicts ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check for interceptor or hook conflicts via TMDO_Conflict_Monitor.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_hook_conflicts(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_hook_conflicts',
|
||||
static function () {
|
||||
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
||||
return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$summary = TMDO_Conflict_Monitor::get_summary();
|
||||
$total = (int) ( $summary['total'] ?? 0 );
|
||||
if ( 0 === $total ) {
|
||||
return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'WPDO hook conflicts detected', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %d: number of conflicts */
|
||||
__( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ),
|
||||
$total
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_hook_conflicts', $result );
|
||||
}
|
||||
|
||||
// ─── Test 4: Autoload bloat ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check total autoloaded options size against a configurable threshold.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_autoload_bloat(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_autoload_bloat',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 );
|
||||
$bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
||||
$mb = $bytes / 1024 / 1024;
|
||||
if ( $mb < $threshold_mb ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: 1: actual size in MB */
|
||||
__( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ),
|
||||
$mb,
|
||||
$threshold_mb
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'Autoload size large', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: actual size in MB, 2: threshold in MB */
|
||||
__( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ),
|
||||
$mb,
|
||||
$threshold_mb
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_autoload_bloat', $result );
|
||||
}
|
||||
|
||||
// ─── Test 5: Postmeta explosion ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether wp_postmeta row count exceeds the explosion threshold.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_postmeta_explosion(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_postmeta_explosion',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
$threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 );
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count < $threshold ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: %s: row count */
|
||||
__( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ),
|
||||
number_format_i18n( $count )
|
||||
)
|
||||
);
|
||||
}
|
||||
// Also check if any zone module is active.
|
||||
$any_active = false;
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
foreach ( TMDO_Feature_Flags::all() as $state ) {
|
||||
if ( 'idle' !== $state ) {
|
||||
$any_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$severity = $any_active ? 'recommended' : 'critical';
|
||||
return self::fail(
|
||||
__( 'wp_postmeta is large', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: row count */
|
||||
__( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ),
|
||||
number_format_i18n( $count )
|
||||
),
|
||||
$severity
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_postmeta_explosion', $result );
|
||||
}
|
||||
|
||||
// ─── Test 6: Orphan zone rows ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect zone table rows that belong to modules currently in idle state.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_orphan_zone_rows(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_orphan_zone',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) );
|
||||
$orphans = array();
|
||||
foreach ( $idle_modules as $module ) {
|
||||
// Best-effort: zone tables for hot_*/cold_* are dynamically named.
|
||||
$candidates = array(
|
||||
$wpdb->prefix . 'wpdo_warm',
|
||||
$wpdb->prefix . 'wpdo_archive',
|
||||
);
|
||||
foreach ( $candidates as $table ) {
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
continue;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( $count > 0 ) {
|
||||
$orphans[ $table ] = $count;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( empty( $orphans ) ) {
|
||||
return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$lines = array();
|
||||
foreach ( $orphans as $t => $n ) {
|
||||
$lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) );
|
||||
}
|
||||
return self::fail(
|
||||
__( 'Orphan zone rows detected', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: list of tables and row counts */
|
||||
__( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ),
|
||||
implode( ', ', $lines )
|
||||
),
|
||||
'recommended'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_orphan_zone_rows', $result );
|
||||
}
|
||||
|
||||
// ─── Test 7: Missing snapshot ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that a recent snapshot exists when modules are in risk states.
|
||||
*
|
||||
* @return array Site Health result.
|
||||
*/
|
||||
public static function check_missing_snapshot(): array {
|
||||
$result = self::cached(
|
||||
'wpdo_sh_missing_snapshot',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
// Risk only applies to modules in cutover/cleanup/complete (data is in custom tables).
|
||||
$risk_modules = array_keys(
|
||||
array_filter(
|
||||
TMDO_Feature_Flags::all(),
|
||||
static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true )
|
||||
)
|
||||
);
|
||||
if ( empty( $risk_modules ) ) {
|
||||
return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) );
|
||||
}
|
||||
$snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$snap_table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return self::fail(
|
||||
__( 'Snapshot table missing', '2meet-data-optimizer' ),
|
||||
__( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
$days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 );
|
||||
$recent = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input)
|
||||
$days
|
||||
)
|
||||
);
|
||||
if ( $recent > 0 ) {
|
||||
return self::pass(
|
||||
sprintf(
|
||||
/* translators: 1: count, 2: days */
|
||||
__( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ),
|
||||
$recent,
|
||||
$days
|
||||
)
|
||||
);
|
||||
}
|
||||
return self::fail(
|
||||
__( 'No recent WPDO snapshot', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: 1: comma-separated module names, 2: days */
|
||||
__( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ),
|
||||
implode( ', ', $risk_modules ),
|
||||
$days
|
||||
),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
);
|
||||
return self::wrap( 'wpdo_missing_snapshot', $result );
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wrap a raw check result with required Site Health envelope fields.
|
||||
*
|
||||
* @param string $key Test slug.
|
||||
* @param array $result Raw result with keys label, status, description, severity.
|
||||
* @return array
|
||||
*/
|
||||
private static function wrap( string $key, array $result ): array {
|
||||
$result['test'] = $key;
|
||||
$result['badge'] = array(
|
||||
'label' => 'WPDO',
|
||||
'color' => 'blue',
|
||||
);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a callable's return value via transient.
|
||||
*
|
||||
* @param string $key Transient key.
|
||||
* @param callable $producer Callable returning result array.
|
||||
* @return array
|
||||
*/
|
||||
private static function cached( string $key, callable $producer ): array {
|
||||
$cached = get_transient( $key );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
$result = $producer();
|
||||
set_transient( $key, $result, self::CACHE_TTL );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a "pass" result envelope.
|
||||
*
|
||||
* @param string $description Body text.
|
||||
* @return array
|
||||
*/
|
||||
private static function pass( string $description ): array {
|
||||
return array(
|
||||
'label' => __( 'WPDO check passed', '2meet-data-optimizer' ),
|
||||
'status' => 'good',
|
||||
'description' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'severity' => 'good',
|
||||
'actions' => '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fail / warning result envelope.
|
||||
*
|
||||
* @param string $label Test heading.
|
||||
* @param string $description Body.
|
||||
* @param string $severity 'critical' | 'recommended'.
|
||||
* @return array
|
||||
*/
|
||||
private static function fail( string $label, string $description, string $severity ): array {
|
||||
return array(
|
||||
'label' => $label,
|
||||
'status' => 'critical' === $severity ? 'critical' : 'recommended',
|
||||
'description' => '<p>' . esc_html( $description ) . '</p>',
|
||||
'severity' => $severity,
|
||||
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Site_Metrics_Collector — daily EAV health snapshot writer.
|
||||
*
|
||||
* Writes structured rows to `wpdo_site_metrics` once per day (via
|
||||
* `wpdo_collect_site_metrics` cron action, scheduled at 05:00 UTC).
|
||||
*
|
||||
* Metric keys written per run:
|
||||
* eav.postmeta_rows / eav.usermeta_rows / eav.termmeta_rows / eav.commentmeta_rows
|
||||
* flat.hot_rows / flat.cold_rows / flat.warm_rows
|
||||
* custom_tables.total_rows / custom_tables.table_count
|
||||
* errors.last_24h / shadow_diffs.last_24h
|
||||
*
|
||||
* Monthly Summary (`TMDO_Monthly_Summary`) reads these rows for the
|
||||
* `zone_growth` section instead of querying live tables, so the monthly
|
||||
* rollup is fast even on large databases.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.2
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects site-wide EAV health metrics and writes to wpdo_site_metrics.
|
||||
*/
|
||||
class TMDO_Site_Metrics_Collector {
|
||||
|
||||
/**
|
||||
* Cron hook name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CRON_HOOK = 'wpdo_collect_site_metrics';
|
||||
|
||||
/**
|
||||
* How many days of daily rows to retain before pruning.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const RETENTION_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Register the cron handler and return the instance for chaining.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( self::CRON_HOOK, array( __CLASS__, 'collect' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all site metrics and persist them to wpdo_site_metrics.
|
||||
*
|
||||
* Called by the daily cron. Safe to call manually (e.g., via WP-CLI).
|
||||
*
|
||||
* @param bool $dry_run When true, collect but do not write to the DB.
|
||||
* @return array<string,int> Map of metric_key => metric_value collected.
|
||||
*/
|
||||
public static function collect( bool $dry_run = false ): array {
|
||||
global $wpdb;
|
||||
|
||||
$now = TMDO_DB::now();
|
||||
$metrics = array();
|
||||
|
||||
// ── EAV row counts ────────────────────────────────────────────────
|
||||
$eav_tables = array(
|
||||
'eav.postmeta_rows' => $wpdb->postmeta,
|
||||
'eav.usermeta_rows' => $wpdb->usermeta,
|
||||
'eav.termmeta_rows' => $wpdb->termmeta,
|
||||
'eav.commentmeta_rows' => $wpdb->commentmeta,
|
||||
);
|
||||
foreach ( $eav_tables as $key => $table ) {
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
||||
$metrics[ $key ] = $count;
|
||||
}
|
||||
|
||||
// ── Flat-table row counts (hot + cold dynamic tables, warm) ───────
|
||||
$hot_rows = 0;
|
||||
$cold_rows = 0;
|
||||
|
||||
if ( class_exists( 'TMDO_Schema_Registry' ) ) {
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
foreach ( $registry->get_hot_post_types() as $pt ) {
|
||||
$ht = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ht ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$hot_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ht}`" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
}
|
||||
foreach ( $registry->get_cold_post_types() as $pt ) {
|
||||
$ct = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $pt );
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $ct ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$cold_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$ct}`" ); // phpcs:ignore WordPress.DB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$warm_table = $wpdb->prefix . 'wpdo_warm';
|
||||
$metrics['flat.hot_rows'] = $hot_rows;
|
||||
$metrics['flat.cold_rows'] = $cold_rows;
|
||||
$metrics['flat.warm_rows'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$warm_table}`" ); // phpcs:ignore WordPress.DB
|
||||
|
||||
// ── Custom table row counts ───────────────────────────────────────
|
||||
$custom_rows = 0;
|
||||
$custom_count = 0;
|
||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||
foreach ( TMDO_Custom_Table_Registry::instance()->all() as $cfg ) {
|
||||
$tbl = $wpdb->prefix . $cfg['table_name'];
|
||||
$exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tbl ) ); // phpcs:ignore WordPress.DB
|
||||
if ( $exists ) {
|
||||
$custom_rows += (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$tbl}`" ); // phpcs:ignore WordPress.DB
|
||||
++$custom_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
$metrics['custom_tables.total_rows'] = $custom_rows;
|
||||
$metrics['custom_tables.table_count'] = $custom_count;
|
||||
|
||||
// ── Error / shadow-diff activity (last 24 h) ─────────────────────
|
||||
$errors_table = $wpdb->prefix . 'wpdo_errors';
|
||||
$metrics['errors.last_24h'] = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$errors_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" // phpcs:ignore WordPress.DB
|
||||
);
|
||||
|
||||
$shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$shadow_exists = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $shadow_table ) ); // phpcs:ignore WordPress.DB
|
||||
$metrics['shadow_diffs.last_24h'] = $shadow_exists
|
||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$shadow_table}` WHERE ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)" ) // phpcs:ignore WordPress.DB
|
||||
: 0;
|
||||
|
||||
if ( $dry_run ) {
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
// ── Persist each metric to wpdo_site_metrics ─────────────────────
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
foreach ( $metrics as $key => $value ) {
|
||||
$wpdb->insert(
|
||||
$dest,
|
||||
array(
|
||||
'collected_at' => $now,
|
||||
'metric_key' => $key,
|
||||
'metric_value' => $value,
|
||||
'context' => null,
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prune old rows beyond retention window ────────────────────────
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::RETENTION_DAYS . ' days' ) );
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM {$dest} WHERE collected_at < %s", $cutoff ) ); // phpcs:ignore WordPress.DB
|
||||
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'site_metrics_collected',
|
||||
array(
|
||||
'metric_count' => count( $metrics ),
|
||||
'postmeta_rows' => $metrics['eav.postmeta_rows'] ?? 0,
|
||||
'hot_rows' => $metrics['flat.hot_rows'] ?? 0,
|
||||
'custom_rows' => $metrics['custom_tables.total_rows'] ?? 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent snapshot (latest collected_at timestamp).
|
||||
*
|
||||
* @return array<string,int> metric_key => metric_value, or empty on miss.
|
||||
*/
|
||||
public static function get_latest_snapshot(): array {
|
||||
global $wpdb;
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
|
||||
$latest_ts = $wpdb->get_var( "SELECT MAX(collected_at) FROM `{$dest}`" ); // phpcs:ignore WordPress.DB
|
||||
if ( ! $latest_ts ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT metric_key, metric_value FROM `{$dest}` WHERE collected_at = %s", // phpcs:ignore WordPress.DB
|
||||
$latest_ts
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$out = array();
|
||||
foreach ( $rows as $r ) {
|
||||
$out[ (string) $r['metric_key'] ] = (int) $r['metric_value'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily metric history for a single key over the past N days.
|
||||
*
|
||||
* @param string $metric_key Metric key (e.g. 'eav.postmeta_rows').
|
||||
* @param int $days Number of days of history to return (default 30).
|
||||
* @return array<array{collected_at:string,value:int}> Oldest-first.
|
||||
*/
|
||||
public static function get_history( string $metric_key, int $days = 30 ): array {
|
||||
global $wpdb;
|
||||
$dest = TMDO_DB::table( 'wpdo_site_metrics' );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $dest is a validated table name from TMDO_DB::table().
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT collected_at, metric_value AS value
|
||||
FROM `{$dest}`
|
||||
WHERE metric_key = %s
|
||||
AND collected_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)
|
||||
ORDER BY collected_at ASC",
|
||||
$metric_key,
|
||||
$days
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
return array_map(
|
||||
static fn( $r ) => array(
|
||||
'collected_at' => (string) $r['collected_at'],
|
||||
'value' => (int) $r['value'],
|
||||
),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
|
||||
*
|
||||
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
|
||||
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
|
||||
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
|
||||
*
|
||||
* 設計決定:
|
||||
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
|
||||
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
|
||||
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
|
||||
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
|
||||
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.3.0
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Audit_Logger {
|
||||
|
||||
public const TABLE_SUFFIX = 'audit';
|
||||
|
||||
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
|
||||
public const MAX_ROWS = 500_000;
|
||||
|
||||
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
|
||||
public const DEFAULT_RETENTION_DAYS = 365;
|
||||
|
||||
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
|
||||
|
||||
public const SOURCE_REST = 'rest';
|
||||
public const SOURCE_CLI = 'cli';
|
||||
public const SOURCE_FE_EDITOR = 'fe-editor';
|
||||
public const SOURCE_INTERNAL = 'internal';
|
||||
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX;
|
||||
}
|
||||
|
||||
public static function init(): void {
|
||||
add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 );
|
||||
add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。
|
||||
*
|
||||
* @param string $entity_type post|user|term|comment
|
||||
* @param int $entity_id
|
||||
* @param string $meta_key
|
||||
* @param mixed $meta_value 寫入後的新值
|
||||
* @param mixed $result perform_upsert 回傳值
|
||||
* @param string $op 'add' | 'update'
|
||||
* @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入)
|
||||
*/
|
||||
public static function on_write(
|
||||
string $entity_type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
$meta_value,
|
||||
$result,
|
||||
string $op,
|
||||
$before_value = null
|
||||
): void {
|
||||
if ( $result === false ) {
|
||||
return; // 寫入失敗不記
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return; // 非 UAE 欄位
|
||||
}
|
||||
|
||||
self::write_row(
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'group_name' => (string) ( $field_def['group'] ?? '' ),
|
||||
'meta_key' => $meta_key,
|
||||
'action' => 'write',
|
||||
'op' => $op, // 'add' | 'update'
|
||||
'value_before' => self::stringify( $before_value ),
|
||||
'value_after' => self::stringify( $meta_value ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除後訂閱。
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @param string $meta_key
|
||||
* @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值)
|
||||
* @param mixed $result
|
||||
* @param bool $delete_all
|
||||
* @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+)
|
||||
*/
|
||||
public static function on_delete(
|
||||
string $entity_type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
$meta_value,
|
||||
$result,
|
||||
bool $delete_all,
|
||||
$before_value = null
|
||||
): void {
|
||||
if ( $result === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::write_row(
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'group_name' => (string) ( $field_def['group'] ?? '' ),
|
||||
'meta_key' => $meta_key,
|
||||
'action' => 'delete',
|
||||
'op' => $delete_all ? 'delete_all' : 'delete',
|
||||
'value_before' => self::stringify( $before_value ),
|
||||
'value_after' => null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static function write_row( array $row ): void {
|
||||
global $wpdb;
|
||||
|
||||
$source = apply_filters(
|
||||
'wpdo_audit_source',
|
||||
defined( 'WP_CLI' ) && WP_CLI
|
||||
? self::SOURCE_CLI
|
||||
: ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL )
|
||||
);
|
||||
|
||||
// wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷,
|
||||
// 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。
|
||||
$wpdb->insert(
|
||||
self::table_name(),
|
||||
array_merge(
|
||||
$row,
|
||||
array(
|
||||
'ts' => current_time( 'mysql', true ),
|
||||
'user_id' => get_current_user_id(),
|
||||
'source' => (string) $source,
|
||||
'trace_id' => TMDO_Logger::trace_id(),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim,
|
||||
// 避免每次寫都查 count。
|
||||
if ( wp_rand( 1, 100 ) === 1 ) {
|
||||
self::maybe_prune();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 MAX_ROWS 與 retention_days 裁剪 audit 表。
|
||||
*
|
||||
* 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。
|
||||
* 回傳總共刪除的 row 數(供 CLI 呈現)。
|
||||
*
|
||||
* @since 1.3.2
|
||||
*/
|
||||
public static function maybe_prune(): int {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$total = 0;
|
||||
|
||||
$retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS );
|
||||
if ( $retention_days > 0 ) {
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
|
||||
$deleted = (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE ts < %s",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
$total += $deleted;
|
||||
|
||||
if ( $deleted > 0 ) {
|
||||
TMDO_Logger::info(
|
||||
'audit_prune_expired',
|
||||
array(
|
||||
'deleted' => $deleted,
|
||||
'cutoff' => $cutoff,
|
||||
'days' => $retention_days,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
if ( $count > self::MAX_ROWS ) {
|
||||
$to_delete = $count - self::MAX_ROWS;
|
||||
$deleted = (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
|
||||
$to_delete
|
||||
)
|
||||
);
|
||||
$total += $deleted;
|
||||
|
||||
TMDO_Logger::warning(
|
||||
'audit_prune_overflow',
|
||||
array(
|
||||
'deleted' => $deleted,
|
||||
'count_before' => $count,
|
||||
'max_rows' => self::MAX_ROWS,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private static function stringify( $value ): ?string {
|
||||
if ( $value === null ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_scalar( $value ) ) {
|
||||
return (string) $value;
|
||||
}
|
||||
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Schema
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function install_table(): void {
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
$table = self::table_name();
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
// `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。
|
||||
// v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete),
|
||||
// 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`ts` DATETIME NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`entity_type` VARCHAR(20) NOT NULL,
|
||||
`entity_id` BIGINT UNSIGNED NOT NULL,
|
||||
`group_name` VARCHAR(64) NOT NULL,
|
||||
`meta_key` VARCHAR(255) NOT NULL,
|
||||
`action` VARCHAR(10) NOT NULL,
|
||||
`op` VARCHAR(10) NOT NULL DEFAULT 'update',
|
||||
`value_before` LONGTEXT NULL,
|
||||
`value_after` LONGTEXT NULL,
|
||||
`source` VARCHAR(20) NULL,
|
||||
`trace_id` CHAR(36) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_entity` (`entity_type`, `entity_id`),
|
||||
KEY `idx_ts` (`ts`),
|
||||
KEY `idx_user` (`user_id`, `ts`),
|
||||
KEY `idx_op` (`op`, `ts`)
|
||||
) {$charset};";
|
||||
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
public static function drop_table(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Read API(供 admin / CLI 查詢)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 取得最近 N 筆 audit 紀錄。
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
if ( $entity_type ) {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
|
||||
$entity_type,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
} else {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
}
|
||||
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
public static function count(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Auto_Promoter — shadow_read 穩定期後自動升級到 aeav_only。
|
||||
*
|
||||
* 僅針對 `shadow_read` 做自動升級(升級到 aeav_only),其他 mode 不自動動。
|
||||
* 判斷條件(皆滿足才觸發):
|
||||
* 1. 當前 mode = shadow_read
|
||||
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
||||
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
|
||||
*
|
||||
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
||||
* 若 diff 出現,規則永不觸發;人工介入即可。
|
||||
*
|
||||
* 可由 option 或 filter 調整:
|
||||
* option `wpdo_auto_promote_enabled` — 全域開關(bool,預設 false)
|
||||
* option `wpdo_auto_promote_min_days` — 停留天數門檻(int,預設 7)
|
||||
* filter `wpdo_auto_promote_should_run` — per-entity 覆寫(bool default)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.5.0
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Auto_Promoter {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_auto_promote_enabled';
|
||||
public const OPT_MIN_DAYS = 'wpdo_auto_promote_min_days';
|
||||
public const DEFAULT_MIN_DAYS = 7;
|
||||
|
||||
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
||||
|
||||
public static function init(): void {
|
||||
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
||||
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次啟用時排入 daily cron。Option 關閉時會停掉,開啟時重排。
|
||||
*/
|
||||
public static function maybe_schedule(): void {
|
||||
$enabled = (bool) get_option( self::OPT_ENABLED, false );
|
||||
$scheduled = wp_next_scheduled( self::CRON_HOOK );
|
||||
|
||||
if ( $enabled && ! $scheduled ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
|
||||
} elseif ( ! $enabled && $scheduled ) {
|
||||
wp_unschedule_event( $scheduled, self::CRON_HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍歷所有 entity,符合條件者升級為 aeav_only。
|
||||
*
|
||||
* @return array<string, string> entity_type → 結果('promoted'|'not_ready'|'no_diff_yet'|'disabled_globally')
|
||||
*/
|
||||
public static function check_all(): array {
|
||||
$results = array();
|
||||
|
||||
if ( ! (bool) get_option( self::OPT_ENABLED, false ) ) {
|
||||
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
|
||||
$results[ $type ] = 'disabled_globally';
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
$min_days = (int) get_option( self::OPT_MIN_DAYS, self::DEFAULT_MIN_DAYS );
|
||||
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
|
||||
|
||||
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
|
||||
$results[ $type ] = self::check_entity( $type, $min_days, $entered_all );
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 評估單一 entity 是否可升級。
|
||||
*
|
||||
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
|
||||
*/
|
||||
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
|
||||
$current = TMDO_Mode_Manager::get( $type );
|
||||
if ( $current !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
|
||||
return 'not_shadow_read';
|
||||
}
|
||||
|
||||
$entered_at = isset( $entered_all[ $type ] ) ? (int) $entered_all[ $type ] : 0;
|
||||
if ( $entered_at === 0 || ( time() - $entered_at ) < $min_days * DAY_IN_SECONDS ) {
|
||||
return 'not_ready';
|
||||
}
|
||||
|
||||
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
|
||||
$count = (int) ( $diff_counts[ $type ] ?? 0 );
|
||||
if ( $count > 0 ) {
|
||||
return 'has_diff';
|
||||
}
|
||||
|
||||
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
|
||||
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
|
||||
if ( ! $should ) {
|
||||
return 'vetoed';
|
||||
}
|
||||
|
||||
$result = TMDO_Mode_Manager::set( $type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
TMDO_Logger::error(
|
||||
'auto_promote_failed',
|
||||
array(
|
||||
'entity_type' => $type,
|
||||
'error' => $result->get_error_message(),
|
||||
)
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
TMDO_Logger::info(
|
||||
'auto_promote_success',
|
||||
array(
|
||||
'entity_type' => $type,
|
||||
'entered_at' => gmdate( 'c', $entered_at ),
|
||||
'days_elapsed' => (int) ( ( time() - $entered_at ) / DAY_IN_SECONDS ),
|
||||
'min_days' => $min_days,
|
||||
)
|
||||
);
|
||||
|
||||
do_action( 'wpdo_auto_promoted', $type, $entered_at, $min_days );
|
||||
|
||||
return 'promoted';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Cache_Orchestrator - 多層快取協調器
|
||||
*
|
||||
* 層級:
|
||||
* L1: PHP Process Memory(同一請求內,靜態陣列)
|
||||
* L2: WP Object Cache(搭配 Redis/Memcached 外掛時為跨請求)
|
||||
* L3: Transient(fallback,資料庫持久化)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Cache_Orchestrator {
|
||||
|
||||
/** @var array<string, mixed> L1 記憶體快取 */
|
||||
private static array $l1_cache = array();
|
||||
|
||||
/** @var int L1 快取計數上限(防記憶體爆炸) */
|
||||
private const L1_MAX_ITEMS = 1000;
|
||||
|
||||
/** @var int L2 TTL 秒數 */
|
||||
private const L2_TTL = HOUR_IN_SECONDS;
|
||||
|
||||
public static function init(): void {
|
||||
// 註冊 L1 清理(避免長命令列任務記憶體膨脹)
|
||||
add_action( 'shutdown', array( self::class, 'clear_l1' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生快取 key
|
||||
*/
|
||||
private static function make_key( string $type, int $id, string $group ): string {
|
||||
return "{$type}:{$id}:{$group}";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 讀取
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function get_row( string $type, int $id, string $group ) {
|
||||
$key = self::make_key( $type, $id, $group );
|
||||
|
||||
// L1
|
||||
if ( array_key_exists( $key, self::$l1_cache ) ) {
|
||||
return self::$l1_cache[ $key ];
|
||||
}
|
||||
|
||||
// L2
|
||||
$cached = wp_cache_get( $key, TMDO_CACHE_GROUP );
|
||||
if ( $cached !== false ) {
|
||||
self::set_l1( $key, $cached );
|
||||
return $cached;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 寫入
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function set_row( string $type, int $id, string $group, array $data ): void {
|
||||
$key = self::make_key( $type, $id, $group );
|
||||
|
||||
self::set_l1( $key, $data );
|
||||
wp_cache_set( $key, $data, TMDO_CACHE_GROUP, self::L2_TTL );
|
||||
}
|
||||
|
||||
private static function set_l1( string $key, $data ): void {
|
||||
// 防 L1 無限膨脹
|
||||
if ( count( self::$l1_cache ) >= self::L1_MAX_ITEMS ) {
|
||||
// 簡易 FIFO:砍掉最舊的 20%
|
||||
$keep = (int) ( self::L1_MAX_ITEMS * 0.8 );
|
||||
self::$l1_cache = array_slice( self::$l1_cache, - $keep, null, true );
|
||||
}
|
||||
self::$l1_cache[ $key ] = $data;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 失效
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function invalidate( string $type, int $id, string $group ): void {
|
||||
$key = self::make_key( $type, $id, $group );
|
||||
unset( self::$l1_cache[ $key ] );
|
||||
wp_cache_delete( $key, TMDO_CACHE_GROUP );
|
||||
}
|
||||
|
||||
public static function flush_entity( string $type, ?int $id = null ): void {
|
||||
// 若沒給 id → flush 整個 entity type(L1 中所有該 type 的 key)
|
||||
if ( $id === null ) {
|
||||
$prefix = 'uae:' . $type . ':';
|
||||
foreach ( array_keys( self::$l1_cache ) as $key ) {
|
||||
if ( strpos( $key, $prefix ) === 0 ) {
|
||||
unset( self::$l1_cache[ $key ] );
|
||||
}
|
||||
}
|
||||
// Object cache 沒法做 prefix flush,用版本號 bump
|
||||
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
|
||||
return;
|
||||
}
|
||||
|
||||
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
|
||||
foreach ( $groups as $group ) {
|
||||
self::invalidate( $type, $id, $group );
|
||||
}
|
||||
}
|
||||
|
||||
public static function flush_all(): void {
|
||||
self::$l1_cache = array();
|
||||
// WP Object Cache 無跨群組 flush,改用版本號方式
|
||||
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
|
||||
}
|
||||
|
||||
public static function clear_l1(): void {
|
||||
self::$l1_cache = array();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 批次預熱(核心效能優化:解決清單頁 N+1)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 批次預載一組 entity IDs 的資料至快取
|
||||
* 用於清單頁渲染前呼叫,避免每個 item 都去查 DB
|
||||
*
|
||||
* @param string $type 實體類型
|
||||
* @param array $ids entity ID 陣列
|
||||
* @param string $group 欄位群組
|
||||
*/
|
||||
public static function warm_batch( string $type, array $ids, string $group ): void {
|
||||
|
||||
if ( empty( $ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找出未快取的 IDs
|
||||
$uncached_ids = array_filter(
|
||||
$ids,
|
||||
function ( $id ) use ( $type, $group ) {
|
||||
return self::get_row( $type, (int) $id, $group ) === false;
|
||||
}
|
||||
);
|
||||
|
||||
if ( empty( $uncached_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids_in = implode( ',', array_map( 'intval', $uncached_ids ) );
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
"SELECT * FROM `{$table}` WHERE `{$id_col}` IN ({$ids_in})",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! is_array( $rows ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 建立 id → row 索引
|
||||
$indexed = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$indexed[ (int) $row[ $id_col ] ] = $row;
|
||||
}
|
||||
|
||||
// 填入快取(未找到者存空陣列避免重查)
|
||||
foreach ( $uncached_ids as $id ) {
|
||||
$id_int = (int) $id;
|
||||
self::set_row( $type, $id_int, $group, $indexed[ $id_int ] ?? array() );
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 統計(供後台顯示)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function get_l1_stats(): array {
|
||||
return array(
|
||||
'count' => count( self::$l1_cache ),
|
||||
'max' => self::L1_MAX_ITEMS,
|
||||
'usage_pct' => self::L1_MAX_ITEMS > 0
|
||||
? round( count( self::$l1_cache ) / self::L1_MAX_ITEMS * 100, 1 )
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Conflict_Detector — 偵測 UAE 與 UAEPG 同欄位衝突
|
||||
*
|
||||
* 背景:
|
||||
* UAEPG(PostgreSQL sidecar)以 metadata filter priority 5 攔截,
|
||||
* UAE 在 priority 10 攔截。若同一 meta_key 被雙方都登錄,
|
||||
* UAEPG 會先短路 return,UAE 寫入永遠不執行,資料靜默遺失。
|
||||
*
|
||||
* 本類別在欄位登錄階段完成後(init:25)掃描雙方 Registry,
|
||||
* 找出 overlap 並:
|
||||
* 1. 寫入 error log(供 CI / 監控系統消化)
|
||||
* 2. 在管理後台顯示紅色 admin_notices
|
||||
* 3. 提供 TMDO_Conflict_Detector::get_conflicts() 與 CLI 指令 wp uae conflicts 查詢
|
||||
*
|
||||
* 結果 cache 在 static property 內,單 request 內不重複掃描。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.1.2
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Conflict_Detector {
|
||||
|
||||
/**
|
||||
* @var array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>|null
|
||||
*/
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function init(): void {
|
||||
// init:25 — register_default_fields 於 init:20 觸發 wpdo_register_fields,
|
||||
// UAEPG 亦在 init:20 觸發 uaepg_register_fields;此時 priority 25 可確保雙邊皆完成登錄。
|
||||
add_action( 'init', array( self::class, 'scan' ), 25 );
|
||||
|
||||
if ( is_admin() ) {
|
||||
add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃描並 cache 衝突清單。
|
||||
*
|
||||
* @return array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>
|
||||
*/
|
||||
public static function scan(): array {
|
||||
if ( self::$cache !== null ) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'UAEPG_Registry' ) ) {
|
||||
return self::$cache = array();
|
||||
}
|
||||
|
||||
$conflicts = array();
|
||||
|
||||
foreach ( TMDO_Entity_Registry::get_all_fields() as $entity_type => $fields_by_key ) {
|
||||
foreach ( $fields_by_key as $meta_key => $wpdo_field ) {
|
||||
if ( ! UAEPG_Registry::is_managed( $entity_type, $meta_key ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uaepg_field = UAEPG_Registry::get_field( $entity_type, $meta_key );
|
||||
|
||||
$conflicts[] = array(
|
||||
'entity_type' => $entity_type,
|
||||
'meta_key' => $meta_key,
|
||||
'wpdo_group' => $wpdo_field['group'] ?? '(unknown)',
|
||||
'uaepg_group' => $uaepg_field['group'] ?? '(unknown)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( $conflicts ) {
|
||||
TMDO_Logger::error(
|
||||
'field_registration_conflict',
|
||||
array(
|
||||
'count' => count( $conflicts ),
|
||||
'fields' => $conflicts,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return self::$cache = $conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得衝突清單(若尚未掃描,觸發掃描)。
|
||||
*/
|
||||
public static function get_conflicts(): array {
|
||||
return self::$cache ?? self::scan();
|
||||
}
|
||||
|
||||
/**
|
||||
* 測試用:強制清除 cache,下次 scan() 會重跑。
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_cache(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 若有衝突則輸出管理後台紅色通知。
|
||||
*/
|
||||
public static function maybe_render_admin_notice(): void {
|
||||
$conflicts = self::get_conflicts();
|
||||
if ( ! $conflicts ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = count( $conflicts );
|
||||
$lines = array();
|
||||
foreach ( array_slice( $conflicts, 0, 5 ) as $c ) {
|
||||
$lines[] = sprintf(
|
||||
'%s / %s(UAE 群組「%s」 ↔ UAEPG 群組「%s」)',
|
||||
esc_html( $c['entity_type'] ),
|
||||
esc_html( $c['meta_key'] ),
|
||||
esc_html( $c['wpdo_group'] ),
|
||||
esc_html( $c['uaepg_group'] )
|
||||
);
|
||||
}
|
||||
$extra = $count > 5 ? sprintf( __( '… 另有 %d 個欄位未顯示', 'uae' ), $count - 5 ) : '';
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-error"><p><strong>%s</strong></p><ul style="list-style:disc;margin-left:20px;"><li>%s</li></ul>%s<p>%s <code>wp uae conflicts</code></p></div>',
|
||||
esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: conflict count */
|
||||
__( 'UAE × UAEPG 欄位衝突偵測:發現 %d 個同 meta_key 被雙方 Registry 重複登錄 — UAEPG 會搶先短路,UAE 寫入將被靜默跳過', 'uae' ),
|
||||
$count
|
||||
)
|
||||
),
|
||||
// $lines elements were each individually esc_html()'d above (lines 121-124).
|
||||
// implode joins escaped strings with literal HTML markers — safe.
|
||||
implode( '</li><li>', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
|
||||
$extra ? '<p><em>' . esc_html( $extra ) . '</em></p>' : '',
|
||||
esc_html__( '查看完整清單:', 'uae' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Health — Entity Bridge 健康狀態聚合器
|
||||
*
|
||||
* 收集每個 entity type (user/term/comment) 的即時健康快照:
|
||||
* - 模式與已停留天數
|
||||
* - 每個群組的覆蓋率(flat table rows vs EAV rows)
|
||||
* - Shadow diff 計數
|
||||
* - 遷移進度(斷點位置、已搬移筆數)
|
||||
* - Auto-promote 資格評估
|
||||
* - 操作建議
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.6.6
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Health {
|
||||
|
||||
/** Entity types managed by this class (post is handled by Feature_Flags FSM). */
|
||||
public const MANAGED_TYPES = array( 'user', 'term', 'comment' );
|
||||
|
||||
/**
|
||||
* 取得全部 entity 的健康快照(批次,供 REST polling 用)。
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public static function get_all(): array {
|
||||
$result = array();
|
||||
foreach ( self::MANAGED_TYPES as $type ) {
|
||||
$result[ $type ] = self::get_one( $type );
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一 entity 的健康快照。
|
||||
*/
|
||||
public static function get_one( string $entity_type ): array {
|
||||
$mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( $entity_type ) : 'disabled';
|
||||
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
|
||||
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
|
||||
$mode_days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
|
||||
|
||||
$groups = self::get_groups_health( $entity_type );
|
||||
|
||||
$shadow_diffs = 0;
|
||||
if ( class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
||||
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
|
||||
$shadow_diffs = (int) ( $diff_counts[ $entity_type ] ?? 0 );
|
||||
}
|
||||
|
||||
$auto_promote = self::get_auto_promote_status( $entity_type, $mode, $entered_at, $shadow_diffs );
|
||||
$backfill_active = self::is_backfill_active( $entity_type );
|
||||
$native_counts = self::get_native_counts( $entity_type );
|
||||
|
||||
return array(
|
||||
'entity_type' => $entity_type,
|
||||
'mode' => $mode,
|
||||
'entered_at' => $entered_at,
|
||||
'mode_days' => $mode_days,
|
||||
'groups' => $groups,
|
||||
'shadow_diffs' => $shadow_diffs,
|
||||
'auto_promote' => $auto_promote,
|
||||
'backfill_active' => $backfill_active,
|
||||
'native_entity_count' => $native_counts['entity_count'],
|
||||
'native_meta_count' => $native_counts['meta_count'],
|
||||
'native_entity_table' => $native_counts['entity_table'],
|
||||
'native_meta_table' => $native_counts['meta_table'],
|
||||
'recommendation' => self::get_recommendation( $entity_type, $mode, $groups, $shadow_diffs ),
|
||||
'next_mode' => self::get_next_mode( $mode ),
|
||||
'prev_mode' => self::get_prev_mode( $mode ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 native EAV 表的總筆數(entity 本體 + meta)。
|
||||
*/
|
||||
private static function get_native_counts( string $entity_type ): array {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_adapter( $entity_type ) : null;
|
||||
|
||||
if ( ! $adapter ) {
|
||||
return array(
|
||||
'entity_table' => '',
|
||||
'meta_table' => '',
|
||||
'entity_count' => 0,
|
||||
'meta_count' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$entity_table = self::get_native_entity_table( $entity_type );
|
||||
|
||||
$entity_count = $entity_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$entity_table}`" ) : 0;
|
||||
$meta_count = $meta_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$meta_table}`" ) : 0;
|
||||
|
||||
return array(
|
||||
'entity_table' => $entity_table,
|
||||
'meta_table' => $meta_table,
|
||||
'entity_count' => $entity_count,
|
||||
'meta_count' => $meta_count,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得 entity 本體資料表名稱(非 meta 表)。
|
||||
*/
|
||||
private static function get_native_entity_table( string $entity_type ): string {
|
||||
global $wpdb;
|
||||
$map = array(
|
||||
'user' => $wpdb->users,
|
||||
'term' => $wpdb->terms,
|
||||
'comment' => $wpdb->comments,
|
||||
'post' => $wpdb->posts,
|
||||
);
|
||||
return $map[ $entity_type ] ?? '';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 群組覆蓋率
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static function get_groups_health( string $entity_type ): array {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Schema_Manager' ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type );
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
$result = array();
|
||||
|
||||
foreach ( $groups as $group_name ) {
|
||||
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
$table_exists = TMDO_Schema_Manager::table_exists( $table );
|
||||
|
||||
$flat_rows = 0;
|
||||
$eav_rows = 0;
|
||||
|
||||
if ( $table_exists && $adapter ) {
|
||||
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
|
||||
$managed_keys = array_column( $fields, 'key' );
|
||||
if ( ! empty( $managed_keys ) ) {
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$phs = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
|
||||
$eav_rows = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$meta_table}` WHERE `meta_key` IN ({$phs})",
|
||||
...$managed_keys
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$coverage_pct = 0.0;
|
||||
if ( $eav_rows > 0 ) {
|
||||
$coverage_pct = round( min( $flat_rows / $eav_rows, 1.0 ) * 100, 1 );
|
||||
} elseif ( $flat_rows > 0 ) {
|
||||
$coverage_pct = 100.0;
|
||||
}
|
||||
|
||||
$migration_info = self::get_migration_status( $entity_type, $group_name );
|
||||
|
||||
$result[] = array(
|
||||
'name' => $group_name,
|
||||
'fields_count' => count( $fields ),
|
||||
'table' => $table,
|
||||
'table_exists' => $table_exists,
|
||||
'flat_rows' => $flat_rows,
|
||||
'eav_rows' => $eav_rows,
|
||||
'coverage_pct' => $coverage_pct,
|
||||
'migration_status' => $migration_info['status'],
|
||||
'last_id' => $migration_info['last_id'],
|
||||
'total_migrated' => $migration_info['total_migrated'],
|
||||
'started_at' => $migration_info['started_at'],
|
||||
'completed_at' => $migration_info['completed_at'],
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function get_migration_status( string $entity_type, string $group_name ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT status, last_id, total_migrated, started_at, completed_at FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
|
||||
$entity_type,
|
||||
$group_name
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array(
|
||||
'status' => $row['status'] ?? 'not_started',
|
||||
'last_id' => (int) ( $row['last_id'] ?? 0 ),
|
||||
'total_migrated' => (int) ( $row['total_migrated'] ?? 0 ),
|
||||
'started_at' => $row['started_at'] ?? null,
|
||||
'completed_at' => $row['completed_at'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auto-promote 資格評估
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static function get_auto_promote_status(
|
||||
string $entity_type,
|
||||
string $mode,
|
||||
int $entered_at,
|
||||
int $shadow_diffs
|
||||
): array {
|
||||
$enabled = (bool) get_option( TMDO_Auto_Promoter::OPT_ENABLED, false );
|
||||
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
|
||||
$days_elapsed = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
|
||||
|
||||
if ( $mode !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
|
||||
return array(
|
||||
'enabled' => $enabled,
|
||||
'min_days' => $min_days,
|
||||
'eligible' => false,
|
||||
'reason' => 'not_shadow_read',
|
||||
);
|
||||
}
|
||||
|
||||
if ( $days_elapsed < $min_days ) {
|
||||
return array(
|
||||
'enabled' => $enabled,
|
||||
'min_days' => $min_days,
|
||||
'days_elapsed' => $days_elapsed,
|
||||
'eligible' => false,
|
||||
'reason' => "need_{$min_days}_days",
|
||||
);
|
||||
}
|
||||
|
||||
if ( $shadow_diffs > 0 ) {
|
||||
return array(
|
||||
'enabled' => $enabled,
|
||||
'min_days' => $min_days,
|
||||
'days_elapsed' => $days_elapsed,
|
||||
'eligible' => false,
|
||||
'reason' => "has_{$shadow_diffs}_diffs",
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'enabled' => $enabled,
|
||||
'min_days' => $min_days,
|
||||
'days_elapsed' => $days_elapsed,
|
||||
'eligible' => true,
|
||||
'reason' => 'ready',
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 建議與模式轉換
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static function get_recommendation(
|
||||
string $entity_type,
|
||||
string $mode,
|
||||
array $groups,
|
||||
int $shadow_diffs
|
||||
): string {
|
||||
switch ( $mode ) {
|
||||
case TMDO_Mode_Manager::MODE_DISABLED:
|
||||
return '啟用 Hook Bus 並切換到 dual_write,讓系統開始對 flat table 雙寫。';
|
||||
|
||||
case TMDO_Mode_Manager::MODE_DUAL_WRITE:
|
||||
foreach ( $groups as $g ) {
|
||||
if ( $g['coverage_pct'] < 99.0 ) {
|
||||
return '執行 Backfill 遷移歷史資料,待所有群組覆蓋率達到 100% 後再升級到 shadow_read。';
|
||||
}
|
||||
}
|
||||
return '所有群組覆蓋率已達 100%,可以升級到 shadow_read 進行驗證期觀察。';
|
||||
|
||||
case TMDO_Mode_Manager::MODE_SHADOW_READ:
|
||||
if ( $shadow_diffs > 0 ) {
|
||||
return "發現 {$shadow_diffs} 筆 shadow diff,請先調查差異原因(Settings → Shadow Diffs)後再考慮升級。";
|
||||
}
|
||||
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
|
||||
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
|
||||
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
|
||||
$days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
|
||||
if ( $days < $min_days ) {
|
||||
return "繼續觀察中(已 {$days}/{$min_days} 天)。無 diff 且穩定後可升級到 aeav_only。";
|
||||
}
|
||||
return '已達穩定期,可以升級到 aeav_only(最終生產模式)。';
|
||||
|
||||
case TMDO_Mode_Manager::MODE_AEAV_ONLY:
|
||||
return '遷移完成。所有讀寫均走 flat table,效能最佳。';
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private static function get_next_mode( string $mode ): ?string {
|
||||
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
|
||||
if ( $idx === false || $idx >= count( TMDO_Mode_Manager::ALL_MODES ) - 1 ) {
|
||||
return null;
|
||||
}
|
||||
return TMDO_Mode_Manager::ALL_MODES[ $idx + 1 ];
|
||||
}
|
||||
|
||||
private static function get_prev_mode( string $mode ): ?string {
|
||||
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
|
||||
if ( $idx === false || $idx <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
|
||||
}
|
||||
|
||||
private static function is_backfill_active( string $entity_type ): bool {
|
||||
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Migration_Engine - 遷移引擎
|
||||
*
|
||||
* 負責將原生 wp_*meta 表中的 EAV 資料搬移至 UAE 扁平化表
|
||||
*
|
||||
* 特性:
|
||||
* - Cursor-based 分頁(避免 OFFSET 效能問題)
|
||||
* - 斷點續傳(記錄 last_id)
|
||||
* - 批次處理(可配置 batch size)
|
||||
* - Transaction 安全
|
||||
* - 統計報告
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Migration_Engine {
|
||||
|
||||
/** 預設批次大小 */
|
||||
public const DEFAULT_BATCH_SIZE = 500;
|
||||
|
||||
/** 批次間延遲(毫秒)*/
|
||||
public const DEFAULT_SLEEP_MS = 100;
|
||||
|
||||
/**
|
||||
* 遷移一個實體類型下的一個群組
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param string $group_name
|
||||
* @param array $options {
|
||||
* @type int $batch_size 每批筆數
|
||||
* @type int $sleep_ms 批次間延遲毫秒
|
||||
* @type bool $resume 是否從斷點續傳
|
||||
* @type bool $dry_run 乾跑(不實際寫入)
|
||||
* }
|
||||
* @return array 統計報告
|
||||
*/
|
||||
public static function migrate_group(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
array $options = array()
|
||||
): array {
|
||||
|
||||
$defaults = array(
|
||||
'batch_size' => self::DEFAULT_BATCH_SIZE,
|
||||
'sleep_ms' => self::DEFAULT_SLEEP_MS,
|
||||
'resume' => true,
|
||||
'dry_run' => false,
|
||||
);
|
||||
$options = wp_parse_args( $options, $defaults );
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return self::error_result( "Adapter not found: {$entity_type}" );
|
||||
}
|
||||
|
||||
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
if ( empty( $group_fields ) ) {
|
||||
return self::error_result( "Group not registered: {$entity_type}/{$group_name}" );
|
||||
}
|
||||
|
||||
$managed_keys = array_column( $group_fields, 'key' );
|
||||
if ( empty( $managed_keys ) ) {
|
||||
return self::error_result( 'No keys to migrate' );
|
||||
}
|
||||
|
||||
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) && ! $options['dry_run'] ) {
|
||||
return self::error_result( "Target table does not exist: {$target_table}" );
|
||||
}
|
||||
|
||||
// 斷點
|
||||
$last_id = $options['resume']
|
||||
? self::get_checkpoint( $entity_type, $group_name )
|
||||
: 0;
|
||||
|
||||
// 標記開始
|
||||
if ( ! $options['dry_run'] ) {
|
||||
self::mark_migration_started( $entity_type, $group_name );
|
||||
}
|
||||
|
||||
$stats = array(
|
||||
'entity_type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'migrated' => 0,
|
||||
'errors' => 0,
|
||||
'skipped' => 0,
|
||||
'elapsed_sec' => 0,
|
||||
'dry_run' => $options['dry_run'],
|
||||
);
|
||||
|
||||
$start_time = microtime( true );
|
||||
|
||||
global $wpdb;
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$field_map = array_column( $group_fields, null, 'key' );
|
||||
|
||||
// 建立 IN 子句佔位符
|
||||
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
|
||||
do {
|
||||
// Cursor-based 分頁
|
||||
$query = $wpdb->prepare(
|
||||
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
|
||||
WHERE `meta_key` IN ({$keys_placeholders})
|
||||
AND `{$id_col}` > %d
|
||||
ORDER BY `{$id_col}` ASC
|
||||
LIMIT %d",
|
||||
...array_merge( $managed_keys, array( $last_id, $options['batch_size'] ) )
|
||||
);
|
||||
|
||||
$entity_ids = $wpdb->get_col( $query );
|
||||
|
||||
if ( empty( $entity_ids ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ( $entity_ids as $entity_id ) {
|
||||
$entity_id = (int) $entity_id;
|
||||
|
||||
try {
|
||||
$migrated = self::migrate_single_entity(
|
||||
$entity_type,
|
||||
$group_name,
|
||||
$entity_id,
|
||||
$managed_keys,
|
||||
$field_map,
|
||||
$adapter,
|
||||
$options['dry_run']
|
||||
);
|
||||
|
||||
if ( $migrated ) {
|
||||
++$stats['migrated'];
|
||||
} else {
|
||||
++$stats['skipped'];
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
++$stats['errors'];
|
||||
error_log(
|
||||
sprintf(
|
||||
'[UAE Migration] Error %s/%s ID=%d: %s',
|
||||
$entity_type,
|
||||
$group_name,
|
||||
$entity_id,
|
||||
$e->getMessage()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$last_id = $entity_id;
|
||||
}
|
||||
|
||||
// 更新斷點
|
||||
if ( ! $options['dry_run'] ) {
|
||||
self::update_checkpoint( $entity_type, $group_name, $last_id, $stats['migrated'] );
|
||||
}
|
||||
|
||||
// 釋放記憶體
|
||||
$wpdb->flush();
|
||||
|
||||
// 延遲(降低 DB 負載)
|
||||
if ( $options['sleep_ms'] > 0 ) {
|
||||
usleep( $options['sleep_ms'] * 1000 );
|
||||
}
|
||||
} while ( count( $entity_ids ) === $options['batch_size'] );
|
||||
|
||||
// 標記完成
|
||||
if ( ! $options['dry_run'] ) {
|
||||
self::mark_migration_completed( $entity_type, $group_name );
|
||||
}
|
||||
|
||||
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遷移單一 entity 的所有 meta
|
||||
*/
|
||||
private static function migrate_single_entity(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
int $entity_id,
|
||||
array $managed_keys,
|
||||
array $field_map,
|
||||
TMDO_Entity_Adapter_Interface $adapter,
|
||||
bool $dry_run
|
||||
): bool {
|
||||
global $wpdb;
|
||||
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
// 取出此 entity 所有相關的 meta
|
||||
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
|
||||
$metas = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
|
||||
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
|
||||
...array_merge( array( $entity_id ), $managed_keys )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $metas ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 組裝 row 資料
|
||||
$data = array( $id_col => $entity_id );
|
||||
$formats = array( '%d' );
|
||||
|
||||
foreach ( $metas as $meta ) {
|
||||
$key = $meta['meta_key'];
|
||||
$value = $meta['meta_value'];
|
||||
|
||||
if ( ! isset( $field_map[ $key ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field_def = $field_map[ $key ];
|
||||
|
||||
// 安全反序列化 — wp_usermeta 是使用者可寫表,攻擊者可植入序列化物件
|
||||
// 觸發 __wakeup/__destruct gadget chain。allowed_classes=false 阻擋。
|
||||
$value = self::safe_unserialize( $value );
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
|
||||
$data[ $col ] = TMDO_Type_Caster::to_db( $value, $field_def );
|
||||
$formats[] = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||
}
|
||||
|
||||
if ( count( $data ) <= 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
|
||||
// REPLACE 作 Upsert
|
||||
return $wpdb->replace( $target_table, $data, $formats ) !== false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 單批次非同步遷移(WP Cron 用)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 執行一個批次遷移並回傳進度(由 WP Cron 驅動,自動重排直到完成)。
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param string $group_name
|
||||
* @param int $batch_size
|
||||
* @return array{done:bool,migrated:int,last_id:int,total:int,status:string,error?:string}
|
||||
*/
|
||||
public static function migrate_group_batch(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
int $batch_size = self::DEFAULT_BATCH_SIZE
|
||||
): array {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return array(
|
||||
'done' => true,
|
||||
'migrated' => 0,
|
||||
'last_id' => 0,
|
||||
'total' => 0,
|
||||
'status' => 'error',
|
||||
'error' => "Adapter not found: {$entity_type}",
|
||||
);
|
||||
}
|
||||
|
||||
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
if ( empty( $group_fields ) ) {
|
||||
return array(
|
||||
'done' => true,
|
||||
'migrated' => 0,
|
||||
'last_id' => 0,
|
||||
'total' => 0,
|
||||
'status' => 'error',
|
||||
'error' => "Group not registered: {$entity_type}/{$group_name}",
|
||||
);
|
||||
}
|
||||
|
||||
$managed_keys = array_column( $group_fields, 'key' );
|
||||
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) ) {
|
||||
return array(
|
||||
'done' => true,
|
||||
'migrated' => 0,
|
||||
'last_id' => 0,
|
||||
'total' => 0,
|
||||
'status' => 'error',
|
||||
'error' => "Target table not found: {$target_table}",
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$status_table = self::get_status_table();
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT last_id, total_migrated FROM `{$status_table}` WHERE entity_type = %s AND group_name = %s",
|
||||
$entity_type,
|
||||
$group_name
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$last_id = (int) ( $row['last_id'] ?? 0 );
|
||||
$prev_total = (int) ( $row['total_migrated'] ?? 0 );
|
||||
|
||||
self::mark_migration_started( $entity_type, $group_name );
|
||||
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$field_map = array_column( $group_fields, null, 'key' );
|
||||
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
|
||||
$entity_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
|
||||
WHERE `meta_key` IN ({$keys_placeholders})
|
||||
AND `{$id_col}` > %d
|
||||
ORDER BY `{$id_col}` ASC
|
||||
LIMIT %d",
|
||||
...array_merge( $managed_keys, array( $last_id, $batch_size ) )
|
||||
)
|
||||
);
|
||||
|
||||
$batch_migrated = 0;
|
||||
foreach ( $entity_ids as $entity_id ) {
|
||||
$entity_id = (int) $entity_id;
|
||||
try {
|
||||
if ( self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, false ) ) {
|
||||
++$batch_migrated;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
|
||||
}
|
||||
$last_id = $entity_id;
|
||||
}
|
||||
|
||||
$total = $prev_total + $batch_migrated;
|
||||
$done = count( $entity_ids ) < $batch_size;
|
||||
|
||||
if ( $done ) {
|
||||
self::mark_migration_completed( $entity_type, $group_name );
|
||||
} else {
|
||||
self::update_checkpoint( $entity_type, $group_name, $last_id, $total );
|
||||
}
|
||||
|
||||
$wpdb->flush();
|
||||
|
||||
return array(
|
||||
'done' => $done,
|
||||
'migrated' => $batch_migrated,
|
||||
'last_id' => $last_id,
|
||||
'total' => $total,
|
||||
'status' => $done ? 'completed' : 'running',
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 斷點管理
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static function get_status_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
|
||||
}
|
||||
|
||||
public static function get_checkpoint( string $entity_type, string $group_name ): int {
|
||||
global $wpdb;
|
||||
$table = self::get_status_table();
|
||||
|
||||
$last_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT last_id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
|
||||
$entity_type,
|
||||
$group_name
|
||||
)
|
||||
);
|
||||
|
||||
return (int) ( $last_id ?? 0 );
|
||||
}
|
||||
|
||||
private static function update_checkpoint( string $entity_type, string $group_name, int $last_id, int $total ): void {
|
||||
global $wpdb;
|
||||
$table = self::get_status_table();
|
||||
|
||||
$wpdb->replace(
|
||||
$table,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
'last_id' => $last_id,
|
||||
'total_migrated' => $total,
|
||||
'status' => 'running',
|
||||
),
|
||||
array( '%s', '%s', '%d', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
private static function mark_migration_started( string $entity_type, string $group_name ): void {
|
||||
global $wpdb;
|
||||
$table = self::get_status_table();
|
||||
|
||||
$existing = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
|
||||
$entity_type,
|
||||
$group_name
|
||||
)
|
||||
);
|
||||
|
||||
if ( $existing ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'started_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'id' => $existing ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
} else {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
'status' => 'running',
|
||||
'started_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function mark_migration_completed( string $entity_type, string $group_name ): void {
|
||||
global $wpdb;
|
||||
$table = self::get_status_table();
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'status' => 'completed',
|
||||
'completed_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function reset_checkpoint( string $entity_type, string $group_name ): void {
|
||||
global $wpdb;
|
||||
$wpdb->delete(
|
||||
self::get_status_table(),
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
),
|
||||
array( '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 驗證
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 驗證遷移資料完整性(抽樣比對)
|
||||
*/
|
||||
public static function verify(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
int $sample_size = 100
|
||||
): array {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return array( 'error' => 'Adapter not found' );
|
||||
}
|
||||
|
||||
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
|
||||
return array( 'error' => "UAE table not found: {$wpdo_table}" );
|
||||
}
|
||||
|
||||
// 隨機抽樣 UAE 表中的 IDs
|
||||
$sample_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT `{$id_col}` FROM `{$wpdo_table}` ORDER BY RAND() LIMIT %d",
|
||||
$sample_size
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $sample_ids ) ) {
|
||||
return array(
|
||||
'sampled' => 0,
|
||||
'match' => 0,
|
||||
'mismatch' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
$field_map = array_column( $group_fields, null, 'key' );
|
||||
$managed_keys = array_column( $group_fields, 'key' );
|
||||
|
||||
$match = 0;
|
||||
$mismatch = 0;
|
||||
$details = array();
|
||||
|
||||
foreach ( $sample_ids as $entity_id ) {
|
||||
$entity_id = (int) $entity_id;
|
||||
|
||||
// 取 UAE 資料
|
||||
$wpdo_row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` = %d",
|
||||
$entity_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// 取原始 meta
|
||||
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
$raw_metas = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
|
||||
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
|
||||
...array_merge( array( $entity_id ), $managed_keys )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$is_match = true;
|
||||
|
||||
foreach ( $raw_metas as $raw ) {
|
||||
$key = $raw['meta_key'];
|
||||
if ( ! isset( $field_map[ $key ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw_value = self::safe_unserialize( $raw['meta_value'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
$field_def = $field_map[ $key ];
|
||||
$wpdo_value = TMDO_Type_Caster::from_db( $wpdo_row[ $col ] ?? null, $field_def );
|
||||
|
||||
// v2.1.6: type-aware comparison. The previous `(string)` cast falsely
|
||||
// flagged numeric-precision differences (`"5678.90" !== "5678.9"`)
|
||||
// even though both are numerically equal. Aligned with
|
||||
// TMDO_Shadow_Diff_Logger::values_equal() semantics.
|
||||
if ( ! self::loose_equal( $raw_value, $wpdo_value, $field_def['type'] ?? 'text' ) ) {
|
||||
$is_match = false;
|
||||
$details[] = array(
|
||||
'entity_id' => $entity_id,
|
||||
'key' => $key,
|
||||
'raw' => $raw_value,
|
||||
'uae' => $wpdo_value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$is_match ? $match++ : $mismatch++;
|
||||
}
|
||||
|
||||
return array(
|
||||
'sampled' => count( $sample_ids ),
|
||||
'match' => $match,
|
||||
'mismatch' => $mismatch,
|
||||
'match_rate_pct' => count( $sample_ids ) > 0
|
||||
? round( $match / count( $sample_ids ) * 100, 2 )
|
||||
: 0,
|
||||
'mismatch_details' => array_slice( $details, 0, 10 ),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 回溯(UAE → wp_*meta 反向遷移)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 反向遷移:將 UAE 表資料寫回 wp_*meta
|
||||
* 用於解除安裝前的資料保留
|
||||
*/
|
||||
public static function rollback_group(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
array $options = array()
|
||||
): array {
|
||||
$defaults = array(
|
||||
'batch_size' => 500,
|
||||
'sleep_ms' => 50,
|
||||
);
|
||||
$options = wp_parse_args( $options, $defaults );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return self::error_result( "Adapter not found: {$entity_type}" );
|
||||
}
|
||||
|
||||
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
$field_map = array_column( $group_fields, null, 'key' );
|
||||
|
||||
$stats = array(
|
||||
'written' => 0,
|
||||
'errors' => 0,
|
||||
);
|
||||
$last_id = 0;
|
||||
|
||||
do {
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d",
|
||||
$last_id,
|
||||
$options['batch_size']
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$entity_id = (int) $row[ $id_col ];
|
||||
|
||||
foreach ( $field_map as $key => $field_def ) {
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
$value = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
|
||||
|
||||
if ( $value === null ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 直接寫 wp_*meta,繞過 UAE 攔截(因為我們正在反向遷移)
|
||||
$wpdb->replace(
|
||||
$meta_table,
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
'meta_key' => $key,
|
||||
'meta_value' => maybe_serialize( $value ),
|
||||
),
|
||||
array( '%d', '%s', '%s' )
|
||||
);
|
||||
++$stats['written'];
|
||||
}
|
||||
|
||||
$last_id = $entity_id;
|
||||
}
|
||||
|
||||
$wpdb->flush();
|
||||
usleep( $options['sleep_ms'] * 1000 );
|
||||
|
||||
} while ( count( $rows ) === $options['batch_size'] );
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private static function error_result( string $message ): array {
|
||||
return array(
|
||||
'error' => $message,
|
||||
'migrated' => 0,
|
||||
'errors' => 1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Object-safe replacement for `maybe_unserialize()`.
|
||||
*
|
||||
* `wp_usermeta` / `wp_postmeta` rows can contain attacker-planted serialized
|
||||
* objects. `maybe_unserialize()` calls `unserialize()` with default options,
|
||||
* which materializes objects and triggers `__wakeup`/`__destruct` gadgets.
|
||||
* During backfill the migration engine runs in admin context, so any gadget
|
||||
* chain in vendor/ becomes RCE.
|
||||
*
|
||||
* This wrapper passes `allowed_classes => false` so PHP returns
|
||||
* `__PHP_Incomplete_Class` instances without ever invoking magic methods on
|
||||
* the original class. We then convert those to null so they cannot leak
|
||||
* into a flat-table column.
|
||||
*
|
||||
* @param mixed $value Raw meta_value from native EAV table.
|
||||
* @return mixed Unserialized array/scalar, or original string if not serialized.
|
||||
*/
|
||||
private static function safe_unserialize( $value ) {
|
||||
if ( ! is_string( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$trimmed = trim( $value );
|
||||
|
||||
// Cheap inline detector — does not rely on WP's is_serialized() so this
|
||||
// function works in CLI / standalone migration contexts. Mirrors the
|
||||
// shape checks WP does: type-tag at offset 0, ':' at offset 1, plausible
|
||||
// terminator. PHP serialize tokens are: a (array), O (object),
|
||||
// s (string), i (int), d (float), b (bool), N; (null).
|
||||
if ( 'N;' !== $trimmed ) {
|
||||
if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) {
|
||||
return $value;
|
||||
}
|
||||
if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b' ), true ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- explicit allowed_classes=false hardens against object injection.
|
||||
$result = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
|
||||
|
||||
if ( false === $result && 'b:0;' !== $trimmed ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces
|
||||
// any object marker with this stub). They must never reach a flat-table
|
||||
// JSON column or a downstream consumer.
|
||||
if ( is_object( $result ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( is_array( $result ) ) {
|
||||
array_walk_recursive(
|
||||
$result,
|
||||
static function ( &$v ) {
|
||||
if ( is_object( $v ) ) {
|
||||
$v = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-aware loose equality check for verify() sample comparison.
|
||||
*
|
||||
* Mirrors TMDO_Shadow_Diff_Logger::values_equal() so the two divergence
|
||||
* detection paths (cron verify + live shadow_compare) agree on what
|
||||
* constitutes a real mismatch vs a representation difference.
|
||||
*
|
||||
* @param mixed $eav Native wp_*meta value (after maybe_unserialize).
|
||||
* @param mixed $flat Value from the WPDO flat table (after type cast).
|
||||
* @param string $type Field type from registry (text/integer/decimal/...).
|
||||
* @return bool
|
||||
*/
|
||||
private static function loose_equal( $eav, $flat, string $type ): bool {
|
||||
if ( $eav === $flat ) {
|
||||
return true;
|
||||
}
|
||||
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
|
||||
return is_numeric( $eav ) && is_numeric( $flat ) && (float) $eav === (float) $flat;
|
||||
}
|
||||
if ( 'boolean' === $type ) {
|
||||
return (bool) $eav === (bool) $flat;
|
||||
}
|
||||
if ( 'json' === $type || is_array( $eav ) || is_array( $flat ) ) {
|
||||
return wp_json_encode( $eav ) === wp_json_encode( $flat );
|
||||
}
|
||||
return (string) $eav === (string) $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有遷移狀態
|
||||
*/
|
||||
public static function get_all_statuses(): array {
|
||||
global $wpdb;
|
||||
$table = self::get_status_table();
|
||||
|
||||
return $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY entity_type, group_name", ARRAY_A ) ?: array();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Entity_Registry - 全域欄位與適配器登錄中心
|
||||
*
|
||||
* 提供:
|
||||
* - 適配器註冊(post/user/term/comment)
|
||||
* - 欄位群組註冊(Schema 定義)
|
||||
* - 欄位查詢 API(meta_key → field_def)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Entity_Registry {
|
||||
|
||||
/** @var array<string, TMDO_Entity_Adapter_Interface> 實體類型 → 適配器實例 */
|
||||
private static array $adapters = array();
|
||||
|
||||
/** @var array<string, array<string, array>> 實體類型 → 群組名 → 欄位定義陣列 */
|
||||
private static array $groups = array();
|
||||
|
||||
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
|
||||
private static array $field_index = array();
|
||||
|
||||
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
||||
private static array $pending_schemas = array();
|
||||
|
||||
/** 合法的欄位型別 */
|
||||
public const VALID_TYPES = array(
|
||||
'text',
|
||||
'textarea',
|
||||
'integer',
|
||||
'decimal',
|
||||
'boolean',
|
||||
'date',
|
||||
'datetime',
|
||||
'timestamp',
|
||||
'json',
|
||||
'enum',
|
||||
'binary',
|
||||
);
|
||||
|
||||
/** 合法的實體類型 */
|
||||
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
|
||||
|
||||
public static function init(): void {
|
||||
self::$adapters = array();
|
||||
self::$groups = array();
|
||||
self::$field_index = array();
|
||||
self::$pending_schemas = array();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 適配器管理
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function register_adapter( string $entity_type, TMDO_Entity_Adapter_Interface $adapter ): void {
|
||||
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
||||
return;
|
||||
}
|
||||
self::$adapters[ $entity_type ] = $adapter;
|
||||
}
|
||||
|
||||
public static function get_adapter( string $entity_type ): ?TMDO_Entity_Adapter_Interface {
|
||||
return self::$adapters[ $entity_type ] ?? null;
|
||||
}
|
||||
|
||||
public static function get_all_adapters(): array {
|
||||
return self::$adapters;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 欄位群組登錄
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 登錄一組 UAE 管理的欄位
|
||||
*
|
||||
* @param string $entity_type post|user|term|comment
|
||||
* @param string $group_name 群組名稱(會成為表名的一部分)
|
||||
* @param array $fields 欄位定義陣列,每項需有 key、type,選填:
|
||||
* - required (bool)
|
||||
* - default (mixed)
|
||||
* - searchable (bool) 加索引
|
||||
* - fulltext (bool) 全文索引(僅 text/textarea)
|
||||
* - unique (bool) 唯一索引
|
||||
* - options (array) enum 選項
|
||||
* - label (string) 顯示名稱
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
|
||||
|
||||
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! isset( self::$adapters[ $entity_type ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防重複登錄
|
||||
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 驗證並正規化欄位定義
|
||||
$normalized = array();
|
||||
|
||||
foreach ( $fields as $field ) {
|
||||
if ( empty( $field['key'] ) || empty( $field['type'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! in_array( $field['type'], self::VALID_TYPES, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized_field = wp_parse_args(
|
||||
$field,
|
||||
array(
|
||||
'key' => '',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'searchable' => false,
|
||||
'fulltext' => false,
|
||||
'unique' => false,
|
||||
'options' => array(),
|
||||
'label' => '',
|
||||
)
|
||||
);
|
||||
|
||||
$normalized_field['group'] = $group_name;
|
||||
$normalized_field['entity_type'] = $entity_type;
|
||||
|
||||
$normalized[] = $normalized_field;
|
||||
|
||||
// 建立索引以供快速查詢
|
||||
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
|
||||
}
|
||||
|
||||
if ( empty( $normalized ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
||||
|
||||
// 加入待建表佇列
|
||||
self::$pending_schemas[] = array(
|
||||
'type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'fields' => $normalized,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 查詢 API
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 依 meta_key 查詢欄位定義
|
||||
*
|
||||
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
|
||||
*/
|
||||
public static function get_field( string $entity_type, string $meta_key ): ?array {
|
||||
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得實體類型下所有群組名稱
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function get_groups_for_type( string $entity_type ): array {
|
||||
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得特定群組的完整欄位定義
|
||||
*/
|
||||
public static function get_group_fields( string $entity_type, string $group_name ): array {
|
||||
return self::$groups[ $entity_type ][ $group_name ] ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得一個群組中所有 meta_key
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function get_group_keys( string $entity_type, string $group_name ): array {
|
||||
$fields = self::get_group_fields( $entity_type, $group_name );
|
||||
return array_column( $fields, 'key' );
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有已登錄的欄位(跨實體、跨群組)
|
||||
*/
|
||||
public static function get_all_fields(): array {
|
||||
return self::$field_index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得待建表清單(供 Schema_Manager 處理)
|
||||
*/
|
||||
public static function get_pending_schemas(): array {
|
||||
return self::$pending_schemas;
|
||||
}
|
||||
|
||||
public static function clear_pending_schemas(): void {
|
||||
self::$pending_schemas = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷某個 meta_key 是否由 UAE 管理
|
||||
*/
|
||||
public static function is_managed( string $entity_type, string $meta_key ): bool {
|
||||
return isset( self::$field_index[ $entity_type ][ $meta_key ] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
|
||||
*
|
||||
* 所有 WordPress 的 meta 操作皆通過此匯流排:
|
||||
* - {type}_metadata 系列 filter(add/get/update/delete)
|
||||
* - 實體刪除 action
|
||||
* - 原生查詢擴充 hook
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Hook_Bus {
|
||||
|
||||
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
|
||||
private static array $internal_ops = array();
|
||||
|
||||
public static function init(): void {
|
||||
|
||||
// 取得所有已註冊的適配器
|
||||
$adapters = TMDO_Entity_Registry::get_all_adapters();
|
||||
|
||||
foreach ( $adapters as $type => $adapter ) {
|
||||
self::register_hooks_for_type( $type, $adapter );
|
||||
}
|
||||
}
|
||||
|
||||
private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void {
|
||||
|
||||
// ── 寫入攔截 ──────────────────────────────────────────
|
||||
add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 );
|
||||
add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 );
|
||||
|
||||
// ── 讀取攔截 ──────────────────────────────────────────
|
||||
add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 );
|
||||
|
||||
// ── 刪除攔截 ──────────────────────────────────────────
|
||||
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
||||
|
||||
// ── 實體刪除時自動清理 ───────────────────────────────
|
||||
add_action(
|
||||
$adapter->get_delete_hook(),
|
||||
function ( $entity_id ) use ( $type, $adapter ) {
|
||||
self::cleanup_entity( $type, (int) $entity_id );
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 寫入:update_{type}_metadata filter
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 短路 WordPress 原生 update_metadata() 流程
|
||||
*
|
||||
* @param null|bool $check 若回傳 null 則 WP 繼續原生流程
|
||||
* @param int $object_id
|
||||
* @param string $meta_key
|
||||
* @param mixed $meta_value
|
||||
* @param mixed $prev_value
|
||||
*/
|
||||
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check; // 非管理欄位,放行
|
||||
}
|
||||
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||
return $check; // disabled
|
||||
}
|
||||
|
||||
// Route decision (v1.2.0):讓 UAEPG 等外掛正式訂閱 routing,不必搶 priority 5。
|
||||
$route = self::decide_route( 'update', $type, (int) $object_id, $meta_key, $meta_value );
|
||||
if ( $route === 'pg' ) {
|
||||
// 讓其他 listener(例如 UAEPG)接手;原生 EAV 也放行。
|
||||
return null;
|
||||
}
|
||||
if ( $route === 'skip' ) {
|
||||
// 不寫 flat、不寫 EAV,但告訴 WP 已處理。
|
||||
return true;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
||||
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
||||
|
||||
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
||||
|
||||
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null; // dual_write / shadow_read
|
||||
}
|
||||
|
||||
return $flat_result; // aeav_only
|
||||
}
|
||||
|
||||
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$route = self::decide_route( 'add', $type, (int) $object_id, $meta_key, $meta_value );
|
||||
if ( $route === 'pg' ) {
|
||||
return null;
|
||||
}
|
||||
if ( $route === 'skip' ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
||||
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
||||
|
||||
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
||||
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $flat_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如 filter `wpdo_capture_before_value` 回 true 才讀 flat table 的 before value,
|
||||
* 否則直接回 null — 讓沒在用 audit / 其他 listener 的站台省一次 DB read。
|
||||
*
|
||||
* filter 參數:(bool $default_true, string $type, string $meta_key, string $op)
|
||||
* $op ∈ { 'add', 'update', 'delete' }
|
||||
*
|
||||
* 使用範例(關閉 audit 的站台):
|
||||
* add_filter( 'wpdo_capture_before_value', '__return_false' );
|
||||
*
|
||||
* @since 1.3.2
|
||||
*/
|
||||
private static function maybe_read_before_value(
|
||||
string $type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
array $field_def,
|
||||
string $op
|
||||
) {
|
||||
$capture = apply_filters(
|
||||
'wpdo_capture_before_value',
|
||||
true,
|
||||
$type,
|
||||
$meta_key,
|
||||
$op
|
||||
);
|
||||
|
||||
if ( ! $capture ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::read_flat_value( $type, $entity_id, $meta_key, $field_def );
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取 flat table 中當前值(before value,供 audit / after_write listener 使用)。
|
||||
*
|
||||
* 此方法**不經 cache 加熱**,直接查 DB,以避免快取污染與遞迴。表不存在回 null。
|
||||
*
|
||||
* @since 1.3.1
|
||||
*/
|
||||
private static function read_flat_value(
|
||||
string $type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
array $field_def
|
||||
) {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$group = $field_def['group'] ?? '';
|
||||
if ( $group === '' ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
$raw = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT `{$col}` FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
|
||||
$entity_id
|
||||
)
|
||||
);
|
||||
|
||||
return $raw === null ? null : TMDO_Type_Caster::from_db( $raw, $field_def );
|
||||
}
|
||||
|
||||
/**
|
||||
* 讓外部 listener(例如 UAEPG)透過 `wpdo_route_decision` filter 指定路由。
|
||||
*
|
||||
* 回傳值:
|
||||
* 'flat' (預設) — UAE 寫入 MySQL flat table
|
||||
* 'pg' — 放行,由其他 listener 接手;UAE 不寫 flat,原生 EAV 依 mode 決定
|
||||
* 'skip' — 都不寫(用於軟刪除之類特殊情境),但告訴 WP 已處理
|
||||
*
|
||||
* 其他非預期值會被 fallback 到 'flat' 以維持安全預設。
|
||||
*
|
||||
* @since 1.2.0
|
||||
*/
|
||||
private static function decide_route(
|
||||
string $op,
|
||||
string $type,
|
||||
int $object_id,
|
||||
string $meta_key,
|
||||
$meta_value
|
||||
): string {
|
||||
$route = apply_filters(
|
||||
'wpdo_route_decision',
|
||||
'flat',
|
||||
$type,
|
||||
$object_id,
|
||||
$meta_key,
|
||||
$meta_value,
|
||||
$op
|
||||
);
|
||||
|
||||
if ( in_array( $route, array( 'flat', 'pg', 'skip' ), true ) ) {
|
||||
return $route;
|
||||
}
|
||||
|
||||
TMDO_Logger::warning(
|
||||
'wpdo_route_decision_invalid_return',
|
||||
array(
|
||||
'returned' => is_scalar( $route ) ? (string) $route : gettype( $route ),
|
||||
'op' => $op,
|
||||
'type' => $type,
|
||||
'key' => $meta_key,
|
||||
)
|
||||
);
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 Upsert 操作
|
||||
*/
|
||||
private static function perform_upsert(
|
||||
string $type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
$meta_value,
|
||||
array $field_def
|
||||
) {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$group = $field_def['group'];
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
|
||||
// 表不存在則讓 WP 走原生流程(降級處理)
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 型別轉換
|
||||
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
||||
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||
|
||||
// 鎖防遞迴
|
||||
$lock_key = $entity_id . ':' . $meta_key;
|
||||
self::$internal_ops[ $lock_key ] = true;
|
||||
|
||||
try {
|
||||
// 檢查列是否存在
|
||||
$exists = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM `{$table}` WHERE `{$id_col}` = %d",
|
||||
$entity_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( $exists ) {
|
||||
// UPDATE
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
array( $col => $db_value ),
|
||||
array( $id_col => $entity_id ),
|
||||
array( $format ),
|
||||
array( '%d' )
|
||||
);
|
||||
} else {
|
||||
// INSERT
|
||||
$result = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
$col => $db_value,
|
||||
),
|
||||
array( '%d', $format )
|
||||
);
|
||||
}
|
||||
|
||||
// 清除快取
|
||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
||||
|
||||
// 短路回傳 true(WP 認為寫入成功)
|
||||
return $result !== false;
|
||||
} finally {
|
||||
unset( self::$internal_ops[ $lock_key ] );
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 讀取:get_{type}_metadata filter
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 攔截 get_metadata() 呼叫
|
||||
*
|
||||
* @param null|mixed $check 若回傳 null 則 WP 繼續原生流程
|
||||
* @param int $object_id
|
||||
* @param string $meta_key 空字串表示取所有 meta
|
||||
* @param bool $single
|
||||
* @param string $meta_type 5.5+ 額外參數
|
||||
*/
|
||||
public static function intercept_get( $check, $object_id, $meta_key, $single, $meta_type = '' ) {
|
||||
|
||||
$type = $meta_type ?: self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
// 空 key:WP 要求所有 meta,UAE 不攔截(維持相容性)
|
||||
if ( $meta_key === '' ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
||||
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
||||
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
|
||||
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
||||
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
||||
return $check; // disabled / dual_write
|
||||
}
|
||||
|
||||
$group = $field_def['group'];
|
||||
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
||||
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||
|
||||
// Shadow-read:與 EAV 比對,記錄差異
|
||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||
try {
|
||||
TMDO_Shadow_Diff_Logger::compare_and_log(
|
||||
$type,
|
||||
(int) $object_id,
|
||||
$meta_key,
|
||||
$value,
|
||||
$field_def
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
// 比對失敗不該影響讀取
|
||||
TMDO_Logger::error(
|
||||
'shadow_compare_exception',
|
||||
array(
|
||||
'error' => $e->getMessage(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 值為空 → 回 WP 原生慣例
|
||||
if ( empty( $row ) || $value === null || $value === '' ) {
|
||||
return $single ? '' : array();
|
||||
}
|
||||
|
||||
// WP 的慣例:get_metadata() 即使 $single=true 也回傳陣列包裝
|
||||
return $single ? array( $value ) : array( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得(或載入)完整列資料,並快取
|
||||
*/
|
||||
private static function get_or_load_row( string $type, int $entity_id, string $group ): array {
|
||||
|
||||
// L1 快取
|
||||
$cached = TMDO_Cache_Orchestrator::get_row( $type, $entity_id, $group );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
|
||||
$entity_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$row = $row ?: array();
|
||||
|
||||
TMDO_Cache_Orchestrator::set_row( $type, $entity_id, $group, $row );
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 刪除:delete_{type}_metadata filter
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function intercept_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
// Mode-aware:disabled 完全不攔截
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$route = self::decide_route( 'delete', $type, (int) $object_id, $meta_key, $meta_value );
|
||||
if ( $route === 'pg' ) {
|
||||
return null;
|
||||
}
|
||||
if ( $route === 'skip' ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1)
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
||||
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
$group = $field_def['group'];
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
// UAE 的邏輯:刪除 meta = 設該欄位為 NULL
|
||||
// 因為一個 entity 只對應一列,完整刪除列會丟失其他欄位
|
||||
$default = $field_def['default'] ?? null;
|
||||
|
||||
if ( $delete_all ) {
|
||||
// Safety cap: refuse mass-null if affected row count exceeds threshold.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
|
||||
if ( $row_count > 500 ) {
|
||||
TMDO_Logger::warning(
|
||||
'intercept_delete_mass_blocked',
|
||||
array(
|
||||
'table' => $table,
|
||||
'col' => $col,
|
||||
'rows' => $row_count,
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
TMDO_Logger::info(
|
||||
'intercept_delete_all',
|
||||
array(
|
||||
'table' => $table,
|
||||
'col' => $col,
|
||||
'rows' => $row_count,
|
||||
)
|
||||
);
|
||||
|
||||
// 刪除所有 entity 的該欄位
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE `{$table}` SET `{$col}` = %s",
|
||||
$default
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$result = $wpdb->update(
|
||||
$table,
|
||||
array( $col => $default ),
|
||||
array( $id_col => $object_id ),
|
||||
array( TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ) ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
||||
}
|
||||
|
||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||
|
||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 實體刪除清理
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function cleanup_entity( string $type, int $entity_id ): void {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
|
||||
|
||||
foreach ( $groups as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
|
||||
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
$wpdb->delete( $table, array( $id_col => $entity_id ), array( '%d' ) );
|
||||
}
|
||||
}
|
||||
|
||||
TMDO_Cache_Orchestrator::flush_entity( $type, $entity_id );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 工具方法
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 從當前 filter 名稱推斷實體類型
|
||||
* 例:update_user_metadata → user
|
||||
*/
|
||||
private static function resolve_type_from_current_filter(): ?string {
|
||||
$current = current_filter();
|
||||
|
||||
if ( preg_match( '/^(?:add|get|update|delete)_(post|user|term|comment)_metadata$/', $current, $matches ) ) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接讀取(繞過 WP filter 系統)
|
||||
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
||||
*
|
||||
* @param string $type
|
||||
* @param int $entity_id
|
||||
* @param string $key 若為空字串則回傳整列
|
||||
* @return mixed
|
||||
*/
|
||||
public static function direct_read( string $type, int $entity_id, string $key = '' ) {
|
||||
|
||||
if ( $key === '' ) {
|
||||
// 回傳所有群組的所有欄位
|
||||
$result = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
||||
$row = self::get_or_load_row( $type, $entity_id, $group );
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $key );
|
||||
if ( ! $field_def ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
return TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
|
||||
*
|
||||
* 4 種運作模式(每個 entity type 可獨立設定):
|
||||
*
|
||||
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
|
||||
* Kill-switch,緊急關閉用。
|
||||
*
|
||||
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
|
||||
* 讀取:走 wp_*meta(原生)
|
||||
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
|
||||
*
|
||||
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
|
||||
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
|
||||
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
|
||||
*
|
||||
* aeav_only — 寫入:只寫 UAE flat table
|
||||
* 讀取:只讀 UAE flat table
|
||||
* 用途:完成遷移後的最終模式,效能最佳
|
||||
*
|
||||
* 合法轉換路徑(安全性):
|
||||
*
|
||||
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
|
||||
* ↑ ↑
|
||||
* └──── aeav_only 可以直接降級到任一前向狀態
|
||||
*
|
||||
* 禁止:disabled → shadow_read 或 disabled → aeav_only
|
||||
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Mode_Manager {
|
||||
|
||||
public const MODE_DISABLED = 'disabled';
|
||||
public const MODE_DUAL_WRITE = 'dual_write';
|
||||
public const MODE_SHADOW_READ = 'shadow_read';
|
||||
public const MODE_AEAV_ONLY = 'aeav_only';
|
||||
|
||||
public const ALL_MODES = array(
|
||||
self::MODE_DISABLED,
|
||||
self::MODE_DUAL_WRITE,
|
||||
self::MODE_SHADOW_READ,
|
||||
self::MODE_AEAV_ONLY,
|
||||
);
|
||||
|
||||
/** wp_options key holding array<entity_type, mode> */
|
||||
private const OPT_KEY = 'wpdo_bridge_modes';
|
||||
|
||||
/**
|
||||
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
|
||||
* Shape: array<string entity_type, int timestamp>
|
||||
*/
|
||||
public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at';
|
||||
|
||||
/** Per-request memoization(避免每次 filter 都查 DB option) */
|
||||
private static ?array $cache = null;
|
||||
|
||||
/**
|
||||
* 預設模式(v2.5.4 起):
|
||||
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
|
||||
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV)
|
||||
*/
|
||||
private static function defaults(): array {
|
||||
return array(
|
||||
'post' => self::MODE_DISABLED,
|
||||
'user' => self::MODE_DUAL_WRITE,
|
||||
'term' => self::MODE_DUAL_WRITE,
|
||||
'comment' => self::MODE_DUAL_WRITE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有 entity 的 mode
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function all(): array {
|
||||
if ( self::$cache !== null ) {
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
$stored = get_option( self::OPT_KEY, array() );
|
||||
$modes = self::defaults();
|
||||
|
||||
if ( is_array( $stored ) ) {
|
||||
foreach ( $stored as $type => $mode ) {
|
||||
if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||
$modes[ $type ] = $mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$cache = $modes;
|
||||
return $modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得單一 entity type 的 mode
|
||||
*/
|
||||
public static function get( string $entity_type ): string {
|
||||
$all = self::all();
|
||||
return $all[ $entity_type ] ?? self::MODE_DISABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定單一 entity type 的 mode(包含轉換安全檢查)
|
||||
*
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function set( string $entity_type, string $new_mode ) {
|
||||
if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||
return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" );
|
||||
}
|
||||
if ( ! self::is_valid_mode( $new_mode ) ) {
|
||||
return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" );
|
||||
}
|
||||
|
||||
$current = self::get( $entity_type );
|
||||
if ( $current === $new_mode ) {
|
||||
return true; // no-op
|
||||
}
|
||||
|
||||
// 驗證轉換安全性
|
||||
if ( ! self::is_safe_transition( $current, $new_mode ) ) {
|
||||
return new \WP_Error(
|
||||
'unsafe_transition',
|
||||
sprintf(
|
||||
__( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', 'uae' ),
|
||||
$current,
|
||||
$new_mode
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$all = self::all();
|
||||
$all[ $entity_type ] = $new_mode;
|
||||
|
||||
update_option( self::OPT_KEY, $all, false );
|
||||
self::$cache = $all;
|
||||
|
||||
// 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。
|
||||
$entered = get_option( self::OPT_ENTERED_AT, array() );
|
||||
if ( ! is_array( $entered ) ) {
|
||||
$entered = array();
|
||||
}
|
||||
$entered[ $entity_type ] = time();
|
||||
update_option( self::OPT_ENTERED_AT, $entered, false );
|
||||
|
||||
TMDO_Logger::info(
|
||||
'bridge_mode_changed',
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'from' => $current,
|
||||
'to' => $new_mode,
|
||||
'user_id' => get_current_user_id(),
|
||||
)
|
||||
);
|
||||
|
||||
// 模式變更時清除所有相關快取
|
||||
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
|
||||
|
||||
do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 緊急 kill-switch:全部 entity 瞬間切到 disabled
|
||||
*
|
||||
* 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled,
|
||||
* 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV),
|
||||
* 只有 aeav_only 切到 disabled 才有資料遺失風險。
|
||||
*
|
||||
* 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的,
|
||||
* 但至少系統不會壞。
|
||||
*
|
||||
* @return int 改變狀態的 entity 數量
|
||||
*/
|
||||
public static function emergency_disable_all(): int {
|
||||
$all = self::all();
|
||||
$changed = 0;
|
||||
|
||||
foreach ( $all as $type => $mode ) {
|
||||
if ( $mode !== self::MODE_DISABLED ) {
|
||||
$all[ $type ] = self::MODE_DISABLED;
|
||||
++$changed;
|
||||
|
||||
TMDO_Logger::warning(
|
||||
'bridge_emergency_disable',
|
||||
array(
|
||||
'entity_type' => $type,
|
||||
'previous_mode' => $mode,
|
||||
'user_id' => get_current_user_id(),
|
||||
'is_aeav_only' => $mode === self::MODE_AEAV_ONLY,
|
||||
'warning' => $mode === self::MODE_AEAV_ONLY
|
||||
? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale'
|
||||
: 'Normal emergency fallback',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( $changed > 0 ) {
|
||||
update_option( self::OPT_KEY, $all, false );
|
||||
self::$cache = $all;
|
||||
TMDO_Cache_Orchestrator::flush_all();
|
||||
do_action( 'wpdo_bridge_emergency_disabled', $changed );
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查)
|
||||
*
|
||||
* @return array<string, true|\WP_Error> 逐 entity 的結果
|
||||
*/
|
||||
public static function set_all( string $mode ): array {
|
||||
$results = array();
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) {
|
||||
$results[ $type ] = self::set( $type, $mode );
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: 目前 mode 是否會寫入 flat table?
|
||||
*/
|
||||
public static function writes_to_flat( string $entity_type ): bool {
|
||||
return in_array(
|
||||
self::get( $entity_type ),
|
||||
array(
|
||||
self::MODE_DUAL_WRITE,
|
||||
self::MODE_SHADOW_READ,
|
||||
self::MODE_AEAV_ONLY,
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入?
|
||||
*/
|
||||
public static function writes_to_eav( string $entity_type ): bool {
|
||||
return in_array(
|
||||
self::get( $entity_type ),
|
||||
array(
|
||||
self::MODE_DISABLED,
|
||||
self::MODE_DUAL_WRITE,
|
||||
self::MODE_SHADOW_READ,
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: 目前 mode 是否從 flat table 讀取?
|
||||
*/
|
||||
public static function reads_from_flat( string $entity_type ): bool {
|
||||
return in_array(
|
||||
self::get( $entity_type ),
|
||||
array(
|
||||
self::MODE_SHADOW_READ,
|
||||
self::MODE_AEAV_ONLY,
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: 是否要做 shadow 比對?
|
||||
*/
|
||||
public static function does_shadow_compare( string $entity_type ): bool {
|
||||
return self::get( $entity_type ) === self::MODE_SHADOW_READ;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 驗證
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function is_valid_mode( $mode ): bool {
|
||||
return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* 轉換是否安全?
|
||||
*
|
||||
* 規則:
|
||||
* - 相鄰的階梯移動:允許(正向或反向)
|
||||
* - 跨階梯降級:允許(後面的 fallback,EAV 還在)
|
||||
* - 跨階梯升級:禁止(下游資料可能還沒跟上)
|
||||
*
|
||||
* 階梯順序(index 越大 = 越「靠 UAE」):
|
||||
* 0: disabled
|
||||
* 1: dual_write
|
||||
* 2: shadow_read
|
||||
* 3: aeav_only
|
||||
*
|
||||
* 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳,
|
||||
* 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。
|
||||
*
|
||||
* 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。
|
||||
*/
|
||||
public static function is_safe_transition( string $from, string $to ): bool {
|
||||
$order = array(
|
||||
self::MODE_DISABLED => 0,
|
||||
self::MODE_DUAL_WRITE => 1,
|
||||
self::MODE_SHADOW_READ => 2,
|
||||
self::MODE_AEAV_ONLY => 3,
|
||||
);
|
||||
|
||||
if ( ! isset( $order[ $from ], $order[ $to ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$diff = $order[ $to ] - $order[ $from ];
|
||||
|
||||
// 升級:只允許 +1
|
||||
if ( $diff > 0 ) {
|
||||
return $diff === 1;
|
||||
}
|
||||
|
||||
// 降級:一律允許(這是 fallback,資料都還在)
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 內部
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 測試用:清除 memoization
|
||||
*/
|
||||
public static function reset_cache(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label
|
||||
*/
|
||||
public static function label( string $mode ): string {
|
||||
switch ( $mode ) {
|
||||
case self::MODE_DISABLED:
|
||||
return __( '停用(原生 EAV)', 'uae' );
|
||||
case self::MODE_DUAL_WRITE:
|
||||
return __( '雙寫', 'uae' );
|
||||
case self::MODE_SHADOW_READ:
|
||||
return __( '影子讀取(驗證中)', 'uae' );
|
||||
case self::MODE_AEAV_ONLY:
|
||||
return __( '僅 UAE(生產模式)', 'uae' );
|
||||
default:
|
||||
return $mode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode 的說明文字(給 admin UI 用)
|
||||
*/
|
||||
public static function description( string $mode ): string {
|
||||
switch ( $mode ) {
|
||||
case self::MODE_DISABLED:
|
||||
return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', 'uae' );
|
||||
case self::MODE_DUAL_WRITE:
|
||||
return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', 'uae' );
|
||||
case self::MODE_SHADOW_READ:
|
||||
return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', 'uae' );
|
||||
case self::MODE_AEAV_ONLY:
|
||||
return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', 'uae' );
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Query_Compiler - 跨實體查詢編譯器
|
||||
*
|
||||
* 將 wpdo_meta_query 編譯為高效 SQL:
|
||||
* - 單次 JOIN UAE 表(而非原生 meta_query 多次 JOIN wp_*meta)
|
||||
* - 型別正確的比較(避免 CAST 開銷)
|
||||
* - 命中索引
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Query_Compiler {
|
||||
|
||||
/** @var array 合法的 SQL 比較運算子 */
|
||||
private const VALID_OPERATORS = array(
|
||||
'=',
|
||||
'!=',
|
||||
'<>',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'LIKE',
|
||||
'NOT LIKE',
|
||||
'IN',
|
||||
'NOT IN',
|
||||
'BETWEEN',
|
||||
'NOT BETWEEN',
|
||||
'EXISTS',
|
||||
'NOT EXISTS',
|
||||
);
|
||||
|
||||
/**
|
||||
* 注入 wpdo_meta_query 至 WP_Query(post 實體)
|
||||
*
|
||||
* @param WP_Query $query
|
||||
* @param array $wpdo_meta_query 結構同 meta_query
|
||||
*/
|
||||
public static function inject_into_wp_query( WP_Query $query, array $wpdo_meta_query ): void {
|
||||
|
||||
$post_type = $query->get( 'post_type' );
|
||||
if ( is_array( $post_type ) ) {
|
||||
$post_type = $post_type[0] ?? 'post';
|
||||
}
|
||||
if ( empty( $post_type ) || $post_type === 'any' ) {
|
||||
$post_type = 'post';
|
||||
}
|
||||
|
||||
// 編譯 JOIN 與 WHERE
|
||||
$compiled = self::compile( 'post', $wpdo_meta_query );
|
||||
|
||||
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 注入 posts_clauses filter
|
||||
add_filter(
|
||||
'posts_clauses',
|
||||
function ( $clauses ) use ( $compiled ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( ! empty( $compiled['joins'] ) ) {
|
||||
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $compiled['where'] ) ) {
|
||||
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
|
||||
}
|
||||
|
||||
return $clauses;
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
|
||||
// wpdo_orderby 支援
|
||||
$wpdo_orderby = $query->get( 'wpdo_orderby' );
|
||||
if ( $wpdo_orderby ) {
|
||||
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
|
||||
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
|
||||
$order_dir = 'DESC';
|
||||
}
|
||||
|
||||
$wpdo_col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
|
||||
|
||||
// 找出該欄位所在的群組
|
||||
$field_def = TMDO_Entity_Registry::get_field( 'post', $wpdo_orderby );
|
||||
if ( $field_def ) {
|
||||
$alias = 'wpdo_' . $field_def['group'];
|
||||
add_filter(
|
||||
'posts_orderby',
|
||||
function ( $orderby ) use ( $alias, $wpdo_col, $order_dir ) {
|
||||
return "`{$alias}`.`{$wpdo_col}` {$order_dir}";
|
||||
},
|
||||
10,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入至 WP_User_Query
|
||||
*/
|
||||
public static function inject_into_user_query( WP_User_Query $query, array $wpdo_meta_query ): void {
|
||||
|
||||
$compiled = self::compile( 'user', $wpdo_meta_query );
|
||||
|
||||
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// 透過 reflection 存取 WP_User_Query 的 query_vars 來注入
|
||||
$query_orderby = &$query->query_orderby;
|
||||
$query_where = &$query->query_where;
|
||||
$query_from = &$query->query_from;
|
||||
|
||||
if ( ! empty( $compiled['joins'] ) ) {
|
||||
$query_from .= ' ' . implode( ' ', $compiled['joins'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $compiled['where'] ) ) {
|
||||
$query_where .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
|
||||
}
|
||||
|
||||
// wpdo_orderby
|
||||
$wpdo_orderby = $query->get( 'wpdo_orderby' );
|
||||
if ( $wpdo_orderby ) {
|
||||
$field_def = TMDO_Entity_Registry::get_field( 'user', $wpdo_orderby );
|
||||
if ( $field_def ) {
|
||||
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
|
||||
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
|
||||
$order_dir = 'DESC';
|
||||
}
|
||||
$alias = 'wpdo_' . $field_def['group'];
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
|
||||
$query_orderby = "ORDER BY `{$alias}`.`{$col}` {$order_dir}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入至 get_terms() 的 clauses
|
||||
*/
|
||||
public static function inject_into_terms_clauses( array $clauses, array $wpdo_meta_query ): array {
|
||||
|
||||
$compiled = self::compile( 'term', $wpdo_meta_query );
|
||||
|
||||
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
|
||||
return $clauses;
|
||||
}
|
||||
|
||||
if ( ! empty( $compiled['joins'] ) ) {
|
||||
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $compiled['where'] ) ) {
|
||||
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
|
||||
}
|
||||
|
||||
return $clauses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心編譯邏輯
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param array $meta_query 結構範例:
|
||||
* [
|
||||
* 'relation' => 'AND',
|
||||
* [ 'key' => 'price', 'value' => [100, 500], 'compare' => 'BETWEEN' ],
|
||||
* [ 'key' => 'stock', 'value' => 'instock' ],
|
||||
* ]
|
||||
* @return array{joins: array<string>, where: array<string>}
|
||||
*/
|
||||
public static function compile( string $entity_type, array $meta_query ): array {
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return array(
|
||||
'joins' => array(),
|
||||
'where' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
$relation = strtoupper( $meta_query['relation'] ?? 'AND' );
|
||||
if ( ! in_array( $relation, array( 'AND', 'OR' ), true ) ) {
|
||||
$relation = 'AND';
|
||||
}
|
||||
unset( $meta_query['relation'] );
|
||||
|
||||
$needed_groups = array(); // group → true
|
||||
$where_clauses = array();
|
||||
|
||||
foreach ( $meta_query as $clause ) {
|
||||
if ( ! is_array( $clause ) || empty( $clause['key'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $clause['key'] );
|
||||
if ( ! $field_def ) {
|
||||
// 非 UAE 管理的欄位,跳過(讓原生 meta_query 接手)
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $field_def['group'];
|
||||
$needed_groups[ $group ] = true;
|
||||
|
||||
$compare = strtoupper( $clause['compare'] ?? '=' );
|
||||
if ( ! in_array( $compare, self::VALID_OPERATORS, true ) ) {
|
||||
$compare = '=';
|
||||
}
|
||||
|
||||
$alias = 'wpdo_' . $group;
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
|
||||
$value = $clause['value'] ?? null;
|
||||
|
||||
$where_clauses[] = self::build_comparison( $alias, $col, $compare, $value, $field_def );
|
||||
}
|
||||
|
||||
// 建立 JOIN
|
||||
$joins = array();
|
||||
$prim_table = $adapter->get_primary_table();
|
||||
$prim_id = $adapter->get_primary_id_column();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
foreach ( array_keys( $needed_groups ) as $group ) {
|
||||
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alias = 'wpdo_' . $group;
|
||||
$joins[] = "LEFT JOIN `{$wpdo_table}` `{$alias}` ON `{$prim_table}`.`{$prim_id}` = `{$alias}`.`{$id_col}`";
|
||||
}
|
||||
|
||||
// 組合 where,依 relation 連接
|
||||
$where_sql = array();
|
||||
if ( ! empty( $where_clauses ) ) {
|
||||
$where_sql[] = implode( " {$relation} ", $where_clauses );
|
||||
}
|
||||
|
||||
return array(
|
||||
'joins' => $joins,
|
||||
'where' => $where_sql,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生單一比較子句
|
||||
*/
|
||||
private static function build_comparison(
|
||||
string $alias,
|
||||
string $col,
|
||||
string $compare,
|
||||
$value,
|
||||
array $field_def
|
||||
): string {
|
||||
global $wpdb;
|
||||
|
||||
$col_ref = "`{$alias}`.`{$col}`";
|
||||
|
||||
switch ( $compare ) {
|
||||
case 'EXISTS':
|
||||
return "{$col_ref} IS NOT NULL";
|
||||
|
||||
case 'NOT EXISTS':
|
||||
return "{$col_ref} IS NULL";
|
||||
|
||||
case 'BETWEEN':
|
||||
case 'NOT BETWEEN':
|
||||
if ( ! is_array( $value ) || count( $value ) !== 2 ) {
|
||||
return '1=1';
|
||||
}
|
||||
$v1 = self::escape_value( $value[0], $field_def );
|
||||
$v2 = self::escape_value( $value[1], $field_def );
|
||||
return "{$col_ref} {$compare} {$v1} AND {$v2}";
|
||||
|
||||
case 'IN':
|
||||
case 'NOT IN':
|
||||
if ( ! is_array( $value ) ) {
|
||||
$value = array( $value );
|
||||
}
|
||||
if ( empty( $value ) ) {
|
||||
return $compare === 'IN' ? '1=0' : '1=1';
|
||||
}
|
||||
$escaped = array_map( fn( $v ) => self::escape_value( $v, $field_def ), $value );
|
||||
return "{$col_ref} {$compare} (" . implode( ',', $escaped ) . ')';
|
||||
|
||||
case 'LIKE':
|
||||
case 'NOT LIKE':
|
||||
$like_val = '%' . $wpdb->esc_like( (string) $value ) . '%';
|
||||
return "{$col_ref} {$compare} '" . esc_sql( $like_val ) . "'";
|
||||
|
||||
default:
|
||||
$escaped = self::escape_value( $value, $field_def );
|
||||
return "{$col_ref} {$compare} {$escaped}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 型別安全地轉義值
|
||||
*/
|
||||
private static function escape_value( $value, array $field_def ): string {
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
|
||||
return match ( $type ) {
|
||||
'integer', 'boolean' => (string) (int) $value,
|
||||
'decimal' => (string) (float) $value,
|
||||
default => "'" . esc_sql( (string) $value ) . "'",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Schema_Manager - 動態 DDL 引擎
|
||||
*
|
||||
* 功能:
|
||||
* - 根據欄位定義動態建立/升級扁平化資料表
|
||||
* - WordPress 型別 → MySQL 型別映射
|
||||
* - 自動索引策略(B-Tree、全文、唯一)
|
||||
* - Schema 版本控制(hash 比對)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Schema_Manager {
|
||||
|
||||
/**
|
||||
* WordPress 邏輯型別 → MySQL 實體型別映射
|
||||
*/
|
||||
private static array $type_map = array(
|
||||
'text' => 'VARCHAR(255)',
|
||||
'textarea' => 'TEXT',
|
||||
'integer' => 'BIGINT(20)',
|
||||
'decimal' => 'DECIMAL(18,6)',
|
||||
'boolean' => 'TINYINT(1)',
|
||||
'date' => 'DATE',
|
||||
'datetime' => 'DATETIME',
|
||||
'timestamp' => 'TIMESTAMP',
|
||||
'json' => 'LONGTEXT', // MySQL 5.7.8+ 可用 JSON,為相容性用 LONGTEXT
|
||||
'enum' => 'VARCHAR(100)', // 在 PHP 層驗證
|
||||
'binary' => 'LONGBLOB',
|
||||
);
|
||||
|
||||
/**
|
||||
* 取得完整資料表名稱
|
||||
*/
|
||||
public static function get_table_name( string $entity_type, string $group_name ): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . TMDO_TABLE_PREFIX . sanitize_key( $entity_type ) . '_' . sanitize_key( $group_name );
|
||||
}
|
||||
|
||||
/**
|
||||
* 批次處理所有待建表
|
||||
*/
|
||||
public static function process_pending_migrations(): void {
|
||||
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
||||
|
||||
foreach ( $pending as $schema ) {
|
||||
self::create_or_upgrade_table(
|
||||
$schema['type'],
|
||||
$schema['group'],
|
||||
$schema['fields']
|
||||
);
|
||||
}
|
||||
|
||||
TMDO_Entity_Registry::clear_pending_schemas();
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立或升級資料表
|
||||
*/
|
||||
public static function create_or_upgrade_table(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
array $field_definitions
|
||||
): bool {
|
||||
global $wpdb;
|
||||
|
||||
// Schema 版本比對:若未變動則跳過
|
||||
$schema_hash = self::calculate_schema_hash( $field_definitions );
|
||||
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
||||
|
||||
if ( $stored_hash === $schema_hash ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$table = self::get_table_name( $entity_type, $group_name );
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
// 建立基礎欄位(每張表都有)
|
||||
$sql_columns = array(
|
||||
'`id` BIGINT(20) NOT NULL AUTO_INCREMENT',
|
||||
"`{$id_col}` BIGINT(20) NOT NULL",
|
||||
'`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
'`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
|
||||
);
|
||||
|
||||
$sql_indexes = array(
|
||||
'PRIMARY KEY (`id`)',
|
||||
"UNIQUE KEY `uk_entity` (`{$id_col}`)",
|
||||
'KEY `idx_created` (`created_at`)',
|
||||
);
|
||||
|
||||
// 處理動態欄位
|
||||
foreach ( $field_definitions as $field ) {
|
||||
$col_name = self::sanitize_column_name( $field['key'] );
|
||||
$col_type = self::$type_map[ $field['type'] ] ?? 'VARCHAR(255)';
|
||||
|
||||
$null_clause = ! empty( $field['required'] ) ? 'NOT NULL' : 'DEFAULT NULL';
|
||||
$default = self::build_default_clause( $field );
|
||||
|
||||
// 組合欄位 DDL
|
||||
$col_ddl = "`{$col_name}` {$col_type} {$null_clause}";
|
||||
if ( $default !== '' ) {
|
||||
$col_ddl .= " {$default}";
|
||||
}
|
||||
|
||||
$sql_columns[] = $col_ddl;
|
||||
|
||||
// 索引策略
|
||||
if ( ! empty( $field['unique'] ) ) {
|
||||
$sql_indexes[] = "UNIQUE KEY `uk_{$col_name}` (`{$col_name}`)";
|
||||
} elseif ( ! empty( $field['searchable'] ) ) {
|
||||
// 不同型別決定索引長度
|
||||
if ( in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
|
||||
// 文字欄位使用前綴索引避免過長
|
||||
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`(100))";
|
||||
} else {
|
||||
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`)";
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $field['fulltext'] ) && in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
|
||||
$sql_indexes[] = "FULLTEXT KEY `ft_{$col_name}` (`{$col_name}`)";
|
||||
}
|
||||
}
|
||||
|
||||
$columns_sql = implode( ",\n ", $sql_columns );
|
||||
$indexes_sql = implode( ",\n ", $sql_indexes );
|
||||
|
||||
$sql = "CREATE TABLE `{$table}` (\n {$columns_sql},\n {$indexes_sql}\n) {$charset};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
// dbDelta 自動處理建表/ALTER TABLE
|
||||
$dbdelta_result = dbDelta( $sql );
|
||||
|
||||
// 記錄 Schema 版本與定義
|
||||
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
|
||||
|
||||
/**
|
||||
* Action: 表建立/升級完成
|
||||
*/
|
||||
do_action( 'wpdo_schema_updated', $entity_type, $group_name, $table, $dbdelta_result );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算 Schema Hash(用於偵測欄位變動)
|
||||
*/
|
||||
public static function calculate_schema_hash( array $fields ): string {
|
||||
// 正規化:僅保留影響 Schema 的屬性
|
||||
$normalized = array_map(
|
||||
function ( $f ) {
|
||||
return array(
|
||||
'key' => $f['key'] ?? '',
|
||||
'type' => $f['type'] ?? '',
|
||||
'required' => ! empty( $f['required'] ),
|
||||
'default' => $f['default'] ?? null,
|
||||
'searchable' => ! empty( $f['searchable'] ),
|
||||
'fulltext' => ! empty( $f['fulltext'] ),
|
||||
'unique' => ! empty( $f['unique'] ),
|
||||
);
|
||||
},
|
||||
$fields
|
||||
);
|
||||
|
||||
// 依 key 排序以確保 hash 穩定
|
||||
usort( $normalized, fn( $a, $b ) => strcmp( $a['key'], $b['key'] ) );
|
||||
|
||||
$json = wp_json_encode( $normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||||
|
||||
return hash( 'sha256', $json );
|
||||
}
|
||||
|
||||
/**
|
||||
* 欄位名稱清理(防止 SQL 注入)
|
||||
*/
|
||||
public static function sanitize_column_name( string $key ): string {
|
||||
// 移除所有非 alphanumeric/底線
|
||||
$clean = preg_replace( '/[^a-zA-Z0-9_]/', '', $key );
|
||||
|
||||
// 若以數字開頭,前綴 f_
|
||||
if ( $clean !== '' && preg_match( '/^\d/', $clean ) ) {
|
||||
$clean = 'f_' . $clean;
|
||||
}
|
||||
|
||||
// MySQL 欄位名長度限制 64 字元
|
||||
return substr( $clean, 0, 60 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 DEFAULT 子句
|
||||
*/
|
||||
private static function build_default_clause( array $field ): string {
|
||||
if ( ! isset( $field['default'] ) || $field['default'] === null ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$default = $field['default'];
|
||||
|
||||
switch ( $field['type'] ) {
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
return 'DEFAULT ' . (int) $default;
|
||||
|
||||
case 'decimal':
|
||||
return 'DEFAULT ' . (float) $default;
|
||||
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
case 'timestamp':
|
||||
if ( strtoupper( (string) $default ) === 'CURRENT_TIMESTAMP' ) {
|
||||
return 'DEFAULT CURRENT_TIMESTAMP';
|
||||
}
|
||||
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
|
||||
|
||||
case 'json':
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'enum':
|
||||
default:
|
||||
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存 Schema metadata 到 wpdo_registry_meta 表
|
||||
*/
|
||||
private static function store_schema_metadata(
|
||||
string $entity_type,
|
||||
string $group_name,
|
||||
string $schema_hash,
|
||||
array $field_definitions
|
||||
): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||
|
||||
$wpdb->replace(
|
||||
$table,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group_name,
|
||||
'schema_hash' => $schema_hash,
|
||||
'field_definitions' => wp_json_encode( $field_definitions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得已儲存的 Schema hash
|
||||
*/
|
||||
public static function get_stored_schema_hash( string $entity_type, string $group_name ): string {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||
|
||||
$hash = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT schema_hash FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
|
||||
$entity_type,
|
||||
$group_name
|
||||
)
|
||||
);
|
||||
|
||||
return $hash ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查表是否存在
|
||||
*/
|
||||
public static function table_exists( string $table_name ): bool {
|
||||
global $wpdb;
|
||||
$result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
|
||||
return $result === $table_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 丟棄表(謹慎使用,僅用於解除安裝)
|
||||
*/
|
||||
public static function drop_table( string $entity_type, string $group_name ): bool {
|
||||
global $wpdb;
|
||||
$table = self::get_table_name( $entity_type, $group_name );
|
||||
return (bool) $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得所有 UAE 建立的表清單
|
||||
*/
|
||||
public static function list_all_uae_tables(): array {
|
||||
global $wpdb;
|
||||
$prefix = $wpdb->prefix . TMDO_TABLE_PREFIX;
|
||||
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $prefix . '%' ) );
|
||||
return $tables ?: array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得資料表統計資訊(大小、列數)
|
||||
*/
|
||||
public static function get_table_stats( string $table_name ): array {
|
||||
global $wpdb;
|
||||
|
||||
$info = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
'SELECT TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
|
||||
DB_NAME,
|
||||
$table_name
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $info ) {
|
||||
return array(
|
||||
'rows' => 0,
|
||||
'size_mb' => 0,
|
||||
'index_mb' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'rows' => (int) $info['TABLE_ROWS'],
|
||||
'size_mb' => round( $info['DATA_LENGTH'] / 1024 / 1024, 2 ),
|
||||
'index_mb' => round( $info['INDEX_LENGTH'] / 1024 / 1024, 2 ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Shadow_Diff_Logger - Shadow-read 差異偵測與記錄
|
||||
*
|
||||
* 當 bridge 為 shadow_read 模式時,每次讀取都會:
|
||||
* 1. 從 UAE flat table 取值
|
||||
* 2. 從 wp_*meta 原生表取值
|
||||
* 3. 比對
|
||||
* 4. 若不一致 → 記錄到 wp_wpdo_uni_shadow_diffs 表
|
||||
*
|
||||
* 這個表是**持久化**的(不像 AEAV 原本用 transient — 會被 flush 掉),
|
||||
* 因為驗證期可能跨多日,不能丟失。
|
||||
*
|
||||
* 寫入有 rate limit(同 entity+key 在 5 分鐘內只記一筆),防止熱門欄位寫爆。
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Shadow_Diff_Logger {
|
||||
|
||||
/** 保留最近 N 筆 diffs(超過就 rolling delete)。防止表爆炸。 */
|
||||
public const MAX_ROWS = 5000;
|
||||
|
||||
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
||||
public const RATELIMIT_WINDOW = 300;
|
||||
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
}
|
||||
|
||||
/**
|
||||
* 比對並記錄差異
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @param string $meta_key
|
||||
* @param mixed $wpdo_value 從 UAE flat table 取得的值(已型別轉換)
|
||||
* @param array $field_def field definition(含 type)
|
||||
*/
|
||||
public static function compare_and_log(
|
||||
string $entity_type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
$wpdo_value,
|
||||
array $field_def
|
||||
): void {
|
||||
// 直接從 native meta 表取(繞過 UAE filter 避免無限迴圈)
|
||||
$eav_raw = self::get_eav_value_raw( $entity_type, $entity_id, $meta_key );
|
||||
|
||||
// 兩邊都無 → 一致
|
||||
if ( $eav_raw === null && ( $wpdo_value === null || $wpdo_value === '' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
$eav_val = self::cast_eav( $eav_raw, $type );
|
||||
|
||||
// 比對
|
||||
if ( self::values_equal( $eav_val, $wpdo_value, $type ) ) {
|
||||
return; // 一致
|
||||
}
|
||||
|
||||
// 不一致 → 檢查 rate limit
|
||||
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 寫入 diff log
|
||||
self::record(
|
||||
$entity_type,
|
||||
$entity_id,
|
||||
$meta_key,
|
||||
self::stringify_for_log( $eav_val ),
|
||||
self::stringify_for_log( $wpdo_value ),
|
||||
$type
|
||||
);
|
||||
|
||||
// Trim 舊 rows
|
||||
self::maybe_trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 wp_*meta 直接取值(繞過 UAE filter)
|
||||
*/
|
||||
private static function get_eav_value_raw( string $entity_type, int $entity_id, string $meta_key ): ?string {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
// 直接 SQL 繞過 get_metadata 系列 filter
|
||||
$value = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s LIMIT 1",
|
||||
$entity_id,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
|
||||
return $value === null ? null : (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 EAV 原始字串轉為該型別的值(用來比對)
|
||||
*/
|
||||
private static function cast_eav( ?string $raw, string $type ) {
|
||||
if ( $raw === null ) {
|
||||
return null;
|
||||
}
|
||||
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
|
||||
// Mirrors WP's maybe_unserialize but with allowed_classes=false.
|
||||
$raw = TMDO_Safe_Unserialize::run( $raw );
|
||||
|
||||
switch ( $type ) {
|
||||
case 'integer':
|
||||
return is_numeric( $raw ) ? (int) $raw : null;
|
||||
case 'decimal':
|
||||
return is_numeric( $raw ) ? (float) $raw : null;
|
||||
case 'boolean':
|
||||
return (bool) $raw;
|
||||
case 'json':
|
||||
if ( is_array( $raw ) || is_object( $raw ) ) {
|
||||
return $raw;
|
||||
}
|
||||
$decoded = json_decode( (string) $raw, true );
|
||||
return $decoded !== null ? $decoded : $raw;
|
||||
default:
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兩值是否一致(依型別做合適比對)
|
||||
*/
|
||||
private static function values_equal( $a, $b, string $type ): bool {
|
||||
if ( $a === $b ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// JSON / array 比對:用 canonical JSON
|
||||
if ( $type === 'json' || is_array( $a ) || is_array( $b ) ) {
|
||||
return wp_json_encode( $a ) === wp_json_encode( $b );
|
||||
}
|
||||
|
||||
// Numeric:比數值
|
||||
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
|
||||
return is_numeric( $a ) && is_numeric( $b )
|
||||
? (float) $a === (float) $b
|
||||
: $a === $b;
|
||||
}
|
||||
|
||||
// Boolean:寬鬆
|
||||
if ( $type === 'boolean' ) {
|
||||
return (bool) $a === (bool) $b;
|
||||
}
|
||||
|
||||
// Default:鬆散字串比較(EAV 字串 vs casted)
|
||||
return (string) $a === (string) $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit 判斷(同 entity+key 在 300 秒內只記一次)
|
||||
*/
|
||||
private static function is_rate_limited( string $entity_type, int $entity_id, string $meta_key ): bool {
|
||||
$key = 'wpdo_sd_rl_' . md5( "{$entity_type}:{$entity_id}:{$meta_key}" );
|
||||
if ( get_transient( $key ) !== false ) {
|
||||
return true;
|
||||
}
|
||||
set_transient( $key, 1, self::RATELIMIT_WINDOW );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入 diff row
|
||||
*
|
||||
* v2.1.6 fix: aligned to actual `wpdo_shadow_diffs` schema installed by
|
||||
* TMDO_Installer. The columns are: ts / entity_type / entity_id / meta_key /
|
||||
* postmeta_value / zone_value / diff_hash. Logger code previously assumed
|
||||
* eav_value / wpdo_value / created_at / field_type — schema-vs-code drift
|
||||
* that silently no-op'd every diff INSERT (wpdb returns 0, no exception).
|
||||
*
|
||||
* `field_type` is consumed only as input to diff_hash so the same
|
||||
* (entity_type, entity_id, meta_key) tuple records distinct rows when the
|
||||
* field's interpreted type changes (rare; mostly a defence-in-depth bucket).
|
||||
*/
|
||||
private static function record(
|
||||
string $entity_type,
|
||||
int $entity_id,
|
||||
string $meta_key,
|
||||
string $eav_value,
|
||||
string $wpdo_value,
|
||||
string $field_type
|
||||
): void {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
$diff_hash = sha1( $entity_type . '|' . $meta_key . '|' . $field_type . '|' . $eav_value . '|' . $wpdo_value );
|
||||
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'ts' => current_time( 'mysql', true ),
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'meta_key' => $meta_key,
|
||||
'postmeta_value' => $eav_value,
|
||||
'zone_value' => $wpdo_value,
|
||||
'diff_hash' => $diff_hash,
|
||||
),
|
||||
array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
TMDO_Logger::warning(
|
||||
'shadow_read_diff',
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'meta_key' => $meta_key,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轉為 log 字串
|
||||
*/
|
||||
private static function stringify_for_log( $value ): string {
|
||||
if ( is_scalar( $value ) || $value === null ) {
|
||||
return (string) $value;
|
||||
}
|
||||
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
/**
|
||||
* 超過 MAX_ROWS 時 trim 最舊的(chance 1% 才執行,不用每次都跑)
|
||||
*/
|
||||
private static function maybe_trim(): void {
|
||||
if ( wp_rand( 1, 100 ) !== 1 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
if ( $count <= self::MAX_ROWS ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$to_delete = $count - self::MAX_ROWS;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
|
||||
$to_delete
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Read API (for admin UI + CLI)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 取得最近 N 筆 diffs
|
||||
*/
|
||||
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
if ( $entity_type ) {
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
|
||||
$entity_type,
|
||||
$limit
|
||||
);
|
||||
} else {
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
$rows = $wpdb->get_results( $sql, ARRAY_A );
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 統計:按 entity / meta_key 聚合
|
||||
*/
|
||||
public static function stats_by_key( int $limit = 20 ): array {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
|
||||
// v2.1.6: column is `ts`, not `created_at` (matches installer schema).
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT entity_type, meta_key, COUNT(*) AS c, MAX(ts) AS last_seen
|
||||
FROM `{$table}`
|
||||
GROUP BY entity_type, meta_key
|
||||
ORDER BY c DESC LIMIT %d",
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return is_array( $rows ) ? $rows : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部 diff 總數
|
||||
*/
|
||||
public static function total_count(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 entity type 統計
|
||||
*/
|
||||
public static function count_by_entity(): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results(
|
||||
'SELECT entity_type, COUNT(*) AS c FROM `' . self::table_name() . '` GROUP BY entity_type',
|
||||
ARRAY_A
|
||||
);
|
||||
$result = array();
|
||||
foreach ( $rows ?: array() as $r ) {
|
||||
$result[ $r['entity_type'] ] = (int) $r['c'];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有 diffs(admin confirmed)
|
||||
*/
|
||||
public static function clear_all(): int {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
$wpdb->query( "TRUNCATE TABLE `{$table}`" );
|
||||
TMDO_Logger::info(
|
||||
'shadow_diffs_cleared',
|
||||
array(
|
||||
'count' => $count,
|
||||
'user_id' => get_current_user_id(),
|
||||
)
|
||||
);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除特定 entity 的 diffs
|
||||
*/
|
||||
public static function clear_entity( string $entity_type ): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
'DELETE FROM `' . self::table_name() . '` WHERE entity_type = %s',
|
||||
$entity_type
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Schema
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static function install_table(): void {
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
|
||||
$table = self::table_name();
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
entity_type VARCHAR(20) NOT NULL,
|
||||
entity_id BIGINT UNSIGNED NOT NULL,
|
||||
meta_key VARCHAR(255) NOT NULL,
|
||||
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
eav_value LONGTEXT NULL,
|
||||
wpdo_value LONGTEXT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_entity (entity_type, entity_id),
|
||||
KEY idx_key (entity_type, meta_key(191)),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset};";
|
||||
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
public static function drop_table(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Type_Caster - 型別轉換器
|
||||
*
|
||||
* 負責:
|
||||
* - PHP → DB 序列化(寫入)
|
||||
* - DB → PHP 反序列化(讀取)
|
||||
* - wpdb format string 生成
|
||||
* - 型別驗證與清理
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||||
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Type_Caster {
|
||||
|
||||
/**
|
||||
* PHP → DB 轉換(寫入)
|
||||
*/
|
||||
public static function to_db( $value, array $field_def ) {
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
|
||||
// null 值處理
|
||||
if ( $value === null ) {
|
||||
if ( ! empty( $field_def['required'] ) ) {
|
||||
return $field_def['default'] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'integer':
|
||||
return (int) $value;
|
||||
|
||||
case 'decimal':
|
||||
return (float) $value;
|
||||
|
||||
case 'boolean':
|
||||
return self::to_bool( $value ) ? 1 : 0;
|
||||
|
||||
case 'date':
|
||||
return self::format_date( $value, 'Y-m-d' );
|
||||
|
||||
case 'datetime':
|
||||
case 'timestamp':
|
||||
return self::format_date( $value, 'Y-m-d H:i:s' );
|
||||
|
||||
case 'json':
|
||||
if ( is_string( $value ) ) {
|
||||
// 若已是 JSON 字串則直接儲存(驗證後)
|
||||
$decoded = json_decode( $value, true );
|
||||
if ( json_last_error() === JSON_ERROR_NONE ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||||
|
||||
case 'enum':
|
||||
$options = $field_def['options'] ?? array();
|
||||
$str = (string) $value;
|
||||
return ( ! empty( $options ) && in_array( $str, $options, true ) ) ? $str : ( $field_def['default'] ?? '' );
|
||||
|
||||
case 'binary':
|
||||
return $value;
|
||||
|
||||
case 'text':
|
||||
return (string) $value;
|
||||
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
|
||||
default:
|
||||
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
|
||||
return maybe_serialize( $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB → PHP 轉換(讀取)
|
||||
*/
|
||||
public static function from_db( $value, array $field_def ) {
|
||||
$type = $field_def['type'] ?? 'text';
|
||||
|
||||
if ( $value === null ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'integer':
|
||||
return (int) $value;
|
||||
|
||||
case 'decimal':
|
||||
return (float) $value;
|
||||
|
||||
case 'boolean':
|
||||
return (bool) (int) $value;
|
||||
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
case 'timestamp':
|
||||
return (string) $value;
|
||||
|
||||
case 'json':
|
||||
$decoded = json_decode( (string) $value, true );
|
||||
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
|
||||
|
||||
case 'enum':
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
|
||||
default:
|
||||
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
|
||||
return TMDO_Safe_Unserialize::run( $value );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* wpdb format 字串(%s / %d / %f)
|
||||
*/
|
||||
public static function get_wpdb_format( string $type ): string {
|
||||
return match ( $type ) {
|
||||
'integer', 'boolean' => '%d',
|
||||
'decimal' => '%f',
|
||||
default => '%s',
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 工具方法
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static function to_bool( $value ): bool {
|
||||
if ( is_bool( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
if ( is_numeric( $value ) ) {
|
||||
return (int) $value !== 0;
|
||||
}
|
||||
$str = strtolower( trim( (string) $value ) );
|
||||
return in_array( $str, array( 'true', 'yes', 'y', '1', 'on' ), true );
|
||||
}
|
||||
|
||||
private static function format_date( $value, string $format ): ?string {
|
||||
if ( $value === '' || $value === null ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 接受 Unix timestamp
|
||||
if ( is_numeric( $value ) ) {
|
||||
return gmdate( $format, (int) $value );
|
||||
}
|
||||
|
||||
$ts = strtotime( (string) $value );
|
||||
if ( $ts === false ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return gmdate( $format, $ts );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_CSV_Writer — Lightweight CSV writer with UTF-8 BOM (v2.5.0 M14).
|
||||
*
|
||||
* Excel reads UTF-8 CSV correctly only when the file starts with EF BB BF
|
||||
* BOM. This writer always emits BOM, escapes embedded `"` and wraps fields
|
||||
* containing `,` / newline / `"`.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder API: build_csv_string( $headers, $rows ): string.
|
||||
*/
|
||||
class TMDO_CSV_Writer {
|
||||
|
||||
/** UTF-8 BOM bytes. */
|
||||
public const BOM = "\xEF\xBB\xBF";
|
||||
|
||||
/**
|
||||
* Build a complete CSV string with BOM + header + rows.
|
||||
*
|
||||
* @param array $headers Header column names.
|
||||
* @param array $rows Each row is an associative array keyed by header.
|
||||
* @return string CSV body ready for Content-Disposition: attachment.
|
||||
*/
|
||||
public static function build( array $headers, array $rows ): string {
|
||||
$out = self::BOM;
|
||||
$out .= self::row_to_csv( $headers );
|
||||
foreach ( $rows as $row ) {
|
||||
$line = array();
|
||||
foreach ( $headers as $h ) {
|
||||
$v = $row[ $h ] ?? '';
|
||||
if ( is_array( $v ) || is_object( $v ) ) {
|
||||
$v = wp_json_encode( $v );
|
||||
}
|
||||
$line[] = (string) $v;
|
||||
}
|
||||
$out .= self::row_to_csv( $line );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one row of fields with proper escaping.
|
||||
*
|
||||
* @param array $fields Field values.
|
||||
* @return string CSV row including trailing CRLF.
|
||||
*/
|
||||
private static function row_to_csv( array $fields ): string {
|
||||
$escaped = array();
|
||||
foreach ( $fields as $f ) {
|
||||
$s = self::sanitize_cell( (string) $f );
|
||||
$needs_quote = ( str_contains( $s, ',' ) || str_contains( $s, '"' ) || str_contains( $s, "\n" ) || str_contains( $s, "\r" ) );
|
||||
if ( $needs_quote ) {
|
||||
$s = '"' . str_replace( '"', '""', $s ) . '"';
|
||||
}
|
||||
$escaped[] = $s;
|
||||
}
|
||||
return implode( ',', $escaped ) . "\r\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralise CSV formula-injection characters (=, +, -, @, TAB, CR).
|
||||
*
|
||||
* Excel/Sheets treat cells starting with these as formulas. Prefixing with
|
||||
* a literal single-quote forces text interpretation without altering the
|
||||
* visual output for normal users.
|
||||
*
|
||||
* @param string $s Raw cell value.
|
||||
* @return string Safe cell value.
|
||||
*/
|
||||
private static function sanitize_cell( string $s ): string {
|
||||
if ( '' !== $s && preg_match( '/^[=+\-@\t\r]/', $s ) ) {
|
||||
return "'" . $s;
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Demo_Entity_Counter — production proof-of-life for entity adapters.
|
||||
*
|
||||
* Task D follow-up to PR-5: demonstrates that the entity adapter framework
|
||||
* actually works end-to-end (not just stubs). Implements a "counter" pattern
|
||||
* shared across post / user / term / comment entities — a common gamification
|
||||
* primitive (user points, listing views, comment helpful_count, term usage).
|
||||
*
|
||||
* Storage:
|
||||
* wp_wpdo_demo_entity_counters (entity_type, entity_id, counter_key, counter_value, updated_at)
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Plugin or test invokes ::set( 'user', 42, 'points', 50 )
|
||||
* - This writes to BOTH wp_usermeta (native, preserved) AND wpdo_demo table
|
||||
* - Reads come from wpdo_demo when feature flag entity_demo_counter == 'cutover',
|
||||
* otherwise fall through to native usermeta (transparent fallback)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches.
|
||||
|
||||
/**
|
||||
* Demo entity counter — proves 4-entity adapter framework works end-to-end.
|
||||
*/
|
||||
final class TMDO_Demo_Entity_Counter {
|
||||
|
||||
/** Feature flag module name. */
|
||||
public const MODULE = 'entity_demo_counter';
|
||||
|
||||
/** Custom table holding all counters. */
|
||||
public const TABLE = 'wpdo_demo_entity_counters';
|
||||
|
||||
/**
|
||||
* Idempotent install of the demo table.
|
||||
*
|
||||
* Called from TMDO_Installer::install_v2_tables() OR manually for demos.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function install_table(): void {
|
||||
global $wpdb;
|
||||
if ( ! function_exists( 'dbDelta' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
}
|
||||
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
entity_type varchar(20) NOT NULL DEFAULT '',
|
||||
entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
counter_key varchar(100) NOT NULL DEFAULT '',
|
||||
counter_value bigint(20) NOT NULL DEFAULT 0,
|
||||
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key),
|
||||
KEY idx_lookup (entity_type, counter_key, counter_value),
|
||||
KEY idx_entity (entity_type, entity_id)
|
||||
) {$charset};";
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the demo table — idempotent.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function drop_table(): void {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a counter for any entity. Writes to BOTH native meta AND demo table
|
||||
* when feature flag is in a write-active state. Otherwise writes native
|
||||
* only (idle path — full backward compatibility).
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug (e.g. 'points', 'view_count').
|
||||
* @param int $value New value.
|
||||
* @return bool
|
||||
*/
|
||||
public static function set( string $entity_type, int $entity_id, string $counter_key, int $value ): bool {
|
||||
if ( ! self::is_valid_entity( $entity_type ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always write the native meta first (durability anchor).
|
||||
TMDO_API::set_entity( $entity_type, $entity_id, $counter_key, $value );
|
||||
|
||||
// Conditional dual-write to demo table.
|
||||
if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) {
|
||||
self::write_to_table( $entity_type, $entity_id, $counter_key, $value );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a counter value. Source depends on feature flag state:
|
||||
* - read_custom (cutover/cleanup/complete) → demo table
|
||||
* - otherwise → native meta (fallback)
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @return int
|
||||
*/
|
||||
public static function get( string $entity_type, int $entity_id, string $counter_key ): int {
|
||||
if ( ! self::is_valid_entity( $entity_type ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) {
|
||||
$row = self::read_from_table( $entity_type, $entity_id, $counter_key );
|
||||
if ( null !== $row ) {
|
||||
return (int) $row;
|
||||
}
|
||||
// Fallback to native if zone row missing — graceful degradation.
|
||||
}
|
||||
|
||||
return (int) TMDO_API::get_entity( $entity_type, $entity_id, $counter_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-N entities by counter value — the killer query that postmeta CANNOT
|
||||
* do efficiently (requires full scan + filesort). Demonstrates the value of
|
||||
* the entity adapter pattern.
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @param int $limit Max rows.
|
||||
* @return array<int, array{entity_id:int, counter_value:int}>
|
||||
*/
|
||||
public static function top_n( string $entity_type, string $counter_key, int $limit = 10 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from constant.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT entity_id, counter_value FROM `{$table}` WHERE entity_type = %s AND counter_key = %s ORDER BY counter_value DESC LIMIT %d",
|
||||
$entity_type,
|
||||
$counter_key,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn( array $r ) => array(
|
||||
'entity_id' => (int) $r['entity_id'],
|
||||
'counter_value' => (int) $r['counter_value'],
|
||||
),
|
||||
$rows ?: array()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param string $entity_type Entity type.
|
||||
*/
|
||||
private static function is_valid_entity( string $entity_type ): bool {
|
||||
return in_array( $entity_type, array( 'post', 'user', 'term', 'comment' ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single counter value via UPSERT (1 round-trip).
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @param int $value New value.
|
||||
* @return void
|
||||
*/
|
||||
private static function write_to_table( string $entity_type, int $entity_id, string $counter_key, int $value ): void {
|
||||
global $wpdb;
|
||||
TMDO_DB::upsert(
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'counter_key' => $counter_key,
|
||||
'counter_value' => $value,
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'counter_value', 'updated_at' ),
|
||||
array( 'entity_type', 'entity_id', 'counter_key' ),
|
||||
array( '%s', '%d', '%s', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single counter value from the demo table.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @return int|null Null when row absent.
|
||||
*/
|
||||
private static function read_from_table( string $entity_type, int $entity_id, string $counter_key ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from constant.
|
||||
$value = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT counter_value FROM `{$table}` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s LIMIT 1",
|
||||
$entity_type,
|
||||
$entity_id,
|
||||
$counter_key
|
||||
)
|
||||
);
|
||||
return null === $value ? null : (int) $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
/**
|
||||
* Member entity field registration for WP Data Optimizer.
|
||||
*
|
||||
* Registers four user entity groups designed for 千萬 (10M) member scale.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_usermeta EAV.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
||||
*
|
||||
* Groups → flat tables:
|
||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
||||
*
|
||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||
*/
|
||||
final class TMDO_Member_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all user entity groups.
|
||||
*
|
||||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||||
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
||||
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
||||
*
|
||||
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
||||
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
||||
* comment_shortcuts, admin_color, use_ssl, show_admin_bar_front,
|
||||
* dismissed_wp_pointers). Without this group, fresh users always show
|
||||
* ratio 1:9 regardless of any other optimization.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_membership_group();
|
||||
self::register_activity_group();
|
||||
self::register_profile_group();
|
||||
self::register_sso_group();
|
||||
self::register_core_profile_group();
|
||||
self::register_social_group();
|
||||
self::register_commerce_group();
|
||||
self::register_hp_user_group();
|
||||
self::register_admin_prefs_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tier level, points balance, expiry — primary search target.
|
||||
*
|
||||
* Extra composite indexes (idx_level_expires, idx_expires_level, idx_points_bal)
|
||||
* are applied by TMDO_Installer::install_member_indexes() after table creation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_membership_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'membership',
|
||||
array(
|
||||
array(
|
||||
'key' => 'membership_level',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
|
||||
'label' => 'Membership tier level',
|
||||
),
|
||||
array(
|
||||
'key' => 'points_balance',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'default' => 0,
|
||||
'label' => 'Current points balance',
|
||||
),
|
||||
array(
|
||||
'key' => 'membership_expires_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'Membership expiry datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'membership_activated_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Membership activation datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'tier_source',
|
||||
'type' => 'text',
|
||||
'label' => 'Tier source: manual / wc_subscription / admin_set',
|
||||
),
|
||||
array(
|
||||
'key' => 'custom_tier_label',
|
||||
'type' => 'text',
|
||||
'label' => 'Custom tier display label (when level=custom)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login counters and last-active timestamps — separated to avoid lock
|
||||
* contention with membership reads during high-traffic periods.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_activity_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'activity',
|
||||
array(
|
||||
array(
|
||||
'key' => 'login_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'default' => 0,
|
||||
'label' => 'Cumulative login count',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_active_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'Last activity datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_login_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last login datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_order_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last order datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'session_count',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'Total session count',
|
||||
),
|
||||
array(
|
||||
'key' => 'account_flags',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'Bitmask: 1=email_verified 2=phone_verified 4=kyc 8=social_signup',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display fields, specialties, avatar — written infrequently.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_profile_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'profile',
|
||||
array(
|
||||
array(
|
||||
'key' => 'specialties',
|
||||
'type' => 'json',
|
||||
'label' => 'Professional specialties (JSON array)',
|
||||
),
|
||||
array(
|
||||
'key' => 'bio_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Bio or portfolio URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'avatar_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Avatar image URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'display_name_custom',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'fulltext' => true,
|
||||
'label' => 'Custom display name (fulltext searchable)',
|
||||
),
|
||||
array(
|
||||
'key' => 'locale',
|
||||
'type' => 'text',
|
||||
'label' => 'User locale (e.g. zh_TW)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
||||
*
|
||||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
||||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
||||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
||||
*
|
||||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
||||
* SHA-256 hash is kept for SLO token comparison.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_sso_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'sso',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hub_global_user_id',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
||||
),
|
||||
array(
|
||||
'key' => 'picture_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Social / SSO profile picture URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_id_token_hash',
|
||||
'type' => 'text',
|
||||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
||||
),
|
||||
array(
|
||||
'key' => 'refresh_token_enc',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
||||
),
|
||||
array(
|
||||
'key' => 'token_expires_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_last_login_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last SSO-initiated login datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_login_count',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'SSO login count',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||||
*
|
||||
* Absorbing these here lets the Hook Bus short-circuit get_user_meta() /
|
||||
* update_user_meta() for the keys WP itself uses for display_name resolution
|
||||
* and the WP profile UI.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_core_profile_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'core_profile',
|
||||
array(
|
||||
array(
|
||||
'key' => 'nickname',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP nickname',
|
||||
),
|
||||
array(
|
||||
'key' => 'first_name',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP first name',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_name',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP last name',
|
||||
),
|
||||
array(
|
||||
'key' => 'description',
|
||||
'type' => 'textarea',
|
||||
'label' => 'WP user bio',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Social profile URLs (HivePress vendor-profile + WP user-contact-methods).
|
||||
*
|
||||
* 15 keys × 8 vendor users ≈ 120 EAV rows in this dataset.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_social_group(): void {
|
||||
$social_keys = array(
|
||||
'facebook',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'youtube',
|
||||
'tiktok',
|
||||
'linkedin',
|
||||
'vimeo',
|
||||
'vkontakte',
|
||||
'mastodon',
|
||||
'medium',
|
||||
'wordpress',
|
||||
'odnoklassniki',
|
||||
'pinterest',
|
||||
'dribbble',
|
||||
'github',
|
||||
);
|
||||
|
||||
// Social URLs typed as `textarea` (TEXT) — VARCHAR(255) silently truncates
|
||||
// long share URLs (utm params, deep paths) under WP's default non-strict
|
||||
// SQL mode. TEXT (64KB) covers all realistic URL lengths.
|
||||
$fields = array();
|
||||
foreach ( $social_keys as $key ) {
|
||||
$fields[] = array(
|
||||
'key' => $key,
|
||||
'type' => 'textarea',
|
||||
'label' => ucfirst( $key ) . ' profile URL',
|
||||
);
|
||||
}
|
||||
|
||||
TMDO_Entity_Registry::register_group( 'user', 'social', $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce billing & shipping address fields.
|
||||
*
|
||||
* Billing_email is searchable for guest-checkout customer lookups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_commerce_group(): void {
|
||||
$address_keys = array(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'company',
|
||||
'address_1',
|
||||
'address_2',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
);
|
||||
|
||||
$fields = array();
|
||||
foreach ( $address_keys as $key ) {
|
||||
$fields[] = array(
|
||||
'key' => 'billing_' . $key,
|
||||
'type' => 'text',
|
||||
'label' => 'WC billing ' . str_replace( '_', ' ', $key ),
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'shipping_' . $key,
|
||||
'type' => 'text',
|
||||
'label' => 'WC shipping ' . str_replace( '_', ' ', $key ),
|
||||
);
|
||||
}
|
||||
|
||||
// Email + phone are billing-only.
|
||||
$fields[] = array(
|
||||
'key' => 'billing_email',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WC billing email',
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'billing_phone',
|
||||
'type' => 'text',
|
||||
'label' => 'WC billing phone',
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'shipping_phone',
|
||||
'type' => 'text',
|
||||
'label' => 'WC shipping phone',
|
||||
);
|
||||
|
||||
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress per-user fields (favorites + avatar attachment).
|
||||
*
|
||||
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
|
||||
* the migration engine safe_unserialize()s it (allowed_classes=false to block
|
||||
* PHP-object injection) then the json type encodes back to a JSON array column.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_user_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'hp_user',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_favorited_listings',
|
||||
'type' => 'json',
|
||||
'label' => 'HivePress favorited listing IDs (array)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_image',
|
||||
'type' => 'text',
|
||||
'label' => 'HivePress avatar attachment ID',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
|
||||
* new user regardless of role. Without registering these, a fresh user
|
||||
* lands at ratio 1:9 (7 admin prefs + wp_capabilities + wp_user_level).
|
||||
* Once registered, Hook Bus intercepts `update_user_meta()` calls from
|
||||
* `wp_insert_user()` and routes them to `wp_wpdo_user_admin_prefs` flat
|
||||
* table → fresh user ratio drops to 1:2.
|
||||
*
|
||||
* Values are stored as text because WP itself stores 'true'/'false'
|
||||
* strings (not booleans), 'fresh' / 'classic' (admin_color enum strings),
|
||||
* and integer-as-string for `use_ssl`. Preserving WP's textual storage
|
||||
* shape ensures downstream code (e.g. theme switchers reading
|
||||
* `admin_color`) sees the exact same value as before.
|
||||
*
|
||||
* @since 2.8.4
|
||||
* @return void
|
||||
*/
|
||||
private static function register_admin_prefs_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'admin_prefs',
|
||||
array(
|
||||
array(
|
||||
'key' => 'rich_editing',
|
||||
'type' => 'text',
|
||||
'label' => 'Visual editor enabled (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'syntax_highlighting',
|
||||
'type' => 'text',
|
||||
'label' => 'Code editor syntax highlighting (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'comment_shortcuts',
|
||||
'type' => 'text',
|
||||
'label' => 'Comment moderation keyboard shortcuts (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'admin_color',
|
||||
'type' => 'text',
|
||||
'label' => 'Admin colour scheme (fresh/classic/etc)',
|
||||
),
|
||||
array(
|
||||
'key' => 'use_ssl',
|
||||
'type' => 'text',
|
||||
'label' => 'Force SSL on admin (0/1 as string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'show_admin_bar_front',
|
||||
'type' => 'text',
|
||||
'label' => 'Show admin bar on front-end (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'dismissed_wp_pointers',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Comma-separated dismissed pointer IDs',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
/**
|
||||
* Atomic points ledger manager for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages member points with atomic DB transactions.
|
||||
*
|
||||
* All balance mutations go through _transact(), which wraps
|
||||
* SELECT … FOR UPDATE + UPDATE membership + INSERT ledger inside a single
|
||||
* InnoDB transaction. This serialises concurrent debits and prevents the
|
||||
* classic double-spend race condition (two requests each read balance=100,
|
||||
* each deduct 60, each write balance=40).
|
||||
*
|
||||
* Table layout:
|
||||
* wp_wpdo_user_membership.points_balance — current snapshot balance
|
||||
* wp_wpdo_user_points_ledger — append-only journal
|
||||
*/
|
||||
final class TMDO_Points_Manager {
|
||||
|
||||
/**
|
||||
* Credit points to a user (positive delta).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Points to add (must be > 0).
|
||||
* @param string $reason Short reason code (≤60 chars).
|
||||
* @param int $ref_id Optional reference ID (order_id, post_id, …).
|
||||
* @param string $ref_type Optional reference type ('order', 'post', 'manual', …).
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array {
|
||||
if ( $delta <= 0 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'credit delta must be positive',
|
||||
);
|
||||
}
|
||||
return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit points from a user (negative delta applied internally).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Points to deduct (positive number; stored as negative).
|
||||
* @param string $reason Short reason code (≤60 chars).
|
||||
* @param int $ref_id Optional reference ID.
|
||||
* @param string $ref_type Optional reference type.
|
||||
* @param bool $allow_overdraft When true, debit proceeds even if balance < delta.
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array {
|
||||
if ( $delta <= 0 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'debit delta must be positive',
|
||||
);
|
||||
}
|
||||
return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current points balance for a user.
|
||||
*
|
||||
* Reads directly from the flat table, bypassing usermeta EAV.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @return int Balance (0 when user has no membership row).
|
||||
*/
|
||||
public static function get_balance( int $user_id ): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_user_membership';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$balance = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) ( $balance ?? 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve ledger entries for a user, newest-first.
|
||||
*
|
||||
* The idx_user_created covering index makes this O(log N) regardless of total rows.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $limit Max rows to return (default 20).
|
||||
* @param int $offset Row offset for pagination (default 0).
|
||||
* @return array<int, array{id:int, delta:int, balance_after:int, reason:string, ref_id:?int, ref_type:?string, created_at:string}>
|
||||
*/
|
||||
public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_user_points_ledger';
|
||||
|
||||
$limit = max( 1, min( 500, $limit ) );
|
||||
$offset = max( 0, $offset );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id,
|
||||
$limit,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $rows ?: array();
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger.
|
||||
*
|
||||
* All balance mutations (credit and debit) route through this single method.
|
||||
* TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise
|
||||
* InnoDB ignores the lock hint and the serialisation guarantee is lost.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Signed delta (positive = credit, negative = debit).
|
||||
* @param string $reason Reason code stored in ledger.
|
||||
* @param int $ref_id Reference ID (0 = none).
|
||||
* @param string $ref_type Reference type ('' = none).
|
||||
* @param bool $allow_overdraft Skip balance-floor check when true.
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array {
|
||||
global $wpdb;
|
||||
$mem_table = $wpdb->prefix . 'wpdo_user_membership';
|
||||
$ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger';
|
||||
|
||||
// Truncate reason to column width to avoid silent DB truncation.
|
||||
$reason = substr( $reason, 0, 60 );
|
||||
$ref_type = substr( $ref_type, 0, 30 );
|
||||
|
||||
TMDO_DB::begin();
|
||||
|
||||
try {
|
||||
// Lock the membership row for this user so concurrent writes wait.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$current_balance = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
$current_balance = (int) ( $current_balance ?? 0 );
|
||||
|
||||
$new_balance = $current_balance + $delta;
|
||||
|
||||
// Reject negative-result debits unless overdraft is explicitly allowed.
|
||||
if ( ! $allow_overdraft && $new_balance < 0 ) {
|
||||
TMDO_DB::rollback();
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'insufficient_balance',
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert with relative increment — prevents concurrent first-credit race.
|
||||
// SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous
|
||||
// first-credits both read balance=0. Using VALUES(points_balance) here means
|
||||
// InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and
|
||||
// applies a relative +delta instead of overwriting with an absolute value.
|
||||
$upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id,
|
||||
$delta
|
||||
)
|
||||
);
|
||||
|
||||
if ( false === $upserted ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'db_error',
|
||||
);
|
||||
}
|
||||
|
||||
// Re-read actual balance so ledger and return value are correct even when
|
||||
// ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$new_balance = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Append ledger row.
|
||||
$ledger_data = array(
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $new_balance,
|
||||
'reason' => $reason,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
$ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' );
|
||||
|
||||
if ( $ref_id ) {
|
||||
$ledger_data['ref_id'] = $ref_id;
|
||||
$ledger_fmt[] = '%d';
|
||||
}
|
||||
if ( '' !== $ref_type ) {
|
||||
$ledger_data['ref_type'] = $ref_type;
|
||||
$ledger_fmt[] = '%s';
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt );
|
||||
|
||||
if ( false === $inserted ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'db_error',
|
||||
);
|
||||
}
|
||||
|
||||
$ledger_id = (int) $wpdb->insert_id;
|
||||
|
||||
TMDO_DB::commit();
|
||||
|
||||
// Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before).
|
||||
do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'balance' => $new_balance,
|
||||
'ledger_id' => $ledger_id,
|
||||
);
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'exception',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
/**
|
||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||
*
|
||||
* Registers seven post entity groups designed to absorb the bulk of
|
||||
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
||||
*
|
||||
* Groups → flat tables (post_type targets):
|
||||
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
||||
* attachment → wp_wpdo_post_attachment (attachment)
|
||||
* wc_product → wp_wpdo_post_wc_product (product)
|
||||
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
|
||||
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
|
||||
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
|
||||
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
||||
*
|
||||
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
||||
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
||||
* Schema_Manager materializes the seven flat tables in init:1
|
||||
* via process_pending_migrations() (idempotent via schema_hash compare).
|
||||
*
|
||||
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
||||
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
|
||||
* as user entity bridge.
|
||||
*
|
||||
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
|
||||
* with entity_type='user'. User entity registration is owned exclusively
|
||||
* by TMDO_Member_Fields.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers seven post entity groups.
|
||||
*/
|
||||
final class TMDO_Post_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all post entity groups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_wp_core_group();
|
||||
self::register_attachment_group();
|
||||
self::register_wc_product_group();
|
||||
self::register_hp_listing_core_group();
|
||||
self::register_hp_request_core_group();
|
||||
self::register_hp_vendor_core_group();
|
||||
self::register_nav_menu_item_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* WP core post meta keys present across post_types.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wp_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wp_core',
|
||||
array(
|
||||
array(
|
||||
'key' => '_thumbnail_id',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured image attachment ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_page_template',
|
||||
'type' => 'text',
|
||||
'label' => 'Page template slug',
|
||||
),
|
||||
array(
|
||||
'key' => '_edit_last',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Last editor user ID',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment-specific meta keys (post_type=attachment).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_attachment_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'attachment',
|
||||
array(
|
||||
array(
|
||||
'key' => '_wp_attached_file',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Relative path of the attached file',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_metadata',
|
||||
'type' => 'json',
|
||||
'label' => 'Image dimensions / EXIF / sizes array',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_image_alt',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Alt text for accessibility',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_caption',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Attachment caption',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce product core meta keys (post_type=product).
|
||||
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wc_product_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wc_product',
|
||||
array(
|
||||
array(
|
||||
'key' => '_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Effective price',
|
||||
),
|
||||
array(
|
||||
'key' => '_regular_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Regular price',
|
||||
),
|
||||
array(
|
||||
'key' => '_sale_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Sale price',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Stock quantity',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
|
||||
'label' => 'Stock status',
|
||||
),
|
||||
array(
|
||||
'key' => '_sku',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Product SKU',
|
||||
),
|
||||
array(
|
||||
'key' => '_manage_stock',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Manage stock?',
|
||||
),
|
||||
array(
|
||||
'key' => '_backorders',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no', 'notify' ),
|
||||
'label' => 'Allow backorders?',
|
||||
),
|
||||
array(
|
||||
'key' => '_sold_individually',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Sold individually?',
|
||||
),
|
||||
array(
|
||||
'key' => '_virtual',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Virtual product?',
|
||||
),
|
||||
array(
|
||||
'key' => '_downloadable',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Downloadable?',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_class',
|
||||
'type' => 'text',
|
||||
'label' => 'Tax class slug',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_status',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'taxable', 'shipping', 'none' ),
|
||||
'label' => 'Tax status',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_limit',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download limit',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_expiry',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download expiry days',
|
||||
),
|
||||
array(
|
||||
'key' => '_product_version',
|
||||
'type' => 'text',
|
||||
'label' => 'WC version product was created on',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_average_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_review_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Review count',
|
||||
),
|
||||
array(
|
||||
'key' => 'total_sales',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Total sales count',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress listing core meta keys (post_type=hp_listing).
|
||||
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
|
||||
* copy-then-cutover the 230 dev10 rows to this group's table.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_listing_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_listing_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Listing price',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
|
||||
'label' => 'Listing status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_vendor',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured_time',
|
||||
'type' => 'integer',
|
||||
'label' => 'Featured-until unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'View counter',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress request core meta keys (post_type=hp_request).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_request_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_request_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
|
||||
'label' => 'Request status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Request author user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_budget',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Budget amount',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'View counter',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress vendor core meta keys (post_type=hp_vendor).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_vendor_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_vendor_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID (linked WP user)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_nav_menu_item_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'nav_menu_item',
|
||||
array(
|
||||
array(
|
||||
'key' => '_menu_item_type',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'post_type', 'taxonomy', 'custom', 'post_type_archive' ),
|
||||
'label' => 'Menu item linking type',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_menu_item_parent',
|
||||
'type' => 'integer',
|
||||
'label' => 'Parent menu item ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_object_id',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Linked object ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_object',
|
||||
'type' => 'text',
|
||||
'label' => 'Linked object slug (post_type/taxonomy)',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_target',
|
||||
'type' => 'text',
|
||||
'label' => 'Link target attribute',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_classes',
|
||||
'type' => 'json',
|
||||
'label' => 'CSS classes array',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_xfn',
|
||||
'type' => 'text',
|
||||
'label' => 'XFN relationship',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_url',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Custom URL (for type=custom)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Garbage_Filter — Block known-garbage writes to
|
||||
* wp_termmeta / wp_commentmeta at the metadata filter layer (v2.12.1 Phase 1).
|
||||
*
|
||||
* Phase 0 (v2.12.0) provided cleanup CLI to delete historical garbage. This
|
||||
* filter prevents the same garbage from accumulating again by intercepting
|
||||
* writes via WordPress metadata filters (`add_term_metadata`, etc.) and
|
||||
* silently dropping them — short-circuiting the database INSERT entirely.
|
||||
*
|
||||
* Targets (must align with TMDO_Termmeta_Cleaner / TMDO_Commentmeta_Cleaner):
|
||||
*
|
||||
* wp_termmeta + wp_commentmeta:
|
||||
* - meta_key matching `_wxr_import_*` (WordPress importer residue —
|
||||
* written once during WXR import, never read afterward)
|
||||
* - meta_key matching `_2meet_demo_*` (project-specific demo markers,
|
||||
* safe to drop and re-seed)
|
||||
*
|
||||
* wp_commentmeta only (orphan post-meta keys):
|
||||
* - 8 hardcoded keys from TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS
|
||||
* — these are bugs / typos writing post-domain meta to comment table
|
||||
*
|
||||
* Read paths are NOT filtered. Existing rows in wp_*meta still resolve normally
|
||||
* via standard WP metadata API; once cleanup CLI runs, reads naturally return
|
||||
* empty. This minimizes risk of breaking any reader code that still expects
|
||||
* the keys (none should, but defense in depth).
|
||||
*
|
||||
* Init: hooks registered on `init` priority 5 from TMDO_Core (after
|
||||
* HivePress's plugins_loaded:5 boot, before main entity bridge filters at 10).
|
||||
*
|
||||
* Lessons applied from v2.11.7 — this class is added to
|
||||
* TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST so `wp wpdo conflict-scan` does not
|
||||
* report a false positive when both Hook Bus and this filter run on the same
|
||||
* `add_term_metadata` / `add_comment_metadata` hook.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block known-garbage writes to wp_termmeta / wp_commentmeta.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Garbage_Filter {
|
||||
|
||||
/** Option key for admin toggle. */
|
||||
public const OPT_ENABLED = 'wpdo_term_comment_garbage_filter_enabled';
|
||||
|
||||
/** Telemetry option: count of garbage writes dropped (rolling 24h cumulative). */
|
||||
public const OPT_DROPPED_COUNT = 'wpdo_term_comment_garbage_drops_24h';
|
||||
|
||||
/** Telemetry option: timestamp of the last reset of the 24h counter. */
|
||||
public const OPT_DROPPED_RESET_AT = 'wpdo_term_comment_garbage_drops_reset_at';
|
||||
|
||||
/**
|
||||
* Pattern prefixes that trigger a drop on writes to BOTH wp_termmeta and
|
||||
* wp_commentmeta. Aligned with cleanup CLI `--target` buckets.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const SHARED_DROP_PREFIXES = array(
|
||||
'_wxr_import_',
|
||||
'_2meet_demo_',
|
||||
);
|
||||
|
||||
/**
|
||||
* Exact meta_keys that are dropped only for wp_commentmeta writes
|
||||
* (post-domain keys mistakenly written to comment table — always a bug).
|
||||
*
|
||||
* Must stay in sync with TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const COMMENT_ONLY_ORPHAN_KEYS = array(
|
||||
'_hp_price',
|
||||
'_hp_status',
|
||||
'_hp_featured',
|
||||
'_hp_verified',
|
||||
'_hp_view_count',
|
||||
'_thumbnail_id',
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register filters. Called from TMDO_Core::run() on init:5.
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Term meta writes — priority 9 (before Hook Bus at 10).
|
||||
add_filter( 'add_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
add_filter( 'update_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
|
||||
// Comment meta writes.
|
||||
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the admin toggle. Defaults to enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the shared drop rules
|
||||
* (applicable to both term and comment meta).
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_shared_garbage_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( self::SHARED_DROP_PREFIXES as $prefix ) {
|
||||
if ( str_starts_with( $meta_key, $prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the comment-only orphan post-meta drop.
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_comment_orphan_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
return in_array( $meta_key, self::COMMENT_ONLY_ORPHAN_KEYS, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_term_metadata / update_term_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow (write to wp_termmeta).
|
||||
* Returns true → short-circuit; WP treats as success without DB write.
|
||||
*
|
||||
* @param mixed $check Filter accumulator (null at our priority).
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_comment_metadata / update_comment_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow.
|
||||
* Returns true → short-circuit (silent drop).
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) || self::is_comment_orphan_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the rolling 24h drop counter.
|
||||
*
|
||||
* Auto-resets every 24h based on a stored timestamp; this avoids
|
||||
* unbounded growth and gives the admin status panel a meaningful
|
||||
* "drops in last day" indicator.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function increment_drop_counter(): void {
|
||||
$now = time();
|
||||
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
|
||||
|
||||
if ( 0 === $reset_at || ( $now - $reset_at ) >= DAY_IN_SECONDS ) {
|
||||
update_option( self::OPT_DROPPED_COUNT, 1, false );
|
||||
update_option( self::OPT_DROPPED_RESET_AT, $now, false );
|
||||
return;
|
||||
}
|
||||
|
||||
$count = (int) get_option( self::OPT_DROPPED_COUNT, 0 );
|
||||
update_option( self::OPT_DROPPED_COUNT, $count + 1, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current 24h rolling drop counter (for admin status panel).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_drop_count_24h(): int {
|
||||
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
|
||||
if ( 0 === $reset_at || ( time() - $reset_at ) >= DAY_IN_SECONDS ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) get_option( self::OPT_DROPPED_COUNT, 0 );
|
||||
}
|
||||
}
|
||||
@@ -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 : '=';
|
||||
}
|
||||
}
|
||||
@@ -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 : '=';
|
||||
}
|
||||
}
|
||||
@@ -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 ) . "'";
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user