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

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