PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Indexable / Term / Term.php

Term.php in ElasticPress 5.3.5, at includes/classes/Indexable/Term/Term.php

1,271 lines 32.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Term indexable
4 *
5 * @since 3.1
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Indexable\Term;
10
11 use WP_Term_Query;
12 use ElasticPress\Elasticsearch;
13 use ElasticPress\Indexable;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 // @codeCoverageIgnoreStart
17 exit; // Exit if accessed directly.
18 // @codeCoverageIgnoreEnd
19 }
20
21 /**
22 * Term indexable class
23 */
24 class Term extends Indexable {
25
26 /**
27 * Indexable slug
28 *
29 * @var string
30 * @since 3.1
31 */
32 public $slug = 'term';
33
34 /**
35 * Flag to indicate if the indexable has support for
36 * `id_range` pagination method during a sync.
37 *
38 * @var boolean
39 * @since 5.2.0
40 */
41 public $support_indexing_advanced_pagination = true;
42
43 /**
44 * Instantiate the indexable SyncManager and QueryIntegration, the main responsibles for the WP integration.
45 *
46 * @since 4.5.0
47 * @return void
48 */
49 public function setup() {
50 $this->labels = [
51 'plural' => esc_html__( 'Terms', 'elasticpress' ),
52 'singular' => esc_html__( 'Term', 'elasticpress' ),
53 ];
54
55 $this->sync_manager = new SyncManager( $this->slug );
56 $this->query_integration = new QueryIntegration( $this->slug );
57 }
58
59 /**
60 * Format query vars into ES query
61 *
62 * @param array $query_vars WP_Term_Query args.
63 * @since 3.1
64 * @return array
65 */
66 public function format_args( $query_vars ) {
67 $query_vars = $this->sanitize_query_vars( $query_vars );
68
69 $formatted_args = [
70 'from' => $this->parse_from( $query_vars ),
71 'size' => $this->parse_size( $query_vars ),
72 ];
73
74 $formatted_args = $this->maybe_orderby( $formatted_args, $query_vars );
75
76 $filters = $this->parse_filters( $query_vars );
77 if ( ! empty( $filters ) ) {
78 $formatted_args['post_filter'] = $filters;
79 }
80
81 $formatted_args = $this->maybe_set_search_fields( $formatted_args, $query_vars );
82 $formatted_args = $this->maybe_set_fields( $formatted_args, $query_vars );
83
84 /**
85 * Filter full Elasticsearch query for Terms indexable
86 *
87 * @hook ep_term_formatted_args
88 * @param {array} $query Elasticsearch query
89 * @param {array} $query_vars Query variables
90 * @since 3.4
91 * @return {array} New query
92 */
93 return apply_filters( 'ep_term_formatted_args', $formatted_args, $query_vars );
94 }
95
96 /**
97 * Generate the mapping array
98 *
99 * @since 3.6.0
100 * @return array
101 */
102 public function generate_mapping() {
103 $es_version = Elasticsearch::factory()->get_elasticsearch_version();
104
105 if ( empty( $es_version ) ) {
106 $es_version = apply_filters( 'ep_fallback_elasticsearch_version', '2.0' );
107 }
108 $es_version = (string) $es_version;
109
110 $mapping_file = '7-0.php';
111
112 if ( version_compare( $es_version, '7.0', '<' ) ) {
113 $mapping_file = 'initial.php';
114 }
115
116 /**
117 * Filter mapping file for Terms indexable
118 *
119 * @hook ep_term_mapping_file
120 * @param {string} $file File name
121 * @since 3.4
122 * @return {string} New file name
123 */
124 $mapping = require apply_filters( 'ep_term_mapping_file', __DIR__ . '/../../../mappings/term/' . $mapping_file );
125
126 /**
127 * Filter full Elasticsearch query for Terms indexable
128 *
129 * @hook ep_term_mapping
130 * @param {array} $mapping Elasticsearch mapping
131 * @since 3.4
132 * @return {array} New mapping
133 */
134 $mapping = apply_filters( 'ep_term_mapping', $mapping );
135
136 return $mapping;
137 }
138
139 /**
140 * Prepare a term document for indexing
141 *
142 * @param int $term_id Term ID
143 * @since 3.1
144 * @return bool|array
145 */
146 public function prepare_document( $term_id ) {
147 $term = get_term( $term_id );
148
149 if ( ! $term || ! is_a( $term, 'WP_Term' ) ) {
150 return false;
151 }
152
153 $term_args = [
154 'term_id' => $term->term_id,
155 'ID' => $term->term_id,
156 'name' => $term->name,
157 'slug' => $term->slug,
158 'term_group' => $term->group,
159 'term_taxonomy_id' => $term->term_taxonomy_id,
160 'taxonomy' => $term->taxonomy,
161 'description' => $term->description,
162 'parent' => $term->parent,
163 'count' => $term->count,
164 'meta' => $this->prepare_meta_types( $this->prepare_meta( $term->term_id ) ),
165 'hierarchy' => $this->prepare_term_hierarchy( $term->term_id, $term->taxonomy ),
166 'object_ids' => $this->prepare_object_ids( $term->term_id, $term->taxonomy ),
167 ];
168
169 /**
170 * Filter term fields pre-sync
171 *
172 * @hook ep_term_sync_args
173 * @param {array} $term_args Current term fields
174 * @param {int} $term_id Term ID
175 * @since 3.4
176 * @return {array} New fields
177 */
178 $term_args = apply_filters( 'ep_term_sync_args', $term_args, $term_id );
179
180 return $term_args;
181 }
182
183 /**
184 * Query DB for terms
185 *
186 * @param array $args Query arguments
187 * @since 3.1
188 * @return array
189 */
190 public function query_db( $args ) {
191 $defaults = [
192 'number' => $this->get_bulk_items_per_page(),
193 'offset' => 0,
194 'orderby' => 'id',
195 'order' => 'desc',
196 'taxonomy' => $this->get_indexable_taxonomies(),
197 'hide_empty' => false,
198 'hierarchical' => false,
199 'update_term_meta_cache' => false,
200 'cache_results' => false,
201 'ep_indexing_advanced_pagination' => true,
202 ];
203
204 if ( isset( $args['per_page'] ) ) {
205 $args['number'] = $args['per_page'];
206 }
207
208 if ( isset( $args['include'] ) ) {
209 $args['include'] = $args['include'];
210 }
211
212 if ( isset( $args['exclude'] ) ) {
213 $args['exclude'] = $args['exclude'];
214 }
215
216 /**
217 * Filter database arguments for term query
218 *
219 * @hook ep_term_query_db_args
220 * @param {array} $args Query arguments based to WP_Term_Query
221 * @since 3.4
222 * @return {array} New arguments
223 */
224 $args = apply_filters( 'ep_term_query_db_args', wp_parse_args( $args, $defaults ) );
225
226 $all_query_args = $args;
227
228 unset( $all_query_args['number'] );
229 unset( $all_query_args['offset'] );
230 unset( $all_query_args['fields'] );
231
232 if ( isset( $args['include'] ) || 0 < $args['offset'] ) {
233 // Disable advanced pagination. Not useful if only indexing specific IDs.
234 $args['ep_indexing_advanced_pagination'] = false;
235 }
236
237 // Explicitly set the orderby to ID to prevent accidental modifications by other code.
238 add_filter( 'terms_clauses', [ $this, 'set_orderby' ], 9999, 3 );
239
240 // Enforce the following query args during advanced pagination to ensure things work correctly.
241 if ( $args['ep_indexing_advanced_pagination'] ) {
242 $args = array_merge(
243 $args,
244 [
245 'suppress_filters' => false,
246 'orderby' => 'ID',
247 'order' => 'DESC',
248 'paged' => 1,
249 'offset' => 0,
250 ]
251 );
252 add_filter( 'terms_clauses', [ $this, 'bulk_indexing_filter_terms_where' ], 9999, 3 );
253
254 $query = new WP_Term_Query( $args );
255 $total_objects = $this->get_total_objects_for_query( $args );
256
257 remove_filter( 'terms_clauses', [ $this, 'bulk_indexing_filter_terms_where' ], 9999, 3 );
258 } else {
259
260 /**
261 * Filter database arguments for term count query
262 *
263 * @hook ep_term_all_query_db_args
264 * @param {array} $args Query arguments based to `wp_count_terms()`
265 * @since 3.4
266 * @return {array} New arguments
267 */
268 $total_objects = wp_count_terms( apply_filters( 'ep_term_all_query_db_args', $all_query_args, $args ) );
269 $total_objects = ! is_wp_error( $total_objects ) ? (int) $total_objects : 0;
270
271 if ( ! empty( $args['offset'] ) ) {
272 if ( (int) $args['offset'] >= $total_objects ) {
273 $total_objects = 0;
274 }
275 }
276
277 $query = new WP_Term_Query( $args );
278 }
279
280 remove_filter( 'terms_clauses', [ $this, 'set_orderby' ], 9999, 3 );
281
282 if ( is_array( $query->terms ) ) {
283 array_walk( $query->terms, array( $this, 'remap_terms' ) );
284 }
285
286 return [
287 'objects' => $query->terms,
288 'total_objects' => $total_objects,
289 ];
290 }
291
292 /**
293 * Filters the WHERE clause of the SQL query used for bulk indexing terms by modifying it to include a range of
294 * comment IDs based on advanced pagination parameters.
295 *
296 * @param array $clauses Associative array of the clauses for the query.
297 * @param array $taxonomies An array of taxonomy names.
298 * @param array $args An array of term query arguments.
299 *
300 * @return array The modified SQL WHERE clauses.
301 */
302 public function bulk_indexing_filter_terms_where( $clauses, $taxonomies, $args ) {
303 $using_advanced_pagination = $args['ep_indexing_advanced_pagination'] ?? false;
304
305 if ( $using_advanced_pagination ) {
306 $requested_upper_limit_id = $args['ep_indexing_upper_limit_object_id'] ?? PHP_INT_MAX;
307 $requested_lower_limit_object_id = $args['ep_indexing_lower_limit_object_id'] ?? 0;
308 $last_processed_id = $args['ep_indexing_last_processed_object_id'] ?? null;
309
310 // On the first loopthrough we begin with the requested upper limit ID. Afterwards, use the last processed ID to paginate.
311 $upper_limit_range_object_id = $requested_upper_limit_id;
312 if ( is_numeric( $last_processed_id ) ) {
313 $upper_limit_range_object_id = $last_processed_id - 1;
314 }
315
316 // Sanitize. Abort if unexpected data at this point.
317 if ( ! is_numeric( $upper_limit_range_object_id ) || ! is_numeric( $requested_lower_limit_object_id ) ) {
318 return $clauses;
319 }
320
321 $range = [
322 'upper_limit' => "t.term_id <= {$upper_limit_range_object_id}",
323 'lower_limit' => "t.term_id >= {$requested_lower_limit_object_id}",
324 ];
325
326 // Skip the end range if it's unnecessary.
327 $skip_ending_range = 0 === $requested_lower_limit_object_id;
328 $where = $clauses['where'];
329 $where = $skip_ending_range ? " {$range['upper_limit']} AND {$where}" : " {$range['upper_limit']} AND {$range['lower_limit']} AND {$where}";
330
331 $clauses['where'] = $where;
332 }
333
334 return $clauses;
335 }
336
337 /**
338 * Get the total number of terms for a given query.
339 *
340 * @param array $query_args The query args.
341 * @return int The total number of terms.
342 */
343 protected function get_total_objects_for_query( $query_args ) {
344 static $object_counts = [];
345
346 // Reset the pagination-related args for optimal caching.
347 $normalized_query_args = array_merge(
348 $query_args,
349 [
350 'offset' => 0,
351 'paged' => 1,
352 'posts_per_page' => 1,
353 'no_found_rows' => false,
354 'ep_indexing_last_processed_object_id' => null,
355 ]
356 );
357
358 $cache_key = md5( get_current_blog_id() . wp_json_encode( $normalized_query_args ) );
359 if ( ! isset( $object_counts[ $cache_key ] ) ) {
360 $object_counts[ $cache_key ] = wp_count_terms( $normalized_query_args );
361 }
362
363 return $object_counts[ $cache_key ];
364 }
365
366 /**
367 * Returns indexable taxonomies for the current site
368 *
369 * @since 3.1
370 * @return mixed|void
371 */
372 public function get_indexable_taxonomies() {
373 $taxonomies = get_taxonomies( [], 'objects' );
374 $public_taxonomies = [];
375
376 foreach ( $taxonomies as $taxonomy ) {
377 if ( $taxonomy->public || $taxonomy->publicly_queryable ) {
378 $public_taxonomies[] = $taxonomy->name;
379 }
380 }
381
382 /**
383 * Filter indexable taxonomies for Terms indexable
384 *
385 * @hook ep_indexable_taxonomies
386 * @param {array} $public_taxonomies Taxonomies
387 * @since 3.4
388 * @return {array} New taxonomies array
389 */
390 return apply_filters( 'ep_indexable_taxonomies', $public_taxonomies );
391 }
392
393 /**
394 * Rebuild our term object to match the fields we need.
395 *
396 * In particular, result of WP_Term_Query does not
397 * include an "id" field, which our index command
398 * expects.
399 *
400 * @param object $value Term object
401 * @since 3.1
402 * @return void Returns by reference
403 */
404 public function remap_terms( &$value ) {
405 $value = (object) array(
406 'ID' => $value->term_id,
407 'term_id' => $value->term_id,
408 'name' => $value->name,
409 'slug' => $value->slug,
410 'term_group' => $value->term_group,
411 'term_taxonomy_id' => $value->term_taxonomy_id,
412 'taxonomy' => $value->taxonomy,
413 'description' => $value->description,
414 'parent' => $value->parent,
415 'count' => $value->count,
416 );
417 }
418
419 /**
420 * Prepare meta to send to ES
421 *
422 * @param int $term_id Term ID
423 * @since 3.1
424 * @return array
425 */
426 public function prepare_meta( $term_id ) {
427 $meta = (array) get_term_meta( $term_id );
428
429 if ( empty( $meta ) ) {
430 return [];
431 }
432
433 $prepared_meta = [];
434
435 /**
436 * Filter index-able private meta
437 *
438 * Allows for specifying private meta keys that may be indexed in the same manner as public meta keys.
439 *
440 * @since 3.4
441 * @hook ep_prepare_term_meta_allowed_protected_keys
442 * @param {array} $allowed_protected_keys Array of index-able private meta keys.
443 * @param {int} $term_id Term ID.
444 * @return {array} New meta keys
445 */
446 $allowed_protected_keys = apply_filters( 'ep_prepare_term_meta_allowed_protected_keys', [], $term_id );
447
448 /**
449 * Filter non-indexed public meta
450 *
451 * Allows for specifying public meta keys that should be excluded from the ElasticPress index.
452 *
453 * @since 3.4
454 * @hook ep_prepare_term_meta_excluded_public_keys
455 * @param {array} $public_keys Array of public meta keys to exclude from index.
456 * @param {int} $term_id Term ID.
457 * @return {array} New keys
458 */
459 $excluded_public_keys = apply_filters(
460 'ep_prepare_term_meta_excluded_public_keys',
461 [
462 'session_tokens',
463 ],
464 $term_id
465 );
466
467 foreach ( $meta as $key => $value ) {
468
469 $allow_index = false;
470
471 if ( is_protected_meta( $key ) ) {
472
473 if ( true === $allowed_protected_keys || in_array( $key, $allowed_protected_keys, true ) ) {
474 $allow_index = true;
475 }
476 } elseif ( true !== $excluded_public_keys && ! in_array( $key, $excluded_public_keys, true ) ) {
477
478 $allow_index = true;
479 }
480
481 /**
482 * Filter kill switch for any term meta
483 *
484 * @since 3.4
485 * @hook ep_prepare_term_meta_whitelist_key
486 * @param {boolean} $index_key Whether to index key or not
487 * @param {string} $key Key name
488 * @param {int} $term_id Term ID.
489 * @return {boolean} New index value
490 */
491 if ( true === $allow_index || apply_filters( 'ep_prepare_term_meta_whitelist_key', false, $key, $term_id ) ) {
492 $prepared_meta[ $key ] = maybe_unserialize( $value );
493 }
494 }
495
496 return $prepared_meta;
497 }
498
499 /**
500 * Prepare term hierarchy to send to ES
501 *
502 * @param int $term_id Term ID.
503 * @param string $taxonomy Term taxonomy.
504 * @since 3.1
505 * @return array
506 */
507 public function prepare_term_hierarchy( $term_id, $taxonomy ) {
508 $hierarchy = [];
509 $children = get_term_children( $term_id, $taxonomy );
510 $ancestors = get_ancestors( $term_id, $taxonomy, 'taxonomy' );
511
512 if ( ! empty( $children ) && ! is_wp_error( $children ) ) {
513 $hierarchy['children']['terms'] = $children;
514 $children_count = 0;
515
516 foreach ( $children as $child_term_id ) {
517 $child_term = get_term( $child_term_id );
518 $children_count += (int) $child_term->count;
519 }
520
521 $hierarchy['children']['count'] = $children_count;
522 } else {
523 $hierarchy['children']['terms'] = 0;
524 $hierarchy['children']['count'] = 0;
525 }
526
527 if ( ! empty( $ancestors ) ) {
528 $hierarchy['ancestors']['terms'] = $ancestors;
529 } else {
530 $hierarchy['ancestors']['terms'] = 0;
531 }
532
533 return $hierarchy;
534 }
535
536 /**
537 * Prepare object IDs to send to ES
538 *
539 * @param int $term_id Term ID.
540 * @param string $taxonomy Term taxonomy.
541 * @since 3.1
542 * @return array
543 */
544 public function prepare_object_ids( $term_id, $taxonomy ) {
545 $ids = [];
546 $object_ids = get_objects_in_term( [ $term_id ], [ $taxonomy ] );
547
548 if ( ! empty( $object_ids ) && ! is_wp_error( $object_ids ) ) {
549 $ids['value'] = array_map( 'absint', array_values( $object_ids ) );
550 } else {
551 $ids['value'] = 0;
552 }
553
554 return $ids;
555 }
556
557 /**
558 * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
559 *
560 * @access protected
561 *
562 * @param string $order The 'order' query variable.
563 * @since 3.1
564 * @return string The sanitized 'order' query variable.
565 */
566 protected function parse_order( $order ) {
567 if ( ! is_string( $order ) || empty( $order ) ) {
568 return 'desc';
569 }
570
571 if ( 'ASC' === strtoupper( $order ) ) {
572 return 'asc';
573 } else {
574 return 'desc';
575 }
576 }
577
578 /**
579 * Convert the alias to a properly-prefixed sort value.
580 *
581 * @access protected
582 *
583 * @param string $orderby Alias or path for the field to order by.
584 * @param string $order Order direction
585 * @param array $args Query args
586 * @since 3.1
587 * @return array
588 */
589 protected function parse_orderby( $orderby, $order, $args ) {
590 $sort = [];
591
592 if ( empty( $orderby ) ) {
593 return $sort;
594 }
595
596 $from_to = [
597 'slug' => 'slug.raw',
598 'id' => 'term_id',
599 'description' => 'description.sortable',
600 ];
601
602 if ( in_array( $orderby, [ 'meta_value', 'meta_value_num' ], true ) ) {
603 if ( empty( $args['meta_key'] ) ) {
604 return $sort;
605 } else {
606 $from_to['meta_value'] = 'meta.' . $args['meta_key'] . '.value';
607 $from_to['meta_value_num'] = 'meta.' . $args['meta_key'] . '.long';
608 }
609 }
610
611 if ( 'name' === $orderby ) {
612 $es_version = Elasticsearch::factory()->get_elasticsearch_version();
613 $from_to['name'] = version_compare( (string) $es_version, '7.0', '<' ) ? 'name.raw' : 'name.sortable';
614 }
615
616 $orderby = $from_to[ $orderby ] ?? $orderby;
617
618 $sort[] = array(
619 $orderby => array(
620 'order' => $order,
621 ),
622 );
623
624 return $sort;
625 }
626
627 /**
628 * Sanitize WP_Term_Query arguments to be used to create the ES query.
629 *
630 * @since 5.1.0
631 * @param array $query_vars WP_Term_Query arguments
632 * @return array
633 */
634 protected function sanitize_query_vars( $query_vars ) {
635 if ( ! empty( $query_vars['get'] ) && 'all' === $query_vars['get'] ) {
636 $query_vars['childless'] = false;
637 $query_vars['child_of'] = 0;
638 $query_vars['hide_empty'] = false;
639 $query_vars['hierarchical'] = false;
640 $query_vars['pad_counts'] = false;
641 }
642
643 $query_vars['taxonomy'] = ( ! empty( $query_vars['taxonomy'] ) ) ?
644 (array) $query_vars['taxonomy'] :
645 [];
646
647 return $query_vars;
648 }
649
650 /**
651 * Parse the `from` clause of the ES Query.
652 *
653 * @since 5.1.0
654 * @param array $query_vars WP_Term_Query arguments
655 * @return int
656 */
657 protected function parse_from( $query_vars ) {
658 return ( isset( $query_vars['offset'] ) ) ? (int) $query_vars['offset'] : 0;
659 }
660
661 /**
662 * Parse the `size` clause of the ES Query.
663 *
664 * @since 5.1.0
665 * @param array $query_vars WP_Term_Query arguments
666 * @return int
667 */
668 protected function parse_size( $query_vars ) {
669 if ( ! empty( $query_vars['number'] ) ) {
670 $number = (int) $query_vars['number'];
671 } else {
672 /**
673 * Set the maximum results window size.
674 *
675 * The request will return a HTTP 500 Internal Error if the size of the
676 * request is larger than the [index.max_result_window] parameter in ES.
677 * See the scroll api for a more efficient way to request large data sets.
678 *
679 * @return int The max results window size.
680 *
681 * @since 2.3.0
682 */
683 $number = apply_filters( 'ep_max_results_window', 10000 );
684 }
685
686 return $number;
687 }
688
689 /**
690 * Parse the order of results in the ES query.
691 *
692 * @since 5.1.0
693 * @param array $formatted_args Formatted Elasticsearch query
694 * @param array $query_vars WP_Term_Query arguments
695 * @return array
696 */
697 protected function maybe_orderby( $formatted_args, $query_vars ) {
698 // Set sort order, default is 'ASC'.
699 if ( ! empty( $query_vars['order'] ) ) {
700 $order = $this->parse_order( $query_vars['order'] );
701 } else {
702 $order = 'desc';
703 }
704
705 // Set orderby, default is 'name'.
706 if ( empty( $query_vars['orderby'] ) ) {
707 $query_vars['orderby'] = 'name';
708 }
709
710 // Set sort type.
711 $formatted_args['sort'] = $this->parse_orderby( $query_vars['orderby'], $order, $query_vars );
712
713 return $formatted_args;
714 }
715
716 /**
717 * Based on WP_Term_Query arguments, parses the various filters that could be applied into the ES query.
718 *
719 * @since 5.1.0
720 * @param array $query_vars WP_Term_Query arguments
721 * @return array
722 */
723 protected function parse_filters( $query_vars ) {
724 $filters = [
725 'taxonomy' => $this->parse_taxonomy( $query_vars ),
726 'object_ids' => $this->parse_object_ids( $query_vars ),
727 'include' => $this->parse_include( $query_vars ),
728 'exclude' => $this->parse_exclude( $query_vars ),
729 'exclude_tree' => $this->parse_exclude_tree( $query_vars ),
730 'name' => $this->parse_name( $query_vars ),
731 'slug' => $this->parse_slug( $query_vars ),
732 'term_taxonomy_id' => $this->parse_term_taxonomy_id( $query_vars ),
733 'hierarchical_hide_empty' => $this->parse_hierarchical_hide_empty( $query_vars ),
734 'child_of' => $this->parse_child_of( $query_vars ),
735 'parent' => $this->parse_parent( $query_vars ),
736 'childless' => $this->parse_childless( $query_vars ),
737 'meta_query' => $this->parse_meta_queries( $query_vars ),
738 ];
739
740 $filters = array_values( array_filter( $filters ) );
741
742 if ( ! empty( $filters ) ) {
743 $filters = [
744 'bool' => [
745 'must' => $filters,
746 ],
747 ];
748 }
749
750 return $filters;
751 }
752
753 /**
754 * Parse the `taxonomy` WP Term Query arg and transform it into an ES query clause.
755 *
756 * @since 5.1.0
757 * @param array $query_vars WP_Term_Query arguments
758 * @return array
759 */
760 protected function parse_taxonomy( $query_vars ) {
761 if ( empty( $query_vars['taxonomy'] ) ) {
762 return [];
763 }
764
765 if ( count( $query_vars['taxonomy'] ) < 2 ) {
766 return [
767 'term' => [
768 'taxonomy.raw' => $query_vars['taxonomy'][0],
769 ],
770 ];
771 }
772
773 return [
774 'terms' => [
775 'taxonomy.raw' => $query_vars['taxonomy'],
776 ],
777 ];
778 }
779
780 /**
781 * Parse the `object_ids` WP Term Query arg and transform it into an ES query clause.
782 *
783 * @since 5.1.0
784 * @param array $query_vars WP_Term_Query arguments
785 * @return array
786 */
787 protected function parse_object_ids( $query_vars ) {
788 if ( empty( $query_vars['object_ids'] ) ) {
789 return [];
790 }
791
792 return [
793 'bool' => [
794 'must' => [
795 'terms' => [
796 'object_ids.value' => (array) $query_vars['object_ids'],
797 ],
798 ],
799 ],
800 ];
801 }
802
803 /**
804 * Parse the `include` WP Term Query arg and transform it into an ES query clause.
805 *
806 * @since 5.1.0
807 * @param array $query_vars WP_Term_Query arguments
808 * @return array
809 */
810 protected function parse_include( $query_vars ) {
811 if ( empty( $query_vars['include'] ) ) {
812 return [];
813 }
814
815 return [
816 'bool' => [
817 'must' => [
818 'terms' => [
819 'term_id' => array_values( (array) $query_vars['include'] ),
820 ],
821 ],
822 ],
823 ];
824 }
825
826 /**
827 * Parse the `exclude` WP Term Query arg and transform it into an ES query clause.
828 *
829 * @since 5.1.0
830 * @param array $query_vars WP_Term_Query arguments
831 * @return array
832 */
833 protected function parse_exclude( $query_vars ) {
834 if ( ! empty( $query_vars['include'] ) || empty( $query_vars['exclude'] ) ) {
835 return [];
836 }
837
838 return [
839 'bool' => [
840 'must_not' => [
841 'terms' => [
842 'term_id' => array_values( (array) $query_vars['exclude'] ),
843 ],
844 ],
845 ],
846 ];
847 }
848
849 /**
850 * Parse the `exclude_tree` WP Term Query arg and transform it into an ES query clause.
851 *
852 * @since 5.1.0
853 * @param array $query_vars WP_Term_Query arguments
854 * @return array
855 */
856 protected function parse_exclude_tree( $query_vars ) {
857 if ( ! empty( $query_vars['include'] ) || empty( $query_vars['exclude_tree'] ) ) {
858 return [];
859 }
860
861 return [
862 'bool' => [
863 'must_not' => [
864 [
865 'terms' => [
866 'term_id' => array_values( (array) $query_vars['exclude_tree'] ),
867 ],
868 ],
869 [
870 'terms' => [
871 'parent' => array_values( (array) $query_vars['exclude_tree'] ),
872 ],
873 ],
874 ],
875 ],
876 ];
877 }
878
879 /**
880 * Parse the `name` WP Term Query arg and transform it into an ES query clause.
881 *
882 * @since 5.1.0
883 * @param array $query_vars WP_Term_Query arguments
884 * @return array
885 */
886 protected function parse_name( $query_vars ) {
887 if ( empty( $query_vars['name'] ) ) {
888 return [];
889 }
890
891 return [
892 'terms' => [
893 'name.raw' => (array) $query_vars['name'],
894 ],
895 ];
896 }
897
898 /**
899 * Parse the `slug` WP Term Query arg and transform it into an ES query clause.
900 *
901 * @since 5.1.0
902 * @param array $query_vars WP_Term_Query arguments
903 * @return array
904 */
905 protected function parse_slug( $query_vars ) {
906 if ( empty( $query_vars['slug'] ) ) {
907 return [];
908 }
909
910 $query_vars['slug'] = (array) $query_vars['slug'];
911 $query_vars['slug'] = array_map( 'sanitize_title', $query_vars['slug'] );
912
913 return [
914 'terms' => [
915 'slug.raw' => (array) $query_vars['slug'],
916 ],
917 ];
918 }
919
920 /**
921 * Parse the `term_taxonomy_id` WP Term Query arg and transform it into an ES query clause.
922 *
923 * @since 5.1.0
924 * @param array $query_vars WP_Term_Query arguments
925 * @return array
926 */
927 protected function parse_term_taxonomy_id( $query_vars ) {
928 if ( empty( $query_vars['term_taxonomy_id'] ) ) {
929 return [];
930 }
931
932 return [
933 'bool' => [
934 'must' => [
935 'terms' => [
936 'term_taxonomy_id' => array_values( (array) $query_vars['term_taxonomy_id'] ),
937 ],
938 ],
939 ],
940 ];
941 }
942
943 /**
944 * Parse the `hide_empty` and `hierarchical` WP Term Query args and transform them into ES query clauses.
945 *
946 * `hierarchical` needs to work in conjunction with `hide_empty`, as per WP docs:
947 * > `hierarchical`: Whether to include terms that have non-empty descendants (even if $hide_empty is set to true).
948 *
949 * In summary:
950 * - hide_empty AND hierarchical: count > 1 OR hierarchy.children > 1
951 * - hide_empty AND NOT hierarchical: count > 1 (ignore hierarchy.children)
952 * - NOT hide_empty (AND hierarchical): there is no need to limit the query
953 *
954 * @see https://developer.wordpress.org/reference/classes/WP_Term_Query/__construct/
955 * @since 5.1.0
956 * @param array $query_vars WP_Term_Query arguments
957 * @return array
958 */
959 protected function parse_hierarchical_hide_empty( $query_vars ) {
960 $hide_empty = isset( $query_vars['hide_empty'] ) ? $query_vars['hide_empty'] : '';
961 if ( ! $hide_empty ) {
962 return [];
963 }
964
965 $hierarchical = isset( $query_vars['hierarchical'] ) ? $query_vars['hierarchical'] : '';
966 if ( ! $hierarchical ) {
967 return [
968 'range' => [
969 'count' => [
970 'gte' => 1,
971 ],
972 ],
973 ];
974 }
975
976 return [
977 'bool' => [
978 'should' => [
979 [
980 'range' => [
981 'count' => [
982 'gte' => 1,
983 ],
984 ],
985 ],
986 [
987 'range' => [
988 'hierarchy.children.count' => [
989 'gte' => 1,
990 ],
991 ],
992 ],
993 ],
994 ],
995 ];
996 }
997
998 /**
999 * Parse the `child_of` WP Term Query arg and transform it into an ES query clause.
1000 *
1001 * @since 5.1.0
1002 * @param array $query_vars WP_Term_Query arguments
1003 * @return array
1004 */
1005 protected function parse_child_of( $query_vars ) {
1006 if ( empty( $query_vars['child_of'] ) || count( $query_vars['taxonomy'] ) > 1 ) {
1007 return [];
1008 }
1009
1010 return [
1011 'bool' => [
1012 'must' => [
1013 'match_phrase' => [
1014 'hierarchy.ancestors.terms' => (int) $query_vars['child_of'],
1015 ],
1016 ],
1017 ],
1018 ];
1019 }
1020
1021 /**
1022 * Parse the `parent` WP Term Query arg and transform it into an ES query clause.
1023 *
1024 * @since 5.1.0
1025 * @param array $query_vars WP_Term_Query arguments
1026 * @return array
1027 */
1028 protected function parse_parent( $query_vars ) {
1029 if ( ! isset( $query_vars['parent'] ) || '' === $query_vars['parent'] ) {
1030 return [];
1031 }
1032
1033 return [
1034 'bool' => [
1035 'must' => [
1036 'term' => [
1037 'parent' => (int) $query_vars['parent'],
1038 ],
1039 ],
1040 ],
1041 ];
1042 }
1043
1044 /**
1045 * Parse the `childless` WP Term Query arg and transform it into an ES query clause.
1046 *
1047 * @since 5.1.0
1048 * @param array $query_vars WP_Term_Query arguments
1049 * @return array
1050 */
1051 protected function parse_childless( $query_vars ) {
1052 if ( empty( $query_vars['childless'] ) ) {
1053 return [];
1054 }
1055
1056 return [
1057 'bool' => [
1058 'must' => [
1059 'term' => [
1060 'hierarchy.children.terms' => 0,
1061 ],
1062 ],
1063 ],
1064 ];
1065 }
1066
1067 /**
1068 * Parse WP Term Query meta queries and transform them into ES query clauses.
1069 *
1070 * @since 5.1.0
1071 * @param array $query_vars WP_Term_Query arguments
1072 * @return array
1073 */
1074 protected function parse_meta_queries( $query_vars ) {
1075 $meta_queries = [];
1076 /**
1077 * Support `meta_key`, `meta_value`, and `meta_compare` query args
1078 */
1079 if ( ! empty( $query_vars['meta_key'] ) ) {
1080 $meta_query_array = [
1081 'key' => $query_vars['meta_key'],
1082 ];
1083
1084 if ( isset( $query_vars['meta_value'] ) && '' !== $query_vars['meta_value'] ) {
1085 $meta_query_array['value'] = $query_vars['meta_value'];
1086 }
1087
1088 if ( isset( $query_vars['meta_compare'] ) ) {
1089 $meta_query_array['compare'] = $query_vars['meta_compare'];
1090 }
1091
1092 $meta_queries[] = $meta_query_array;
1093 }
1094
1095 /**
1096 * Support 'meta_query' query var.
1097 */
1098 if ( ! empty( $query_vars['meta_query'] ) ) {
1099 $meta_queries = array_merge( $meta_queries, $query_vars['meta_query'] );
1100 }
1101
1102 if ( ! empty( $meta_queries ) ) {
1103 $built_meta_queries = $this->build_meta_query( $meta_queries );
1104
1105 if ( $built_meta_queries ) {
1106 return $built_meta_queries;
1107 }
1108 }
1109
1110 return [];
1111 }
1112
1113 /**
1114 * If in a search context, using `name__like`, or `description__like` set search fields, otherwise query everything.
1115 *
1116 * @since 5.1.0
1117 * @param array $formatted_args Formatted Elasticsearch query
1118 * @param array $query_vars WP_Term_Query arguments
1119 * @return array
1120 */
1121 protected function maybe_set_search_fields( $formatted_args, $query_vars ) {
1122 if ( empty( $query_vars['search'] ) && empty( $query_vars['name__like'] ) && empty( $query_vars['description__like'] ) ) {
1123 $formatted_args['query']['match_all'] = [
1124 'boost' => 1,
1125 ];
1126
1127 return $formatted_args;
1128 }
1129
1130 $search = ! empty( $query_vars['search'] ) ? $query_vars['search'] : '';
1131 $search_fields = [];
1132
1133 if ( ! empty( $query_vars['name__like'] ) ) {
1134 $search = $query_vars['name__like'];
1135 $search_fields[] = 'name';
1136 }
1137
1138 if ( ! empty( $query_vars['description__like'] ) ) {
1139 $search = $query_vars['description__like'];
1140 $search_fields[] = 'description';
1141 }
1142
1143 /**
1144 * Allow for search field specification
1145 */
1146 if ( ! empty( $query_vars['search_fields'] ) ) {
1147 $search_fields = $query_vars['search_fields'];
1148 }
1149
1150 if ( ! empty( $search_fields ) ) {
1151 $prepared_search_fields = [];
1152
1153 if ( ! empty( $search_fields['meta'] ) ) {
1154 $metas = (array) $search_fields['meta'];
1155
1156 foreach ( $metas as $meta ) {
1157 $prepared_search_fields[] = 'meta.' . $meta . '.value';
1158 }
1159
1160 unset( $search_fields['meta'] );
1161 }
1162
1163 $prepared_search_fields = array_merge( $search_fields, $prepared_search_fields );
1164 } else {
1165 $prepared_search_fields = [
1166 'name',
1167 'slug',
1168 'taxonomy',
1169 'description',
1170 ];
1171 }
1172
1173 /**
1174 * Filter fields to search on Term query
1175 *
1176 * @hook ep_term_search_fields
1177 * @param {array} $search_fields Search fields
1178 * @param {array} $query_vars Query variables
1179 * @since 3.4
1180 * @return {array} New search fields
1181 */
1182 $prepared_search_fields = apply_filters( 'ep_term_search_fields', $prepared_search_fields, $query_vars );
1183
1184 $search_algorithm = $this->get_search_algorithm( $search, $prepared_search_fields, $query_vars );
1185 $formatted_args['query'] = $search_algorithm->get_query( 'term', $search, $prepared_search_fields, $query_vars );
1186
1187 return $formatted_args;
1188 }
1189
1190 /**
1191 * If needed set the `fields` ES query clause.
1192 *
1193 * @since 5.1.0
1194 * @param array $formatted_args Formatted Elasticsearch query
1195 * @param array $query_vars WP_Term_Query arguments
1196 * @return array
1197 */
1198 protected function maybe_set_fields( $formatted_args, $query_vars ) {
1199 if ( ! isset( $query_vars['fields'] ) ) {
1200 return $formatted_args;
1201 }
1202
1203 switch ( $query_vars['fields'] ) {
1204 case 'ids':
1205 $formatted_args['_source'] = [
1206 'includes' => [
1207 'term_id',
1208 ],
1209 ];
1210 break;
1211
1212 case 'id=>name':
1213 $formatted_args['_source'] = [
1214 'includes' => [
1215 'term_id',
1216 'name',
1217 ],
1218 ];
1219 break;
1220
1221 case 'id=>parent':
1222 $formatted_args['_source'] = [
1223 'includes' => [
1224 'term_id',
1225 'parent',
1226 ],
1227 ];
1228 break;
1229
1230 case 'id=>slug':
1231 $formatted_args['_source'] = [
1232 'includes' => [
1233 'term_id',
1234 'slug',
1235 ],
1236 ];
1237 break;
1238
1239 case 'names':
1240 $formatted_args['_source'] = [
1241 'includes' => [
1242 'name',
1243 ],
1244 ];
1245 break;
1246 case 'tt_ids':
1247 $formatted_args['_source'] = [
1248 'includes' => [
1249 'term_taxonomy_id',
1250 ],
1251 ];
1252 break;
1253 }
1254
1255 return $formatted_args;
1256 }
1257
1258 /**
1259 * Sets the ORDER BY clause for term queries to order terms by their term_id.
1260 *
1261 * @param array $clauses The SQL clauses array to modify.
1262 * @return array The modified SQL clauses array with the ORDER BY clause set to term_id.
1263 *
1264 * @since 5.2.0
1265 */
1266 public function set_orderby( $clauses ): array {
1267 $clauses['orderby'] = 'ORDER BY t.term_id';
1268 return $clauses;
1269 }
1270 }
1271