From 508f261d1c1e82377fe52a75642de5db367e2735 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Oct 2025 13:27:53 +1100 Subject: [PATCH 01/21] Enhance WP_Query ordering to ensure deterministic results by adding ID as a secondary sort field. This change addresses potential duplicate records across pages when multiple posts share the same value for a field. A list of fields requiring deterministic ordering has been introduced to improve query consistency --- src/wp-includes/class-wp-query.php | 63 ++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 3268351e99347..4a66e39cf6b92 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -1886,6 +1886,7 @@ public function set( $query_var, $value ) { * database query. * * @since 1.5.0 + * @since x.x.x Adds deterministic ordering to prevent duplicate records across pages. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2501,12 +2502,45 @@ public function get_posts() { if ( isset( $query_vars['orderby'] ) && ( is_array( $query_vars['orderby'] ) || false === $query_vars['orderby'] ) ) { $orderby = ''; } else { - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + /* + * Ensure deterministic ordering to prevent duplicate records across pages. + * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. + * Note: this is to circumvent a bug that is currently being tracked in https://core.trac.wordpress.org/ticket/44349. + */ + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; } } elseif ( 'none' === $query_vars['orderby'] ) { $orderby = ''; } else { - $orderby_array = array(); + /* + * Ensure deterministic ordering to prevent duplicate records across pages. + * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. + * Note: this is to circumvent a bug that is currently being tracked in https://core.trac.wordpress.org/ticket/44349. + */ + $fields_requiring_deterministic_orderby = array( + 'post_name', + 'post_author', + 'post_date', + 'post_title', + 'post_modified', + 'post_mime_type', + 'post_parent', + 'post_type', + 'name', + 'author', + 'date', + 'title', + 'modified', + 'parent', + 'type', + 'menu_order', + 'comment_count', + ); + + $orderby_array = array(); + $needs_deterministic_orderby = false; + $has_id_orderby = false; + if ( is_array( $query_vars['orderby'] ) ) { foreach ( $query_vars['orderby'] as $_orderby => $order ) { $orderby = addslashes_gpc( urldecode( $_orderby ) ); @@ -2517,7 +2551,20 @@ public function get_posts() { } $orderby_array[] = $parsed . ' ' . $this->parse_order( $order ); + + // Check if this field needs deterministic ordering + if ( in_array( $_orderby, $fields_requiring_deterministic_orderby, true ) ) { + $needs_deterministic_orderby = true; + } elseif ( 'ID' === $_orderby ) { + $has_id_orderby = true; + } + } + + // Add ID as tie-breaker if needed and not already present + if ( $needs_deterministic_orderby && ! $has_id_orderby ) { + $orderby_array[] = "{$wpdb->posts}.ID " . $query_vars['order']; } + $orderby = implode( ', ', $orderby_array ); } else { @@ -2532,11 +2579,21 @@ public function get_posts() { } $orderby_array[] = $parsed; + + // Check if this field needs deterministic ordering + if ( in_array( $orderby, $fields_requiring_deterministic_orderby, true ) ) { + $needs_deterministic_orderby = true; + } elseif ( 'ID' === $orderby ) { + $has_id_orderby = true; + } } $orderby = implode( ' ' . $query_vars['order'] . ', ', $orderby_array ); if ( empty( $orderby ) ) { - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; + } elseif ( $needs_deterministic_orderby && ! $has_id_orderby ) { + // Add ID as tie-breaker for deterministic ordering + $orderby .= ", {$wpdb->posts}.ID " . $query_vars['order']; } elseif ( ! empty( $query_vars['order'] ) ) { $orderby .= " {$query_vars['order']}"; } From f4d75ba9d5cc37d899a2722db74a7b4ec5bacade Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Oct 2025 13:48:38 +1100 Subject: [PATCH 02/21] WHITESPACE! Oh no! --- src/wp-includes/class-wp-query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 4a66e39cf6b92..179382ea546fa 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2520,7 +2520,7 @@ public function get_posts() { $fields_requiring_deterministic_orderby = array( 'post_name', 'post_author', - 'post_date', + 'post_date', 'post_title', 'post_modified', 'post_mime_type', From 85be0205c5cff3cabec3a95fa823d4c990a7625e Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 09:21:38 +1100 Subject: [PATCH 03/21] Refactor WP_Query ordering logic to ensure consistent results by appending ID as a secondary sort field. Update related unit tests to reflect changes in expected SQL output for various orderby scenarios --- src/wp-includes/class-wp-query.php | 5 ++--- .../tests/admin/wpPrivacyRequestsTable.php | 20 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 179382ea546fa..a4f349f74ec3e 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2523,7 +2523,6 @@ public function get_posts() { 'post_date', 'post_title', 'post_modified', - 'post_mime_type', 'post_parent', 'post_type', 'name', @@ -2578,7 +2577,7 @@ public function get_posts() { continue; } - $orderby_array[] = $parsed; + $orderby_array[] = $parsed . ' ' . $query_vars['order']; // Check if this field needs deterministic ordering if ( in_array( $orderby, $fields_requiring_deterministic_orderby, true ) ) { @@ -2587,7 +2586,7 @@ public function get_posts() { $has_id_orderby = true; } } - $orderby = implode( ' ' . $query_vars['order'] . ', ', $orderby_array ); + $orderby = implode( ', ', $orderby_array ); if ( empty( $orderby ) ) { $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; diff --git a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php index 66e3e02501cfb..08eadb62e6bd3 100644 --- a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php +++ b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php @@ -99,7 +99,13 @@ public function test_columns_should_be_sortable( $order, $orderby, $search, $exp unset( $_REQUEST['orderby'] ); unset( $_REQUEST['s'] ); - $this->assertStringContainsString( "ORDER BY {$wpdb->posts}.{$expected}", $this->sql ); + $expected_query = explode( ', ', $expected ); + $expected_query = array_map( function( $item ) use ( $wpdb ) { + return "{$wpdb->posts}.{$item}"; + }, $expected_query ); + $expected_query = implode( ', ', $expected_query ); + + $this->assertStringContainsString( "ORDER BY {$expected_query}", $this->sql ); } /** @@ -136,42 +142,42 @@ public function data_columns_should_be_sortable() { 'order' => null, 'orderby' => null, 's' => null, - 'expected' => 'post_date DESC', + 'expected' => 'post_date DESC, ID DESC', ), // Default order (ID) DESC. array( 'order' => '', 'orderby' => '', 's' => '', - 'expected' => 'post_date DESC', + 'expected' => 'post_date DESC, ID DESC', ), // Order by requester (post_title) ASC. array( 'order' => 'ASC', 'orderby' => 'requester', 's' => '', - 'expected' => 'post_title ASC', + 'expected' => 'post_title ASC, ID ASC', ), // Order by requester (post_title) DESC. array( 'order' => 'DESC', 'orderby' => 'requester', 's' => null, - 'expected' => 'post_title DESC', + 'expected' => 'post_title DESC, ID DESC', ), // Order by requested (post_date) ASC. array( 'order' => 'ASC', 'orderby' => 'requested', 's' => null, - 'expected' => 'post_date ASC', + 'expected' => 'post_date ASC, ID ASC', ), // Order by requested (post_date) DESC. array( 'order' => 'DESC', 'orderby' => 'requested', 's' => null, - 'expected' => 'post_date DESC', + 'expected' => 'post_date DESC, ID DESC', ), // Search and order by relevance. array( From d2defb0f2b29ee0bf7136c20710fb869dd02515f Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 10:37:04 +1100 Subject: [PATCH 04/21] Consolidate ID tie-breaker logic and ensure consistent SQL output in unit tests for orderby scenarios. --- src/wp-includes/class-wp-query.php | 28 ++++++++----------- .../tests/admin/wpPrivacyRequestsTable.php | 12 ++++---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index a4f349f74ec3e..6b3cad1131d62 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2558,14 +2558,6 @@ public function get_posts() { $has_id_orderby = true; } } - - // Add ID as tie-breaker if needed and not already present - if ( $needs_deterministic_orderby && ! $has_id_orderby ) { - $orderby_array[] = "{$wpdb->posts}.ID " . $query_vars['order']; - } - - $orderby = implode( ', ', $orderby_array ); - } else { $query_vars['orderby'] = urldecode( $query_vars['orderby'] ); $query_vars['orderby'] = addslashes_gpc( $query_vars['orderby'] ); @@ -2586,16 +2578,18 @@ public function get_posts() { $has_id_orderby = true; } } - $orderby = implode( ', ', $orderby_array ); + } - if ( empty( $orderby ) ) { - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; - } elseif ( $needs_deterministic_orderby && ! $has_id_orderby ) { - // Add ID as tie-breaker for deterministic ordering - $orderby .= ", {$wpdb->posts}.ID " . $query_vars['order']; - } elseif ( ! empty( $query_vars['order'] ) ) { - $orderby .= " {$query_vars['order']}"; - } + // Add ID as tie-breaker if needed and not already present + if ( $needs_deterministic_orderby && ! $has_id_orderby ) { + $orderby_array[] = "{$wpdb->posts}.ID " . $query_vars['order']; + } + + // Build the final orderby string + if ( empty( $orderby_array ) ) { + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; + } else { + $orderby = implode( ', ', $orderby_array ); } } diff --git a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php index 08eadb62e6bd3..9d1374e25a3c8 100644 --- a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php +++ b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php @@ -100,12 +100,14 @@ public function test_columns_should_be_sortable( $order, $orderby, $search, $exp unset( $_REQUEST['s'] ); $expected_query = explode( ', ', $expected ); - $expected_query = array_map( function( $item ) use ( $wpdb ) { - return "{$wpdb->posts}.{$item}"; - }, $expected_query ); - $expected_query = implode( ', ', $expected_query ); + $expected_query = array_map( + function( $item ) use ( $wpdb ) { + return "{$wpdb->posts}.{$item}"; + }, + $expected_query + ); - $this->assertStringContainsString( "ORDER BY {$expected_query}", $this->sql ); + $this->assertStringContainsString( "ORDER BY " . implode( ', ', $expected_query ), $this->sql ); } /** From 1b9818febf91d8a7dabefcae9ec0afd13656f6f3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 10:40:51 +1100 Subject: [PATCH 05/21] whitespace in unit test --- tests/phpunit/tests/admin/wpPrivacyRequestsTable.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php index 9d1374e25a3c8..bc7f9d3ac93d7 100644 --- a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php +++ b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php @@ -101,13 +101,13 @@ public function test_columns_should_be_sortable( $order, $orderby, $search, $exp $expected_query = explode( ', ', $expected ); $expected_query = array_map( - function( $item ) use ( $wpdb ) { + function ( $item ) use ( $wpdb ) { return "{$wpdb->posts}.{$item}"; }, $expected_query ); - $this->assertStringContainsString( "ORDER BY " . implode( ', ', $expected_query ), $this->sql ); + $this->assertStringContainsString( 'ORDER BY ' . implode( ', ', $expected_query ), $this->sql ); } /** From 41d210bfb9f39ded0fdae067e1655d7d0dead379 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 11:29:32 +1100 Subject: [PATCH 06/21] Refine WP_Query ordering logic to handle 'none' in orderby scenarios and ensure ID is consistently used as a tie-breaker. Update unit tests to reflect changes in expected SQL output for various orderby cases. --- src/wp-includes/class-wp-query.php | 10 +- .../tests/admin/wpPrivacyRequestsTable.php | 4 +- .../tests/query/deterministicOrdering.php | 291 ++++++++++++++++++ 3 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/tests/query/deterministicOrdering.php diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 6b3cad1131d62..990c7b02b7fe9 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2509,7 +2509,10 @@ public function get_posts() { */ $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; } - } elseif ( 'none' === $query_vars['orderby'] ) { + // See get_pages(): when sort_column is 'none', the get_pages() function should not generate any ORDER BY clause. + // Should it rather be handled in the get_pages() function? + // src/wp-includes/post.php L6496 + } elseif ( 'none' === $query_vars['orderby'] || ( is_array( $query_vars['orderby'] ) && array_key_exists( 'none', $query_vars['orderby'] ) ) ) { $orderby = ''; } else { /* @@ -2539,6 +2542,7 @@ public function get_posts() { $orderby_array = array(); $needs_deterministic_orderby = false; $has_id_orderby = false; + $id_tie_breaker_order = $query_vars['order']; // Default to global order if ( is_array( $query_vars['orderby'] ) ) { foreach ( $query_vars['orderby'] as $_orderby => $order ) { @@ -2554,6 +2558,8 @@ public function get_posts() { // Check if this field needs deterministic ordering if ( in_array( $_orderby, $fields_requiring_deterministic_orderby, true ) ) { $needs_deterministic_orderby = true; + // Use the order from the array for ID tie-breaker + $id_tie_breaker_order = $this->parse_order( $order ); } elseif ( 'ID' === $_orderby ) { $has_id_orderby = true; } @@ -2582,7 +2588,7 @@ public function get_posts() { // Add ID as tie-breaker if needed and not already present if ( $needs_deterministic_orderby && ! $has_id_orderby ) { - $orderby_array[] = "{$wpdb->posts}.ID " . $query_vars['order']; + $orderby_array[] = "{$wpdb->posts}.ID " . $id_tie_breaker_order; } // Build the final orderby string diff --git a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php index bc7f9d3ac93d7..1fc58f88e2059 100644 --- a/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php +++ b/tests/phpunit/tests/admin/wpPrivacyRequestsTable.php @@ -193,14 +193,14 @@ public function data_columns_should_be_sortable() { 'order' => 'ASC', 'orderby' => 'requester', 's' => 'foo', - 'expected' => 'post_title ASC', + 'expected' => 'post_title ASC, ID ASC', ), // Search and order by requested (post_date) ASC. array( 'order' => 'ASC', 'orderby' => 'requested', 's' => 'foo', - 'expected' => 'post_date ASC', + 'expected' => 'post_date ASC, ID ASC', ), ); } diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php new file mode 100644 index 0000000000000..f7c81f63a21db --- /dev/null +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -0,0 +1,291 @@ +post->create( array( + 'post_title' => 'Post A', + 'post_date' => '2023-01-01 10:00:00', + ) ); + $post2 = self::factory()->post->create( array( + 'post_title' => 'Post B', + 'post_date' => '2023-01-01 10:00:00', // Same date as post1 + ) ); + $post3 = self::factory()->post->create( array( + 'post_title' => 'Post C', + 'post_date' => '2023-01-01 10:00:00', // Same date as post1 and post2 + ) ); + + // Test ordering by post_date (should add ID tie-breaker) + $query = new WP_Query( array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Verify SQL contains ID as secondary sort + $this->assertStringContainsString( 'ORDER BY', $query->request ); + $this->assertStringContainsString( 'post_date ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); // No double ASC + } + + /** + * Test that deterministic ordering works with post_title. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_post_title() { + // Create posts with same title to test deterministic ordering + $post1 = self::factory()->post->create( array( + 'post_title' => 'Same Title', + 'post_date' => '2023-01-01 10:00:00', + ) ); + $post2 = self::factory()->post->create( array( + 'post_title' => 'Same Title', // Same title as post1 + 'post_date' => '2023-01-01 11:00:00', + ) ); + + $query = new WP_Query( array( + 'orderby' => 'post_title', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Verify SQL contains ID as secondary sort + $this->assertStringContainsString( 'post_title ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + } + + /** + * Test that deterministic ordering works with DESC order. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_desc_order() { + $query = new WP_Query( array( + 'orderby' => 'post_date', + 'order' => 'DESC', + 'posts_per_page' => 10, + ) ); + + // Verify SQL contains ID as secondary sort with DESC + $this->assertStringContainsString( 'post_date DESC', $query->request ); + $this->assertStringContainsString( 'ID DESC', $query->request ); + $this->assertStringNotContainsString( 'DESC DESC', $query->request ); + } + + /** + * Test that deterministic ordering works with array orderby. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_array_orderby() { + $query = new WP_Query( array( + 'orderby' => array( + 'post_date' => 'ASC', + 'post_title' => 'ASC', + ), + 'posts_per_page' => 10, + ) ); + + // Verify SQL contains both fields with directions + $this->assertStringContainsString( 'post_date ASC', $query->request ); + $this->assertStringContainsString( 'post_title ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + } + + /** + * Test that deterministic ordering doesn't add ID when ID is already present. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_does_not_duplicate_id() { + $query = new WP_Query( array( + 'orderby' => 'ID', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Should not add duplicate ID + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ID ASC, ID ASC', $query->request ); + } + + /** + * Test that deterministic ordering works with fields that don't need it. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_non_deterministic_fields() { + $query = new WP_Query( array( + 'orderby' => 'rand', + 'posts_per_page' => 10, + ) ); + + // Should not add ID tie-breaker for rand + $this->assertStringContainsString( 'RAND()', $query->request ); + $this->assertStringNotContainsString( 'ID ASC', $query->request ); + } + + /** + * Test that deterministic ordering works with default ordering. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_default_ordering() { + $query = new WP_Query( array( + 'posts_per_page' => 10, + ) ); + + // Default ordering should include ID tie-breaker + $this->assertStringContainsString( 'post_date DESC', $query->request ); + $this->assertStringContainsString( 'ID DESC', $query->request ); + $this->assertStringNotContainsString( 'DESC DESC', $query->request ); + } + + /** + * Test that deterministic ordering prevents duplicate records across pages. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_prevents_duplicates_across_pages() { + // Create multiple posts with same post_date + $posts = array(); + for ( $i = 1; $i <= 10; $i++ ) { + $posts[] = self::factory()->post->create( array( + 'post_title' => "Post $i", + 'post_date' => '2023-01-01 10:00:00', // All same date + ) ); + } + + // Get first page + $query1 = new WP_Query( array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 5, + 'paged' => 1, + ) ); + + // Get second page + $query2 = new WP_Query( array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 5, + 'paged' => 2, + ) ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // No overlap between pages + $this->assertEmpty( array_intersect( $page1_ids, $page2_ids ) ); + + // Total posts should equal sum of both pages + $this->assertEquals( 10, $query1->found_posts ); + $this->assertEquals( 5, count( $page1_ids ) ); + $this->assertEquals( 5, count( $page2_ids ) ); + } + + /** + * Test that deterministic ordering works with search queries. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_search() { + // Create posts with searchable content + $post1 = self::factory()->post->create( array( + 'post_title' => 'Test Post 1', + 'post_content' => 'This is a test post', + 'post_date' => '2023-01-01 10:00:00', + ) ); + $post2 = self::factory()->post->create( array( + 'post_title' => 'Test Post 2', + 'post_content' => 'This is another test post', + 'post_date' => '2023-01-01 10:00:00', // Same date + ) ); + + $query = new WP_Query( array( + 's' => 'test', + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Should still have deterministic ordering even with search + $this->assertStringContainsString( 'post_date ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + } + + /** + * Test that deterministic ordering works with meta queries. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_meta_query() { + // Create posts with meta values + $post1 = self::factory()->post->create(); + add_post_meta( $post1, 'test_meta', 'value1' ); + + $post2 = self::factory()->post->create(); + add_post_meta( $post2, 'test_meta', 'value2' ); + + $query = new WP_Query( array( + 'meta_key' => 'test_meta', + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Should still have deterministic ordering with meta queries + $this->assertStringContainsString( 'post_date ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + } + + /** + * Test that deterministic ordering works with taxonomy queries. + * + * @ticket 44349 + */ + public function test_deterministic_ordering_with_taxonomy_query() { + // Create posts with categories + $post1 = self::factory()->post->create(); + $post2 = self::factory()->post->create(); + + $cat_id = self::factory()->category->create( array( 'name' => 'Test Category' ) ); + wp_set_post_categories( $post1, array( $cat_id ) ); + wp_set_post_categories( $post2, array( $cat_id ) ); + + $query = new WP_Query( array( + 'category_name' => 'test-category', + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) ); + + // Should still have deterministic ordering with taxonomy queries + $this->assertStringContainsString( 'post_date ASC', $query->request ); + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + } +} From b2db52b7afaa4254eeca1cb317a7b3fd13deb71a Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 11:36:20 +1100 Subject: [PATCH 07/21] lint --- src/wp-includes/class-wp-query.php | 6 +- .../tests/query/deterministicOrdering.php | 462 +++++++++--------- 2 files changed, 239 insertions(+), 229 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 990c7b02b7fe9..144564b1cf9d1 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2509,9 +2509,9 @@ public function get_posts() { */ $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; } - // See get_pages(): when sort_column is 'none', the get_pages() function should not generate any ORDER BY clause. - // Should it rather be handled in the get_pages() function? - // src/wp-includes/post.php L6496 + // See get_pages(): when sort_column is 'none', the get_pages() function should not generate any ORDER BY clause. + // Should it rather be handled in the get_pages() function? + // src/wp-includes/post.php L6496 } elseif ( 'none' === $query_vars['orderby'] || ( is_array( $query_vars['orderby'] ) && array_key_exists( 'none', $query_vars['orderby'] ) ) ) { $orderby = ''; } else { diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index f7c81f63a21db..132b76beb069f 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -11,67 +11,118 @@ class Tests_Query_DeterministicOrdering extends WP_UnitTestCase { /** - * Test that deterministic ordering adds ID as tie-breaker for fields that can have duplicates. + * Test that deterministic ordering prevents duplicate records across pages. + * + * This is the core test for the bug fix. When multiple posts have the same + * value for a field (like post_date), pagination can show duplicate records + * without deterministic ordering. * * @ticket 44349 */ - public function test_deterministic_ordering_adds_id_tie_breaker() { - global $wpdb; - - // Create posts with same post_date to test deterministic ordering - $post1 = self::factory()->post->create( array( - 'post_title' => 'Post A', - 'post_date' => '2023-01-01 10:00:00', - ) ); - $post2 = self::factory()->post->create( array( - 'post_title' => 'Post B', - 'post_date' => '2023-01-01 10:00:00', // Same date as post1 - ) ); - $post3 = self::factory()->post->create( array( - 'post_title' => 'Post C', - 'post_date' => '2023-01-01 10:00:00', // Same date as post1 and post2 - ) ); - - // Test ordering by post_date (should add ID tie-breaker) - $query = new WP_Query( array( - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); - - // Verify SQL contains ID as secondary sort - $this->assertStringContainsString( 'ORDER BY', $query->request ); - $this->assertStringContainsString( 'post_date ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); // No double ASC + public function test_deterministic_ordering_prevents_duplicates_across_pages() { + // Create multiple posts with identical post_date to trigger the bug + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); + + for ( $i = 1; $i <= 20; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => "Post $i", + 'post_date' => $identical_date, + ) + ); + } + + // Get first page + $query1 = new WP_Query( + array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + // Get second page + $query2 = new WP_Query( + array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no overlap between pages (no duplicates) + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts' ); + + // Verify total count is correct + $this->assertEquals( 20, $query1->found_posts, 'Total posts should be 20' ); + $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); + $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); + + // Verify deterministic ordering: same query should return same results + $query1_repeat = new WP_Query( + array( + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + $page1_repeat_ids = wp_list_pluck( $query1_repeat->posts, 'ID' ); + + $this->assertEquals( $page1_ids, $page1_repeat_ids, 'Same query should return same results' ); } /** - * Test that deterministic ordering works with post_title. + * Test that deterministic ordering works with post_title field. * * @ticket 44349 */ public function test_deterministic_ordering_with_post_title() { - // Create posts with same title to test deterministic ordering - $post1 = self::factory()->post->create( array( - 'post_title' => 'Same Title', - 'post_date' => '2023-01-01 10:00:00', - ) ); - $post2 = self::factory()->post->create( array( - 'post_title' => 'Same Title', // Same title as post1 - 'post_date' => '2023-01-01 11:00:00', - ) ); - - $query = new WP_Query( array( - 'orderby' => 'post_title', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); - - // Verify SQL contains ID as secondary sort - $this->assertStringContainsString( 'post_title ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + $identical_title = 'Same Title'; + $post_ids = array(); + + for ( $i = 1; $i <= 15; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => $identical_title, + 'post_date' => "2023-01-0$i 10:00:00", + ) + ); + } + + // Get first page + $query1 = new WP_Query( + array( + 'orderby' => 'post_title', + 'order' => 'ASC', + 'posts_per_page' => 8, + 'paged' => 1, + ) + ); + + // Get second page + $query2 = new WP_Query( + array( + 'orderby' => 'post_title', + 'order' => 'ASC', + 'posts_per_page' => 8, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no duplicates across pages + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts when ordering by title' ); } /** @@ -80,16 +131,44 @@ public function test_deterministic_ordering_with_post_title() { * @ticket 44349 */ public function test_deterministic_ordering_with_desc_order() { - $query = new WP_Query( array( - 'orderby' => 'post_date', - 'order' => 'DESC', - 'posts_per_page' => 10, - ) ); - - // Verify SQL contains ID as secondary sort with DESC - $this->assertStringContainsString( 'post_date DESC', $query->request ); - $this->assertStringContainsString( 'ID DESC', $query->request ); - $this->assertStringNotContainsString( 'DESC DESC', $query->request ); + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); + + for ( $i = 1; $i <= 12; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => "Post $i", + 'post_date' => $identical_date, + ) + ); + } + + // Get first page with DESC order + $query1 = new WP_Query( + array( + 'orderby' => 'post_date', + 'order' => 'DESC', + 'posts_per_page' => 6, + 'paged' => 1, + ) + ); + + // Get second page with DESC order + $query2 = new WP_Query( + array( + 'orderby' => 'post_date', + 'order' => 'DESC', + 'posts_per_page' => 6, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no duplicates across pages + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts with DESC order' ); } /** @@ -98,111 +177,78 @@ public function test_deterministic_ordering_with_desc_order() { * @ticket 44349 */ public function test_deterministic_ordering_with_array_orderby() { - $query = new WP_Query( array( - 'orderby' => array( - 'post_date' => 'ASC', - 'post_title' => 'ASC', - ), - 'posts_per_page' => 10, - ) ); - - // Verify SQL contains both fields with directions - $this->assertStringContainsString( 'post_date ASC', $query->request ); - $this->assertStringContainsString( 'post_title ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); - } + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); + + for ( $i = 1; $i <= 16; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => "Post $i", + 'post_date' => $identical_date, + ) + ); + } - /** - * Test that deterministic ordering doesn't add ID when ID is already present. - * - * @ticket 44349 - */ - public function test_deterministic_ordering_does_not_duplicate_id() { - $query = new WP_Query( array( - 'orderby' => 'ID', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); + // Test with array orderby + $query1 = new WP_Query( + array( + 'orderby' => array( + 'post_date' => 'ASC', + 'post_title' => 'ASC', + ), + 'posts_per_page' => 8, + 'paged' => 1, + ) + ); + + $query2 = new WP_Query( + array( + 'orderby' => array( + 'post_date' => 'ASC', + 'post_title' => 'ASC', + ), + 'posts_per_page' => 8, + 'paged' => 2, + ) + ); - // Should not add duplicate ID - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ID ASC, ID ASC', $query->request ); - } + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); - /** - * Test that deterministic ordering works with fields that don't need it. - * - * @ticket 44349 - */ - public function test_deterministic_ordering_with_non_deterministic_fields() { - $query = new WP_Query( array( - 'orderby' => 'rand', - 'posts_per_page' => 10, - ) ); - - // Should not add ID tie-breaker for rand - $this->assertStringContainsString( 'RAND()', $query->request ); - $this->assertStringNotContainsString( 'ID ASC', $query->request ); + // Verify no duplicates across pages + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts with array orderby' ); } /** - * Test that deterministic ordering works with default ordering. + * Test that deterministic ordering doesn't add ID when ID is already present. * * @ticket 44349 */ - public function test_deterministic_ordering_with_default_ordering() { - $query = new WP_Query( array( - 'posts_per_page' => 10, - ) ); - - // Default ordering should include ID tie-breaker - $this->assertStringContainsString( 'post_date DESC', $query->request ); - $this->assertStringContainsString( 'ID DESC', $query->request ); - $this->assertStringNotContainsString( 'DESC DESC', $query->request ); - } + public function test_deterministic_ordering_does_not_duplicate_id() { + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); - /** - * Test that deterministic ordering prevents duplicate records across pages. - * - * @ticket 44349 - */ - public function test_deterministic_ordering_prevents_duplicates_across_pages() { - // Create multiple posts with same post_date - $posts = array(); for ( $i = 1; $i <= 10; $i++ ) { - $posts[] = self::factory()->post->create( array( - 'post_title' => "Post $i", - 'post_date' => '2023-01-01 10:00:00', // All same date - ) ); + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => "Post $i", + 'post_date' => $identical_date, + ) + ); } - // Get first page - $query1 = new WP_Query( array( - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 5, - 'paged' => 1, - ) ); - - // Get second page - $query2 = new WP_Query( array( - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 5, - 'paged' => 2, - ) ); - - $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); - $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + $query = new WP_Query( + array( + 'orderby' => 'ID', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) + ); - // No overlap between pages - $this->assertEmpty( array_intersect( $page1_ids, $page2_ids ) ); - - // Total posts should equal sum of both pages - $this->assertEquals( 10, $query1->found_posts ); - $this->assertEquals( 5, count( $page1_ids ) ); - $this->assertEquals( 5, count( $page2_ids ) ); + // Should not add duplicate ID ordering + $this->assertStringContainsString( 'ID ASC', $query->request ); + $this->assertStringNotContainsString( 'ID ASC, ID ASC', $query->request ); } /** @@ -211,81 +257,45 @@ public function test_deterministic_ordering_prevents_duplicates_across_pages() { * @ticket 44349 */ public function test_deterministic_ordering_with_search() { - // Create posts with searchable content - $post1 = self::factory()->post->create( array( - 'post_title' => 'Test Post 1', - 'post_content' => 'This is a test post', - 'post_date' => '2023-01-01 10:00:00', - ) ); - $post2 = self::factory()->post->create( array( - 'post_title' => 'Test Post 2', - 'post_content' => 'This is another test post', - 'post_date' => '2023-01-01 10:00:00', // Same date - ) ); - - $query = new WP_Query( array( - 's' => 'test', - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); - - // Should still have deterministic ordering even with search - $this->assertStringContainsString( 'post_date ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); - } + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); + + for ( $i = 1; $i <= 12; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_title' => "Test Post $i", + 'post_content' => 'This is a test post', + 'post_date' => $identical_date, + ) + ); + } - /** - * Test that deterministic ordering works with meta queries. - * - * @ticket 44349 - */ - public function test_deterministic_ordering_with_meta_query() { - // Create posts with meta values - $post1 = self::factory()->post->create(); - add_post_meta( $post1, 'test_meta', 'value1' ); - - $post2 = self::factory()->post->create(); - add_post_meta( $post2, 'test_meta', 'value2' ); - - $query = new WP_Query( array( - 'meta_key' => 'test_meta', - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); - - // Should still have deterministic ordering with meta queries - $this->assertStringContainsString( 'post_date ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); - } + // Test with search + $query1 = new WP_Query( + array( + 's' => 'test', + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 6, + 'paged' => 1, + ) + ); + + $query2 = new WP_Query( + array( + 's' => 'test', + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 6, + 'paged' => 2, + ) + ); - /** - * Test that deterministic ordering works with taxonomy queries. - * - * @ticket 44349 - */ - public function test_deterministic_ordering_with_taxonomy_query() { - // Create posts with categories - $post1 = self::factory()->post->create(); - $post2 = self::factory()->post->create(); - - $cat_id = self::factory()->category->create( array( 'name' => 'Test Category' ) ); - wp_set_post_categories( $post1, array( $cat_id ) ); - wp_set_post_categories( $post2, array( $cat_id ) ); - - $query = new WP_Query( array( - 'category_name' => 'test-category', - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - ) ); - - // Should still have deterministic ordering with taxonomy queries - $this->assertStringContainsString( 'post_date ASC', $query->request ); - $this->assertStringContainsString( 'ID ASC', $query->request ); - $this->assertStringNotContainsString( 'ASC ASC', $query->request ); + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no duplicates across pages even with search + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even with search' ); } } From 0429eda20c7f7dd73b13fb5f2fb44f24a75b7968 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 11:38:38 +1100 Subject: [PATCH 08/21] Remove ticket number in tests for now --- .../phpunit/tests/query/deterministicOrdering.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index 132b76beb069f..b46ed02757f03 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -6,7 +6,7 @@ * * @group query * @group ordering - * @ticket 44349 + * @ticket xxxxx */ class Tests_Query_DeterministicOrdering extends WP_UnitTestCase { @@ -17,7 +17,7 @@ class Tests_Query_DeterministicOrdering extends WP_UnitTestCase { * value for a field (like post_date), pagination can show duplicate records * without deterministic ordering. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_prevents_duplicates_across_pages() { // Create multiple posts with identical post_date to trigger the bug @@ -82,7 +82,7 @@ public function test_deterministic_ordering_prevents_duplicates_across_pages() { /** * Test that deterministic ordering works with post_title field. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_with_post_title() { $identical_title = 'Same Title'; @@ -128,7 +128,7 @@ public function test_deterministic_ordering_with_post_title() { /** * Test that deterministic ordering works with DESC order. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_with_desc_order() { $identical_date = '2023-01-01 10:00:00'; @@ -174,7 +174,7 @@ public function test_deterministic_ordering_with_desc_order() { /** * Test that deterministic ordering works with array orderby. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_with_array_orderby() { $identical_date = '2023-01-01 10:00:00'; @@ -223,7 +223,7 @@ public function test_deterministic_ordering_with_array_orderby() { /** * Test that deterministic ordering doesn't add ID when ID is already present. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_does_not_duplicate_id() { $identical_date = '2023-01-01 10:00:00'; @@ -254,7 +254,7 @@ public function test_deterministic_ordering_does_not_duplicate_id() { /** * Test that deterministic ordering works with search queries. * - * @ticket 44349 + * @ticket xxxxx */ public function test_deterministic_ordering_with_search() { $identical_date = '2023-01-01 10:00:00'; From 109cfc5eca948aa3fc16dc117dd6cd3b31f086c8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 15:58:41 +1100 Subject: [PATCH 09/21] Refactor WP_Query to ensure consistent ordering by appending ID as a secondary sort field in various scenarios. Update unit tests to reflect changes in expected SQL output for orderby cases, enhancing determinism in query results. --- src/wp-includes/class-wp-query.php | 17 ++++++++++++++--- .../tests/rest-api/rest-posts-controller.php | 18 +++++++++--------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 144564b1cf9d1..4e8fbbd3a5936 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2595,7 +2595,7 @@ public function get_posts() { if ( empty( $orderby_array ) ) { $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; } else { - $orderby = implode( ', ', $orderby_array ); + $orderby = trim( implode( ', ', $orderby_array ) ); } } @@ -3311,7 +3311,18 @@ public function get_posts() { } if ( $query_vars['cache_results'] && $id_query_is_cacheable ) { - $new_request = str_replace( $fields, "{$wpdb->posts}.*", $this->request ); + $new_request = $this->request; + // Split SQL into parts. + $parts = explode( 'ORDER BY', $new_request ); + if ( count( $parts ) === 2 ) { + // Replace only in the SELECT part, preserve ORDER BY. + $select_part = str_replace( $fields, "{$wpdb->posts}.*", $parts[0] ); + $new_request = $select_part . 'ORDER BY' . $parts[1]; + } else { + // No ORDER BY clause, safe to replace. + $new_request = str_replace( $fields, "{$wpdb->posts}.*", $new_request ); + } + $cache_key = $this->generate_cache_key( $query_vars, $new_request ); $cache_found = false; @@ -5100,7 +5111,7 @@ protected function generate_cache_key( array $args, $sql ) { // Add a default orderby value of date to ensure same cache key generation. if ( ! isset( $args['orderby'] ) ) { - $args['orderby'] = 'date'; + $args['orderby'] = 'date, ID'; } $placeholder = $wpdb->placeholder_escape(); diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index d701d12f9dd68..36d05afad67ce 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -488,7 +488,7 @@ public function test_get_items_include_query( $method ) { $this->assertSame( 2, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' ); } - $this->assertPostsOrderedBy( '{posts}.post_date DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_date DESC, {posts}.ID DESC' ); // 'orderby' => 'include'. $request->set_param( 'orderby', 'include' ); @@ -544,7 +544,7 @@ public function test_get_items_orderby_author_query() { $this->assertSame( self::$editor_id, $data[1]['author'] ); $this->assertSame( self::$editor_id, $data[2]['author'] ); - $this->assertPostsOrderedBy( '{posts}.post_author DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_author DESC, {posts}.ID DESC' ); } public function test_get_items_orderby_modified_query() { @@ -568,7 +568,7 @@ public function test_get_items_orderby_modified_query() { $this->assertSame( $id3, $data[1]['id'] ); $this->assertSame( $id2, $data[2]['id'] ); - $this->assertPostsOrderedBy( '{posts}.post_modified DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_modified DESC, {posts}.ID DESC' ); } public function test_get_items_orderby_parent_query() { @@ -606,7 +606,7 @@ public function test_get_items_orderby_parent_query() { $this->assertSame( 0, $data[1]['parent'] ); $this->assertSame( 0, $data[2]['parent'] ); - $this->assertPostsOrderedBy( '{posts}.post_parent DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_parent DESC, {posts}.ID DESC' ); } public function test_get_items_exclude_query() { @@ -976,14 +976,14 @@ public function test_get_items_order_and_orderby() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $this->assertSame( 'Apple Sauce', $data[0]['title']['rendered'] ); - $this->assertPostsOrderedBy( '{posts}.post_title DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_title DESC, {posts}.ID DESC' ); // 'order' => 'asc'. $request->set_param( 'order', 'asc' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $this->assertSame( 'Apple Cobbler', $data[0]['title']['rendered'] ); - $this->assertPostsOrderedBy( '{posts}.post_title ASC' ); + $this->assertPostsOrderedBy( '{posts}.post_title ASC, {posts}.ID ASC' ); // 'order' => 'asc,id' should error. $request->set_param( 'order', 'asc,id' ); @@ -1068,7 +1068,7 @@ public function test_get_items_with_orderby_slug() { // Default ORDER is DESC. $this->assertSame( 'xyz', $data[0]['slug'] ); $this->assertSame( 'abc', $data[1]['slug'] ); - $this->assertPostsOrderedBy( '{posts}.post_name DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_name DESC, {posts}.ID DESC' ); } public function test_get_items_with_orderby_slugs() { @@ -1120,7 +1120,7 @@ public function test_get_items_with_orderby_relevance() { $this->assertCount( 2, $data ); $this->assertSame( $id1, $data[0]['id'] ); $this->assertSame( $id2, $data[1]['id'] ); - $this->assertPostsOrderedBy( '{posts}.post_title LIKE \'%relevant%\' DESC, {posts}.post_date DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_title LIKE \'%relevant%\' DESC, {posts}.post_date DESC, {posts}.ID DESC' ); } public function test_get_items_with_orderby_relevance_two_terms() { @@ -1148,7 +1148,7 @@ public function test_get_items_with_orderby_relevance_two_terms() { $this->assertCount( 2, $data ); $this->assertSame( $id1, $data[0]['id'] ); $this->assertSame( $id2, $data[1]['id'] ); - $this->assertPostsOrderedBy( '(CASE WHEN {posts}.post_title LIKE \'%relevant content%\' THEN 1 WHEN {posts}.post_title LIKE \'%relevant%\' AND {posts}.post_title LIKE \'%content%\' THEN 2 WHEN {posts}.post_title LIKE \'%relevant%\' OR {posts}.post_title LIKE \'%content%\' THEN 3 WHEN {posts}.post_excerpt LIKE \'%relevant content%\' THEN 4 WHEN {posts}.post_content LIKE \'%relevant content%\' THEN 5 ELSE 6 END), {posts}.post_date DESC' ); + $this->assertPostsOrderedBy( '(CASE WHEN {posts}.post_title LIKE \'%relevant content%\' THEN 1 WHEN {posts}.post_title LIKE \'%relevant%\' AND {posts}.post_title LIKE \'%content%\' THEN 2 WHEN {posts}.post_title LIKE \'%relevant%\' OR {posts}.post_title LIKE \'%content%\' THEN 3 WHEN {posts}.post_excerpt LIKE \'%relevant content%\' THEN 4 WHEN {posts}.post_content LIKE \'%relevant content%\' THEN 5 ELSE 6 END), {posts}.post_date DESC, {posts}.ID DESC' ); } public function test_get_items_with_orderby_relevance_missing_search() { From 45ab8af977a1b7f919818eec99d9af3642978ddc Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 16:37:52 +1100 Subject: [PATCH 10/21] Enhance WP_Query ordering logic by normalizing 'date' to 'date, ID' for consistent cache key generation. This change ensures deterministic results when 'date' is specified as the orderby value, improving query consistency. --- src/wp-includes/class-wp-query.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 4e8fbbd3a5936..9d16b0857e7f3 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -5109,9 +5109,13 @@ protected function generate_cache_key( array $args, $sql ) { sort( $args['post_status'] ); } + // Add a default orderby value of date to ensure same cache key generation. // Add a default orderby value of date to ensure same cache key generation. if ( ! isset( $args['orderby'] ) ) { $args['orderby'] = 'date, ID'; + } elseif ( $args['orderby'] === 'date' ) { + // Normalize 'date' to 'date, ID' to match deterministic ordering + $args['orderby'] = 'date, ID'; } $placeholder = $wpdb->placeholder_escape(); From f763cc3255470905117fd46c866f7be2ad22bf90 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 16:47:36 +1100 Subject: [PATCH 11/21] lint --- src/wp-includes/class-wp-query.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 9d16b0857e7f3..cddc7abf8eabf 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -3324,8 +3324,8 @@ public function get_posts() { } $cache_key = $this->generate_cache_key( $query_vars, $new_request ); - $cache_found = false; + if ( null === $this->posts ) { $cached_results = wp_cache_get_salted( $cache_key, 'post-queries', $last_changed ); @@ -5109,12 +5109,14 @@ protected function generate_cache_key( array $args, $sql ) { sort( $args['post_status'] ); } - // Add a default orderby value of date to ensure same cache key generation. - // Add a default orderby value of date to ensure same cache key generation. + + /* + * Ensure deterministic ordering to prevent duplicate records across pages. + * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. + */ if ( ! isset( $args['orderby'] ) ) { $args['orderby'] = 'date, ID'; - } elseif ( $args['orderby'] === 'date' ) { - // Normalize 'date' to 'date, ID' to match deterministic ordering + } elseif ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date, ID'; } From 54e4cf49b10cb98cac12a45ba35169f57696d17a Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 22 Oct 2025 16:48:18 +1100 Subject: [PATCH 12/21] linto --- src/wp-includes/class-wp-query.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index cddc7abf8eabf..50d8c7c507629 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -5109,7 +5109,6 @@ protected function generate_cache_key( array $args, $sql ) { sort( $args['post_status'] ); } - /* * Ensure deterministic ordering to prevent duplicate records across pages. * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. From 80d9298b4828ada43c0d081e5f1c09913857c8d4 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 5 Dec 2025 12:30:17 +1100 Subject: [PATCH 13/21] Fix date formatting in deterministic ordering test to ensure consistent post creation. Updated post_date to use str_pad for zero-padding single-digit days. --- tests/phpunit/tests/query/deterministicOrdering.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index b46ed02757f03..a6bdfb915c6b9 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -92,7 +92,7 @@ public function test_deterministic_ordering_with_post_title() { $post_ids[] = self::factory()->post->create( array( 'post_title' => $identical_title, - 'post_date' => "2023-01-0$i 10:00:00", + 'post_date' => "2023-01-" . str_pad((string) $i, 2, '0', STR_PAD_LEFT) . " 10:00:00", ) ); } From 63d627fab5f5364c5a68829c93740b2aa13d4e07 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 5 Dec 2025 23:04:30 +1100 Subject: [PATCH 14/21] Refactor deterministic ordering tests to utilize shared fixtures for post creation. Introduced separate arrays for posts with identical dates, titles, and menu orders to enhance test clarity and maintainability. Updated queries to reference these shared fixtures, ensuring consistent results across pagination and ordering scenarios. --- .../tests/query/deterministicOrdering.php | 271 +++++++++++++----- 1 file changed, 200 insertions(+), 71 deletions(-) diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index a6bdfb915c6b9..0c74fb1ed886c 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -11,31 +11,135 @@ class Tests_Query_DeterministicOrdering extends WP_UnitTestCase { /** - * Test that deterministic ordering prevents duplicate records across pages. + * Post IDs for posts with identical dates (for date ordering tests). * - * This is the core test for the bug fix. When multiple posts have the same - * value for a field (like post_date), pagination can show duplicate records - * without deterministic ordering. + * @var array + */ + protected static $date_identical_post_ids = array(); + + /** + * Post IDs for posts with identical titles (for title ordering tests). * - * @ticket xxxxx + * @var array */ - public function test_deterministic_ordering_prevents_duplicates_across_pages() { - // Create multiple posts with identical post_date to trigger the bug - $identical_date = '2023-01-01 10:00:00'; - $post_ids = array(); + protected static $title_identical_post_ids = array(); + + /** + * Post IDs for search tests. + * + * @var array + */ + protected static $search_post_ids = array(); + + /** + * Post IDs for menu_order tests. + * + * @var array + */ + protected static $menu_order_post_ids = array(); + + /** + * Set up shared fixtures for all tests. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + // Register custom post types for test isolation. + register_post_type( + 'wptests_time_ident', + array( + 'public' => true, + ) + ); + + register_post_type( + 'wptests_title_ident', + array( + 'public' => true, + ) + ); + + // Create posts with identical dates for date ordering tests. + $identical_date = '2023-01-01 10:00:00'; for ( $i = 1; $i <= 20; $i++ ) { - $post_ids[] = self::factory()->post->create( + self::$date_identical_post_ids[] = self::factory()->post->create( array( + 'post_type' => 'wptests_time_ident', 'post_title' => "Post $i", 'post_date' => $identical_date, ) ); } + // Create posts with identical titles for title ordering tests. + $identical_title = 'Same Title'; + for ( $i = 1; $i <= 15; $i++ ) { + self::$title_identical_post_ids[] = self::factory()->post->create( + array( + 'post_type' => 'wptests_title_ident', + 'post_title' => $identical_title, + 'post_date' => '2023-01-' . str_pad( (string) $i, 2, '0', STR_PAD_LEFT ) . ' 10:00:00', + ) + ); + } + + // Create posts for search tests. + $identical_date = '2023-01-01 10:00:00'; + for ( $i = 1; $i <= 12; $i++ ) { + self::$search_post_ids[] = self::factory()->post->create( + array( + 'post_type' => 'wptests_time_ident', + 'post_title' => "Test Post $i", + 'post_content' => 'This is a test post', + 'post_date' => $identical_date, + ) + ); + } + + // Create pages with identical menu_order for menu_order tests. + for ( $i = 1; $i <= 20; $i++ ) { + self::$menu_order_post_ids[] = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => "Page $i", + 'menu_order' => 0, // All pages have same menu_order + ) + ); + } + } + + /** + * Clean up after all tests. + */ + public static function tear_down_after_class() { + _unregister_post_type( 'wptests_time_ident' ); + _unregister_post_type( 'wptests_title_ident' ); + + self::$date_identical_post_ids = array(); + self::$title_identical_post_ids = array(); + self::$search_post_ids = array(); + self::$menu_order_post_ids = array(); + + parent::tear_down_after_class(); + } + + /** + * Test that deterministic ordering prevents duplicate records across pages. + * + * This is the core test for the bug fix. When multiple posts have the same + * value for a field (like post_date), pagination can show duplicate records + * without deterministic ordering. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_prevents_duplicates_across_pages() { + // Use shared fixtures with identical post_date + // Get first page $query1 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'ASC', 'posts_per_page' => 10, @@ -46,6 +150,8 @@ public function test_deterministic_ordering_prevents_duplicates_across_pages() { // Get second page $query2 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'ASC', 'posts_per_page' => 10, @@ -68,6 +174,8 @@ public function test_deterministic_ordering_prevents_duplicates_across_pages() { // Verify deterministic ordering: same query should return same results $query1_repeat = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'ASC', 'posts_per_page' => 10, @@ -85,21 +193,12 @@ public function test_deterministic_ordering_prevents_duplicates_across_pages() { * @ticket xxxxx */ public function test_deterministic_ordering_with_post_title() { - $identical_title = 'Same Title'; - $post_ids = array(); - - for ( $i = 1; $i <= 15; $i++ ) { - $post_ids[] = self::factory()->post->create( - array( - 'post_title' => $identical_title, - 'post_date' => "2023-01-" . str_pad((string) $i, 2, '0', STR_PAD_LEFT) . " 10:00:00", - ) - ); - } - + // Use shared fixtures with identical post_title // Get first page $query1 = new WP_Query( array( + 'post_type' => 'wptests_title_ident', + 'post__in' => self::$title_identical_post_ids, 'orderby' => 'post_title', 'order' => 'ASC', 'posts_per_page' => 8, @@ -110,6 +209,8 @@ public function test_deterministic_ordering_with_post_title() { // Get second page $query2 = new WP_Query( array( + 'post_type' => 'wptests_title_ident', + 'post__in' => self::$title_identical_post_ids, 'orderby' => 'post_title', 'order' => 'ASC', 'posts_per_page' => 8, @@ -131,21 +232,12 @@ public function test_deterministic_ordering_with_post_title() { * @ticket xxxxx */ public function test_deterministic_ordering_with_desc_order() { - $identical_date = '2023-01-01 10:00:00'; - $post_ids = array(); - - for ( $i = 1; $i <= 12; $i++ ) { - $post_ids[] = self::factory()->post->create( - array( - 'post_title' => "Post $i", - 'post_date' => $identical_date, - ) - ); - } - + // Use shared fixtures with identical post_date // Get first page with DESC order $query1 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'DESC', 'posts_per_page' => 6, @@ -156,6 +248,8 @@ public function test_deterministic_ordering_with_desc_order() { // Get second page with DESC order $query2 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'DESC', 'posts_per_page' => 6, @@ -177,21 +271,12 @@ public function test_deterministic_ordering_with_desc_order() { * @ticket xxxxx */ public function test_deterministic_ordering_with_array_orderby() { - $identical_date = '2023-01-01 10:00:00'; - $post_ids = array(); - - for ( $i = 1; $i <= 16; $i++ ) { - $post_ids[] = self::factory()->post->create( - array( - 'post_title' => "Post $i", - 'post_date' => $identical_date, - ) - ); - } - + // Use shared fixtures with identical post_date // Test with array orderby $query1 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => array( 'post_date' => 'ASC', 'post_title' => 'ASC', @@ -203,6 +288,8 @@ public function test_deterministic_ordering_with_array_orderby() { $query2 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => array( 'post_date' => 'ASC', 'post_title' => 'ASC', @@ -226,20 +313,11 @@ public function test_deterministic_ordering_with_array_orderby() { * @ticket xxxxx */ public function test_deterministic_ordering_does_not_duplicate_id() { - $identical_date = '2023-01-01 10:00:00'; - $post_ids = array(); - - for ( $i = 1; $i <= 10; $i++ ) { - $post_ids[] = self::factory()->post->create( - array( - 'post_title' => "Post $i", - 'post_date' => $identical_date, - ) - ); - } - + // Use shared fixtures with identical post_date $query = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, 'orderby' => 'ID', 'order' => 'ASC', 'posts_per_page' => 10, @@ -257,22 +335,12 @@ public function test_deterministic_ordering_does_not_duplicate_id() { * @ticket xxxxx */ public function test_deterministic_ordering_with_search() { - $identical_date = '2023-01-01 10:00:00'; - $post_ids = array(); - - for ( $i = 1; $i <= 12; $i++ ) { - $post_ids[] = self::factory()->post->create( - array( - 'post_title' => "Test Post $i", - 'post_content' => 'This is a test post', - 'post_date' => $identical_date, - ) - ); - } - + // Use shared fixtures for search tests // Test with search $query1 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_post_ids, 's' => 'test', 'orderby' => 'post_date', 'order' => 'ASC', @@ -283,6 +351,8 @@ public function test_deterministic_ordering_with_search() { $query2 = new WP_Query( array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_post_ids, 's' => 'test', 'orderby' => 'post_date', 'order' => 'ASC', @@ -298,4 +368,63 @@ public function test_deterministic_ordering_with_search() { $overlap = array_intersect( $page1_ids, $page2_ids ); $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even with search' ); } + + /** + * Test that deterministic ordering works with menu_order field. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_with_menu_order() { + // Use shared fixtures with identical menu_order + // Get first page + $query1 = new WP_Query( + array( + 'post_type' => 'page', + 'post__in' => self::$menu_order_post_ids, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + // Get second page + $query2 = new WP_Query( + array( + 'post_type' => 'page', + 'post__in' => self::$menu_order_post_ids, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no overlap between pages (no duplicates) + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts when ordering by menu_order' ); + + // Verify total count is correct + $this->assertEquals( 20, $query1->found_posts, 'Total pages should be 20' ); + $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 pages' ); + $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 pages' ); + + // Verify deterministic ordering: same query should return same results + $query1_repeat = new WP_Query( + array( + 'post_type' => 'page', + 'post__in' => self::$menu_order_post_ids, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + $page1_repeat_ids = wp_list_pluck( $query1_repeat->posts, 'ID' ); + + $this->assertEquals( $page1_ids, $page1_repeat_ids, 'Same query should return same results when ordering by menu_order' ); + } } From a71e6c64fdacadeca52d233fd743f616a1002ea1 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 5 Dec 2025 23:33:19 +1100 Subject: [PATCH 15/21] Refactor WP_Query ordering logic to implement a blacklist approach for deterministic ordering. Updated comments for clarity and introduced a new test for metadata ordering to ensure no duplicates across paginated results. --- src/wp-includes/class-wp-query.php | 38 ++++------ .../tests/query/deterministicOrdering.php | 70 +++++++++++++++++++ 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 50d8c7c507629..e700ddcd1b200 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2519,24 +2519,14 @@ public function get_posts() { * Ensure deterministic ordering to prevent duplicate records across pages. * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. * Note: this is to circumvent a bug that is currently being tracked in https://core.trac.wordpress.org/ticket/44349. + * + * Use a blacklist approach: add ID as tie-breaker for all orderby fields except those that are + * already deterministic (ID itself, random ordering, or search relevance). */ - $fields_requiring_deterministic_orderby = array( - 'post_name', - 'post_author', - 'post_date', - 'post_title', - 'post_modified', - 'post_parent', - 'post_type', - 'name', - 'author', - 'date', - 'title', - 'modified', - 'parent', - 'type', - 'menu_order', - 'comment_count', + $fields_excluding_deterministic_orderby = array( + 'ID', + 'rand', + 'relevance', ); $orderby_array = array(); @@ -2555,10 +2545,10 @@ public function get_posts() { $orderby_array[] = $parsed . ' ' . $this->parse_order( $order ); - // Check if this field needs deterministic ordering - if ( in_array( $_orderby, $fields_requiring_deterministic_orderby, true ) ) { + // Check if this field should have deterministic ordering (not in blacklist). + if ( ! in_array( $_orderby, $fields_excluding_deterministic_orderby, true ) ) { $needs_deterministic_orderby = true; - // Use the order from the array for ID tie-breaker + // Use the order from the array for ID tie-breaker. $id_tie_breaker_order = $this->parse_order( $order ); } elseif ( 'ID' === $_orderby ) { $has_id_orderby = true; @@ -2577,8 +2567,8 @@ public function get_posts() { $orderby_array[] = $parsed . ' ' . $query_vars['order']; - // Check if this field needs deterministic ordering - if ( in_array( $orderby, $fields_requiring_deterministic_orderby, true ) ) { + // Check if this field should have deterministic ordering (not in blacklist). + if ( ! in_array( $orderby, $fields_excluding_deterministic_orderby, true ) ) { $needs_deterministic_orderby = true; } elseif ( 'ID' === $orderby ) { $has_id_orderby = true; @@ -2586,12 +2576,12 @@ public function get_posts() { } } - // Add ID as tie-breaker if needed and not already present + // Add ID as tie-breaker if needed and not already present. if ( $needs_deterministic_orderby && ! $has_id_orderby ) { $orderby_array[] = "{$wpdb->posts}.ID " . $id_tie_breaker_order; } - // Build the final orderby string + // Build the final orderby string. if ( empty( $orderby_array ) ) { $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; } else { diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index 0c74fb1ed886c..264d964aee23f 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -427,4 +427,74 @@ public function test_deterministic_ordering_with_menu_order() { $this->assertEquals( $page1_ids, $page1_repeat_ids, 'Same query should return same results when ordering by menu_order' ); } + + /** + * Test that deterministic ordering works with metadata ordering. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_with_metadata() { + $post_ids = array(); + + // Create posts with identical meta values to trigger the bug + $identical_meta_value = 'same_price'; + for ( $i = 1; $i <= 20; $i++ ) { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wptests_time_ident', + 'post_title' => "Post $i", + ) + ); + add_post_meta( $post_id, 'price', $identical_meta_value ); + $post_ids[] = $post_id; + } + + // Get first page ordering by metadata + $query1 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => $post_ids, + 'meta_query' => array( + 'price_key' => array( + 'key' => 'price', + 'compare' => 'EXISTS', + ), + ), + 'orderby' => 'price_key', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + // Get second page ordering by metadata + $query2 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => $post_ids, + 'meta_query' => array( + 'price_key' => array( + 'key' => 'price', + 'compare' => 'EXISTS', + ), + ), + 'orderby' => 'price_key', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no overlap between pages (no duplicates) + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts when ordering by metadata' ); + + // Verify total count is correct + $this->assertEquals( 20, $query1->found_posts, 'Total posts should be 20' ); + $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); + $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); + } } From f64169a0cd0598f125cf2262f52b3b60741dd8d9 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 5 Dec 2025 23:50:52 +1100 Subject: [PATCH 16/21] Add search relevance tests to deterministic ordering suite Introduced new tests to verify deterministic ordering when posts are ordered by search relevance. Created shared fixtures for posts with identical content to ensure consistent relevance scores, preventing duplicates across paginated results. Updated the test suite to include scenarios for both explicit and empty orderby parameters, ensuring robust coverage of search-related ordering behavior. --- .../tests/query/deterministicOrdering.php | 144 +++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index 264d964aee23f..1f6941e9fbf6e 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -38,6 +38,13 @@ class Tests_Query_DeterministicOrdering extends WP_UnitTestCase { */ protected static $menu_order_post_ids = array(); + /** + * Post IDs for search relevance tests. + * + * @var array + */ + protected static $search_relevance_post_ids = array(); + /** * Set up shared fixtures for all tests. */ @@ -106,6 +113,20 @@ public static function set_up_before_class() { ) ); } + + // Create posts for search relevance tests. + // All posts will have the same content to ensure same relevance scores. + $identical_content = 'This is a search test post with identical content'; + for ( $i = 1; $i <= 20; $i++ ) { + self::$search_relevance_post_ids[] = self::factory()->post->create( + array( + 'post_type' => 'wptests_time_ident', + 'post_title' => "Search Post $i", + 'post_content' => $identical_content, + 'post_excerpt' => $identical_content, + ) + ); + } } /** @@ -115,10 +136,11 @@ public static function tear_down_after_class() { _unregister_post_type( 'wptests_time_ident' ); _unregister_post_type( 'wptests_title_ident' ); - self::$date_identical_post_ids = array(); - self::$title_identical_post_ids = array(); - self::$search_post_ids = array(); - self::$menu_order_post_ids = array(); + self::$date_identical_post_ids = array(); + self::$title_identical_post_ids = array(); + self::$search_post_ids = array(); + self::$menu_order_post_ids = array(); + self::$search_relevance_post_ids = array(); parent::tear_down_after_class(); } @@ -497,4 +519,118 @@ public function test_deterministic_ordering_with_metadata() { $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); } + + /** + * Test that deterministic ordering works with search relevance ordering. + * + * When ordering by search relevance, multiple posts can have the same relevance score, + * causing duplicate records across pages without deterministic ordering. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_with_search_relevance() { + // Use shared fixtures with identical content (same relevance scores) + // Get first page ordering by relevance + $query1 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_relevance_post_ids, + 's' => 'search test', + 'orderby' => 'relevance', + 'order' => 'DESC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + // Get second page ordering by relevance + $query2 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_relevance_post_ids, + 's' => 'search test', + 'orderby' => 'relevance', + 'order' => 'DESC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no overlap between pages (no duplicates) + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts when ordering by search relevance' ); + + // Verify total count is correct + $this->assertEquals( 20, $query1->found_posts, 'Total posts should be 20' ); + $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); + $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); + + // Verify deterministic ordering: same query should return same results + $query1_repeat = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_relevance_post_ids, + 's' => 'search test', + 'orderby' => 'relevance', + 'order' => 'DESC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + $page1_repeat_ids = wp_list_pluck( $query1_repeat->posts, 'ID' ); + + $this->assertEquals( $page1_ids, $page1_repeat_ids, 'Same query should return same results when ordering by search relevance' ); + } + + /** + * Test that deterministic ordering works with search when orderby is empty (defaults to relevance). + * + * When orderby is empty and search is present, WordPress orders by relevance. + * Multiple posts can have the same relevance score, causing duplicate records across pages. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_with_search_empty_orderby() { + // Use shared fixtures with identical content (same relevance scores) + // Get first page with empty orderby (defaults to relevance) + $query1 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_relevance_post_ids, + 's' => 'search test', + 'orderby' => '', // Empty orderby with search defaults to relevance + 'order' => 'DESC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + // Get second page with empty orderby + $query2 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$search_relevance_post_ids, + 's' => 'search test', + 'orderby' => '', // Empty orderby with search defaults to relevance + 'order' => 'DESC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no overlap between pages (no duplicates) + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts when ordering by search relevance (empty orderby)' ); + + // Verify total count is correct + $this->assertEquals( 20, $query1->found_posts, 'Total posts should be 20' ); + $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); + $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); + } } From 671cc853c4c369b05112046579d9dcf823d478a4 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 6 Dec 2025 00:15:08 +1100 Subject: [PATCH 17/21] Enhance WP_Query ordering by adding new fields to the orderby array. Included 'post__in', 'post_name__in', 'post_parent__in', and 'include' to improve query flexibility and support additional ordering scenarios. --- src/wp-includes/class-wp-query.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index e700ddcd1b200..19f3fa274dc7b 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2527,6 +2527,10 @@ public function get_posts() { 'ID', 'rand', 'relevance', + 'post__in', + 'post_name__in', + 'post_parent__in', + 'include', ); $orderby_array = array(); From ffe31dc82d377169901c6cfd34e27e7cd176d218 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 31 Dec 2025 15:52:01 +1100 Subject: [PATCH 18/21] Implement deterministic ordering in WP_Query by adding ID tie-breaker after filters. Updated logic to ensure filters receive the original orderby value, maintaining backward compatibility. Added tests to verify correct behavior when filters modify orderby and prevent duplicate posts across paginated results. --- src/wp-includes/class-wp-query.php | 63 +++-- .../tests/query/deterministicOrdering.php | 219 ++++++++++++++++++ 2 files changed, 263 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 19f3fa274dc7b..d36d71bbb3135 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2494,6 +2494,13 @@ public function get_posts() { } // Order by. + // Store metadata for deterministic ordering to be applied after filters. + $deterministic_orderby_meta = array( + 'needed' => false, + 'has_id' => false, + 'order' => $query_vars['order'], + ); + if ( empty( $query_vars['orderby'] ) ) { /* * Boolean false or empty array blanks out ORDER BY, @@ -2506,13 +2513,16 @@ public function get_posts() { * Ensure deterministic ordering to prevent duplicate records across pages. * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. * Note: this is to circumvent a bug that is currently being tracked in https://core.trac.wordpress.org/ticket/44349. + * + * Build base orderby without ID tie-breaker for filters, then add it after filters. */ - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + $deterministic_orderby_meta['needed'] = true; } // See get_pages(): when sort_column is 'none', the get_pages() function should not generate any ORDER BY clause. // Should it rather be handled in the get_pages() function? // src/wp-includes/post.php L6496 - } elseif ( 'none' === $query_vars['orderby'] || ( is_array( $query_vars['orderby'] ) && array_key_exists( 'none', $query_vars['orderby'] ) ) ) { + } elseif ( 'none' === $query_vars['orderby'] || isset( $query_vars['orderby']['none'] ) ) { $orderby = ''; } else { /* @@ -2522,6 +2532,8 @@ public function get_posts() { * * Use a blacklist approach: add ID as tie-breaker for all orderby fields except those that are * already deterministic (ID itself, random ordering, or search relevance). + * + * Build base orderby without ID tie-breaker for filters, then add it after filters. */ $fields_excluding_deterministic_orderby = array( 'ID', @@ -2533,10 +2545,7 @@ public function get_posts() { 'include', ); - $orderby_array = array(); - $needs_deterministic_orderby = false; - $has_id_orderby = false; - $id_tie_breaker_order = $query_vars['order']; // Default to global order + $orderby_array = array(); if ( is_array( $query_vars['orderby'] ) ) { foreach ( $query_vars['orderby'] as $_orderby => $order ) { @@ -2551,11 +2560,11 @@ public function get_posts() { // Check if this field should have deterministic ordering (not in blacklist). if ( ! in_array( $_orderby, $fields_excluding_deterministic_orderby, true ) ) { - $needs_deterministic_orderby = true; + $deterministic_orderby_meta['needed'] = true; // Use the order from the array for ID tie-breaker. - $id_tie_breaker_order = $this->parse_order( $order ); + $deterministic_orderby_meta['order'] = $this->parse_order( $order ); } elseif ( 'ID' === $_orderby ) { - $has_id_orderby = true; + $deterministic_orderby_meta['has_id'] = true; } } } else { @@ -2573,21 +2582,17 @@ public function get_posts() { // Check if this field should have deterministic ordering (not in blacklist). if ( ! in_array( $orderby, $fields_excluding_deterministic_orderby, true ) ) { - $needs_deterministic_orderby = true; + $deterministic_orderby_meta['needed'] = true; } elseif ( 'ID' === $orderby ) { - $has_id_orderby = true; + $deterministic_orderby_meta['has_id'] = true; } } } - // Add ID as tie-breaker if needed and not already present. - if ( $needs_deterministic_orderby && ! $has_id_orderby ) { - $orderby_array[] = "{$wpdb->posts}.ID " . $id_tie_breaker_order; - } - - // Build the final orderby string. + // Build the base orderby string (without ID tie-breaker) for filters. if ( empty( $orderby_array ) ) { - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order'] . ', ' . "{$wpdb->posts}.ID " . $query_vars['order']; + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + $deterministic_orderby_meta['needed'] = true; } else { $orderby = trim( implode( ', ', $orderby_array ) ); } @@ -3206,12 +3211,31 @@ public function get_posts() { $where = $clauses['where'] ?? ''; $groupby = $clauses['groupby'] ?? ''; $join = $clauses['join'] ?? ''; - $orderby = $clauses['orderby'] ?? ''; + // Preserve orderby from posts_orderby_request if posts_clauses_request doesn't provide one. + $orderby = $clauses['orderby'] ?? $orderby; $distinct = $clauses['distinct'] ?? ''; $fields = $clauses['fields'] ?? ''; $limits = $clauses['limits'] ?? ''; } + /* + * Ensure deterministic ordering to prevent duplicate records across pages. + * Add ID tie-breaker after filters have been applied, so filters receive + * the original orderby value (for backward compatibility) and the tie-breaker + * is preserved even if filters modify the orderby. + * + * Note: this is to circumvent a bug that is currently being tracked in + * https://core.trac.wordpress.org/ticket/44349. + */ + if ( ! empty( $orderby ) && $deterministic_orderby_meta['needed'] ) { + // Check if ID tie-breaker is already present in the orderby string. + $id_tie_breaker_pattern = '/\b' . preg_quote( $wpdb->posts, '/' ) . '\.ID\b/i'; + if ( ! preg_match( $id_tie_breaker_pattern, $orderby ) ) { + // Add ID as tie-breaker at the end. + $orderby .= ', ' . "{$wpdb->posts}.ID " . $deterministic_orderby_meta['order']; + } + } + if ( ! empty( $groupby ) ) { $groupby = 'GROUP BY ' . $groupby; } @@ -5186,3 +5210,4 @@ public function lazyload_comment_meta( $check, $comment_id ) { return $check; } } + diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index 1f6941e9fbf6e..e4a3cedc47619 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -633,4 +633,223 @@ public function test_deterministic_ordering_with_search_empty_orderby() { $this->assertEquals( 10, count( $page1_ids ), 'First page should have 10 posts' ); $this->assertEquals( 10, count( $page2_ids ), 'Second page should have 10 posts' ); } + + /** + * Test that filters receive the original orderby value (without ID tie-breaker). + * + * This ensures backward compatibility - filters should receive the same orderby + * value they received before the deterministic ordering changes. + * + * @ticket xxxxx + */ + public function test_filters_receive_original_orderby() { + global $wpdb; + + $received_orderby = ''; + + // Capture the orderby value received by the filter. + $filter_callback = function( $orderby ) use ( &$received_orderby ) { + $received_orderby = $orderby; + return $orderby; + }; + + add_filter( 'posts_orderby', $filter_callback ); + + $query = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) + ); + + remove_filter( 'posts_orderby', $filter_callback ); + + // Filter should receive orderby without ID tie-breaker. + $expected_orderby = "{$wpdb->posts}.post_date ASC"; + $this->assertEquals( $expected_orderby, $received_orderby, 'Filter should receive original orderby without ID tie-breaker' ); + + // But the final query should still have ID tie-breaker for deterministic ordering. + $this->assertStringContainsString( 'ID ASC', $query->request, 'Final query should have ID tie-breaker' ); + } + + /** + * Test that posts_clauses filter receives original orderby (without ID tie-breaker). + * + * @ticket xxxxx + */ + public function test_posts_clauses_filter_receives_original_orderby() { + global $wpdb; + + $received_orderby = ''; + + // Capture the orderby value received by the filter. + $filter_callback = function( $clauses ) use ( &$received_orderby ) { + $received_orderby = $clauses['orderby'] ?? ''; + return $clauses; + }; + + add_filter( 'posts_clauses', $filter_callback ); + + $query = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) + ); + + remove_filter( 'posts_clauses', $filter_callback ); + + // Filter should receive orderby without ID tie-breaker. + $expected_orderby = "{$wpdb->posts}.post_date ASC"; + $this->assertEquals( $expected_orderby, $received_orderby, 'posts_clauses filter should receive original orderby without ID tie-breaker' ); + + // But the final query should still have ID tie-breaker. + $this->assertStringContainsString( 'ID ASC', $query->request, 'Final query should have ID tie-breaker' ); + } + + /** + * Test that deterministic ordering works when filters modify orderby. + * + * Even if a filter modifies the orderby, the ID tie-breaker should still + * be added after the filter to ensure deterministic ordering. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_works_after_filter_modifies_orderby() { + // Filter that modifies the orderby. + $filter_callback = function( $orderby ) { + // Add a custom field to the orderby. + global $wpdb; + return $orderby . ', ' . "{$wpdb->posts}.post_title ASC"; + }; + + add_filter( 'posts_orderby', $filter_callback ); + + $query1 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + $query2 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + remove_filter( 'posts_orderby', $filter_callback ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no duplicates across pages even when filter modifies orderby. + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even when filter modifies orderby' ); + + // Verify ID tie-breaker is still present in the final query. + $this->assertStringContainsString( 'ID ASC', $query1->request, 'ID tie-breaker should be present after filter modifies orderby' ); + } + + /** + * Test that deterministic ordering works when posts_clauses filter modifies orderby. + * + * @ticket xxxxx + */ + public function test_deterministic_ordering_works_after_posts_clauses_modifies_orderby() { + // Filter that modifies the orderby via posts_clauses. + $filter_callback = function( $clauses ) { + global $wpdb; + // Modify orderby to add post_title. + $clauses['orderby'] = "{$wpdb->posts}.post_date ASC, {$wpdb->posts}.post_title ASC"; + return $clauses; + }; + + add_filter( 'posts_clauses', $filter_callback ); + + $query1 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 1, + ) + ); + + $query2 = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + 'paged' => 2, + ) + ); + + remove_filter( 'posts_clauses', $filter_callback ); + + $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); + $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); + + // Verify no duplicates across pages. + $overlap = array_intersect( $page1_ids, $page2_ids ); + $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even when posts_clauses modifies orderby' ); + + // Verify ID tie-breaker is still present. + $this->assertStringContainsString( 'ID ASC', $query1->request, 'ID tie-breaker should be present after posts_clauses modifies orderby' ); + } + + /** + * Test that ID tie-breaker is not duplicated when filter already includes ID. + * + * If a filter adds ID to the orderby, we should not add it again. + * + * @ticket xxxxx + */ + public function test_id_tie_breaker_not_duplicated_when_filter_includes_id() { + global $wpdb; + + // Filter that already includes ID in orderby. + $filter_callback = function( $orderby ) use ( $wpdb ) { + return "{$wpdb->posts}.post_date ASC, {$wpdb->posts}.ID ASC"; + }; + + add_filter( 'posts_orderby', $filter_callback ); + + $query = new WP_Query( + array( + 'post_type' => 'wptests_time_ident', + 'post__in' => self::$date_identical_post_ids, + 'orderby' => 'post_date', + 'order' => 'ASC', + 'posts_per_page' => 10, + ) + ); + + remove_filter( 'posts_orderby', $filter_callback ); + + // Should not have duplicate ID ordering. + $this->assertStringContainsString( 'ID ASC', $query->request, 'ID should be present' ); + // Count occurrences of "ID ASC" - should be exactly 1. + $id_count = substr_count( $query->request, 'ID ASC' ); + $this->assertEquals( 1, $id_count, 'ID should not be duplicated when filter already includes it' ); + } } From b522456f82f4d37eabd6839dee8ad8283c9fa4bd Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 31 Dec 2025 16:39:52 +1100 Subject: [PATCH 19/21] lint --- src/wp-includes/class-wp-query.php | 23 +++++++++---------- .../tests/query/deterministicOrdering.php | 10 ++++---- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index d36d71bbb3135..bd5d9cd22de23 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2496,9 +2496,9 @@ public function get_posts() { // Order by. // Store metadata for deterministic ordering to be applied after filters. $deterministic_orderby_meta = array( - 'needed' => false, - 'has_id' => false, - 'order' => $query_vars['order'], + 'needed' => false, + 'has_id' => false, + 'order' => $query_vars['order'], ); if ( empty( $query_vars['orderby'] ) ) { @@ -2513,10 +2513,10 @@ public function get_posts() { * Ensure deterministic ordering to prevent duplicate records across pages. * When multiple posts have the same value for a field, add ID as secondary sort to guarantee consistent ordering. * Note: this is to circumvent a bug that is currently being tracked in https://core.trac.wordpress.org/ticket/44349. - * + * * Build base orderby without ID tie-breaker for filters, then add it after filters. */ - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; $deterministic_orderby_meta['needed'] = true; } // See get_pages(): when sort_column is 'none', the get_pages() function should not generate any ORDER BY clause. @@ -2532,7 +2532,7 @@ public function get_posts() { * * Use a blacklist approach: add ID as tie-breaker for all orderby fields except those that are * already deterministic (ID itself, random ordering, or search relevance). - * + * * Build base orderby without ID tie-breaker for filters, then add it after filters. */ $fields_excluding_deterministic_orderby = array( @@ -2591,7 +2591,7 @@ public function get_posts() { // Build the base orderby string (without ID tie-breaker) for filters. if ( empty( $orderby_array ) ) { - $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; + $orderby = "{$wpdb->posts}.post_date " . $query_vars['order']; $deterministic_orderby_meta['needed'] = true; } else { $orderby = trim( implode( ', ', $orderby_array ) ); @@ -3208,9 +3208,9 @@ public function get_posts() { */ $clauses = (array) apply_filters_ref_array( 'posts_clauses_request', array( compact( $pieces ), &$this ) ); - $where = $clauses['where'] ?? ''; - $groupby = $clauses['groupby'] ?? ''; - $join = $clauses['join'] ?? ''; + $where = $clauses['where'] ?? ''; + $groupby = $clauses['groupby'] ?? ''; + $join = $clauses['join'] ?? ''; // Preserve orderby from posts_orderby_request if posts_clauses_request doesn't provide one. $orderby = $clauses['orderby'] ?? $orderby; $distinct = $clauses['distinct'] ?? ''; @@ -3223,7 +3223,7 @@ public function get_posts() { * Add ID tie-breaker after filters have been applied, so filters receive * the original orderby value (for backward compatibility) and the tie-breaker * is preserved even if filters modify the orderby. - * + * * Note: this is to circumvent a bug that is currently being tracked in * https://core.trac.wordpress.org/ticket/44349. */ @@ -5210,4 +5210,3 @@ public function lazyload_comment_meta( $check, $comment_id ) { return $check; } } - diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index e4a3cedc47619..a2d8d943f17ba 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -648,7 +648,7 @@ public function test_filters_receive_original_orderby() { $received_orderby = ''; // Capture the orderby value received by the filter. - $filter_callback = function( $orderby ) use ( &$received_orderby ) { + $filter_callback = function ( $orderby ) use ( &$received_orderby ) { $received_orderby = $orderby; return $orderby; }; @@ -686,7 +686,7 @@ public function test_posts_clauses_filter_receives_original_orderby() { $received_orderby = ''; // Capture the orderby value received by the filter. - $filter_callback = function( $clauses ) use ( &$received_orderby ) { + $filter_callback = function ( $clauses ) use ( &$received_orderby ) { $received_orderby = $clauses['orderby'] ?? ''; return $clauses; }; @@ -723,7 +723,7 @@ public function test_posts_clauses_filter_receives_original_orderby() { */ public function test_deterministic_ordering_works_after_filter_modifies_orderby() { // Filter that modifies the orderby. - $filter_callback = function( $orderby ) { + $filter_callback = function ( $orderby ) { // Add a custom field to the orderby. global $wpdb; return $orderby . ', ' . "{$wpdb->posts}.post_title ASC"; @@ -773,7 +773,7 @@ public function test_deterministic_ordering_works_after_filter_modifies_orderby( */ public function test_deterministic_ordering_works_after_posts_clauses_modifies_orderby() { // Filter that modifies the orderby via posts_clauses. - $filter_callback = function( $clauses ) { + $filter_callback = function ( $clauses ) { global $wpdb; // Modify orderby to add post_title. $clauses['orderby'] = "{$wpdb->posts}.post_date ASC, {$wpdb->posts}.post_title ASC"; @@ -828,7 +828,7 @@ public function test_id_tie_breaker_not_duplicated_when_filter_includes_id() { global $wpdb; // Filter that already includes ID in orderby. - $filter_callback = function( $orderby ) use ( $wpdb ) { + $filter_callback = function ( $orderby ) use ( $wpdb ) { return "{$wpdb->posts}.post_date ASC, {$wpdb->posts}.ID ASC"; }; From 3275ebfc8746a71d87e385211d36b1abf261517e Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 2 Jan 2026 10:34:28 +1100 Subject: [PATCH 20/21] Refactor REST API post ordering tests to remove ID tie-breaker from assertions. Added a new test to verify the inclusion of ID tie-breaker in the final SQL query for deterministic ordering, ensuring backward compatibility with filters. --- .../tests/rest-api/rest-posts-controller.php | 92 +++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 36d05afad67ce..e72aeb2178ed5 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -488,7 +488,7 @@ public function test_get_items_include_query( $method ) { $this->assertSame( 2, $headers['X-WP-Total'], 'Failed asserting that the number of posts is correct.' ); } - $this->assertPostsOrderedBy( '{posts}.post_date DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_date DESC' ); // 'orderby' => 'include'. $request->set_param( 'orderby', 'include' ); @@ -544,7 +544,7 @@ public function test_get_items_orderby_author_query() { $this->assertSame( self::$editor_id, $data[1]['author'] ); $this->assertSame( self::$editor_id, $data[2]['author'] ); - $this->assertPostsOrderedBy( '{posts}.post_author DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_author DESC' ); } public function test_get_items_orderby_modified_query() { @@ -568,7 +568,7 @@ public function test_get_items_orderby_modified_query() { $this->assertSame( $id3, $data[1]['id'] ); $this->assertSame( $id2, $data[2]['id'] ); - $this->assertPostsOrderedBy( '{posts}.post_modified DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_modified DESC' ); } public function test_get_items_orderby_parent_query() { @@ -606,7 +606,7 @@ public function test_get_items_orderby_parent_query() { $this->assertSame( 0, $data[1]['parent'] ); $this->assertSame( 0, $data[2]['parent'] ); - $this->assertPostsOrderedBy( '{posts}.post_parent DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_parent DESC' ); } public function test_get_items_exclude_query() { @@ -976,14 +976,14 @@ public function test_get_items_order_and_orderby() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $this->assertSame( 'Apple Sauce', $data[0]['title']['rendered'] ); - $this->assertPostsOrderedBy( '{posts}.post_title DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_title DESC' ); // 'order' => 'asc'. $request->set_param( 'order', 'asc' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $this->assertSame( 'Apple Cobbler', $data[0]['title']['rendered'] ); - $this->assertPostsOrderedBy( '{posts}.post_title ASC, {posts}.ID ASC' ); + $this->assertPostsOrderedBy( '{posts}.post_title ASC' ); // 'order' => 'asc,id' should error. $request->set_param( 'order', 'asc,id' ); @@ -1068,7 +1068,7 @@ public function test_get_items_with_orderby_slug() { // Default ORDER is DESC. $this->assertSame( 'xyz', $data[0]['slug'] ); $this->assertSame( 'abc', $data[1]['slug'] ); - $this->assertPostsOrderedBy( '{posts}.post_name DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_name DESC' ); } public function test_get_items_with_orderby_slugs() { @@ -1120,7 +1120,7 @@ public function test_get_items_with_orderby_relevance() { $this->assertCount( 2, $data ); $this->assertSame( $id1, $data[0]['id'] ); $this->assertSame( $id2, $data[1]['id'] ); - $this->assertPostsOrderedBy( '{posts}.post_title LIKE \'%relevant%\' DESC, {posts}.post_date DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '{posts}.post_title LIKE \'%relevant%\' DESC, {posts}.post_date DESC' ); } public function test_get_items_with_orderby_relevance_two_terms() { @@ -1148,7 +1148,7 @@ public function test_get_items_with_orderby_relevance_two_terms() { $this->assertCount( 2, $data ); $this->assertSame( $id1, $data[0]['id'] ); $this->assertSame( $id2, $data[1]['id'] ); - $this->assertPostsOrderedBy( '(CASE WHEN {posts}.post_title LIKE \'%relevant content%\' THEN 1 WHEN {posts}.post_title LIKE \'%relevant%\' AND {posts}.post_title LIKE \'%content%\' THEN 2 WHEN {posts}.post_title LIKE \'%relevant%\' OR {posts}.post_title LIKE \'%content%\' THEN 3 WHEN {posts}.post_excerpt LIKE \'%relevant content%\' THEN 4 WHEN {posts}.post_content LIKE \'%relevant content%\' THEN 5 ELSE 6 END), {posts}.post_date DESC, {posts}.ID DESC' ); + $this->assertPostsOrderedBy( '(CASE WHEN {posts}.post_title LIKE \'%relevant content%\' THEN 1 WHEN {posts}.post_title LIKE \'%relevant%\' AND {posts}.post_title LIKE \'%content%\' THEN 2 WHEN {posts}.post_title LIKE \'%relevant%\' OR {posts}.post_title LIKE \'%content%\' THEN 3 WHEN {posts}.post_excerpt LIKE \'%relevant content%\' THEN 4 WHEN {posts}.post_content LIKE \'%relevant content%\' THEN 5 ELSE 6 END), {posts}.post_date DESC' ); } public function test_get_items_with_orderby_relevance_missing_search() { @@ -1158,6 +1158,80 @@ public function test_get_items_with_orderby_relevance_missing_search() { $this->assertErrorResponse( 'rest_no_search_term_defined', $response, 400 ); } + /** + * Test that ID tie-breaker is added to final SQL query for deterministic ordering. + * + * This test verifies that the ID tie-breaker is present in the final SQL query, + * even though filters receive the orderby without ID (for backward compatibility). + * + * @ticket xxxxx + */ + public function test_id_tie_breaker_in_final_sql_query() { + global $wpdb; + + $identical_date = '2023-01-01 10:00:00'; + $post_ids = array(); + for ( $i = 1; $i <= 5; $i++ ) { + $post_ids[] = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_date' => $identical_date, + ) + ); + } + + /* + * Capture the WP_Query instance via posts_clauses filter. + * We use the same hook as other tests in this class (save_posts_clauses), + * but we need to capture the query instance to access $query->request after execution. + * The existing save_posts_clauses method stores clauses but not the query instance. + */ + $captured_query = null; + $filter_callback = function ( $clauses, $query ) use ( &$captured_query ) { + /* + * Short-circuit: only capture the query on the first call. + * The posts_clauses filter may be called multiple times (e.g., for main query + * and sub-queries), but we only need the main query instance once. + */ + if ( null === $captured_query ) { + $captured_query = $query; + } + return $clauses; + }; + add_filter( 'posts_clauses', $filter_callback, 10, 2 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'order', 'desc' ); + $request->set_param( 'per_page', 100 ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'posts_clauses', $filter_callback ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotNull( $captured_query, 'WP_Query should be captured' ); + $this->assertInstanceOf( 'WP_Query', $captured_query, 'Captured query should be a WP_Query instance' ); + + /** @var WP_Query $captured_query */ + $sql = $captured_query->request; + $posts_table = preg_quote( $wpdb->posts, '/' ); + + $orderby_pattern = '/ORDER\s+BY\s+.*' . $posts_table . '\.ID\s+(?:ASC|DESC)/i'; + $this->assertMatchesRegularExpression( + $orderby_pattern, + $sql, + 'Final SQL query should include ID tie-breaker in ORDER BY clause' + ); + + $this->assertCount( 1, $this->posts_clauses ); + $filter_orderby = $this->posts_clauses[0]['orderby']; + $this->assertStringNotContainsString( + 'ID', + $filter_orderby, + 'Filters should receive orderby without ID tie-breaker for backward compatibility' + ); + } + public function test_get_items_offset_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); $request->set_param( 'per_page', self::$per_page ); From 56868f85cebfe15c0df1debbee3cdc2e5ae72ca4 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 2 Jan 2026 10:48:25 +1100 Subject: [PATCH 21/21] Refactor WP_Query to preserve filter modifications to orderby. Adjusted related tests to verify that filter modifications are respected. --- src/wp-includes/class-wp-query.php | 26 +++++-- .../tests/query/deterministicOrdering.php | 75 ++++++------------- 2 files changed, 41 insertions(+), 60 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index bd5d9cd22de23..ba397cfecefcb 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2493,12 +2493,15 @@ public function get_posts() { $query_vars['order'] = ''; } - // Order by. - // Store metadata for deterministic ordering to be applied after filters. + /* + * Order by. + * Store metadata for deterministic ordering to be applied after filters. + */ $deterministic_orderby_meta = array( - 'needed' => false, - 'has_id' => false, - 'order' => $query_vars['order'], + 'needed' => false, + 'has_id' => false, + 'order' => $query_vars['order'], + 'original' => '', // Store original orderby to detect filter modifications. ); if ( empty( $query_vars['orderby'] ) ) { @@ -2622,6 +2625,11 @@ public function get_posts() { } } + // Store the original orderby after all core modifications but before filters modify it. + if ( $deterministic_orderby_meta['needed'] ) { + $deterministic_orderby_meta['original'] = $orderby; + } + if ( is_array( $post_type ) && count( $post_type ) > 1 ) { $post_type_cap = 'multiple_post_type'; } else { @@ -3228,9 +3236,11 @@ public function get_posts() { * https://core.trac.wordpress.org/ticket/44349. */ if ( ! empty( $orderby ) && $deterministic_orderby_meta['needed'] ) { - // Check if ID tie-breaker is already present in the orderby string. - $id_tie_breaker_pattern = '/\b' . preg_quote( $wpdb->posts, '/' ) . '\.ID\b/i'; - if ( ! preg_match( $id_tie_breaker_pattern, $orderby ) ) { + /* + * Only add ID tie-breaker if no filter modified the orderby. + * If a filter modified it, we assume they know what they're doing and don't interfere. + */ + if ( ! empty( $deterministic_orderby_meta['original'] ) && $orderby === $deterministic_orderby_meta['original'] ) { // Add ID as tie-breaker at the end. $orderby .= ', ' . "{$wpdb->posts}.ID " . $deterministic_orderby_meta['order']; } diff --git a/tests/phpunit/tests/query/deterministicOrdering.php b/tests/phpunit/tests/query/deterministicOrdering.php index a2d8d943f17ba..9c98b8f0c8d1f 100644 --- a/tests/phpunit/tests/query/deterministicOrdering.php +++ b/tests/phpunit/tests/query/deterministicOrdering.php @@ -714,67 +714,54 @@ public function test_posts_clauses_filter_receives_original_orderby() { } /** - * Test that deterministic ordering works when filters modify orderby. + * Test that filter modifications to orderby are preserved. * - * Even if a filter modifies the orderby, the ID tie-breaker should still - * be added after the filter to ensure deterministic ordering. + * When a filter modifies the orderby, the modification should be preserved + * and we should not add the ID tie-breaker (we assume the filter knows what it's doing). * * @ticket xxxxx */ - public function test_deterministic_ordering_works_after_filter_modifies_orderby() { - // Filter that modifies the orderby. + public function test_filter_modifications_to_orderby_are_preserved() { + // Filter that modifies the orderby by adding post_title. $filter_callback = function ( $orderby ) { - // Add a custom field to the orderby. global $wpdb; return $orderby . ', ' . "{$wpdb->posts}.post_title ASC"; }; add_filter( 'posts_orderby', $filter_callback ); - $query1 = new WP_Query( - array( - 'post_type' => 'wptests_time_ident', - 'post__in' => self::$date_identical_post_ids, - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - 'paged' => 1, - ) - ); - - $query2 = new WP_Query( + $query = new WP_Query( array( 'post_type' => 'wptests_time_ident', 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'ASC', 'posts_per_page' => 10, - 'paged' => 2, ) ); remove_filter( 'posts_orderby', $filter_callback ); - $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); - $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); - - // Verify no duplicates across pages even when filter modifies orderby. - $overlap = array_intersect( $page1_ids, $page2_ids ); - $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even when filter modifies orderby' ); + // Verify filter modification is preserved in the final query. + $this->assertStringContainsString( 'post_title ASC', $query->request, 'Filter modification to orderby should be preserved' ); - // Verify ID tie-breaker is still present in the final query. - $this->assertStringContainsString( 'ID ASC', $query1->request, 'ID tie-breaker should be present after filter modifies orderby' ); + // Verify ID tie-breaker is NOT added when filter modifies orderby. + $this->assertStringNotContainsString( ', ' . $GLOBALS['wpdb']->posts . '.ID ASC', $query->request, 'ID tie-breaker should not be added when filter modifies orderby' ); } /** - * Test that deterministic ordering works when posts_clauses filter modifies orderby. + * Test that posts_clauses filter modifications to orderby are preserved. + * + * When a posts_clauses filter modifies the orderby, the modification should be preserved + * and we should not add the ID tie-breaker (we assume the filter knows what it's doing). * * @ticket xxxxx */ - public function test_deterministic_ordering_works_after_posts_clauses_modifies_orderby() { + public function test_posts_clauses_filter_modifications_to_orderby_are_preserved() { + global $wpdb; + // Filter that modifies the orderby via posts_clauses. - $filter_callback = function ( $clauses ) { - global $wpdb; + $filter_callback = function ( $clauses ) use ( $wpdb ) { // Modify orderby to add post_title. $clauses['orderby'] = "{$wpdb->posts}.post_date ASC, {$wpdb->posts}.post_title ASC"; return $clauses; @@ -782,39 +769,23 @@ public function test_deterministic_ordering_works_after_posts_clauses_modifies_o add_filter( 'posts_clauses', $filter_callback ); - $query1 = new WP_Query( - array( - 'post_type' => 'wptests_time_ident', - 'post__in' => self::$date_identical_post_ids, - 'orderby' => 'post_date', - 'order' => 'ASC', - 'posts_per_page' => 10, - 'paged' => 1, - ) - ); - - $query2 = new WP_Query( + $query = new WP_Query( array( 'post_type' => 'wptests_time_ident', 'post__in' => self::$date_identical_post_ids, 'orderby' => 'post_date', 'order' => 'ASC', 'posts_per_page' => 10, - 'paged' => 2, ) ); remove_filter( 'posts_clauses', $filter_callback ); - $page1_ids = wp_list_pluck( $query1->posts, 'ID' ); - $page2_ids = wp_list_pluck( $query2->posts, 'ID' ); - - // Verify no duplicates across pages. - $overlap = array_intersect( $page1_ids, $page2_ids ); - $this->assertEmpty( $overlap, 'Pages should not contain duplicate posts even when posts_clauses modifies orderby' ); + // Verify filter modification is preserved in the final query. + $this->assertStringContainsString( 'post_title ASC', $query->request, 'posts_clauses filter modification to orderby should be preserved' ); - // Verify ID tie-breaker is still present. - $this->assertStringContainsString( 'ID ASC', $query1->request, 'ID tie-breaker should be present after posts_clauses modifies orderby' ); + // Verify ID tie-breaker is NOT added when filter modifies orderby. + $this->assertStringNotContainsString( ', ' . $wpdb->posts . '.ID ASC', $query->request, 'ID tie-breaker should not be added when posts_clauses filter modifies orderby' ); } /**