fix(2meet-data-optimizer): route a meta query only when every registered clause is routable
- 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
This commit is contained in:
@@ -64,6 +64,9 @@ class TMDO_Post_Query_Router {
|
||||
/** 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?".
|
||||
*
|
||||
@@ -77,7 +80,9 @@ class TMDO_Post_Query_Router {
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
|
||||
// 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 );
|
||||
@@ -103,55 +108,52 @@ class TMDO_Post_Query_Router {
|
||||
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 ( empty( $post_types ) ) {
|
||||
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 ) || ! isset( $clause['key'] ) ) {
|
||||
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;
|
||||
}
|
||||
|
||||
$matched = false;
|
||||
foreach ( $post_types as $pt ) {
|
||||
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt );
|
||||
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'] ?? '';
|
||||
$is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true );
|
||||
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 ) {
|
||||
$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 ) ) {
|
||||
@@ -411,6 +413,95 @@ class TMDO_Post_Query_Router {
|
||||
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.
|
||||
*
|
||||
@@ -429,9 +520,8 @@ class TMDO_Post_Query_Router {
|
||||
* @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 : '=';
|
||||
return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,6 +558,10 @@ class TMDO_Post_Query_Router {
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user