WordPress.org

Make WordPress Core


Ignore:
Timestamp:
10/14/2014 04:02:41 AM (5 years ago)
Author:
boonebgorges
Message:

Introduce support for nested queries in WP_Tax_Query.

Previously, tax query arguments could be joined by a single AND or OR relation.
Now, these queries can be arbitrarily nested, allowing clauses to be linked
together with multiple relations.

In a few places, WP_Query runs through a list of clauses in a tax_query in order
to set certain query vars for backward compatibility. The necessary changes have
been made to WP_Query to support this feature with the new complex structure of
tax_query. Unit tests are included for these backward compatibility fixes.

Unit tests for the new nesting syntax are included.

Props boonebgorges.
Fixes #29718. See #29738.

File:
1 edited

Legend:

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

    r29867 r29891  
    628628
    629629/**
    630  * Container class for a multiple taxonomy query.
     630 * Class for generating SQL clauses that filter a primary query according to object taxonomy terms.
     631 *
     632 * `WP_Tax_Query` is a helper that allows primary query classes, such as {@see WP_Query}, to filter
     633 * their results by object metadata, by generating `JOIN` and `WHERE` subclauses to be attached
     634 * to the primary SQL query string.
    631635 *
    632636 * @since 3.1.0
     
    635639
    636640    /**
    637      * List of taxonomy queries. A single taxonomy query is an associative array:
    638      * - 'taxonomy' string The taxonomy being queried. Optional when using the term_taxonomy_id field.
    639      * - 'terms' string|array The list of terms
    640      * - 'field' string (optional) Which term field is being used.
    641      *      Possible values: 'term_id', 'slug', 'name', or 'term_taxonomy_id'
    642      *      Default: 'term_id'
    643      * - 'operator' string (optional)
    644      *      Possible values: 'AND', 'IN' or 'NOT IN'.
    645      *      Default: 'IN'
    646      * - 'include_children' bool (optional) Whether to include child terms. Requires that a taxonomy be specified.
    647      *      Default: true
     641     * Array of taxonomy queries.
     642     *
     643     * See {@see WP_Tax_Query::__construct()} for information on tax query arguments.
    648644     *
    649645     * @since 3.1.0
     
    669665     * @var string
    670666     */
    671     private static $no_results = array( 'join' => '', 'where' => ' AND 0 = 1' );
     667    private static $no_results = array( 'join' => array( '' ), 'where' => array( '0 = 1' ) );
     668
     669    /**
     670     * A flat list of table aliases used in the JOIN clauses.
     671     *
     672     * @since 4.1.0
     673     * @access protected
     674     * @var array
     675     */
     676    protected $table_aliases = array();
     677
     678    /**
     679     * Terms and taxonomies fetched by this query.
     680     *
     681     * We store this data in a flat array because they are referenced in a
     682     * number of places by WP_Query.
     683     *
     684     * @since 4.1.0
     685     * @access public
     686     * @var array
     687     */
     688    public $queried_terms = array();
    672689
    673690    /**
    674691     * Constructor.
    675      *
    676      * Parses a compact tax query and sets defaults.
    677692     *
    678693     * @since 3.1.0
    679694     * @access public
    680695     *
    681      * @param array $tax_query A compact tax query:
    682      *  array(
    683      *    'relation' => 'OR',
    684      *    array(
    685      *      'taxonomy' => 'tax1',
    686      *      'terms' => array( 'term1', 'term2' ),
    687      *      'field' => 'slug',
    688      *    ),
    689      *    array(
    690      *      'taxonomy' => 'tax2',
    691      *      'terms' => array( 'term-a', 'term-b' ),
    692      *      'field' => 'slug',
    693      *    ),
    694      *  )
     696     * @param array $tax_query {
     697     *     Array of taxonoy query clauses.
     698     *
     699     *     @type string $relation Optional. The MySQL keyword used to join
     700     *                            the clauses of the query. Accepts 'AND', or 'OR'. Default 'AND'.
     701     *     @type array {
     702     *         Optional. An array of first-order clause parameters, or another fully-formed tax query.
     703     *
     704     *         @type string           $taxonomy         Taxonomy being queried. Optional when field=term_taxonomy_id.
     705     *         @type string|int|array $terms            Term or terms to filter by.
     706     *         @type string           $field            Field to match $terms against. Accepts 'term_id', 'slug',
     707     *                                                 'name', or 'term_taxonomy_id'. Default: 'term_id'.
     708     *         @type string           $operator         MySQL operator to be used with $terms in the WHERE clause.
     709     *                                                  Accepts 'AND', 'IN', or 'OR. Default: 'IN'.
     710     *         @type bool             $include_children Optional. Whether to include child terms.
     711     *                                                  Requires a $taxonomy. Default: true.
     712     *     }
     713     * }
    695714     */
    696715    public function __construct( $tax_query ) {
     
    701720        }
    702721
     722        $this->queries = $this->sanitize_query( $tax_query );
     723    }
     724
     725    /**
     726     * Ensure the `tax_query` argument passed to the class constructor is well-formed.
     727     *
     728     * Ensures that each query-level clause has a 'relation' key, and that
     729     * each first-order clause contains all the necessary keys from $defaults.
     730     *
     731     * @since 4.1.0
     732     * @access public
     733     *
     734     * @param  array $queries Array of queries clauses.
     735     * @return array Sanitized array of query clauses.
     736     */
     737    public function sanitize_query( $queries ) {
     738        $cleaned_query = array();
     739
    703740        $defaults = array(
    704741            'taxonomy' => '',
    705742            'terms' => array(),
    706             'include_children' => true,
    707743            'field' => 'term_id',
    708744            'operator' => 'IN',
     745            'include_children' => true,
    709746        );
    710747
    711         foreach ( $tax_query as $query ) {
    712             if ( ! is_array( $query ) )
    713                 continue;
    714 
    715             $query = array_merge( $defaults, $query );
    716 
    717             $query['terms'] = (array) $query['terms'];
    718 
    719             $this->queries[] = $query;
    720         }
     748        foreach ( $queries as $key => $query ) {
     749            if ( 'relation' === $key ) {
     750                $cleaned_query['relation'] = $query;
     751
     752            // First-order clause.
     753            } else if ( self::is_first_order_clause( $query ) ) {
     754
     755                $cleaned_clause = array_merge( $defaults, $query );
     756                $cleaned_clause['terms'] = (array) $cleaned_clause['terms'];
     757                $cleaned_query[] = $cleaned_clause;
     758
     759                /*
     760                 * Keep a copy of the clause in the flate
     761                 * $queried_terms array, for use in WP_Query.
     762                 */
     763                if ( ! empty( $cleaned_clause['taxonomy'] ) && 'NOT IN' !== $cleaned_clause['operator'] ) {
     764                    $taxonomy = $cleaned_clause['taxonomy'];
     765                    if ( ! isset( $this->queried_terms[ $taxonomy ] ) ) {
     766                        $this->queried_terms[ $taxonomy ] = array();
     767                    }
     768
     769                    /*
     770                     * Backward compatibility: Only store the first
     771                     * 'terms' and 'field' found for a given taxonomy.
     772                     */
     773                    if ( ! empty( $cleaned_clause['terms'] ) && ! isset( $this->queried_terms[ $taxonomy ]['terms'] ) ) {
     774                        $this->queried_terms[ $taxonomy ]['terms'] = $cleaned_clause['terms'];
     775                    }
     776
     777                    if ( ! empty( $cleaned_clause['field'] ) && ! isset( $this->queried_terms[ $taxonomy ]['field'] ) ) {
     778                        $this->queried_terms[ $taxonomy ]['field'] = $cleaned_clause['field'];
     779                    }
     780                }
     781
     782            // Otherwise, it's a nested query, so we recurse.
     783            } else if ( is_array( $query ) ) {
     784                $cleaned_subquery = $this->sanitize_query( $query );
     785
     786                if ( ! empty( $cleaned_subquery ) ) {
     787                    $cleaned_query[] = $cleaned_subquery;
     788                }
     789            }
     790        }
     791
     792        return $cleaned_query;
     793    }
     794
     795    /**
     796     * Determine whether a clause is first-order.
     797     *
     798     * A "first-order" clause is one that contains any of the first-order
     799     * clause keys ('terms', 'taxonomy', 'include_children', 'field',
     800     * 'operator'). An empty clause also counts as a first-order clause,
     801     * for backward compatibility. Any clause that doesn't meet this is
     802     * determined, by process of elimination, to be a higher-order query.
     803     *
     804     * @since 4.1.0
     805     * @access protected
     806     *
     807     * @param  array $query Tax query arguments.
     808     * @return bool  Whether the query clause is a first-order clause.
     809     */
     810    protected static function is_first_order_clause( $query ) {
     811        return empty( $query ) || array_key_exists( 'terms', $query ) || array_key_exists( 'taxonomy', $query ) || array_key_exists( 'include_children', $query ) || array_key_exists( 'field', $query ) || array_key_exists( 'operator', $query );
    721812    }
    722813
     
    727818     * @access public
    728819     *
    729      * @param string $primary_table
    730      * @param string $primary_id_column
    731      * @return array
     820     * @param string $primary_table     Database table where the object being filtered is stored (eg wp_users).
     821     * @param string $primary_id_column ID column for the filtered object in $primary_table.
     822     * @return array {
     823     *     Array containing JOIN and WHERE SQL clauses to append to the main query.
     824     *
     825     *     @type string $join  SQL fragment to append to the main JOIN clause.
     826     *     @type string $where SQL fragment to append to the main WHERE clause.
     827     * }
    732828     */
    733829    public function get_sql( $primary_table, $primary_id_column ) {
     830        $this->primary_table = $primary_table;
     831        $this->primary_id_column = $primary_id_column;
     832
     833        return $this->get_sql_clauses();
     834    }
     835
     836    /**
     837     * Generate SQL clauses to be appended to a main query.
     838     *
     839     * Called by the public {@see WP_Tax_Query::get_sql()}, this method
     840     * is abstracted out to maintain parity with the other Query classes.
     841     *
     842     * @since 4.1.0
     843     * @access protected
     844     *
     845     * @return array {
     846     *     Array containing JOIN and WHERE SQL clauses to append to the main query.
     847     *
     848     *     @type string $join  SQL fragment to append to the main JOIN clause.
     849     *     @type string $where SQL fragment to append to the main WHERE clause.
     850     * }
     851     */
     852    protected function get_sql_clauses() {
     853        $sql = $this->get_sql_for_query( $this->queries );
     854
     855        if ( ! empty( $sql['where'] ) ) {
     856            $sql['where'] = ' AND ' . $sql['where'];
     857        }
     858
     859        return $sql;
     860    }
     861
     862    /**
     863     * Generate SQL clauses for a single query array.
     864     *
     865     * If nested subqueries are found, this method recurses the tree to
     866     * produce the properly nested SQL.
     867     *
     868     * @since 4.1.0
     869     * @access protected
     870     *
     871     * @param array $query Query to parse.
     872     * @param int   $depth Optional. Number of tree levels deep we currently are.
     873     *              Used to calculate indentation.
     874     * @return array {
     875     *     Array containing JOIN and WHERE SQL clauses to append to a single query array.
     876     *
     877     *     @type string $join  SQL fragment to append to the main JOIN clause.
     878     *     @type string $where SQL fragment to append to the main WHERE clause.
     879     * }
     880     */
     881    protected function get_sql_for_query( $query, $depth = 0 ) {
     882        $sql_chunks = array(
     883            'join'  => array(),
     884            'where' => array(),
     885        );
     886
     887        $sql = array(
     888            'join'  => '',
     889            'where' => '',
     890        );
     891
     892        $indent = '';
     893        for ( $i = 0; $i < $depth; $i++ ) {
     894            $indent .= "  ";
     895        }
     896
     897        foreach ( $query as $key => $clause ) {
     898            if ( 'relation' === $key ) {
     899                $relation = $query['relation'];
     900            } else if ( is_array( $clause ) ) {
     901
     902                // This is a first-order clause.
     903                if ( $this->is_first_order_clause( $clause ) ) {
     904                    $clause_sql = $this->get_sql_for_clause( $clause, $query );
     905
     906                    $where_count = count( $clause_sql['where'] );
     907                    if ( ! $where_count ) {
     908                        $sql_chunks['where'][] = '';
     909                    } else if ( 1 === $where_count ) {
     910                        $sql_chunks['where'][] = $clause_sql['where'][0];
     911                    } else {
     912                        $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
     913                    }
     914
     915                    $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
     916                // This is a subquery, so we recurse.
     917                } else {
     918                    $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
     919
     920                    $sql_chunks['where'][] = $clause_sql['where'];
     921                    $sql_chunks['join'][]  = $clause_sql['join'];
     922                }
     923            }
     924        }
     925
     926        // Filter to remove empties.
     927        $sql_chunks['join']  = array_filter( $sql_chunks['join'] );
     928        $sql_chunks['where'] = array_filter( $sql_chunks['where'] );
     929
     930        if ( empty( $relation ) ) {
     931            $relation = 'AND';
     932        }
     933
     934        // Filter duplicate JOIN clauses and combine into a single string.
     935        if ( ! empty( $sql_chunks['join'] ) ) {
     936            $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) );
     937        }
     938
     939        // Generate a single WHERE clause with proper brackets and indentation.
     940        if ( ! empty( $sql_chunks['where'] ) ) {
     941            $sql['where'] = '( ' . "\n  " . $indent . implode( ' ' . "\n  " . $indent . $relation . ' ' . "\n  " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')';
     942        }
     943
     944        return $sql;
     945    }
     946
     947    /**
     948     * Generate SQL JOIN and WHERE clauses for a first-order query clause.
     949
     950     * @since 4.1.0
     951     * @access public
     952     *
     953     * @param  array $clause       Query clause.
     954     * @param  array $parent_query Parent query array.
     955     * @return array {
     956     *     Array containing JOIN and WHERE SQL clauses to append to a first-order query.
     957     *
     958     *     @type string $join  SQL fragment to append to the main JOIN clause.
     959     *     @type string $where SQL fragment to append to the main WHERE clause.
     960     * }
     961     */
     962    public function get_sql_for_clause( $clause, $parent_query ) {
    734963        global $wpdb;
    735964
     965        $sql = array(
     966            'where' => array(),
     967            'join'  => array(),
     968        );
     969
    736970        $join = '';
    737         $where = array();
    738         $i = 0;
    739         $count = count( $this->queries );
    740 
    741         foreach ( $this->queries as $index => $query ) {
    742             $this->clean_query( $query );
    743 
    744             if ( is_wp_error( $query ) ) {
     971
     972        $this->clean_query( $clause );
     973
     974        if ( is_wp_error( $clause ) ) {
     975            return self::$no_results;
     976        }
     977
     978        $terms = $clause['terms'];
     979        $operator = strtoupper( $clause['operator'] );
     980
     981        if ( 'IN' == $operator ) {
     982
     983            if ( empty( $terms ) ) {
    745984                return self::$no_results;
    746985            }
    747986
    748             $terms = $query['terms'];
    749             $operator = strtoupper( $query['operator'] );
    750 
    751             if ( 'IN' == $operator ) {
    752 
    753                 if ( empty( $terms ) ) {
    754                     if ( 'OR' == $this->relation ) {
    755                         if ( ( $index + 1 === $count ) && empty( $where ) ) {
    756                             return self::$no_results;
    757                         }
    758                         continue;
    759                     } else {
    760                         return self::$no_results;
    761                     }
    762                 }
    763 
    764                 $terms = implode( ',', $terms );
    765 
    766                 $alias = $i ? 'tt' . $i : $wpdb->term_relationships;
    767 
    768                 $join .= " INNER JOIN $wpdb->term_relationships";
    769                 $join .= $i ? " AS $alias" : '';
    770                 $join .= " ON ($primary_table.$primary_id_column = $alias.object_id)";
    771 
    772                 $where[] = "$alias.term_taxonomy_id $operator ($terms)";
    773             } elseif ( 'NOT IN' == $operator ) {
    774 
    775                 if ( empty( $terms ) ) {
    776                     continue;
    777                 }
    778 
    779                 $terms = implode( ',', $terms );
    780 
    781                 $where[] = "$primary_table.$primary_id_column NOT IN (
    782                     SELECT object_id
    783                     FROM $wpdb->term_relationships
    784                     WHERE term_taxonomy_id IN ($terms)
    785                 )";
    786             } elseif ( 'AND' == $operator ) {
    787 
    788                 if ( empty( $terms ) ) {
    789                     continue;
    790                 }
    791 
    792                 $num_terms = count( $terms );
    793 
    794                 $terms = implode( ',', $terms );
    795 
    796                 $where[] = "(
    797                     SELECT COUNT(1)
    798                     FROM $wpdb->term_relationships
    799                     WHERE term_taxonomy_id IN ($terms)
    800                     AND object_id = $primary_table.$primary_id_column
    801                 ) = $num_terms";
     987            $terms = implode( ',', $terms );
     988
     989            $i = count( $this->table_aliases );
     990            $alias = $i ? 'tt' . $i : $wpdb->term_relationships;
     991            $this->table_aliases[] = $alias;
     992
     993            $join .= " INNER JOIN $wpdb->term_relationships";
     994            $join .= $i ? " AS $alias" : '';
     995            $join .= " ON ($this->primary_table.$this->primary_id_column = $alias.object_id)";
     996
     997            $where = "$alias.term_taxonomy_id $operator ($terms)";
     998
     999        } elseif ( 'NOT IN' == $operator ) {
     1000
     1001            if ( empty( $terms ) ) {
     1002                continue;
    8021003            }
    8031004
    804             $i++;
    805         }
    806 
    807         if ( ! empty( $where ) ) {
    808             $where = ' AND ( ' . implode( " $this->relation ", $where ) . ' )';
    809         } else {
    810             $where = '';
    811         }
    812         return compact( 'join', 'where' );
    813     }
     1005            $terms = implode( ',', $terms );
     1006
     1007            $where = "$this->primary_table.$this->primary_id_column NOT IN (
     1008                SELECT object_id
     1009                FROM $wpdb->term_relationships
     1010                WHERE term_taxonomy_id IN ($terms)
     1011            )";
     1012
     1013        } elseif ( 'AND' == $operator ) {
     1014
     1015            if ( empty( $terms ) ) {
     1016                continue;
     1017            }
     1018
     1019            $num_terms = count( $terms );
     1020
     1021            $terms = implode( ',', $terms );
     1022
     1023            $where = "(
     1024                SELECT COUNT(1)
     1025                FROM $wpdb->term_relationships
     1026                WHERE term_taxonomy_id IN ($terms)
     1027                AND object_id = $this->primary_table.$this->primary_id_column
     1028            ) = $num_terms";
     1029        }
     1030
     1031        $sql['join'][]  = $join;
     1032        $sql['where'][] = $where;
     1033        return $sql;
     1034    }
     1035
    8141036
    8151037    /**
     
    8191041     * @access private
    8201042     *
    821      * @param array &$query The single query
     1043     * @param array &$query The single query.
    8221044     */
    8231045    private function clean_query( &$query ) {
     
    8591081     * @since 3.2.0
    8601082     *
    861      * @param array &$query The single query
    862      * @param string $resulting_field The resulting field
     1083     * @param array  &$query          The single query.
     1084     * @param string $resulting_field The resulting field. Accepts 'slug', 'name', 'term_taxonomy_id',
     1085     *                                or 'term_id'. Default: 'term_id'.
    8631086     */
    8641087    public function transform_query( &$query, $resulting_field ) {
Note: See TracChangeset for help on using the changeset viewer.