Overhaul SQL generating logic in WP_Meta_Query to avoid unnecessary table joins.

The logic used to generate clause SQL in `WP_Meta_Query` is somewhat arcane,
stemming mostly from an ongoing effort to eliminate costly table joins when
they are not necessary. By systematizing the process of looking for shareable
joins - as was done in `WP_Tax_Query` [29902] - it becomes possible to simplify
the construction of SQL queries in `get_sql_for_clause()`. Moreover, the
simplified logic is actually considerably better at identifying shareable
joins, such that certain uses of `WP_Meta_Query` will see joins reduced by 50%
or more.

Includes integration tests for a representative cross-section of the query
clause combinations that result in shared table aliases.

Props boonebgorges, sc0ttkclark.
See #24093.
Built from https://develop.svn.wordpress.org/trunk@29940


git-svn-id: http://core.svn.wordpress.org/trunk@29691 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
Boone Gorges 2014-10-17 20:21:18 +00:00
parent ea679e358e
commit 9345e9ffa5

View File

@ -987,6 +987,10 @@ class WP_Meta_Query {
// First-order clause.
} else if ( $this->is_first_order_clause( $query ) ) {
if ( isset( $query['value'] ) && array() === $query['value'] ) {
unset( $query['value'] );
}
$clean_queries[] = $query;
// Otherwise, it's a nested query, so we recurse.
@ -1167,7 +1171,12 @@ class WP_Meta_Query {
* }
*/
protected function get_sql_clauses() {
$sql = $this->get_sql_for_query( $this->queries );
/*
* $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'];
@ -1195,7 +1204,7 @@ class WP_Meta_Query {
* @type string $where SQL fragment to append to the main WHERE clause.
* }
*/
protected function get_sql_for_query( $query, $depth = 0 ) {
protected function get_sql_for_query( &$query, $depth = 0 ) {
$sql_chunks = array(
'join' => array(),
'where' => array(),
@ -1211,7 +1220,7 @@ class WP_Meta_Query {
$indent .= " ";
}
foreach ( $query as $key => $clause ) {
foreach ( $query as $key => &$clause ) {
if ( 'relation' === $key ) {
$relation = $query['relation'];
} else if ( is_array( $clause ) ) {
@ -1278,7 +1287,7 @@ class WP_Meta_Query {
* @type string $where SQL fragment to append to the main WHERE clause.
* }
*/
public function get_sql_for_clause( $clause, $parent_query ) {
public function get_sql_for_clause( &$clause, $parent_query ) {
global $wpdb;
$sql_chunks = array(
@ -1286,16 +1295,13 @@ class WP_Meta_Query {
'join' => array(),
);
$i = count( $this->table_aliases );
$alias = $i ? 'mt' . $i : $this->meta_table;
if ( isset( $clause['compare'] ) ) {
$meta_compare = strtoupper( $clause['compare'] );
$clause['compare'] = strtoupper( $clause['compare'] );
} else {
$meta_compare = isset( $clause['value'] ) && is_array( $clause['value'] ) ? 'IN' : '=';
$clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) ? 'IN' : '=';
}
if ( ! in_array( $meta_compare, array(
if ( ! in_array( $clause['compare'], array(
'=', '!=', '>', '>=', '<', '<=',
'LIKE', 'NOT LIKE',
'IN', 'NOT IN',
@ -1303,58 +1309,62 @@ class WP_Meta_Query {
'EXISTS', 'NOT EXISTS',
'REGEXP', 'NOT REGEXP', 'RLIKE'
) ) ) {
$meta_compare = '=';
$clause['compare'] = '=';
}
/*
* There are a number of different query structures that get
* built in different ways.
* 1. Key-only clauses - (a) clauses without a 'value' key that
* appear in the context of an OR relation and do not use
* 'NOT EXISTS' as the 'compare', or (b) clauses with an
* empty array for 'value'.
*/
if ( ! empty( $clause['key'] ) && (
( ! array_key_exists( 'value', $clause ) && 'NOT EXISTS' !== $meta_compare && 'OR' === $parent_query['relation'] ) ||
( isset( $clause['value'] ) && is_array( $clause['value'] ) && empty( $clause['value'] ) )
) ) {
$meta_compare = $clause['compare'];
$alias = $this->meta_table;
$sql_chunks['join'][] = " INNER JOIN $this->meta_table ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column)";
$sql_chunks['where'][] = $wpdb->prepare( "$this->meta_table.meta_key = %s", trim( $clause['key'] ) );
// First build the JOIN clause, if one is required.
$join = '';
// 2. NOT EXISTS.
} else if ( 'NOT EXISTS' === $meta_compare ) {
$join = " LEFT JOIN $this->meta_table";
// 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 );
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 ) {
$join .= " LEFT JOIN $this->meta_table";
$join .= $i ? " AS $alias" : '';
$join .= $wpdb->prepare( " ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column AND $alias.meta_key = %s )", $clause['key'] );
$sql_chunks['join'][] = $join;
$sql_chunks['where'][] = $alias . '.' . $this->meta_id_column . ' IS NULL';
// All other JOIN clauses.
} else {
$alias = $this->find_compatible_table_alias( $clause, $parent_query );
if ( false === $alias ) {
$alias = $i ? 'mt' . $i : $this->meta_table;
// 3. EXISTS and other key-only queries.
} else if ( 'EXISTS' === $meta_compare || ( ! empty( $clause['key'] ) && ! array_key_exists( 'value', $clause ) ) ) {
$join = " INNER JOIN $this->meta_table";
$join .= " INNER JOIN $this->meta_table";
$join .= $i ? " AS $alias" : '';
$join .= " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column )";
$sql_chunks['join'][] = $join;
$sql_chunks['where'][] = $wpdb->prepare( $alias . '.meta_key = %s', trim( $clause['key'] ) );
// 4. Clauses that have a value.
} else if ( array_key_exists( 'value', $clause ) ) {
$join = " INNER JOIN $this->meta_table";
$join .= $i ? " AS $alias" : '';
$join .= " ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column)";
$sql_chunks['join'][] = $join;
if ( ! empty( $clause['key'] ) ) {
$sql_chunks['where'][] = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) );
}
}
$meta_type = $this->get_cast_for_type( isset( $clause['type'] ) ? $clause['type'] : '' );
$this->table_aliases[] = $alias;
$sql_chunks['join'][] = $join;
}
$meta_value = isset( $clause['value'] ) ? $clause['value'] : '';
// Save the alias to this clause, for future siblings to find.
$clause['alias'] = $alias;
// Next, build the WHERE clause.
$where = '';
// meta_key.
if ( array_key_exists( 'key', $clause ) ) {
if ( 'NOT EXISTS' === $meta_compare ) {
$sql_chunks['where'][] = $alias . '.' . $this->meta_id_column . ' IS NULL';
} else {
$sql_chunks['where'][] = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) );
}
}
// meta_value.
if ( array_key_exists( 'value', $clause ) ) {
$meta_value = $clause['value'];
$meta_type = $this->get_cast_for_type( isset( $clause['type'] ) ? $clause['type'] : '' );
if ( in_array( $meta_compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ) ) ) {
if ( ! is_array( $meta_value ) ) {
@ -1364,19 +1374,34 @@ class WP_Meta_Query {
$meta_value = trim( $meta_value );
}
if ( 'IN' == substr( $meta_compare, -2 ) ) {
switch ( $meta_compare ) {
case 'IN' :
case 'NOT IN' :
$meta_compare_string = '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')';
} elseif ( 'BETWEEN' == substr( $meta_compare, -7 ) ) {
$where = $wpdb->prepare( $meta_compare_string, $meta_value );
break;
case 'BETWEEN' :
case 'NOT BETWEEN' :
$meta_value = array_slice( $meta_value, 0, 2 );
$meta_compare_string = '%s AND %s';
} elseif ( 'LIKE' == $meta_compare || 'NOT LIKE' == $meta_compare ) {
$where = $wpdb->prepare( '%s AND %s', $meta_value );
break;
case 'LIKE' :
case 'NOT LIKE' :
$meta_value = '%' . $wpdb->esc_like( $meta_value ) . '%';
$meta_compare_string = '%s';
} else {
$meta_compare_string = '%s';
$where = $wpdb->prepare( '%s', $meta_value );
break;
default :
$where = $wpdb->prepare( '%s', $meta_value );
break;
}
$sql_chunks['where'][] = $wpdb->prepare( "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$meta_compare_string}", $meta_value );
if ( $where ) {
$sql_chunks['where'][] = "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$where}";
}
}
/*
@ -1387,10 +1412,62 @@ class WP_Meta_Query {
$sql_chunks['where'] = array( '( ' . implode( ' AND ', $sql_chunks['where'] ) . ' )' );
}
$this->table_aliases[] = $alias;
return $sql_chunks;
}
/**
* Identify an existing table alias that is compatible with the current query clause.
*
* We avoid unnecessary table joins by allowing each clause to look for
* an existing table alias that is compatible with the query that it
* needs to perform. An existing alias is compatible if (a) it is a
* sibling of $clause (ie, it's under the scope of the same relation),
* and (b) the combination of operator and relation between the clauses
* allows for a shared table join. In the case of WP_Meta_Query, this
* only applies to IN clauses that are connected by the relation OR.
*
* @since 4.1.0
* @access protected
*
* @param array $clause Query clause.
* @param array $parent_query Parent query of $clause.
* @return string|bool Table alias if found, otherwise false.
*/
protected function find_compatible_table_alias( $clause, $parent_query ) {
$alias = false;
foreach ( $parent_query as $sibling ) {
// If the sibling has no alias yet, there's nothing to check.
if ( empty( $sibling['alias'] ) ) {
continue;
}
// We're only interested in siblings that are first-order clauses.
if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) {
continue;
}
$compatible_compares = array();
// Clauses connected by OR can share joins as long as they have "positive" operators.
if ( 'OR' === $parent_query['relation'] ) {
$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.
} else if ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
$compatible_compares = array( '!=', 'NOT IN', 'NOT LIKE' );
}
$clause_compare = strtoupper( $clause['compare'] );
$sibling_compare = strtoupper( $sibling['compare'] );
if ( in_array( $clause_compare, $compatible_compares ) && in_array( $sibling_compare, $compatible_compares ) ) {
$alias = $sibling['alias'];
break;
}
}
return apply_filters( 'meta_query_find_compatible_table_alias', $alias, $clause, $parent_query, $this ) ;
}
}
/**