Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/wp-includes/link-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,8 @@ function get_next_post( $in_same_term = false, $excluded_terms = '', $taxonomy =
* Can either be next or previous post.
*
* @since 2.5.0
* @since 6.9.0 Introduce deterministic fallback based in IDs to account for date collisions.
* @since 6.9.1 Remove deterministic fallback for sites modifying the WHERE clause via a filter. See #64390.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
Expand Down Expand Up @@ -1965,7 +1967,8 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
$join = apply_filters( "get_{$adjacent}_post_join", $join, $in_same_term, $excluded_terms, $taxonomy, $post );

// Prepare the where clause for the adjacent post query.
$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 '>'.
$where_prepared = $wpdb->prepare( "WHERE p.post_date $comparison_operator %s AND p.post_type = %s $where", $current_post_date, $post->post_type ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $comparison_operator is a string literal, either '<' or '>'.
$deterministic_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 '>'.

/**
* Filters the WHERE clause in the SQL for an adjacent post query.
Expand All @@ -1980,16 +1983,22 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
*
* @since 2.5.0
* @since 4.4.0 Added the `$taxonomy` and `$post` parameters.
* @since 6.9.0 Adds ID-based fallback for posts with identical dates in adjacent post queries.
*
* @param string $where The `WHERE` clause in the SQL.
* @param string $where_prepared The `WHERE` clause in the SQL.
* @param bool $in_same_term Whether post should be in the same taxonomy term.
* @param int[]|string $excluded_terms Array of excluded term IDs. Empty string if none were provided.
* @param string $taxonomy Taxonomy. Used to identify the term used when `$in_same_term` is true.
* @param WP_Post $post WP_Post object.
*/
$where = apply_filters( "get_{$adjacent}_post_where", $where_prepared, $in_same_term, $excluded_terms, $taxonomy, $post );

// Only force deterministic fallback if the where clause has not been modified by a filter.
if ( $where === $where_prepared ) {
$where = $deterministic_where_prepared;
}

$sort_prepared = "ORDER BY p.post_date $order LIMIT 1";

/**
* Filters the ORDER BY clause in the SQL for an adjacent post query.
*
Expand All @@ -2005,12 +2014,18 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo
* @since 4.4.0 Added the `$post` parameter.
* @since 4.9.0 Added the `$order` parameter.
* @since 6.9.0 Adds ID sort to ensure deterministic ordering for posts with identical dates.
* @since 6.9.1 Remove deterministic fallback for sites modifying the SORT clause via a filter. See #64390.
*
* @param string $order_by The `ORDER BY` clause in the SQL.
* @param WP_Post $post WP_Post object.
* @param string $order Sort order. 'DESC' for previous post, 'ASC' for next.
*/
$sort = apply_filters( "get_{$adjacent}_post_sort", "ORDER BY p.post_date $order, p.ID $order LIMIT 1", $post, $order );
$sort = apply_filters( "get_{$adjacent}_post_sort", $sort_prepared, $post, $order );

// Only force deterministic sort if the sort clause has not been modified by a filter.
if ( $sort === $sort_prepared ) {
$sort = "ORDER BY p.post_date $order, p.ID $order LIMIT 1";
}

$query = "SELECT p.ID FROM $wpdb->posts AS p $join $where $sort";
$key = md5( $query );
Expand Down
219 changes: 219 additions & 0 deletions tests/phpunit/tests/link/getAdjacentPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,225 @@ public function test_get_adjacent_post_with_identical_dates() {
$this->assertEquals( $post_ids[3], $next->ID );
}

/**
* Test that deterministic ID fallback is applied when WHERE filter doesn't modify the clause.
*
* @ticket 64390
*/
public function test_get_adjacent_post_identical_dates_applies_deterministic_where_when_filter_unmodified() {
$identical_date = '2024-01-01 12:00:00';

// Create posts with identical dates but different IDs.
$post_ids = array();
for ( $i = 1; $i <= 5; $i++ ) {
$post_ids[] = self::factory()->post->create(
array(
'post_title' => "Post $i",
'post_date' => $identical_date,
)
);
}

// Add a filter that doesn't modify the WHERE clause (returns unchanged).
add_filter(
'get_next_post_where',
static function ( $where ) {
// Return unchanged - deterministic fallback should be applied.
return $where;
}
);

// Test navigation from the middle post (ID: 3rd post).
$current_post_id = $post_ids[2]; // 3rd post
$this->go_to( get_permalink( $current_post_id ) );

// Next post should be the 4th post (higher ID, same date) - deterministic.
$next = get_adjacent_post( false, '', false );
$this->assertInstanceOf( 'WP_Post', $next );
$this->assertSame( $post_ids[3], $next->ID, 'Next post should be the 4th post (higher ID, same date).' );
}

/**
* Test that deterministic ID fallback is NOT applied when WHERE filter modifies the clause.
*
* @ticket 64390
*/
public function test_get_adjacent_post_identical_dates_respects_modified_where_filter() {
$identical_date = '2024-01-01 12:00:00';

// Create posts with identical dates but different IDs.
$post_ids = array();
for ( $i = 1; $i <= 5; $i++ ) {
$post_ids[] = self::factory()->post->create(
array(
'post_title' => "Post $i",
'post_date' => $identical_date,
)
);
}

// Capture what the filter receives and what it returns.
$filter_received = '';
$filter_returned = '';
add_filter(
'get_next_post_where',
static function ( $where ) use ( &$filter_received, &$filter_returned ) {
$filter_received = $where;
/*
* Modify the WHERE clause - deterministic fallback should NOT be applied.
* Add a harmless condition that won't affect results but proves the filter was applied.
* This is to ensure that the deterministic fallback is not applied on top of the filter's modification.
*/
$filter_returned = $where . ' AND 1=1';
return $filter_returned;
}
);

// Test navigation from the middle post (ID: 3rd post).
$current_post_id = $post_ids[2]; // 3rd post
$this->go_to( get_permalink( $current_post_id ) );

// Call get_adjacent_post to trigger the filter.
get_adjacent_post( false, '', false );

// Verify the filter received the non-deterministic WHERE clause (without ID fallback).
$this->assertNotEmpty( $filter_received, 'Filter should have been called.' );
$this->assertStringNotContainsString( 'AND p.ID', $filter_received, 'Filter should receive WHERE clause without deterministic ID fallback.' );
// Verify the filter's modification is preserved (proves deterministic logic wasn't applied on top).
$this->assertStringContainsString( 'AND 1=1', $filter_returned, 'Filter modification should be preserved.' );
}

/**
* Test that deterministic ID sort is applied when SORT filter doesn't modify the clause.
*
* @ticket 64390
*/
public function test_get_adjacent_post_identical_dates_applies_deterministic_sort_when_filter_unmodified() {
$identical_date = '2024-01-01 12:00:00';

// Create posts with identical dates but different IDs.
$post_ids = array();
for ( $i = 1; $i <= 5; $i++ ) {
$post_ids[] = self::factory()->post->create(
array(
'post_title' => "Post $i",
'post_date' => $identical_date,
)
);
}

// Add a filter that doesn't modify the SORT clause (returns unchanged).
add_filter(
'get_next_post_sort',
static function ( $sort ) {
// Return unchanged - deterministic ID sort should be applied.
return $sort;
}
);

// Test navigation from the middle post (ID: 3rd post).
$current_post_id = $post_ids[2]; // 3rd post
$this->go_to( get_permalink( $current_post_id ) );

// Next post should be the 4th post (higher ID, same date) - deterministic.
$next = get_adjacent_post( false, '', false );
$this->assertInstanceOf( 'WP_Post', $next );
$this->assertSame( $post_ids[3], $next->ID, 'Next post should be the 4th post (higher ID, same date).' );
}

/**
* Test that deterministic ID sort is NOT applied when SORT filter modifies the clause.
*
* @ticket 64390
*/
public function test_get_adjacent_post_identical_dates_respects_modified_sort_filter() {
$identical_date = '2024-01-01 12:00:00';

// Create posts with identical dates but different IDs.
$post_ids = array();
for ( $i = 1; $i <= 5; $i++ ) {
$post_ids[] = self::factory()->post->create(
array(
'post_title' => "Post $i",
'post_date' => $identical_date,
)
);
}

// Capture what the filter receives and what it returns.
$filter_received = '';
$filter_returned = '';
add_filter(
'get_next_post_sort',
static function ( $sort, $post, $order ) use ( &$filter_received, &$filter_returned ) {
$filter_received = $sort;
// Modify to remove ID - deterministic ID sort should NOT be applied.
$filter_returned = "ORDER BY p.post_date $order LIMIT 1";
return $filter_returned;
},
10,
3
);

// Test navigation from the middle post (ID: 3rd post).
$current_post_id = $post_ids[2]; // 3rd post
$this->go_to( get_permalink( $current_post_id ) );

// Call get_adjacent_post to trigger the filter.
get_adjacent_post( false, '', false );

// Verify the filter received the non-deterministic SORT clause (without ID).
$this->assertNotEmpty( $filter_received, 'Filter should have been called.' );
$this->assertStringNotContainsString( 'p.ID', $filter_received, 'Filter should receive SORT clause without deterministic ID sort.' );
// Verify the filter's modification is preserved (proves deterministic logic wasn't applied on top).
$this->assertStringNotContainsString( 'p.ID', $filter_returned, 'Filter modification should not include ID when filter removes it.' );
$this->assertStringContainsString( 'ORDER BY p.post_date', $filter_returned, 'Filter modification should be preserved.' );
}

/**
* Test that both WHERE and SORT filters work together correctly.
*
* @ticket 64390
*/
public function test_get_adjacent_post_identical_dates_with_both_filters_unmodified() {
$identical_date = '2024-01-01 12:00:00';

// Create posts with identical dates but different IDs.
$post_ids = array();
for ( $i = 1; $i <= 5; $i++ ) {
$post_ids[] = self::factory()->post->create(
array(
'post_title' => "Post $i",
'post_date' => $identical_date,
)
);
}

// Add filters that don't modify the clauses.
add_filter(
'get_previous_post_where',
static function ( $where ) {
return $where;
}
);

add_filter(
'get_previous_post_sort',
static function ( $sort ) {
return $sort;
}
);

// Test navigation from the middle post (ID: 3rd post).
$current_post_id = $post_ids[2]; // 3rd post
$this->go_to( get_permalink( $current_post_id ) );

// Previous post should be the 2nd post (lower ID, same date) - deterministic.
$previous = get_adjacent_post( false, '', true );
$this->assertInstanceOf( 'WP_Post', $previous );
$this->assertSame( $post_ids[1], $previous->ID, 'Previous post should be the 2nd post (lower ID, same date).' );
}

/**
* Test get_adjacent_post with mixed dates and identical dates.
*
Expand Down
Loading