From ec7572197602ceddae79cd45d328f5ad30554b5b Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 20:04:23 +0800 Subject: [PATCH] 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 --- .../query/class-tmdo-post-query-router.php | 189 +++++++++++++++--- .../integration/PostQueryRouterOrderTest.php | 138 +++++++++++++ tests/integration/PostQueryRouterTest.php | 11 - tests/live/README.md | 2 +- tests/live/suites/router.php | 49 ++++- 5 files changed, 348 insertions(+), 41 deletions(-) create mode 100644 tests/integration/PostQueryRouterOrderTest.php diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 8db77f7..d5b6a24 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -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,27 +235,32 @@ 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 ) { - $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); - $alias = self::table_alias( $post_type, $group ); + foreach ( $pairs as list( $post_type, $group ) ) { + $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; - } - - // The copy route only applies to single-group searches. - $type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT'; - $join .= " {$type} JOIN `{$table}` AS `{$alias}` " - . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; + // Don't double-join. + if ( false !== strpos( $join, "`{$alias}`" ) ) { + continue; } + + // The copy route only applies to single-group searches. + $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; @@ -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,7 +312,13 @@ class TMDO_Post_Query_Router { */ public function posts_orderby( ?string $orderby, \WP_Query $query ): string { $orderby = (string) ( $orderby ?? '' ); - $copy = $query->get( self::COPY_VAR ); + $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 $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 $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. * diff --git a/tests/integration/PostQueryRouterOrderTest.php b/tests/integration/PostQueryRouterOrderTest.php new file mode 100644 index 0000000..5653581 --- /dev/null +++ b/tests/integration/PostQueryRouterOrderTest.php @@ -0,0 +1,138 @@ +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 $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 + */ + 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'] ); + } +} diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php index ecb3bd8..9c16205 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -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( diff --git a/tests/live/README.md b/tests/live/README.md index b35e0d5..3d772db 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -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/`) diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php index 49d9c4c..e368d2e 100644 --- a/tests/live/suites/router.php +++ b/tests/live/suites/router.php @@ -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 + ); + } +);