diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index d1af2996bc7fb..3128a3eae2c16 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -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. * @@ -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. @@ -1980,9 +1983,8 @@ 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. @@ -1990,6 +1992,13 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo */ $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. * @@ -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 ); diff --git a/tests/phpunit/tests/link/getAdjacentPost.php b/tests/phpunit/tests/link/getAdjacentPost.php index 4d68493bfe8de..30de08c982b39 100644 --- a/tests/phpunit/tests/link/getAdjacentPost.php +++ b/tests/phpunit/tests/link/getAdjacentPost.php @@ -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. *