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

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