Skip to content

Commit 70ca20b

Browse files
committed
get_adjacent_post: modify WHERE clause to include ID-based fallback to ensure deterministic ordering
Since WordPress 2.7, where multiple posts have identical post_date values (e.g., when bulk publishing drafts), the next/previous post navigation skips posts or behaves unpredictably. This is because the WHERE clause uses strict inequality (`>` or `<`) which excludes posts with the same date. To ensure deterministic ordering, this commit modifies the WHERE clause to include ID-based fallback for posts with identical dates. Props ramonopoly, westonruter, andrewserong. Fixes #8107. git-svn-id: https://develop.svn.wordpress.org/trunk@61066 602fd350-edb4-49c9-b593-d223f7449a82
1 parent f5a0517 commit 70ca20b

File tree

3 files changed

+259
-4
lines changed

3 files changed

+259
-4
lines changed

src/wp-includes/link-template.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,8 +1939,8 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19391939
$where .= " AND p.post_status = 'publish'";
19401940
}
19411941

1942-
$op = $previous ? '<' : '>';
1943-
$order = $previous ? 'DESC' : 'ASC';
1942+
$comparison_operator = $previous ? '<' : '>';
1943+
$order = $previous ? 'DESC' : 'ASC';
19441944

19451945
/**
19461946
* Filters the JOIN clause in the SQL for an adjacent post query.
@@ -1964,6 +1964,9 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19641964
*/
19651965
$join = apply_filters( "get_{$adjacent}_post_join", $join, $in_same_term, $excluded_terms, $taxonomy, $post );
19661966

1967+
// 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+
19671970
/**
19681971
* Filters the WHERE clause in the SQL for an adjacent post query.
19691972
*
@@ -1977,14 +1980,15 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
19771980
*
19781981
* @since 2.5.0
19791982
* @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.
19801984
*
19811985
* @param string $where The `WHERE` clause in the SQL.
19821986
* @param bool $in_same_term Whether post should be in the same taxonomy term.
19831987
* @param int[]|string $excluded_terms Array of excluded term IDs. Empty string if none were provided.
19841988
* @param string $taxonomy Taxonomy. Used to identify the term used when `$in_same_term` is true.
19851989
* @param WP_Post $post WP_Post object.
19861990
*/
1987-
$where = apply_filters( "get_{$adjacent}_post_where", $wpdb->prepare( "WHERE p.post_date $op %s AND p.post_type = %s $where", $current_post_date, $post->post_type ), $in_same_term, $excluded_terms, $taxonomy, $post );
1991+
$where = apply_filters( "get_{$adjacent}_post_where", $where_prepared, $in_same_term, $excluded_terms, $taxonomy, $post );
19881992

19891993
/**
19901994
* Filters the ORDER BY clause in the SQL for an adjacent post query.
@@ -2000,12 +2004,13 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
20002004
* @since 2.5.0
20012005
* @since 4.4.0 Added the `$post` parameter.
20022006
* @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.
20032008
*
20042009
* @param string $order_by The `ORDER BY` clause in the SQL.
20052010
* @param WP_Post $post WP_Post object.
20062011
* @param string $order Sort order. 'DESC' for previous post, 'ASC' for next.
20072012
*/
2008-
$sort = apply_filters( "get_{$adjacent}_post_sort", "ORDER BY p.post_date $order LIMIT 1", $post, $order );
2013+
$sort = apply_filters( "get_{$adjacent}_post_sort", "ORDER BY p.post_date $order, p.ID $order LIMIT 1", $post, $order );
20092014

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

