[ Index ]

PHP Cross Reference of WordPress

title

Body

[close]

/wp-includes/ -> class-wp-tax-query.php (source)

   1  <?php
   2  /**
   3   * Taxonomy API: WP_Tax_Query class
   4   *
   5   * @package WordPress
   6   * @subpackage Taxonomy
   7   * @since 4.4.0
   8   */
   9  
  10  /**
  11   * Core class used to implement taxonomy queries for the Taxonomy API.
  12   *
  13   * Used for generating SQL clauses that filter a primary query according to object
  14   * taxonomy terms.
  15   *
  16   * WP_Tax_Query is a helper that allows primary query classes, such as WP_Query, to filter
  17   * their results by object metadata, by generating `JOIN` and `WHERE` subclauses to be
  18   * attached to the primary SQL query string.
  19   *
  20   * @since 3.1.0
  21   */
  22  class WP_Tax_Query {
  23  
  24      /**
  25       * Array of taxonomy queries.
  26       *
  27       * See WP_Tax_Query::__construct() for information on tax query arguments.
  28       *
  29       * @since 3.1.0
  30       * @var array
  31       */
  32      public $queries = array();
  33  
  34      /**
  35       * The relation between the queries. Can be one of 'AND' or 'OR'.
  36       *
  37       * @since 3.1.0
  38       * @var string
  39       */
  40      public $relation;
  41  
  42      /**
  43       * Standard response when the query should not return any rows.
  44       *
  45       * @since 3.2.0
  46       * @var string
  47       */
  48      private static $no_results = array(
  49          'join'  => array( '' ),
  50          'where' => array( '0 = 1' ),
  51      );
  52  
  53      /**
  54       * A flat list of table aliases used in the JOIN clauses.
  55       *
  56       * @since 4.1.0
  57       * @var array
  58       */
  59      protected $table_aliases = array();
  60  
  61      /**
  62       * Terms and taxonomies fetched by this query.
  63       *
  64       * We store this data in a flat array because they are referenced in a
  65       * number of places by WP_Query.
  66       *
  67       * @since 4.1.0
  68       * @var array
  69       */
  70      public $queried_terms = array();
  71  
  72      /**
  73       * Database table that where the metadata's objects are stored (eg $wpdb->users).
  74       *
  75       * @since 4.1.0
  76       * @var string
  77       */
  78      public $primary_table;
  79  
  80      /**
  81       * Column in 'primary_table' that represents the ID of the object.
  82       *
  83       * @since 4.1.0
  84       * @var string
  85       */
  86      public $primary_id_column;
  87  
  88      /**
  89       * Constructor.
  90       *
  91       * @since 3.1.0
  92       * @since 4.1.0 Added support for `$operator` 'NOT EXISTS' and 'EXISTS' values.
  93       *
  94       * @param array $tax_query {
  95       *     Array of taxonomy query clauses.
  96       *
  97       *     @type string $relation Optional. The MySQL keyword used to join
  98       *                            the clauses of the query. Accepts 'AND', or 'OR'. Default 'AND'.
  99       *     @type array  ...$0 {
 100       *         An array of first-order clause parameters, or another fully-formed tax query.
 101       *
 102       *         @type string           $taxonomy         Taxonomy being queried. Optional when field=term_taxonomy_id.
 103       *         @type string|int|array $terms            Term or terms to filter by.
 104       *         @type string           $field            Field to match $terms against. Accepts 'term_id', 'slug',
 105       *                                                 'name', or 'term_taxonomy_id'. Default: 'term_id'.
 106       *         @type string           $operator         MySQL operator to be used with $terms in the WHERE clause.
 107       *                                                  Accepts 'AND', 'IN', 'NOT IN', 'EXISTS', 'NOT EXISTS'.
 108       *                                                  Default: 'IN'.
 109       *         @type bool             $include_children Optional. Whether to include child terms.
 110       *                                                  Requires a $taxonomy. Default: true.
 111       *     }
 112       * }
 113       */
 114  	public function __construct( $tax_query ) {
 115          if ( isset( $tax_query['relation'] ) ) {
 116              $this->relation = $this->sanitize_relation( $tax_query['relation'] );
 117          } else {
 118              $this->relation = 'AND';
 119          }
 120  
 121          $this->queries = $this->sanitize_query( $tax_query );
 122      }
 123  
 124      /**
 125       * Ensure the 'tax_query' argument passed to the class constructor is well-formed.
 126       *
 127       * Ensures that each query-level clause has a 'relation' key, and that
 128       * each first-order clause contains all the necessary keys from `$defaults`.
 129       *
 130       * @since 4.1.0
 131       *
 132       * @param array $queries Array of queries clauses.
 133       * @return array Sanitized array of query clauses.
 134       */
 135  	public function sanitize_query( $queries ) {
 136          $cleaned_query = array();
 137  
 138          $defaults = array(
 139              'taxonomy'         => '',
 140              'terms'            => array(),
 141              'field'            => 'term_id',
 142              'operator'         => 'IN',
 143              'include_children' => true,
 144          );
 145  
 146          foreach ( $queries as $key => $query ) {
 147              if ( 'relation' === $key ) {
 148                  $cleaned_query['relation'] = $this->sanitize_relation( $query );
 149  
 150                  // First-order clause.
 151              } elseif ( self::is_first_order_clause( $query ) ) {
 152  
 153                  $cleaned_clause          = array_merge( $defaults, $query );
 154                  $cleaned_clause['terms'] = (array) $cleaned_clause['terms'];
 155                  $cleaned_query[]         = $cleaned_clause;
 156  
 157                  /*
 158                   * Keep a copy of the clause in the flate
 159                   * $queried_terms array, for use in WP_Query.
 160                   */
 161                  if ( ! empty( $cleaned_clause['taxonomy'] ) && 'NOT IN' !== $cleaned_clause['operator'] ) {
 162                      $taxonomy = $cleaned_clause['taxonomy'];
 163                      if ( ! isset( $this->queried_terms[ $taxonomy ] ) ) {
 164                          $this->queried_terms[ $taxonomy ] = array();
 165                      }
 166  
 167                      /*
 168                       * Backward compatibility: Only store the first
 169                       * 'terms' and 'field' found for a given taxonomy.
 170                       */
 171                      if ( ! empty( $cleaned_clause['terms'] ) && ! isset( $this->queried_terms[ $taxonomy ]['terms'] ) ) {
 172                          $this->queried_terms[ $taxonomy ]['terms'] = $cleaned_clause['terms'];
 173                      }
 174  
 175                      if ( ! empty( $cleaned_clause['field'] ) && ! isset( $this->queried_terms[ $taxonomy ]['field'] ) ) {
 176                          $this->queried_terms[ $taxonomy ]['field'] = $cleaned_clause['field'];
 177                      }
 178                  }
 179  
 180                  // Otherwise, it's a nested query, so we recurse.
 181              } elseif ( is_array( $query ) ) {
 182                  $cleaned_subquery = $this->sanitize_query( $query );
 183  
 184                  if ( ! empty( $cleaned_subquery ) ) {
 185                      // All queries with children must have a relation.
 186                      if ( ! isset( $cleaned_subquery['relation'] ) ) {
 187                          $cleaned_subquery['relation'] = 'AND';
 188                      }
 189  
 190                      $cleaned_query[] = $cleaned_subquery;
 191                  }
 192              }
 193          }
 194  
 195          return $cleaned_query;
 196      }
 197  
 198      /**
 199       * Sanitize a 'relation' operator.
 200       *
 201       * @since 4.1.0
 202       *
 203       * @param string $relation Raw relation key from the query argument.
 204       * @return string Sanitized relation ('AND' or 'OR').
 205       */
 206  	public function sanitize_relation( $relation ) {
 207          if ( 'OR' === strtoupper( $relation ) ) {
 208              return 'OR';
 209          } else {
 210              return 'AND';
 211          }
 212      }
 213  
 214      /**
 215       * Determine whether a clause is first-order.
 216       *
 217       * A "first-order" clause is one that contains any of the first-order
 218       * clause keys ('terms', 'taxonomy', 'include_children', 'field',
 219       * 'operator'). An empty clause also counts as a first-order clause,
 220       * for backward compatibility. Any clause that doesn't meet this is
 221       * determined, by process of elimination, to be a higher-order query.
 222       *
 223       * @since 4.1.0
 224       *
 225       * @param array $query Tax query arguments.
 226       * @return bool Whether the query clause is a first-order clause.
 227       */
 228  	protected static function is_first_order_clause( $query ) {
 229          return is_array( $query ) && ( 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 ) );
 230      }
 231  
 232      /**
 233       * Generates SQL clauses to be appended to a main query.
 234       *
 235       * @since 3.1.0
 236       *
 237       * @param string $primary_table     Database table where the object being filtered is stored (eg wp_users).
 238       * @param string $primary_id_column ID column for the filtered object in $primary_table.
 239       * @return string[] {
 240       *     Array containing JOIN and WHERE SQL clauses to append to the main query.
 241       *
 242       *     @type string $join  SQL fragment to append to the main JOIN clause.
 243       *     @type string $where SQL fragment to append to the main WHERE clause.
 244       * }
 245       */
 246  	public function get_sql( $primary_table, $primary_id_column ) {
 247          $this->primary_table     = $primary_table;
 248          $this->primary_id_column = $primary_id_column;
 249  
 250          return $this->get_sql_clauses();
 251      }
 252  
 253      /**
 254       * Generate SQL clauses to be appended to a main query.
 255       *
 256       * Called by the public WP_Tax_Query::get_sql(), this method
 257       * is abstracted out to maintain parity with the other Query classes.
 258       *
 259       * @since 4.1.0
 260       *
 261       * @return string[] {
 262       *     Array containing JOIN and WHERE SQL clauses to append to the main query.
 263       *
 264       *     @type string $join  SQL fragment to append to the main JOIN clause.
 265       *     @type string $where SQL fragment to append to the main WHERE clause.
 266       * }
 267       */
 268  	protected function get_sql_clauses() {
 269          /*
 270           * $queries are passed by reference to get_sql_for_query() for recursion.
 271           * To keep $this->queries unaltered, pass a copy.
 272           */
 273          $queries = $this->queries;
 274          $sql     = $this->get_sql_for_query( $queries );
 275  
 276          if ( ! empty( $sql['where'] ) ) {
 277              $sql['where'] = ' AND ' . $sql['where'];
 278          }
 279  
 280          return $sql;
 281      }
 282  
 283      /**
 284       * Generate SQL clauses for a single query array.
 285       *
 286       * If nested subqueries are found, this method recurses the tree to
 287       * produce the properly nested SQL.
 288       *
 289       * @since 4.1.0
 290       *
 291       * @param array $query Query to parse (passed by reference).
 292       * @param int   $depth Optional. Number of tree levels deep we currently are.
 293       *                     Used to calculate indentation. Default 0.
 294       * @return string[] {
 295       *     Array containing JOIN and WHERE SQL clauses to append to a single query array.
 296       *
 297       *     @type string $join  SQL fragment to append to the main JOIN clause.
 298       *     @type string $where SQL fragment to append to the main WHERE clause.
 299       * }
 300       */
 301  	protected function get_sql_for_query( &$query, $depth = 0 ) {
 302          $sql_chunks = array(
 303              'join'  => array(),
 304              'where' => array(),
 305          );
 306  
 307          $sql = array(
 308              'join'  => '',
 309              'where' => '',
 310          );
 311  
 312          $indent = '';
 313          for ( $i = 0; $i < $depth; $i++ ) {
 314              $indent .= '  ';
 315          }
 316  
 317          foreach ( $query as $key => &$clause ) {
 318              if ( 'relation' === $key ) {
 319                  $relation = $query['relation'];
 320              } elseif ( is_array( $clause ) ) {
 321  
 322                  // This is a first-order clause.
 323                  if ( $this->is_first_order_clause( $clause ) ) {
 324                      $clause_sql = $this->get_sql_for_clause( $clause, $query );
 325  
 326                      $where_count = count( $clause_sql['where'] );
 327                      if ( ! $where_count ) {
 328                          $sql_chunks['where'][] = '';
 329                      } elseif ( 1 === $where_count ) {
 330                          $sql_chunks['where'][] = $clause_sql['where'][0];
 331                      } else {
 332                          $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
 333                      }
 334  
 335                      $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
 336                      // This is a subquery, so we recurse.
 337                  } else {
 338                      $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
 339  
 340                      $sql_chunks['where'][] = $clause_sql['where'];
 341                      $sql_chunks['join'][]  = $clause_sql['join'];
 342                  }
 343              }
 344          }
 345  
 346          // Filter to remove empties.
 347          $sql_chunks['join']  = array_filter( $sql_chunks['join'] );
 348          $sql_chunks['where'] = array_filter( $sql_chunks['where'] );
 349  
 350          if ( empty( $relation ) ) {
 351              $relation = 'AND';
 352          }
 353  
 354          // Filter duplicate JOIN clauses and combine into a single string.
 355          if ( ! empty( $sql_chunks['join'] ) ) {
 356              $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) );
 357          }
 358  
 359          // Generate a single WHERE clause with proper brackets and indentation.
 360          if ( ! empty( $sql_chunks['where'] ) ) {
 361              $sql['where'] = '( ' . "\n  " . $indent . implode( ' ' . "\n  " . $indent . $relation . ' ' . "\n  " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')';
 362          }
 363  
 364          return $sql;
 365      }
 366  
 367      /**
 368       * Generate SQL JOIN and WHERE clauses for a "first-order" query clause.
 369       *
 370       * @since 4.1.0
 371       *
 372       * @global wpdb $wpdb The WordPress database abstraction object.
 373       *
 374       * @param array $clause       Query clause (passed by reference).
 375       * @param array $parent_query Parent query array.
 376       * @return string[] {
 377       *     Array containing JOIN and WHERE SQL clauses to append to a first-order query.
 378       *
 379       *     @type string $join  SQL fragment to append to the main JOIN clause.
 380       *     @type string $where SQL fragment to append to the main WHERE clause.
 381       * }
 382       */
 383  	public function get_sql_for_clause( &$clause, $parent_query ) {
 384          global $wpdb;
 385  
 386          $sql = array(
 387              'where' => array(),
 388              'join'  => array(),
 389          );
 390  
 391          $join  = '';
 392          $where = '';
 393  
 394          $this->clean_query( $clause );
 395  
 396          if ( is_wp_error( $clause ) ) {
 397              return self::$no_results;
 398          }
 399  
 400          $terms    = $clause['terms'];
 401          $operator = strtoupper( $clause['operator'] );
 402  
 403          if ( 'IN' === $operator ) {
 404  
 405              if ( empty( $terms ) ) {
 406                  return self::$no_results;
 407              }
 408  
 409              $terms = implode( ',', $terms );
 410  
 411              /*
 412               * Before creating another table join, see if this clause has a
 413               * sibling with an existing join that can be shared.
 414               */
 415              $alias = $this->find_compatible_table_alias( $clause, $parent_query );
 416              if ( false === $alias ) {
 417                  $i     = count( $this->table_aliases );
 418                  $alias = $i ? 'tt' . $i : $wpdb->term_relationships;
 419  
 420                  // Store the alias as part of a flat array to build future iterators.
 421                  $this->table_aliases[] = $alias;
 422  
 423                  // Store the alias with this clause, so later siblings can use it.
 424                  $clause['alias'] = $alias;
 425  
 426                  $join .= " LEFT JOIN $wpdb->term_relationships";
 427                  $join .= $i ? " AS $alias" : '';
 428                  $join .= " ON ($this->primary_table.$this->primary_id_column = $alias.object_id)";
 429              }
 430  
 431              $where = "$alias.term_taxonomy_id $operator ($terms)";
 432  
 433          } elseif ( 'NOT IN' === $operator ) {
 434  
 435              if ( empty( $terms ) ) {
 436                  return $sql;
 437              }
 438  
 439              $terms = implode( ',', $terms );
 440  
 441              $where = "$this->primary_table.$this->primary_id_column NOT IN (
 442                  SELECT object_id
 443                  FROM $wpdb->term_relationships
 444                  WHERE term_taxonomy_id IN ($terms)
 445              )";
 446  
 447          } elseif ( 'AND' === $operator ) {
 448  
 449              if ( empty( $terms ) ) {
 450                  return $sql;
 451              }
 452  
 453              $num_terms = count( $terms );
 454  
 455              $terms = implode( ',', $terms );
 456  
 457              $where = "(
 458                  SELECT COUNT(1)
 459                  FROM $wpdb->term_relationships
 460                  WHERE term_taxonomy_id IN ($terms)
 461                  AND object_id = $this->primary_table.$this->primary_id_column
 462              ) = $num_terms";
 463  
 464          } elseif ( 'NOT EXISTS' === $operator || 'EXISTS' === $operator ) {
 465  
 466              $where = $wpdb->prepare(
 467                  "$operator (
 468                  SELECT 1
 469                  FROM $wpdb->term_relationships
 470                  INNER JOIN $wpdb->term_taxonomy
 471                  ON $wpdb->term_taxonomy.term_taxonomy_id = $wpdb->term_relationships.term_taxonomy_id
 472                  WHERE $wpdb->term_taxonomy.taxonomy = %s
 473                  AND $wpdb->term_relationships.object_id = $this->primary_table.$this->primary_id_column
 474              )",
 475                  $clause['taxonomy']
 476              );
 477  
 478          }
 479  
 480          $sql['join'][]  = $join;
 481          $sql['where'][] = $where;
 482          return $sql;
 483      }
 484  
 485      /**
 486       * Identify an existing table alias that is compatible with the current query clause.
 487       *
 488       * We avoid unnecessary table joins by allowing each clause to look for
 489       * an existing table alias that is compatible with the query that it
 490       * needs to perform.
 491       *
 492       * An existing alias is compatible if (a) it is a sibling of `$clause`
 493       * (ie, it's under the scope of the same relation), and (b) the combination
 494       * of operator and relation between the clauses allows for a shared table
 495       * join. In the case of WP_Tax_Query, this only applies to 'IN'
 496       * clauses that are connected by the relation 'OR'.
 497       *
 498       * @since 4.1.0
 499       *
 500       * @param array $clause       Query clause.
 501       * @param array $parent_query Parent query of $clause.
 502       * @return string|false Table alias if found, otherwise false.
 503       */
 504  	protected function find_compatible_table_alias( $clause, $parent_query ) {
 505          $alias = false;
 506  
 507          // Sanity check. Only IN queries use the JOIN syntax.
 508          if ( ! isset( $clause['operator'] ) || 'IN' !== $clause['operator'] ) {
 509              return $alias;
 510          }
 511  
 512          // Since we're only checking IN queries, we're only concerned with OR relations.
 513          if ( ! isset( $parent_query['relation'] ) || 'OR' !== $parent_query['relation'] ) {
 514              return $alias;
 515          }
 516  
 517          $compatible_operators = array( 'IN' );
 518  
 519          foreach ( $parent_query as $sibling ) {
 520              if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) {
 521                  continue;
 522              }
 523  
 524              if ( empty( $sibling['alias'] ) || empty( $sibling['operator'] ) ) {
 525                  continue;
 526              }
 527  
 528              // The sibling must both have compatible operator to share its alias.
 529              if ( in_array( strtoupper( $sibling['operator'] ), $compatible_operators, true ) ) {
 530                  $alias = preg_replace( '/\W/', '_', $sibling['alias'] );
 531                  break;
 532              }
 533          }
 534  
 535          return $alias;
 536      }
 537  
 538      /**
 539       * Validates a single query.
 540       *
 541       * @since 3.2.0
 542       *
 543       * @param array $query The single query. Passed by reference.
 544       */
 545  	private function clean_query( &$query ) {
 546          if ( empty( $query['taxonomy'] ) ) {
 547              if ( 'term_taxonomy_id' !== $query['field'] ) {
 548                  $query = new WP_Error( 'invalid_taxonomy', __( 'Invalid taxonomy.' ) );
 549                  return;
 550              }
 551  
 552              // So long as there are shared terms, 'include_children' requires that a taxonomy is set.
 553              $query['include_children'] = false;
 554          } elseif ( ! taxonomy_exists( $query['taxonomy'] ) ) {
 555              $query = new WP_Error( 'invalid_taxonomy', __( 'Invalid taxonomy.' ) );
 556              return;
 557          }
 558  
 559          if ( 'slug' === $query['field'] || 'name' === $query['field'] ) {
 560              $query['terms'] = array_unique( (array) $query['terms'] );
 561          } else {
 562              $query['terms'] = wp_parse_id_list( $query['terms'] );
 563          }
 564  
 565          if ( is_taxonomy_hierarchical( $query['taxonomy'] ) && $query['include_children'] ) {
 566              $this->transform_query( $query, 'term_id' );
 567  
 568              if ( is_wp_error( $query ) ) {
 569                  return;
 570              }
 571  
 572              $children = array();
 573              foreach ( $query['terms'] as $term ) {
 574                  $children   = array_merge( $children, get_term_children( $term, $query['taxonomy'] ) );
 575                  $children[] = $term;
 576              }
 577              $query['terms'] = $children;
 578          }
 579  
 580          $this->transform_query( $query, 'term_taxonomy_id' );
 581      }
 582  
 583      /**
 584       * Transforms a single query, from one field to another.
 585       *
 586       * Operates on the `$query` object by reference. In the case of error,
 587       * `$query` is converted to a WP_Error object.
 588       *
 589       * @since 3.2.0
 590       *
 591       * @global wpdb $wpdb The WordPress database abstraction object.
 592       *
 593       * @param array  $query           The single query. Passed by reference.
 594       * @param string $resulting_field The resulting field. Accepts 'slug', 'name', 'term_taxonomy_id',
 595       *                                or 'term_id'. Default 'term_id'.
 596       */
 597  	public function transform_query( &$query, $resulting_field ) {
 598          if ( empty( $query['terms'] ) ) {
 599              return;
 600          }
 601  
 602          if ( $query['field'] == $resulting_field ) {
 603              return;
 604          }
 605  
 606          $resulting_field = sanitize_key( $resulting_field );
 607  
 608          // Empty 'terms' always results in a null transformation.
 609          $terms = array_filter( $query['terms'] );
 610          if ( empty( $terms ) ) {
 611              $query['terms'] = array();
 612              $query['field'] = $resulting_field;
 613              return;
 614          }
 615  
 616          $args = array(
 617              'get'                    => 'all',
 618              'number'                 => 0,
 619              'taxonomy'               => $query['taxonomy'],
 620              'update_term_meta_cache' => false,
 621              'orderby'                => 'none',
 622          );
 623  
 624          // Term query parameter name depends on the 'field' being searched on.
 625          switch ( $query['field'] ) {
 626              case 'slug':
 627                  $args['slug'] = $terms;
 628                  break;
 629              case 'name':
 630                  $args['name'] = $terms;
 631                  break;
 632              case 'term_taxonomy_id':
 633                  $args['term_taxonomy_id'] = $terms;
 634                  break;
 635              default:
 636                  $args['include'] = wp_parse_id_list( $terms );
 637                  break;
 638          }
 639  
 640          if ( ! is_taxonomy_hierarchical( $query['taxonomy'] ) ) {
 641              $args['number'] = count( $terms );
 642          }
 643  
 644          $term_query = new WP_Term_Query();
 645          $term_list  = $term_query->query( $args );
 646  
 647          if ( is_wp_error( $term_list ) ) {
 648              $query = $term_list;
 649              return;
 650          }
 651  
 652          if ( 'AND' === $query['operator'] && count( $term_list ) < count( $query['terms'] ) ) {
 653              $query = new WP_Error( 'inexistent_terms', __( 'Inexistent terms.' ) );
 654              return;
 655          }
 656  
 657          $query['terms'] = wp_list_pluck( $term_list, $resulting_field );
 658          $query['field'] = $resulting_field;
 659      }
 660  }


Generated: Wed Oct 9 01:00:02 2024 Cross-referenced by PHPXref 0.7.1