Files
2meet-data-optimizer/includes/query/class-tmdo-query-interceptor-base.php
T
wpdev d36bb954d1 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
2026-07-31 05:06:36 +08:00

359 lines
11 KiB
PHP

<?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 : '=';
}
}