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. */
|
/** Object cache group for found-rows counts. */
|
||||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
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?".
|
* Per-request answer to "are this group's copies usable?".
|
||||||
*
|
*
|
||||||
@@ -77,7 +80,9 @@ class TMDO_Post_Query_Router {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function register_hooks(): 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_join', array( $this, 'posts_join' ), 10, 2 );
|
||||||
add_filter( 'posts_where', array( $this, 'posts_where' ), 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_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
||||||
@@ -103,55 +108,52 @@ class TMDO_Post_Query_Router {
|
|||||||
return;
|
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' );
|
$post_types = (array) $query->get( 'post_type' );
|
||||||
if ( empty( $post_types ) ) {
|
if ( 1 !== count( $post_types ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$pt = (string) reset( $post_types );
|
||||||
|
|
||||||
$raw_meta_query = (array) $query->get( 'meta_query' );
|
$raw_meta_query = (array) $query->get( 'meta_query' );
|
||||||
if ( empty( $raw_meta_query ) ) {
|
if ( empty( $raw_meta_query ) ) {
|
||||||
return;
|
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.
|
$routed = array(); // Keyed by post_type.
|
||||||
$remaining = array();
|
$remaining = array();
|
||||||
|
|
||||||
foreach ( $raw_meta_query as $k => $clause ) {
|
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;
|
$remaining[ $k ] = $clause;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$matched = false;
|
$field = self::field_for( $clause, $pt );
|
||||||
foreach ( $post_types as $pt ) {
|
if ( ! $field ) {
|
||||||
$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 ) {
|
|
||||||
$remaining[ $k ] = $clause;
|
$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 ) ) {
|
if ( empty( $routed ) ) {
|
||||||
@@ -411,6 +413,95 @@ class TMDO_Post_Query_Router {
|
|||||||
return self::$copies_usable[ $group ];
|
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.
|
* 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 '=').
|
* @return string Sanitized operator (defaults to '=').
|
||||||
*/
|
*/
|
||||||
private static function sanitize_compare( string $compare ): string {
|
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 ) );
|
$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;
|
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 );
|
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
array(
|
array(
|
||||||
'post_type' => 'rc_item',
|
'post_type' => 'rc_item',
|
||||||
'post_status' => 'publish',
|
'post_status' => 'publish',
|
||||||
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ),
|
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
|
||||||
),
|
),
|
||||||
$vars
|
$vars
|
||||||
) as $k => $v ) {
|
) as $k => $v ) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
$query = new WP_Query();
|
$query = new WP_Query();
|
||||||
$query->set( 'post_type', 'fr_item' );
|
$query->set( 'post_type', 'fr_item' );
|
||||||
$query->set( 'post_status', 'publish' );
|
$query->set( 'post_status', 'publish' );
|
||||||
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) );
|
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ) );
|
||||||
$router = new TMDO_Post_Query_Router();
|
$router = new TMDO_Post_Query_Router();
|
||||||
$router->pre_get_posts( $query );
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -100,7 +100,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -123,7 +123,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
@@ -177,7 +177,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -202,7 +202,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50' ),
|
array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
'wpdo_post_clauses' => array(),
|
'wpdo_post_clauses' => array(),
|
||||||
) );
|
) );
|
||||||
@@ -221,7 +221,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
|
array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
|
|
||||||
@@ -242,4 +242,156 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── all-or-nothing: native unless every registered clause is routable ────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pre_get_posts in aeav_only and assert the query was left to WordPress.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars Query vars.
|
||||||
|
*/
|
||||||
|
private function assert_not_routed( array $vars, string $why ): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$query = $this->make_query( $vars );
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' );
|
||||||
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_or_relation_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => '_stock', 'value' => '1' ),
|
||||||
|
array( 'key' => '_sku', 'value' => 'x' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'OR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_several_post_types_are_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => array( 'product', 'post' ),
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
'several post types'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_range_on_a_numeric_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ),
|
||||||
|
),
|
||||||
|
'CHAR > on DECIMAL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ),
|
||||||
|
),
|
||||||
|
'CHAR = on DECIMAL (native "10.00" != "10")'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_numeric_type_on_a_text_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
'NUMERIC on VARCHAR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_compare_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ),
|
||||||
|
),
|
||||||
|
'REGEXP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'one unroutable clause'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nested_group_with_a_registered_key_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => '_price', 'compare' => 'NOT EXISTS' ),
|
||||||
|
array( 'key' => '_price', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'nested group'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_orderby_on_a_named_registered_clause_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
|
||||||
|
'orderby' => array( 'price' => 'ASC' ),
|
||||||
|
),
|
||||||
|
'orderby names a routed clause'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$query = $this->make_query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3' ),
|
||||||
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
$this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_like_matches_an_escaped_substring(): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$router = new WPDO_Post_Query_Router();
|
||||||
|
$query = $this->make_query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
|||||||
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
||||||
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
||||||
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
||||||
|
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE) |
|
||||||
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||||
|
|
||||||
## Tools (`tools/`)
|
## Tools (`tools/`)
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: a routed WP_Query returns exactly what the native meta_query returns.
|
||||||
|
*
|
||||||
|
* Bugs (dev36 qbench, 2026-09-26): the post router pulled registered clauses
|
||||||
|
* out of any meta_query and ANDed them, so OR relations (Q04, Q16), queries on
|
||||||
|
* several post types (Q08), untyped comparisons on numeric columns (Q14,
|
||||||
|
* native compares strings) and orderby on a named clause (Q15) returned other
|
||||||
|
* posts than WordPress would; LIKE compared without wildcards (Q06).
|
||||||
|
*
|
||||||
|
* Every case runs the same query twice on fixture posts — router hooks off,
|
||||||
|
* then on with post mode shadow_read — and compares the ids in order.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a WP_Query restricted to $ids, optionally through the post router.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args Query args (meta_query, post_type, orderby …).
|
||||||
|
* @param int[] $ids Fixture posts to search within.
|
||||||
|
* @param bool $routed Hook a TMDO_Post_Query_Router for this query.
|
||||||
|
* @return array{ids:int[], routed:bool}
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_query( array $args, array $ids, bool $routed ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
if ( $routed ) {
|
||||||
|
$router->register_hooks();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$wpdb->last_error = '';
|
||||||
|
$q = new WP_Query(
|
||||||
|
$args + array(
|
||||||
|
'post__in' => $ids,
|
||||||
|
'post_status' => 'any',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'orderby' => 'ID',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
'cache_results' => false,
|
||||||
|
'suppress_filters' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_groupby', 'posts_orderby', 'posts_request', 'found_posts_query' ) as $hook ) {
|
||||||
|
$priority = has_filter( $hook, array( $router, $hook ) );
|
||||||
|
if ( false !== $priority ) {
|
||||||
|
remove_filter( $hook, array( $router, $hook ), $priority );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' );
|
||||||
|
return array(
|
||||||
|
'ids' => array_map( 'intval', $q->posts ),
|
||||||
|
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create posts of $post_type with the given meta, in post mode shadow_read
|
||||||
|
* (so the Hook Bus keeps the flat rows in step).
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type.
|
||||||
|
* @param array<string, array<string, mixed>> $posts Label => meta.
|
||||||
|
* @return array<string, int> Label => post id.
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_posts( string $post_type, array $posts ): array {
|
||||||
|
$ids = array();
|
||||||
|
foreach ( $posts as $label => $meta ) {
|
||||||
|
$ids[ $label ] = TMDO_Live_Fixtures::post( $post_type );
|
||||||
|
foreach ( $meta as $key => $value ) {
|
||||||
|
update_post_meta( $ids[ $label ], $key, $value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listing fixtures shared by the hp_listing cases.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_listings(): array {
|
||||||
|
foreach ( array( 'hp_price', 'hp_verified', 'hp_featured', 'hp_hourly_rate' ) as $key ) {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' );
|
||||||
|
}
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
return tmdo_live_router_posts(
|
||||||
|
'hp_listing',
|
||||||
|
array(
|
||||||
|
'L1' => array( 'hp_price' => '10000', 'hp_featured' => '1', 'hp_verified' => '0', 'hp_hourly_rate' => '40' ),
|
||||||
|
'L2' => array( 'hp_price' => '9500', 'hp_verified' => '1', 'hp_hourly_rate' => '30' ),
|
||||||
|
'L3' => array( 'hp_price' => '500', 'hp_featured' => '0', 'hp_hourly_rate' => '45' ),
|
||||||
|
'L4' => array( 'hp_price' => '20000', 'tmdo_live_unregistered' => '0' ),
|
||||||
|
'L5' => array(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the routed query equals native; optionally that it was routed at all.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args Query args.
|
||||||
|
* @param int[] $ids Fixture ids.
|
||||||
|
* @param bool|null $expect_routed true / false to assert, null to ignore.
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null ): void {
|
||||||
|
$native = tmdo_live_router_query( $args, $ids, false );
|
||||||
|
$routed = tmdo_live_router_query( $args, $ids, true );
|
||||||
|
tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids (in order) vs native' );
|
||||||
|
if ( null !== $expect_routed ) {
|
||||||
|
tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q04 OR across registered keys returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => 'hp_featured', 'value' => '1' ),
|
||||||
|
array( 'key' => 'hp_verified', 'value' => '1' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q16 OR mixing a registered and an unregistered key returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => 'tmdo_live_unregistered', 'value' => '0' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q14 untyped comparison on a numeric column keeps string semantics',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q15 orderby on a named clause keeps the native order',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ),
|
||||||
|
'price' => array( 'key' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ),
|
||||||
|
),
|
||||||
|
'orderby' => 'price',
|
||||||
|
'order' => 'ASC',
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q08 several post types return the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_budget', 'hp_request' );
|
||||||
|
$r = tmdo_live_router_posts( 'hp_request', array( 'R1' => array( 'hp_budget' => '90000' ) ) );
|
||||||
|
update_post_meta( $l['L2'], 'hp_budget', '85000' );
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => array( 'hp_listing', 'hp_request' ),
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
array_merge( array_values( $l ), array_values( $r ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'typed AND on registered keys is routed and returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => 'hp_price', 'value' => array( 9000, 15000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => 'hp_verified', 'value' => '1' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q06 LIKE matches substrings and escapes wildcards, routed',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', '_sku', 'product' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
$p = tmdo_live_router_posts(
|
||||||
|
'product',
|
||||||
|
array(
|
||||||
|
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
|
||||||
|
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
|
||||||
|
'P3' => array( '_sku' => 'tmdo-live-B2-1' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ),
|
||||||
|
),
|
||||||
|
array_values( $p ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user