33addd1e49
- flat_first and the copy route share ties_on_post_id(): a posts.ID term alone (optionally with a direction) reads the flat post_id; posts.ID inside an expression a plugin added (FIELD(), IN) is left alone — inserting a direction there made the SQL invalid and the page empty - PCRE failures return the text given instead of '' (the query kept its shape rather than becoming an empty request) - Found in the 2.4 correctness review (Medium) and security review (Low L1) - Verify: 2 new integration tests (the flat-first one runs the SQL), router suites 69 OK - PR: PR-131
185 lines
8.4 KiB
PHP
185 lines
8.4 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 );
|
|
}
|
|
|
|
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' ) ) ) ) );
|
|
}
|
|
}
|