Make WordPress Core

Changeset 61066


Ignore:
Timestamp:
10/26/2025 09:19:07 PM (6 weeks 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.