diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 67e2d3d27ee0b..78fc83bb68ce7 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -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. * @@ -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 { @@ -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. @@ -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; @@ -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 ) ) { + 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. * @@ -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. diff --git a/tests/phpunit/tests/meta/query.php b/tests/phpunit/tests/meta/query.php index bf82a66337776..49491d3942197 100644 --- a/tests/phpunit/tests/meta/query.php +++ b/tests/phpunit/tests/meta/query.php @@ -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. */ diff --git a/tests/phpunit/tests/query/metaQuery.php b/tests/phpunit/tests/query/metaQuery.php index e930ef3266654..d29b2721927ca 100644 --- a/tests/phpunit/tests/query/metaQuery.php +++ b/tests/phpunit/tests/query/metaQuery.php @@ -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 );