query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" ); $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' ); $GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_cache'] = array(); $GLOBALS['_wp_last_changed'] = array(); $GLOBALS['_wp_is_admin'] = false; $GLOBALS['_wp_current_filter'] = ''; $GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' ); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref->setAccessible( true ); $ref->setValue( null, array() ); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); TMDO_Post_Query_Router::flush_copy_cache(); WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Entity_Registry::register_group( 'post', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) ); } protected function tearDown(): void { self::drop(); WPDO_Entity_Registry::init(); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); $mode->setValue( null, null ); } private static function drop(): void { global $wpdb; foreach ( array( $wpdb->posts, self::TABLE ) as $table ) { $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } } /** A routed query plus the SQL WordPress would build for it (page size 1). */ 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( $clause ) ); $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 ) . " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1"; return array( $router, $query, $request ); } public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void { $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); $mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) ); list( , $query ) = $this->routed(); $this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' ); } public function test_routed_request_drops_sql_calc_found_rows(): void { list( $router, $query, $request ) = $this->routed(); $sql = $router->posts_request( $request, $query ); $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); $this->assertStringContainsString( 'LIMIT 0, 1', $sql ); } public function test_unrouted_request_is_left_alone(): void { $router = new TMDO_Post_Query_Router(); $request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10'; $this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) ); $this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) ); } public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void { global $wpdb; list( $router, $query, $request ) = $this->routed(); $router->posts_request( $request, $query ); $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) ); $wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' ); $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' ); wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view. $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' ); TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written. $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); } /** * HivePress excludes a different random set of posts on each search page; * the total is the cached count without the exclusion minus a live count * of the excluded posts that match (by primary key). */ public function test_excluded_posts_do_not_split_the_cached_total(): void { global $wpdb; $count = function ( string $not_in ) use ( $wpdb ): string { list( $router, $query, $request ) = $this->routed(); $request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request ); $router->posts_request( $request, $query ); return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ); }; $this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' ); $this->assertSame( '0', $count( '2,3' ) ); $wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change. $this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' ); $this->assertSame( '1', $count( '2' ) ); TMDO_Post_Query_Router::bump_count_version(); $this->assertSame( '0', $count( '2' ), 'recounted' ); } /** * Keyword searches carry wpdb's placeholder escape for "%", which changes * with every request: the cache key uses the SQL without it, or no search * with a keyword ever hits the cached count. */ public function test_the_cached_count_survives_a_new_placeholder_escape(): void { global $wpdb; $real = $wpdb; $wpdb = new class( $real ) { public int $counts = 0; public function __construct( private object $db ) {} public function remove_placeholder_escape( string $sql ): string { return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql ); } public function __call( string $name, array $args ) { if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) { ++$this->counts; } return $this->db->$name( ...$args ); } public function __get( string $name ) { return $this->db->$name; } }; try { foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests. list( $router, $query, $request ) = $this->routed(); $request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request ); $router->posts_request( $request, $query ); $this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) ); } $counts = $wpdb->counts; } finally { $wpdb = $real; } $this->assertSame( 1, $counts, 'counted once' ); } public function test_flat_writes_and_deletes_move_posts_last_changed(): void { $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; $before = wp_cache_get_last_changed( 'posts' ); $version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ); WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' ); $after_write = wp_cache_get_last_changed( 'posts' ); $GLOBALS['_wp_current_filter'] = 'delete_post_metadata'; WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false ); $this->assertNotSame( $before, $after_write ); $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); $this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' ); } // ── 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 = '>=', string $order = 'DESC' ): 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', $order ); $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 LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' ); $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 ) ); } /** * The ID tie-break follows the sort direction, so a (column, post_id) * index returns rows in order with no filesort (2.4 PR-118); ties are * ordered by ID the same way as the column. */ public function test_flat_first_tie_break_follows_the_sort_direction(): void { global $wpdb; list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' ); $sql = $router->posts_request( $request, $query ); $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql ); $this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) ); } /** * Only standalone posts.ID terms are tie-breaks: inside an expression a * plugin added (pinning posts with FIELD() or IN) the direction must not be * inserted, or the SQL breaks (2.4 correctness review). */ public function test_flat_first_leaves_posts_id_inside_expressions_alone(): void { global $wpdb; list( $router, $query, $request ) = $this->sorted(); $pinned = str_replace( ' LIMIT 0, 10', ", FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT 0, 10", $request ); $sql = $router->posts_request( $pinned, $query ); $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql ); $this->assertStringContainsString( "FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT", $sql ); $wpdb->last_error = ''; $this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $pinned ) ), $wpdb->get_col( $sql ) ); $this->assertSame( '', $wpdb->last_error ); } 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 ────────────────── /** * WordPress's full-row request for a routed query, before and after posts_request. * * @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string, 3:array} */ private function full_rows(): array { global $wpdb; list( $router, $query, $request ) = $this->routed(); $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', 1 ); return array( $router, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 1' ) ); } public function test_routed_full_row_request_is_still_split(): void { list( $router, $query, $old_request, $clauses ) = $this->full_rows(); $this->assertTrue( $router->split_the_query( false, $query, $old_request, $clauses ) ); } public function test_request_changed_by_others_is_not_split(): void { list( $router, $query, $old_request, $clauses ) = $this->full_rows(); $query->request .= ' /* another plugin */'; $this->assertFalse( $router->split_the_query( false, $query, $old_request, $clauses ) ); } public function test_unrouted_query_keeps_the_core_decision(): void { $router = new TMDO_Post_Query_Router(); $this->assertFalse( $router->split_the_query( false, new WP_Query(), 'SELECT 1', array( 'fields' => 'wp_itest_posts.*', 'limits' => 'LIMIT 0, 1' ) ) ); $this->assertTrue( $router->split_the_query( true, new WP_Query(), 'SELECT 1', array() ) ); } public function test_split_id_request_goes_through_posts_request_too(): void { unset( $GLOBALS['_wp_filter_callbacks']['posts_request_ids'], $GLOBALS['_wp_filter_callbacks']['split_the_query'] ); $router = new TMDO_Post_Query_Router(); $router->register_hooks(); $this->assertContains( array( $router, 'posts_request' ), $GLOBALS['_wp_filter_callbacks']['posts_request_ids'] ?? array() ); $this->assertContains( array( $router, 'split_the_query' ), $GLOBALS['_wp_filter_callbacks']['split_the_query'] ?? array() ); } }