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,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 : '=';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user