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

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