Files
2meet-data-optimizer/tests/integration/PostQueryRouterCopyTest.php
T
wpdev 3e96357459 fix(2meet-data-optimizer): give new posts' flat rows their status/date copies; sync drops cached counts
- add_flat_rows (wp_insert_post, after transition_post_status) inserts the
  copies with the empty row: a NULL copy turned the copy route off for the
  whole group until the next sync
- wp tmdo post-status-sync bumps the routed count version (not on dry runs)
- Test bootstrap: minimal WP_Post stub
- Found in the 2.4 correctness review (residual risks)
- Verify: 2 new integration tests; integration 606, unit 704, phpcs 0, stan OK
- PR: PR-133
2026-09-28 00:32:59 +08:00

227 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Routed searches filter status and sort by date on the flat table's
* post_status / post_date copies (O-1), when those copies are trustworthy.
*/
class PostQueryRouterCopyTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_rc_core';
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.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;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_id (post_status, post_date, post_id) ) ENGINE=InnoDB" );
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
$GLOBALS['_wp_is_admin'] = false;
$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', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, null );
}
/**
* The full request WordPress would build for the routed search, after posts_request.
*
* @param string $extra_join Another JOIN (a tax query's).
* @param string $ties ID tie-breaks plugins appended.
*/
private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string {
global $wpdb;
$ties = str_replace( '{posts}', $wpdb->posts, $ties );
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query )
. ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query )
. " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10';
return $router->posts_request( $request, $query );
}
/**
* 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every
* match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks
* (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms).
*/
public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void {
$sql = $this->request();
$this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
public function test_copy_route_keeps_each_tie_break_direction(): void {
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) );
}
public function test_copy_route_leaves_posts_id_inside_expressions_alone(): void {
global $wpdb;
$sql = $this->request( '', ', FIELD({posts}.ID,5,9) DESC, ({posts}.ID IN (3,4)) DESC, {posts}.ID DESC' );
$this->assertStringEndsWith( "`post_date` DESC, FIELD({$wpdb->posts}.ID,5,9) DESC, ({$wpdb->posts}.ID IN (3,4)) DESC, " . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
/**
* The router's query vars are its own: values a caller passed in
* (new WP_Query( array( 'wpdo_post_copy_route' => … ) )) are dropped
* (2.4 security review L2).
*/
public function test_query_vars_passed_by_a_caller_are_dropped(): void {
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'orderby' => 'title',
'wpdo_post_copy_route' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'statuses' => array( 'publish' ), 'order' => 'DESC, (SELECT 1)' ),
'wpdo_post_sort' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'column' => 'rc_price', 'order' => 'ASC' ),
'wpdo_post_clauses' => array( 'rc_item' => array() ),
'wpdo_post_count_sql' => 'SELECT 1',
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
foreach ( array( 'wpdo_post_copy_route', 'wpdo_post_sort', 'wpdo_post_clauses', 'wpdo_post_count_sql' ) as $var ) {
$this->assertEmpty( $query->get( $var ), $var );
}
$this->assertSame( 'wp_posts.post_title ASC', $router->posts_orderby( 'wp_posts.post_title ASC', $query ) );
}
/**
* wp_insert_post runs after transition_post_status: a new post's empty flat
* row must carry the copies itself, or one NULL turns the copy route off for
* the whole group (2.4 correctness review).
*/
public function test_a_new_post_gets_its_flat_row_with_the_copies(): void {
global $wpdb;
$post = new WP_Post( (object) array( 'ID' => 7, 'post_type' => 'rc_item', 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ) );
$GLOBALS['_wp_posts'][7] = $post;
( new TMDO_Post_Query_Router() )->add_flat_rows( 7, $post, false );
$this->assertSame( array( 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ), $wpdb->get_row( 'SELECT post_status, post_date FROM `' . self::TABLE . '` WHERE post_id = 7', ARRAY_A ) );
unset( $GLOBALS['_wp_posts'][7] );
}
public function test_copy_route_keeps_group_by_with_another_join(): void {
global $wpdb;
$sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" );
$this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql );
}
/** @return array{join:string, where:string, orderby:string} */
private function sql( array $vars ): array {
$query = new WP_Query();
foreach ( array_merge(
array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
),
$vars
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
return array(
'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
);
}
private function assert_legacy_shape( array $sql ): void {
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
$sql = $this->sql( array( 'order' => 'ASC' ) );
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
}
public function test_several_explicit_statuses_are_kept(): void {
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
}
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
global $wpdb;
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
}
public function test_not_exists_keeps_the_legacy_shape(): void {
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
}
}