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

/**
* Whether the query contains any clauses requiring LEFT JOINs.
*
* @since 7.1.0
* @var bool
*/
private $has_left_join = false;

/**
* Constructor.
*
Expand Down Expand Up @@ -170,6 +178,8 @@ public function __construct( $meta_query = array() ) {
return;
}

$this->has_left_join = false;

if ( isset( $meta_query['relation'] ) && 'OR' === strtoupper( $meta_query['relation'] ) ) {
$this->relation = 'OR';
} else {
Expand Down Expand Up @@ -209,6 +219,14 @@ public function sanitize_query( $queries ) {
unset( $query['value'] );
}

if (
array_key_exists( 'key', $query ) &&
isset( $query['compare'] ) &&
'NOT EXISTS' === strtoupper( $query['compare'] )
) {
$this->has_left_join = true;
}

$clean_queries[ $key ] = $query;

// Otherwise, it's a nested query, so we recurse.
Expand Down Expand Up @@ -608,7 +626,18 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' )
} else {
$join .= " INNER JOIN $this->meta_table";
$join .= $i ? " AS $alias" : '';
$join .= " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column )";

$join_on = "$this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column";

if ( $this->should_include_meta_key_in_join( $clause, $parent_query, $clause_key ) ) {
$join_meta_key = $this->get_sql_for_join_meta_key( $clause, $alias );

if ( '' !== $join_meta_key ) {
$join_on .= " AND $join_meta_key";
}
}

$join .= " ON ( $join_on )";
}

$this->table_aliases[] = $alias;
Expand Down Expand Up @@ -793,6 +822,182 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' )
return $sql_chunks;
}

/**
* Determines whether a clause's meta_key comparison can be added to the JOIN clause.
*
* @since 7.1.0
*
* @param array $clause Query clause.
* @param array $parent_query Parent query of $clause.
* @param int|string $clause_key The array key used to name the clause in the original `$meta_query` parameters.
* @return bool Whether the meta_key comparison should be added to the JOIN clause.
*/
private function should_include_meta_key_in_join( $clause, $parent_query, $clause_key ) {
if ( ! array_key_exists( 'key', $clause ) ) {

@apermo apermo Jun 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Did you consider isset( $clause[ 'key' ] ), iirc it is interchangable, but isset() is cheaper and less cognitive load.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping array_key_exists() intentionally: it mirrors the existing meta_key WHERE logic in this method. isset() would treat key => null as absent, while the existing WHERE builder still treats it as present.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good point. Makes sense

return false;
}

if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
return false;
}

/*
* A filter can force a later clause to reuse this alias. Because that is not
* knowable before the later clause is processed, avoid constraining aliases
* while compatibility filtering is active.
*/
if ( has_filter( 'meta_query_find_compatible_table_alias' ) ) {
return false;
}

/*
* Adding a meta_key condition to an INNER JOIN can change the results of OR
* queries, because it may remove rows needed by another OR branch before the
* WHERE clause is evaluated. When a NOT EXISTS clause is present, all joins
* are later converted to LEFT JOINs, so non-matching branches remain NULL and
* the WHERE clause preserves the existing boolean logic.
*/
if ( $this->has_or_relation && ! $this->has_left_join ) {
return false;
}

/*
* In OR relations, compatible positive clauses can share the same table alias.
* Do not constrain a shared alias to one clause's meta_key, as that would make
* sibling clauses using different keys unable to match.
*/
if (
isset( $parent_query['relation'] ) &&
'OR' === $parent_query['relation'] &&
$this->has_compatible_or_relation_sibling( $clause, $parent_query, $clause_key )
) {
return false;
}

return true;
}

/**
* Generates the SQL fragment for a positive meta_key comparison in a JOIN clause.
*
* @since 7.1.0
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param array $clause Query clause.
* @param string $alias Table alias.
* @return string SQL fragment, or an empty string when no safe fragment can be generated.
*/
private function get_sql_for_join_meta_key( $clause, $alias ) {
global $wpdb;

switch ( $clause['compare_key'] ) {
case '=':
case 'EXISTS':
return $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

case 'LIKE':
$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
return $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

case 'IN':
$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')';
return $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

case 'RLIKE':
case 'REGEXP':
$operator = $clause['compare_key'];
if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
$cast = 'BINARY';
$meta_key = "CAST($alias.meta_key AS BINARY)";
} else {
$cast = '';
$meta_key = "$alias.meta_key";
}

return $wpdb->prepare( "$meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}

return '';
}

/**
* Determines whether a first-order clause has an OR sibling that can share its table alias.
*
* @since 7.1.0
*
* @param array $clause Query clause.
* @param array $parent_query Parent query of $clause.
* @param int|string $clause_key The array key used to name the clause in the original `$meta_query` parameters.
* @return bool Whether a compatible OR sibling exists.
*/
private function has_compatible_or_relation_sibling( $clause, $parent_query, $clause_key ) {
// Keep in sync with WP_Meta_Query::find_compatible_table_alias().
$compatible_compares = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
$clause_compare = $this->get_normalized_meta_compare( $clause );

if ( ! in_array( $clause_compare, $compatible_compares, true ) ) {
return false;
}

foreach ( $parent_query as $sibling_key => $sibling ) {
if ( 'relation' === $sibling_key || $sibling_key === $clause_key ) {
continue;
}

if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) {
continue;
}

$sibling_compare = $this->get_normalized_meta_compare( $sibling );

if ( in_array( $sibling_compare, $compatible_compares, true ) ) {
return true;
}
}

return false;
}

