* Meta API: WP_Meta_Query class
* Core class used to implement meta queries for the Meta API.
* Used for generating SQL clauses that filter a primary query according to metadata keys and values.
* WP_Meta_Query is a helper that allows primary query classes, such as WP_Query and WP_User_Query,
* to filter their results by object metadata, by generating `JOIN` and `WHERE` subclauses to be attached
* to the primary SQL query string.
#[AllowDynamicProperties]
* Array of metadata queries.
* See WP_Meta_Query::__construct() for information on meta query arguments.
public $queries = array();
* The relation between the queries. Can be one of 'AND' or 'OR'.
* Database table to query for the metadata.
* Column in meta_table that represents the ID of the object the metadata belongs to.
* Database table that where the metadata's objects are stored (eg $wpdb->users).
* Column in primary_table that represents the ID of the object.
public $primary_id_column;
* A flat list of table aliases used in JOIN clauses.
protected $table_aliases = array();
* A flat list of clauses, keyed by clause 'name'.
protected $clauses = array();
* Whether the query contains any OR relations.
protected $has_or_relation = false;
* @since 4.2.0 Introduced support for naming query clauses by associative array keys.
* @since 5.1.0 Introduced `$compare_key` clause parameter, which enables LIKE key matches.
* @since 5.3.0 Increased the number of operators available to `$compare_key`. Introduced `$type_key`,
* which enables the `$key` to be cast to a new data type for comparisons.
* @param array $meta_query {
* Array of meta query clauses. When first-order clauses or sub-clauses use strings as
* their array keys, they may be referenced in the 'orderby' parameter of the parent query.
* @type string $relation Optional. The MySQL keyword used to join the clauses of the query.
* Accepts 'AND' or 'OR'. Default 'AND'.
* Optional. An array of first-order clause parameters, or another fully-formed meta query.
* @type string|string[] $key Meta key or keys to filter by.
* @type string $compare_key MySQL operator used for comparing the $key. Accepts:
* - 'EXISTS' (alias of '=')
* - 'NOT EXISTS' (alias of '!=')
* Default is 'IN' when `$key` is an array, '=' otherwise.
* @type string $type_key MySQL data type that the meta_key column will be CAST to for
* comparisons. Accepts 'BINARY' for case-sensitive regular expression
* comparisons. Default is ''.
* @type string|string[] $value Meta value or values to filter by.
* @type string $compare MySQL operator used for comparing the $value. Accepts:
* Default is 'IN' when `$value` is an array, '=' otherwise.
* @type string $type MySQL data type that the meta_value column will be CAST to for
public function __construct( $meta_query = false ) {
if ( isset( $meta_query['relation'] ) && 'OR' === strtoupper( $meta_query['relation'] ) ) {
$this->queries = $this->sanitize_query( $meta_query );
* Ensures the 'meta_query' argument passed to the class constructor is well-formed.
* Eliminates empty items and ensures that a 'relation' is set.
* @param array $queries Array of query clauses.
* @return array Sanitized array of query clauses.
public function sanitize_query( $queries ) {
$clean_queries = array();
if ( ! is_array( $queries ) ) {
foreach ( $queries as $key => $query ) {
if ( 'relation' === $key ) {
} elseif ( ! is_array( $query ) ) {
} elseif ( $this->is_first_order_clause( $query ) ) {
if ( isset( $query['value'] ) && array() === $query['value'] ) {
unset( $query['value'] );
$clean_queries[ $key ] = $query;
// Otherwise, it's a nested query, so we recurse.
$cleaned_query = $this->sanitize_query( $query );
if ( ! empty( $cleaned_query ) ) {
$clean_queries[ $key ] = $cleaned_query;
if ( empty( $clean_queries ) ) {
// Sanitize the 'relation' key provided in the query.
if ( isset( $relation ) && 'OR' === strtoupper( $relation ) ) {
$clean_queries['relation'] = 'OR';
$this->has_or_relation = true;
* If there is only a single clause, call the relation 'OR'.
* This value will not actually be used to join clauses, but it
* simplifies the logic around combining key-only queries.
} elseif ( 1 === count( $clean_queries ) ) {
$clean_queries['relation'] = 'OR';
$clean_queries['relation'] = 'AND';
* Determines whether a query clause is first-order.
* A first-order meta query clause is one that has either a 'key' or
* @param array $query Meta query arguments.
* @return bool Whether the query clause is a first-order clause.
protected function is_first_order_clause( $query ) {
return isset( $query['key'] ) || isset( $query['value'] );
* Constructs a meta query based on 'meta_*' query vars
* @param array $qv The query variables.
public function parse_query_vars( $qv ) {
* For orderby=meta_value to work correctly, simple query needs to be
* first (so that its table join is against an unaliased meta table) and
* needs to be its own clause (so it doesn't interfere with the logic of
* the rest of the meta_query).
$primary_meta_query = array();
foreach ( array( 'key', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
if ( ! empty( $qv[ "meta_$key" ] ) ) {
$primary_meta_query[ $key ] = $qv[ "meta_$key" ];
// WP_Query sets 'meta_value' = '' by default.
if ( isset( $qv['meta_value'] ) && '' !== $qv['meta_value'] && ( ! is_array( $qv['meta_value'] ) || $qv['meta_value'] ) ) {
$primary_meta_query['value'] = $qv['meta_value'];
$existing_meta_query = isset( $qv['meta_query'] ) && is_array( $qv['meta_query'] ) ? $qv['meta_query'] : array();
if ( ! empty( $primary_meta_query ) && ! empty( $existing_meta_query ) ) {
} elseif ( ! empty( $primary_meta_query ) ) {
} elseif ( ! empty( $existing_meta_query ) ) {
$meta_query = $existing_meta_query;
$this->__construct( $meta_query );
* Returns the appropriate alias for the given meta type if applicable.
* @param string $type MySQL type to cast meta_value.
* @return string MySQL type.
public function get_cast_for_type( $type = '' ) {
$meta_type = strtoupper( $type );
if ( ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
if ( 'NUMERIC' === $meta_type ) {
* Generates SQL clauses to be appended to a main query.
* @param string $type Type of meta. Possible values include but are not limited
* to 'post', 'comment', 'blog', 'term', and 'user'.
* @param string $primary_table Database table where the object being filtered is stored (eg wp_users).
* @param string $primary_id_column ID column for the filtered object in $primary_table.
* @param object $context Optional. The main query object that corresponds to the type, for
* example a `WP_Query`, `WP_User_Query`, or `WP_Site_Query`.
* @return string[]|false {
* Array containing JOIN and WHERE SQL clauses to append to the main query,
* or false if no table exists for the requested meta type.
* @type string $join SQL fragment to append to the main JOIN clause.
* @type string $where SQL fragment to append to the main WHERE clause.
public function get_sql( $type, $primary_table, $primary_id_column, $context = null ) {
$meta_table = _get_meta_table( $type );
$this->table_aliases = array();
$this->meta_table = $meta_table;
$this->meta_id_column = sanitize_key( $type . '_id' );
$this->primary_table = $primary_table;
$this->primary_id_column = $primary_id_column;
$sql = $this->get_sql_clauses();
* If any JOINs are LEFT JOINs (as in the case of NOT EXISTS), then all JOINs should
* be LEFT. Otherwise posts with no metadata will be excluded from results.
if ( str_contains( $sql['join'], 'LEFT JOIN' ) ) {
$sql['join'] = str_replace( 'INNER JOIN', 'LEFT JOIN', $sql['join'] );
* Filters the meta query's generated SQL.
* @param string[] $sql Array containing the query's JOIN and WHERE clauses.
* @param array $queries Array of meta queries.
* @param string $type Type of meta. Possible values include but are not limited
* to 'post', 'comment', 'blog', 'term', and 'user'.
* @param string $primary_table Primary table.
* @param string $primary_id_column Primary column ID.
* @param object $context The main query object that corresponds to the type, for
* example a `WP_Query`, `WP_User_Query`, or `WP_Site_Query`.
return apply_filters_ref_array( 'get_meta_sql', array( $sql, $this->queries, $type, $primary_table, $primary_id_column, $context ) );
* Generates SQL clauses to be appended to a main query.
* Called by the public WP_Meta_Query::get_sql(), this method is abstracted
* out to maintain parity with the other Query classes.
* Array containing JOIN and WHERE SQL clauses to append to the main query.
* @type string $join SQL fragment to append to the main JOIN clause.
* @type string $where SQL fragment to append to the main WHERE clause.
protected function get_sql_clauses() {
* $queries are passed by reference to get_sql_for_query() for recursion.
* To keep $this->queries unaltered, pass a copy.
$queries = $this->queries;
$sql = $this->get_sql_for_query( $queries );
if ( ! empty( $sql['where'] ) ) {
$sql['where'] = ' AND ' . $sql['where'];
* Generates SQL clauses for a single query array.
* If nested subqueries are found, this method recurses the tree to
* produce the properly nested SQL.
* @param array $query Query to parse (passed by reference).
* @param int $depth Optional. Number of tree levels deep we currently are.
* Used to calculate indentation. Default 0.
* Array containing JOIN and WHERE SQL clauses to append to a single query array.
* @type string $join SQL fragment to append to the main JOIN clause.
* @type string $where SQL fragment to append to the main WHERE clause.
protected function get_sql_for_query( &$query, $depth = 0 ) {
for ( $i = 0; $i < $depth; $i++ ) {
foreach ( $query as $key => &$clause ) {
if ( 'relation' === $key ) {
$relation = $query['relation'];
} elseif ( is_array( $clause ) ) {
// This is a first-order clause.
if ( $this->is_first_order_clause( $clause ) ) {
$clause_sql = $this->get_sql_for_clause( $clause, $query, $key );
$where_count = count( $clause_sql['where'] );
$sql_chunks['where'][] = '';
} elseif ( 1 === $where_count ) {
$sql_chunks['where'][] = $clause_sql['where'][0];
$sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
$sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
// This is a subquery, so we recurse.
$clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
$sql_chunks['where'][] = $clause_sql['where'];
$sql_chunks['join'][] = $clause_sql['join'];
// Filter to remove empties.
$sql_chunks['join'] = array_filter( $sql_chunks['join'] );
$sql_chunks['where'] = array_filter( $sql_chunks['where'] );
if ( empty( $relation ) ) {
// Filter duplicate JOIN clauses and combine into a single string.
if ( ! empty( $sql_chunks['join'] ) ) {