feat(2meet-data-optimizer): route meta orderby including the HivePress sort clause

- Sort shape (relation OR of NOT EXISTS + EXISTS on one key, named in orderby): drop it from meta_query, LEFT JOIN the flat table and order by its column, ties by ID
- Named routed clause as the sole orderby, and meta_key + meta_value_num (meta_value on string columns) with IS NOT NULL; stay native when the column would not order like CAST(meta_value AS type), orderby has several keys, or meta_value filters
- Verify: tests/live/run.php router (11/11); fe.sh price__asc / hourly_rate__desc / rating__desc ids == native
- PR: PR-062
This commit is contained in:
2026-09-26 20:04:23 +08:00
parent f2e261c522
commit ec75721976
5 changed files with 348 additions and 41 deletions
+152 -15
View File
@@ -60,6 +60,9 @@ class TMDO_Post_Query_Router {
/** Query var holding the status / date copy route, when one applies. */
private const COPY_VAR = 'wpdo_post_copy_route';
/** Query var holding the flat column a routed meta orderby sorts on. */
private const SORT_VAR = 'wpdo_post_sort';
/** Query var holding the COUNT for found_posts_query. */
private const COUNT_VAR = 'wpdo_post_count_sql';
@@ -118,18 +121,41 @@ class TMDO_Post_Query_Router {
$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 );
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) );
$sort_name = 1 === count( $named ) ? (string) reset( $named ) : '';
$direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC';
$routed = array(); // Keyed by post_type.
$remaining = array();
$sort = array();
// meta_key + orderby meta_value(_num): native INNER JOINs the key
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
$meta_key = $query->get( 'meta_key' );
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $take_key ) {
$field = self::field_for( array( 'key' => $meta_key ), $pt );
$type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' );
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
'type' => $type,
),
$field
);
if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) {
return;
}
$routed[ $pt ][] = $route;
$sort = self::sort_for( $pt, $route, $direction );
}
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) ) {
@@ -137,6 +163,17 @@ class TMDO_Post_Query_Router {
continue;
}
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
if ( $field ) {
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
if ( ! $route || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
continue;
}
if ( self::has_registered_key( $clause, $pt ) ) {
return; // Nested group.
}
@@ -149,17 +186,30 @@ class TMDO_Post_Query_Router {
$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 ) ) ) {
if ( ! $route || 'AND' !== $relation ) {
return;
}
// Named by orderby: only as the sole ordering, on a column that
// orders like native (otherwise the name would be lost).
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
}
$routed[ $pt ][] = $route;
}
if ( empty( $routed ) ) {
if ( empty( $routed ) && empty( $sort ) ) {
return;
}
if ( $take_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
$query->set( self::SORT_VAR, $sort );
}
// Preserve relation if surviving clauses still have it.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
@@ -185,14 +235,20 @@ class TMDO_Post_Query_Router {
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;
$sort = $query->get( self::SORT_VAR );
$pairs = array(); // Pairs of post type and group.
foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) {
foreach ( array_column( $clauses, 'group' ) as $group ) {
$pairs[] = array( $post_type, $group );
}
}
if ( ! empty( $sort ) ) {
$pairs[] = array( $sort['post_type'], $sort['group'] );
}
global $wpdb;
foreach ( $routed as $post_type => $clauses ) {
foreach ( array_unique( array_column( $clauses, 'group' ) ) as $group ) {
foreach ( $pairs as list( $post_type, $group ) ) {
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
@@ -206,7 +262,6 @@ class TMDO_Post_Query_Router {
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
}
return $join;
}
@@ -248,7 +303,8 @@ class TMDO_Post_Query_Router {
}
/**
* Order by the flat table's post_date copy when the copy route applies.
* Order by the routed meta orderby column (ties by ID), or 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.
@@ -256,6 +312,12 @@ class TMDO_Post_Query_Router {
*/
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
$orderby = (string) ( $orderby ?? '' );
$sort = $query->get( self::SORT_VAR );
if ( ! empty( $sort ) ) {
global $wpdb;
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
}
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $orderby;
@@ -272,7 +334,8 @@ class TMDO_Post_Query_Router {
*/
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' ) ) {
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( ! $routed || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) {
return $request;
}
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
@@ -454,7 +517,7 @@ class TMDO_Post_Query_Router {
return null;
}
$numeric_type = 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
$numeric_type = self::is_numeric_type( $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 ) ) {
@@ -479,6 +542,80 @@ class TMDO_Post_Query_Router {
);
}
/**
* Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …).
*
* @param string $type Upper-cased clause type.
* @return bool
*/
private static function is_numeric_type( string $type ): bool {
return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
}
/**
* Whether ordering by the flat column matches native ordering by
* CAST(meta_value AS type): numbers by a numeric type, strings by CHAR.
*
* @param array{type:string} $route Routed clause.
* @param array<string, mixed> $field Registered field.
* @return bool
*/
private static function sortable( array $route, array $field ): bool {
$kind = $field['type'] ?? 'text';
if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) {
return self::is_numeric_type( $route['type'] );
}
return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type'];
}
/**
* The ordering a routed meta orderby resolves to.
*
* @param string $post_type Post type slug.
* @param array{group:string, column:string} $route Routed clause.
* @param string $direction ASC or DESC.
* @return array{post_type:string, group:string, column:string, order:string}
*/
private static function sort_for( string $post_type, array $route, string $direction ): array {
return array(
'post_type' => $post_type,
'group' => $route['group'],
'column' => $route['column'],
'order' => $direction,
);
}
/**
* The clause name of a "sort by a key, keep posts without it" group —
* relation OR of exactly NOT EXISTS and EXISTS on one key and type, one
* of them named (HivePress search sorting) — or '' for any other group.
*
* @param array<int|string, mixed> $group Nested meta query group.
* @return string
*/
private static function sort_shape( array $group ): string {
if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) {
return '';
}
unset( $group['relation'] );
if ( 2 !== count( $group ) ) {
return '';
}
$name = '';
$compares = array();
foreach ( $group as $k => $clause ) {
if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) {
return '';
}
$compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) );
$name = is_string( $k ) ? $k : $name;
}
list( $a, $b ) = array_values( $group );
sort( $compares );
$same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) );
return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : '';
}
/**
* Generate a deterministic JOIN alias for a (post_type, group) pair.
*
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Meta orderby on the flat table: the "sort by an attribute, keep posts
* without it" shape (relation OR of NOT EXISTS + EXISTS on one key, named
* clause in orderby — HivePress search sorting), orderby on a named routed
* clause, and meta_key + meta_value(_num).
*
* Only when the flat column orders the way native CASTs meta_value;
* otherwise the whole query stays native.
*/
class PostQueryRouterOrderTest extends TestCase {
private const ALIAS = '`wpdoflat_product_wc_product`';
public static function setUpBeforeClass(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
protected function setUp(): void {
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
}
public static function tearDownAfterClass(): void {
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
/**
* @param array<string, mixed> $vars Query vars (post_type defaults to product).
* @return array{query:WP_Query, join:string, where:string, orderby:string}
*/
private function route( array $vars ): array {
$query = new WP_Query();
foreach ( $vars + array( 'post_type' => 'product' ) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
return array(
'query' => $query,
'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( '', $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
);
}
/**
* @param string $type Clause type.
* @return array<int|string, mixed>
*/
private function sort_shape( string $type ): array {
return array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'type' => $type, 'compare' => 'NOT EXISTS' ),
array( 'key' => '_price', 'type' => $type, 'compare' => 'EXISTS' ),
),
);
}
public function test_sort_shape_left_joins_and_orders_by_the_flat_column_without_filtering(): void {
$sql = $this->route( array( 'meta_query' => $this->sort_shape( 'DECIMAL' ), 'orderby' => 'price__order', 'order' => 'ASC' ) );
$this->assertSame( array(), $sql['query']->get( 'meta_query' ), 'sort group removed from the native meta_query' );
$this->assertStringContainsString( 'LEFT JOIN `wp_itest_wpdo_post_wc_product` AS ' . self::ALIAS, $sql['join'] );
$this->assertStringNotContainsString( '_price', $sql['where'], 'every post matches: no condition' );
$this->assertStringStartsWith( self::ALIAS . '.`_price` ASC', $sql['orderby'] );
}
public function test_sort_shape_with_char_type_on_a_numeric_column_stays_native(): void {
$vars = array( 'meta_query' => $this->sort_shape( 'CHAR' ), 'orderby' => 'price__order', 'order' => 'ASC' );
$sql = $this->route( $vars );
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_orderby_on_a_named_routed_clause_orders_by_its_column(): void {
$sql = $this->route(
array(
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
'orderby' => array( 'price' => 'DESC' ),
)
);
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'] );
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
}
public function test_orderby_naming_a_routed_clause_among_other_keys_stays_native(): void {
$vars = array(
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
'orderby' => array( 'price' => 'DESC', 'title' => 'ASC' ),
);
$sql = $this->route( $vars );
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_meta_value_num_on_a_numeric_key_requires_the_key_and_orders_by_its_column(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ) );
$this->assertSame( '', $sql['query']->get( 'meta_key' ), 'primary meta clause taken over' );
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'], 'native INNER JOIN drops posts without the key' );
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
}
public function test_meta_value_on_a_string_key_is_routed(): void {
$sql = $this->route( array( 'meta_key' => '_sku', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
$this->assertStringStartsWith( self::ALIAS . '.`_sku` ASC', $sql['orderby'] );
}
public function test_meta_value_on_a_numeric_key_stays_native(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ), 'native string order' );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_meta_key_with_a_meta_value_filter_stays_native(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'meta_value' => '5', 'orderby' => 'meta_value_num' ) );
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
}
-11
View File
@@ -381,17 +381,6 @@ class PostQueryRouterTest extends TestCase {
);
}
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(
+1 -1
View File
@@ -39,7 +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) |
| `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) |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route); routed SQL has no GROUP BY |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
+46 -3
View File
@@ -58,7 +58,7 @@ function tmdo_live_router_query( array $args, array $ids, bool $routed ): array
tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' );
return array(
'ids' => array_map( 'intval', $q->posts ),
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ),
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ) || ! empty( $q->get( 'wpdo_post_sort' ) ),
'sql' => (string) $q->request,
);
}
@@ -179,7 +179,7 @@ TMDO_Live::test(
);
TMDO_Live::test(
'Q15 orderby on a named clause keeps the native order',
'Q15 orderby on a named clause keeps the native order, routed',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
@@ -192,7 +192,8 @@ TMDO_Live::test(
'orderby' => 'price',
'order' => 'ASC',
),
array_values( $l )
array_values( $l ),
true
);
}
);
@@ -297,3 +298,45 @@ TMDO_Live::test(
);
}
);
TMDO_Live::test(
'HivePress sort shape (NOT EXISTS OR EXISTS, named clause) keeps the native order, routed',
static function () {
$l = tmdo_live_router_listings();
foreach ( array( 'ASC', 'DESC' ) as $order ) {
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'NOT EXISTS' ),
array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ),
),
),
'orderby' => 'price__order',
'order' => $order,
),
array_values( $l ),
true
);
}
}
);
TMDO_Live::test(
'Q07 meta_key + meta_value_num keeps the native posts and order, routed',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'orderby' => 'meta_value_num',
'order' => 'DESC',
),
array_values( $l ),
true
);
}
);