PluginProbe
ElasticPress / 4.6.1
ElasticPress v4.6.1
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 / Post / Post.php

Post.php in ElasticPress 4.6.1, at includes/classes/Indexable/Post/Post.php

2,891 lines 81.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * User indexable
4 *
5 * @since 3.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Indexable\Post;
10
11 use ElasticPress\Indexable as Indexable;
12 use ElasticPress\Elasticsearch as Elasticsearch;
13 use \WP_Query as WP_Query;
14 use \WP_User as WP_User;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 // @codeCoverageIgnoreStart
18 exit; // Exit if accessed directly.
19 // @codeCoverageIgnoreEnd
20 }
21
22 /**
23 * Post indexable class
24 */
25 class Post extends Indexable {
26
27 /**
28 * Indexable slug used for identification
29 *
30 * @var string
31 * @since 3.0
32 */
33 public $slug = 'post';
34
35 /**
36 * Flag to indicate if the indexable has support for
37 * `id_range` pagination method during a sync.
38 *
39 * @var boolean
40 * @since 4.1.0
41 */
42 public $support_indexing_advanced_pagination = true;
43
44 /**
45 * Create indexable and initialize dependencies
46 *
47 * @since 3.0
48 */
49 public function __construct() {
50 $this->labels = [
51 'plural' => esc_html__( 'Posts', 'elasticpress' ),
52 'singular' => esc_html__( 'Post', 'elasticpress' ),
53 ];
54
55 $this->sync_manager = new SyncManager( $this->slug );
56 $this->query_integration = new QueryIntegration( $this->slug );
57 }
58
59 /**
60 * Query database for posts
61 *
62 * @param array $args Query DB args
63 * @since 3.0
64 * @return array
65 */
66 public function query_db( $args ) {
67 $defaults = [
68 'posts_per_page' => $this->get_bulk_items_per_page(),
69 'post_type' => $this->get_indexable_post_types(),
70 'post_status' => $this->get_indexable_post_status(),
71 'offset' => 0,
72 'ignore_sticky_posts' => true,
73 'orderby' => 'ID',
74 'order' => 'desc',
75 'no_found_rows' => false,
76 'ep_indexing_advanced_pagination' => true,
77 'has_password' => false,
78 ];
79
80 if ( isset( $args['per_page'] ) ) {
81 $args['posts_per_page'] = $args['per_page'];
82 }
83
84 if ( isset( $args['include'] ) ) {
85 $args['post__in'] = $args['include'];
86 }
87
88 if ( isset( $args['exclude'] ) ) {
89 $args['post__not_in'] = $args['exclude'];
90 }
91
92 /**
93 * Filter arguments used to query posts from database
94 *
95 * @hook ep_post_query_db_args
96 * @param {array} $args Database arguments
97 * @return {array} New arguments
98 */
99 $args = apply_filters( 'ep_index_posts_args', apply_filters( 'ep_post_query_db_args', wp_parse_args( $args, $defaults ) ) );
100
101 if ( isset( $args['post__in'] ) || 0 < $args['offset'] ) {
102 // Disable advanced pagination. Not useful if only indexing specific IDs.
103 $args['ep_indexing_advanced_pagination'] = false;
104 }
105
106 // Enforce the following query args during advanced pagination to ensure things work correctly.
107 if ( $args['ep_indexing_advanced_pagination'] ) {
108 $args = array_merge(
109 $args,
110 [
111 'suppress_filters' => false,
112 'orderby' => 'ID',
113 'order' => 'DESC',
114 'paged' => 1,
115 'offset' => 0,
116 'no_found_rows' => true,
117 ]
118 );
119 add_filter( 'posts_where', array( $this, 'bulk_indexing_filter_posts_where' ), 9999, 2 );
120
121 $query = new WP_Query( $args );
122 $total_objects = $this->get_total_objects_for_query( $args );
123
124 remove_filter( 'posts_where', array( $this, 'bulk_indexing_filter_posts_where' ), 9999, 2 );
125 } else {
126 $query = new WP_Query( $args );
127 $total_objects = $query->found_posts;
128 }
129
130 return [
131 'objects' => $query->posts,
132 'total_objects' => $total_objects,
133 ];
134 }
135
136 /**
137 * Manipulate the WHERE clause of the bulk indexing query to paginate by ID in order to avoid performance issues with SQL offset.
138 *
139 * @param string $where The current $where clause.
140 * @param WP_Query $query WP_Query object.
141 * @return string WHERE clause with our pagination added if needed.
142 */
143 public function bulk_indexing_filter_posts_where( $where, $query ) {
144 $using_advanced_pagination = $query->get( 'ep_indexing_advanced_pagination', false );
145
146 if ( $using_advanced_pagination ) {
147 $requested_upper_limit_id = $query->get( 'ep_indexing_upper_limit_object_id', PHP_INT_MAX );
148 $requested_lower_limit_post_id = $query->get( 'ep_indexing_lower_limit_object_id', 0 );
149 $last_processed_id = $query->get( 'ep_indexing_last_processed_object_id', null );
150
151 // On the first loopthrough we begin with the requested upper limit ID. Afterwards, use the last processed ID to paginate.
152 $upper_limit_range_post_id = $requested_upper_limit_id;
153 if ( is_numeric( $last_processed_id ) ) {
154 $upper_limit_range_post_id = $last_processed_id - 1;
155 }
156
157 // Sanitize. Abort if unexpected data at this point.
158 if ( ! is_numeric( $upper_limit_range_post_id ) || ! is_numeric( $requested_lower_limit_post_id ) ) {
159 return $where;
160 }
161
162 $range = [
163 'upper_limit' => "{$GLOBALS['wpdb']->posts}.ID <= {$upper_limit_range_post_id}",
164 'lower_limit' => "{$GLOBALS['wpdb']->posts}.ID >= {$requested_lower_limit_post_id}",
165 ];
166
167 // Skip the end range if it's unnecessary.
168 $skip_ending_range = 0 === $requested_lower_limit_post_id;
169 $where = $skip_ending_range ? "AND {$range['upper_limit']} {$where}" : "AND {$range['upper_limit']} AND {$range['lower_limit']} {$where}";
170 }
171
172 return $where;
173 }
174
175 /**
176 * Get SQL_CALC_FOUND_ROWS for a specific query based on it's args.
177 *
178 * @param array $query_args The query args.
179 * @return int The query result's found_posts.
180 */
181 protected function get_total_objects_for_query( $query_args ) {
182 static $object_counts = [];
183
184 // Reset the pagination-related args for optimal caching.
185 $normalized_query_args = array_merge(
186 $query_args,
187 [
188 'offset' => 0,
189 'paged' => 1,
190 'posts_per_page' => 1,
191 'no_found_rows' => false,
192 'ep_indexing_last_processed_object_id' => null,
193 ]
194 );
195
196 $cache_key = md5( get_current_blog_id() . wp_json_encode( $normalized_query_args ) );
197
198 if ( ! isset( $object_counts[ $cache_key ] ) ) {
199 $object_counts[ $cache_key ] = ( new WP_Query( $normalized_query_args ) )->found_posts;
200 }
201
202 if ( 0 === $object_counts[ $cache_key ] ) {
203 // Do a DB count to make sure the query didn't just die and return 0.
204 $db_post_count = $this->get_total_objects_for_query_from_db( $normalized_query_args );
205
206 if ( $db_post_count !== $object_counts[ $cache_key ] ) {
207 $object_counts[ $cache_key ] = $db_post_count;
208 }
209 }
210
211 return $object_counts[ $cache_key ];
212 }
213
214 /**
215 * Get total posts from DB for a specific query based on it's args.
216 *
217 * @param array $query_args The query args.
218 * @since 4.0.0
219 * @return int The total posts.
220 */
221 protected function get_total_objects_for_query_from_db( $query_args ) {
222 global $wpdb;
223
224 $post_count = 0;
225
226 if ( ! isset( $query_args['post_type'] ) || isset( $query_args['ep_indexing_upper_limit_object_id'] )
227 || isset( $query_args['ep_indexing_lower_limit_object_id'] ) ) {
228 return $post_count;
229 }
230
231 foreach ( $query_args['post_type'] as $post_type ) {
232 $post_counts_by_post_status = wp_count_posts( $post_type );
233 foreach ( $post_counts_by_post_status as $post_status => $post_status_count ) {
234 if ( ! in_array( $post_status, $query_args['post_status'], true ) ) {
235 continue;
236 }
237 $post_count += $post_status_count;
238 }
239 }
240
241 /**
242 * As `wp_count_posts` will also count posts with password, we need to remove
243 * them from the final count if they will not be used.
244 *
245 * The if below will pass if `has_password` is false but not null.
246 */
247 if ( isset( $query_args['has_password'] ) && ! $query_args['has_password'] ) {
248 $posts_with_password = (int) $wpdb->get_var( "SELECT COUNT(1) AS posts_with_password FROM {$wpdb->posts} WHERE post_password != ''" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
249
250 $post_count -= $posts_with_password;
251 }
252
253 return $post_count;
254 }
255
256 /**
257 * Returns indexable post types for the current site
258 *
259 * @since 0.9
260 * @return mixed|void
261 */
262 public function get_indexable_post_types() {
263 $post_types = get_post_types( array( 'public' => true ) );
264
265 /**
266 * Remove attachments by default
267 *
268 * @since 3.0
269 */
270 unset( $post_types['attachment'] );
271
272 /**
273 * Filter indexable post types
274 *
275 * @hook ep_indexable_post_types
276 * @param {array} $post_types Indexable post types
277 * @return {array} New post types
278 */
279 return apply_filters( 'ep_indexable_post_types', $post_types );
280 }
281
282 /**
283 * Return indexable post_status for the current site
284 *
285 * @since 1.3
286 * @return array
287 */
288 public function get_indexable_post_status() {
289 /**
290 * Filter indexable post statuses
291 *
292 * @hook ep_indexable_post_status
293 * @param {array} $post_statuses Indexable post statuses
294 * @return {array} New post statuses
295 */
296 return apply_filters( 'ep_indexable_post_status', array( 'publish' ) );
297 }
298
299 /**
300 * Determine required mapping file
301 *
302 * @since 3.6.2
303 * @return string
304 */
305 public function get_mapping_name() {
306 $es_version = Elasticsearch::factory()->get_elasticsearch_version();
307
308 if ( empty( $es_version ) ) {
309 /**
310 * Filter fallback Elasticsearch version
311 *
312 * @hook ep_fallback_elasticsearch_version
313 * @param {string} $version Fall back Elasticsearch version
314 * @return {string} New version
315 */
316 $es_version = apply_filters( 'ep_fallback_elasticsearch_version', '2.0' );
317 }
318
319 $mapping_file = '5-2.php';
320
321 if ( ! $es_version || version_compare( $es_version, '5.0' ) < 0 ) {
322 $mapping_file = 'pre-5-0.php';
323 } elseif ( version_compare( $es_version, '5.0', '>=' ) && version_compare( $es_version, '5.2', '<' ) ) {
324 $mapping_file = '5-0.php';
325 } elseif ( version_compare( $es_version, '5.2', '>=' ) && version_compare( $es_version, '7.0', '<' ) ) {
326 $mapping_file = '5-2.php';
327 } elseif ( version_compare( $es_version, '7.0', '>=' ) ) {
328 $mapping_file = '7-0.php';
329 }
330
331 return apply_filters( 'ep_post_mapping_version', $mapping_file );
332 }
333
334 /**
335 * Generate the mapping array
336 *
337 * @since 4.1.0
338 * @return array
339 */
340 public function generate_mapping() {
341 $mapping_file = $this->get_mapping_name();
342
343 /**
344 * Filter post indexable mapping file
345 *
346 * @hook ep_post_mapping_file
347 * @param {string} $file Path to file
348 * @return {string} New file path
349 */
350 $mapping = require apply_filters( 'ep_post_mapping_file', __DIR__ . '/../../../mappings/post/' . $mapping_file );
351
352 /**
353 * Filter post indexable mapping
354 *
355 * @hook ep_post_mapping
356 * @param {array} $mapping Mapping
357 * @return {array} New mapping
358 */
359 $mapping = apply_filters( 'ep_post_mapping', $mapping );
360
361 delete_transient( 'ep_post_mapping_version' );
362
363 return $mapping;
364 }
365
366 /**
367 * Determine version of mapping currently on the post index.
368 *
369 * @since 3.6.2
370 * @return string|WP_Error|false $version
371 */
372 public function determine_mapping_version() {
373 $version = get_transient( 'ep_post_mapping_version' );
374
375 if ( empty( $version ) ) {
376 $index = $this->get_index_name();
377 $mapping = Elasticsearch::factory()->get_mapping( $index );
378
379 if ( empty( $mapping ) ) {
380 return new \WP_Error( 'ep_failed_mapping_version', esc_html__( 'Error while fetching the mapping version.', 'elasticpress' ) );
381 }
382
383 if ( ! isset( $mapping[ $index ] ) ) {
384 return false;
385 }
386
387 $version = $this->determine_mapping_version_based_on_existing( $mapping, $index );
388
389 set_transient(
390 'ep_post_mapping_version',
391 $version,
392 /**
393 * Filter the post mapping version cache expiration.
394 *
395 * @hook ep_post_mapping_version_cache_expiration
396 * @since 3.6.5
397 * @param {int} $version Time in seconds for the transient expiration
398 * @return {int} New time
399 */
400 apply_filters( 'ep_post_mapping_version_cache_expiration', DAY_IN_SECONDS )
401 );
402 }
403
404 /**
405 * Filter the mapping version for posts.
406 *
407 * @hook ep_post_mapping_version_determined
408 * @since 3.6.2
409 * @param {string} $version Determined version string
410 * @return {string} New version string
411 */
412 return apply_filters( 'ep_post_mapping_version_determined', $version );
413 }
414
415 /**
416 * Prepare a post for syncing
417 *
418 * @param int $post_id Post ID.
419 * @since 0.9.1
420 * @return bool|array
421 */
422 public function prepare_document( $post_id ) {
423 global $post;
424 $post = get_post( $post_id );
425 setup_postdata( $post );
426
427 if ( empty( $post ) ) {
428 return false;
429 }
430
431 $user = get_userdata( $post->post_author );
432
433 if ( $user instanceof WP_User ) {
434 $user_data = array(
435 'raw' => $user->user_login,
436 'login' => $user->user_login,
437 'display_name' => $user->display_name,
438 'id' => $user->ID,
439 );
440 } else {
441 $user_data = array(
442 'raw' => '',
443 'login' => '',
444 'display_name' => '',
445 'id' => '',
446 );
447 }
448
449 $post_date = $post->post_date;
450 $post_date_gmt = $post->post_date_gmt;
451 $post_modified = $post->post_modified;
452 $post_modified_gmt = $post->post_modified_gmt;
453 $comment_count = absint( $post->comment_count );
454 $comment_status = $post->comment_status;
455 $ping_status = $post->ping_status;
456 $menu_order = (int) $post->menu_order;
457
458 /**
459 * Filter to ignore invalid dates
460 *
461 * @hook ep_ignore_invalid_dates
462 * @param {bool} $ignore True to ignore
463 * @param {int} $post_id Post ID
464 * @param {WP_Post} $post Post object
465 * @return {bool} New ignore value
466 */
467 if ( apply_filters( 'ep_ignore_invalid_dates', true, $post_id, $post ) ) {
468 if ( ! strtotime( $post_date ) || '0000-00-00 00:00:00' === $post_date ) {
469 $post_date = null;
470 }
471
472 if ( ! strtotime( $post_date_gmt ) || '0000-00-00 00:00:00' === $post_date_gmt ) {
473 $post_date_gmt = null;
474 }
475
476 if ( ! strtotime( $post_modified ) || '0000-00-00 00:00:00' === $post_modified ) {
477 $post_modified = null;
478 }
479
480 if ( ! strtotime( $post_modified_gmt ) || '0000-00-00 00:00:00' === $post_modified_gmt ) {
481 $post_modified_gmt = null;
482 }
483 }
484
485 // To prevent infinite loop, we don't queue when updated_postmeta.
486 remove_action( 'updated_postmeta', [ $this->sync_manager, 'action_queue_meta_sync' ], 10 );
487
488 /**
489 * Filter to allow indexing of filtered post content
490 *
491 * @hook ep_allow_post_content_filtered_index
492 * @param {bool} $ignore True to allow
493 * @return {bool} New value
494 */
495 $post_content_filtered_allowed = apply_filters( 'ep_allow_post_content_filtered_index', true );
496
497 $post_args = array(
498 'post_id' => $post_id,
499 'ID' => $post_id,
500 'post_author' => $user_data,
501 'post_date' => $post_date,
502 'post_date_gmt' => $post_date_gmt,
503 'post_title' => $post->post_title,
504 'post_excerpt' => $post->post_excerpt,
505 'post_content_filtered' => $post_content_filtered_allowed ? apply_filters( 'the_content', $post->post_content ) : '',
506 'post_content' => $post->post_content,
507 'post_status' => $post->post_status,
508 'post_name' => $post->post_name,
509 'post_modified' => $post_modified,
510 'post_modified_gmt' => $post_modified_gmt,
511 'post_parent' => $post->post_parent,
512 'post_type' => $post->post_type,
513 'post_mime_type' => $post->post_mime_type,
514 'permalink' => get_permalink( $post_id ),
515 'terms' => $this->prepare_terms( $post ),
516 'meta' => $this->prepare_meta_types( $this->prepare_meta( $post ) ), // post_meta removed in 2.4.
517 'date_terms' => $this->prepare_date_terms( $post_date ),
518 'comment_count' => $comment_count,
519 'comment_status' => $comment_status,
520 'ping_status' => $ping_status,
521 'menu_order' => $menu_order,
522 'guid' => $post->guid,
523 'thumbnail' => $this->prepare_thumbnail( $post ),
524 );
525
526 /**
527 * Filter sync arguments for a post. For backwards compatibility.
528 *
529 * @hook ep_post_sync_args
530 * @param {array} $post_args Post arguments
531 * @param {int} $post_id Post ID
532 * @return {array} New arguments
533 */
534 $post_args = apply_filters( 'ep_post_sync_args', $post_args, $post_id );
535
536 /**
537 * Filter sync arguments for a post after meta preparation.
538 *
539 * @hook ep_post_sync_args_post_prepare_meta
540 * @param {array} $post_args Post arguments
541 * @param {int} $post_id Post ID
542 * @return {array} New arguments
543 */
544 $post_args = apply_filters( 'ep_post_sync_args_post_prepare_meta', $post_args, $post_id );
545
546 // Turn back on updated_postmeta hook
547 add_action( 'updated_postmeta', [ $this->sync_manager, 'action_queue_meta_sync' ], 10, 4 );
548
549 return $post_args;
550 }
551
552 /**
553 * Prepare thumbnail to send to ES.
554 *
555 * @param WP_Post $post Post object.
556 * @return array|null Thumbnail data.
557 */
558 public function prepare_thumbnail( $post ) {
559 $attachment_id = get_post_thumbnail_id( $post );
560
561 if ( ! $attachment_id ) {
562 return null;
563 }
564
565 /**
566 * Filters the image size to use when indexing the post thumbnail.
567 *
568 * Defaults to the `woocommerce_thumbnail` size if WooCommerce is in
569 * use. Otherwise the `thumbnail` size is used.
570 *
571 * @hook ep_thumbnail_image_size
572 * @since 4.0.0
573 * @param {string|int[]} $image_size Image size. Can be any registered
574 * image size name, or an array of
575 * width and height values in pixels
576 * (in that order).
577 * @param {WP_Post} $post Post being indexed.
578 * @return {array} Image size to pass to wp_get_attachment_image_src().
579 */
580 $image_size = apply_filters(
581 'ep_post_thumbnail_image_size',
582 function_exists( 'WC' ) ? 'woocommerce_thumbnail' : 'thumbnail',
583 $post
584 );
585
586 $image_src = wp_get_attachment_image_src( $attachment_id, $image_size );
587 $image_alt = trim( wp_strip_all_tags( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) );
588
589 if ( ! $image_src ) {
590 return null;
591 }
592
593 return [
594 'ID' => $attachment_id,
595 'src' => $image_src[0],
596 'width' => $image_src[1],
597 'height' => $image_src[2],
598 'alt' => $image_alt,
599 ];
600 }
601
602 /**
603 * Prepare date terms to send to ES.
604 *
605 * @param string $date_to_prepare Post date
606 * @since 0.1.4
607 * @return array
608 */
609 public function prepare_date_terms( $date_to_prepare ) {
610 $terms_to_prepare = [
611 'year' => 'Y',
612 'month' => 'm',
613 'week' => 'W',
614 'dayofyear' => 'z',
615 'day' => 'd',
616 'dayofweek' => 'w',
617 'dayofweek_iso' => 'N',
618 'hour' => 'H',
619 'minute' => 'i',
620 'second' => 's',
621 'm' => 'Ym', // yearmonth
622 ];
623
624 // Combine all the date term formats and perform one single call to date_i18n() for performance.
625 $date_format = implode( '||', array_values( $terms_to_prepare ) );
626 $combined_dates = explode( '||', date_i18n( $date_format, strtotime( $date_to_prepare ) ) );
627
628 // Then split up the results for individual indexing.
629 $date_terms = [];
630 foreach ( $terms_to_prepare as $term_name => $date_format ) {
631 $index_in_combined_format = array_search( $term_name, array_keys( $terms_to_prepare ), true );
632 $date_terms[ $term_name ] = (int) $combined_dates[ $index_in_combined_format ];
633 }
634
635 return $date_terms;
636 }
637
638 /**
639 * Get an array of taxonomies that are indexable for the given post
640 *
641 * @since 4.0.0
642 * @param WP_Post $post Post object
643 * @return array Array of WP_Taxonomy objects that should be indexed
644 */
645 public function get_indexable_post_taxonomies( $post ) {
646 $taxonomies = get_object_taxonomies( $post->post_type, 'objects' );
647 $selected_taxonomies = [];
648
649 foreach ( $taxonomies as $taxonomy ) {
650 if ( $taxonomy->public || $taxonomy->publicly_queryable ) {
651 $selected_taxonomies[] = $taxonomy;
652 }
653 }
654
655 /**
656 * Filter taxonomies to be synced with post
657 *
658 * @hook ep_sync_taxonomies
659 * @param {array} $selected_taxonomies Selected taxonomies
660 * @param {WP_Post} Post object
661 * @return {array} New taxonomies
662 */
663 $selected_taxonomies = (array) apply_filters( 'ep_sync_taxonomies', $selected_taxonomies, $post );
664
665 // Important we validate here to ensure there are no invalid taxonomy values returned from the filter, as just one would cause wp_get_object_terms() to fail.
666 $validated_taxonomies = [];
667 foreach ( $selected_taxonomies as $selected_taxonomy ) {
668 // If we get a taxonomy name, we need to convert it to taxonomy object
669 if ( ! is_object( $selected_taxonomy ) && taxonomy_exists( (string) $selected_taxonomy ) ) {
670 $selected_taxonomy = get_taxonomy( $selected_taxonomy );
671 }
672
673 // We check if the $taxonomy object has a valid name property. Backward compatibility since WP_Taxonomy introduced in WP 4.7
674 if ( ! is_a( $selected_taxonomy, '\WP_Taxonomy' ) || ! property_exists( $selected_taxonomy, 'name' ) || ! taxonomy_exists( $selected_taxonomy->name ) ) {
675 continue;
676 }
677
678 $validated_taxonomies[] = $selected_taxonomy;
679 }
680
681 return $validated_taxonomies;
682 }
683
684 /**
685 * Prepare terms to send to ES.
686 *
687 * @param WP_Post $post Post object
688 * @since 0.1.0
689 * @return array
690 */
691 private function prepare_terms( $post ) {
692 $selected_taxonomies = $this->get_indexable_post_taxonomies( $post );
693
694 if ( empty( $selected_taxonomies ) ) {
695 return [];
696 }
697
698 $terms = [];
699
700 /**
701 * Filter to allow child terms to be indexed
702 *
703 * @hook ep_sync_terms_allow_hierarchy
704 * @param {bool} $allow True means allow
705 * @return {bool} New value
706 */
707 $allow_hierarchy = apply_filters( 'ep_sync_terms_allow_hierarchy', true );
708
709 foreach ( $selected_taxonomies as $taxonomy ) {
710 $object_terms = get_the_terms( $post->ID, $taxonomy->name );
711
712 if ( ! $object_terms || is_wp_error( $object_terms ) ) {
713 continue;
714 }
715
716 $terms_dic = [];
717
718 foreach ( $object_terms as $term ) {
719 if ( ! isset( $terms_dic[ $term->term_id ] ) ) {
720 $terms_dic[ $term->term_id ] = $this->get_formatted_term( $term, $post->ID );
721
722 if ( $allow_hierarchy ) {
723 $terms_dic = $this->get_parent_terms( $terms_dic, $term, $taxonomy->name, $post->ID );
724 }
725 }
726 }
727 $terms[ $taxonomy->name ] = array_values( $terms_dic );
728 }
729
730 return $terms;
731 }
732
733 /**
734 * Recursively get all the ancestor terms of the given term
735 *
736 * @param array $terms Terms array
737 * @param WP_Term $term Current term
738 * @param string $tax_name Taxonomy
739 * @param int $object_id Post ID
740 *
741 * @return array
742 */
743 private function get_parent_terms( $terms, $term, $tax_name, $object_id ) {
744 $parent_term = get_term( $term->parent, $tax_name );
745 if ( ! $parent_term || is_wp_error( $parent_term ) ) {
746 return $terms;
747 }
748 if ( ! isset( $terms[ $parent_term->term_id ] ) ) {
749 $terms[ $parent_term->term_id ] = $this->get_formatted_term( $parent_term, $object_id );
750
751 }
752 return $this->get_parent_terms( $terms, $parent_term, $tax_name, $object_id );
753 }
754
755 /**
756 * Given a term, format it to be appended to the post ES document.
757 *
758 * @since 4.5.0
759 * @param \WP_Term $term Term to be formatted
760 * @param int $post_id The post ID
761 * @return array
762 */
763 private function get_formatted_term( \WP_Term $term, int $post_id ) : array {
764 $formatted_term = [
765 'term_id' => $term->term_id,
766 'slug' => $term->slug,
767 'name' => $term->name,
768 'parent' => $term->parent,
769 'term_taxonomy_id' => $term->term_taxonomy_id,
770 'term_order' => (int) $this->get_term_order( $term->term_taxonomy_id, $post_id ),
771 ];
772
773 /**
774 * As the name implies, the facet attribute is used to list all terms in facets.
775 * As in facets, the term_order associated with a post does not matter, we set it as 0 here.
776 * Note that this is set as 0 instead of simply removed to keep backward compatibility.
777 */
778 $term_facet = $formatted_term;
779 $term_facet['term_order'] = 0;
780 $formatted_term['facet'] = wp_json_encode( $term_facet );
781
782 return $formatted_term;
783 }
784
785 /**
786 * Retreives term order for the object/term_taxonomy_id combination
787 *
788 * @param int $term_taxonomy_id Term Taxonomy ID
789 * @param int $object_id Post ID
790 *
791 * @return int Term Order
792 */
793 protected function get_term_order( $term_taxonomy_id, $object_id ) {
794 global $wpdb;
795
796 $cache_key = "{$object_id}_term_order";
797 $term_orders = wp_cache_get( $cache_key );
798
799 if ( false === $term_orders ) {
800 $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
801 $wpdb->prepare(
802 "SELECT term_taxonomy_id, term_order from $wpdb->term_relationships where object_id=%d;",
803 $object_id
804 ),
805 ARRAY_A
806 );
807
808 $term_orders = [];
809
810 foreach ( $results as $result ) {
811 $term_orders[ $result['term_taxonomy_id'] ] = $result['term_order'];
812 }
813
814 wp_cache_set( $cache_key, $term_orders );
815 }
816
817 return isset( $term_orders[ $term_taxonomy_id ] ) ? (int) $term_orders[ $term_taxonomy_id ] : 0;
818
819 }
820
821 /**
822 * Checks if meta key is allowed
823 *
824 * @param string $meta_key meta key to check
825 * @param WP_Post $post Post object
826 * @since 4.3.0
827 * @return boolean
828 */
829 public function is_meta_allowed( $meta_key, $post ) {
830 $test_metas = [
831 $meta_key => true,
832 ];
833
834 $filtered_test_metas = $this->filter_allowed_metas( $test_metas, $post );
835
836 return array_key_exists( $meta_key, $filtered_test_metas );
837 }
838
839 /**
840 * Filter post meta to only the allowed ones to be send to ES
841 *
842 * @param array $metas Key => value pairs of post meta
843 * @param WP_Post $post Post object
844 * @since 4.3.0
845 * @return array
846 */
847 public function filter_allowed_metas( $metas, $post ) {
848 $filtered_metas = [];
849
850 /**
851 * Filter indexable protected meta keys for posts
852 *
853 * @hook ep_prepare_meta_allowed_protected_keys
854 * @param {array} $keys Allowed protected keys
855 * @param {WP_Post} $post Post object
856 * @since 1.7
857 * @return {array} New keys
858 */
859 $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], $post );
860
861 /**
862 * Filter public keys to exclude from indexed post
863 *
864 * @hook ep_prepare_meta_excluded_public_keys
865 * @param {array} $keys Excluded protected keys
866 * @param {WP_Post} $post Post object
867 * @since 1.7
868 * @return {array} New keys
869 */
870 $excluded_public_keys = apply_filters( 'ep_prepare_meta_excluded_public_keys', [], $post );
871
872 foreach ( $metas as $key => $value ) {
873
874 $allow_index = false;
875
876 if ( is_protected_meta( $key ) ) {
877
878 if ( true === $allowed_protected_keys || in_array( $key, $allowed_protected_keys, true ) ) {
879 $allow_index = true;
880 }
881 } else {
882
883 if ( true !== $excluded_public_keys && ! in_array( $key, $excluded_public_keys, true ) ) {
884 $allow_index = true;
885 }
886 }
887
888 /**
889 * Filter force whitelisting a meta key
890 *
891 * @hook ep_prepare_meta_whitelist_key
892 * @param {bool} $whitelist True to whitelist key
893 * @param {string} $key Meta key
894 * @param {WP_Post} $post Post object
895 * @return {bool} New whitelist value
896 */
897 if ( true === $allow_index || apply_filters( 'ep_prepare_meta_whitelist_key', false, $key, $post ) ) {
898 $filtered_metas[ $key ] = $value;
899 }
900 }
901 return $filtered_metas;
902 }
903
904 /**
905 * Prepare post meta to send to ES
906 *
907 * @param WP_Post $post Post object
908 * @since 0.1.0
909 * @return array
910 */
911 public function prepare_meta( $post ) {
912 /**
913 * Filter pre-prepare meta for a post
914 *
915 * @hook ep_prepare_meta_data
916 * @param {array} $meta Meta data
917 * @param {WP_Post} $post Post object
918 * @return {array} New meta
919 */
920 $meta = apply_filters( 'ep_prepare_meta_data', (array) get_post_meta( $post->ID ), $post );
921
922 if ( empty( $meta ) ) {
923 /**
924 * Filter final list of prepared meta.
925 *
926 * @hook ep_prepared_post_meta
927 * @param {array} $prepared_meta Prepared meta
928 * @param {WP_Post} $post Post object
929 * @since 3.4
930 * @return {array} Prepared meta
931 */
932 return apply_filters( 'ep_prepared_post_meta', [], $post );
933 }
934
935 $filtered_metas = $this->filter_allowed_metas( $meta, $post );
936 $prepared_meta = [];
937
938 foreach ( $filtered_metas as $key => $value ) {
939 if ( ! empty( $key ) ) {
940 $prepared_meta[ $key ] = maybe_unserialize( $value );
941 }
942 }
943
944 /**
945 * Filter final list of prepared meta.
946 *
947 * @hook ep_prepared_post_meta
948 * @param {array} $prepared_meta Prepared meta
949 * @param {WP_Post} $post Post object
950 * @since 3.4
951 * @return {array} Prepared meta
952 */
953 return apply_filters( 'ep_prepared_post_meta', $prepared_meta, $post );
954
955 }
956
957 /**
958 * Format WP query args for ES
959 *
960 * @param array $args WP_Query arguments.
961 * @param WP_Query $wp_query WP_Query object
962 * @since 0.9.0
963 * @return array
964 */
965 public function format_args( $args, $wp_query ) {
966 $args = $this->sanitize_wp_query_args( $args );
967
968 $formatted_args = [
969 'from' => $this->parse_from( $args ),
970 'size' => $this->parse_size( $args ),
971 ];
972
973 $filters = $this->parse_filters( $args, $wp_query );
974
975 if ( ! empty( $filters ) ) {
976 $formatted_args['post_filter'] = $filters;
977 }
978
979 $formatted_args = $this->maybe_set_search_fields( $formatted_args, $args );
980 $formatted_args = $this->maybe_set_fields( $formatted_args, $args );
981 $formatted_args = $this->maybe_orderby( $formatted_args, $args );
982 $formatted_args = $this->maybe_add_sticky_posts( $formatted_args, $args );
983 $formatted_args = $this->maybe_set_aggs( $formatted_args, $args, $filters );
984
985 /**
986 * Filter formatted Elasticsearch [ost ]query (entire query)
987 *
988 * @hook ep_formatted_args
989 * @param {array} $formatted_args Formatted Elasticsearch query
990 * @param {array} $query_vars Query variables
991 * @param {array} $query Query part
992 * @return {array} New query
993 */
994 $formatted_args = apply_filters( 'ep_formatted_args', $formatted_args, $args, $wp_query );
995
996 /**
997 * Filter formatted Elasticsearch [ost ]query (entire query)
998 *
999 * @hook ep_post_formatted_args
1000 * @param {array} $formatted_args Formatted Elasticsearch query
1001 * @param {array} $query_vars Query variables
1002 * @param {array} $query Query part
1003 * @return {array} New query
1004 */
1005 $formatted_args = apply_filters( 'ep_post_formatted_args', $formatted_args, $args, $wp_query );
1006
1007 return $formatted_args;
1008 }
1009
1010 /**
1011 * Adjust the fuzziness parameter if needed.
1012 *
1013 * If using fields with type `long`, queries should not have a fuzziness parameter.
1014 *
1015 * @param array $query Current query
1016 * @param array $query_vars Query variables
1017 * @param string $search_text Search text
1018 * @param array $search_fields Search fields
1019 * @return array New query
1020 */
1021 public function adjust_query_fuzziness( $query, $query_vars, $search_text, $search_fields ) {
1022 if ( empty( array_intersect( $search_fields, [ 'ID', 'post_id', 'post_parent' ] ) ) ) {
1023 return $query;
1024 }
1025
1026 if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['should'] ) ) {
1027 return $query;
1028 }
1029
1030 foreach ( $query['bool']['should'] as &$clause ) {
1031 if ( ! isset( $clause['multi_match'] ) ) {
1032 continue;
1033 }
1034
1035 if ( isset( $clause['multi_match']['fuzziness'] ) ) {
1036 unset( $clause['multi_match']['fuzziness'] );
1037 }
1038 }
1039
1040 return $query;
1041 }
1042
1043 /**
1044 * Parse and build out our tax query.
1045 *
1046 * @access protected
1047 *
1048 * @param array $query Tax query
1049 * @return array
1050 */
1051 protected function parse_tax_query( $query ) {
1052 $tax_query = [
1053 'tax_filter' => [],
1054 'tax_must_not_filter' => [],
1055 ];
1056 $relation = '';
1057
1058 foreach ( $query as $tax_queries ) {
1059 // If we have a nested tax query, recurse through that
1060 if ( is_array( $tax_queries ) && empty( $tax_queries['taxonomy'] ) ) {
1061 $result = $this->parse_tax_query( $tax_queries );
1062 $relation = ( ! empty( $tax_queries['relation'] ) ) ? strtolower( $tax_queries['relation'] ) : 'and';
1063 $filter_type = 'and' === $relation ? 'must' : 'should';
1064
1065 // Set the proper filter type and must_not filter, as needed
1066 if ( ! empty( $result['tax_must_not_filter'] ) ) {
1067 $tax_query['tax_filter'][] = [
1068 'bool' => [
1069 $filter_type => $result['tax_filter'],
1070 'must_not' => $result['tax_must_not_filter'],
1071 ],
1072 ];
1073 } else {
1074 $tax_query['tax_filter'][] = [
1075 'bool' => [
1076 $filter_type => $result['tax_filter'],
1077 ],
1078 ];
1079 }
1080 }
1081
1082 // Parse each individual tax query part
1083 $single_tax_query = $tax_queries;
1084 if ( ! empty( $single_tax_query['taxonomy'] ) ) {
1085 $terms = isset( $single_tax_query['terms'] ) ? (array) $single_tax_query['terms'] : array();
1086 $field = $this->parse_tax_query_field( $single_tax_query['field'] );
1087
1088 if ( 'slug' === $field ) {
1089 $terms = array_map( 'sanitize_title', $terms );
1090 }
1091
1092 // Set up our terms object
1093 $terms_obj = array(
1094 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => array_values( array_filter( $terms ) ),
1095 );
1096
1097 $operator = ( ! empty( $single_tax_query['operator'] ) ) ? strtolower( $single_tax_query['operator'] ) : 'in';
1098
1099 switch ( $operator ) {
1100 case 'exists':
1101 /**
1102 * add support for "EXISTS" operator
1103 *
1104 * @since 2.5
1105 */
1106 $tax_query['tax_filter'][]['bool'] = array(
1107 'must' => array(
1108 array(
1109 'exists' => array(
1110 'field' => key( $terms_obj ),
1111 ),
1112 ),
1113 ),
1114 );
1115
1116 break;
1117 case 'not exists':
1118 /**
1119 * add support for "NOT EXISTS" operator
1120 *
1121 * @since 2.5
1122 */
1123 $tax_query['tax_filter'][]['bool'] = array(
1124 'must_not' => array(
1125 array(
1126 'exists' => array(
1127 'field' => key( $terms_obj ),
1128 ),
1129 ),
1130 ),
1131 );
1132
1133 break;
1134 case 'not in':
1135 /**
1136 * add support for "NOT IN" operator
1137 *
1138 * @since 2.1
1139 */
1140 // If "NOT IN" than it should filter as must_not
1141 $tax_query['tax_must_not_filter'][]['terms'] = $terms_obj;
1142
1143 break;
1144 case 'and':
1145 /**
1146 * add support for "and" operator
1147 *
1148 * @since 2.4
1149 */
1150 $and_nest = array(
1151 'bool' => array(
1152 'must' => array(),
1153 ),
1154 );
1155
1156 foreach ( $terms as $term ) {
1157 $and_nest['bool']['must'][] = array(
1158 'terms' => array(
1159 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => (array) $term,
1160 ),
1161 );
1162 }
1163
1164 $tax_query['tax_filter'][] = $and_nest;
1165
1166 break;
1167 case 'in':
1168 default:
1169 /**
1170 * Default to IN operator
1171 */
1172 // Add the tax query filter
1173 $tax_query['tax_filter'][]['terms'] = $terms_obj;
1174
1175 break;
1176 }
1177 }
1178 }
1179
1180 return $tax_query;
1181 }
1182
1183 /**
1184 * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
1185 *
1186 * @since 1.1
1187 * @access protected
1188 *
1189 * @param string $order The 'order' query variable.
1190 * @return string The sanitized 'order' query variable.
1191 */
1192 protected function parse_order( $order ) {
1193 // Core will always set sort order to DESC for any invalid value,
1194 // so we can't do any automated testing of this function.
1195 // @codeCoverageIgnoreStart
1196 if ( ! is_string( $order ) || empty( $order ) ) {
1197 return 'desc';
1198 }
1199 // @codeCoverageIgnoreEnd
1200
1201 if ( 'ASC' === strtoupper( $order ) ) {
1202 return 'asc';
1203 } else {
1204 return 'desc';
1205 }
1206 }
1207
1208 /**
1209 * Convert the alias to a properly-prefixed sort value.
1210 *
1211 * @since 1.1
1212 * @access protected
1213 *
1214 * @param string $orderbys Alias or path for the field to order by.
1215 * @param string $default_order Default order direction
1216 * @param array $args Query args
1217 * @return array
1218 */
1219 protected function parse_orderby( $orderbys, $default_order, $args ) {
1220 $orderbys = $this->get_orderby_array( $orderbys );
1221
1222 $from_to = [
1223 'relevance' => '_score',
1224 'date' => 'post_date',
1225 'type' => 'post_type.raw',
1226 'modified' => 'post_modified',
1227 'name' => 'post_name.raw',
1228 'title' => 'post_title.sortable',
1229 ];
1230
1231 $sort = [];
1232
1233 foreach ( $orderbys as $key => $value ) {
1234 if ( is_string( $key ) ) {
1235 $orderby_clause = $key;
1236 $order = $value;
1237 } else {
1238 $orderby_clause = $value;
1239 $order = $default_order;
1240 }
1241
1242 if ( empty( $orderby_clause ) || 'rand' === $orderby_clause ) {
1243 continue;
1244 }
1245
1246 /**
1247 * If `orderby` is 'none', WordPress will let the database decide on what should be used to order.
1248 * It will use the primary key ASC.
1249 */
1250 if ( 'none' === $orderby_clause ) {
1251 $orderby_clause = 'ID';
1252 $order = 'asc';
1253 }
1254
1255 if ( ! empty( $from_to[ $orderby_clause ] ) ) {
1256 $orderby_clause = $from_to[ $orderby_clause ];
1257 } else {
1258 $orderby_clause = $this->parse_orderby_meta_fields( $orderby_clause, $args );
1259 }
1260
1261 $sort[] = array(
1262 $orderby_clause => array(
1263 'order' => $order,
1264 ),
1265 );
1266 }
1267
1268 return $sort;
1269 }
1270
1271 /**
1272 * Try to parse orderby meta fields
1273 *
1274 * @since 4.6.0
1275 * @param string $orderby_clause Current orderby value
1276 * @param array $args Query args
1277 * @return string New orderby value
1278 */
1279 protected function parse_orderby_meta_fields( $orderby_clause, $args ) {
1280 global $wpdb;
1281
1282 $from_to_metatypes = [
1283 'num' => 'long',
1284 'numeric' => 'long',
1285 'binary' => 'value.sortable',
1286 'char' => 'value.sortable',
1287 'date' => 'date',
1288 'datetime' => 'datetime',
1289 'decimal' => 'double',
1290 'signed' => 'long',
1291 'time' => 'time',
1292 'unsigned' => 'long',
1293 ];
1294
1295 // Code is targeting Elasticsearch directly
1296 if ( preg_match( '/^meta\.(.*?)\.(.*)/', $orderby_clause, $match_meta ) ) {
1297 return $orderby_clause;
1298 }
1299
1300 // WordPress meta_value_* compatibility
1301 if ( preg_match( '/^meta_value_?(.*)/', $orderby_clause, $match_type ) ) {
1302 $meta_type = $from_to_metatypes[ strtolower( $match_type[1] ) ] ?? 'value.sortable';
1303 }
1304
1305 if ( ! empty( $args['meta_key'] ) ) {
1306 $meta_field = $args['meta_key'];
1307 }
1308
1309 // Already have everything needed
1310 if ( isset( $meta_type ) && isset( $meta_field ) ) {
1311 return "meta.{$meta_field}.{$meta_type}";
1312 }
1313
1314 // Don't have any other ways to guess
1315 if ( empty( $args['meta_query'] ) ) {
1316 return $orderby_clause;
1317 }
1318
1319 $meta_query = new \WP_Meta_Query( $args['meta_query'] );
1320 // Calling get_sql() to populate the WP_Meta_Query->clauses attribute
1321 $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
1322
1323 $clauses = $meta_query->get_clauses();
1324
1325 // If it refers to a named meta_query clause
1326 if ( ! empty( $clauses[ $orderby_clause ] ) ) {
1327 $meta_field = $clauses[ $orderby_clause ]['key'];
1328 $clause_meta_type = strtolower( $clauses[ $orderby_clause ]['type'] ?? $clauses[ $orderby_clause ]['cast'] );
1329 } else {
1330 /**
1331 * At this point we:
1332 * 1. Try to find the meta key in any meta_query clause and use the type WP found
1333 * 2. If ordering by `meta_value*`, use the first meta_query clause
1334 * 3. Give up and use the orderby clause as is (code could be capturing it later on)
1335 */
1336 $meta_keys_and_types = wp_list_pluck( $clauses, 'cast', 'key' );
1337 if ( isset( $meta_keys_and_types[ $orderby_clause ] ) ) {
1338 $meta_field = $orderby_clause;
1339 $clause_meta_type = strtolower( $meta_keys_and_types[ $orderby_clause ] ?? $meta_keys_and_types[ $orderby_clause ] );
1340 } elseif ( isset( $meta_type ) ) {
1341 $primary_clause = reset( $clauses );
1342 $meta_field = $primary_clause['key'];
1343 } else {
1344 unset( $meta_type );
1345 unset( $meta_field );
1346 }
1347 }
1348
1349 if ( ! isset( $meta_type ) && isset( $clause_meta_type ) ) {
1350 $meta_type = $from_to_metatypes[ $clause_meta_type ] ?? 'value.sortable';
1351 }
1352
1353 if ( isset( $meta_type ) && isset( $meta_field ) ) {
1354 $orderby_clause = "meta.{$meta_field}.{$meta_type}";
1355 }
1356
1357 return $orderby_clause;
1358 }
1359
1360 /**
1361 * Get Order by args Array
1362 *
1363 * @param string|array $orderbys Order by string or array
1364 * @since 2.1
1365 * @return array
1366 */
1367 protected function get_orderby_array( $orderbys ) {
1368 if ( ! is_array( $orderbys ) ) {
1369 $orderbys = explode( ' ', $orderbys );
1370 }
1371
1372 return $orderbys;
1373 }
1374
1375 /**
1376 * Given a mapping content, try to determine the version used.
1377 *
1378 * @since 3.6.3
1379 *
1380 * @param array $mapping Mapping content.
1381 * @param string $index Index name
1382 * @return string Version of the mapping being used.
1383 */
1384 protected function determine_mapping_version_based_on_existing( $mapping, $index ) {
1385 if ( isset( $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'] ) ) {
1386 return $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'];
1387 }
1388 if ( isset( $mapping[ $index ]['mappings']['_meta']['mapping_version'] ) ) {
1389 return $mapping[ $index ]['mappings']['_meta']['mapping_version'];
1390 }
1391
1392 /**
1393 * Check for 7-0 mapping.
1394 * If mapping has a `post` type, it can't be ES 7, as mapping types were removed in that release.
1395 *
1396 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
1397 */
1398 if ( ! isset( $mapping[ $index ]['mappings']['post'] ) ) {
1399 return '7-0.php';
1400 }
1401
1402 $post_mapping = $mapping[ $index ]['mappings']['post'];
1403
1404 /**
1405 * Starting at this point, our tests rely on the post_title.fields.sortable field.
1406 * As this field is present in all our mappings, if this field is not present in
1407 * the mapping, this is a custom mapping.
1408 *
1409 * To have this code working with custom mappings, use the `ep_post_mapping_version_determined` filter.
1410 */
1411 if ( ! isset( $post_mapping['properties']['post_title']['fields']['sortable'] ) ) {
1412 return 'unknown';
1413 }
1414
1415 $post_title_sortable = $post_mapping['properties']['post_title']['fields']['sortable'];
1416
1417 /**
1418 * Check for 5-2 mapping.
1419 * Normalizers on keyword fields were only made available in ES 5.2
1420 *
1421 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.2/release-notes-5.2.0.html
1422 */
1423 if ( isset( $post_title_sortable['normalizer'] ) ) {
1424 return '5-2.php';
1425 }
1426
1427 /**
1428 * Check for 5-0 mapping.
1429 * `keyword` fields were only made available in ES 5.0
1430 *
1431 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
1432 */
1433 if ( 'keyword' === $post_title_sortable['type'] ) {
1434 return '5-0.php';
1435 }
1436
1437 /**
1438 * Check for pre-5-0 mapping.
1439 * `string` fields were deprecated in ES 5.0 in favor of text/keyword
1440 *
1441 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
1442 */
1443 if ( 'string' === $post_title_sortable['type'] ) {
1444 return 'pre-5-0.php';
1445 }
1446
1447 return 'unknown';
1448 }
1449
1450 /**
1451 * Given ES args, add aggregations to it.
1452 *
1453 * @since 4.1.0
1454 * @param array $formatted_args Formatted Elasticsearch query
1455 * @param array $agg Aggregation data.
1456 * @param boolean $use_filters Whether filters should be used or not.
1457 * @param array $filter Filters defined so far.
1458 * @return array Formatted Elasticsearch query with the aggregation added.
1459 */
1460 protected function apply_aggregations( $formatted_args, $agg, $use_filters, $filter ) {
1461 if ( empty( $agg['aggs'] ) ) {
1462 return $formatted_args;
1463 }
1464
1465 // Add a name to the aggregation if it was passed through
1466 $agg_name = ( ! empty( $agg['name'] ) ) ? $agg['name'] : 'aggregation_name';
1467
1468 // Add/use the filter if warranted
1469 if ( isset( $agg['use-filter'] ) && false !== $agg['use-filter'] && $use_filters ) {
1470
1471 // If a filter is being used, use it on the aggregation as well to receive relevant information to the query
1472 $formatted_args['aggs'][ $agg_name ]['filter'] = $filter;
1473 $formatted_args['aggs'][ $agg_name ]['aggs'] = $agg['aggs'];
1474 } else {
1475 $formatted_args['aggs'][ $agg_name ] = $agg['aggs'];
1476 }
1477
1478 return $formatted_args;
1479 }
1480
1481 /**
1482 * Get the search algorithm that should be used.
1483 *
1484 * @since 4.3.0
1485 * @param string $search_text Search term(s)
1486 * @param array $search_fields Search fields
1487 * @param array $query_vars Query vars
1488 * @return SearchAlgorithm Instance of search algorithm to be used
1489 */
1490 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
1491 $search_algorithm_version_option = \ElasticPress\Utils\get_option( 'ep_search_algorithm_version', '4.0' );
1492
1493 /**
1494 * Filter the algorithm version to be used.
1495 *
1496 * @since 3.5
1497 * @hook ep_search_algorithm_version
1498 * @param {string} $search_algorithm_version Algorithm version.
1499 * @return {string} New algorithm version
1500 */
1501 $search_algorithm = apply_filters( 'ep_search_algorithm_version', $search_algorithm_version_option );
1502
1503 /**
1504 * Filter the search algorithm to be used
1505 *
1506 * @hook ep_{$indexable_slug}_search_algorithm
1507 * @since 4.3.0
1508 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1509 * @param {string} $search_term Search term
1510 * @param {array} $search_fields Fields to be searched
1511 * @param {array} $query_vars Query variables
1512 * @return {string} New search algorithm slug
1513 */
1514 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", $search_algorithm, $search_text, $search_fields, $query_vars );
1515
1516 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1517 }
1518
1519 /**
1520 * Based on WP_Query arguments, parses the various filters that could be applied into the ES query.
1521 *
1522 * @since 4.4.0
1523 * @param array $args WP_Query arguments
1524 * @param WP_Query $query WP_Query object
1525 * @return array
1526 */
1527 protected function parse_filters( $args, $query ) {
1528 /**
1529 * A note about the order of this array indices:
1530 * As previously there was no way to access each part, some snippets might be accessing
1531 * these filters by its usual numeric indices (see the array_values() call below.)
1532 */
1533 $filters = [
1534 'tax_query' => $this->parse_tax_queries( $args, $query ),
1535 'post_parent' => $this->parse_post_parent( $args ),
1536 'post_parent__in' => $this->parse_post_parent__in( $args ),
1537 'post_parent__not_in' => $this->parse_post_parent__not_in( $args ),
1538 'post__in' => $this->parse_post__in( $args ),
1539 'post_name__in' => $this->parse_post_name__in( $args ),
1540 'post__not_in' => $this->parse_post__not_in( $args ),
1541 'category__not_in' => $this->parse_category__not_in( $args ),
1542 'tag__not_in' => $this->parse_tag__not_in( $args ),
1543 'author' => $this->parse_author( $args ),
1544 'post_mime_type' => $this->parse_post_mime_type( $args ),
1545 'date' => $this->parse_date( $args ),
1546 'meta_query' => $this->parse_meta_queries( $args ),
1547 'post_type' => $this->parse_post_type( $args ),
1548 'post_status' => $this->parse_post_status( $args ),
1549 ];
1550
1551 /**
1552 * Filter the ES filters that will be applied to the ES query.
1553 *
1554 * Although each index of the `$filters` array contains the related WP Query argument,
1555 * it will be removed before applied to the ES query.
1556 *
1557 * @hook ep_post_filters
1558 * @param {array} Current filters
1559 * @param {array} WP Query args
1560 * @param {WP_Query} WP Query object
1561 * @return {array} New filters
1562 */
1563 $filters = apply_filters( 'ep_post_filters', $filters, $args, $query );
1564
1565 $filters = array_values( array_filter( $filters ) );
1566
1567 if ( ! empty( $filters ) ) {
1568 $filters = [
1569 'bool' => [
1570 'must' => $filters,
1571 ],
1572 ];
1573 }
1574
1575 return $filters;
1576 }
1577
1578 /**
1579 * Sanitize WP_Query arguments to be used to create the ES query.
1580 *
1581 * Elasticsearch will error if a terms query contains empty items like an empty string.
1582 *
1583 * @since 4.4.0
1584 * @param array $args WP_Query arguments
1585 * @return array
1586 */
1587 protected function sanitize_wp_query_args( $args ) {
1588 $keys_to_sanitize = [
1589 'author__in',
1590 'author__not_in',
1591 'category__and',
1592 'category__in',
1593 'category__not_in',
1594 'tag__and',
1595 'tag__in',
1596 'tag__not_in',
1597 'tag_slug__and',
1598 'tag_slug__in',
1599 'post_parent__in',
1600 'post_parent__not_in',
1601 'post__in',
1602 'post__not_in',
1603 'post_name__in',
1604 ];
1605 foreach ( $keys_to_sanitize as $key ) {
1606 if ( ! isset( $args[ $key ] ) ) {
1607 continue;
1608 }
1609 $args[ $key ] = array_filter( (array) $args[ $key ] );
1610 }
1611
1612 return $args;
1613 }
1614
1615 /**
1616 * Parse the `from` clause of the ES Query.
1617 *
1618 * @since 4.4.0
1619 * @param array $args WP_Query arguments
1620 * @return int
1621 */
1622 protected function parse_from( $args ) {
1623 $from = 0;
1624
1625 if ( isset( $args['offset'] ) ) {
1626 $from = (int) $args['offset'];
1627 }
1628
1629 if ( isset( $args['paged'] ) && $args['paged'] > 1 ) {
1630 $from = $args['posts_per_page'] * ( $args['paged'] - 1 );
1631 }
1632
1633 /**
1634 * Fix negative offset. This happens, for example, on hierarchical post types.
1635 *
1636 * Ref: https://github.com/10up/ElasticPress/issues/2480
1637 */
1638 if ( $from < 0 ) {
1639 $from = 0;
1640 }
1641
1642 return $from;
1643 }
1644
1645 /**
1646 * Parse the `size` clause of the ES Query.
1647 *
1648 * @since 4.4.0
1649 * @param array $args WP_Query arguments
1650 * @return int
1651 */
1652 protected function parse_size( $args ) {
1653 if ( empty( $args['posts_per_page'] ) ) {
1654 return (int) get_option( 'posts_per_page' );
1655 }
1656
1657 $posts_per_page = (int) $args['posts_per_page'];
1658
1659 // ES have a maximum size allowed so we have to convert "-1" to a maximum size.
1660 if ( -1 === $posts_per_page ) {
1661 /**
1662 * Filter max result size if set to -1
1663 *
1664 * The request will return a HTTP 500 Internal Error if the size of the
1665 * request is larger than the [index.max_result_window] parameter in ES.
1666 * See the scroll api for a more efficient way to request large data sets.
1667 *
1668 * @hook ep_max_results_window
1669 * @param {int} Max result window
1670 * @return {int} New window
1671 */
1672 $posts_per_page = apply_filters( 'ep_max_results_window', 10000 );
1673 }
1674
1675 return $posts_per_page;
1676 }
1677
1678 /**
1679 * Parse the order of results in the ES query. It could simply be a `sort` clause or a function score query if using RAND.
1680 *
1681 * @since 4.4.0
1682 * @param array $formatted_args Formatted Elasticsearch query
1683 * @param array $args WP_Query arguments
1684 * @return array
1685 */
1686 protected function maybe_orderby( $formatted_args, $args ) {
1687 /**
1688 * Order and Orderby arguments
1689 *
1690 * Used for how Elasticsearch will sort results
1691 *
1692 * @since 1.1
1693 */
1694
1695 // Set sort order, default is 'desc'.
1696 if ( ! empty( $args['order'] ) ) {
1697 $order = $this->parse_order( $args['order'] );
1698 } else {
1699 $order = 'desc';
1700 }
1701
1702 // Default sort for non-searches to date.
1703 if ( empty( $args['orderby'] ) && ( ! isset( $args['s'] ) || '' === $args['s'] ) ) {
1704 /**
1705 * Filter default post query order by
1706 *
1707 * @hook ep_set_default_sort
1708 * @param {string} $sort Default sort
1709 * @param {string $order Order direction
1710 * @return {string} New default
1711 */
1712 $args['orderby'] = apply_filters( 'ep_set_default_sort', 'date', $order );
1713 }
1714
1715 // Set sort type.
1716 if ( ! empty( $args['orderby'] ) ) {
1717 $formatted_args['sort'] = $this->parse_orderby( $args['orderby'], $order, $args );
1718 } else {
1719 // Default sort is to use the score (based on relevance).
1720 $default_sort = array(
1721 array(
1722 '_score' => array(
1723 'order' => $order,
1724 ),
1725 ),
1726 );
1727
1728 /**
1729 * Filter the ES query order (`sort` clause)
1730 *
1731 * This filter is used in searches if `orderby` is not set in the WP_Query args.
1732 * The default value is:
1733 *
1734 * $default_sort = array(
1735 * array(
1736 * '_score' => array(
1737 * 'order' => $order,
1738 * ),
1739 * ),
1740 * );
1741 *
1742 * @hook ep_set_sort
1743 * @since 3.6.3
1744 * @param {array} $sort Default sort.
1745 * @param {string} $order Order direction
1746 * @return {array} New default
1747 */
1748 $default_sort = apply_filters( 'ep_set_sort', $default_sort, $order );
1749
1750 $formatted_args['sort'] = $default_sort;
1751 }
1752
1753 /**
1754 * Order by 'rand' support
1755 *
1756 * Ref: https://github.com/elastic/elasticsearch/issues/1170
1757 */
1758 if ( ! empty( $args['orderby'] ) ) {
1759 $orderbys = $this->get_orderby_array( $args['orderby'] );
1760 if ( in_array( 'rand', $orderbys, true ) ) {
1761 $formatted_args_query = $formatted_args['query'];
1762 $formatted_args['query'] = [];
1763 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1764 $formatted_args['query']['function_score']['random_score'] = (object) [];
1765 }
1766 }
1767
1768 return $formatted_args;
1769 }
1770
1771 /**
1772 * Parse all taxonomy queries.
1773 *
1774 * Although the name may be misleading, it handles the `tax_query` argument. There is a `parse_tax_query` that handles each "small" query.
1775 *
1776 * @since 4.4.0
1777 * @param array $args WP_Query arguments
1778 * @param WP_Query $query WP_Query object
1779 * @return array
1780 */
1781 protected function parse_tax_queries( $args, $query ) {
1782 /**
1783 * Tax Query support
1784 *
1785 * Support for the tax_query argument of WP_Query. Currently only provides support for the 'AND' relation
1786 * between taxonomies. Field only supports slug, term_id, and name defaulting to term_id.
1787 *
1788 * @use field = slug
1789 * terms array
1790 * @since 0.9.1
1791 */
1792 if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) ) {
1793 $args['tax_query'] = $query->tax_query->queries;
1794 }
1795
1796 if ( empty( $args['tax_query'] ) ) {
1797 return [];
1798 }
1799
1800 // Main tax_query array for ES.
1801 $es_tax_query = [];
1802
1803 $tax_queries = $this->parse_tax_query( $args['tax_query'] );
1804
1805 if ( ! empty( $tax_queries['tax_filter'] ) ) {
1806 $relation = 'must';
1807
1808 if ( ! empty( $args['tax_query']['relation'] ) && 'or' === strtolower( $args['tax_query']['relation'] ) ) {
1809 $relation = 'should';
1810 }
1811
1812 $es_tax_query[ $relation ] = $tax_queries['tax_filter'];
1813 }
1814
1815 if ( ! empty( $tax_queries['tax_must_not_filter'] ) ) {
1816 $es_tax_query['must_not'] = $tax_queries['tax_must_not_filter'];
1817 }
1818
1819 if ( ! empty( $es_tax_query ) ) {
1820 return [ 'bool' => $es_tax_query ];
1821 }
1822
1823 return [];
1824 }
1825
1826 /**
1827 * Parse the `post_parent` WP Query arg and transform it into an ES query clause.
1828 *
1829 * @since 4.4.0
1830 * @param array $args WP_Query arguments
1831 * @return array
1832 */
1833 protected function parse_post_parent( $args ) {
1834 $has_post_parent = isset( $args['post_parent'] ) && ( in_array( $args['post_parent'], [ 0, '0' ], true ) || ! empty( $args['post_parent'] ) );
1835 if ( ! $has_post_parent || 'any' === strtolower( $args['post_parent'] ) ) {
1836 return [];
1837 }
1838
1839 return [
1840 'bool' => [
1841 'must' => [
1842 'term' => [
1843 'post_parent' => (int) $args['post_parent'],
1844 ],
1845 ],
1846 ],
1847 ];
1848 }
1849
1850 /**
1851 * Parse the `post_parent__in` WP Query arg and transform it into an ES query clause.
1852 *
1853 * @since 4.5.0
1854 * @param array $args WP_Query arguments
1855 * @return array
1856 */
1857 protected function parse_post_parent__in( $args ) {
1858 if ( empty( $args['post_parent__in'] ) ) {
1859 return [];
1860 }
1861
1862 return [
1863 'bool' => [
1864 'must' => [
1865 'terms' => [
1866 'post_parent' => array_values( (array) $args['post_parent__in'] ),
1867 ],
1868 ],
1869 ],
1870 ];
1871 }
1872
1873 /**
1874 * Parse the `post_parent__not_in` WP Query arg and transform it into an ES query clause.
1875 *
1876 * @since 4.5.0
1877 * @param array $args WP_Query arguments
1878 * @return array
1879 */
1880 protected function parse_post_parent__not_in( $args ) {
1881 if ( empty( $args['post_parent__not_in'] ) ) {
1882 return [];
1883 }
1884
1885 return [
1886 'bool' => [
1887 'must_not' => [
1888 'terms' => [
1889 'post_parent' => array_values( (array) $args['post_parent__not_in'] ),
1890 ],
1891 ],
1892 ],
1893 ];
1894 }
1895
1896 /**
1897 * Parse the `post__in` WP Query arg and transform it into an ES query clause.
1898 *
1899 * @since 4.4.0
1900 * @param array $args WP_Query arguments
1901 * @return array
1902 */
1903 protected function parse_post__in( $args ) {
1904 if ( empty( $args['post__in'] ) ) {
1905 return [];
1906 }
1907
1908 return [
1909 'bool' => [
1910 'must' => [
1911 'terms' => [
1912 'post_id' => array_values( (array) $args['post__in'] ),
1913 ],
1914 ],
1915 ],
1916 ];
1917 }
1918
1919 /**
1920 * Parse the `post_name__in` WP Query arg and transform it into an ES query clause.
1921 *
1922 * @since 4.4.0
1923 * @param array $args WP_Query arguments
1924 * @return array
1925 */
1926 protected function parse_post_name__in( $args ) {
1927 if ( empty( $args['post_name__in'] ) ) {
1928 return [];
1929 }
1930
1931 return [
1932 'bool' => [
1933 'must' => [
1934 'terms' => [
1935 'post_name.raw' => array_values( (array) $args['post_name__in'] ),
1936 ],
1937 ],
1938 ],
1939 ];
1940 }
1941
1942 /**
1943 * Parse the `post__not_in` WP Query arg and transform it into an ES query clause.
1944 *
1945 * @since 4.4.0
1946 * @param array $args WP_Query arguments
1947 * @return array
1948 */
1949 protected function parse_post__not_in( $args ) {
1950 if ( empty( $args['post__not_in'] ) ) {
1951 return [];
1952 }
1953
1954 return [
1955 'bool' => [
1956 'must_not' => [
1957 'terms' => [
1958 'post_id' => (array) $args['post__not_in'],
1959 ],
1960 ],
1961 ],
1962 ];
1963 }
1964
1965 /**
1966 * Parse the `category__not_in` WP Query arg and transform it into an ES query clause.
1967 *
1968 * @since 4.4.0
1969 * @param array $args WP_Query arguments
1970 * @return array
1971 */
1972 protected function parse_category__not_in( $args ) {
1973 if ( empty( $args['category__not_in'] ) ) {
1974 return [];
1975 }
1976
1977 return [
1978 'bool' => [
1979 'must_not' => [
1980 'terms' => [
1981 'terms.category.term_id' => array_values( (array) $args['category__not_in'] ),
1982 ],
1983 ],
1984 ],
1985 ];
1986 }
1987
1988 /**
1989 * Parse the `tag__not_in` WP Query arg and transform it into an ES query clause.
1990 *
1991 * @since 4.4.0
1992 * @param array $args WP_Query arguments
1993 * @return array
1994 */
1995 protected function parse_tag__not_in( $args ) {
1996 if ( empty( $args['tag__not_in'] ) ) {
1997 return [];
1998 }
1999
2000 return [
2001 'bool' => [
2002 'must_not' => [
2003 'terms' => [
2004 'terms.post_tag.term_id' => array_values( (array) $args['tag__not_in'] ),
2005 ],
2006 ],
2007 ],
2008 ];
2009 }
2010
2011 /**
2012 * Parse the various author-related WP Query args and transform them into ES query clauses.
2013 *
2014 * @since 4.4.0
2015 * @param array $args WP_Query arguments
2016 * @return array
2017 */
2018 protected function parse_author( $args ) {
2019 if ( ! empty( $args['author'] ) ) {
2020 return [
2021 'term' => [
2022 'post_author.id' => $args['author'],
2023 ],
2024 ];
2025 }
2026
2027 if ( ! empty( $args['author_name'] ) ) {
2028 // Since this was set to use the display name initially, there might be some code that used this feature.
2029 // Let's ensure that any query vars coming in using author_name are in fact slugs.
2030 // This was changed back in ticket #1622 to use the display name, so we removed the sanitize_user() call.
2031 return [
2032 'term' => [
2033 'post_author.display_name' => $args['author_name'],
2034 ],
2035 ];
2036 }
2037
2038 if ( ! empty( $args['author__in'] ) ) {
2039 return [
2040 'bool' => [
2041 'must' => [
2042 'terms' => [
2043 'post_author.id' => array_values( (array) $args['author__in'] ),
2044 ],
2045 ],
2046 ],
2047 ];
2048 }
2049
2050 if ( ! empty( $args['author__not_in'] ) ) {
2051 return [
2052 'bool' => [
2053 'must_not' => [
2054 'terms' => [
2055 'post_author.id' => array_values( (array) $args['author__not_in'] ),
2056 ],
2057 ],
2058 ],
2059 ];
2060 }
2061
2062 return [];
2063 }
2064
2065 /**
2066 * Parse the `post_mime_type` WP Query arg and transform it into an ES query clause.
2067 *
2068 * If we have array, it will be fool text search filter.
2069 * If we have string(like filter images in media screen), we will have mime type "image" so need to check it as
2070 * regexp filter.
2071 *
2072 * @since 4.4.0
2073 * @param array $args WP_Query arguments
2074 * @return array
2075 */
2076 protected function parse_post_mime_type( $args ) {
2077 if ( empty( $args['post_mime_type'] ) ) {
2078 return [];
2079 }
2080
2081 if ( is_array( $args['post_mime_type'] ) ) {
2082
2083 $args_post_mime_type = [];
2084
2085 foreach ( $args['post_mime_type'] as $mime_type ) {
2086 /**
2087 * check if matches the MIME type pattern: type/subtype and
2088 * leave an empty string as posts, pages and CPTs don't have a MIME type
2089 */
2090 if ( preg_match( '/^[-._a-z0-9]+\/[-._a-z0-9]+$/i', $mime_type ) || empty( $mime_type ) ) {
2091 $args_post_mime_type[] = $mime_type;
2092 } else {
2093 $filtered_mime_type_by_type = wp_match_mime_types( $mime_type, wp_get_mime_types() );
2094
2095 $args_post_mime_type = array_merge( $args_post_mime_type, $filtered_mime_type_by_type[ $mime_type ] );
2096 }
2097 }
2098
2099 return [
2100 'terms' => [
2101 'post_mime_type' => $args_post_mime_type,
2102 ],
2103 ];
2104 }
2105
2106 if ( is_string( $args['post_mime_type'] ) ) {
2107 return [
2108 'regexp' => array(
2109 'post_mime_type' => $args['post_mime_type'] . '.*',
2110 ),
2111 ];
2112 }
2113
2114 return [];
2115 }
2116
2117 /**
2118 * Parse the various date-related WP Query args and transform them into ES query clauses.
2119 *
2120 * @since 4.4.0
2121 * @param array $args WP_Query arguments
2122 * @return array
2123 */
2124 protected function parse_date( $args ) {
2125 $date_filter = DateQuery::simple_es_date_filter( $args );
2126
2127 if ( ! empty( $date_filter ) ) {
2128 return $date_filter;
2129 }
2130
2131 if ( ! empty( $args['date_query'] ) ) {
2132
2133 $date_query = new DateQuery( $args['date_query'] );
2134
2135 $date_filter = $date_query->get_es_filter();
2136
2137 if ( array_key_exists( 'and', $date_filter ) ) {
2138 return $date_filter['and'];
2139 }
2140 }
2141 }
2142
2143 /**
2144 * Parse all meta queries.
2145 *
2146 * Although the name may be misleading, it handles the `meta_query` argument. There is a `build_meta_query` that handles each "small" query.
2147 *
2148 * @since 4.4.0
2149 * @param array $args WP_Query arguments
2150 * @return array
2151 */
2152 protected function parse_meta_queries( $args ) {
2153 /**
2154 * 'meta_query' arg support.
2155 *
2156 * Relation supports 'AND' and 'OR'. 'AND' is the default. For each individual query, the
2157 * following 'compare' values are supported: =, !=, EXISTS, NOT EXISTS. '=' is the default.
2158 *
2159 * @since 1.3
2160 */
2161 $meta_queries = ( ! empty( $args['meta_query'] ) ) ? $args['meta_query'] : [];
2162 $meta_queries = ( new \WP_Meta_Query() )->sanitize_query( $meta_queries );
2163
2164 /**
2165 * Todo: Support meta_type
2166 */
2167
2168 /**
2169 * Support `meta_key`, `meta_value`, `meta_value_num`, and `meta_compare` query args
2170 */
2171 if ( ! empty( $args['meta_key'] ) ) {
2172 $meta_query_array = [
2173 'key' => $args['meta_key'],
2174 ];
2175
2176 if ( isset( $args['meta_value'] ) && '' !== $args['meta_value'] ) {
2177 $meta_query_array['value'] = $args['meta_value'];
2178 } elseif ( isset( $args['meta_value_num'] ) && '' !== $args['meta_value_num'] ) {
2179 $meta_query_array['value'] = $args['meta_value_num'];
2180 }
2181
2182 if ( isset( $args['meta_compare'] ) ) {
2183 $meta_query_array['compare'] = $args['meta_compare'];
2184 }
2185
2186 if ( ! empty( $meta_queries ) ) {
2187 $meta_queries = [
2188 'relation' => 'AND',
2189 $meta_query_array,
2190 $meta_queries,
2191 ];
2192 } else {
2193 $meta_queries = [ $meta_query_array ];
2194 }
2195 }
2196
2197 if ( ! empty( $meta_queries ) ) {
2198 // get meta query filter
2199 $meta_filter = $this->build_meta_query( $meta_queries );
2200
2201 if ( ! empty( $meta_filter ) ) {
2202 return $meta_filter;
2203 }
2204 }
2205
2206 return [];
2207 }
2208
2209 /**
2210 * Parse the `post_type` WP Query arg and transform it into an ES query clause.
2211 *
2212 * @since 4.4.0
2213 * @param array $args WP_Query arguments
2214 * @return array
2215 */
2216 protected function parse_post_type( $args ) {
2217 /**
2218 * If not set default to post. If search and not set, default to "any".
2219 */
2220 if ( ! empty( $args['post_type'] ) ) {
2221 // should NEVER be "any" but just in case
2222 if ( 'any' !== $args['post_type'] ) {
2223 $post_types = (array) $args['post_type'];
2224 $terms_map_name = 'terms';
2225
2226 return [
2227 $terms_map_name => [
2228 'post_type.raw' => array_values( $post_types ),
2229 ],
2230 ];
2231 }
2232 } elseif ( empty( $args['s'] ) ) {
2233 return [
2234 'term' => [
2235 'post_type.raw' => 'post',
2236 ],
2237 ];
2238 }
2239
2240 return [];
2241 }
2242
2243 /**
2244 * Parse the `post_status` WP Query arg and transform it into an ES query clause.
2245 *
2246 * @since 4.4.0
2247 * @param array $args WP_Query arguments
2248 * @return array
2249 */
2250 protected function parse_post_status( $args ) {
2251 /**
2252 * Like WP_Query in search context, if no post_status is specified we default to "any". To
2253 * be safe you should ALWAYS specify the post_status parameter UNLIKE with WP_Query.
2254 *
2255 * @since 2.1
2256 */
2257 if ( ! empty( $args['post_status'] ) ) {
2258 // should NEVER be "any" but just in case
2259 if ( 'any' !== $args['post_status'] ) {
2260 $post_status = (array) ( is_string( $args['post_status'] ) ? explode( ',', $args['post_status'] ) : $args['post_status'] );
2261 $post_status = array_map( 'trim', $post_status );
2262 $terms_map_name = 'terms';
2263 if ( count( $post_status ) < 2 ) {
2264 $terms_map_name = 'term';
2265 $post_status = $post_status[0];
2266 }
2267
2268 return [
2269 $terms_map_name => [
2270 'post_status' => $post_status,
2271 ],
2272 ];
2273 }
2274 } else {
2275 $statuses = get_post_stati( array( 'public' => true ) );
2276
2277 if ( is_admin() ) {
2278 /**
2279 * In the admin we will add protected and private post statuses to the default query
2280 * per WP default behavior.
2281 */
2282 $statuses = array_merge(
2283 $statuses,
2284 get_post_stati(
2285 array(
2286 'protected' => true,
2287 'show_in_admin_all_list' => true,
2288 )
2289 )
2290 );
2291
2292 if ( is_user_logged_in() ) {
2293 $statuses = array_merge( $statuses, get_post_stati( array( 'private' => true ) ) );
2294 }
2295 }
2296
2297 $statuses = array_values( $statuses );
2298
2299 $post_status_filter_type = 'terms';
2300
2301 return [
2302 $post_status_filter_type => [
2303 'post_status' => $statuses,
2304 ],
2305 ];
2306 }
2307
2308 return [];
2309 }
2310
2311 /**
2312 * If in a search context set search fields, otherwise query everything.
2313 *
2314 * @since 4.4.0
2315 * @param array $formatted_args Formatted Elasticsearch query
2316 * @param array $args WP_Query arguments
2317 * @return array
2318 */
2319 protected function maybe_set_search_fields( $formatted_args, $args ) {
2320 /**
2321 * Allow for search field specification
2322 *
2323 * @since 1.0
2324 */
2325 if ( ! empty( $args['search_fields'] ) ) {
2326 $search_field_args = $args['search_fields'];
2327 $search_fields = [];
2328
2329 if ( ! empty( $search_field_args['taxonomies'] ) ) {
2330 $taxes = (array) $search_field_args['taxonomies'];
2331
2332 foreach ( $taxes as $tax ) {
2333 $search_fields[] = 'terms.' . $tax . '.name';
2334 }
2335
2336 unset( $search_field_args['taxonomies'] );
2337 }
2338
2339 if ( ! empty( $search_field_args['meta'] ) ) {
2340 $metas = (array) $search_field_args['meta'];
2341
2342 foreach ( $metas as $meta ) {
2343 $search_fields[] = 'meta.' . $meta . '.value';
2344 }
2345
2346 unset( $search_field_args['meta'] );
2347 }
2348
2349 if ( in_array( 'author_name', $search_field_args, true ) ) {
2350 $search_fields[] = 'post_author.login';
2351
2352 $author_name_index = array_search( 'author_name', $search_field_args, true );
2353 unset( $search_field_args[ $author_name_index ] );
2354 }
2355
2356 $search_fields = array_merge( $search_field_args, $search_fields );
2357 } else {
2358 $search_fields = array(
2359 'post_title',
2360 'post_excerpt',
2361 'post_content',
2362 );
2363 }
2364
2365 /**
2366 * Filter default post search fields
2367 *
2368 * If you are using the weighting engine, this filter should not be used.
2369 * Instead, you should use the ep_weighting_configuration_for_search filter.
2370 *
2371 * @hook ep_search_fields
2372 * @param {array} $search_fields Default search fields
2373 * @param {array} $args WP Query arguments
2374 * @return {array} New defaults
2375 */
2376 $search_fields = apply_filters( 'ep_search_fields', $search_fields, $args );
2377
2378 $search_text = ( ! empty( $args['s'] ) ) ? $args['s'] : '';
2379
2380 /**
2381 * We are using ep_integrate instead of ep_match_all. ep_match_all will be
2382 * supported for legacy code but may be deprecated and removed eventually.
2383 *
2384 * @since 1.3
2385 */
2386
2387 if ( ! empty( $search_text ) ) {
2388 add_filter( 'ep_post_formatted_args_query', [ $this, 'adjust_query_fuzziness' ], 100, 4 );
2389
2390 $search_algorithm = $this->get_search_algorithm( $search_text, $search_fields, $args );
2391 $formatted_args['query'] = $search_algorithm->get_query( 'post', $search_text, $search_fields, $args );
2392 } elseif ( ! empty( $args['ep_match_all'] ) || ! empty( $args['ep_integrate'] ) ) {
2393 $formatted_args['query']['match_all'] = array(
2394 'boost' => 1,
2395 );
2396 }
2397
2398 return $formatted_args;
2399 }
2400
2401 /**
2402 * If needed bring sticky posts and order them.
2403 *
2404 * @since 4.4.0
2405 * @param array $formatted_args Formatted Elasticsearch query
2406 * @param array $args WP_Query arguments
2407 * @return array
2408 */
2409 protected function maybe_add_sticky_posts( $formatted_args, $args ) {
2410 /**
2411 * Sticky posts support
2412 */
2413
2414 // Check first if there's sticky posts and show them only in the front page
2415 $sticky_posts = get_option( 'sticky_posts' );
2416 $sticky_posts = ( is_array( $sticky_posts ) && empty( $sticky_posts ) ) ? false : $sticky_posts;
2417
2418 /**
2419 * Filter whether to enable sticky posts for this request
2420 *
2421 * @hook ep_enable_sticky_posts
2422 *
2423 * @param {bool} $allow Allow sticky posts for this request
2424 * @param {array} $args Query variables
2425 * @param {array} $formatted_args EP formatted args
2426 *
2427 * @return {bool} $allow
2428 */
2429 $enable_sticky_posts = apply_filters( 'ep_enable_sticky_posts', is_home(), $args, $formatted_args );
2430
2431 if ( false !== $sticky_posts
2432 && $enable_sticky_posts
2433 && empty( $args['s'] )
2434 && in_array( $args['ignore_sticky_posts'], array( 'false', 0, false ), true ) ) {
2435 $new_sort = [
2436 [
2437 '_score' => [
2438 'order' => 'desc',
2439 ],
2440 ],
2441 ];
2442
2443 $formatted_args['sort'] = array_merge( $new_sort, $formatted_args['sort'] );
2444
2445 $formatted_args_query = $formatted_args['query'];
2446 $formatted_args['query'] = array();
2447 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
2448 $formatted_args['query']['function_score']['functions'] = array(
2449 // add extra weight to sticky posts to show them on top
2450 (object) array(
2451 'filter' => array(
2452 'terms' => array( '_id' => $sticky_posts ),
2453 ),
2454 'weight' => 20,
2455 ),
2456 );
2457 }
2458
2459 return $formatted_args;
2460 }
2461
2462 /**
2463 * If needed set the `fields` ES query clause.
2464 *
2465 * @since 4.4.0
2466 * @param array $formatted_args Formatted Elasticsearch query
2467 * @param array $args WP_Query arguments
2468 * @return array
2469 */
2470 protected function maybe_set_fields( $formatted_args, $args ) {
2471 /**
2472 * Support fields.
2473 */
2474 if ( isset( $args['fields'] ) ) {
2475 switch ( $args['fields'] ) {
2476 case 'ids':
2477 $formatted_args['_source'] = array(
2478 'includes' => array(
2479 'post_id',
2480 ),
2481 );
2482 break;
2483
2484 case 'id=>parent':
2485 $formatted_args['_source'] = array(
2486 'includes' => array(
2487 'post_id',
2488 'post_parent',
2489 ),
2490 );
2491 break;
2492 }
2493 }
2494
2495 return $formatted_args;
2496 }
2497
2498 /**
2499 * If needed set the `aggs` ES query clause.
2500 *
2501 * @since 4.4.0
2502 * @param array $formatted_args Formatted Elasticsearch query.
2503 * @param array $args WP_Query arguments
2504 * @param array $filters Filters to be applied to the ES query
2505 * @return array
2506 */
2507 protected function maybe_set_aggs( $formatted_args, $args, $filters ) {
2508 /**
2509 * Aggregations
2510 */
2511 if ( ! empty( $args['aggs'] ) && is_array( $args['aggs'] ) ) {
2512 // Check if the array indexes are all numeric.
2513 $agg_keys = array_keys( $args['aggs'] );
2514 $agg_num_keys = array_filter( $agg_keys, 'is_int' );
2515 $has_only_num_keys = count( $agg_num_keys ) === count( $args['aggs'] );
2516
2517 if ( $has_only_num_keys ) {
2518 foreach ( $args['aggs'] as $agg ) {
2519 $formatted_args = $this->apply_aggregations( $formatted_args, $agg, ! empty( $filters ), $filters );
2520 }
2521 } else {
2522 // Single aggregation.
2523 $formatted_args = $this->apply_aggregations( $formatted_args, $args['aggs'], ! empty( $filters ), $filters );
2524 }
2525 }
2526
2527 return $formatted_args;
2528 }
2529
2530 /**
2531 * Parse tax query field value.
2532 *
2533 * @since 4.4.0
2534 * @param string $field Field name
2535 * @return string
2536 */
2537 protected function parse_tax_query_field( string $field ) : string {
2538
2539 $from_to = [
2540 'name' => 'name.raw',
2541 'slug' => 'slug',
2542 'term_taxonomy_id' => 'term_taxonomy_id',
2543 ];
2544
2545 return $from_to[ $field ] ?? 'term_id';
2546 }
2547
2548 /**
2549 * Return all distinct meta fields in the database.
2550 *
2551 * @since 4.4.0
2552 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2553 * @return array
2554 */
2555 public function get_distinct_meta_field_keys_db( bool $force_refresh = false ) : array {
2556 global $wpdb;
2557
2558 /**
2559 * Short-circuits the process of getting distinct meta keys from the database.
2560 *
2561 * Returning a non-null value will effectively short-circuit the function.
2562 *
2563 * @since 4.4.0
2564 * @hook ep_post_pre_meta_keys_db
2565 * @param {null} $meta_keys Distinct meta keys array
2566 * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2567 */
2568 $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db', null );
2569 if ( null !== $pre_meta_keys ) {
2570 return $pre_meta_keys;
2571 }
2572
2573 $cache_key = 'ep_meta_field_keys';
2574
2575 if ( ! $force_refresh ) {
2576 $cached = get_transient( $cache_key );
2577 if ( false !== $cached ) {
2578 $cached = (array) json_decode( (string) $cached );
2579 /* this filter is documented below */
2580 return (array) apply_filters( 'ep_post_meta_keys_db', $cached );
2581 }
2582 }
2583
2584 /**
2585 * To avoid running a too expensive SQL query, we run a query getting all public keys
2586 * and only the private keys allowed by the `ep_prepare_meta_allowed_protected_keys` filter.
2587 * This query does not order by on purpose, as that also brings a performance penalty.
2588 */
2589 $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], new \WP_Post( (object) [] ) );
2590 $allowed_protected_keys_sql = '';
2591 if ( ! empty( $allowed_protected_keys ) ) {
2592 $placeholders = implode( ',', array_fill( 0, count( $allowed_protected_keys ), '%s' ) );
2593 $allowed_protected_keys_sql = " OR meta_key IN ( {$placeholders} ) ";
2594 }
2595
2596 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
2597 $meta_keys = $wpdb->get_col(
2598 $wpdb->prepare(
2599 "SELECT DISTINCT meta_key
2600 FROM {$wpdb->postmeta}
2601 WHERE meta_key NOT LIKE %s {$allowed_protected_keys_sql}
2602 LIMIT 800",
2603 '\_%',
2604 ...$allowed_protected_keys
2605 )
2606 );
2607 // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
2608
2609 sort( $meta_keys );
2610
2611 // Make sure the size of the transient will not be bigger than 1MB
2612 do {
2613 $transient_size = strlen( wp_json_encode( $meta_keys ) );
2614 if ( $transient_size >= MB_IN_BYTES ) {
2615 array_pop( $meta_keys );
2616 } else {
2617 break;
2618 }
2619 } while ( true );
2620 set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2621
2622 /**
2623 * Filter the distinct meta keys fetched from the database.
2624 *
2625 * @since 4.4.0
2626 * @hook ep_post_meta_keys_db
2627 * @param {array} $meta_keys Distinct meta keys array
2628 * @return {array} New distinct meta keys array
2629 */
2630 return (array) apply_filters( 'ep_post_meta_keys_db', $meta_keys );
2631 }
2632
2633 /**
2634 * Return all distinct meta fields in the database per post type.
2635 *
2636 * @since 4.4.0
2637 * @param string $post_type Post type slug
2638 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2639 * @return array
2640 */
2641 public function get_distinct_meta_field_keys_db_per_post_type( string $post_type, bool $force_refresh = false ) : array {
2642 $allowed_screen = 'status-report' === \ElasticPress\Screen::factory()->get_current_screen();
2643
2644 /**
2645 * Filter if the current screen is allowed or not to use the function.
2646 *
2647 * This method can be too resource intensive, use it with caution.
2648 *
2649 * @since 4.4.0
2650 * @hook ep_post_meta_keys_db_per_post_type_allowed_screen
2651 * @param {bool} $allowed_screen Whether this is an allowed screen or not.
2652 * @return {bool} New value of $allowed_screen
2653 */
2654 if ( ! apply_filters( 'ep_post_meta_keys_db_per_post_type_allowed_screen', $allowed_screen ) ) {
2655 _doing_it_wrong(
2656 __METHOD__,
2657 esc_html__( 'This method should not be called outside specific pages. Use the `ep_post_meta_keys_db_per_post_type_allowed_screen` filter if you need to use it in your custom screen.' ),
2658 'ElasticPress 4.4.0'
2659 );
2660 return [];
2661 }
2662
2663 /**
2664 * Short-circuits the process of getting distinct meta keys from the database per post type.
2665 *
2666 * Returning a non-null value will effectively short-circuit the function.
2667 *
2668 * @since 4.4.0
2669 * @hook ep_post_pre_meta_keys_db_per_post_type
2670 * @param {null} $meta_keys Distinct meta keys array
2671 * @param {string} $post_type Post type slug
2672 * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2673 */
2674 $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db_per_post_type', null, $post_type );
2675 if ( null !== $pre_meta_keys ) {
2676 return $pre_meta_keys;
2677 }
2678
2679 $cache_key = 'ep_meta_field_keys_' . $post_type;
2680
2681 if ( ! $force_refresh ) {
2682 $cached = get_transient( $cache_key );
2683 if ( false !== $cached ) {
2684 $cached = (array) json_decode( (string) $cached );
2685 /* this filter is documented below */
2686 return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $cached, $post_type );
2687 }
2688 }
2689
2690 $meta_keys = [];
2691 $post_ids_batches = $this->get_lazy_post_type_ids( $post_type );
2692 foreach ( $post_ids_batches as $post_ids ) {
2693 $new_meta_keys = $this->get_meta_keys_from_post_ids( $post_ids );
2694
2695 $meta_keys = array_unique( array_merge( $meta_keys, $new_meta_keys ) );
2696 }
2697
2698 // Make sure the size of the transient will not be bigger than 1MB
2699 do {
2700 $transient_size = strlen( wp_json_encode( $meta_keys ) );
2701 if ( $transient_size >= MB_IN_BYTES ) {
2702 array_pop( $meta_keys );
2703 } else {
2704 break;
2705 }
2706 } while ( true );
2707 set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2708
2709 /**
2710 * Filter the distinct meta keys fetched from the database per post type.
2711 *
2712 * @since 4.4.0
2713 * @hook ep_post_meta_keys_db_per_post_type
2714 * @param {array} $meta_keys Distinct meta keys array
2715 * @param {string} $post_type Post type slug
2716 * @return {array} New distinct meta keys array
2717 */
2718 return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $meta_keys, $post_type );
2719 }
2720
2721 /**
2722 * Return all distinct meta fields in the database per post type.
2723 *
2724 * @since 4.4.0
2725 * @param string $post_type Post type slug
2726 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2727 * @return array
2728 */
2729 public function get_indexable_meta_keys_per_post_type( string $post_type, bool $force_refresh = false ) : array {
2730 $mock_post = new \WP_Post( (object) [ 'post_type' => $post_type ] );
2731 $meta_keys = $this->get_distinct_meta_field_keys_db_per_post_type( $post_type, $force_refresh );
2732
2733 $fake_meta_values = array_combine( $meta_keys, array_fill( 0, count( $meta_keys ), 'test-value' ) );
2734 $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $mock_post );
2735
2736 return array_filter(
2737 array_keys( $filtered_meta ),
2738 function ( $meta_key ) use ( $mock_post ) {
2739 return $this->is_meta_allowed( $meta_key, $mock_post );
2740 }
2741 );
2742 }
2743
2744 /**
2745 * Return the meta keys that will (possibly) be indexed.
2746 *
2747 * This function gets all the meta keys in the database, creates a fake post without a type and with all the meta fields,
2748 * runs the `ep_prepare_meta_data` filter against it and checks if meta keys are allowed or not.
2749 * Although it provides a good indicator, it is not 100% correct as developers could create code using the
2750 * `ep_prepare_meta_data` filter that would depend on "real" data.
2751 *
2752 * @since 4.4.0
2753 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2754 * @return array
2755 */
2756 public function get_predicted_indexable_meta_keys( bool $force_refresh = false ) : array {
2757 $empty_post = new \WP_Post( (object) [] );
2758 $meta_keys = $this->get_distinct_meta_field_keys_db( $force_refresh );
2759
2760 $fake_meta_values = array_combine( $meta_keys, array_fill( 0, count( $meta_keys ), 'test-value' ) );
2761 $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $empty_post );
2762
2763 $all_keys = array_filter(
2764 array_keys( $filtered_meta ),
2765 function( $meta_key ) use ( $empty_post ) {
2766 return $this->is_meta_allowed( $meta_key, $empty_post );
2767 }
2768 );
2769
2770 sort( $all_keys );
2771
2772 return $all_keys;
2773 }
2774
2775 /**
2776 * Given a post type, *yields* their Post IDs.
2777 *
2778 * If post IDs are found, this function will return a PHP Generator. To avoid timeout, it will yield 8 groups or 11,000 IDs.
2779 *
2780 * @since 4.4.0
2781 * @see https://www.php.net/manual/en/language.generators.overview.php
2782 * @param string $post_type The post type slug
2783 * @return iterator
2784 */
2785 protected function get_lazy_post_type_ids( string $post_type ) {
2786 global $wpdb;
2787
2788 $total = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2789 $wpdb->prepare(
2790 "SELECT count(*) FROM {$wpdb->posts} WHERE post_type = %s",
2791 $post_type
2792 )
2793 );
2794
2795 if ( ! $total ) {
2796 return [];
2797 }
2798
2799 /**
2800 * Filter the number of IDs to be fetched per page to discover distinct meta fields per post type.
2801 *
2802 * @hook ep_post_meta_by_type_ids_per_page
2803 * @since 4.4.0
2804 * @param {int} $per_page Number of IDs
2805 * @param {string} $post_type The post type slug
2806 * @return {string} New number of IDs
2807 */
2808 $per_page = apply_filters( 'ep_post_meta_by_type_ids_per_page', 11000, $post_type );
2809
2810 $pages = min( ceil( $total / $per_page ), 8 );
2811
2812 /**
2813 * Filter the number of times EP will fetch IDs from the database
2814 *
2815 * @hook ep_post_meta_by_type_number_of_pages
2816 * @since 4.4.0
2817 * @param {int} $pages Number of "pages" (not WP post type)
2818 * @param {int} $per_page Number of IDs per page
2819 * @param {string} $post_type The post type slug
2820 * @return {string} New number of pages
2821 */
2822 $pages = apply_filters( 'ep_post_meta_by_type_number_of_pages', $pages, $per_page, $post_type );
2823
2824 for ( $page = 0; $page < $pages; $page++ ) {
2825 $start = $per_page * $page;
2826 $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2827 $wpdb->prepare(
2828 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s LIMIT %d, %d",
2829 $post_type,
2830 $start,
2831 $per_page
2832 )
2833 );
2834 yield $ids;
2835 }
2836 }
2837
2838 /**
2839 * Given a set of post IDs, return distinct meta keys associated with them.
2840 *
2841 * @since 4.4.0
2842 * @param array $post_ids Set of post IDs
2843 * @return array
2844 */
2845 protected function get_meta_keys_from_post_ids( array $post_ids ) : array {
2846 global $wpdb;
2847
2848 if ( empty( $post_ids ) ) {
2849 return [];
2850 }
2851
2852 $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
2853 $meta_keys = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2854 $wpdb->prepare(
2855 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
2856 "SELECT DISTINCT meta_key FROM {$wpdb->postmeta} WHERE post_id IN ( {$placeholders} )",
2857 $post_ids
2858 )
2859 );
2860
2861 return $meta_keys;
2862 }
2863
2864 /**
2865 * Add a `term_suggest` field to the mapping.
2866 *
2867 * This method assumes the `edge_ngram_analyzer` analyzer was already added to the mapping.
2868 *
2869 * @since 4.5.0
2870 * @param array $mapping The mapping array
2871 * @return array
2872 */
2873 public function add_term_suggest_field( array $mapping ) : array {
2874 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
2875 $mapping_properties = &$mapping['mappings']['post']['properties'];
2876 } else {
2877 $mapping_properties = &$mapping['mappings']['properties'];
2878 }
2879
2880 $text_type = $mapping_properties['post_content']['type'];
2881
2882 $mapping_properties['term_suggest'] = array(
2883 'type' => $text_type,
2884 'analyzer' => 'edge_ngram_analyzer',
2885 'search_analyzer' => 'standard',
2886 );
2887
2888 return $mapping;
2889 }
2890 }
2891