PluginProbe
ElasticPress / 4.7.2
ElasticPress v4.7.2
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.7.2, at includes/classes/Indexable/Post/Post.php

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