class-wp-tax-query.php 19 KB

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