dc9a8e9886
- A filtered search ordered by a flat column is rewritten to SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts, with posts.ID tie-breaks (ours and plugin-appended) read as the equal flat post_id, so MariaDB sorts one table instead of looking every post up and sorting in a temporary table - Only when the routed conditions already require a flat row on the sort group, without the copy route or a selective post filter; filter tmdo_router_flat_first turns it off; split_the_query compares with the same rewrite - Verify: tests/live/run.php router (13/13); integration 522; dev36 page SQL Q07 263→147 ms, Q15 250→142 ms; fe.sh hourly_rate__desc 850→367 ms, ids == native - PR: PR-062c
262 lines
12 KiB
PHP
262 lines
12 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_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();
|
|
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
|
|
}
|
|
|
|
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' );
|
|
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' ) );
|
|
}
|
|
|
|
// ── 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 ──────────────────
|
|
|
|
/**
|
|
* 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() );
|
|
}
|
|
}
|