$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, sql:string} */ 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 { tmdo_live_router_unhook( $router ); } tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' ); return array( 'ids' => array_map( 'intval', $q->posts ), 'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ) || ! empty( $q->get( 'wpdo_post_sort' ) ), 'sql' => (string) $q->request, ); } /** * Remove every hook TMDO_Post_Query_Router::register_hooks() added. * * @param TMDO_Post_Query_Router $router Router instance. */ function tmdo_live_router_unhook( TMDO_Post_Query_Router $router ): void { foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_orderby', 'posts_request', 'split_the_query', 'found_posts_query' ) as $method ) { foreach ( 'posts_request' === $method ? array( 'posts_request', 'posts_request_ids' ) : array( $method ) as $hook ) { $priority = has_filter( $hook, array( $router, $method ) ); if ( false !== $priority ) { remove_filter( $hook, array( $router, $method ), $priority ); } } } } /** * 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> $posts Label => meta. * @return array 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 */ 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 $args Query args. * @param int[] $ids Fixture ids. * @param bool|null $expect_routed true / false to assert, null to ignore. * @param bool $ordered Compare order too (false when the order has ties). */ function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null, bool $ordered = true ): void { $native = tmdo_live_router_query( $args, $ids, false ); $routed = tmdo_live_router_query( $args, $ids, true ); if ( ! $ordered ) { sort( $native['ids'] ); sort( $routed['ids'] ); } tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids vs native' ); if ( null !== $expect_routed ) { tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' ); } if ( $routed['routed'] ) { tmdo_live_assert_true( false === stripos( $routed['sql'], 'GROUP BY' ), 'routed query adds no GROUP BY: ' . $routed['sql'] ); } } 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, routed', 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 ), true ); } ); 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 () { // A text key of wp_core (WooCommerce's _sku left the flat tables in 1.2.0). TMDO_Live_Fixtures::field( 'post', '_wp_page_template' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); $p = tmdo_live_router_posts( 'post', array( 'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ), 'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ), 'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ), ) ); foreach ( array( 'B1-1', 'B1_1' ) as $needle ) { tmdo_live_router_assert_native( array( 'post_type' => 'post', 'meta_query' => array( array( 'key' => '_wp_page_template', 'value' => $needle, 'compare' => 'LIKE' ) ), ), array_values( $p ), true ); } } ); TMDO_Live::test( 'Q05 keys of two groups are both joined, routed', static function () { $l = tmdo_live_router_listings(); TMDO_Live_Fixtures::field( 'post', '_thumbnail_id', 'hp_listing' ); update_post_meta( $l['L1'], '_thumbnail_id', '123' ); update_post_meta( $l['L3'], '_thumbnail_id', '124' ); tmdo_live_router_assert_native( array( 'post_type' => 'hp_listing', 'meta_query' => array( array( 'key' => 'hp_hourly_rate', 'value' => 42, 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ), ), ), array_values( $l ), true ); } ); TMDO_Live::test( 'NOT EXISTS with explicit statuses and date order keeps posts without a flat row', static function () { $l = tmdo_live_router_listings(); tmdo_live_router_assert_native( array( 'post_type' => 'hp_listing', 'post_status' => array( 'draft' ), 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ), ), array_values( $l ), true, false ); } ); 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 ); } ); TMDO_Live::test( 'a routed full-row search keeps the split query (IDs first) and native found_posts', static function () { global $wpdb; $l = tmdo_live_router_listings(); $args = array( 'post_type' => 'hp_listing', 'post__in' => array_values( $l ), 'post_status' => 'any', 'posts_per_page' => 2, 'orderby' => 'ID', 'order' => 'ASC', 'meta_query' => array( array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '>=', 'type' => 'NUMERIC' ) ), ); $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( wp_list_pluck( $native->posts, 'ID' ), wp_list_pluck( $routed->posts, 'ID' ), 'page of posts' ); tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); tmdo_live_assert_true( false !== strpos( $routed->request, 'wpdoflat_' ), 'routed: ' . $routed->request ); 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 ); } ); /** * HivePress-style sort-only search (rating__order), fields ids, first page. * DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends. * * @param string $order ASC | DESC. * @return array WP_Query args. */ function tmdo_live_router_rating_sort( string $order ): array { return array( 'post_type' => 'hp_listing', 'post_status' => 'publish', 'posts_per_page' => 10, 'fields' => 'ids', 'suppress_filters' => false, 'meta_query' => array( array( 'relation' => 'OR', 'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ), array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ), ), ), 'orderby' => 'rating__order', 'order' => $order, ); } /** * The native sort key of each post, in order (null without a rating). On the * whole site many listings share a rating and native orders ties by chance, * so a page is compared by its keys, not its ids. * * @param array $ids Post ids. * @return array */ function tmdo_live_router_rating_keys( array $ids ): array { global $wpdb; $ids = array_map( 'intval', $ids ); if ( empty( $ids ) ) { return array(); } $rows = $wpdb->get_results( "SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')', OBJECT_K ); return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids ); } /** * Native and routed runs of the same query. * * @param array $args Query args. * @return array{0:WP_Query, 1:WP_Query} */ function tmdo_live_router_native_and_routed( array $args ): array { $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 ); } return array( $native, $routed ); } TMDO_Live::test( 'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)', static function () { TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); TMDO_Post_Query_Router::forget_coverage(); foreach ( array( 'DESC', 'ASC' ) as $order ) { list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) ); tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" ); tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" ); tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request ); } } ); TMDO_Live::test( 'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)', static function () { global $wpdb; TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); $bare = TMDO_Live_Fixtures::post( 'hp_listing' ); $wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) ); TMDO_Post_Query_Router::forget_coverage(); try { list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) ); } finally { TMDO_Post_Query_Router::forget_coverage(); } tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request ); } ); TMDO_Live::test( 'a post created while the router is active gets its empty flat row (2.2 PR-079)', static function () { global $wpdb; TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); $router = new TMDO_Post_Query_Router(); $router->register_hooks(); try { $id = TMDO_Live_Fixtures::post( 'hp_listing' ); } finally { tmdo_live_router_unhook( $router ); } $table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ); tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } ); TMDO_Live::test( 'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)', static function () { TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); TMDO_Post_Query_Router::forget_coverage(); // Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN // is a subquery, yet WordPress groups by posts.ID for any tax query. $args = tmdo_live_router_rating_sort( 'DESC' ) + array( 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ), ); list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); 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 ); tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request ); } ); TMDO_Live::test( 'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)', static function () { TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); TMDO_Post_Query_Router::forget_coverage(); $terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) ); if ( count( (array) $terms ) < 2 ) { throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' ); } $args = tmdo_live_router_rating_sort( 'DESC' ) + array( 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ), ); list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request ); } );