[ 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 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                      if ( ! isset( $this->queried_terms[ $taxonomy ] ) ) {
 165                          $this->queried_terms[ $taxonomy ] = array();
 166                      }
 167  
 168                      /*
 169                       * Backward compatibility: Only store the first
 170                       * 'terms' and 'field' found for a given taxonomy.
 171                       */
 172                      if ( ! empty( $cleaned_clause['terms'] ) && ! isset( $this->queried_terms[ $taxonomy ]['terms'] ) ) {
 173                          $this->queried_terms[ $taxonomy ]['terms'] = $cleaned_clause['terms'];
 174                      }
 175  
 176                      if ( ! empty( $cleaned_clause['field'] ) && ! isset( $this->queried_terms[ $taxonomy ]['field'] ) ) {
 177                          $this->queried_terms[ $taxonomy ]['field'] = $cleaned_clause['field'];
 178                      }
 179                  }
 180  
 181                  // Otherwise, it's a nested query, so we recurse.
 182              } elseif ( is_array( $query ) ) {
 183                  $cleaned_subquery = $this->sanitize_query( $query );
 184  
 185                  if ( ! empty( $cleaned_subquery ) ) {
 186                      // All queries with children must have a relation.
 187                      if ( ! isset( $cleaned_subquery['relation'] ) ) {
 188                          $cleaned_subquery['relation'] = 'AND';
 189                      }
 190  
 191                      $cleaned_query[] = $cleaned_subquery;
 192                  }
 193              }
 194          }
 195  
 196          return $cleaned_query;
 197      }
 198  
 199      /**
 200       * Sanitizes a 'relation' operator.
 201       *
 202       * @since 4.1.0
 203       *
 204       * @param string $relation Raw relation key from the query argument.
 205       * @return string Sanitized relation. Either 'AND' or 'OR'.
 206       */
 207  	public function sanitize_relation( $relation ) {
 208          if ( 'OR' === strtoupper( $relation ) ) {
 209              return 'OR';
 210          } else {
 211              return 'AND';
 212          }
 213      }
 214  
 215      /**
 216       * Determines whether a clause is first-order.
 217       *
 218       * A "first-order" clause is one that contains any of the first-order
 219       * clause keys ('terms', 'taxonomy', 'include_children', 'field',
 220       * 'operator'). An empty clause also counts as a first-order clause,
 221       * for backward compatibility. Any clause that doesn't meet this is
 222       * determined, by process of elimination, to be a higher-order query.
 223       *
 224       * @since 4.1.0
 225       *
 226       * @param array $query Tax query arguments.
 227       * @return bool Whether the query clause is a first-order clause.
 228       */
 229  	protected static function is_first_order_clause( $query ) {
 230          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 ) );
 231      }
 232  
 233      /**
 234       * Generates SQL clauses to be appended to a main query.
 235       *
 236       * @since 3.1.0
 237       *
 238       * @param string $primary_table     Database table where the object being filtered is stored (eg wp_users).
 239       * @param string $primary_id_column ID column for the filtered object in $primary_table.
 240       * @return string[] {
 241       *     Array containing JOIN and WHERE SQL clauses to append to the main query.
 242       *
 243       *     @type string $join  SQL fragment to append to the main JOIN clause.
 244       *     @type string $where SQL fragment to append to the main WHERE clause.
 245       * }
 246       */
 247  	public function get_sql( $primary_table, $primary_id_column ) {
 248          $this->primary_table     = $primary_table;
 249          $this->primary_id_column = $primary_id_column;
 250  
 251          return $this->get_sql_clauses();
 252      }
 253  
 254      /**
 255       * Generates SQL clauses to be appended to a main query.
 256       *
 257       * Called by the public WP_Tax_Query::get_sql(), this method
 258       * is abstracted out to maintain parity with the other Query classes.
 259       *
 260       * @since 4.1.0
 261       *
 262       * @return string[] {
 263       *     Array containing JOIN and WHERE SQL clauses to append to the main query.
 264       *
 265       *     @type string $join  SQL fragment to append to the main JOIN clause.
 266       *     @type string $where SQL fragment to append to the main WHERE clause.
 267       * }
 268       */
 269  	protected function get_sql_clauses() {
 270          /*
 271           * $queries are passed by reference to get_sql_for_query() for recursion.
 272           * To keep $this->queries unaltered, pass a copy.
 273           */
 274          $queries = $this->queries;
 275          $sql     = $this->get_sql_for_query( $queries );
 276  
 277          if ( ! empty( $sql['where'] ) ) {
 278              $sql['where'] = ' AND ' . $sql['where'];
 279          }
 280  
 281          return $sql;
 282      }
 283  
 284      /**
 285       * Generates SQL clauses for a single query array.
 286       *
 287       * If nested subqueries are found, this method recurses the tree to
 288       * produce the properly nested SQL.
 289       *
 290       * @since 4.1.0
 291       *
 292       * @param array $query Query to parse (passed by reference).
 293       * @param int   $depth Optional. Number of tree levels deep we currently are.
 294       *                     Used to calculate indentation. Default 0.
 295       * @return string[] {
 296       *     Array containing JOIN and WHERE SQL clauses to append to a single query array.
 297       *
 298       *     @type string $join  SQL fragment to append to the main JOIN clause.
 299       *     @type string $where SQL fragment to append to the main WHERE clause.
 300       * }
 301       */
 302  	protected function get_sql_for_query( &$query, $depth = 0 ) {
 303          $sql_chunks = array(
 304              'join'  => array(),
 305              'where' => array(),
 306          );
 307  
 308          $sql = array(
 309              'join'  => '',
 310              'where' => '',
 311          );
 312  
 313          $indent = '';
 314          for ( $i = 0; $i < $depth; $i++ ) {
 315              $indent .= '  ';
 316          }
 317  
 318          foreach ( $query as $key => &$clause ) {
 319              if ( 'relation' === $key ) {
 320                  $relation = $query['relation'];
 321              } elseif ( is_array( $clause ) ) {
 322  
 323                  // This is a first-order clause.
 324                  if ( $this->is_first_order_clause( $clause ) ) {
 325                      $clause_sql = $this->get_sql_for_clause( $clause, $query );
 326  
 327                      $where_count = count( $clause_sql['where'] );
 328                      if ( ! $where_count ) {
 329                          $sql_chunks['where'][] = '';
 330                      } elseif ( 1 === $where_count ) {
 331                          $sql_chunks['where'][] = $clause_sql['where'][0];
 332                      } else {
 333                          $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )';
 334                      }
 335  
 336                      $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] );
 337                      // This is a subquery, so we recurse.
 338                  } else {
 339                      $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 );
 340  
 341                      $sql_chunks['where'][] = $clause_sql['where'];
 342                      $sql_chunks['join'][]  = $clause_sql['join'];
 343                  }
 344              }
 345          }
 346  
 347          // Filter to remove empties.
 348          $sql_chunks['join']  = array_filter( $sql_chunks['join'] );
 349          $sql_chunks['where'] = array_filter( $sql_chunks['where'] );
 350  
 351          if ( empty( $relation ) ) {
 352              $relation = 'AND';
 353          }
 354  
 355          // Filter duplicate JOIN clauses and combine into a single string.
 356          if ( ! empty( $sql_chunks['join'] ) ) {
 357              $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) );
 358          }
 359  
 360          // Generate a single WHERE clause with proper brackets and indentation.
 361          if ( ! empty( $sql_chunks['where'] ) ) {
 362              $sql['where'] = '( ' . "\n  " . $indent . implode( ' ' . "\n  " . $indent . $relation . ' ' . "\n  " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')';
 363          }
 364  
 365          return $sql;
 366      }
 367  
 368      /**
 369       * Generates SQL JOIN and WHERE clauses for a "first-order" query clause.
 370       *
 371       * @since 4.1.0
 372       *
 373       * @global wpdb $wpdb The WordPress database abstraction object.
 374       *
 375       * @param array $clause       Query clause (passed by reference).
 376       * @param array $parent_query Parent query array.
 377       * @return array {
 378       *     Array containing JOIN and WHERE SQL clauses to append to a first-order query.
 379       *
 380       *     @type string[] $join  Array of SQL fragments to append to the main JOIN clause.
 381       *     @type string[] $where Array of SQL fragments to append to the main WHERE clause.
 382       * }
 383       */
 384  	public function get_sql_for_clause( &$clause, $parent_query ) {
 385          global $wpdb;
 386  
 387          $sql = array(
 388              'where' => array(),
 389              'join'  => array(),
 390          );
 391  
 392          $join  = '';
 393          $where = '';
 394  
 395          $this->clean_query( $clause );
 396  
 397          if ( is_wp_error( $clause ) ) {
 398              return self::$no_results;
 399          }
 400  
 401          $terms    = $clause['terms'];
 402          $operator = strtoupper( $clause['operator'] );
 403  
 404          if ( 'IN' === $operator ) {
 405  
 406              if ( empty( $terms ) ) {
 407                  return self::$no_results;
 408              }
 409  
 410              $terms = implode( ',', $terms );
 411  
 412              /*
 413               * Before creating another table join, see if this clause has a
 414               * sibling with an existing join that can be shared.
 415               */
 416              $alias = $this->find_compatible_table_alias( $clause, $parent_query );
 417              if ( false === $alias ) {
 418                  $i     = count( $this->table_aliases );
 419                  $alias = $i ? 'tt' . $i : $wpdb->term_relationships;
 420  
 421                  // Store the alias as part of a flat array to build future iterators.
 422                  $this->table_aliases[] = $alias;
 423  
 424                  // Store the alias with this clause, so later siblings can use it.
 425                  $clause['alias'] = $alias;
 426  
 427                  $join .= " LEFT JOIN $wpdb->term_relationships";
 428                  $join .= $i ? " AS $alias" : '';
 429                  $join .= " ON ($this->primary_table.$this->primary_id_column = $alias.object_id)";
 430              }
 431  
 432              $where = "$alias.term_taxonomy_id $operator ($terms)";
 433  
 434          } elseif ( 'NOT IN' === $operator ) {
 435  
 436              if ( empty( $terms ) ) {
 437                  return $sql;
 438              }
 439  
 440              $terms = implode( ',', $terms );
 441  
 442              $where = "$this->primary_table.$this->primary_id_column NOT IN (
 443                  SELECT object_id
 444                  FROM $wpdb->term_relationships
 445                  WHERE term_taxonomy_id IN ($terms)
 446              )";
 447  
 448          } elseif ( 'AND' === $operator ) {
 449  
 450              if ( empty( $terms ) ) {
 451                  return $sql;
 452              }
 453  
 454              $num_terms = count( $terms );
 455  
 456              $terms = implode( ',', $terms );
 457  
 458              $where = "(
 459                  SELECT COUNT(1)
 460                  FROM $wpdb->term_relationships
 461                  WHERE term_taxonomy_id IN ($terms)
 462                  AND object_id = $this->primary_table.$this->primary_id_column
 463              ) = $num_terms";
 464  
 465          } elseif ( 'NOT EXISTS' === $operator || 'EXISTS' === $operator ) {
 466  
 467              $where = $wpdb->prepare(
 468                  "$operator (
 469                      SELECT 1
 470                      FROM $wpdb->term_relationships
 471                      INNER JOIN $wpdb->term_taxonomy
 472                      ON $wpdb->term_taxonomy.term_taxonomy_id = $wpdb->term_relationships.term_taxonomy_id
 473                      WHERE $wpdb->term_taxonomy.taxonomy = %s
 474                      AND $wpdb->term_relationships.object_id = $this->primary_table.$this->primary_id_column
 475                  )",
 476                  $clause['taxonomy']
 477              );
 478  
 479          }
 480  
 481          $sql['join'][]  = $join;
 482          $sql['where'][] = $where;
 483          return $sql;
 484      }
 485  
 486      /**
 487       * Identifies an existing table alias that is compatible with the current query clause.
 488       *
 489       * We avoid unnecessary table joins by allowing each clause to look for
 490       * an existing table alias that is compatible with the query that it
 491       * needs to perform.
 492       *
 493       * An existing alias is compatible if (a) it is a sibling of `$clause`
 494       * (ie, it's under the scope of the same relation), and (b) the combination
 495       * of operator and relation between the clauses allows for a shared table
 496       * join. In the case of WP_Tax_Query, this only applies to 'IN'
 497       * clauses that are connected by the relation 'OR'.
 498       *
 499       * @since 4.1.0
 500       *
 501       * @param array $clause       Query clause.
 502       * @param array $parent_query Parent query of $clause.
 503       * @return string|false Table alias if found, otherwise false.
 504       */
 505  	protected function find_compatible_table_alias( $clause, $parent_query ) {
 506          $alias = false;
 507  
 508          // Confidence check. Only IN queries use the JOIN syntax.
 509          if ( ! isset( $clause['operator'] ) || 'IN' !== $clause['operator'] ) {
 510              return $alias;
 511          }
 512  
 513          // Since we're only checking IN queries, we're only concerned with OR relations.
 514          if ( ! isset( $parent_query['relation'] ) || 'OR' !== $parent_query['relation'] ) {
 515              return $alias;
 516          }
 517  
 518          $compatible_operators = array( 'IN' );
 519  
 520          foreach ( $parent_query as $sibling ) {
 521              if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) {
 522                  continue;
 523              }
 524  
 525              if ( empty( $sibling['alias'] ) || empty( $sibling['operator'] ) ) {
 526                  continue;
 527              }
 528  
 529              // The sibling must both have compatible operator to share its alias.
 530              if ( in_array( strtoupper( $sibling['operator'] ), $compatible_operators, true ) ) {
 531                  $alias = preg_replace( '/\W/', '_', $sibling['alias'] );
 532                  break;
 533              }
 534          }
 535  
 536          return $alias;
 537      }
 538  
 539      /**
 540       * Validates a single query.
 541       *
 542       * @since 3.2.0
 543       *
 544       * @param array $query The single query. Passed by reference.
 545       */
 546  	private function clean_query( &$query ) {
 547          if ( empty( $query['taxonomy'] ) ) {
 548              if ( 'term_taxonomy_id' !== $query['field'] ) {
 549                  $query = new WP_Error( 'invalid_taxonomy', __( 'Invalid taxonomy.' ) );
 550                  return;
 551              }
 552  
 553              // So long as there are shared terms, 'include_children' requires that a taxonomy is set.
 554              $query['include_children'] = false;
 555          } elseif ( ! taxonomy_exists( $query['taxonomy'] ) ) {
 556              $query = new WP_Error( 'invalid_taxonomy', __( 'Invalid taxonomy.' ) );
 557              return;
 558          }
 559  
 560          if ( 'slug' === $query['field'] || 'name' === $query['field'] ) {
 561              $query['terms'] = array_unique( (array) $query['terms'] );
 562          } else {
 563              $query['terms'] = wp_parse_id_list( $query['terms'] );
 564          }
 565  
 566          if ( is_taxonomy_hierarchical( $query['taxonomy'] ) && $query['include_children'] ) {
 567              $this->transform_query( $query, 'term_id' );
 568  
 569              if ( is_wp_error( $query ) ) {
 570                  return;
 571              }
 572  
 573              $children = array();
 574              foreach ( $query['terms'] as $term ) {
 575                  $children   = array_merge( $children, get_term_children( $term, $query['taxonomy'] ) );
 576                  $children[] = $term;
 577              }
 578              $query['terms'] = $children;
 579          }
 580  
 581          $this->transform_query( $query, 'term_taxonomy_id' );
 582      }
 583  
 584      /**
 585       * Transforms a single query, from one field to another.
 586       *
 587       * Operates on the `$query` object by reference. In the case of error,
 588       * `$query` is converted to a WP_Error object.
 589       *
 590       * @since 3.2.0
 591       *
 592       * @param array  $query           The single query. Passed by reference.
 593       * @param string $resulting_field The resulting field. Accepts 'slug', 'name', 'term_taxonomy_id',
 594       *                                or 'term_id'. Default 'term_id'.
 595       */
 596  	public function transform_query( &$query, $resulting_field ) {
 597          if ( empty( $query['terms'] ) ) {
 598              return;
 599          }
 600  
 601          if ( $query['field'] === $resulting_field ) {
 602              return;
 603          }
 604  
 605          $resulting_field = sanitize_key( $resulting_field );
 606  
 607          // Empty 'terms' always results in a null transformation.
 608          $terms = array_filter( $query['terms'] );
 609          if ( empty( $terms ) ) {
 610              $query['terms'] = array();
 611              $query['field'] = $resulting_field;
 612              return;
 613          }
 614  
 615          $args = array(
 616              'get'                    => 'all',
 617              'number'                 => 0,
 618              'taxonomy'               => $query['taxonomy'],
 619              'update_term_meta_cache' => false,
 620              'orderby'                => 'none',
 621          );
 622  
 623          // Term query parameter name depends on the 'field' being searched on.
 624          switch ( $query['field'] ) {
 625              case 'slug':
 626                  $args['slug'] = $terms;
 627                  break;
 628              case 'name':
 629                  $args['name'] = $terms;
 630                  break;
 631              case 'term_taxonomy_id':
 632                  $args['term_taxonomy_id'] = $terms;
 633                  break;
 634              default:
 635                  $args['include'] = wp_parse_id_list( $terms );
 636                  break;
 637          }
 638  
 639          if ( ! is_taxonomy_hierarchical( $query['taxonomy'] ) ) {
 640              $args['number'] = count( $terms );
 641          }
 642  
 643          $term_query = new WP_Term_Query();
 644          $term_list  = $term_query->query( $args );
 645  
 646          if ( is_wp_error( $term_list ) ) {
 647              $query = $term_list;
 648              return;
 649          }
 650  
 651          if ( 'AND' === $query['operator'] && count( $term_list ) < count( $query['terms'] ) ) {
 652              $query = new WP_Error( 'inexistent_terms', __( 'Inexistent terms.' ) );
 653              return;
 654          }
 655  
 656          $query['terms'] = wp_list_pluck( $term_list, $resulting_field );
 657          $query['field'] = $resulting_field;
 658      }
 659  }


Generated : Tue Apr 16 08:20:01 2024 Cross-referenced by PHPXref