[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

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


Generated : Sun Sep 20 08:20:30 2026 Cross-referenced by PHPXref