Skip to content

Commit 71e1817

Browse files
committed
Add deterministic fallback for adjacent post queries only when the query hasn't been modified.
This update introduces a deterministic fallback for the SQL `WHERE` and `ORDER BY` clauses in the `get_adjacent_post` function when posts have identical dates. The fallback is applied only if the respective clauses have not been modified by filters, ensuring consistent behavior. Unit tests have been added to verify the correct application of this fallback under various conditions, including scenarios where filters are applied or not. See Trac ticket https://core.trac.wordpress.org/ticket/64390.
1 parent 9aac91b commit 71e1817

File tree

2 files changed

+242
-5
lines changed

2 files changed

+242
-5
lines changed

src/wp-includes/link-template.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1814,6 +1814,7 @@ function get_next_post( $in_same_term = false, $excluded_terms = '', $taxonomy =
18141814
* Can either be next or previous post.
18151815
*
18161816
* @since 2.5.0
1817+
* @since 6.9.1 Adds deterministic fallback for sort clause if not modified by a filter.
18171818
*
18181819
* @global wpdb $wpdb WordPress database abstraction object.
18191820
*
@@ -1965,8 +1966,8 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19651966
$join = apply_filters( "get_{$adjacent}_post_join", $join, $in_same_term, $excluded_terms, $taxonomy, $post );
19661967

19671968
// Prepare the where clause for the adjacent post query.
1968-
$where_prepared = $wpdb->prepare( "WHERE (p.post_date $comparison_operator %s OR (p.post_date = %s AND p.ID $comparison_operator %d)) AND p.post_type = %s $where", $current_post_date, $current_post_date, $post->ID, $post->post_type ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $comparison_operator is a string literal, either '<' or '>'.
1969-
1969+
$where_prepared_with_deterministic_fallback = $wpdb->prepare( "WHERE (p.post_date $comparison_operator %s OR (p.post_date = %s AND p.ID $comparison_operator %d)) AND p.post_type = %s $where", $current_post_date, $current_post_date, $post->ID, $post->post_type ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $comparison_operator is a string literal, either '<' or '>'.
1970+
$where_prepared = $wpdb->prepare( "WHERE p.post_date $comparison_operator %s AND p.post_type = %s $where", $current_post_date, $post->post_type );
19701971
/**
19711972
* Filters the WHERE clause in the SQL for an adjacent post query.
19721973
*
@@ -1980,7 +1981,6 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19801981
*
19811982
* @since 2.5.0
19821983
* @since 4.4.0 Added the `$taxonomy` and `$post` parameters.
1983-
* @since 6.9.0 Adds ID-based fallback for posts with identical dates in adjacent post queries.
19841984
*
19851985
* @param string $where The `WHERE` clause in the SQL.
19861986
* @param bool $in_same_term Whether post should be in the same taxonomy term.
@@ -1989,6 +1989,11 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19891989
* @param WP_Post $post WP_Post object.
19901990
*/
19911991
$where = apply_filters( "get_{$adjacent}_post_where", $where_prepared, $in_same_term, $excluded_terms, $taxonomy, $post );
1992+
1993+
// Only force deterministic fallback if the where clause has not been modified by a filter.
1994+
if ( $where === $where_prepared ) {
1995+
$where = $where_prepared_with_deterministic_fallback;
1996+
}
19921997

19931998
/**
19941999
* Filters the ORDER BY clause in the SQL for an adjacent post query.
@@ -2004,13 +2009,18 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
20042009
* @since 2.5.0
20052010
* @since 4.4.0 Added the `$post` parameter.
20062011
* @since 4.9.0 Added the `$order` parameter.
2007-
* @since 6.9.0 Adds ID sort to ensure deterministic ordering for posts with identical dates.
20082012
*
20092013
* @param string $order_by The `ORDER BY` clause in the SQL.
20102014
* @param WP_Post $post WP_Post object.
20112015
* @param string $order Sort order. 'DESC' for previous post, 'ASC' for next.
20122016
*/
2013-
$sort = apply_filters( "get_{$adjacent}_post_sort", "ORDER BY p.post_date $order, p.ID $order LIMIT 1", $post, $order );
2017+
$sort_prepared = "ORDER BY p.post_date $order LIMIT 1";
2018+
$sort = apply_filters( "get_{$adjacent}_post_sort", $sort_prepared, $post, $order );
2019+
2020+
// Only force deterministic sort if the sort clause has not been modified by a filter.
2021+
if ( $sort === $sort_prepared ) {
2022+
$sort = "ORDER BY p.post_date $order, p.ID $order LIMIT 1";
2023+
}
20142024

20152025
$query = "SELECT p.ID FROM $wpdb->posts AS p $join $where $sort";
20162026
$key = md5( $query );

tests/phpunit/tests/link/getAdjacentPost.php

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,233 @@ public function test_get_adjacent_post_with_identical_dates() {
622622
$this->assertEquals( $post_ids[3], $next->ID );
623623
}
624624

625+
/**
626+
* Test that deterministic ID fallback is applied when WHERE filter doesn't modify the clause.
627+
*
628+
* @ticket 64390
629+
*/
630+
public function test_get_adjacent_post_identical_dates_applies_deterministic_where_when_filter_unmodified() {
631+
$identical_date = '2024-01-01 12:00:00';
632+
633+
// Create posts with identical dates but different IDs.
634+
$post_ids = array();
635+
for ( $i = 1; $i <= 5; $i++ ) {
636+
$post_ids[] = self::factory()->post->create(
637+
array(
638+
'post_title' => "Post $i",
639+
'post_date' => $identical_date,
640+
)
641+
);
642+
}
643+
644+
// Add a filter that doesn't modify the WHERE clause (returns unchanged).
645+
add_filter(
646+
'get_next_post_where',
647+
static function( $where ) {
648+
// Return unchanged - deterministic fallback should be applied.
649+
return $where;
650+
}
651+
);
652+
653+
// Test navigation from the middle post (ID: 3rd post).
654+
$current_post_id = $post_ids[2]; // 3rd post
655+
$this->go_to( get_permalink( $current_post_id ) );
656+
657+
// Next post should be the 4th post (higher ID, same date) - deterministic.
658+
$next = get_adjacent_post( false, '', false );
659+
$this->assertInstanceOf( 'WP_Post', $next );
660+
$this->assertEquals( $post_ids[3], $next->ID );
661+
662+
remove_all_filters( 'get_next_post_where' );
663+
}
664+
665+
/**
666+
* Test that deterministic ID fallback is NOT applied when WHERE filter modifies the clause.
667+
*
668+
* @ticket 64390
669+
*/
670+
public function test_get_adjacent_post_identical_dates_respects_modified_where_filter() {
671+
$identical_date = '2024-01-01 12:00:00';
672+
673+
// Create posts with identical dates but different IDs.
674+
$post_ids = array();
675+
for ( $i = 1; $i <= 5; $i++ ) {
676+
$post_ids[] = self::factory()->post->create(
677+
array(
678+
'post_title' => "Post $i",
679+
'post_date' => $identical_date,
680+
)
681+
);
682+
}
683+
684+
// Capture what the filter receives and what it returns.
685+
$filter_received = '';
686+
$filter_returned = '';
687+
add_filter(
688+
'get_next_post_where',
689+
static function( $where ) use ( &$filter_received, &$filter_returned ) {
690+
$filter_received = $where;
691+
// Modify the WHERE clause - deterministic fallback should NOT be applied.
692+
// Add a harmless condition that won't affect results but proves the filter was applied.
693+
$filter_returned = $where . ' AND 1=1';
694+
return $filter_returned;
695+
}
696+
);
697+
698+
// Test navigation from the middle post (ID: 3rd post).
699+
$current_post_id = $post_ids[2]; // 3rd post
700+
$this->go_to( get_permalink( $current_post_id ) );
701+
702+
// Call get_adjacent_post to trigger the filter.
703+
get_adjacent_post( false, '', false );
704+
705+
// Verify the filter received the non-deterministic WHERE clause (without ID fallback).
706+
$this->assertNotEmpty( $filter_received, 'Filter should have been called.' );
707+
$this->assertStringNotContainsString( 'AND p.ID', $filter_received, 'Filter should receive WHERE clause without deterministic ID fallback.' );
708+
// Verify the filter's modification is preserved (proves deterministic logic wasn't applied on top).
709+
$this->assertStringContainsString( 'AND 1=1', $filter_returned, 'Filter modification should be preserved.' );
710+
711+
remove_all_filters( 'get_next_post_where' );
712+
}
713+
714+
/**
715+
* Test that deterministic ID sort is applied when SORT filter doesn't modify the clause.
716+
*
717+
* @ticket 64390
718+
*/
719+
public function test_get_adjacent_post_identical_dates_applies_deterministic_sort_when_filter_unmodified() {
720+
$identical_date = '2024-01-01 12:00:00';
721+
722+
// Create posts with identical dates but different IDs.
723+
$post_ids = array();
724+
for ( $i = 1; $i <= 5; $i++ ) {
725+
$post_ids[] = self::factory()->post->create(
726+
array(
727+
'post_title' => "Post $i",
728+
'post_date' => $identical_date,
729+
)
730+
);
731+
}
732+
733+
// Add a filter that doesn't modify the SORT clause (returns unchanged).
734+
add_filter(
735+
'get_next_post_sort',
736+
static function( $sort ) {
737+
// Return unchanged - deterministic ID sort should be applied.
738+
return $sort;
739+
}
740+
);
741+
742+
// Test navigation from the middle post (ID: 3rd post).
743+
$current_post_id = $post_ids[2]; // 3rd post
744+
$this->go_to( get_permalink( $current_post_id ) );
745+
746+
// Next post should be the 4th post (higher ID, same date) - deterministic.
747+
$next = get_adjacent_post( false, '', false );
748+
$this->assertInstanceOf( 'WP_Post', $next );
749+
$this->assertEquals( $post_ids[3], $next->ID );
750+
751+
remove_all_filters( 'get_next_post_sort' );
752+
}
753+
754+
/**
755+
* Test that deterministic ID sort is NOT applied when SORT filter modifies the clause.
756+
*
757+
* @ticket 64390
758+
*/
759+
public function test_get_adjacent_post_identical_dates_respects_modified_sort_filter() {
760+
$identical_date = '2024-01-01 12:00:00';
761+
762+
// Create posts with identical dates but different IDs.
763+
$post_ids = array();
764+
for ( $i = 1; $i <= 5; $i++ ) {
765+
$post_ids[] = self::factory()->post->create(
766+
array(
767+
'post_title' => "Post $i",
768+
'post_date' => $identical_date,
769+
)
770+
);
771+
}
772+
773+
// Capture what the filter receives and what it returns.
774+
$filter_received = '';
775+
$filter_returned = '';
776+
add_filter(
777+
'get_next_post_sort',
778+
static function( $sort, $post, $order ) use ( &$filter_received, &$filter_returned ) {
779+
$filter_received = $sort;
780+
// Modify to remove ID - deterministic ID sort should NOT be applied.
781+
$filter_returned = "ORDER BY p.post_date $order LIMIT 1";
782+
return $filter_returned;
783+
},
784+
10,
785+
3
786+
);
787+
788+
// Test navigation from the middle post (ID: 3rd post).
789+
$current_post_id = $post_ids[2]; // 3rd post
790+
$this->go_to( get_permalink( $current_post_id ) );
791+
792+
// Call get_adjacent_post to trigger the filter.
793+
get_adjacent_post( false, '', false );
794+
795+
// Verify the filter received the non-deterministic SORT clause (without ID).
796+
$this->assertNotEmpty( $filter_received, 'Filter should have been called.' );
797+
$this->assertStringNotContainsString( 'p.ID', $filter_received, 'Filter should receive SORT clause without deterministic ID sort.' );
798+
// Verify the filter's modification is preserved (proves deterministic logic wasn't applied on top).
799+
$this->assertStringNotContainsString( 'p.ID', $filter_returned, 'Filter modification should not include ID when filter removes it.' );
800+
$this->assertStringContainsString( 'ORDER BY p.post_date', $filter_returned, 'Filter modification should be preserved.' );
801+
802+
remove_all_filters( 'get_next_post_sort' );
803+
}
804+
805+
/**
806+
* Test that both WHERE and SORT filters work together correctly.
807+
*
808+
* @ticket 64390
809+
*/
810+
public function test_get_adjacent_post_identical_dates_with_both_filters_unmodified() {
811+
$identical_date = '2024-01-01 12:00:00';
812+
813+
// Create posts with identical dates but different IDs.
814+
$post_ids = array();
815+
for ( $i = 1; $i <= 5; $i++ ) {
816+
$post_ids[] = self::factory()->post->create(
817+
array(
818+
'post_title' => "Post $i",
819+
'post_date' => $identical_date,
820+
)
821+
);
822+
}
823+
824+
// Add filters that don't modify the clauses.
825+
add_filter(
826+
'get_previous_post_where',
827+
static function( $where ) {
828+
return $where;
829+
}
830+
);
831+
832+
add_filter(
833+
'get_previous_post_sort',
834+
static function( $sort ) {
835+
return $sort;
836+
}
837+
);
838+
839+
// Test navigation from the middle post (ID: 3rd post).
840+
$current_post_id = $post_ids[2]; // 3rd post
841+
$this->go_to( get_permalink( $current_post_id ) );
842+
843+
// Previous post should be the 2nd post (lower ID, same date) - deterministic.
844+
$previous = get_adjacent_post( false, '', true );
845+
$this->assertInstanceOf( 'WP_Post', $previous );
846+
$this->assertEquals( $post_ids[1], $previous->ID );
847+
848+
remove_all_filters( 'get_previous_post_where' );
849+
remove_all_filters( 'get_previous_post_sort' );
850+
}
851+
625852
/**
626853
* Test get_adjacent_post with mixed dates and identical dates.
627854
*

0 commit comments

Comments
 (0)