tests/phpunit/tests/link/getAdjacentPost.php

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,4 +587,196 @@ public function test_get_adjacent_post_cache() {
587587
$this->assertEquals( $post_four, get_adjacent_post( true, '', false ), 'Result of function call is wrong after after adding new term' );
588588
$this->assertSame( get_num_queries() - $num_queries, 2, 'Number of queries run was not two after adding new term' );
589589
}
590+
591+
/**
592+
* Test get_adjacent_post with posts having identical post_date.
593+
*
594+
* @ticket 8107
595+
*/
596+
public function test_get_adjacent_post_with_identical_dates() {
597+
$identical_date = '2024-01-01 12:00:00';
598+
599+
// Create posts with identical dates but different IDs.
600+
$post_ids = array();
601+
for ( $i = 1; $i <= 5; $i++ ) {
602+
$post_ids[] = self::factory()->post->create(
603+
array(
604+
'post_title' => "Post $i",
605+
'post_date' => $identical_date,
606+
)
607+
);
608+
}
609+
610+
// Test navigation from the middle post (ID: 3rd post).
611+
$current_post_id = $post_ids[2]; // 3rd post
612+
$this->go_to( get_permalink( $current_post_id ) );
613+
614+
// Previous post should be the 2nd post (lower ID, same date).
615+
$previous = get_adjacent_post( false, '', true );
616+
$this->assertInstanceOf( 'WP_Post', $previous );
617+
$this->assertEquals( $post_ids[1], $previous->ID );
618+
619+
// Next post should be the 4th post (higher ID, same date).
620+
$next = get_adjacent_post( false, '', false );
621+
$this->assertInstanceOf( 'WP_Post', $next );
622+
$this->assertEquals( $post_ids[3], $next->ID );
623+
}
624+
625+
/**
626+
* Test get_adjacent_post with mixed dates and identical dates.
627+
*
628+
* @ticket 8107
629+
*/
630+
public function test_get_adjacent_post_mixed_dates_with_identical_groups() {
631+
// Create posts with different dates.
632+
$post_early = self::factory()->post->create(
633+
array(
634+
'post_title' => 'Early Post',
635+
'post_date' => '2024-01-01 10:00:00',
636+
)
637+
);
638+
639+
// Create multiple posts with identical date.
640+
$identical_date = '2024-01-01 12:00:00';
641+
$post_ids = array();
642+
for ( $i = 1; $i <= 3; $i++ ) {
643+
$post_ids[] = self::factory()->post->create(
644+
array(
645+
'post_title' => "Identical Post $i",
646+
'post_date' => $identical_date,
647+
)
648+
);
649+
}
650+
651+
$post_late = self::factory()->post->create(
652+
array(
653+
'post_title' => 'Late Post',
654+
'post_date' => '2024-01-01 14:00:00',
655+
)
656+
);
657+
658+
// Test from first identical post.
659+
$this->go_to( get_permalink( $post_ids[0] ) );
660+
661+
// Previous should be the early post (different date).
662+
$previous = get_adjacent_post( false, '', true );
663+
$this->assertInstanceOf( 'WP_Post', $previous );
664+
$this->assertEquals( $post_early, $previous->ID );
665+
666+
// Next should be the second identical post (same date, higher ID).
667+
$next = get_adjacent_post( false, '', false );
668+
$this->assertInstanceOf( 'WP_Post', $next );
669+
$this->assertEquals( $post_ids[1], $next->ID );
670+
671+
// Test from middle identical post.
672+
$this->go_to( get_permalink( $post_ids[1] ) );
673+
674+
// Previous should be the first identical post (same date, lower ID).
675+
$previous = get_adjacent_post( false, '', true );
676+
$this->assertInstanceOf( 'WP_Post', $previous );
677+
$this->assertEquals( $post_ids[0], $previous->ID );
678+
679+
// Next should be the third identical post (same date, higher ID).
680+
$next = get_adjacent_post( false, '', false );
681+
$this->assertInstanceOf( 'WP_Post', $next );
682+
$this->assertEquals( $post_ids[2], $next->ID );
683+
684+
// Test from last identical post.
685+
$this->go_to( get_permalink( $post_ids[2] ) );
686+
687+
// Previous should be the second identical post (same date, lower ID).
688+
$previous = get_adjacent_post( false, '', true );
689+
$this->assertInstanceOf( 'WP_Post', $previous );
690+
$this->assertEquals( $post_ids[1], $previous->ID );
691+
692+
// Next should be the late post (different date).
693+
$next = get_adjacent_post( false, '', false );
694+
$this->assertInstanceOf( 'WP_Post', $next );
695+
$this->assertEquals( $post_late, $next->ID );
696+
}
697+
698+
/**
699+
* Test get_adjacent_post navigation through all posts with identical dates.
700+
*
701+
* @ticket 8107
702+
*/
703+
public function test_get_adjacent_post_navigation_through_identical_dates() {
704+
$identical_date = '2024-01-01 12:00:00';
705+
706+
// Create 4 posts with identical dates.
707+
$post_ids = array();
708+
for ( $i = 1; $i <= 4; $i++ ) {
709+
$post_ids[] = self::factory()->post->create(
710+
array(
711+
'post_title' => "Post $i",
712+
'post_date' => $identical_date,
713+
)
714+
);
715+
}
716+
717+
// Test navigation sequence: 1 -> 2 -> 3 -> 4.
718+
$this->go_to( get_permalink( $post_ids[0] ) );
719+
720+
// From post 1, next should be post 2.
721+
$next = get_adjacent_post( false, '', false );
722+
$this->assertEquals( $post_ids[1], $next->ID );
723+
724+
// From post 2, previous should be post 1, next should be post 3.
725+
$this->go_to( get_permalink( $post_ids[1] ) );
726+
$previous = get_adjacent_post( false, '', true );
727+
$this->assertEquals( $post_ids[0], $previous->ID );
728+
$next = get_adjacent_post( false, '', false );
729+
$this->assertEquals( $post_ids[2], $next->ID );
730+
731+
// From post 3, previous should be post 2, next should be post 4.
732+
$this->go_to( get_permalink( $post_ids[2] ) );
733+
$previous = get_adjacent_post( false, '', true );
734+
$this->assertEquals( $post_ids[1], $previous->ID );
735+
$next = get_adjacent_post( false, '', false );
736+
$this->assertEquals( $post_ids[3], $next->ID );
737+
738+
// From post 4, previous should be post 3.
739+
$this->go_to( get_permalink( $post_ids[3] ) );
740+
$previous = get_adjacent_post( false, '', true );
741+
$this->assertEquals( $post_ids[2], $previous->ID );
742+
}
743+
744+
/**
745+
* Test get_adjacent_post with identical dates and category filtering.
746+
*
747+
* @ticket 8107
748+
*/
749+
public function test_get_adjacent_post_identical_dates_with_category() {
750+
$identical_date = '2024-01-01 12:00:00';
751+
$category_id = self::factory()->category->create( array( 'name' => 'Test Category' ) );
752+
753+
// Create posts with identical dates, some in category.
754+
$post_ids = array();
755+
for ( $i = 1; $i <= 4; $i++ ) {
756+
$post_id = self::factory()->post->create(
757+
array(
758+
'post_title' => "Post $i",
759+
'post_date' => $identical_date,
760+
)
761+
);
762+
763+
// Add every other post to the category.
764+
if ( 0 === $i % 2 ) {
765+
wp_set_post_categories( $post_id, array( $category_id ) );
766+
}
767+
768+
$post_ids[] = $post_id;
769+
}
770+
771+
// Test from post 2 (in category).
772+
$this->go_to( get_permalink( $post_ids[1] ) );
773+
774+
// With category filtering, should only see posts in same category.
775+
$previous = get_adjacent_post( true, '', true, 'category' );
776+
$this->assertSame( '', $previous ); // No previous post in category
777+
778+
$next = get_adjacent_post( true, '', false, 'category' );
779+
$this->assertInstanceOf( 'WP_Post', $next );
780+
$this->assertEquals( $post_ids[3], $next->ID ); // Post 4 (in category)
781+
}
590782
}

