Make WordPress Core

Changeset 61066


Ignore:
Timestamp:
10/26/2025 09:19:07 PM (9 months ago)
Author:
ramonopoly
Message:

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.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/link-template.php

    r60987 r61066  
    19401940        }
    19411941
    1942         $op    = $previous ? '<' : '>';
    1943         $order = $previous ? 'DESC' : 'ASC';
     1942        $comparison_operator = $previous ? '<' : '>';
     1943        $order               = $previous ? 'DESC' : 'ASC';
    19441944
    19451945        /**
     
    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.
     
    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.
     
    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        /**
     
    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.
     
    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";
  • trunk/tests/phpunit/tests/link/getAdjacentPost.php

    r60733 r61066  
    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}
  • trunk/tests/phpunit/tests/url.php

    r60253 r61066  
    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}
Note: See TracChangeset for help on using the changeset viewer.