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
130 changes: 130 additions & 0 deletions src/wp-includes/class-wp-meta-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ class WP_Meta_Query {
*/
protected $has_or_relation = false;

/**
* The type of object metadata is for (e.g. 'post', 'user', 'term').
*
* @since 7.0.0
* @var string
*/
protected $object_type = '';

/**
* Object subtypes for the query (e.g. post types).
*
* @since 7.0.0
* @var string[]
*/
protected $object_subtypes = array();

/**
* Constructor.
*
Expand Down Expand Up @@ -364,6 +380,18 @@ public function get_sql( $type, $primary_table, $primary_id_column, $context = n

$this->meta_table = $meta_table;
$this->meta_id_column = sanitize_key( $type . '_id' );
$this->object_type = $type;

// Extract object subtypes from the parent query context.
$this->object_subtypes = array();
if ( 'post' === $type && $context instanceof WP_Query && ! empty( $context->query_vars['post_type'] ) ) {
$post_type = $context->query_vars['post_type'];
if ( is_array( $post_type ) ) {
$this->object_subtypes = $post_type;
} elseif ( 'any' !== $post_type ) {
$this->object_subtypes = array( $post_type );
}
}

$this->primary_table = $primary_table;
$this->primary_id_column = $primary_id_column;
Expand Down Expand Up @@ -533,6 +561,28 @@ protected function get_sql_for_query( &$query, $depth = 0 ) {
public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) {
global $wpdb;

// Refuse to query by a post meta key registered as non-query-cacheable.
if ( 'post' === $this->object_type
&& isset( $clause['key'] )
&& is_string( $clause['key'] )
&& $this->is_non_cacheable_meta_key( $clause['key'] )
) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: %s: The meta key. */
__( 'The meta key "%s" is registered with invalidates_query_cache set to false and cannot be used in meta queries.' ),
$clause['key']
),
'7.0.0'
);

return array(
'join' => array(),
'where' => array(),
);
}

$sql_chunks = array(
'where' => array(),
'join' => array(),
Expand Down Expand Up @@ -726,6 +776,21 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' )
}
}

