diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index ad706ba532114..6caa418dec0f0 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -180,6 +180,9 @@ public function __call( $name, $arguments ) { * See WP_Meta_Query::__construct() for accepted values and default value. * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons. * See WP_Meta_Query::__construct() for accepted values and default value. + * @type bool $meta_exclude_empty Whether to exclude objects that have no value for `$meta_key`. + * Passing false keeps them in the results, e.g. when ordering by + * 'meta_value'. Default true. * @type array $meta_query An associative array of WP_Meta_Query arguments. * See WP_Meta_Query::__construct() for accepted values. * @type int $number Maximum number of comments to retrieve. diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 67e2d3d27ee0b..ceab95f1bd944 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -103,6 +103,7 @@ class WP_Meta_Query { * @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. + * @since 7.3.0 Introduced the `$exclude_empty` clause parameter. * * @param array $meta_query { * Array of meta query clauses. When first-order clauses or sub-clauses use strings as @@ -162,6 +163,14 @@ class WP_Meta_Query { * - 'TIME' * - 'UNSIGNED' * Default is 'CHAR'. + * @type bool $exclude_empty Whether to exclude objects that have no metadata for `$key`. + * When false, the meta table is LEFT JOINed on `$key`, so + * objects without the key are kept in the results (and sort + * as NULL when ordering by this clause). Any `$value` + * condition then only applies to objects that have the key. + * Only honored when `$key` is set and `$compare_key` is '=', + * 'EXISTS' or 'IN'; ignored for `$compare` 'NOT EXISTS'. + * Default true. * } * } */ @@ -265,6 +274,7 @@ protected function is_first_order_clause( $query ) { * Constructs a meta query based on 'meta_*' query vars * * @since 3.2.0 + * @since 7.3.0 Added support for the 'meta_exclude_empty' query var. * * @param array $qv The query variables. */ @@ -284,6 +294,11 @@ public function parse_query_vars( $qv ) { } } + // 'meta_exclude_empty' only makes sense alongside 'meta_key', and false is meaningful. + if ( isset( $primary_meta_query['key'] ) && isset( $qv['meta_exclude_empty'] ) ) { + $primary_meta_query['exclude_empty'] = wp_validate_boolean( $qv['meta_exclude_empty'] ); + } + // 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']; @@ -584,17 +599,46 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) $meta_compare = $clause['compare']; $meta_compare_key = $clause['compare_key']; + if ( isset( $clause['exclude_empty'] ) ) { + $clause['exclude_empty'] = wp_validate_boolean( $clause['exclude_empty'] ); + } + + /* + * When 'exclude_empty' is false, objects without the meta key are kept by + * LEFT JOINing the meta table with the key condition inside the ON clause. + * This is only possible for positive key comparisons, and is meaningless for NOT EXISTS. + */ + $include_empty = isset( $clause['key'] ) + && isset( $clause['exclude_empty'] ) + && false === $clause['exclude_empty'] + && 'NOT EXISTS' !== $meta_compare + && in_array( $meta_compare_key, array( '=', 'EXISTS', 'IN' ), true ); + // First build the JOIN clause, if one is required. $join = ''; - // We prefer to avoid joins if possible. Look for an existing join compatible with this clause. - $alias = $this->find_compatible_table_alias( $clause, $parent_query ); + /* + * We prefer to avoid joins if possible. Look for an existing join compatible with this clause. + * A LEFT JOIN restricted to a specific key can't be shared with other clauses. + */ + $alias = $include_empty ? false : $this->find_compatible_table_alias( $clause, $parent_query ); if ( false === $alias ) { $i = count( $this->table_aliases ); $alias = $i ? 'mt' . $i : $this->meta_table; - // JOIN clauses for NOT EXISTS have their own syntax. - if ( 'NOT EXISTS' === $meta_compare ) { + if ( $include_empty ) { + $join .= " LEFT JOIN $this->meta_table"; + $join .= $i ? " AS $alias" : ''; + + if ( 'IN' === $meta_compare_key ) { + $keys = (array) $clause['key']; + $join .= $wpdb->prepare( " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column AND $alias.meta_key IN (" . substr( str_repeat( ',%s', count( $keys ) ), 1 ) . ') )', $keys ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + } else { + $join .= $wpdb->prepare( " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column AND $alias.meta_key = %s )", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + // JOIN clauses for NOT EXISTS have their own syntax. + } elseif ( 'NOT EXISTS' === $meta_compare ) { $join .= " LEFT JOIN $this->meta_table"; $join .= $i ? " AS $alias" : ''; @@ -641,8 +685,8 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) // Next, build the WHERE clause. - // meta_key. - if ( array_key_exists( 'key', $clause ) ) { + // meta_key. When including empty values, the key condition already lives in the JOIN. + if ( array_key_exists( 'key', $clause ) && ! $include_empty ) { if ( 'NOT EXISTS' === $meta_compare ) { $sql_chunks['where'][] = $alias . '.' . $this->meta_id_column . ' IS NULL'; } else { @@ -775,10 +819,17 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) if ( $where ) { if ( 'CHAR' === $meta_type ) { - $sql_chunks['where'][] = "$alias.meta_value {$meta_compare} {$where}"; + $value_where = "$alias.meta_value {$meta_compare} {$where}"; } else { - $sql_chunks['where'][] = "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$where}"; + $value_where = "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$where}"; } + + // Objects without the key have no value to compare, but are kept. + if ( $include_empty ) { + $value_where = "( $alias.$this->meta_id_column IS NULL OR $value_where )"; + } + + $sql_chunks['where'][] = $value_where; } } @@ -841,6 +892,11 @@ protected function find_compatible_table_alias( $clause, $parent_query ) { continue; } + // A sibling that keeps empty values may have a key-restricted LEFT JOIN, which can't be shared. + if ( isset( $sibling['exclude_empty'] ) && false === $sibling['exclude_empty'] ) { + continue; + } + $compatible_compares = array(); // Clauses connected by OR can share joins as long as they have "positive" operators. diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index d9827a5deddac..26c7962035e15 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -730,6 +730,9 @@ public function fill_query_vars( $query_vars ) { * See WP_Meta_Query::__construct() for accepted values and default value. * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons. * See WP_Meta_Query::__construct() for accepted values and default value. + * @type bool $meta_exclude_empty Whether to exclude objects that have no value for `$meta_key`. + * Passing false keeps them in the results, e.g. when ordering by + * 'meta_value'. Default true. * @type array $meta_query An associative array of WP_Meta_Query arguments. * See WP_Meta_Query::__construct() for accepted values. * @type int $menu_order The menu order of the posts. diff --git a/src/wp-includes/class-wp-site-query.php b/src/wp-includes/class-wp-site-query.php index aab8b511fcf83..daf6bddbb405a 100644 --- a/src/wp-includes/class-wp-site-query.php +++ b/src/wp-includes/class-wp-site-query.php @@ -181,6 +181,9 @@ class WP_Site_Query { * See WP_Meta_Query::__construct() for accepted values and default value. * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons. * See WP_Meta_Query::__construct() for accepted values and default value. + * @type bool $meta_exclude_empty Whether to exclude objects that have no value for `$meta_key`. + * Passing false keeps them in the results, e.g. when ordering by + * 'meta_value'. Default true. * @type array $meta_query An associative array of WP_Meta_Query arguments. * See WP_Meta_Query::__construct() for accepted values. * } diff --git a/src/wp-includes/class-wp-term-query.php b/src/wp-includes/class-wp-term-query.php index 0f4e85ae80433..9d9b44b61c7e5 100644 --- a/src/wp-includes/class-wp-term-query.php +++ b/src/wp-includes/class-wp-term-query.php @@ -189,6 +189,9 @@ class WP_Term_Query { * See WP_Meta_Query::__construct() for accepted values and default value. * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons. * See WP_Meta_Query::__construct() for accepted values and default value. + * @type bool $meta_exclude_empty Whether to exclude objects that have no value for `$meta_key`. + * Passing false keeps them in the results, e.g. when ordering by + * 'meta_value'. Default true. * @type array $meta_query An associative array of WP_Meta_Query arguments. * See WP_Meta_Query::__construct() for accepted values. * } diff --git a/src/wp-includes/class-wp-user-query.php b/src/wp-includes/class-wp-user-query.php index 61fc3edbc8ef9..db6fc16623383 100644 --- a/src/wp-includes/class-wp-user-query.php +++ b/src/wp-includes/class-wp-user-query.php @@ -169,6 +169,9 @@ public static function fill_query_vars( $args ) { * See WP_Meta_Query::__construct() for accepted values and default value. * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons. * See WP_Meta_Query::__construct() for accepted values and default value. + * @type bool $meta_exclude_empty Whether to exclude objects that have no value for `$meta_key`. + * Passing false keeps them in the results, e.g. when ordering by + * 'meta_value'. Default true. * @type array $meta_query An associative array of WP_Meta_Query arguments. * See WP_Meta_Query::__construct() for accepted values. * @type string|string[] $capability An array or a comma-separated list of capability names that users diff --git a/tests/phpunit/tests/meta/query.php b/tests/phpunit/tests/meta/query.php index bf82a66337776..8aaf834004465 100644 --- a/tests/phpunit/tests/meta/query.php +++ b/tests/phpunit/tests/meta/query.php @@ -1012,4 +1012,122 @@ public function test_has_or_relation_should_return_true_for_nested_or() { $this->assertTrue( $q->has_or_relation() ); } + + /** + * @ticket 19653 + */ + public function test_exclude_empty_false_uses_left_join_with_key_in_on_clause() { + global $wpdb; + + $query = new WP_Meta_Query( + array( + array( + 'key' => 'foo', + 'value' => 'bar', + 'exclude_empty' => false, + ), + ) + ); + + $sql = $query->get_sql( 'post', $wpdb->posts, 'ID' ); + + $this->assertStringContainsString( "LEFT JOIN {$wpdb->postmeta} ON ( {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id AND {$wpdb->postmeta}.meta_key = 'foo' )", $sql['join'] ); + $this->assertStringNotContainsString( 'INNER JOIN', $sql['join'] ); + $this->assertStringNotContainsString( "{$wpdb->postmeta}.meta_key = 'foo'", $sql['where'] ); + $this->assertStringContainsString( "( {$wpdb->postmeta}.post_id IS NULL OR {$wpdb->postmeta}.meta_value = 'bar' )", $sql['where'] ); + } + + /** + * @ticket 19653 + */ + public function test_exclude_empty_false_key_only_adds_no_where() { + global $wpdb; + + $query = new WP_Meta_Query( + array( + array( + 'key' => 'foo', + 'exclude_empty' => false, + ), + ) + ); + + $sql = $query->get_sql( 'post', $wpdb->posts, 'ID' ); + + $this->assertStringContainsString( 'LEFT JOIN', $sql['join'] ); + $this->assertSame( '', $sql['where'] ); + } + + /** + * @ticket 19653 + */ + public function test_exclude_empty_defaults_to_true() { + global $wpdb; + + $query = new WP_Meta_Query( + array( + array( + 'key' => 'foo', + ), + ) + ); + + $sql = $query->get_sql( 'post', $wpdb->posts, 'ID' ); + + $this->assertStringContainsString( 'INNER JOIN', $sql['join'] ); + $this->assertStringNotContainsString( 'LEFT JOIN', $sql['join'] ); + } + + /** + * @ticket 19653 + */ + public function test_parse_query_vars_meta_exclude_empty() { + $query = new WP_Meta_Query(); + $query->parse_query_vars( + array( + 'meta_key' => 'foo', + 'meta_exclude_empty' => '0', + ) + ); + + $this->assertSame( + array( + 'key' => 'foo', + 'exclude_empty' => false, + ), + $query->queries[0] + ); + } + + /** + * @ticket 19653 + */ + public function test_exclude_empty_false_clauses_do_not_share_alias_under_or() { + global $wpdb; + + $query = new WP_Meta_Query( + array( + 'relation' => 'OR', + array( + 'key' => 'foo', + 'value' => 'a', + ), + array( + 'key' => 'bar', + 'value' => 'b', + 'exclude_empty' => false, + ), + array( + 'key' => 'baz', + 'value' => 'c', + ), + ) + ); + + $sql = $query->get_sql( 'post', $wpdb->posts, 'ID' ); + + // 'foo' and 'baz' share one join; 'bar' gets its own. + $this->assertSame( 2, substr_count( $sql['join'], 'JOIN' ) ); + $this->assertStringContainsString( "mt1.meta_key = 'bar' )", $sql['join'] ); + } } diff --git a/tests/phpunit/tests/query/metaQuery.php b/tests/phpunit/tests/query/metaQuery.php index e930ef3266654..eabd984fee6ab 100644 --- a/tests/phpunit/tests/query/metaQuery.php +++ b/tests/phpunit/tests/query/metaQuery.php @@ -2164,4 +2164,513 @@ public function test_compare_key_not_regexp() { $this->assertSameSets( array( $posts[1], $posts[2] ), $q->posts ); } + + /** + * Default behavior is unchanged: objects without the meta key are excluded. + * + * @ticket 19653 + */ + public function test_meta_key_orderby_excludes_posts_without_key_by_default() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'foo', 'b' ); + add_post_meta( $p2, 'foo', 'a' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'foo', + 'orderby' => 'meta_value', + 'order' => 'ASC', + ) + ); + + $this->assertSame( array( $p2, $p1 ), $q->posts ); + $this->assertStringNotContainsString( 'LEFT JOIN', $q->request ); + } + + /** + * @ticket 19653 + */ + public function test_meta_exclude_empty_true_excludes_posts_without_key() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'foo', 'b' ); + add_post_meta( $p2, 'foo', 'a' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'foo', + 'meta_exclude_empty' => true, + 'orderby' => 'meta_value', + 'order' => 'ASC', + ) + ); + + $this->assertSame( array( $p2, $p1 ), $q->posts ); + } + + /** + * @ticket 19653 + */ + public function test_meta_exclude_empty_false_includes_posts_without_key_when_ordering() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + $p4 = self::factory()->post->create(); + + add_post_meta( $p1, 'foo', 'b' ); + add_post_meta( $p2, 'foo', 'a' ); + add_post_meta( $p4, 'bar', 'z' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'foo', + 'meta_exclude_empty' => false, + 'orderby' => array( + 'meta_value' => 'DESC', + 'ID' => 'ASC', + ), + ) + ); + + // Posts with the key come first (DESC), then posts without it (NULL), in ID order. + $this->assertSame( array( $p1, $p2, $p3, $p4 ), $q->posts ); + } + + /** + * @ticket 19653 + */ + public function test_meta_exclude_empty_false_with_meta_value_num() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'num', '10' ); + add_post_meta( $p2, 'num', '9' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'num', + 'meta_exclude_empty' => false, + 'orderby' => array( + 'meta_value_num' => 'DESC', + 'ID' => 'ASC', + ), + ) + ); + + $this->assertSame( array( $p1, $p2, $p3 ), $q->posts ); + } + + /** + * Values such as the string 'false' from a URL should be honored. + * + * @ticket 19653 + */ + public function test_meta_exclude_empty_accepts_string_false() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + + add_post_meta( $p1, 'foo', 'bar' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'foo', + 'meta_exclude_empty' => 'false', + ) + ); + + $this->assertSameSets( array( $p1, $p2 ), $q->posts ); + } + + /** + * 'meta_exclude_empty' without 'meta_key' does nothing. + * + * @ticket 19653 + */ + public function test_meta_exclude_empty_without_meta_key_is_ignored() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_exclude_empty' => false, + ) + ); + + $this->assertSameSets( array( $p1, $p2 ), $q->posts ); + $this->assertStringNotContainsString( 'JOIN', $q->request ); + } + + /** + * A value condition applies only to posts that have the key. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_with_value() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'color', 'red' ); + add_post_meta( $p2, 'color', 'blue' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'color', + 'value' => 'red', + 'exclude_empty' => false, + ), + ), + ) + ); + + $this->assertSameSets( array( $p1, $p3 ), $q->posts ); + } + + /** + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_with_numeric_compare() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'num', '5' ); + add_post_meta( $p2, 'num', '15' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'num', + 'value' => 10, + 'compare' => '>', + 'type' => 'NUMERIC', + 'exclude_empty' => false, + ), + ), + ) + ); + + $this->assertSameSets( array( $p2, $p3 ), $q->posts ); + } + + /** + * The top-level clause keeps empty values while a separate meta_query still filters. + * + * @ticket 19653 + */ + public function test_meta_exclude_empty_false_combined_with_meta_query() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + $p4 = self::factory()->post->create(); + + add_post_meta( $p1, 'sort', '2' ); + add_post_meta( $p1, 'type', 'yes' ); + add_post_meta( $p2, 'sort', '1' ); + add_post_meta( $p2, 'type', 'no' ); + add_post_meta( $p3, 'type', 'yes' ); + add_post_meta( $p4, 'sort', '3' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_key' => 'sort', + 'meta_exclude_empty' => false, + 'orderby' => array( + 'meta_value' => 'DESC', + 'ID' => 'ASC', + ), + 'meta_query' => array( + array( + 'key' => 'type', + 'value' => 'yes', + ), + ), + ) + ); + + $this->assertSame( array( $p1, $p3 ), $q->posts ); + } + + /** + * Several clauses that keep empty values each get their own key-restricted LEFT JOIN. + * + * @ticket 19653 + */ + public function test_meta_query_multiple_clauses_exclude_empty_false() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + $p4 = self::factory()->post->create(); + + add_post_meta( $p1, 'a', '1' ); + add_post_meta( $p1, 'b', '2' ); + add_post_meta( $p2, 'a', '2' ); + add_post_meta( $p3, 'b', '1' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + 'a_clause' => array( + 'key' => 'a', + 'exclude_empty' => false, + ), + 'b_clause' => array( + 'key' => 'b', + 'exclude_empty' => false, + ), + ), + 'orderby' => array( + 'a_clause' => 'DESC', + 'b_clause' => 'DESC', + 'ID' => 'ASC', + ), + ) + ); + + $this->assertSame( array( $p2, $p1, $p3, $p4 ), $q->posts ); + $this->assertSame( 2, substr_count( $q->request, 'LEFT JOIN' ) ); + } + + /** + * Mixing a clause that keeps empty values with a regular one. + * + * @ticket 19653 + */ + public function test_meta_query_mixed_exclude_empty_clauses() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'a', '1' ); + add_post_meta( $p1, 'b', '1' ); + add_post_meta( $p2, 'b', '1' ); + add_post_meta( $p3, 'a', '1' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'a', + 'exclude_empty' => false, + ), + array( + 'key' => 'b', + ), + ), + ) + ); + + $this->assertSameSets( array( $p1, $p2 ), $q->posts ); + } + + /** + * A clause that keeps empty values must not share its key-restricted join with an OR sibling. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_or_relation_does_not_share_join() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'a', 'x' ); + add_post_meta( $p2, 'a', 'y' ); + add_post_meta( $p2, 'b', 'y' ); + add_post_meta( $p3, 'a', 'y' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'a', + 'value' => 'x', + 'exclude_empty' => false, + ), + array( + 'key' => 'b', + 'value' => 'y', + ), + ), + ) + ); + + $this->assertSameSets( array( $p1, $p2 ), $q->posts ); + } + + /** + * Nested queries. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_nested() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'a', 'x' ); + add_post_meta( $p1, 'c', '1' ); + add_post_meta( $p2, 'c', '1' ); + add_post_meta( $p3, 'a', 'z' ); + add_post_meta( $p3, 'c', '1' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => 'c', + 'value' => '1', + ), + array( + 'relation' => 'OR', + array( + 'key' => 'a', + 'value' => 'x', + 'exclude_empty' => false, + ), + array( + 'key' => 'a', + 'value' => 'never', + ), + ), + ), + ) + ); + + $this->assertSameSets( array( $p1, $p2 ), $q->posts ); + } + + /** + * Array of keys with compare_key 'IN'. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_key_in() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + $p3 = self::factory()->post->create(); + + add_post_meta( $p1, 'a', '1' ); + add_post_meta( $p2, 'b', '2' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => array( 'a', 'b' ), + 'value' => '1', + 'exclude_empty' => false, + ), + ), + ) + ); + + $this->assertSameSets( array( $p1, $p3 ), $q->posts ); + } + + /** + * 'exclude_empty' is ignored for compare_key operators that can't be expressed in the JOIN. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_ignored_for_compare_key_like() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + + add_post_meta( $p1, 'foobar', '1' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'foo', + 'compare_key' => 'LIKE', + 'exclude_empty' => false, + ), + ), + ) + ); + + $this->assertSameSets( array( $p1 ), $q->posts ); + } + + /** + * 'exclude_empty' has no effect on NOT EXISTS. + * + * @ticket 19653 + */ + public function test_meta_query_exclude_empty_false_ignored_for_not_exists() { + $p1 = self::factory()->post->create(); + $p2 = self::factory()->post->create(); + + add_post_meta( $p1, 'foo', '1' ); + + $q = new WP_Query( + array( + 'fields' => 'ids', + 'meta_query' => array( + array( + 'key' => 'foo', + 'compare' => 'NOT EXISTS', + 'exclude_empty' => false, + ), + ), + ) + ); + + $this->assertSameSets( array( $p2 ), $q->posts ); + } + + /** + * Other object types share the implementation. + * + * @ticket 19653 + */ + public function test_meta_exclude_empty_false_user_query() { + $u1 = self::factory()->user->create(); + $u2 = self::factory()->user->create(); + $u3 = self::factory()->user->create(); + + update_user_meta( $u1, 'rank', 'b' ); + update_user_meta( $u2, 'rank', 'a' ); + + $args = array( + 'fields' => 'ID', + 'include' => array( $u1, $u2, $u3 ), + 'meta_key' => 'rank', + 'orderby' => 'meta_value', + 'order' => 'DESC', + ); + + $q = new WP_User_Query( $args ); + $this->assertSame( array( $u1, $u2 ), array_map( 'intval', $q->get_results() ) ); + + $q = new WP_User_Query( array_merge( $args, array( 'meta_exclude_empty' => false ) ) ); + $this->assertSame( array( $u1, $u2, $u3 ), array_map( 'intval', $q->get_results() ) ); + } }