PluginProbe
ElasticPress / 5.0.2
ElasticPress v5.0.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 5.0.2, at includes/classes/Indexable/Post/Post.php

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