class-wp-tax-query.php 19 KB

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