You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

666 lines
19 KiB

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