b338925605
- Leave the query to WordPress on OR, nested groups with registered keys, several post types, orderby on a named routed clause, and compare/type pairs whose flat-column semantics differ from native (untyped numeric ranges, CHAR on decimals, NUMERIC on text) - Wrap LIKE values as %esc_like%; hook pre_get_posts at priority 1000 so plugin clauses (HivePress) are seen - Verify: wp eval-file tests/live/run.php router (7/7); qbench Q04/Q06/Q08/Q14/Q16 == native on dev36 - PR: PR-060
568 lines
20 KiB
PHP
568 lines
20 KiB
PHP
<?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
|
|
*
|
|
* Status / date on the flat table (O-1): when the group's post_status /
|
|
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
|
* a single-post-type search with explicit statuses and date ordering
|
|
* INNER JOINs the table, filters the copies, orders by the copied
|
|
* post_date and skips GROUP BY (one row per post), so MySQL can walk
|
|
* `idx_post_status_date` and stop at the LIMIT.
|
|
*
|
|
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
|
|
* match inside the page query); found_posts_query answers from a COUNT of the
|
|
* same query, kept in the object cache until posts' last_changed moves (core
|
|
* post saves, and flat writes via TMDO_Hook_Bus).
|
|
*
|
|
* 🔒 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
|
|
*/
|
|
|
|
declare(strict_types=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';
|
|
|
|
/** Query var holding the status / date copy route, when one applies. */
|
|
private const COPY_VAR = 'wpdo_post_copy_route';
|
|
|
|
/** Query var holding the COUNT for found_posts_query. */
|
|
private const COUNT_VAR = 'wpdo_post_count_sql';
|
|
|
|
/** Object cache group for found-rows counts. */
|
|
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
|
|
|
/** Comparison operators the router can express on a flat column. */
|
|
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
|
|
|
|
/**
|
|
* Per-request answer to "are this group's copies usable?".
|
|
*
|
|
* @var array<string, bool>
|
|
*/
|
|
private static array $copies_usable = array();
|
|
|
|
/**
|
|
* Register all WP_Query hooks.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function register_hooks(): void {
|
|
// Late, so meta clauses other plugins add at the default priority
|
|
// (e.g. HivePress search filters and sorting) are already in the query.
|
|
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 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 );
|
|
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
|
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
|
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 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;
|
|
}
|
|
|
|
// One post type only: with several, a clause may belong to one type's
|
|
// group and the others must still match through wp_postmeta.
|
|
$post_types = (array) $query->get( 'post_type' );
|
|
if ( 1 !== count( $post_types ) ) {
|
|
return;
|
|
}
|
|
$pt = (string) reset( $post_types );
|
|
|
|
$raw_meta_query = (array) $query->get( 'meta_query' );
|
|
if ( empty( $raw_meta_query ) ) {
|
|
return;
|
|
}
|
|
|
|
// All or nothing: routed clauses are ANDed into the WHERE, so the
|
|
// query is routed only when every registered clause can be, and
|
|
// the remaining (unregistered) clauses are ANDed too.
|
|
$relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) );
|
|
$orderby = $query->get( 'orderby' );
|
|
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', (string) $orderby );
|
|
$routed = array(); // Keyed by post_type.
|
|
$remaining = array();
|
|
|
|
foreach ( $raw_meta_query as $k => $clause ) {
|
|
if ( 'relation' === $k || ! is_array( $clause ) ) {
|
|
$remaining[ $k ] = $clause;
|
|
continue;
|
|
}
|
|
if ( ! isset( $clause['key'] ) ) {
|
|
if ( self::has_registered_key( $clause, $pt ) ) {
|
|
return; // Nested group.
|
|
}
|
|
$remaining[ $k ] = $clause;
|
|
continue;
|
|
}
|
|
|
|
$field = self::field_for( $clause, $pt );
|
|
if ( ! $field ) {
|
|
$remaining[ $k ] = $clause;
|
|
continue;
|
|
}
|
|
// Unroutable, under OR, or named by orderby (the name would be lost).
|
|
$route = self::route_clause( $clause, $field );
|
|
if ( ! $route || 'AND' !== $relation || ( is_string( $k ) && in_array( $k, $named, true ) ) ) {
|
|
return;
|
|
}
|
|
$routed[ $pt ][] = $route;
|
|
}
|
|
|
|
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 );
|
|
|
|
$copy = self::copy_route( $query, $routed );
|
|
if ( $copy ) {
|
|
$query->set( self::COPY_VAR, $copy );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
|
$join .= " {$type} 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 );
|
|
}
|
|
}
|
|
|
|
$copy = $query->get( self::COPY_VAR );
|
|
if ( ! empty( $copy ) ) {
|
|
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
|
|
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
|
|
}
|
|
|
|
return $where;
|
|
}
|
|
|
|
/**
|
|
* Order by the flat table's post_date copy when the copy route applies.
|
|
*
|
|
* @param string|null $orderby Current ORDER BY SQL.
|
|
* @param \WP_Query $query Current WP_Query.
|
|
* @return string Modified ORDER BY SQL.
|
|
*/
|
|
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
|
$orderby = (string) ( $orderby ?? '' );
|
|
$copy = $query->get( self::COPY_VAR );
|
|
if ( empty( $copy ) ) {
|
|
return $orderby;
|
|
}
|
|
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
|
}
|
|
|
|
/**
|
|
* 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 ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
|
return $groupby;
|
|
}
|
|
|
|
global $wpdb;
|
|
if ( '' === trim( $groupby ) ) {
|
|
$groupby = "`{$wpdb->posts}`.`ID`";
|
|
}
|
|
|
|
return $groupby;
|
|
}
|
|
|
|
/**
|
|
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
|
*
|
|
* @param string|null $request Full SELECT.
|
|
* @param \WP_Query $query Current WP_Query.
|
|
* @return string
|
|
*/
|
|
public function posts_request( ?string $request, \WP_Query $query ): string {
|
|
$request = (string) ( $request ?? '' );
|
|
if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) {
|
|
return $request;
|
|
}
|
|
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
|
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
|
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
|
return $request;
|
|
}
|
|
|
|
/**
|
|
* Answer found rows from the cached COUNT of a routed search.
|
|
*
|
|
* @param string|null $sql Default 'SELECT FOUND_ROWS()'.
|
|
* @param \WP_Query $query Current WP_Query.
|
|
* @return string
|
|
*/
|
|
public function found_posts_query( ?string $sql, \WP_Query $query ): string {
|
|
$count_sql = $query->get( self::COUNT_VAR );
|
|
if ( empty( $count_sql ) ) {
|
|
return (string) ( $sql ?? '' );
|
|
}
|
|
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
|
|
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
|
if ( false === $total ) {
|
|
global $wpdb;
|
|
$total = (int) $wpdb->get_var( $count_sql );
|
|
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
|
}
|
|
return 'SELECT ' . (int) $total;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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' );
|
|
}
|
|
|
|
/**
|
|
* Forget per-request copy checks (tests, or after `post-status-sync`).
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function flush_copy_cache(): void {
|
|
self::$copies_usable = array();
|
|
}
|
|
|
|
/**
|
|
* The status / date copy route for a routed query, or [] when it does not apply.
|
|
*
|
|
* @param \WP_Query $query Current WP_Query.
|
|
* @param array<string, array<int, array<string, mixed>>> $routed Routed clauses keyed by post type.
|
|
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
|
|
*/
|
|
private static function copy_route( \WP_Query $query, array $routed ): array {
|
|
if ( 1 !== count( $routed ) ) {
|
|
return array();
|
|
}
|
|
$post_type = (string) array_key_first( $routed );
|
|
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
|
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) {
|
|
return array();
|
|
}
|
|
$statuses = $query->get( 'post_status' );
|
|
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
|
|
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
|
|
return array();
|
|
}
|
|
return array(
|
|
'post_type' => $post_type,
|
|
'group' => $groups[0],
|
|
'statuses' => $statuses,
|
|
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the copy route applies to this post type's join.
|
|
*
|
|
* @param \WP_Query $query Current WP_Query.
|
|
* @param string $post_type Post type slug.
|
|
* @return bool
|
|
*/
|
|
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
|
|
$copy = $query->get( self::COPY_VAR );
|
|
return ! empty( $copy ) && $copy['post_type'] === $post_type;
|
|
}
|
|
|
|
/**
|
|
* Copies are marked ready and no row lacks them (bulk writes that bypass
|
|
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
|
|
* One index probe per group per request.
|
|
*
|
|
* @param string $group Entity group name.
|
|
* @return bool
|
|
*/
|
|
private static function copies_usable( string $group ): bool {
|
|
if ( ! isset( self::$copies_usable[ $group ] ) ) {
|
|
global $wpdb;
|
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
|
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
|
|
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
|
|
&& TMDO_Schema_Manager::table_exists( $table )
|
|
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
|
|
}
|
|
return self::$copies_usable[ $group ];
|
|
}
|
|
|
|
/**
|
|
* The registered field a clause targets for this post type, or null.
|
|
*
|
|
* @param array<string, mixed> $clause Meta query clause.
|
|
* @param string $post_type Post type slug.
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function field_for( array $clause, string $post_type ): ?array {
|
|
if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) {
|
|
return null;
|
|
}
|
|
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type );
|
|
if ( ! $field ) {
|
|
return null;
|
|
}
|
|
// Either cross-cutting wp_core or one of the post type's own groups.
|
|
$group = $field['group'] ?? '';
|
|
return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null;
|
|
}
|
|
|
|
/**
|
|
* Whether a nested meta query group references a registered key.
|
|
*
|
|
* @param array<int|string, mixed> $group Nested group.
|
|
* @param string $post_type Post type slug.
|
|
* @return bool
|
|
*/
|
|
private static function has_registered_key( array $group, string $post_type ): bool {
|
|
foreach ( $group as $clause ) {
|
|
if ( ! is_array( $clause ) ) {
|
|
continue;
|
|
}
|
|
if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The routed form of a clause, or null when the flat column would not
|
|
* compare the way WordPress compares the meta_value string.
|
|
*
|
|
* Native casts meta_value by the clause type (CHAR when absent); flat
|
|
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
|
|
* CHAR; numeric columns with a numeric type (no LIKE); integer columns
|
|
* with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way).
|
|
*
|
|
* @param array<string, mixed> $clause Meta query clause.
|
|
* @param array<string, mixed> $field Registered field.
|
|
* @return array{group:string, column:string, value:mixed, compare:string, type:string}|null
|
|
*/
|
|
private static function route_clause( array $clause, array $field ): ?array {
|
|
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
|
|
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
|
|
$type = '' === $type ? 'CHAR' : $type;
|
|
$value = $clause['value'] ?? '';
|
|
$kind = $field['type'] ?? 'text';
|
|
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
|
|
|
|
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
|
|
return null;
|
|
}
|
|
|
|
$numeric_type = 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
|
|
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
|
|
$ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true );
|
|
} elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) {
|
|
$ok = 'CHAR' === $type;
|
|
} elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) {
|
|
$ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true );
|
|
} else {
|
|
$ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type
|
|
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
|
|
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
|
|
}
|
|
if ( ! $ok ) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'group' => (string) $field['group'],
|
|
'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ),
|
|
'value' => $value,
|
|
'compare' => $compare,
|
|
'type' => $type,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
$compare = strtoupper( trim( $compare ) );
|
|
return in_array( $compare, self::COMPARES, 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;
|
|
}
|
|
|
|
if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) {
|
|
$value = '%' . $wpdb->esc_like( (string) $value ) . '%';
|
|
}
|
|
|
|
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
|
|
}
|
|
}
|