Make WordPress Core

Changeset 64589


Ignore:
Timestamp:
10/20/2026 05:49:16 AM (25 hours ago)
Author:
westonruter
Message:

Posts, Post Types: Add pre_wp_count_attachments filter.

Introduces the pre_wp_count_attachments filter to wp_count_attachments(), the counterpart of pre_wp_count_posts. Returning a stdClass object short-circuits the function, skipping the object cache lookup and both of its queries. Since the counts cache group is non-persistent, those queries otherwise run on every request that needs the counts, and on a site with millions of attachments the GROUP BY post_mime_type aggregate alone can take several seconds. The filter allows the counts to be served instead from a value pre-computed in the background.

Unlike post statuses, the set of MIME types is not known ahead of time, so only a missing trash count is filled in with zero. The filtered value should therefore contain a count for every MIME type matching $mime_type, and only those types. As with pre_wp_count_posts, the value is not cached and is passed through the wp_count_attachments filter.

The pre_wp_count_posts filter introduced in r64245 is brought in line with the new one. A filtered value now short-circuits only when it is a stdClass object: any other non-null value was previously cast to an object, turning false into a nonsensical result, and filling in missing counts on another class can trigger a dynamic property deprecation. Both filters also clone the returned object, so that a callback which keeps it, such as in a static variable, does not have it modified by core or by later callbacks on the wp_count_posts and wp_count_attachments filters.

Developed in ​https://github.com/WordPress/wordpress-develop/pull/14210.
Follow-up to r64245.

Props jdeep, westonruter, peterwilsoncc.
See #66098.
Fixes #66291.

Location:
trunk
Files:
3 edited

