Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
508f261
Enhance WP_Query ordering to ensure deterministic results by adding I…
ramonjd Oct 15, 2025
f4d75ba
WHITESPACE! Oh no!
ramonjd Oct 15, 2025
85be020
Refactor WP_Query ordering logic to ensure consistent results by appe…
ramonjd Oct 21, 2025
d2defb0
Consolidate ID tie-breaker logic and ensure consistent SQL output in …
ramonjd Oct 21, 2025
1b9818f
whitespace in unit test
ramonjd Oct 21, 2025
41d210b
Refine WP_Query ordering logic to handle 'none' in orderby scenarios …
ramonjd Oct 22, 2025
b2db52b
lint
ramonjd Oct 22, 2025
0429eda
Remove ticket number in tests for now
ramonjd Oct 22, 2025
109cfc5
Refactor WP_Query to ensure consistent ordering by appending ID as a …
ramonjd Oct 22, 2025
45ab8af
Enhance WP_Query ordering logic by normalizing 'date' to 'date, ID' f…
ramonjd Oct 22, 2025
f763cc3
lint
ramonjd Oct 22, 2025
54e4cf4
linto
ramonjd Oct 22, 2025
80d9298
Fix date formatting in deterministic ordering test to ensure consiste…
ramonjd Dec 5, 2025
63d627f
Refactor deterministic ordering tests to utilize shared fixtures for …
ramonjd Dec 5, 2025
a71e6c6
Refactor WP_Query ordering logic to implement a blacklist approach fo…
ramonjd Dec 5, 2025
f64169a
Add search relevance tests to deterministic ordering suite
ramonjd Dec 5, 2025
671cc85
Enhance WP_Query ordering by adding new fields to the orderby array. …
ramonjd Dec 5, 2025
ffe31dc
Implement deterministic ordering in WP_Query by adding ID tie-breaker…
ramonjd Dec 31, 2025
b522456
lint
ramonjd Dec 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 109 additions & 19 deletions src/wp-includes/class-wp-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -2493,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,
Expand All @@ -2501,12 +2509,44 @@ 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.
*
* Build base orderby without ID tie-breaker for filters, then add it after filters.
*/
$orderby = "{$wpdb->posts}.post_date " . $query_vars['order'];
$deterministic_orderby_meta['needed'] = true;
}
} 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'] || isset( $query_vars['orderby']['none'] ) ) {
$orderby = '';
} else {
/*
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peterwilsoncc When you get a spare moment (can be after 6.9 or whenever you have head space) could you sanity check this approach for me?

The TL;DR is:

When multiple posts have identical values for the primary sort field (like post_date, post_title, menu_order), the database doesn't guarantee consistent ordering across pagination.

This causes inconsistent pagination results, mainly in the form of dupes.

The solution here (and in all the other attempts from 6 years ago) has been to automatically add ID as a secondary sort field when ordering by fields that can have duplicate values. This ensures records with identical primary sort values always appear in the same 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.
*
* 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',
'rand',
'relevance',
'post__in',
'post_name__in',
'post_parent__in',
'include',
);

$orderby_array = array();

if ( is_array( $query_vars['orderby'] ) ) {
foreach ( $query_vars['orderby'] as $_orderby => $order ) {
$orderby = addslashes_gpc( urldecode( $_orderby ) );
Expand All @@ -2517,9 +2557,16 @@ public function get_posts() {
}

$orderby_array[] = $parsed . ' ' . $this->parse_order( $order );
}
$orderby = implode( ', ', $orderby_array );

// Check if this field should have deterministic ordering (not in blacklist).
if ( ! in_array( $_orderby, $fields_excluding_deterministic_orderby, true ) ) {
$deterministic_orderby_meta['needed'] = true;
// Use the order from the array for ID tie-breaker.
$deterministic_orderby_meta['order'] = $this->parse_order( $order );
} elseif ( 'ID' === $_orderby ) {
$deterministic_orderby_meta['has_id'] = true;
}
}
} else {
$query_vars['orderby'] = urldecode( $query_vars['orderby'] );
$query_vars['orderby'] = addslashes_gpc( $query_vars['orderby'] );
Expand All @@ -2531,16 +2578,24 @@ public function get_posts() {
continue;
}

$orderby_array[] = $parsed;
}
$orderby = implode( ' ' . $query_vars['order'] . ', ', $orderby_array );
$orderby_array[] = $parsed . ' ' . $query_vars['order'];

if ( empty( $orderby ) ) {
$orderby = "{$wpdb->posts}.post_date " . $query_vars['order'];
} elseif ( ! empty( $query_vars['order'] ) ) {
$orderby .= " {$query_vars['order']}";
// Check if this field should have deterministic ordering (not in blacklist).
if ( ! in_array( $orderby, $fields_excluding_deterministic_orderby, true ) ) {
$deterministic_orderby_meta['needed'] = true;
} elseif ( 'ID' === $orderby ) {
$deterministic_orderby_meta['has_id'] = true;
}
}
}

// Build the base orderby string (without ID tie-breaker) for filters.
if ( empty( $orderby_array ) ) {
$orderby = "{$wpdb->posts}.post_date " . $query_vars['order'];
$deterministic_orderby_meta['needed'] = true;
} else {
$orderby = trim( implode( ', ', $orderby_array ) );
}
}

// Order search results by relevance only when another "orderby" is not specified in the query.
Expand Down Expand Up @@ -3153,15 +3208,34 @@ 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'] ?? '';
$orderby = $clauses['orderby'] ?? '';
$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'] ?? '';
$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;
}
Expand Down Expand Up @@ -3255,10 +3329,21 @@ public function get_posts() {
}

if ( $query_vars['cache_results'] && $id_query_is_cacheable ) {
$new_request = str_replace( $fields, "{$wpdb->posts}.*", $this->request );
$cache_key = $this->generate_cache_key( $query_vars, $new_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 );
}
Comment on lines +3332 to +3342
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current str_replace is too greedy in that it will replace strings in the ORDER BY clause as well.


$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 );

Expand Down Expand Up @@ -5042,9 +5127,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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another nitpick :) This comment still seems okay imho. No need to delete it, just edit it?

/*
* 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';
$args['orderby'] = 'date, ID';
} elseif ( 'date' === $args['orderby'] ) {
$args['orderby'] = 'date, ID';
}

$placeholder = $wpdb->placeholder_escape();
Expand Down
26 changes: 17 additions & 9 deletions tests/phpunit/tests/admin/wpPrivacyRequestsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,15 @@ 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
);

$this->assertStringContainsString( 'ORDER BY ' . implode( ', ', $expected_query ), $this->sql );
}

/**
Expand Down Expand Up @@ -136,42 +144,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(
Expand All @@ -185,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',
),
);
}
Expand Down
Loading
Loading