class-wp-tax-query.php 19 KB

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