diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index a6b105ef2877c..b1d75838682c9 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -16,7 +16,7 @@ * @since 4.5.0 Removed the `$comments_popup` property. */ #[AllowDynamicProperties] -class WP_Query { +class WP_Query implements JsonSerializable, Serializable { /** * Query vars set by the user. @@ -84,6 +84,14 @@ class WP_Query { */ public $request; + /** + * Get post database count query + * + * @since x.x.x + * @var string + */ + public $count_request; + /** * Array of post objects or post IDs. * @@ -165,17 +173,32 @@ class WP_Query { * If limit clause was not used, equals $post_count. * * @since 2.1.0 - * @var int + * @since x.x.x This value is now lazily loaded only when it's needed. + * + * @var int|null */ - public $found_posts = 0; + private $found_posts = null; /** * The number of pages. * * @since 2.1.0 + * @since x.x.x This value is now lazily loaded only when it's needed. + * * @var int */ - public $max_num_pages = 0; + private $max_num_pages = 0; + + /** + * Whether the SQL query to count the results should use `SQL_CALC_FOUND_ROWS`. + * + * Used only as a fallback if a filter on the main query reinstates `SELECT FOUND_ROWS()`. + * + * @since x.x.x + * + * @var bool + */ + private $use_calc_found_rows = false; /** * The number of comment pages. @@ -461,6 +484,14 @@ class WP_Query { */ private $stopwords; + /** + * The LIMIT clause of the query, used only during counting of results. + * + * @since x.x.x + * @var string + */ + private $limits = ''; + private $compat_fields = array( 'query_vars_hash', 'query_vars_changed' ); private $compat_methods = array( 'init_query_flags', 'parse_tax_query' ); @@ -509,21 +540,21 @@ private function init_query_flags() { * @since 1.5.0 */ public function init() { - unset( $this->posts ); - unset( $this->query ); + $this->posts = null; + $this->query = null; $this->query_vars = array(); - unset( $this->queried_object ); - unset( $this->queried_object_id ); + $this->queried_object = null; + $this->queried_object_id = null; $this->post_count = 0; $this->current_post = -1; $this->in_the_loop = false; - unset( $this->request ); - unset( $this->post ); - unset( $this->comments ); - unset( $this->comment ); + $this->request = null; + $this->post = null; + $this->comments = null; + $this->comment = null; $this->comment_count = 0; $this->current_comment = -1; - $this->found_posts = 0; + $this->found_posts = null; $this->max_num_pages = 0; $this->max_num_comment_pages = 0; @@ -1975,7 +2006,7 @@ public function get_posts() { $q['page'] = absint( $q['page'] ); } - // If true, forcibly turns off SQL_CALC_FOUND_ROWS even when limits are present. + // If true, forcibly turns off the query to count found rows even when limits are present. if ( isset( $q['no_found_rows'] ) ) { $q['no_found_rows'] = (bool) $q['no_found_rows']; } else { @@ -3044,20 +3075,19 @@ public function get_posts() { $limits = isset( $clauses['limits'] ) ? $clauses['limits'] : ''; } + $count_field = "{$wpdb->posts}.ID"; + if ( ! empty( $groupby ) ) { + $count_field = $groupby; + $groupby = 'GROUP BY ' . $groupby; } if ( ! empty( $orderby ) ) { $orderby = 'ORDER BY ' . $orderby; } - $found_rows = ''; - if ( ! $q['no_found_rows'] && ! empty( $limits ) ) { - $found_rows = 'SQL_CALC_FOUND_ROWS'; - } - $old_request = " - SELECT $found_rows $distinct $fields + SELECT $distinct $fields FROM {$wpdb->posts} $join WHERE 1=1 $where $groupby @@ -3066,17 +3096,39 @@ public function get_posts() { "; $this->request = $old_request; + $this->count_request = " + SELECT COUNT(DISTINCT {$count_field}) + FROM {$wpdb->posts} $join + WHERE 1=1 $where + "; if ( ! $q['suppress_filters'] ) { /** * Filters the completed SQL query before sending. * * @since 2.0.0 + * @since x.x.x This query no longer contains a `SQL_CALC_FOUND_ROWS` modifier by default. * * @param string $request The complete SQL query. * @param WP_Query $query The WP_Query instance (passed by reference). */ $this->request = apply_filters_ref_array( 'posts_request', array( $this->request, &$this ) ); + + if ( false !== strpos( $this->request, 'SQL_CALC_FOUND_ROWS' ) ) { + _deprecated_argument( + 'The posts_request filter', + 'x.x.x', + sprintf( + /* translators: 1: SQL query modifier 2: SQL query */ + __( 'The %1$s query modifier should no longer be added to queries because results are no longer counted with %2$s by default.' ), + 'SQL_CALC_FOUND_ROWS', + 'SELECT FOUND_ROWS()' + ) + ); + + $this->use_calc_found_rows = true; + $this->count_request = 'SELECT FOUND_ROWS()'; + } } /** @@ -3173,7 +3225,7 @@ public function get_posts() { /** @var int[] */ $this->posts = array_map( 'intval', $this->posts ); $this->post_count = count( $this->posts ); - $this->set_found_posts( $q, $limits ); + $this->set_limits( $limits ); if ( $q['cache_results'] && $id_query_is_cacheable ) { $cache_value = array( @@ -3194,7 +3246,7 @@ public function get_posts() { } $this->post_count = count( $this->posts ); - $this->set_found_posts( $q, $limits ); + $this->set_limits( $limits ); /** @var int[] */ $post_parents = array(); @@ -3242,7 +3294,7 @@ public function get_posts() { // First get the IDs and then fill in the objects. $this->request = " - SELECT $found_rows $distinct {$wpdb->posts}.ID + SELECT $distinct {$wpdb->posts}.ID FROM {$wpdb->posts} $join WHERE 1=1 $where $groupby @@ -3254,6 +3306,7 @@ public function get_posts() { * Filters the Post IDs SQL request before sending. * * @since 3.4.0 + * @since x.x.x This query now no longer contains a `SQL_CALC_FOUND_ROWS` modifier. * * @param string $request The post ID request. * @param WP_Query $query The WP_Query instance. @@ -3264,14 +3317,14 @@ public function get_posts() { if ( $post_ids ) { $this->posts = $post_ids; - $this->set_found_posts( $q, $limits ); + $this->set_limits( $limits ); _prime_post_caches( $post_ids, $q['update_post_term_cache'], $q['update_post_meta_cache'] ); } else { $this->posts = array(); } } else { $this->posts = $wpdb->get_results( $this->request ); - $this->set_found_posts( $q, $limits ); + $this->set_limits( $limits ); } } @@ -3502,18 +3555,24 @@ public function get_posts() { * for the current query. * * @since 3.5.0 + * @since x.x.x The `$q` and `$limits` parameters were removed. * * @global wpdb $wpdb WordPress database abstraction object. - * - * @param array $q Query variables. - * @param string $limits LIMIT clauses of the query. */ - private function set_found_posts( $q, $limits ) { + private function set_found_posts() { global $wpdb; + if ( null !== $this->found_posts ) { + return; + } + + $q = $this->query_vars; + $limits = $this->limits; + // Bail if posts is an empty array. Continue if posts is an empty string, // null, or false to accommodate caching plugins that fill posts later. - if ( $q['no_found_rows'] || ( is_array( $this->posts ) && ! $this->posts ) ) { + if ( ! isset( $q['no_found_rows'] ) || $q['no_found_rows'] || ( is_array( $this->posts ) && ! $this->posts ) ) { + $this->found_posts = 0; return; } @@ -3522,11 +3581,15 @@ private function set_found_posts( $q, $limits ) { * Filters the query to run for retrieving the found posts. * * @since 2.1.0 + * @since x.x.x This query was changed from `SELECT FOUND_ROWS()` to a more + * efficient `COUNT` query which only runs when the `found_posts` + * or `max_num_pages` properties are first accessed. This allows + * for lazy population of the result count. * * @param string $found_posts_query The query to run to find the found posts. * @param WP_Query $query The WP_Query instance (passed by reference). */ - $found_posts_query = apply_filters_ref_array( 'found_posts_query', array( 'SELECT FOUND_ROWS()', &$this ) ); + $found_posts_query = apply_filters_ref_array( 'found_posts_query', array( $this->count_request, &$this ) ); $this->found_posts = (int) $wpdb->get_var( $found_posts_query ); } else { @@ -3545,6 +3608,9 @@ private function set_found_posts( $q, $limits ) { * Filters the number of found posts for the query. * * @since 2.1.0 + * @since x.x.x By default this filter now only runs when the `found_posts` or + * `max_num_pages` properties are first accessed. This allows for + * lazy population of the result count. * * @param int $found_posts The number of posts found. * @param WP_Query $query The WP_Query instance (passed by reference). @@ -3556,6 +3622,21 @@ private function set_found_posts( $q, $limits ) { } } + /** + * Stores the LIMIT clause of the query for later use. + * + * @since x.x.x + * + * @param string $limits The LIMIT clause of the query. + */ + private function set_limits( $limits ) { + $this->limits = $limits; + + if ( $this->use_calc_found_rows ) { + $this->set_found_posts(); + } + } + /** * Set up the next post and iterate current post index. * @@ -3883,21 +3964,48 @@ public function __construct( $query = '' ) { } /** - * Make private properties readable for backward compatibility. + * Make private properties readable for backward compatibility and lazy loading. * * @since 4.0.0 + * @since x.x.x The `found_posts` and `max_num_pages` properties are now lazily loaded. * * @param string $name Property to get. * @return mixed Property. */ public function __get( $name ) { + if ( 'found_posts' === $name || 'max_num_pages' === $name ) { + $this->set_found_posts(); + return $this->$name; + } + if ( in_array( $name, $this->compat_fields, true ) ) { return $this->$name; } } /** - * Make private properties checkable for backward compatibility. + * Allows some private properties to be set. + * + * @since x.x.x + * + * @param string $name Name of property to set. + * @param mixed $value Value to set. + */ + public function __set( $name, $value ) { + if ( 'found_posts' === $name || 'max_num_pages' === $name ) { + $this->$name = $value; + return; + } + + // This allows third party code to set dynamic properties. + if ( ! property_exists( $this, $name ) ) { + $this->$name = $value; + return; + } + } + + /** + * Make private properties checkable for backward compatibility and lazy loading. * * @since 4.0.0 * @@ -3905,6 +4013,10 @@ public function __get( $name ) { * @return bool Whether the property is set. */ public function __isset( $name ) { + if ( 'found_posts' === $name || 'max_num_pages' === $name ) { + return true; + } + if ( in_array( $name, $this->compat_fields, true ) ) { return isset( $this->$name ); } @@ -3926,6 +4038,74 @@ public function __call( $name, $arguments ) { return false; } + /** + * Controls how the object is represented during PHP serialization. + * + * @since x.x.x + * + * @return string The PHP serialized representation of the object. + */ + public function serialize() { + return serialize( $this->__serialize() ); + } + + /** + * Controls how the object is represented during PHP serialization. + * + * Used by PHP >= 7.4. + * + * @since x.x.x + * + * @return array The properties of the object as an associative array. + */ + public function __serialize() { // phpcs:ignore PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__serializeFound + $this->set_found_posts(); + + return get_object_vars( $this ); + } + + /** + * Controls how the object is reconstructed from a PHP serialized representation. + * + * @since x.x.x + * + * @param string $data The PHP serialized representation of the object. + * @return void + */ + public function unserialize( $data ) { + $this->__unserialize( unserialize( $data ) ); + } + + /** + * Controls how the object is reconstructed from a PHP serialized representation. + * + * Used by PHP >= 7.4. + * + * @since x.x.x + * + * @param array $data The associative array representation of the object. + * @return void + */ + public function __unserialize( $data ) { // phpcs:ignore PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__unserializeFound + foreach ( $data as $key => $value ) { + $this->$key = $value; + } + } + + /** + * Controls how the object is represented during JSON serialization. + * + * @since x.x.x + * + * @return array The properties of the object as an associative array. + */ + #[ReturnTypeWillChange] + public function jsonSerialize() { + $this->set_found_posts(); + + return get_object_vars( $this ); + } + /** * Is the query for an existing archive page? * diff --git a/tests/phpunit/tests/post/query.php b/tests/phpunit/tests/post/query.php index 66bcdc26915eb..72557b29aafc4 100644 --- a/tests/phpunit/tests/post/query.php +++ b/tests/phpunit/tests/post/query.php @@ -777,4 +777,948 @@ public function test_found_posts_should_be_integer_even_if_found_posts_filter_re $this->assertIsInt( $q->found_posts ); } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_with_no_found_rows_false( $fields ) { + $q = new WP_Query( + array( + 'fields' => $fields, + 'post_type' => 'post', + 'no_found_rows' => false, + ) + ); + + $this->assertTrue( property_exists( $q, 'found_posts' ) ); + $this->assertTrue( isset( $q->found_posts ) ); + $this->assertNotNull( $q->found_posts ); + + $this->assertTrue( property_exists( $q, 'max_num_pages' ) ); + $this->assertTrue( isset( $q->max_num_pages ) ); + $this->assertNotNull( $q->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_with_no_found_rows_true( $fields ) { + $q = new WP_Query( + array( + 'fields' => $fields, + 'post_type' => 'post', + 'no_found_rows' => true, + ) + ); + + $this->assertTrue( property_exists( $q, 'found_posts' ) ); + $this->assertTrue( isset( $q->found_posts ) ); + $this->assertNotNull( $q->found_posts ); + + $this->assertTrue( property_exists( $q, 'max_num_pages' ) ); + $this->assertTrue( isset( $q->max_num_pages ) ); + $this->assertNotNull( $q->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_can_be_set_externally( $fields ) { + add_filter( + 'posts_request', + static function( $request, $query ) { + $query->found_posts = 123; + $query->max_num_pages = 456; + + return $request; + }, + 10, + 2 + ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'post_type' => 'post', + ) + ); + + $this->assertSame( 123, $q->found_posts ); + $this->assertEquals( 456, $q->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_after_php_unserialization( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + ) + ); + + $serialized = serialize( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = unserialize( $serialized ); + + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 5, $unserialized->found_posts ); + + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertEquals( 3, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_after_php_unserialization_with_no_found_rows( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + 'no_found_rows' => true, + ) + ); + + $serialized = serialize( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = unserialize( $serialized ); + + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 0, $unserialized->found_posts ); + + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertSame( 0, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_after_json_unserialization( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + ) + ); + + $serialized = json_encode( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = json_decode( $serialized ); + + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 5, $unserialized->found_posts ); + + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertEquals( 3, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_after_json_unserialization_with_no_found_rows( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + 'no_found_rows' => true, + ) + ); + + $serialized = json_encode( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = json_decode( $serialized ); + + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 0, $unserialized->found_posts ); + + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertSame( 0, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_before_php_unserialization( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + ) + ); + + // Fetch found_posts and max_num_pages here so the values are populated when serialized. + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + $serialized = serialize( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = unserialize( $serialized ); + + $this->assertSame( 5, $found_posts ); + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 5, $unserialized->found_posts ); + + $this->assertEquals( 3, $max_num_pages ); + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertEquals( 3, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_before_php_unserialization_with_no_found_rows( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + 'no_found_rows' => true, + ) + ); + + // Fetch found_posts and max_num_pages here so the values are populated when serialized. + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + $serialized = serialize( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = unserialize( $serialized ); + + $this->assertSame( 0, $found_posts ); + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 0, $unserialized->found_posts ); + + $this->assertSame( 0, $max_num_pages ); + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertSame( 0, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_before_json_unserialization( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + ) + ); + + // Fetch found_posts and max_num_pages here so the values are populated when serialized. + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + $serialized = json_encode( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = json_decode( $serialized ); + + $this->assertSame( 5, $found_posts ); + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 5, $unserialized->found_posts ); + + $this->assertEquals( 3, $max_num_pages ); + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertEquals( 3, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_property_is_present_when_calculated_before_json_unserialization_with_no_found_rows( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'post_type' => 'post', + 'no_found_rows' => true, + ) + ); + + // Fetch found_posts and max_num_pages here so the values are populated when serialized. + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + $serialized = json_encode( $q ); + + // Create more matching posts to simulate unserialization occuring at a later date. + self::factory()->post->create_many( 5 ); + + $unserialized = json_decode( $serialized ); + + $this->assertSame( 0, $found_posts ); + $this->assertTrue( property_exists( $unserialized, 'found_posts' ) ); + $this->assertTrue( isset( $unserialized->found_posts ) ); + $this->assertSame( 0, $unserialized->found_posts ); + + $this->assertSame( 0, $max_num_pages ); + $this->assertTrue( property_exists( $unserialized, 'max_num_pages' ) ); + $this->assertTrue( isset( $unserialized->max_num_pages ) ); + $this->assertSame( 0, $unserialized->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_should_be_lazily_loaded( $fields ) { + global $wpdb; + + self::factory()->post->create_many( 5 ); + + $start = $wpdb->num_queries; + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'update_post_term_cache' => false, + 'update_post_meta_cache' => false, + ) + ); + + // Count the queries + $before_found_posts = ( $wpdb->num_queries - $start ); + + // Fetch found posts + $post_count = $q->post_count; + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + // Count the queries after fetching found posts. + $after_found_posts = ( $wpdb->num_queries - $start ); + + // Repeat + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + // Count the queries after fetching found posts a second time. + $after_found_posts_again = ( $wpdb->num_queries - $start ); + + // Ensure the posts were not initially counted. + $this->assertSame( 1, $before_found_posts ); + + // Ensure the counts are correct. + $this->assertSame( 2, $post_count ); + $this->assertSame( 5, $found_posts ); + $this->assertEquals( 3, $max_num_pages ); + + // Ensure subsequent counts only triggered one query. + $this->assertSame( ( $before_found_posts + 1 ), $after_found_posts ); + $this->assertSame( ( $before_found_posts + 1 ), $after_found_posts_again ); + } + + /** + * @ticket 47280 + */ + public function test_found_posts_are_correct_for_empty_query() { + self::factory()->post->create_many( 12 ); + + $q = new WP_Query(); + + $this->assertSame( 0, $q->post_count ); + $this->assertSame( 0, $q->found_posts ); + $this->assertSame( 0, $q->max_num_pages ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_query_with_no_results( $fields ) { + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_status' => 'draft', + ) + ); + + $this->assertSame( 0, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 0, $q->found_posts, self::get_count_message( $q ) ); + $this->assertSame( 0, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_basic_query( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_query_with_no_limit( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => -1, + ) + ); + + $this->assertSame( 5, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + // You would expect this to be 1 but historically it's 0 for posts without paging. + $this->assertSame( 0, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_query_with_no_paging( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'nopaging' => true, + ) + ); + + $this->assertSame( 5, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + // You would expect this to be 1 but historically it's 0 for posts without paging. + $this->assertSame( 0, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_query_with_no_found_rows( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'no_found_rows' => true, + ) + ); + + $this->assertSame( 5, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 0, $q->found_posts, self::get_count_message( $q ) ); + $this->assertSame( 0, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_paged_query( $fields ) { + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'paged' => 3, + ) + ); + + $this->assertSame( 1, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_author_queries( $fields ) { + $author = self::factory()->user->create(); + self::factory()->post->create_many( 5 ); + self::factory()->post->create_many( + 5, + array( + 'post_author' => $author, + ) + ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'author' => $author, + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * A query for the following triggers an additional LEFT JOIN on `wp_posts`: + * + * - Custom taxonomy query + * - `post_status` specified + * - `post_type` not specified + * + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_query_that_performs_post_status_join( $fields ) { + $taxonomy = 'post_status_join_tax'; + register_taxonomy( $taxonomy, 'post' ); + $term = self::factory()->term->create_and_get( + array( + 'taxonomy' => $taxonomy, + ) + ); + self::factory()->post->create_many( 5 ); + $ids = self::factory()->post->create_many( 5 ); + + foreach ( $ids as $id ) { + wp_set_post_terms( $id, $term->slug, $taxonomy ); + } + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'taxonomy' => $taxonomy, + 'term' => $term->slug, + 'post_status' => 'publish', + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_tax_queries( $fields ) { + $term = self::factory()->term->create_and_get(); + self::factory()->post->create_many( 5 ); + $ids = self::factory()->post->create_many( 5 ); + + foreach ( $ids as $id ) { + wp_set_post_terms( $id, $term->slug ); + } + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'tag' => $term->slug, + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_meta_queries( $fields ) { + self::factory()->post->create_many( 5 ); + $ids = self::factory()->post->create_many( 5 ); + + foreach ( $ids as $id ) { + add_post_meta( $id, 'my_meta', 'foo' ); + } + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'meta_key' => 'my_meta', + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_multiple_meta_queries( $fields ) { + self::factory()->post->create_many( 5 ); + $ids = self::factory()->post->create_many( 5 ); + + foreach ( $ids as $id ) { + add_post_meta( $id, 'field_1', 'foo' ); + add_post_meta( $id, 'field_2', 'bar' ); + } + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'meta_query' => array( + array( + 'key' => 'field_1', + ), + array( + 'key' => 'field_2', + ), + ), + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_OR_meta_queries( $fields ) { + self::factory()->post->create_many( 5 ); + $ids = self::factory()->post->create_many( 5 ); + + // Add a mixture of meta values so all 5 posts match the meta query. + add_post_meta( $ids[0], 'field_1', 'foo' ); + add_post_meta( $ids[0], 'field_2', 'bar' ); + add_post_meta( $ids[1], 'field_1', 'foo' ); + add_post_meta( $ids[2], 'field_1', 'foo' ); + add_post_meta( $ids[3], 'field_2', 'bar' ); + add_post_meta( $ids[4], 'field_2', 'bar' ); + + // This query results in a `GROUP BY wp_posts.ID` clause, which means the + // count query must count `DISTINCT wp_posts.ID` to eliminate duplicates. + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'field_1', + ), + array( + 'key' => 'field_2', + ), + ), + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_for_group_by_queries( $fields ) { + $user1 = self::factory()->user->create(); + $user2 = self::factory()->user->create(); + + self::factory()->post->create_many( + 5, + array( + 'post_author' => $user1, + ) + ); + self::factory()->post->create_many( + 5, + array( + 'post_author' => $user2, + ) + ); + + /** + * Adds a GROUP BY clause to the query. + */ + add_filter( + 'posts_groupby_request', + function() { + return "{$GLOBALS['wpdb']->posts}.post_author"; + } + ); + + // This query results in a `GROUP BY wp_posts.post_author` clause, which means the + // count query must count `DISTINCT wp_posts.post_author` to eliminate duplicates. + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + ) + ); + + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + // There is a total of two distinct authors in the results. + $this->assertSame( 2, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 1, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @group ms-required + * @dataProvider data_fields + * + * @param string $fields Value of the `fields` argument for `WP_Query`. + */ + public function test_found_posts_are_correct_when_switching_between_sites( $fields ) { + $blog = self::factory()->blog->create(); + self::factory()->post->create_many( 5 ); + + $q = new WP_Query( + array( + 'fields' => $fields, + 'posts_per_page' => 2, + ) + ); + + // Switch to another site. + switch_to_blog( $blog ); + + // Count the posts from the original site. This works because the SQL query + // and its table names has already been formed during the original query. + $post_count = $q->post_count; + $found_posts = $q->found_posts; + $max_num_pages = $q->max_num_pages; + + // Switch back. + restore_current_blog(); + + $this->assertSame( 2, $post_count, self::get_count_message( $q ) ); + $this->assertSame( 5, $found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 3, $max_num_pages, self::get_count_message( $q ) ); + } + + /** + * @ticket 47280 + * @expectedDeprecated The posts_request filter + */ + public function test_posts_are_counted_with_select_found_rows_when_query_includes_sql_calc_found_rows() { + // Create five published posts. + self::factory()->post->create_many( 5 ); + // Create ten draft posts. + self::factory()->post->create_many( + 10, + array( + 'post_status' => 'draft', + ) + ); + + add_filter( + 'posts_request', + static function( $request ) { + global $wpdb; + + return " + SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID + FROM {$wpdb->posts} + WHERE 1=1 + AND {$wpdb->posts}.post_type = 'post' + AND {$wpdb->posts}.post_status = 'draft' + ORDER BY {$wpdb->posts}.post_date + DESC LIMIT 0, 2 + "; + } + ); + + $q = new WP_Query( + array( + 'posts_per_page' => 2, + ) + ); + + // These results should now reflect the results for draft posts, as set by the filter. + $this->assertStringContainsString( 'SELECT FOUND_ROWS()', $q->count_request ); + $this->assertSame( 2, $q->post_count, self::get_count_message( $q ) ); + $this->assertSame( 10, $q->found_posts, self::get_count_message( $q ) ); + $this->assertEquals( 5, $q->max_num_pages, self::get_count_message( $q ) ); + } + + /** + * Data provider for tests which need to run once for each possible value of the fields argument. + * + * @return array Test data. + */ + public function data_fields() { + return array( + 'posts' => array( + '', + ), + 'ids' => array( + 'ids', + ), + 'parents' => array( + 'id=>parent', + ), + ); + } + + /** + * Helper method which returns a readable representation of the SQL queries performed. + * + * @param WP_Query $query The current query instance. + * @return string The formatted message. + */ + protected static function get_count_message( WP_Query $query ) { + return sprintf( + "Request SQL:\n\n%s\n\nCount SQL:\n\n%s\n", + self::format_sql( $query->request ), + self::format_sql( $query->count_request ) + ); + } + + /** + * Applies some basic formatting to an SQL query to make it more readable during a test failure. + * + * @param string $sql The SQL query to be formatted. + * @return string The formatted SQL query. + */ + protected static function format_sql( $sql ) { + $sql = preg_replace( + '# (FROM|INNER JOIN|LEFT JOIN|ON|WHERE|AND|GROUP BY|ORDER BY|LIMIT) #', + "\n\$1 ", + $sql + ); + $sql = preg_replace( '#^\t+#m', '', $sql ); + + return $sql; + } + } diff --git a/tests/phpunit/tests/query/noFoundRows.php b/tests/phpunit/tests/query/noFoundRows.php index 7b4f66a416030..39544de91358f 100644 --- a/tests/phpunit/tests/query/noFoundRows.php +++ b/tests/phpunit/tests/query/noFoundRows.php @@ -4,6 +4,10 @@ * @group query */ class Tests_Query_NoFoundRows extends WP_UnitTestCase { + + /** + * @ticket 47280 + */ public function test_no_found_rows_default() { $q = new WP_Query( array( @@ -11,9 +15,12 @@ public function test_no_found_rows_default() { ) ); - $this->assertStringContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); } + /** + * @ticket 47280 + */ public function test_no_found_rows_false() { $q = new WP_Query( array( @@ -22,9 +29,12 @@ public function test_no_found_rows_false() { ) ); - $this->assertStringContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); } + /** + * @ticket 47280 + */ public function test_no_found_rows_0() { $q = new WP_Query( array( @@ -33,9 +43,12 @@ public function test_no_found_rows_0() { ) ); - $this->assertStringContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); } + /** + * @ticket 47280 + */ public function test_no_found_rows_empty_string() { $q = new WP_Query( array( @@ -44,9 +57,12 @@ public function test_no_found_rows_empty_string() { ) ); - $this->assertStringContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); } + /** + * @ticket 47280 + */ public function test_no_found_rows_true() { $q = new WP_Query( array( @@ -58,6 +74,9 @@ public function test_no_found_rows_true() { $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); } + /** + * @ticket 47280 + */ public function test_no_found_rows_non_bool_cast_to_true() { $q = new WP_Query( array( @@ -71,6 +90,7 @@ public function test_no_found_rows_non_bool_cast_to_true() { /** * @ticket 29552 + * @ticket 47280 */ public function test_no_found_rows_default_with_nopaging_true() { $p = self::factory()->post->create(); @@ -88,6 +108,7 @@ public function test_no_found_rows_default_with_nopaging_true() { /** * @ticket 29552 + * @ticket 47280 */ public function test_no_found_rows_default_with_postsperpage_minus1() { $p = self::factory()->post->create();