Skip to content

Commit 2d6be55

Browse files
ellatrixclaude
andcommitted
Restrict invalidates_query_cache to post meta, add reasoning comments
Rename wp_meta_key_invalidates_query_cache to wp_post_meta_invalidates_query_cache with a required post_type parameter. Reject the flag for non-post object types in register_meta. In WP_Meta_Query, only refuse non-cacheable keys when the post type is known from the parent WP_Query context. Without context, allow the query through — it is better to allow a query than to incorrectly block a legitimate one. Cache correctness is guaranteed by the invalidation side (wp_cache_set_posts_last_changed), which always knows the post type from the object ID. Add comments explaining this reasoning throughout the code and tests. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 6e3d8eb commit 2d6be55

4 files changed

Lines changed: 104 additions & 47 deletions

File tree

src/wp-includes/class-wp-meta-query.php

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -561,8 +561,8 @@ protected function get_sql_for_query( &$query, $depth = 0 ) {
561561
public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) {
562562
global $wpdb;
563563

564-
// Refuse to query by a meta key registered as non-query-cacheable.
565-
if ( $this->object_type
564+
// Refuse to query by a post meta key registered as non-query-cacheable.
565+
if ( 'post' === $this->object_type
566566
&& isset( $clause['key'] )
567567
&& is_string( $clause['key'] )
568568
&& $this->is_non_cacheable_meta_key( $clause['key'] )
@@ -860,28 +860,39 @@ public function get_clauses() {
860860
/**
861861
* Checks whether a meta key is registered as non-query-cacheable.
862862
*
863-
* When object subtypes are known (e.g. post types from the parent WP_Query),
864-
* checks each subtype individually. If any subtype has the key registered as
865-
* non-cacheable, returns true. Falls back to searching across all subtypes
866-
* when no subtypes are available.
863+
* This check is deliberately conservative: it only refuses a meta key
864+
* when the post type is known from the parent WP_Query context. When
865+
* no post type context is available (e.g. direct WP_Meta_Query usage
866+
* without a WP_Query, or post_type set to 'any'), the key is allowed
867+
* through. This avoids incorrectly blocking legitimate queries.
868+
*
869+
* Correctness is guaranteed by the cache invalidation side
870+
* (wp_cache_set_posts_last_changed), which always has the post type
871+
* from the object ID. That side is authoritative — it ensures
872+
* non-cacheable meta never invalidates query caches. This query-side
873+
* check is a safety net to warn developers and prevent obviously
874+
* broken queries, not the primary enforcement mechanism.
875+
*
876+
* When multiple post types are queried, the key is refused if any
877+
* of them has it registered as non-cacheable.
867878
*
868879
* @since 7.0.0
869880
*
870881
* @param string $meta_key Meta key to check.
871882
* @return bool True if the meta key is non-cacheable, false otherwise.
872883
*/
873884
protected function is_non_cacheable_meta_key( $meta_key ) {
874-
if ( ! empty( $this->object_subtypes ) ) {
875-
foreach ( $this->object_subtypes as $subtype ) {
876-
if ( ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key, $subtype ) ) {
877-
return true;
878-
}
879-
}
885+
if ( empty( $this->object_subtypes ) ) {
880886
return false;
881887
}
882888

883-
// No subtypes available — fall back to checking all subtypes.
884-
return ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key );
889+
foreach ( $this->object_subtypes as $subtype ) {
890+
if ( ! wp_post_meta_invalidates_query_cache( $meta_key, $subtype ) ) {
891+
return true;
892+
}
893+
}
894+
895+
return false;
885896
}
886897

887898
/**

src/wp-includes/meta.php

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,6 +1509,12 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) {
15091509
}
15101510
}
15111511

1512+
if ( ! $args['invalidates_query_cache'] && 'post' !== $object_type ) {
1513+
_doing_it_wrong( __FUNCTION__, __( 'The invalidates_query_cache parameter is only supported for post meta.' ), '7.0.0' );
1514+
1515+
return false;
1516+
}
1517+
15121518
// If `auth_callback` is not provided, fall back to `is_protected_meta()`.
15131519
if ( empty( $args['auth_callback'] ) ) {
15141520
if ( is_protected_meta( $meta_key, $object_type ) ) {
@@ -1646,47 +1652,34 @@ function registered_meta_key_exists( $object_type, $meta_key, $object_subtype =
16461652
}
16471653

16481654
/**
1649-
* Checks whether a meta key invalidates query caches when updated.
1655+
* Checks whether a post meta key invalidates query caches when updated.
16501656
*
1651-
* Meta keys registered with `'invalidates_query_cache' => false` will not
1657+
* Post meta keys registered with `'invalidates_query_cache' => false` will not
16521658
* cause query cache invalidation when added, updated, or deleted. This is
1653-
* useful for high-frequency meta that is never used in query filters, such
1659+
* useful for high-frequency post meta that is never used in query filters, such
16541660
* as real-time collaboration sync data.
16551661
*
16561662
* Unregistered meta keys are assumed to invalidate query caches.
16571663
*
16581664
* @since 7.0.0
16591665
*
1660-
* @param string $object_type Type of object metadata is for. Accepts 'post', 'comment', 'term', 'user',
1661-
* or any other object type with an associated meta table.
1662-
* @param string $meta_key Metadata key.
1663-
* @param string $object_subtype Optional. The subtype of the object type. Default empty string.
1666+
* @param string $meta_key Metadata key.
1667+
* @param string $post_type Post type to check.
16641668
* @return bool True if the meta key invalidates query caches, false otherwise.
16651669
*/
1666-
function wp_meta_key_invalidates_query_cache( $object_type, $meta_key, $object_subtype = '' ) {
1667-
global $wp_meta_keys;
1668-
1669-
$meta_keys = get_registered_meta_keys( $object_type, $object_subtype );
1670+
function wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) {
1671+
// Check keys registered for this specific post type.
1672+
$meta_keys = get_registered_meta_keys( 'post', $post_type );
16701673

16711674
if ( isset( $meta_keys[ $meta_key ] ) ) {
16721675
return $meta_keys[ $meta_key ]['invalidates_query_cache'];
16731676
}
16741677

1675-
// Also check keys registered without a subtype.
1676-
if ( '' !== $object_subtype ) {
1677-
$meta_keys = get_registered_meta_keys( $object_type );
1678-
if ( isset( $meta_keys[ $meta_key ] ) ) {
1679-
return $meta_keys[ $meta_key ]['invalidates_query_cache'];
1680-
}
1681-
}
1678+
// Also check keys registered for all post types.
1679+
$meta_keys = get_registered_meta_keys( 'post' );
16821680

1683-
// When no subtype is given, search across all registered subtypes.
1684-
if ( '' === $object_subtype && is_array( $wp_meta_keys ) && isset( $wp_meta_keys[ $object_type ] ) ) {
1685-
foreach ( $wp_meta_keys[ $object_type ] as $registered_keys ) {
1686-
if ( isset( $registered_keys[ $meta_key ] ) ) {
1687-
return $registered_keys[ $meta_key ]['invalidates_query_cache'];
1688-
}
1689-
}
1681+
if ( isset( $meta_keys[ $meta_key ] ) ) {
1682+
return $meta_keys[ $meta_key ]['invalidates_query_cache'];
16901683
}
16911684

16921685
// Unregistered keys default to invalidating caches.

src/wp-includes/post.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8461,6 +8461,11 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) {
84618461
* the cache is not invalidated. This allows high-frequency meta writes (e.g.
84628462
* real-time collaboration data) to avoid invalidating query caches site-wide.
84638463
*
8464+
* This is the authoritative check — meta actions always provide both the
8465+
* object ID and meta key, so the exact post type is always known. This
8466+
* guarantees that non-cacheable meta never incorrectly invalidates caches,
8467+
* regardless of how the meta key was registered (globally or per post type).
8468+
*
84648469
* @since 5.0.0
84658470
* @since 7.0.0 Added the `$meta_id`, `$object_id`, and `$meta_key` parameters.
84668471
*
@@ -8469,9 +8474,9 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) {
84698474
* @param string $meta_key Optional. Meta key. Passed by meta action hooks.
84708475
*/
84718476
function wp_cache_set_posts_last_changed( $meta_id = 0, $object_id = 0, $meta_key = '' ) {
8472-
if ( $meta_key ) {
8473-
$post_type = $object_id ? get_post_type( $object_id ) : '';
8474-
if ( ! wp_meta_key_invalidates_query_cache( 'post', $meta_key, $post_type ? $post_type : '' ) ) {
8477+
if ( $meta_key && $object_id ) {
8478+
$post_type = get_post_type( $object_id );
8479+
if ( $post_type && ! wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) ) {
84758480
return;
84768481
}
84778482
}

tests/phpunit/tests/meta/invalidatesQueryCache.php

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
/**
33
* Tests for the `invalidates_query_cache` parameter of `register_meta()`.
44
*
5+
* Cache correctness relies on two complementary mechanisms:
6+
*
7+
* 1. Cache invalidation (wp_cache_set_posts_last_changed): the authoritative
8+
* check. Meta write hooks always provide the object ID, so the post type
9+
* is always known. This guarantees non-cacheable meta never incorrectly
10+
* bumps the 'posts' last_changed timestamp.
11+
*
12+
* 2. Meta query blocking (WP_Meta_Query): a conservative safety net. It only
13+
* refuses a non-cacheable key when the post type is known from the parent
14+
* WP_Query context. Without context, the key is allowed through — it's
15+
* better to allow a query than to incorrectly block a legitimate one.
16+
*
517
* @group meta
618
* @group cache
719
*
@@ -152,7 +164,8 @@ public function test_registered_cacheable_meta_still_invalidates_cache() {
152164
}
153165

154166
/**
155-
* WP_Meta_Query should refuse to query by a non-cacheable meta key.
167+
* WP_Meta_Query should refuse to query by a non-cacheable meta key
168+
* when the WP_Query context provides a post type.
156169
*
157170
* @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause
158171
*/
@@ -163,6 +176,9 @@ public function test_meta_query_refuses_non_cacheable_key() {
163176
array( 'invalidates_query_cache' => false )
164177
);
165178

179+
$query = new WP_Query();
180+
$query->query_vars['post_type'] = 'post';
181+
166182
$meta_query = new WP_Meta_Query(
167183
array(
168184
array(
@@ -172,12 +188,39 @@ public function test_meta_query_refuses_non_cacheable_key() {
172188
)
173189
);
174190

175-
$sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' );
191+
$sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID', $query );
176192

177193
$this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause.' );
178194
$this->assertStringNotContainsString( 'nocache_meta', $sql['join'], 'Non-cacheable meta key should not appear in JOIN clause.' );
179195
}
180196

197+
/**
198+
* WP_Meta_Query should allow a non-cacheable meta key when no WP_Query
199+
* context is available. Without knowing the post type, refusing the query
200+
* could incorrectly block legitimate usage. Cache correctness is still
201+
* guaranteed by the invalidation side, which always knows the post type.
202+
*/
203+
public function test_meta_query_allows_non_cacheable_key_without_context() {
204+
register_post_meta(
205+
'',
206+
'nocache_meta',
207+
array( 'invalidates_query_cache' => false )
208+
);
209+
210+
$meta_query = new WP_Meta_Query(
211+
array(
212+
array(
213+
'key' => 'nocache_meta',
214+
'value' => 'test',
215+
),
216+
)
217+
);
218+
219+
$sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' );
220+
221+
$this->assertStringContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should be allowed without WP_Query context.' );
222+
}
223+
181224
/**
182225
* WP_Meta_Query should work normally for regular meta keys.
183226
*/
@@ -248,7 +291,10 @@ public function test_subtype_registration_refuses_meta_query_for_matching_post_t
248291

249292
/**
250293
* A key registered as non-cacheable for one post type should be allowed
251-
* in meta queries targeting a different post type.
294+
* in meta queries targeting a different post type. The query-side check
295+
* is conservative — it only refuses when the post type is known to be
296+
* non-cacheable. Cache correctness for the other post type is enforced
297+
* by the invalidation side.
252298
*/
253299
public function test_subtype_registration_allows_meta_query_for_different_post_type() {
254300
register_post_meta(
@@ -305,8 +351,10 @@ public function test_subtype_registration_refuses_meta_query_for_array_with_matc
305351
}
306352

307353
/**
308-
* A key registered for a specific post type should not skip cache invalidation
309-
* when written to a different post type.
354+
* A key registered as non-cacheable for one post type should still
355+
* invalidate the cache when written to a different post type. The
356+
* invalidation side always knows the exact post type from the object ID,
357+
* so per-post-type behavior is always correct.
310358
*/
311359
public function test_subtype_registration_does_not_skip_cache_for_different_post_type() {
312360
register_post_meta(

0 commit comments

Comments
 (0)