Make WordPress Core


Ignore:
Timestamp:
10/17/2014 08:20:04 PM (10 years ago)
Author:
boonebgorges
Message:

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.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/meta.php

    r29890 r29940  
    988988            // First-order clause.
    989989            } else if ( $this->is_first_order_clause( $query ) ) {
     990                if ( isset( $query['value'] ) && array() === $query['value'] ) {
     991                    unset( $query['value'] );
     992                }
     993
    990994                $clean_queries[] = $query;
    991995
     
    11681172     */
    11691173    protected function get_sql_clauses() {
    1170         $sql = $this->get_sql_for_query( $this->queries );
     1174        /*
     1175         * $queries are passed by reference to get_sql_for_query() for recursion.
     1176         * To keep $this->queries unaltered, pass a copy.
     1177         */
     1178        $queries = $this->queries;
     1179        $sql = $this->get_sql_for_query( $queries );
    11711180
    11721181        if ( ! empty( $sql['where'] ) ) {
     
    11961205     * }
    11971206     */
    1198     protected function get_sql_for_query( $query, $depth = 0 ) {
     1207    protected function get_sql_for_query( &$query, $depth = 0 ) {
    11991208        $sql_chunks = array(
    12001209            'join'  => array(),
     
    12121221        }
    12131222
    1214         foreach ( $query as $key => $clause ) {
     1223        foreach ( $query as $key => &$clause ) {
    12151224            if ( 'relation' === $key ) {
    12161225                $relation = $query['relation'];
     
    12791288     * }
    12801289     */
    1281     public function get_sql_for_clause( $clause, $parent_query ) {
     1290    public function get_sql_for_clause( &$clause, $parent_query ) {
    12821291        global $wpdb;
    12831292
     
    12871296        );
    12881297
    1289         $i = count( $this->table_aliases );
    1290         $alias = $i ? 'mt' . $i : $this->meta_table;
    1291 
    12921298        if ( isset( $clause['compare'] ) ) {
    1293             $meta_compare = strtoupper( $clause['compare'] );
     1299            $clause['compare'] = strtoupper( $clause['compare'] );
    12941300        } else {
    1295             $meta_compare = isset( $clause['value'] ) && is_array( $clause['value'] ) ? 'IN' : '=';
    1296         }
    1297 
    1298         if ( ! in_array( $meta_compare, array(
     1301            $clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) ? 'IN' : '=';
     1302        }
     1303
     1304        if ( ! in_array( $clause['compare'], array(
    12991305            '=', '!=', '>', '>=', '<', '<=',
    13001306            'LIKE', 'NOT LIKE',
     
    13041310            'REGEXP', 'NOT REGEXP', 'RLIKE'
    13051311        ) ) ) {
    1306             $meta_compare = '=';
    1307         }
    1308 
    1309         /*
    1310          * There are a number of different query structures that get
    1311          * built in different ways.
    1312          * 1. Key-only clauses - (a) clauses without a 'value' key that
    1313          *    appear in the context of an OR relation and do not use
    1314          *    'NOT EXISTS' as the 'compare', or (b) clauses with an
    1315          *    empty array for 'value'.
    1316          */
    1317         if ( ! empty( $clause['key'] ) && (
    1318             ( ! array_key_exists( 'value', $clause ) && 'NOT EXISTS' !== $meta_compare && 'OR' === $parent_query['relation'] ) ||
    1319             ( isset( $clause['value'] ) && is_array( $clause['value'] ) && empty( $clause['value'] ) )
    1320         ) ) {
    1321 
    1322             $alias = $this->meta_table;
    1323             $sql_chunks['join'][] = " INNER JOIN $this->meta_table ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column)";
    1324             $sql_chunks['where'][] = $wpdb->prepare( "$this->meta_table.meta_key = %s", trim( $clause['key'] ) );
    1325 
    1326         // 2. NOT EXISTS.
    1327         } else if ( 'NOT EXISTS' === $meta_compare ) {
    1328             $join  = " LEFT JOIN $this->meta_table";
    1329             $join .= $i ? " AS $alias" : '';
    1330             $join .= $wpdb->prepare( " ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column AND $alias.meta_key = %s )", $clause['key'] );
     1312            $clause['compare'] = '=';
     1313        }
     1314
     1315        $meta_compare = $clause['compare'];
     1316
     1317        // First build the JOIN clause, if one is required.
     1318        $join = '';
     1319
     1320        // We prefer to avoid joins if possible. Look for an existing join compatible with this clause.
     1321        $alias = $this->find_compatible_table_alias( $clause, $parent_query );
     1322        if ( false === $alias ) {
     1323            $i = count( $this->table_aliases );
     1324            $alias = $i ? 'mt' . $i : $this->meta_table;
     1325
     1326            // JOIN clauses for NOT EXISTS have their own syntax.
     1327            if ( 'NOT EXISTS' === $meta_compare ) {
     1328                $join .= " LEFT JOIN $this->meta_table";
     1329                $join .= $i ? " AS $alias" : '';
     1330                $join .= $wpdb->prepare( " ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column AND $alias.meta_key = %s )", $clause['key'] );
     1331                $sql_chunks['join'][] = $join;
     1332
     1333            // All other JOIN clauses.
     1334            } else {
     1335                $alias = $this->find_compatible_table_alias( $clause, $parent_query );
     1336                if ( false === $alias ) {
     1337                    $alias = $i ? 'mt' . $i : $this->meta_table;
     1338
     1339                    $join .= " INNER JOIN $this->meta_table";
     1340                    $join .= $i ? " AS $alias" : '';
     1341                    $join .= " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column )";
     1342                }
     1343            }
     1344
     1345            $this->table_aliases[] = $alias;
    13311346            $sql_chunks['join'][] = $join;
    1332 
    1333             $sql_chunks['where'][] = $alias . '.' . $this->meta_id_column . ' IS NULL';
    1334 
    1335         // 3. EXISTS and other key-only queries.
    1336         } else if ( 'EXISTS' === $meta_compare || ( ! empty( $clause['key'] ) && ! array_key_exists( 'value', $clause ) ) ) {
    1337             $join  = " INNER JOIN $this->meta_table";
    1338             $join .= $i ? " AS $alias" : '';
    1339             $join .= " ON ( $this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column )";
    1340             $sql_chunks['join'][] = $join;
    1341 
    1342             $sql_chunks['where'][] = $wpdb->prepare( $alias . '.meta_key = %s', trim( $clause['key'] ) );
    1343 
    1344         // 4. Clauses that have a value.
    1345         } else if ( array_key_exists( 'value', $clause ) ) {
    1346             $join  = " INNER JOIN $this->meta_table";
    1347             $join .= $i ? " AS $alias" : '';
    1348             $join .= " ON ($this->primary_table.$this->primary_id_column = $alias.$this->meta_id_column)";
    1349             $sql_chunks['join'][] = $join;
    1350 
    1351             if ( ! empty( $clause['key'] ) ) {
     1347        }
     1348
     1349        // Save the alias to this clause, for future siblings to find.
     1350        $clause['alias'] = $alias;
     1351
     1352        // Next, build the WHERE clause.
     1353        $where = '';
     1354
     1355        // meta_key.
     1356        if ( array_key_exists( 'key', $clause ) ) {
     1357            if ( 'NOT EXISTS' === $meta_compare ) {
     1358                $sql_chunks['where'][] = $alias . '.' . $this->meta_id_column . ' IS NULL';
     1359            } else {
    13521360                $sql_chunks['where'][] = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) );
    13531361            }
    1354 
     1362        }
     1363
     1364        // meta_value.
     1365        if ( array_key_exists( 'value', $clause ) ) {
     1366            $meta_value = $clause['value'];
    13551367            $meta_type = $this->get_cast_for_type( isset( $clause['type'] ) ? $clause['type'] : '' );
    1356 
    1357             $meta_value = isset( $clause['value'] ) ? $clause['value'] : '';
    13581368
    13591369            if ( in_array( $meta_compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ) ) ) {
     
    13651375            }
    13661376
    1367             if ( 'IN' == substr( $meta_compare, -2 ) ) {
    1368                 $meta_compare_string = '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')';
    1369             } elseif ( 'BETWEEN' == substr( $meta_compare, -7 ) ) {
    1370                 $meta_value = array_slice( $meta_value, 0, 2 );
    1371                 $meta_compare_string = '%s AND %s';
    1372             } elseif ( 'LIKE' == $meta_compare || 'NOT LIKE' == $meta_compare ) {
    1373                 $meta_value = '%' . $wpdb->esc_like( $meta_value ) . '%';
    1374                 $meta_compare_string = '%s';
    1375             } else {
    1376                 $meta_compare_string = '%s';
     1377            switch ( $meta_compare ) {
     1378                case 'IN' :
     1379                case 'NOT IN' :
     1380                    $meta_compare_string = '(' . substr( str_repeat( ',%s', count( $meta_value ) ), 1 ) . ')';
     1381                    $where = $wpdb->prepare( $meta_compare_string, $meta_value );
     1382                    break;
     1383
     1384                case 'BETWEEN' :
     1385                case 'NOT BETWEEN' :
     1386                    $meta_value = array_slice( $meta_value, 0, 2 );
     1387                    $where = $wpdb->prepare( '%s AND %s', $meta_value );
     1388                    break;
     1389
     1390                case 'LIKE' :
     1391                case 'NOT LIKE' :
     1392                    $meta_value = '%' . $wpdb->esc_like( $meta_value ) . '%';
     1393                    $where = $wpdb->prepare( '%s', $meta_value );
     1394                    break;
     1395
     1396                default :
     1397                    $where = $wpdb->prepare( '%s', $meta_value );
     1398                    break;
     1399
    13771400            }
    13781401
    1379             $sql_chunks['where'][] = $wpdb->prepare( "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$meta_compare_string}", $meta_value );
     1402            if ( $where ) {
     1403                $sql_chunks['where'][] = "CAST($alias.meta_value AS {$meta_type}) {$meta_compare} {$where}";
     1404            }
    13801405        }
    13811406
     
    13881413        }
    13891414
    1390         $this->table_aliases[] = $alias;
    1391 
    13921415        return $sql_chunks;
     1416    }
     1417
     1418    /**
     1419     * Identify an existing table alias that is compatible with the current query clause.
     1420     *
     1421     * We avoid unnecessary table joins by allowing each clause to look for
     1422     * an existing table alias that is compatible with the query that it
     1423     * needs to perform. An existing alias is compatible if (a) it is a
     1424     * sibling of $clause (ie, it's under the scope of the same relation),
     1425     * and (b) the combination of operator and relation between the clauses
     1426     * allows for a shared table join. In the case of WP_Meta_Query, this
     1427     * only applies to IN clauses that are connected by the relation OR.
     1428     *
     1429     * @since 4.1.0
     1430     * @access protected
     1431     *
     1432     * @param  array       $clause       Query clause.
     1433     * @param  array       $parent_query Parent query of $clause.
     1434     * @return string|bool Table alias if found, otherwise false.
     1435     */
     1436    protected function find_compatible_table_alias( $clause, $parent_query ) {
     1437        $alias = false;
     1438
     1439        foreach ( $parent_query as $sibling ) {
     1440            // If the sibling has no alias yet, there's nothing to check.
     1441            if ( empty( $sibling['alias'] ) ) {
     1442                continue;
     1443            }
     1444
     1445            // We're only interested in siblings that are first-order clauses.
     1446            if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) {
     1447                continue;
     1448            }
     1449
     1450            $compatible_compares = array();
     1451
     1452            // Clauses connected by OR can share joins as long as they have "positive" operators.
     1453            if ( 'OR' === $parent_query['relation'] ) {
     1454                $compatible_compares = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
     1455
     1456            // Clauses joined by AND with "negative" operators share a join only if they also share a key.
     1457            } else if ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
     1458                $compatible_compares = array( '!=', 'NOT IN', 'NOT LIKE' );
     1459            }
     1460
     1461            $clause_compare  = strtoupper( $clause['compare'] );
     1462            $sibling_compare = strtoupper( $sibling['compare'] );
     1463            if ( in_array( $clause_compare, $compatible_compares ) && in_array( $sibling_compare, $compatible_compares ) ) {
     1464                $alias = $sibling['alias'];
     1465                break;
     1466            }
     1467        }
     1468
     1469        return apply_filters( 'meta_query_find_compatible_table_alias', $alias, $clause, $parent_query, $this ) ;
    13931470    }
    13941471}
Note: See TracChangeset for help on using the changeset viewer.