Legend:

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

    r64583 r64589  
    35453545         * Filters the post counts before the query is run.
    35463546         *
    3547          * Returning a non-null value short-circuits wp_count_posts(), skipping both
    3548          * the object cache lookup and the database query. This allows the counts to
    3549          * be served from another source, such as a value pre-computed in the
    3550          * background, when an exact real-time count is not required.
    3551          *
    3552          * Any registered post status missing from the filtered value are added
     3547         * Returning a stdClass object short-circuits wp_count_posts(), skipping
     3548         * both the object cache lookup and the database query. This allows the
     3549         * counts to be served from another source, such as a value pre-computed in
     3550         * the background, when an exact real-time count is not required.
     3551         *
     3552         * Any registered post status missing from the filtered value is added
    35533553         * to the result and set to zero.
    35543554         *
    … …  
    35663566        $counts = apply_filters( 'pre_wp_count_posts', null, $type, $perm );
    35673567
    3568         if ( null !== $counts ) {
    3569                 $counts = (object) $counts;
     3568        if ( $counts instanceof stdClass ) {
     3569                $counts = clone $counts;
    35703570
    35713571                foreach ( get_post_stati() as $status ) {
    … …  
    36693669function wp_count_attachments( $mime_type = '' ) {
    36703670        global $wpdb;
     3671
     3672        /**
     3673         * Filters the attachment counts before the query is run.
     3674         *
     3675         * Returning a stdClass object short-circuits wp_count_attachments(),
     3676         * skipping both the object cache lookup and the database queries. This
     3677         * allows the counts to be served from another source, such as a value
     3678         * pre-computed in the background, when an exact real-time count is not
     3679         * required.
     3680         *
     3681         * If the filtered value does not contain a trash count, one is added to the
     3682         * result and set to zero. Missing MIME types are not added, as they depend on
     3683         * the attachments that exist. The filtered value should therefore contain a
     3684         * count for every MIME type matching `$mime_type` that has attachments,
     3685         * including types first uploaded after the value was computed, as callers
     3686         * may read the count for any MIME type that exists. It should also contain
     3687         * only MIME types matching `$mime_type`, with a trash count limited to
     3688         * attachments of those types, as the default query does.
     3689         *
     3690         * The returned value is not cached and passed through the
     3691         * {@see 'wp_count_attachments'} filter.
     3692         *
     3693         * @since 7.2.0
     3694         *
     3695         * @param stdClass|null   $counts    An object containing the attachment counts by
     3696         *                                   mime type, or null to run the default query.
     3697         *                                   Default null.
     3698         * @param string|string[] $mime_type Array or comma-separated list of MIME patterns.
     3699         */
     3700        $counts = apply_filters( 'pre_wp_count_attachments', null, $mime_type );
     3701
     3702        if ( $counts instanceof stdClass ) {
     3703                $counts = clone $counts;
     3704
     3705                if ( ! isset( $counts->trash ) ) {
     3706                        $counts->trash = 0;
     3707                }
     3708
     3709                /** This filter is documented in wp-includes/post.php */
     3710                return apply_filters( 'wp_count_attachments', $counts, $mime_type );
     3711        }
    36713712
    36723713        $cache_key = sprintf(
  • trunk/tests/phpunit/tests/post/wpCountAttachments.php

    r64071 r64589  
    3232                $this->assertEquals( $expected, $actual );
    3333        }
     34
     35        /**
     36         * @ticket 66291
     37         */
     38        public function test_pre_wp_count_attachments_short_circuits_query() {
     39                self::factory()->post->create_many(
     40                        3,
     41                        array(
     42                                'post_type'      => 'attachment',
     43                                'post_mime_type' => 'image/jpeg',
     44                        )
     45                );
     46
     47                $mime_type  = 'image/jpeg';
     48                $mock_count = 42;
     49                add_filter(
     50                        'pre_wp_count_attachments',
     51                        static function () use ( $mime_type, $mock_count ) {
     52                                return (object) array( $mime_type => $mock_count );
     53                        }
     54                );
     55
     56                $start_num_queries = get_num_queries();
     57                $counts            = wp_count_attachments();
     58
     59                $this->assertSame( 0, get_num_queries() - $start_num_queries, 'No database query should run when the filter returns a value.' );
     60                $this->assertObjectHasProperty( $mime_type, $counts );
     61                $this->assertSame( $mock_count, $counts->$mime_type, 'Count is expected to be filtered value.' );
     62                $this->assertObjectHasProperty( 'trash', $counts );
     63                $this->assertSame( 0, $counts->trash, 'Expected trash to be set to zero.' );
     64        }
     65
     66        /**
     67         * @ticket 66291
     68         */
     69        public function test_pre_wp_count_attachments_takes_precedence_over_cached_counts() {
     70                self::factory()->post->create_many(
     71                        3,
     72                        array(
     73                                'post_type'      => 'attachment',
     74                                'post_mime_type' => 'image/jpeg',
     75                        )
     76                );
     77
     78                wp_count_attachments();
     79                $this->assertNotFalse( wp_cache_get( 'attachments', 'counts' ), 'Expected the counts to be cached before the filter is added.' );
     80
     81                $mime_type  = 'image/jpeg';
     82                $mock_count = 42;
     83                add_filter(
     84                        'pre_wp_count_attachments',
     85                        static function () use ( $mime_type, $mock_count ) {
     86                                return (object) array( $mime_type => $mock_count );
     87                        }
     88                );
     89
     90                $this->assertSame( $mock_count, wp_count_attachments()->$mime_type, 'The filtered value should be returned instead of the cached counts.' );
     91        }
     92
     93        /**
     94         * @ticket 66291
     95         */
     96        public function test_pre_wp_count_attachments_null_runs_default_query() {
     97                self::factory()->post->create_many(
     98                        3,
     99                        array(
     100                                'post_type'      => 'attachment',
     101                                'post_mime_type' => 'image/jpeg',
     102                        )
     103                );
     104
     105                add_filter( 'pre_wp_count_attachments', '__return_null' );
     106                $start_num_queries = get_num_queries();
     107
     108                $this->assertSame( '3', wp_count_attachments()->{'image/jpeg'}, 'Attachment count is expected to be set.' );
     109                $this->assertGreaterThan( 0, get_num_queries() - $start_num_queries, 'wp_count_attachments() is expected to trigger database queries.' );
     110        }
     111
     112        /**
     113         * @ticket 66291
     114         *
     115         * @dataProvider data_non_stdclass_values
     116         *
     117         * @param mixed $value Filtered value.
     118         */
     119        public function test_pre_wp_count_attachments_non_stdclass_runs_default_query( $value ) {
     120                self::factory()->post->create_many(
     121                        3,
     122                        array(
     123                                'post_type'      => 'attachment',
     124                                'post_mime_type' => 'image/jpeg',
     125                        )
     126                );
     127
     128                add_filter(
     129                        'pre_wp_count_attachments',
     130                        static function () use ( $value ) {
     131                                return $value;
     132                        }
     133                );
     134                $start_num_queries = get_num_queries();
     135
     136                $this->assertSame( '3', wp_count_attachments()->{'image/jpeg'}, 'A filtered value that is not a stdClass object is expected to be ignored.' );
     137                $this->assertGreaterThan( 0, get_num_queries() - $start_num_queries, 'wp_count_attachments() is expected to trigger database queries.' );
     138        }
     139
     140        /**
     141         * Data provider for {@see self::test_pre_wp_count_attachments_non_stdclass_runs_default_query()}.
     142         *
     143         * @return array<non-falsy-string, array{ 0: mixed }>
     144         */
     145        public function data_non_stdclass_values(): array {
     146                return array(
     147                        'array'       => array( array( 'image/jpeg' => 42 ) ),
     148                        'false'       => array( false ),
     149                        'ArrayObject' => array( new ArrayObject( array( 'image/jpeg' => 42 ) ) ),
     150                );
     151        }
     152
     153        /**
     154         * @ticket 66291
     155         */
     156        public function test_pre_wp_count_attachments_receives_mime_type() {
     157                $filter = new MockAction();
     158                add_filter( 'pre_wp_count_attachments', array( $filter, 'filter' ), 10, 2 );
     159
     160                wp_count_attachments( array( 'image/jpeg', 'image/png' ) );
     161
     162                $args = $filter->get_args();
     163                $this->assertSame( array( null, array( 'image/jpeg', 'image/png' ) ), $args[0] );
     164        }
     165
     166        /**
     167         * @ticket 66291
     168         */
     169        public function test_pre_wp_count_attachments_keeps_filtered_trash_count() {
     170                $trash_count = 7;
     171                add_filter(
     172                        'pre_wp_count_attachments',
     173                        static function () use ( $trash_count ) {
     174                                return (object) array( 'trash' => $trash_count );
     175                        }
     176                );
     177
     178                $this->assertSame( $trash_count, wp_count_attachments()->trash, 'Expected trash count to be the filtered value.' );
     179        }
     180
     181        /**
     182         * @ticket 66291
     183         */
     184        public function test_pre_wp_count_attachments_value_is_passed_to_wp_count_attachments_filter() {
     185                add_filter(
     186                        'pre_wp_count_attachments',
     187                        static function () {
     188                                return (object) array( 'image/jpeg' => 5 );
     189                        }
     190                );
     191                $filter = new MockAction();
     192                add_filter( 'wp_count_attachments', array( $filter, 'filter' ), 10, 2 );
     193
     194                wp_count_attachments( 'image' );
     195
     196                $args = $filter->get_args();
     197                $this->assertSame( 1, $filter->get_call_count(), 'Expected the wp_count_attachments filter to run once.' );
     198                $this->assertEquals(
     199                        array(
     200                                (object) array(
     201                                        'image/jpeg' => 5,
     202                                        'trash'      => 0,
     203                                ),
     204                                'image',
     205                        ),
     206                        $args[0],
     207                        'Expected the wp_count_attachments filter to receive the filtered counts and MIME type.'
     208                );
     209        }
     210
     211        /**
     212         * @ticket 66291
     213         */
     214        public function test_pre_wp_count_attachments_does_not_modify_filtered_object() {
     215                $stored_counts = (object) array( 'image/jpeg' => 5 );
     216                add_filter(
     217                        'pre_wp_count_attachments',
     218                        static function () use ( $stored_counts ) {
     219                                return $stored_counts;
     220                        }
     221                );
     222
     223                $counts = wp_count_attachments();
     224
     225                $this->assertSame( 0, $counts->trash, 'Expected trash to be set to zero.' );
     226                $this->assertEquals( (object) array( 'image/jpeg' => 5 ), $stored_counts, 'The object returned by the filter should not be modified.' );
     227        }
     228
     229        /**
     230         * @ticket 66291
     231         */
     232        public function test_pre_wp_count_attachments_result_is_not_cached() {
     233                self::factory()->post->create_many(
     234                        3,
     235                        array(
     236                                'post_type'      => 'attachment',
     237                                'post_mime_type' => 'image/jpeg',
     238                        )
     239                );
     240
     241                $filter = static function () {
     242                        return (object) array( 'image/jpeg' => 99 );
     243                };
     244                add_filter( 'pre_wp_count_attachments', $filter );
     245
     246                $this->assertSame( 99, wp_count_attachments( 'image/jpeg' )->{'image/jpeg'}, 'Expected the filtered count.' );
     247                $this->assertFalse( wp_cache_get( 'attachments:image_jpeg', 'counts' ), 'Expected the filtered counts not to be cached.' );
     248
     249                remove_filter( 'pre_wp_count_attachments', $filter );
     250                $start_num_queries = get_num_queries();
     251
     252                $this->assertSame( '3', wp_count_attachments( 'image/jpeg' )->{'image/jpeg'}, 'Expected the real count once the filter is removed.' );
     253                $this->assertGreaterThan( 0, get_num_queries() - $start_num_queries, 'Expected database queries since nothing should have been cached.' );
     254        }
    34255}
  • trunk/tests/phpunit/tests/post/wpCountPosts.php

    r64245 r64589  
    4545        /**
    4646         * @ticket 66098
     47         *
     48         * @dataProvider data_non_stdclass_values
     49         *
     50         * @param mixed $value Filtered value.
     51         */
     52        public function test_pre_wp_count_posts_non_stdclass_runs_default_query( $value ) {
     53                self::factory()->post->create_many( 3 );
     54
     55                add_filter(
     56                        'pre_wp_count_posts',
     57                        static function () use ( $value ) {
     58                                return $value;
     59                        }
     60                );
     61                $start_num_queries = get_num_queries();
     62
     63                $this->assertSame( '3', wp_count_posts()->publish, 'A filtered value that is not a stdClass object is expected to be ignored.' );
     64                $this->assertGreaterThan( 0, get_num_queries() - $start_num_queries, 'wp_count_posts() is expected to trigger database queries.' );
     65        }
     66
     67        /**
     68         * Data provider for {@see self::test_pre_wp_count_posts_non_stdclass_runs_default_query()}.
     69         *
     70         * @return array<non-falsy-string, array{ 0: mixed }>
     71         */
     72        public function data_non_stdclass_values(): array {
     73                return array(
     74                        'array'       => array( array( 'publish' => 42 ) ),
     75                        'false'       => array( false ),
     76                        'ArrayObject' => array( new ArrayObject( array( 'publish' => 42 ) ) ),
     77                );
     78        }
     79
     80        /**
     81         * @ticket 66098
    4782         */
    4883        public function test_pre_wp_count_posts_receives_type_and_perm() {
    … …  
    106141         * @ticket 66098
    107142         */
     143        public function test_pre_wp_count_posts_value_is_passed_to_wp_count_posts_filter() {
     144                add_filter(
     145                        'pre_wp_count_posts',
     146                        static function () {
     147                                return (object) array( 'publish' => 5 );
     148                        }
     149                );
     150                $filter = new MockAction();
     151                add_filter( 'wp_count_posts', array( $filter, 'filter' ), 10, 3 );
     152
     153                wp_count_posts( 'page', 'readable' );
     154
     155                $expected_counts = (object) array_merge(
     156                        array_fill_keys( get_post_stati(), 0 ),
     157                        array( 'publish' => 5 )
     158                );
     159
     160                $args = $filter->get_args();
     161                $this->assertSame( 1, $filter->get_call_count(), 'Expected the wp_count_posts filter to run once.' );
     162                $this->assertEquals( array( $expected_counts, 'page', 'readable' ), $args[0], 'Expected the wp_count_posts filter to receive the filtered counts, type, and permission.' );
     163        }
     164
     165        /**
     166         * @ticket 66098
     167         */
     168        public function test_pre_wp_count_posts_does_not_modify_filtered_object() {
     169                $stored_counts = (object) array( 'publish' => 5 );
     170                add_filter(
     171                        'pre_wp_count_posts',
     172                        static function () use ( $stored_counts ) {
     173                                return $stored_counts;
     174                        }
     175                );
     176
     177                $counts = wp_count_posts();
     178
     179                $this->assertSame( 0, $counts->draft, 'A status missing from the filtered value should default to 0.' );
     180                $this->assertEquals( (object) array( 'publish' => 5 ), $stored_counts, 'The object returned by the filter should not be modified.' );
     181        }
     182
     183        /**
     184         * @ticket 66098
     185         */
    108186        public function test_pre_wp_count_posts_result_is_not_cached() {
    109                 add_filter(
    110                         'pre_wp_count_posts',
    111                         static function () {
    112                                 return (object) array( 'publish' => 99 );
    113                         }
    114                 );
    115 
    116                 wp_count_posts();
    117 
    118                 $this->assertFalse( wp_cache_get( _count_posts_cache_key( 'post' ), 'counts' ) );
     187                self::factory()->post->create_many( 3 );
     188
     189                $filter = static function () {
     190                        return (object) array( 'publish' => 99 );
     191                };
     192                add_filter( 'pre_wp_count_posts', $filter );
     193
     194                $this->assertSame( 99, wp_count_posts()->publish, 'Expected the filtered count.' );
     195                $this->assertFalse( wp_cache_get( _count_posts_cache_key( 'post' ), 'counts' ), 'Expected the filtered counts not to be cached.' );
     196
     197                remove_filter( 'pre_wp_count_posts', $filter );
     198                $start_num_queries = get_num_queries();
     199
     200                $this->assertSame( '3', wp_count_posts()->publish, 'Expected the real count once the filter is removed.' );
     201                $this->assertGreaterThan( 0, get_num_queries() - $start_num_queries, 'Expected database queries since nothing should have been cached.' );
    119202        }
    120203}
Note: See TracChangeset for help on using the changeset viewer.