PluginProbe
ElasticPress / 4.5.1
ElasticPress v4.5.1
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Indexable / Post / Post.php

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

2,799 lines 78.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 != ''" );
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 = absint( $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(
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 ( in_array( $orderby_clause, [ 'meta_value', 'meta_value_num' ], true ) ) {
1254 if ( empty( $args['meta_key'] ) ) {
1255 continue;
1256 } else {
1257 $from_to['meta_value'] = 'meta.' . $args['meta_key'] . '.raw';
1258 $from_to['meta_value_num'] = 'meta.' . $args['meta_key'] . '.long';
1259 }
1260 }
1261
1262 $orderby_clause = $from_to[ $orderby_clause ] ?? $orderby_clause;
1263
1264 $sort[] = array(
1265 $orderby_clause => array(
1266 'order' => $order,
1267 ),
1268 );
1269 }
1270
1271 return $sort;
1272 }
1273
1274 /**
1275 * Get Order by args Array
1276 *
1277 * @param string|array $orderbys Order by string or array
1278 * @since 2.1
1279 * @return array
1280 */
1281 protected function get_orderby_array( $orderbys ) {
1282 if ( ! is_array( $orderbys ) ) {
1283 $orderbys = explode( ' ', $orderbys );
1284 }
1285
1286 return $orderbys;
1287 }
1288
1289 /**
1290 * Given a mapping content, try to determine the version used.
1291 *
1292 * @since 3.6.3
1293 *
1294 * @param array $mapping Mapping content.
1295 * @param string $index Index name
1296 * @return string Version of the mapping being used.
1297 */
1298 protected function determine_mapping_version_based_on_existing( $mapping, $index ) {
1299 if ( isset( $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'] ) ) {
1300 return $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'];
1301 }
1302 if ( isset( $mapping[ $index ]['mappings']['_meta']['mapping_version'] ) ) {
1303 return $mapping[ $index ]['mappings']['_meta']['mapping_version'];
1304 }
1305
1306 /**
1307 * Check for 7-0 mapping.
1308 * If mapping has a `post` type, it can't be ES 7, as mapping types were removed in that release.
1309 *
1310 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
1311 */
1312 if ( ! isset( $mapping[ $index ]['mappings']['post'] ) ) {
1313 return '7-0.php';
1314 }
1315
1316 $post_mapping = $mapping[ $index ]['mappings']['post'];
1317
1318 /**
1319 * Starting at this point, our tests rely on the post_title.fields.sortable field.
1320 * As this field is present in all our mappings, if this field is not present in
1321 * the mapping, this is a custom mapping.
1322 *
1323 * To have this code working with custom mappings, use the `ep_post_mapping_version_determined` filter.
1324 */
1325 if ( ! isset( $post_mapping['properties']['post_title']['fields']['sortable'] ) ) {
1326 return 'unknown';
1327 }
1328
1329 $post_title_sortable = $post_mapping['properties']['post_title']['fields']['sortable'];
1330
1331 /**
1332 * Check for 5-2 mapping.
1333 * Normalizers on keyword fields were only made available in ES 5.2
1334 *
1335 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.2/release-notes-5.2.0.html
1336 */
1337 if ( isset( $post_title_sortable['normalizer'] ) ) {
1338 return '5-2.php';
1339 }
1340
1341 /**
1342 * Check for 5-0 mapping.
1343 * `keyword` fields were only made available in ES 5.0
1344 *
1345 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
1346 */
1347 if ( 'keyword' === $post_title_sortable['type'] ) {
1348 return '5-0.php';
1349 }
1350
1351 /**
1352 * Check for pre-5-0 mapping.
1353 * `string` fields were deprecated in ES 5.0 in favor of text/keyword
1354 *
1355 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
1356 */
1357 if ( 'string' === $post_title_sortable['type'] ) {
1358 return 'pre-5-0.php';
1359 }
1360
1361 return 'unknown';
1362 }
1363
1364 /**
1365 * Given ES args, add aggregations to it.
1366 *
1367 * @since 4.1.0
1368 * @param array $formatted_args Formatted Elasticsearch query
1369 * @param array $agg Aggregation data.
1370 * @param boolean $use_filters Whether filters should be used or not.
1371 * @param array $filter Filters defined so far.
1372 * @return array Formatted Elasticsearch query with the aggregation added.
1373 */
1374 protected function apply_aggregations( $formatted_args, $agg, $use_filters, $filter ) {
1375 if ( empty( $agg['aggs'] ) ) {
1376 return $formatted_args;
1377 }
1378
1379 // Add a name to the aggregation if it was passed through
1380 $agg_name = ( ! empty( $agg['name'] ) ) ? $agg['name'] : 'aggregation_name';
1381
1382 // Add/use the filter if warranted
1383 if ( isset( $agg['use-filter'] ) && false !== $agg['use-filter'] && $use_filters ) {
1384
1385 // If a filter is being used, use it on the aggregation as well to receive relevant information to the query
1386 $formatted_args['aggs'][ $agg_name ]['filter'] = $filter;
1387 $formatted_args['aggs'][ $agg_name ]['aggs'] = $agg['aggs'];
1388 } else {
1389 $formatted_args['aggs'][ $agg_name ] = $agg['aggs'];
1390 }
1391
1392 return $formatted_args;
1393 }
1394
1395 /**
1396 * Get the search algorithm that should be used.
1397 *
1398 * @since 4.3.0
1399 * @param string $search_text Search term(s)
1400 * @param array $search_fields Search fields
1401 * @param array $query_vars Query vars
1402 * @return SearchAlgorithm Instance of search algorithm to be used
1403 */
1404 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
1405 $search_algorithm_version_option = \ElasticPress\Utils\get_option( 'ep_search_algorithm_version', '4.0' );
1406
1407 /**
1408 * Filter the algorithm version to be used.
1409 *
1410 * @since 3.5
1411 * @hook ep_search_algorithm_version
1412 * @param {string} $search_algorithm_version Algorithm version.
1413 * @return {string} New algorithm version
1414 */
1415 $search_algorithm = apply_filters( 'ep_search_algorithm_version', $search_algorithm_version_option );
1416
1417 /**
1418 * Filter the search algorithm to be used
1419 *
1420 * @hook ep_{$indexable_slug}_search_algorithm
1421 * @since 4.3.0
1422 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1423 * @param {string} $search_term Search term
1424 * @param {array} $search_fields Fields to be searched
1425 * @param {array} $query_vars Query variables
1426 * @return {string} New search algorithm slug
1427 */
1428 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", $search_algorithm, $search_text, $search_fields, $query_vars );
1429
1430 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1431 }
1432
1433 /**
1434 * Based on WP_Query arguments, parses the various filters that could be applied into the ES query.
1435 *
1436 * @since 4.4.0
1437 * @param array $args WP_Query arguments
1438 * @param WP_Query $query WP_Query object
1439 * @return array
1440 */
1441 protected function parse_filters( $args, $query ) {
1442 /**
1443 * A note about the order of this array indices:
1444 * As previously there was no way to access each part, some snippets might be accessing
1445 * these filters by its usual numeric indices (see the array_values() call below.)
1446 */
1447 $filters = [
1448 'tax_query' => $this->parse_tax_queries( $args, $query ),
1449 'post_parent' => $this->parse_post_parent( $args ),
1450 'post_parent__in' => $this->parse_post_parent__in( $args ),
1451 'post_parent__not_in' => $this->parse_post_parent__not_in( $args ),
1452 'post__in' => $this->parse_post__in( $args ),
1453 'post_name__in' => $this->parse_post_name__in( $args ),
1454 'post__not_in' => $this->parse_post__not_in( $args ),
1455 'category__not_in' => $this->parse_category__not_in( $args ),
1456 'tag__not_in' => $this->parse_tag__not_in( $args ),
1457 'author' => $this->parse_author( $args ),
1458 'post_mime_type' => $this->parse_post_mime_type( $args ),
1459 'date' => $this->parse_date( $args ),
1460 'meta_query' => $this->parse_meta_queries( $args ),
1461 'post_type' => $this->parse_post_type( $args ),
1462 'post_status' => $this->parse_post_status( $args ),
1463 ];
1464
1465 /**
1466 * Filter the ES filters that will be applied to the ES query.
1467 *
1468 * Although each index of the `$filters` array contains the related WP Query argument,
1469 * it will be removed before applied to the ES query.
1470 *
1471 * @hook ep_post_filters
1472 * @param {array} Current filters
1473 * @param {array} WP Query args
1474 * @param {WP_Query} WP Query object
1475 * @return {array} New filters
1476 */
1477 $filters = apply_filters( 'ep_post_filters', $filters, $args, $query );
1478
1479 $filters = array_values( array_filter( $filters ) );
1480
1481 if ( ! empty( $filters ) ) {
1482 $filters = [
1483 'bool' => [
1484 'must' => $filters,
1485 ],
1486 ];
1487 }
1488
1489 return $filters;
1490 }
1491
1492 /**
1493 * Sanitize WP_Query arguments to be used to create the ES query.
1494 *
1495 * Elasticsearch will error if a terms query contains empty items like an empty string.
1496 *
1497 * @since 4.4.0
1498 * @param array $args WP_Query arguments
1499 * @return array
1500 */
1501 protected function sanitize_wp_query_args( $args ) {
1502 $keys_to_sanitize = [
1503 'author__in',
1504 'author__not_in',
1505 'category__and',
1506 'category__in',
1507 'category__not_in',
1508 'tag__and',
1509 'tag__in',
1510 'tag__not_in',
1511 'tag_slug__and',
1512 'tag_slug__in',
1513 'post_parent__in',
1514 'post_parent__not_in',
1515 'post__in',
1516 'post__not_in',
1517 'post_name__in',
1518 ];
1519 foreach ( $keys_to_sanitize as $key ) {
1520 if ( ! isset( $args[ $key ] ) ) {
1521 continue;
1522 }
1523 $args[ $key ] = array_filter( (array) $args[ $key ] );
1524 }
1525
1526 return $args;
1527 }
1528
1529 /**
1530 * Parse the `from` clause of the ES Query.
1531 *
1532 * @since 4.4.0
1533 * @param array $args WP_Query arguments
1534 * @return int
1535 */
1536 protected function parse_from( $args ) {
1537 $from = 0;
1538
1539 if ( isset( $args['offset'] ) ) {
1540 $from = (int) $args['offset'];
1541 }
1542
1543 if ( isset( $args['paged'] ) && $args['paged'] > 1 ) {
1544 $from = $args['posts_per_page'] * ( $args['paged'] - 1 );
1545 }
1546
1547 /**
1548 * Fix negative offset. This happens, for example, on hierarchical post types.
1549 *
1550 * Ref: https://github.com/10up/ElasticPress/issues/2480
1551 */
1552 if ( $from < 0 ) {
1553 $from = 0;
1554 }
1555
1556 return $from;
1557 }
1558
1559 /**
1560 * Parse the `size` clause of the ES Query.
1561 *
1562 * @since 4.4.0
1563 * @param array $args WP_Query arguments
1564 * @return int
1565 */
1566 protected function parse_size( $args ) {
1567 if ( empty( $args['posts_per_page'] ) ) {
1568 return (int) get_option( 'posts_per_page' );
1569 }
1570
1571 $posts_per_page = (int) $args['posts_per_page'];
1572
1573 // ES have a maximum size allowed so we have to convert "-1" to a maximum size.
1574 if ( -1 === $posts_per_page ) {
1575 /**
1576 * Filter max result size if set to -1
1577 *
1578 * The request will return a HTTP 500 Internal Error if the size of the
1579 * request is larger than the [index.max_result_window] parameter in ES.
1580 * See the scroll api for a more efficient way to request large data sets.
1581 *
1582 * @hook ep_max_results_window
1583 * @param {int} Max result window
1584 * @return {int} New window
1585 */
1586 $posts_per_page = apply_filters( 'ep_max_results_window', 10000 );
1587 }
1588
1589 return $posts_per_page;
1590 }
1591
1592 /**
1593 * Parse the order of results in the ES query. It could simply be a `sort` clause or a function score query if using RAND.
1594 *
1595 * @since 4.4.0
1596 * @param array $formatted_args Formatted Elasticsearch query
1597 * @param array $args WP_Query arguments
1598 * @return array
1599 */
1600 protected function maybe_orderby( $formatted_args, $args ) {
1601 /**
1602 * Order and Orderby arguments
1603 *
1604 * Used for how Elasticsearch will sort results
1605 *
1606 * @since 1.1
1607 */
1608
1609 // Set sort order, default is 'desc'.
1610 if ( ! empty( $args['order'] ) ) {
1611 $order = $this->parse_order( $args['order'] );
1612 } else {
1613 $order = 'desc';
1614 }
1615
1616 // Default sort for non-searches to date.
1617 if ( empty( $args['orderby'] ) && ( ! isset( $args['s'] ) || '' === $args['s'] ) ) {
1618 /**
1619 * Filter default post query order by
1620 *
1621 * @hook ep_set_default_sort
1622 * @param {string} $sort Default sort
1623 * @param {string $order Order direction
1624 * @return {string} New default
1625 */
1626 $args['orderby'] = apply_filters( 'ep_set_default_sort', 'date', $order );
1627 }
1628
1629 // Set sort type.
1630 if ( ! empty( $args['orderby'] ) ) {
1631 $formatted_args['sort'] = $this->parse_orderby( $args['orderby'], $order, $args );
1632 } else {
1633 // Default sort is to use the score (based on relevance).
1634 $default_sort = array(
1635 array(
1636 '_score' => array(
1637 'order' => $order,
1638 ),
1639 ),
1640 );
1641
1642 /**
1643 * Filter the ES query order (`sort` clause)
1644 *
1645 * This filter is used in searches if `orderby` is not set in the WP_Query args.
1646 * The default value is:
1647 *
1648 * $default_sort = array(
1649 * array(
1650 * '_score' => array(
1651 * 'order' => $order,
1652 * ),
1653 * ),
1654 * );
1655 *
1656 * @hook ep_set_sort
1657 * @since 3.6.3
1658 * @param {array} $sort Default sort.
1659 * @param {string} $order Order direction
1660 * @return {array} New default
1661 */
1662 $default_sort = apply_filters( 'ep_set_sort', $default_sort, $order );
1663
1664 $formatted_args['sort'] = $default_sort;
1665 }
1666
1667 /**
1668 * Order by 'rand' support
1669 *
1670 * Ref: https://github.com/elastic/elasticsearch/issues/1170
1671 */
1672 if ( ! empty( $args['orderby'] ) ) {
1673 $orderbys = $this->get_orderby_array( $args['orderby'] );
1674 if ( in_array( 'rand', $orderbys, true ) ) {
1675 $formatted_args_query = $formatted_args['query'];
1676 $formatted_args['query'] = [];
1677 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1678 $formatted_args['query']['function_score']['random_score'] = (object) [];
1679 }
1680 }
1681
1682 return $formatted_args;
1683 }
1684
1685 /**
1686 * Parse all taxonomy queries.
1687 *
1688 * Although the name may be misleading, it handles the `tax_query` argument. There is a `parse_tax_query` that handles each "small" query.
1689 *
1690 * @since 4.4.0
1691 * @param array $args WP_Query arguments
1692 * @param WP_Query $query WP_Query object
1693 * @return array
1694 */
1695 protected function parse_tax_queries( $args, $query ) {
1696 /**
1697 * Tax Query support
1698 *
1699 * Support for the tax_query argument of WP_Query. Currently only provides support for the 'AND' relation
1700 * between taxonomies. Field only supports slug, term_id, and name defaulting to term_id.
1701 *
1702 * @use field = slug
1703 * terms array
1704 * @since 0.9.1
1705 */
1706 if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) ) {
1707 $args['tax_query'] = $query->tax_query->queries;
1708 }
1709
1710 if ( empty( $args['tax_query'] ) ) {
1711 return [];
1712 }
1713
1714 // Main tax_query array for ES.
1715 $es_tax_query = [];
1716
1717 $tax_queries = $this->parse_tax_query( $args['tax_query'] );
1718
1719 if ( ! empty( $tax_queries['tax_filter'] ) ) {
1720 $relation = 'must';
1721
1722 if ( ! empty( $args['tax_query']['relation'] ) && 'or' === strtolower( $args['tax_query']['relation'] ) ) {
1723 $relation = 'should';
1724 }
1725
1726 $es_tax_query[ $relation ] = $tax_queries['tax_filter'];
1727 }
1728
1729 if ( ! empty( $tax_queries['tax_must_not_filter'] ) ) {
1730 $es_tax_query['must_not'] = $tax_queries['tax_must_not_filter'];
1731 }
1732
1733 if ( ! empty( $es_tax_query ) ) {
1734 return [ 'bool' => $es_tax_query ];
1735 }
1736
1737 return [];
1738 }
1739
1740 /**
1741 * Parse the `post_parent` WP Query arg and transform it into an ES query clause.
1742 *
1743 * @since 4.4.0
1744 * @param array $args WP_Query arguments
1745 * @return array
1746 */
1747 protected function parse_post_parent( $args ) {
1748 $has_post_parent = isset( $args['post_parent'] ) && ( in_array( $args['post_parent'], [ 0, '0' ], true ) || ! empty( $args['post_parent'] ) );
1749 if ( ! $has_post_parent || 'any' === strtolower( $args['post_parent'] ) ) {
1750 return [];
1751 }
1752
1753 return [
1754 'bool' => [
1755 'must' => [
1756 'term' => [
1757 'post_parent' => (int) $args['post_parent'],
1758 ],
1759 ],
1760 ],
1761 ];
1762 }
1763
1764 /**
1765 * Parse the `post_parent__in` WP Query arg and transform it into an ES query clause.
1766 *
1767 * @since 4.5.0
1768 * @param array $args WP_Query arguments
1769 * @return array
1770 */
1771 protected function parse_post_parent__in( $args ) {
1772 if ( empty( $args['post_parent__in'] ) ) {
1773 return [];
1774 }
1775
1776 return [
1777 'bool' => [
1778 'must' => [
1779 'terms' => [
1780 'post_parent' => array_values( (array) $args['post_parent__in'] ),
1781 ],
1782 ],
1783 ],
1784 ];
1785 }
1786
1787 /**
1788 * Parse the `post_parent__not_in` WP Query arg and transform it into an ES query clause.
1789 *
1790 * @since 4.5.0
1791 * @param array $args WP_Query arguments
1792 * @return array
1793 */
1794 protected function parse_post_parent__not_in( $args ) {
1795 if ( empty( $args['post_parent__not_in'] ) ) {
1796 return [];
1797 }
1798
1799 return [
1800 'bool' => [
1801 'must_not' => [
1802 'terms' => [
1803 'post_parent' => array_values( (array) $args['post_parent__not_in'] ),
1804 ],
1805 ],
1806 ],
1807 ];
1808 }
1809
1810 /**
1811 * Parse the `post__in` WP Query arg and transform it into an ES query clause.
1812 *
1813 * @since 4.4.0
1814 * @param array $args WP_Query arguments
1815 * @return array
1816 */
1817 protected function parse_post__in( $args ) {
1818 if ( empty( $args['post__in'] ) ) {
1819 return [];
1820 }
1821
1822 return [
1823 'bool' => [
1824 'must' => [
1825 'terms' => [
1826 'post_id' => array_values( (array) $args['post__in'] ),
1827 ],
1828 ],
1829 ],
1830 ];
1831 }
1832
1833 /**
1834 * Parse the `post_name__in` WP Query arg and transform it into an ES query clause.
1835 *
1836 * @since 4.4.0
1837 * @param array $args WP_Query arguments
1838 * @return array
1839 */
1840 protected function parse_post_name__in( $args ) {
1841 if ( empty( $args['post_name__in'] ) ) {
1842 return [];
1843 }
1844
1845 return [
1846 'bool' => [
1847 'must' => [
1848 'terms' => [
1849 'post_name.raw' => array_values( (array) $args['post_name__in'] ),
1850 ],
1851 ],
1852 ],
1853 ];
1854 }
1855
1856 /**
1857 * Parse the `post__not_in` WP Query arg and transform it into an ES query clause.
1858 *
1859 * @since 4.4.0
1860 * @param array $args WP_Query arguments
1861 * @return array
1862 */
1863 protected function parse_post__not_in( $args ) {
1864 if ( empty( $args['post__not_in'] ) ) {
1865 return [];
1866 }
1867
1868 return [
1869 'bool' => [
1870 'must_not' => [
1871 'terms' => [
1872 'post_id' => (array) $args['post__not_in'],
1873 ],
1874 ],
1875 ],
1876 ];
1877 }
1878
1879 /**
1880 * Parse the `category__not_in` WP Query arg and transform it into an ES query clause.
1881 *
1882 * @since 4.4.0
1883 * @param array $args WP_Query arguments
1884 * @return array
1885 */
1886 protected function parse_category__not_in( $args ) {
1887 if ( empty( $args['category__not_in'] ) ) {
1888 return [];
1889 }
1890
1891 return [
1892 'bool' => [
1893 'must_not' => [
1894 'terms' => [
1895 'terms.category.term_id' => array_values( (array) $args['category__not_in'] ),
1896 ],
1897 ],
1898 ],
1899 ];
1900 }
1901
1902 /**
1903 * Parse the `tag__not_in` WP Query arg and transform it into an ES query clause.
1904 *
1905 * @since 4.4.0
1906 * @param array $args WP_Query arguments
1907 * @return array
1908 */
1909 protected function parse_tag__not_in( $args ) {
1910 if ( empty( $args['tag__not_in'] ) ) {
1911 return [];
1912 }
1913
1914 return [
1915 'bool' => [
1916 'must_not' => [
1917 'terms' => [
1918 'terms.post_tag.term_id' => array_values( (array) $args['tag__not_in'] ),
1919 ],
1920 ],
1921 ],
1922 ];
1923 }
1924
1925 /**
1926 * Parse the various author-related WP Query args and transform them into ES query clauses.
1927 *
1928 * @since 4.4.0
1929 * @param array $args WP_Query arguments
1930 * @return array
1931 */
1932 protected function parse_author( $args ) {
1933 if ( ! empty( $args['author'] ) ) {
1934 return [
1935 'term' => [
1936 'post_author.id' => $args['author'],
1937 ],
1938 ];
1939 }
1940
1941 if ( ! empty( $args['author_name'] ) ) {
1942 // Since this was set to use the display name initially, there might be some code that used this feature.
1943 // Let's ensure that any query vars coming in using author_name are in fact slugs.
1944 // This was changed back in ticket #1622 to use the display name, so we removed the sanitize_user() call.
1945 return [
1946 'term' => [
1947 'post_author.display_name' => $args['author_name'],
1948 ],
1949 ];
1950 }
1951
1952 if ( ! empty( $args['author__in'] ) ) {
1953 return [
1954 'bool' => [
1955 'must' => [
1956 'terms' => [
1957 'post_author.id' => array_values( (array) $args['author__in'] ),
1958 ],
1959 ],
1960 ],
1961 ];
1962 }
1963
1964 if ( ! empty( $args['author__not_in'] ) ) {
1965 return [
1966 'bool' => [
1967 'must_not' => [
1968 'terms' => [
1969 'post_author.id' => array_values( (array) $args['author__not_in'] ),
1970 ],
1971 ],
1972 ],
1973 ];
1974 }
1975
1976 return [];
1977 }
1978
1979 /**
1980 * Parse the `post_mime_type` WP Query arg and transform it into an ES query clause.
1981 *
1982 * If we have array, it will be fool text search filter.
1983 * If we have string(like filter images in media screen), we will have mime type "image" so need to check it as
1984 * regexp filter.
1985 *
1986 * @since 4.4.0
1987 * @param array $args WP_Query arguments
1988 * @return array
1989 */
1990 protected function parse_post_mime_type( $args ) {
1991 if ( empty( $args['post_mime_type'] ) ) {
1992 return [];
1993 }
1994
1995 if ( is_array( $args['post_mime_type'] ) ) {
1996
1997 $args_post_mime_type = [];
1998
1999 foreach ( $args['post_mime_type'] as $mime_type ) {
2000 /**
2001 * check if matches the MIME type pattern: type/subtype and
2002 * leave an empty string as posts, pages and CPTs don't have a MIME type
2003 */
2004 if ( preg_match( '/^[-._a-z0-9]+\/[-._a-z0-9]+$/i', $mime_type ) || empty( $mime_type ) ) {
2005 $args_post_mime_type[] = $mime_type;
2006 } else {
2007 $filtered_mime_type_by_type = wp_match_mime_types( $mime_type, wp_get_mime_types() );
2008
2009 $args_post_mime_type = array_merge( $args_post_mime_type, $filtered_mime_type_by_type[ $mime_type ] );
2010 }
2011 }
2012
2013 return [
2014 'terms' => [
2015 'post_mime_type' => $args_post_mime_type,
2016 ],
2017 ];
2018 }
2019
2020 if ( is_string( $args['post_mime_type'] ) ) {
2021 return [
2022 'regexp' => array(
2023 'post_mime_type' => $args['post_mime_type'] . '.*',
2024 ),
2025 ];
2026 }
2027
2028 return [];
2029 }
2030
2031 /**
2032 * Parse the various date-related WP Query args and transform them into ES query clauses.
2033 *
2034 * @since 4.4.0
2035 * @param array $args WP_Query arguments
2036 * @return array
2037 */
2038 protected function parse_date( $args ) {
2039 $date_filter = DateQuery::simple_es_date_filter( $args );
2040
2041 if ( ! empty( $date_filter ) ) {
2042 return $date_filter;
2043 }
2044
2045 if ( ! empty( $args['date_query'] ) ) {
2046
2047 $date_query = new DateQuery( $args['date_query'] );
2048
2049 $date_filter = $date_query->get_es_filter();
2050
2051 if ( array_key_exists( 'and', $date_filter ) ) {
2052 return $date_filter['and'];
2053 }
2054 }
2055 }
2056
2057 /**
2058 * Parse all meta queries.
2059 *
2060 * Although the name may be misleading, it handles the `meta_query` argument. There is a `build_meta_query` that handles each "small" query.
2061 *
2062 * @since 4.4.0
2063 * @param array $args WP_Query arguments
2064 * @return array
2065 */
2066 protected function parse_meta_queries( $args ) {
2067 /**
2068 * 'meta_query' arg support.
2069 *
2070 * Relation supports 'AND' and 'OR'. 'AND' is the default. For each individual query, the
2071 * following 'compare' values are supported: =, !=, EXISTS, NOT EXISTS. '=' is the default.
2072 *
2073 * @since 1.3
2074 */
2075 $meta_queries = ( ! empty( $args['meta_query'] ) ) ? $args['meta_query'] : [];
2076 $meta_queries = ( new \WP_Meta_Query() )->sanitize_query( $meta_queries );
2077
2078 /**
2079 * Todo: Support meta_type
2080 */
2081
2082 /**
2083 * Support `meta_key`, `meta_value`, `meta_value_num`, and `meta_compare` query args
2084 */
2085 if ( ! empty( $args['meta_key'] ) ) {
2086 $meta_query_array = [
2087 'key' => $args['meta_key'],
2088 ];
2089
2090 if ( isset( $args['meta_value'] ) && '' !== $args['meta_value'] ) {
2091 $meta_query_array['value'] = $args['meta_value'];
2092 } elseif ( isset( $args['meta_value_num'] ) && '' !== $args['meta_value_num'] ) {
2093 $meta_query_array['value'] = $args['meta_value_num'];
2094 }
2095
2096 if ( isset( $args['meta_compare'] ) ) {
2097 $meta_query_array['compare'] = $args['meta_compare'];
2098 }
2099
2100 if ( ! empty( $meta_queries ) ) {
2101 $meta_queries = [
2102 'relation' => 'AND',
2103 $meta_query_array,
2104 $meta_queries,
2105 ];
2106 } else {
2107 $meta_queries = [ $meta_query_array ];
2108 }
2109 }
2110
2111 if ( ! empty( $meta_queries ) ) {
2112 // get meta query filter
2113 $meta_filter = $this->build_meta_query( $meta_queries );
2114
2115 if ( ! empty( $meta_filter ) ) {
2116 return $meta_filter;
2117 }
2118 }
2119
2120 return [];
2121 }
2122
2123 /**
2124 * Parse the `post_type` WP Query arg and transform it into an ES query clause.
2125 *
2126 * @since 4.4.0
2127 * @param array $args WP_Query arguments
2128 * @return array
2129 */
2130 protected function parse_post_type( $args ) {
2131 /**
2132 * If not set default to post. If search and not set, default to "any".
2133 */
2134 if ( ! empty( $args['post_type'] ) ) {
2135 // should NEVER be "any" but just in case
2136 if ( 'any' !== $args['post_type'] ) {
2137 $post_types = (array) $args['post_type'];
2138 $terms_map_name = 'terms';
2139
2140 return [
2141 $terms_map_name => [
2142 'post_type.raw' => array_values( $post_types ),
2143 ],
2144 ];
2145 }
2146 } elseif ( empty( $args['s'] ) ) {
2147 return [
2148 'term' => [
2149 'post_type.raw' => 'post',
2150 ],
2151 ];
2152 }
2153
2154 return [];
2155 }
2156
2157 /**
2158 * Parse the `post_status` WP Query arg and transform it into an ES query clause.
2159 *
2160 * @since 4.4.0
2161 * @param array $args WP_Query arguments
2162 * @return array
2163 */
2164 protected function parse_post_status( $args ) {
2165 /**
2166 * Like WP_Query in search context, if no post_status is specified we default to "any". To
2167 * be safe you should ALWAYS specify the post_status parameter UNLIKE with WP_Query.
2168 *
2169 * @since 2.1
2170 */
2171 if ( ! empty( $args['post_status'] ) ) {
2172 // should NEVER be "any" but just in case
2173 if ( 'any' !== $args['post_status'] ) {
2174 $post_status = (array) ( is_string( $args['post_status'] ) ? explode( ',', $args['post_status'] ) : $args['post_status'] );
2175 $post_status = array_map( 'trim', $post_status );
2176 $terms_map_name = 'terms';
2177 if ( count( $post_status ) < 2 ) {
2178 $terms_map_name = 'term';
2179 $post_status = $post_status[0];
2180 }
2181
2182 return [
2183 $terms_map_name => [
2184 'post_status' => $post_status,
2185 ],
2186 ];
2187 }
2188 } else {
2189 $statuses = get_post_stati( array( 'public' => true ) );
2190
2191 if ( is_admin() ) {
2192 /**
2193 * In the admin we will add protected and private post statuses to the default query
2194 * per WP default behavior.
2195 */
2196 $statuses = array_merge(
2197 $statuses,
2198 get_post_stati(
2199 array(
2200 'protected' => true,
2201 'show_in_admin_all_list' => true,
2202 )
2203 )
2204 );
2205
2206 if ( is_user_logged_in() ) {
2207 $statuses = array_merge( $statuses, get_post_stati( array( 'private' => true ) ) );
2208 }
2209 }
2210
2211 $statuses = array_values( $statuses );
2212
2213 $post_status_filter_type = 'terms';
2214
2215 return [
2216 $post_status_filter_type => [
2217 'post_status' => $statuses,
2218 ],
2219 ];
2220 }
2221
2222 return [];
2223 }
2224
2225 /**
2226 * If in a search context set search fields, otherwise query everything.
2227 *
2228 * @since 4.4.0
2229 * @param array $formatted_args Formatted Elasticsearch query
2230 * @param array $args WP_Query arguments
2231 * @return array
2232 */
2233 protected function maybe_set_search_fields( $formatted_args, $args ) {
2234 /**
2235 * Allow for search field specification
2236 *
2237 * @since 1.0
2238 */
2239 if ( ! empty( $args['search_fields'] ) ) {
2240 $search_field_args = $args['search_fields'];
2241 $search_fields = [];
2242
2243 if ( ! empty( $search_field_args['taxonomies'] ) ) {
2244 $taxes = (array) $search_field_args['taxonomies'];
2245
2246 foreach ( $taxes as $tax ) {
2247 $search_fields[] = 'terms.' . $tax . '.name';
2248 }
2249
2250 unset( $search_field_args['taxonomies'] );
2251 }
2252
2253 if ( ! empty( $search_field_args['meta'] ) ) {
2254 $metas = (array) $search_field_args['meta'];
2255
2256 foreach ( $metas as $meta ) {
2257 $search_fields[] = 'meta.' . $meta . '.value';
2258 }
2259
2260 unset( $search_field_args['meta'] );
2261 }
2262
2263 if ( in_array( 'author_name', $search_field_args, true ) ) {
2264 $search_fields[] = 'post_author.login';
2265
2266 $author_name_index = array_search( 'author_name', $search_field_args, true );
2267 unset( $search_field_args[ $author_name_index ] );
2268 }
2269
2270 $search_fields = array_merge( $search_field_args, $search_fields );
2271 } else {
2272 $search_fields = array(
2273 'post_title',
2274 'post_excerpt',
2275 'post_content',
2276 );
2277 }
2278
2279 /**
2280 * Filter default post search fields
2281 *
2282 * If you are using the weighting engine, this filter should not be used.
2283 * Instead, you should use the ep_weighting_configuration_for_search filter.
2284 *
2285 * @hook ep_search_fields
2286 * @param {array} $search_fields Default search fields
2287 * @param {array} $args WP Query arguments
2288 * @return {array} New defaults
2289 */
2290 $search_fields = apply_filters( 'ep_search_fields', $search_fields, $args );
2291
2292 $search_text = ( ! empty( $args['s'] ) ) ? $args['s'] : '';
2293
2294 /**
2295 * We are using ep_integrate instead of ep_match_all. ep_match_all will be
2296 * supported for legacy code but may be deprecated and removed eventually.
2297 *
2298 * @since 1.3
2299 */
2300
2301 if ( ! empty( $search_text ) ) {
2302 add_filter( 'ep_post_formatted_args_query', [ $this, 'adjust_query_fuzziness' ], 100, 4 );
2303
2304 $search_algorithm = $this->get_search_algorithm( $search_text, $search_fields, $args );
2305 $formatted_args['query'] = $search_algorithm->get_query( 'post', $search_text, $search_fields, $args );
2306 } elseif ( ! empty( $args['ep_match_all'] ) || ! empty( $args['ep_integrate'] ) ) {
2307 $formatted_args['query']['match_all'] = array(
2308 'boost' => 1,
2309 );
2310 }
2311
2312 return $formatted_args;
2313 }
2314
2315 /**
2316 * If needed bring sticky posts and order them.
2317 *
2318 * @since 4.4.0
2319 * @param array $formatted_args Formatted Elasticsearch query
2320 * @param array $args WP_Query arguments
2321 * @return array
2322 */
2323 protected function maybe_add_sticky_posts( $formatted_args, $args ) {
2324 /**
2325 * Sticky posts support
2326 */
2327
2328 // Check first if there's sticky posts and show them only in the front page
2329 $sticky_posts = get_option( 'sticky_posts' );
2330 $sticky_posts = ( is_array( $sticky_posts ) && empty( $sticky_posts ) ) ? false : $sticky_posts;
2331
2332 /**
2333 * Filter whether to enable sticky posts for this request
2334 *
2335 * @hook ep_enable_sticky_posts
2336 *
2337 * @param {bool} $allow Allow sticky posts for this request
2338 * @param {array} $args Query variables
2339 * @param {array} $formatted_args EP formatted args
2340 *
2341 * @return {bool} $allow
2342 */
2343 $enable_sticky_posts = apply_filters( 'ep_enable_sticky_posts', is_home(), $args, $formatted_args );
2344
2345 if ( false !== $sticky_posts
2346 && $enable_sticky_posts
2347 && empty( $args['s'] )
2348 && in_array( $args['ignore_sticky_posts'], array( 'false', 0, false ), true ) ) {
2349 $new_sort = [
2350 [
2351 '_score' => [
2352 'order' => 'desc',
2353 ],
2354 ],
2355 ];
2356
2357 $formatted_args['sort'] = array_merge( $new_sort, $formatted_args['sort'] );
2358
2359 $formatted_args_query = $formatted_args['query'];
2360 $formatted_args['query'] = array();
2361 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
2362 $formatted_args['query']['function_score']['functions'] = array(
2363 // add extra weight to sticky posts to show them on top
2364 (object) array(
2365 'filter' => array(
2366 'terms' => array( '_id' => $sticky_posts ),
2367 ),
2368 'weight' => 20,
2369 ),
2370 );
2371 }
2372
2373 return $formatted_args;
2374 }
2375
2376 /**
2377 * If needed set the `fields` ES query clause.
2378 *
2379 * @since 4.4.0
2380 * @param array $formatted_args Formatted Elasticsearch query
2381 * @param array $args WP_Query arguments
2382 * @return array
2383 */
2384 protected function maybe_set_fields( $formatted_args, $args ) {
2385 /**
2386 * Support fields.
2387 */
2388 if ( isset( $args['fields'] ) ) {
2389 switch ( $args['fields'] ) {
2390 case 'ids':
2391 $formatted_args['_source'] = array(
2392 'includes' => array(
2393 'post_id',
2394 ),
2395 );
2396 break;
2397
2398 case 'id=>parent':
2399 $formatted_args['_source'] = array(
2400 'includes' => array(
2401 'post_id',
2402 'post_parent',
2403 ),
2404 );
2405 break;
2406 }
2407 }
2408
2409 return $formatted_args;
2410 }
2411
2412 /**
2413 * If needed set the `aggs` ES query clause.
2414 *
2415 * @since 4.4.0
2416 * @param array $formatted_args Formatted Elasticsearch query.
2417 * @param array $args WP_Query arguments
2418 * @param array $filters Filters to be applied to the ES query
2419 * @return array
2420 */
2421 protected function maybe_set_aggs( $formatted_args, $args, $filters ) {
2422 /**
2423 * Aggregations
2424 */
2425 if ( ! empty( $args['aggs'] ) && is_array( $args['aggs'] ) ) {
2426 // Check if the array indexes are all numeric.
2427 $agg_keys = array_keys( $args['aggs'] );
2428 $agg_num_keys = array_filter( $agg_keys, 'is_int' );
2429 $has_only_num_keys = count( $agg_num_keys ) === count( $args['aggs'] );
2430
2431 if ( $has_only_num_keys ) {
2432 foreach ( $args['aggs'] as $agg ) {
2433 $formatted_args = $this->apply_aggregations( $formatted_args, $agg, ! empty( $filters ), $filters );
2434 }
2435 } else {
2436 // Single aggregation.
2437 $formatted_args = $this->apply_aggregations( $formatted_args, $args['aggs'], ! empty( $filters ), $filters );
2438 }
2439 }
2440
2441 return $formatted_args;
2442 }
2443
2444 /**
2445 * Parse tax query field value.
2446 *
2447 * @since 4.4.0
2448 * @param string $field Field name
2449 * @return string
2450 */
2451 protected function parse_tax_query_field( string $field ) : string {
2452
2453 $from_to = [
2454 'name' => 'name.raw',
2455 'slug' => 'slug',
2456 'term_taxonomy_id' => 'term_taxonomy_id',
2457 ];
2458
2459 return $from_to[ $field ] ?? 'term_id';
2460 }
2461
2462 /**
2463 * Return all distinct meta fields in the database.
2464 *
2465 * @since 4.4.0
2466 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2467 * @return array
2468 */
2469 public function get_distinct_meta_field_keys_db( bool $force_refresh = false ) : array {
2470 global $wpdb;
2471
2472 /**
2473 * Short-circuits the process of getting distinct meta keys from the database.
2474 *
2475 * Returning a non-null value will effectively short-circuit the function.
2476 *
2477 * @since 4.4.0
2478 * @hook ep_post_pre_meta_keys_db
2479 * @param {null} $meta_keys Distinct meta keys array
2480 * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2481 */
2482 $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db', null );
2483 if ( null !== $pre_meta_keys ) {
2484 return $pre_meta_keys;
2485 }
2486
2487 $cache_key = 'ep_meta_field_keys';
2488
2489 if ( ! $force_refresh ) {
2490 $cached = get_transient( $cache_key );
2491 if ( false !== $cached ) {
2492 $cached = (array) json_decode( (string) $cached );
2493 /* this filter is documented below */
2494 return (array) apply_filters( 'ep_post_meta_keys_db', $cached );
2495 }
2496 }
2497
2498 /**
2499 * To avoid running a too expensive SQL query, we run a query getting all public keys
2500 * and only the private keys allowed by the `ep_prepare_meta_allowed_protected_keys` filter.
2501 * This query does not order by on purpose, as that also brings a performance penalty.
2502 */
2503 $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], new \WP_Post( (object) [] ) );
2504 $allowed_protected_keys_sql = '';
2505 if ( ! empty( $allowed_protected_keys ) ) {
2506 $placeholders = implode( ',', array_fill( 0, count( $allowed_protected_keys ), '%s' ) );
2507 $allowed_protected_keys_sql = " OR meta_key IN ( {$placeholders} ) ";
2508 }
2509
2510 $meta_keys = $wpdb->get_col(
2511 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
2512 $wpdb->prepare(
2513 "SELECT DISTINCT meta_key
2514 FROM {$wpdb->postmeta}
2515 WHERE meta_key NOT LIKE %s {$allowed_protected_keys_sql}
2516 LIMIT 800",
2517 '\_%',
2518 ...$allowed_protected_keys
2519 )
2520 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
2521 );
2522 sort( $meta_keys );
2523
2524 // Make sure the size of the transient will not be bigger than 1MB
2525 do {
2526 $transient_size = strlen( wp_json_encode( $meta_keys ) );
2527 if ( $transient_size >= MB_IN_BYTES ) {
2528 array_pop( $meta_keys );
2529 } else {
2530 break;
2531 }
2532 } while ( true );
2533 set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2534
2535 /**
2536 * Filter the distinct meta keys fetched from the database.
2537 *
2538 * @since 4.4.0
2539 * @hook ep_post_meta_keys_db
2540 * @param {array} $meta_keys Distinct meta keys array
2541 * @return {array} New distinct meta keys array
2542 */
2543 return (array) apply_filters( 'ep_post_meta_keys_db', $meta_keys );
2544 }
2545
2546 /**
2547 * Return all distinct meta fields in the database per post type.
2548 *
2549 * @since 4.4.0
2550 * @param string $post_type Post type slug
2551 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2552 * @return array
2553 */
2554 public function get_distinct_meta_field_keys_db_per_post_type( string $post_type, bool $force_refresh = false ) : array {
2555 $allowed_screen = 'status-report' === \ElasticPress\Screen::factory()->get_current_screen();
2556
2557 /**
2558 * Filter if the current screen is allowed or not to use the function.
2559 *
2560 * This method can be too resource intensive, use it with caution.
2561 *
2562 * @since 4.4.0
2563 * @hook ep_post_meta_keys_db_per_post_type_allowed_screen
2564 * @param {bool} $allowed_screen Whether this is an allowed screen or not.
2565 * @return {bool} New value of $allowed_screen
2566 */
2567 if ( ! apply_filters( 'ep_post_meta_keys_db_per_post_type_allowed_screen', $allowed_screen ) ) {
2568 _doing_it_wrong(
2569 __METHOD__,
2570 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.' ),
2571 'ElasticPress 4.4.0'
2572 );
2573 return [];
2574 }
2575
2576 /**
2577 * Short-circuits the process of getting distinct meta keys from the database per post type.
2578 *
2579 * Returning a non-null value will effectively short-circuit the function.
2580 *
2581 * @since 4.4.0
2582 * @hook ep_post_pre_meta_keys_db_per_post_type
2583 * @param {null} $meta_keys Distinct meta keys array
2584 * @param {string} $post_type Post type slug
2585 * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2586 */
2587 $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db_per_post_type', null, $post_type );
2588 if ( null !== $pre_meta_keys ) {
2589 return $pre_meta_keys;
2590 }
2591
2592 $cache_key = 'ep_meta_field_keys_' . $post_type;
2593
2594 if ( ! $force_refresh ) {
2595 $cached = get_transient( $cache_key );
2596 if ( false !== $cached ) {
2597 $cached = (array) json_decode( (string) $cached );
2598 /* this filter is documented below */
2599 return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $cached, $post_type );
2600 }
2601 }
2602
2603 $meta_keys = [];
2604 $post_ids_batches = $this->get_lazy_post_type_ids( $post_type );
2605 foreach ( $post_ids_batches as $post_ids ) {
2606 $new_meta_keys = $this->get_meta_keys_from_post_ids( $post_ids );
2607
2608 $meta_keys = array_unique( array_merge( $meta_keys, $new_meta_keys ) );
2609 }
2610
2611 // Make sure the size of the transient will not be bigger than 1MB
2612 do {
2613 $transient_size = strlen( wp_json_encode( $meta_keys ) );
2614 if ( $transient_size >= MB_IN_BYTES ) {
2615 array_pop( $meta_keys );
2616 } else {
2617 break;
2618 }
2619 } while ( true );
2620 set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2621
2622 /**
2623 * Filter the distinct meta keys fetched from the database per post type.
2624 *
2625 * @since 4.4.0
2626 * @hook ep_post_meta_keys_db_per_post_type
2627 * @param {array} $meta_keys Distinct meta keys array
2628 * @param {string} $post_type Post type slug
2629 * @return {array} New distinct meta keys array
2630 */
2631 return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $meta_keys, $post_type );
2632 }
2633
2634 /**
2635 * Return all distinct meta fields in the database per post type.
2636 *
2637 * @since 4.4.0
2638 * @param string $post_type Post type slug
2639 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2640 * @return array
2641 */
2642 public function get_indexable_meta_keys_per_post_type( string $post_type, bool $force_refresh = false ) : array {
2643 $mock_post = new \WP_Post( (object) [ 'post_type' => $post_type ] );
2644 $meta_keys = $this->get_distinct_meta_field_keys_db_per_post_type( $post_type, $force_refresh );
2645
2646 $fake_meta_values = array_combine( $meta_keys, array_fill( 0, count( $meta_keys ), 'test-value' ) );
2647 $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $mock_post );
2648
2649 return array_filter(
2650 array_keys( $filtered_meta ),
2651 function ( $meta_key ) use ( $mock_post ) {
2652 return $this->is_meta_allowed( $meta_key, $mock_post );
2653 }
2654 );
2655 }
2656
2657 /**
2658 * Return the meta keys that will (possibly) be indexed.
2659 *
2660 * This function gets all the meta keys in the database, creates a fake post without a type and with all the meta fields,
2661 * runs the `ep_prepare_meta_data` filter against it and checks if meta keys are allowed or not.
2662 * Although it provides a good indicator, it is not 100% correct as developers could create code using the
2663 * `ep_prepare_meta_data` filter that would depend on "real" data.
2664 *
2665 * @since 4.4.0
2666 * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2667 * @return array
2668 */
2669 public function get_predicted_indexable_meta_keys( bool $force_refresh = false ) : array {
2670 $empty_post = new \WP_Post( (object) [] );
2671 $meta_keys = $this->get_distinct_meta_field_keys_db( $force_refresh );
2672
2673 $fake_meta_values = array_combine( $meta_keys, array_fill( 0, count( $meta_keys ), 'test-value' ) );
2674 $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $empty_post );
2675
2676 $all_keys = array_filter(
2677 array_keys( $filtered_meta ),
2678 function( $meta_key ) use ( $empty_post ) {
2679 return $this->is_meta_allowed( $meta_key, $empty_post );
2680 }
2681 );
2682
2683 sort( $all_keys );
2684
2685 return $all_keys;
2686 }
2687
2688 /**
2689 * Given a post type, *yields* their Post IDs.
2690 *
2691 * If post IDs are found, this function will return a PHP Generator. To avoid timeout, it will yield 8 groups or 11,000 IDs.
2692 *
2693 * @since 4.4.0
2694 * @see https://www.php.net/manual/en/language.generators.overview.php
2695 * @param string $post_type The post type slug
2696 * @return iterator
2697 */
2698 protected function get_lazy_post_type_ids( string $post_type ) {
2699 global $wpdb;
2700
2701 $total = $wpdb->get_var( $wpdb->prepare( "SELECT count(*) FROM {$wpdb->posts} WHERE post_type = %s", $post_type ) );
2702
2703 if ( ! $total ) {
2704 return [];
2705 }
2706
2707 /**
2708 * Filter the number of IDs to be fetched per page to discover distinct meta fields per post type.
2709 *
2710 * @hook ep_post_meta_by_type_ids_per_page
2711 * @since 4.4.0
2712 * @param {int} $per_page Number of IDs
2713 * @param {string} $post_type The post type slug
2714 * @return {string} New number of IDs
2715 */
2716 $per_page = apply_filters( 'ep_post_meta_by_type_ids_per_page', 11000, $post_type );
2717
2718 $pages = min( ceil( $total / $per_page ), 8 );
2719
2720 /**
2721 * Filter the number of times EP will fetch IDs from the database
2722 *
2723 * @hook ep_post_meta_by_type_number_of_pages
2724 * @since 4.4.0
2725 * @param {int} $pages Number of "pages" (not WP post type)
2726 * @param {int} $per_page Number of IDs per page
2727 * @param {string} $post_type The post type slug
2728 * @return {string} New number of pages
2729 */
2730 $pages = apply_filters( 'ep_post_meta_by_type_number_of_pages', $pages, $per_page, $post_type );
2731
2732 for ( $page = 0; $page < $pages; $page++ ) {
2733 $start = $per_page * $page;
2734 $ids = $wpdb->get_col(
2735 $wpdb->prepare(
2736 "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s LIMIT %d, %d",
2737 $post_type,
2738 $start,
2739 $per_page
2740 )
2741 );
2742 yield $ids;
2743 }
2744 }
2745
2746 /**
2747 * Given a set of post IDs, return distinct meta keys associated with them.
2748 *
2749 * @since 4.4.0
2750 * @param array $post_ids Set of post IDs
2751 * @return array
2752 */
2753 protected function get_meta_keys_from_post_ids( array $post_ids ) : array {
2754 global $wpdb;
2755
2756 if ( empty( $post_ids ) ) {
2757 return [];
2758 }
2759
2760 $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
2761 $meta_keys = $wpdb->get_col(
2762 $wpdb->prepare(
2763 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
2764 "SELECT DISTINCT meta_key FROM {$wpdb->postmeta} WHERE post_id IN ( {$placeholders} )",
2765 $post_ids
2766 )
2767 );
2768
2769 return $meta_keys;
2770 }
2771
2772 /**
2773 * Add a `term_suggest` field to the mapping.
2774 *
2775 * This method assumes the `edge_ngram_analyzer` analyzer was already added to the mapping.
2776 *
2777 * @since 4.5.0
2778 * @param array $mapping The mapping array
2779 * @return array
2780 */
2781 public function add_term_suggest_field( array $mapping ) : array {
2782 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
2783 $mapping_properties = &$mapping['mappings']['post']['properties'];
2784 } else {
2785 $mapping_properties = &$mapping['mappings']['properties'];
2786 }
2787
2788 $text_type = $mapping_properties['post_content']['type'];
2789
2790 $mapping_properties['term_suggest'] = array(
2791 'type' => $text_type,
2792 'analyzer' => 'edge_ngram_analyzer',
2793 'search_analyzer' => 'standard',
2794 );
2795
2796 return $mapping;
2797 }
2798 }
2799