Files
2meet-data-optimizer/tests/integration/PostQueryRouterFoundRowsTest.php
T
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00

298 lines
14 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();
$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' ) );
wp_cache_set_posts_last_changed();
$this->assertSame( '0', $count( '2' ), '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() );
}
}