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
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp-comment-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 64 additions & 8 deletions src/wp-includes/class-wp-meta-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
* }
* }
*/
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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'];
Expand Down Expand Up @@ -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" : '';

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp-site-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* }
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp-term-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* }
Expand Down
3 changes: 3 additions & 0 deletions src/wp-includes/class-wp-user-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/phpunit/tests/meta/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}
}
Loading
Loading