| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sun Sep 20 08:20:30 2026 | Cross-referenced by PHPXref |