eac0955b76
- a group index may end with the entity ID column (post_id, ...) - flat-first rewrites the posts.ID tie-breaks once, in the sort's direction: with a (column, post_id) index MySQL returns rows in order instead of sorting the whole match. dev36 (1.3M listings), price range sorted by price: 850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column (natively ID DESC); pagination stays stable, result sets are unchanged - Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC tie-break, single tie-break), unit 704, integration 589, phpcs, stan; dev36 with the HivePress indexes (PR-119) - PR: PR-118
361 lines
16 KiB
PHP
361 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Routed searches drop SQL_CALC_FOUND_ROWS; the total comes from a COUNT
|
|
* cached until posts' last_changed moves (O-4).
|
|
*
|
|
* Three fr_item posts, prices 10 / 20 / 30.
|
|
*/
|
|
class PostQueryRouterFoundRowsTest extends TestCase {
|
|
|
|
private const TABLE = 'wp_itest_wpdo_post_fr_core';
|
|
|
|
public static function setUpBeforeClass(): void {
|
|
$base = WPDO_PLUGIN_DIR;
|
|
foreach ( array(
|
|
'includes/adapters/interface-entity-adapter.php',
|
|
'includes/engine/class-tmdo-type-caster.php',
|
|
'includes/engine/class-tmdo-entity-registry.php',
|
|
'includes/engine/class-tmdo-mode-manager.php',
|
|
'includes/engine/class-tmdo-schema-manager.php',
|
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
|
'includes/adapters/class-tmdo-adapter-post.php',
|
|
'includes/integrations/class-tmdo-post-fields.php',
|
|
'includes/engine/class-tmdo-hook-bus.php',
|
|
'includes/engine/class-tmdo-post-status-sync.php',
|
|
'includes/query/class-tmdo-post-query-router.php',
|
|
) as $file ) {
|
|
require_once $base . $file;
|
|
}
|
|
}
|
|
|
|
protected function setUp(): void {
|
|
global $wpdb;
|
|
self::drop();
|
|
$wpdb->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 ) );
|
|
}
|
|
|
|
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<string, string>}
|
|
*/
|
|
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() );
|
|
}
|
|
}
|