[ 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 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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated : Thu Nov 21 08:20:01 2024 | Cross-referenced by PHPXref |