// When querying by meta_value without a specific key, exclude
// non-cacheable meta keys so their rows never appear in results.
// This is necessary because cache invalidation is skipped for these
// keys, so including them would produce stale cached query results.
if ( 'post' === $this->object_type
&& ! array_key_exists( 'key', $clause )
&& array_key_exists( 'value', $clause )
) {
$excluded_keys = $this->get_non_cacheable_meta_keys();
if ( ! empty( $excluded_keys ) ) {
$placeholders = implode( ',', array_fill( 0, count( $excluded_keys ), '%s' ) );
$sql_chunks['where'][] = $wpdb->prepare( "$alias.meta_key NOT IN ($placeholders)", $excluded_keys ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ellatrix and I discussed the complexity addition of a NOT IN query here. It seems like it would be not more complex with it since it would be a query on value only.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do lean towards removing the query modification and just leaving it as an edge case with a note. These kinds of meta value only queries should anyway be rare and discouraged (imo).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you checked how plugins are using meta_query to know how much of an edge case this is?

I agree it should be discouraged, but I don't know how prevalent this is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you search for plugins using meta_value without meta_key?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a specific regex for that, so I would suggest manually checking the code. Otherwise, I think we shoud lean towards being as defensive as possible and not assuming it's an edge case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would you check them manually other than just a few?
What does defensive mean in this case? I guess you're arguing for keeping the query adjustment and excluding these keys?

}
}

// meta_value.
if ( array_key_exists( 'value', $clause ) ) {
$meta_value = $clause['value'];
Expand Down Expand Up @@ -807,6 +872,71 @@ public function get_clauses() {
return $this->clauses;
}

/**
* Checks whether a meta key is registered as non-query-cacheable.
*
* This check is deliberately conservative: it only refuses a meta key
* when the post type is known from the parent WP_Query context. When
* no post type context is available (e.g. direct WP_Meta_Query usage
* without a WP_Query, or post_type set to 'any'), the key is allowed
* through. This avoids incorrectly blocking legitimate queries.
*
* Correctness is guaranteed by the cache invalidation side
* (wp_cache_set_posts_last_changed), which always has the post type
* from the object ID. That side is authoritative — it ensures
* non-cacheable meta never invalidates query caches. This query-side
* check is a safety net to warn developers and prevent obviously
* broken queries, not the primary enforcement mechanism.
*
* When multiple post types are queried, the key is refused if any
* of them has it registered as non-cacheable.
*
* @since 7.0.0
*
* @param string $meta_key Meta key to check.
* @return bool True if the meta key is non-cacheable, false otherwise.
*/
protected function is_non_cacheable_meta_key( $meta_key ) {
if ( empty( $this->object_subtypes ) ) {
return false;
}

foreach ( $this->object_subtypes as $subtype ) {
if ( ! wp_post_meta_invalidates_query_cache( $meta_key, $subtype ) ) {
return true;
}
}

return false;
}

/**
* Returns all post meta keys registered with invalidates_query_cache false.
*
* @since 7.0.0
*
* @return string[] Array of non-cacheable meta keys.
*/
protected function get_non_cacheable_meta_keys() {
global $wp_meta_keys;

$keys = array();

if ( ! is_array( $wp_meta_keys ) || ! isset( $wp_meta_keys['post'] ) ) {
return $keys;
}

foreach ( $wp_meta_keys['post'] as $registered_keys ) {
foreach ( $registered_keys as $meta_key => $args ) {
if ( isset( $args['invalidates_query_cache'] ) && ! $args['invalidates_query_cache'] ) {
$keys[] = $meta_key;
}
}
}

return array_unique( $keys );
}

/**
* Identifies an existing table alias that is compatible with the current
* query clause.
Expand Down
6 changes: 3 additions & 3 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@
}

// Post meta.
add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' );
add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' );
add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' );
add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 );
add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 );
add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 );

// User meta.
add_action( 'added_user_meta', 'wp_cache_set_users_last_changed' );
Expand Down
62 changes: 52 additions & 10 deletions src/wp-includes/meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -1440,16 +1440,17 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) {
}

$defaults = array(
'object_subtype' => '',
'type' => 'string',
'label' => '',
'description' => '',
'default' => '',
'single' => false,
'sanitize_callback' => null,
'auth_callback' => null,
'show_in_rest' => false,
'revisions_enabled' => false,
'object_subtype' => '',
'type' => 'string',
'label' => '',
'description' => '',
'default' => '',
'single' => false,
'sanitize_callback' => null,
'auth_callback' => null,
'show_in_rest' => false,
'revisions_enabled' => false,
'invalidates_query_cache' => true,
);

// There used to be individual args for sanitize and auth callbacks.
Expand Down Expand Up @@ -1508,6 +1509,12 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) {
}
}

if ( ! $args['invalidates_query_cache'] && 'post' !== $object_type ) {
_doing_it_wrong( __FUNCTION__, __( 'The invalidates_query_cache parameter is only supported for post meta.' ), '7.0.0' );

return false;
}

// If `auth_callback` is not provided, fall back to `is_protected_meta()`.
if ( empty( $args['auth_callback'] ) ) {
if ( is_protected_meta( $meta_key, $object_type ) ) {
Expand Down Expand Up @@ -1644,6 +1651,41 @@ function registered_meta_key_exists( $object_type, $meta_key, $object_subtype =
return isset( $meta_keys[ $meta_key ] );
}

/**
* Checks whether a post meta key invalidates query caches when updated.
*
* Post meta keys registered with `'invalidates_query_cache' => false` will not
* cause query cache invalidation when added, updated, or deleted. This is
* useful for high-frequency post meta that is never used in query filters, such
* as real-time collaboration sync data.
*
* Unregistered meta keys are assumed to invalidate query caches.
*
* @since 7.0.0
*
* @param string $meta_key Metadata key.
* @param string $post_type Post type to check.
* @return bool True if the meta key invalidates query caches, false otherwise.
*/
function wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) {
// Check keys registered for this specific post type.
$meta_keys = get_registered_meta_keys( 'post', $post_type );

if ( isset( $meta_keys[ $meta_key ] ) ) {
return $meta_keys[ $meta_key ]['invalidates_query_cache'];
}

// Also check keys registered for all post types.
$meta_keys = get_registered_meta_keys( 'post' );

if ( isset( $meta_keys[ $meta_key ] ) ) {
return $meta_keys[ $meta_key ]['invalidates_query_cache'];
}

// Unregistered keys default to invalidating caches.
return true;
}

/**
* Unregisters a meta key from the list of registered keys.
*
Expand Down
40 changes: 39 additions & 1 deletion src/wp-includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,22 @@ function create_initial_post_types() {
'supports' => array( 'custom-fields' ),
)
);

register_post_meta(
'',
'wp_sync_awareness',
array(
'invalidates_query_cache' => false,
)
);

register_post_meta(
'',
'wp_sync_update',
array(
'invalidates_query_cache' => false,
)
);
}

register_post_status(
Expand Down Expand Up @@ -8440,9 +8456,31 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) {
/**
* Sets the last changed time for the 'posts' cache group.
*
* When called from a post meta action (`added_post_meta`, `updated_post_meta`,
* or `deleted_post_meta`), the meta key is checked against the registered meta
* registry. If the meta key was registered with `'invalidates_query_cache' => false`,
* the cache is not invalidated. This allows high-frequency meta writes (e.g.
* real-time collaboration data) to avoid invalidating query caches site-wide.
*
* This is the authoritative check — meta actions always provide both the
* object ID and meta key, so the exact post type is always known. This
* guarantees that non-cacheable meta never incorrectly invalidates caches,
* regardless of how the meta key was registered (globally or per post type).
*
* @since 5.0.0
* @since 7.0.0 Added the `$meta_id`, `$object_id`, and `$meta_key` parameters.
*
* @param int $meta_id Optional. Meta ID. Passed by meta action hooks.
* @param int $object_id Optional. Object ID. Passed by meta action hooks.
* @param string $meta_key Optional. Meta key. Passed by meta action hooks.
*/
function wp_cache_set_posts_last_changed() {
function wp_cache_set_posts_last_changed( $meta_id = 0, $object_id = 0, $meta_key = '' ) {
if ( $meta_key && $object_id ) {
$post_type = get_post_type( $object_id );
if ( $post_type && ! wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) ) {
return;
}
}
wp_cache_set_last_changed( 'posts' );
}

Expand Down
Loading
Loading