tests/phpunit/tests/url.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,4 +569,62 @@ public function test_url_functions_for_dots_in_paths() {
569569
);
570570
}
571571
}
572+
573+
/**
574+
* Test get_adjacent_post with posts having identical post_date.
575+
*
576+
* @ticket 8107
577+
* @covers ::get_adjacent_post
578+
*/
579+
public function test_get_adjacent_post_with_identical_dates() {
580+
$identical_date = gmdate( 'Y-m-d H:i:s', time() );
581+
582+
// Create 3 posts with identical dates but different IDs.
583+
$post_ids = array();
584+
for ( $i = 1; $i <= 3; $i++ ) {
585+
$post_ids[] = self::factory()->post->create(
586+
array(
587+
'post_title' => "Identical Post $i",
588+
'post_date' => $identical_date,
589+
)
590+
);
591+
}
592+
593+
// Test from the middle post (2nd post).
594+
$GLOBALS['post'] = get_post( $post_ids[1] );
595+
596+
// Previous post should be the 1st post (lower ID, same date).
597+
$previous = get_adjacent_post( false, '', true );
598+
$this->assertInstanceOf( 'WP_Post', $previous );
599+
$this->assertSame( $post_ids[0], $previous->ID );
600+
601+
// Next post should be the 3rd post (higher ID, same date).
602+
$next = get_adjacent_post( false, '', false );
603+
$this->assertInstanceOf( 'WP_Post', $next );
604+
$this->assertSame( $post_ids[2], $next->ID );
605+
606+
// Test from the first post.
607+
$GLOBALS['post'] = get_post( $post_ids[0] );
608+
609+
// Previous should be empty (no earlier posts).
610+
$previous = get_adjacent_post( false, '', true );
611+
$this->assertSame( '', $previous );
612+
613+
// Next should be the 2nd post.
614+
$next = get_adjacent_post( false, '', false );
615+
$this->assertInstanceOf( 'WP_Post', $next );
616+
$this->assertSame( $post_ids[1], $next->ID );
617+
618+
// Test from the last post.
619+
$GLOBALS['post'] = get_post( $post_ids[2] );
620+
621+
// Previous should be the 2nd post.
622+
$previous = get_adjacent_post( false, '', true );
623+
$this->assertInstanceOf( 'WP_Post', $previous );
624+
$this->assertSame( $post_ids[1], $previous->ID );
625+
626+
// Next should be empty (no later posts).
627+
$next = get_adjacent_post( false, '', false );
628+
$this->assertSame( '', $next );
629+
}
572630
}

0 commit comments

Comments
 (0)