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:
2026-09-26 19:41:49 +08:00
parent 8c049f0cf2
commit b338925605
6 changed files with 537 additions and 42 deletions
+127 -33
View File
@@ -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 );
}
}