diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 806fa62..bf1f1ac 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -337,10 +337,14 @@ class TMDO_Post_Query_Router { public function posts_request( ?string $request, \WP_Query $query ): string { $request = (string) ( $request ?? '' ); $routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) ); - if ( ! $routed || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) { + if ( ! $routed ) { + return $request; + } + $counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' ); + $request = self::routed_request( $request, $query ); + if ( ! $counted ) { return $request; } - $request = self::without_found_rows( $request ); $matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); return $request; @@ -367,7 +371,7 @@ class TMDO_Post_Query_Router { global $wpdb; // Core's other conditions: full rows, and a LIMIT under 500 or an external object cache. return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' ) - && self::without_found_rows( (string) $old_request ) === $query->request + && self::routed_request( (string) $old_request, $query ) === $query->request && ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() ); } @@ -569,6 +573,81 @@ class TMDO_Post_Query_Router { ); } + /** + * What posts_request makes of a routed request. + * + * @param string $request SELECT statement. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + private static function routed_request( string $request, \WP_Query $query ): string { + return self::flat_first( self::without_found_rows( $request ), $query ); + } + + /** + * Drive a filtered search ordered by a flat column from its flat table. + * + * MariaDB starts from wp_posts (the type / status index), looks every + * candidate up in the flat table and sorts in a temporary table. From the + * flat table, with the posts.ID tie-break read as the equal flat post_id, + * it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms, + * HivePress sort + filter 300 → 36 ms). Only when the routed conditions + * already require a flat row (LEFT JOIN = INNER JOIN), all on the sort + * group, without a selective post filter; `tmdo_router_flat_first` can + * turn it off. + * + * @param string $request SELECT statement. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + private static function flat_first( string $request, \WP_Query $query ): string { + $routed = $query->get( self::QUERY_VAR ); + $sort = $query->get( self::SORT_VAR ); + if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { + return $request; + } + $post_type = (string) array_key_first( $routed ); + $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); + $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); + if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { + return $request; + } + foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) { + if ( ! empty( $query->get( $var ) ) ) { + return $request; + } + } + if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) { + return $request; + } + + global $wpdb; + $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] ); + $alias = self::table_alias( $post_type, $groups[0] ); + $on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; + $join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}"; + if ( 1 !== substr_count( $request, $join ) ) { + return $request; + } + $flat_first = preg_replace( + '/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/', + "FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}", + str_replace( $join, '', $request ), + 1, + $replaced + ); + if ( 1 !== $replaced ) { + return $request; + } + // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id. + $flat_first = preg_replace_callback( + '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', + static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ), + (string) $flat_first + ); + return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 ); + } + /** * A request without its SQL_CALC_FOUND_ROWS. * diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index c93a53b..78e01eb 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -75,12 +75,12 @@ class PostQueryRouterFoundRowsTest extends TestCase { } /** A routed query plus the SQL WordPress would build for it (page size 1). */ - private function routed(): array { + private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array { global $wpdb; $query = new WP_Query(); $query->set( 'post_type', 'fr_item' ); $query->set( 'post_status', 'publish' ); - $query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ) ); + $query->set( 'meta_query', array( $clause ) ); $router = new TMDO_Post_Query_Router(); $router->pre_get_posts( $query ); @@ -134,6 +134,86 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); } + // ── flat-first: a filtered search ordered by a flat column ───────────── + + /** + * A routed search ordered by the flat price (named clause), with the + * posts.ID tie-break a plugin such as HivePress appends. + * + * @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string} + */ + private function sorted( string $compare = '>=' ): array { + global $wpdb; + $query = new WP_Query(); + $query->set( 'post_type', 'fr_item' ); + $query->set( 'post_status', 'publish' ); + $query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) ); + $query->set( 'orderby', 'price' ); + $query->set( 'order', 'DESC' ); + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + + $request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query ) + . " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query ) + . ' ORDER BY ' . $router->posts_orderby( '', $query ) . ", {$wpdb->posts}.ID DESC LIMIT 0, 10"; + return array( $router, $query, $request ); + } + + public function test_sorted_filtered_request_drives_from_the_flat_table_with_the_same_rows(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted(); + + $sql = $router->posts_request( $request, $query ); + + $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql ); + $this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql ); + $this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql ); + $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' ); + $this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) ); + $this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) ); + } + + public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void { + list( $router, $query, $request ) = $this->routed(); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_needs_a_condition_that_rejects_posts_without_a_flat_row(): void { + list( $router, $query, $request ) = $this->sorted( 'NOT EXISTS' ); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_is_skipped_for_selective_post_filters(): void { + list( $router, $query, $request ) = $this->sorted(); + $query->set( 'post__in', array( 2 ) ); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_can_be_turned_off(): void { + list( $router, $query, $request ) = $this->sorted(); + $off = static fn() => false; + add_filter( 'tmdo_router_flat_first', $off ); + try { + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } finally { + remove_filter( 'tmdo_router_flat_first', $off ); + } + } + + public function test_flat_first_keeps_the_split_query(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted(); + $old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request ); + $query->request = $router->posts_request( $old_request, $query ); + $query->set( 'posts_per_page', 10 ); + + $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $query->request ); + $this->assertTrue( $router->split_the_query( false, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 10' ) ) ); + } + // ── split query: IDs first, then cached post objects ────────────────── /** diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php index 120ded8..0ce0a6e 100644 --- a/tests/live/suites/router.php +++ b/tests/live/suites/router.php @@ -381,3 +381,45 @@ TMDO_Live::test( tmdo_live_assert_true( 1 === preg_match( '/^\s*SELECT\s+' . preg_quote( $wpdb->posts, '/' ) . '\.ID\s/', $routed->request ), 'IDs first: ' . $routed->request ); } ); + +TMDO_Live::test( + 'a filtered search ordered by a flat column drives from the flat table and matches native', + static function () { + foreach ( array( 'hp_price', 'hp_hourly_rate' ) as $key ) { + TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' ); + } + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + // A price band no seeded listing uses keeps the result to the fixtures. + tmdo_live_router_posts( + 'hp_listing', + array( + 'A' => array( 'hp_price' => '9876501', 'hp_hourly_rate' => '12' ), + 'B' => array( 'hp_price' => '9876502', 'hp_hourly_rate' => '15' ), + 'C' => array( 'hp_price' => '9876503' ), + ) + ); + $args = array( + 'post_type' => 'hp_listing', + 'post_status' => 'any', + 'posts_per_page' => 10, + 'fields' => 'ids', + 'suppress_filters' => false, + 'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 9876500, 9876510 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), + 'meta_key' => 'hp_price', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + ); + $native = new WP_Query( $args ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $routed = new WP_Query( $args ); + } finally { + tmdo_live_router_unhook( $router ); + } + + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); + } +);