-
Notifications
You must be signed in to change notification settings - Fork 3.2k
WP_Query: force deterministic ordering #10262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
508f261
f4d75ba
85be020
d2defb0
1b9818f
41d210b
b2db52b
0429eda
109cfc5
45ab8af
f763cc3
54e4cf4
80d9298
63d627f
a71e6c6
f64169a
671cc85
ffe31dc
b522456
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| * | ||
|
|
@@ -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, | ||
|
|
@@ -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 { | ||
| /* | ||
| * 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 ) ); | ||
|
|
@@ -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'] ); | ||
|
|
@@ -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. | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current |
||
|
|
||
| $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 ); | ||
|
|
||
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
|
||
There was a problem hiding this comment.
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.