/**
* Normalizes a clause's meta_value compare operator using WP_Meta_Query defaults.
*
* @since 7.1.0
*
* @param array $clause Query clause.
* @return string Normalized meta_value compare operator.
*/
private function get_normalized_meta_compare( $clause ) {
if ( isset( $clause['compare'] ) ) {
$compare = strtoupper( $clause['compare'] );
} else {
$compare = isset( $clause['value'] ) && is_array( $clause['value'] ) ? 'IN' : '=';
}

switch ( $compare ) {
case '=':
case '!=':
case 'LIKE':
case 'NOT LIKE':
case 'IN':
case 'NOT IN':
case 'EXISTS':
case 'NOT EXISTS':
case 'RLIKE':
case 'REGEXP':
case 'NOT REGEXP':
case '>':
case '>=':
case '<':
case '<=':
case 'BETWEEN':
case 'NOT BETWEEN':
return $compare;
}

return '=';
}

/**
* Gets a flattened list of sanitized meta clauses.
*
Expand Down Expand Up @@ -845,6 +1050,7 @@ protected function find_compatible_table_alias( $clause, $parent_query ) {

// Clauses connected by OR can share joins as long as they have "positive" operators.
if ( 'OR' === $parent_query['relation'] ) {
// Keep in sync with WP_Meta_Query::has_compatible_or_relation_sibling().
$compatible_compares = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );

// Clauses joined by AND with "negative" operators share a join only if they also share a key.
Expand Down
50 changes: 50 additions & 0 deletions tests/phpunit/tests/meta/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,56 @@ public function test_single_inner_join_for_keys_only() {
$this->assertSame( 1, substr_count( $sql['join'], 'INNER JOIN' ) );
}

/**
* @ticket 52559
*/
public function test_meta_key_is_added_to_join_when_not_exists_converts_joins_to_left_joins() {
global $wpdb;

$query = new WP_Meta_Query(
array(
'relation' => 'AND',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '>=',
),
array(
'relation' => 'OR',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '>',
),
array(
'relation' => 'AND',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '=',
),
array(
'key' => 'events_time_frame_end',
'value' => '14:59:19',
'compare' => '>=',
),
),
array(
'key' => 'events_time_frame_end',
'compare' => 'NOT EXISTS',
),
),
)
);

$sql = $query->get_sql( 'post', $wpdb->posts, 'ID', $this );

$this->assertStringNotContainsString( 'INNER JOIN', $sql['join'] );
$this->assertSame( 5, substr_count( $sql['join'], 'LEFT JOIN' ) );
$this->assertSame( 3, substr_count( $sql['join'], "meta_key = 'events_date_till'" ) );
$this->assertSame( 2, substr_count( $sql['join'], "meta_key = 'events_time_frame_end'" ) );
}

/**
* WP_Query-style query must be at index 0 for order_by=meta_value to work.
*/
Expand Down
72 changes: 72 additions & 0 deletions tests/phpunit/tests/query/metaQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,78 @@ public function test_meta_query_nested_two_levels_deep() {
$this->assertSameSets( $expected, $query->posts );
}

/**
* @ticket 52559
*/
public function test_meta_query_nested_or_with_not_exists_matches_expected_posts() {
$posts = self::factory()->post->create_many( 6 );

foreach ( $posts as $post_id ) {
add_post_meta( $post_id, 'unrelated_one', 'value' );
add_post_meta( $post_id, 'unrelated_two', 'value' );
}

add_post_meta( $posts[0], 'events_date_till', '20210218' );
add_post_meta( $posts[0], 'events_time_frame_end', '10:00:00' );

add_post_meta( $posts[1], 'events_date_till', '20210217' );
add_post_meta( $posts[1], 'events_time_frame_end', '15:00:00' );

add_post_meta( $posts[2], 'events_date_till', '20210217' );

add_post_meta( $posts[3], 'events_date_till', '20210217' );
add_post_meta( $posts[3], 'events_time_frame_end', '14:00:00' );

add_post_meta( $posts[4], 'events_date_till', '20210216' );

add_post_meta( $posts[5], 'events_date_till', '20210217' );
add_post_meta( $posts[5], 'events_time_frame_end', '14:59:19' );

$query = new WP_Query(
array(
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '>=',
),
array(
'relation' => 'OR',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '>',
),
array(
'relation' => 'AND',
array(
'key' => 'events_date_till',
'value' => '20210217',
'compare' => '=',
),
array(
'key' => 'events_time_frame_end',
'value' => '14:59:19',
'compare' => '>=',
),
),
array(
'key' => 'events_time_frame_end',
'compare' => 'NOT EXISTS',
),
),
),
)
);

$this->assertSameSets( array( $posts[0], $posts[1], $posts[2], $posts[5] ), $query->posts );
}

public function test_meta_between_not_between() {
$post_id = self::factory()->post->create();
add_post_meta( $post_id, 'time', 500 );
Expand Down
Loading