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

2,157 lines 60.0 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 if ( ! empty( $args['posts_per_page'] ) ) {
951 $posts_per_page = (int) $args['posts_per_page'];
952
953 // ES have a maximum size allowed so we have to convert "-1" to a maximum size.
954 if ( -1 === $posts_per_page ) {
955 /**
956 * Set the maximum results window size.
957 *
958 * The request will return a HTTP 500 Internal Error if the size of the
959 * request is larger than the [index.max_result_window] parameter in ES.
960 * See the scroll api for a more efficient way to request large data sets.
961 *
962 * @return int The max results window size.
963 *
964 * @since 2.3.0
965 */
966
967 /**
968 * Filter max result size if set to -1
969 *
970 * @hook ep_max_results_window
971 * @param {int} Max result window
972 * @return {int} New window
973 */
974 $posts_per_page = apply_filters( 'ep_max_results_window', 10000 );
975 }
976 } else {
977 $posts_per_page = (int) get_option( 'posts_per_page' );
978 }
979
980 $formatted_args = array(
981 'from' => 0,
982 'size' => $posts_per_page,
983 );
984
985 /**
986 * Order and Orderby arguments
987 *
988 * Used for how Elasticsearch will sort results
989 *
990 * @since 1.1
991 */
992
993 // Set sort order, default is 'desc'.
994 if ( ! empty( $args['order'] ) ) {
995 $order = $this->parse_order( $args['order'] );
996 } else {
997 $order = 'desc';
998 }
999
1000 // Default sort for non-searches to date.
1001 if ( empty( $args['orderby'] ) && ( ! isset( $args['s'] ) || '' === $args['s'] ) ) {
1002 /**
1003 * Filter default post query order by
1004 *
1005 * @hook ep_set_default_sort
1006 * @param {string} $sort Default sort
1007 * @param {string $order Order direction
1008 * @return {string} New default
1009 */
1010 $args['orderby'] = apply_filters( 'ep_set_default_sort', 'date', $order );
1011 }
1012
1013 // Set sort type.
1014 if ( ! empty( $args['orderby'] ) ) {
1015 $formatted_args['sort'] = $this->parse_orderby( $args['orderby'], $order, $args );
1016 } else {
1017 // Default sort is to use the score (based on relevance).
1018 $default_sort = array(
1019 array(
1020 '_score' => array(
1021 'order' => $order,
1022 ),
1023 ),
1024 );
1025
1026 /**
1027 * Filter the ES query order (`sort` clause)
1028 *
1029 * This filter is used in searches if `orderby` is not set in the WP_Query args.
1030 * The default value is:
1031 *
1032 * $default_sort = array(
1033 * array(
1034 * '_score' => array(
1035 * 'order' => $order,
1036 * ),
1037 * ),
1038 * );
1039 *
1040 * @hook ep_set_sort
1041 * @since 3.6.3
1042 * @param {array} $sort Default sort.
1043 * @param {string} $order Order direction
1044 * @return {array} New default
1045 */
1046 $default_sort = apply_filters( 'ep_set_sort', $default_sort, $order );
1047
1048 $formatted_args['sort'] = $default_sort;
1049 }
1050
1051 $filter = array(
1052 'bool' => array(
1053 'must' => [],
1054 ),
1055 );
1056 $use_filters = false;
1057
1058 // Sanitize array query args. Elasticsearch will error if a terms query contains empty items like an
1059 // empty string.
1060 $keys_to_sanitize = [
1061 'author__in',
1062 'author__not_in',
1063 'category__and',
1064 'category__in',
1065 'category__not_in',
1066 'tag__and',
1067 'tag__in',
1068 'tag__not_in',
1069 'tag_slug__and',
1070 'tag_slug__in',
1071 'post_parent__in',
1072 'post_parent__not_in',
1073 'post__in',
1074 'post__not_in',
1075 'post_name__in',
1076 ];
1077 foreach ( $keys_to_sanitize as $key ) {
1078 if ( ! isset( $args[ $key ] ) ) {
1079 continue;
1080 }
1081 $args[ $key ] = array_filter( (array) $args[ $key ] );
1082 }
1083
1084 /**
1085 * Tax Query support
1086 *
1087 * Support for the tax_query argument of WP_Query. Currently only provides support for the 'AND' relation
1088 * between taxonomies. Field only supports slug, term_id, and name defaulting to term_id.
1089 *
1090 * @use field = slug
1091 * terms array
1092 * @since 0.9.1
1093 */
1094 if ( ! empty( $wp_query->tax_query ) && ! empty( $wp_query->tax_query->queries ) ) {
1095 $args['tax_query'] = $wp_query->tax_query->queries;
1096 }
1097
1098 if ( ! empty( $args['tax_query'] ) ) {
1099 // Main tax_query array for ES.
1100 $es_tax_query = [];
1101
1102 $tax_queries = $this->parse_tax_query( $args['tax_query'] );
1103
1104 if ( ! empty( $tax_queries['tax_filter'] ) ) {
1105 $relation = 'must';
1106
1107 if ( ! empty( $args['tax_query']['relation'] ) && 'or' === strtolower( $args['tax_query']['relation'] ) ) {
1108 $relation = 'should';
1109 }
1110
1111 $es_tax_query[ $relation ] = $tax_queries['tax_filter'];
1112 }
1113
1114 if ( ! empty( $tax_queries['tax_must_not_filter'] ) ) {
1115 $es_tax_query['must_not'] = $tax_queries['tax_must_not_filter'];
1116 }
1117
1118 if ( ! empty( $es_tax_query ) ) {
1119 $filter['bool']['must'][]['bool'] = $es_tax_query;
1120 }
1121
1122 $use_filters = true;
1123 }
1124
1125 /**
1126 * 'post_parent' arg support.
1127 *
1128 * @since 2.0
1129 */
1130 if ( isset( $args['post_parent'] ) && '' !== $args['post_parent'] && 'any' !== strtolower( $args['post_parent'] ) ) {
1131 $filter['bool']['must'][]['bool']['must'] = array(
1132 'term' => array(
1133 'post_parent' => $args['post_parent'],
1134 ),
1135 );
1136
1137 $use_filters = true;
1138 }
1139
1140 /**
1141 * 'post__in' arg support.
1142 *
1143 * @since x.x
1144 */
1145 if ( ! empty( $args['post__in'] ) ) {
1146 $filter['bool']['must'][]['bool']['must'] = array(
1147 'terms' => array(
1148 'post_id' => array_values( (array) $args['post__in'] ),
1149 ),
1150 );
1151
1152 $use_filters = true;
1153 }
1154
1155 /**
1156 * 'post_name__in' arg support.
1157 *
1158 * @since 3.6.0
1159 */
1160 if ( ! empty( $args['post_name__in'] ) ) {
1161 $filter['bool']['must'][]['bool']['must'] = array(
1162 'terms' => array(
1163 'post_name.raw' => array_values( (array) $args['post_name__in'] ),
1164 ),
1165 );
1166
1167 $use_filters = true;
1168 }
1169
1170 /**
1171 * 'post__not_in' arg support.
1172 *
1173 * @since x.x
1174 */
1175 if ( ! empty( $args['post__not_in'] ) ) {
1176 $filter['bool']['must'][]['bool']['must_not'] = array(
1177 'terms' => array(
1178 'post_id' => (array) $args['post__not_in'],
1179 ),
1180 );
1181
1182 $use_filters = true;
1183 }
1184
1185 /**
1186 * 'category__not_in' arg support.
1187 *
1188 * @since 3.6.0
1189 */
1190 if ( ! empty( $args['category__not_in'] ) ) {
1191 $filter['bool']['must'][]['bool']['must_not'] = array(
1192 'terms' => array(
1193 'terms.category.term_id' => array_values( (array) $args['category__not_in'] ),
1194 ),
1195 );
1196
1197 $use_filters = true;
1198 }
1199
1200 /**
1201 * 'tag__not_in' arg support.
1202 *
1203 * @since 3.6.0
1204 */
1205 if ( ! empty( $args['tag__not_in'] ) ) {
1206 $filter['bool']['must'][]['bool']['must_not'] = array(
1207 'terms' => array(
1208 'terms.post_tag.term_id' => array_values( (array) $args['tag__not_in'] ),
1209 ),
1210 );
1211
1212 $use_filters = true;
1213 }
1214
1215 /**
1216 * Author query support
1217 *
1218 * @since 1.0
1219 */
1220 if ( ! empty( $args['author'] ) ) {
1221 $filter['bool']['must'][] = array(
1222 'term' => array(
1223 'post_author.id' => $args['author'],
1224 ),
1225 );
1226
1227 $use_filters = true;
1228 } elseif ( ! empty( $args['author_name'] ) ) {
1229 // Since this was set to use the display name initially, there might be some code that used this feature.
1230 // Let's ensure that any query vars coming in using author_name are in fact slugs.
1231 // This was changed back in ticket #1622 to use the display name, so we removed the sanitize_user() call.
1232 $filter['bool']['must'][] = array(
1233 'term' => array(
1234 'post_author.display_name' => $args['author_name'],
1235 ),
1236 );
1237
1238 $use_filters = true;
1239 } elseif ( ! empty( $args['author__in'] ) ) {
1240 $filter['bool']['must'][]['bool']['must'] = array(
1241 'terms' => array(
1242 'post_author.id' => array_values( (array) $args['author__in'] ),
1243 ),
1244 );
1245
1246 $use_filters = true;
1247 } elseif ( ! empty( $args['author__not_in'] ) ) {
1248 $filter['bool']['must'][]['bool']['must_not'] = array(
1249 'terms' => array(
1250 'post_author.id' => array_values( (array) $args['author__not_in'] ),
1251 ),
1252 );
1253
1254 $use_filters = true;
1255 }
1256
1257 /**
1258 * Add support for post_mime_type
1259 *
1260 * If we have array, it will be fool text search filter.
1261 * If we have string(like filter images in media screen), we will have mime type "image" so need to check it as
1262 * regexp filter.
1263 *
1264 * @since 2.3
1265 */
1266 if ( ! empty( $args['post_mime_type'] ) ) {
1267 if ( is_array( $args['post_mime_type'] ) ) {
1268
1269 $args_post_mime_type = [];
1270
1271 foreach ( $args['post_mime_type'] as $mime_type ) {
1272 /**
1273 * check if matches the MIME type pattern: type/subtype and
1274 * leave an empty string as posts, pages and CPTs don't have a MIME type
1275 */
1276 if ( preg_match( '/^[-._a-z0-9]+\/[-._a-z0-9]+$/i', $mime_type ) || empty( $mime_type ) ) {
1277 $args_post_mime_type[] = $mime_type;
1278 } else {
1279 $filtered_mime_type_by_type = wp_match_mime_types( $mime_type, wp_get_mime_types() );
1280
1281 $args_post_mime_type = array_merge( $args_post_mime_type, $filtered_mime_type_by_type[ $mime_type ] );
1282 }
1283 }
1284
1285 $filter['bool']['must'][] = array(
1286 'terms' => array(
1287 'post_mime_type' => $args_post_mime_type,
1288 ),
1289 );
1290
1291 $use_filters = true;
1292 } elseif ( is_string( $args['post_mime_type'] ) ) {
1293 $filter['bool']['must'][] = array(
1294 'regexp' => array(
1295 'post_mime_type' => $args['post_mime_type'] . '.*',
1296 ),
1297 );
1298
1299 $use_filters = true;
1300 }
1301 }
1302
1303 /**
1304 * Simple date params support
1305 *
1306 * @since 1.3
1307 */
1308 $date_filter = DateQuery::simple_es_date_filter( $args );
1309
1310 if ( ! empty( $date_filter ) ) {
1311 $filter['bool']['must'][] = $date_filter;
1312 $use_filters = true;
1313 }
1314
1315 /**
1316 * 'date_query' arg support.
1317 */
1318 if ( ! empty( $args['date_query'] ) ) {
1319
1320 $date_query = new DateQuery( $args['date_query'] );
1321
1322 $date_filter = $date_query->get_es_filter();
1323
1324 if ( array_key_exists( 'and', $date_filter ) ) {
1325 $filter['bool']['must'][] = $date_filter['and'];
1326 $use_filters = true;
1327 }
1328 }
1329
1330 $meta_queries = [];
1331
1332 /**
1333 * Support `meta_key`, `meta_value`, `meta_value_num`, and `meta_compare` query args
1334 */
1335 if ( ! empty( $args['meta_key'] ) ) {
1336 $meta_query_array = [
1337 'key' => $args['meta_key'],
1338 ];
1339
1340 if ( isset( $args['meta_value'] ) && '' !== $args['meta_value'] ) {
1341 $meta_query_array['value'] = $args['meta_value'];
1342 } elseif ( isset( $args['meta_value_num'] ) && '' !== $args['meta_value_num'] ) {
1343 $meta_query_array['value'] = $args['meta_value_num'];
1344 }
1345
1346 if ( isset( $args['meta_compare'] ) ) {
1347 $meta_query_array['compare'] = $args['meta_compare'];
1348 }
1349
1350 $meta_queries[] = $meta_query_array;
1351 }
1352
1353 /**
1354 * Todo: Support meta_type
1355 */
1356
1357 /**
1358 * 'meta_query' arg support.
1359 *
1360 * Relation supports 'AND' and 'OR'. 'AND' is the default. For each individual query, the
1361 * following 'compare' values are supported: =, !=, EXISTS, NOT EXISTS. '=' is the default.
1362 *
1363 * @since 1.3
1364 */
1365 if ( ! empty( $args['meta_query'] ) ) {
1366 $meta_queries = array_merge( $meta_queries, $args['meta_query'] );
1367 }
1368
1369 if ( ! empty( $meta_queries ) ) {
1370
1371 $relation = 'must';
1372 if ( ! empty( $args['meta_query'] ) && ! empty( $args['meta_query']['relation'] ) && 'or' === strtolower( $args['meta_query']['relation'] ) ) {
1373 $relation = 'should';
1374 }
1375
1376 // get meta query filter
1377 $meta_filter = $this->build_meta_query( $meta_queries );
1378
1379 if ( ! empty( $meta_filter ) ) {
1380 $filter['bool']['must'][] = $meta_filter;
1381
1382 $use_filters = true;
1383 }
1384 }
1385
1386 /**
1387 * Allow for search field specification
1388 *
1389 * @since 1.0
1390 */
1391 if ( ! empty( $args['search_fields'] ) ) {
1392 $search_field_args = $args['search_fields'];
1393 $search_fields = [];
1394
1395 if ( ! empty( $search_field_args['taxonomies'] ) ) {
1396 $taxes = (array) $search_field_args['taxonomies'];
1397
1398 foreach ( $taxes as $tax ) {
1399 $search_fields[] = 'terms.' . $tax . '.name';
1400 }
1401
1402 unset( $search_field_args['taxonomies'] );
1403 }
1404
1405 if ( ! empty( $search_field_args['meta'] ) ) {
1406 $metas = (array) $search_field_args['meta'];
1407
1408 foreach ( $metas as $meta ) {
1409 $search_fields[] = 'meta.' . $meta . '.value';
1410 }
1411
1412 unset( $search_field_args['meta'] );
1413 }
1414
1415 if ( in_array( 'author_name', $search_field_args, true ) ) {
1416 $search_fields[] = 'post_author.login';
1417
1418 $author_name_index = array_search( 'author_name', $search_field_args, true );
1419 unset( $search_field_args[ $author_name_index ] );
1420 }
1421
1422 $search_fields = array_merge( $search_field_args, $search_fields );
1423 } else {
1424 $search_fields = array(
1425 'post_title',
1426 'post_excerpt',
1427 'post_content',
1428 );
1429 }
1430
1431 /**
1432 * Filter default post search fields
1433 *
1434 * If you are using the weighting engine, this filter should not be used.
1435 * Instead, you should use the ep_weighting_configuration_for_search filter.
1436 *
1437 * @hook ep_search_fields
1438 * @param {array} $search_fields Default search fields
1439 * @param {array} $args WP Query arguments
1440 * @return {array} New defaults
1441 */
1442 $search_fields = apply_filters( 'ep_search_fields', $search_fields, $args );
1443
1444 $search_text = ( ! empty( $args['s'] ) ) ? $args['s'] : '';
1445
1446 /**
1447 * We are using ep_integrate instead of ep_match_all. ep_match_all will be
1448 * supported for legacy code but may be deprecated and removed eventually.
1449 *
1450 * @since 1.3
1451 */
1452
1453 if ( ! empty( $search_text ) ) {
1454 add_filter( 'ep_post_formatted_args_query', [ $this, 'adjust_query_fuzziness' ], 100, 4 );
1455
1456 $search_algorithm = $this->get_search_algorithm( $search_text, $search_fields, $args );
1457 $formatted_args['query'] = $search_algorithm->get_query( 'post', $search_text, $search_fields, $args );
1458 } elseif ( ! empty( $args['ep_match_all'] ) || ! empty( $args['ep_integrate'] ) ) {
1459 $formatted_args['query']['match_all'] = array(
1460 'boost' => 1,
1461 );
1462 }
1463
1464 /**
1465 * Order by 'rand' support
1466 *
1467 * Ref: https://github.com/elastic/elasticsearch/issues/1170
1468 */
1469 if ( ! empty( $args['orderby'] ) ) {
1470 $orderbys = $this->get_orderby_array( $args['orderby'] );
1471 if ( in_array( 'rand', $orderbys, true ) ) {
1472 $formatted_args_query = $formatted_args['query'];
1473 $formatted_args['query'] = [];
1474 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1475 $formatted_args['query']['function_score']['random_score'] = (object) [];
1476 }
1477 }
1478
1479 /**
1480 * Sticky posts support
1481 */
1482
1483 // Check first if there's sticky posts and show them only in the front page
1484 $sticky_posts = get_option( 'sticky_posts' );
1485 $sticky_posts = ( is_array( $sticky_posts ) && empty( $sticky_posts ) ) ? false : $sticky_posts;
1486
1487 /**
1488 * Filter whether to enable sticky posts for this request
1489 *
1490 * @hook ep_enable_sticky_posts
1491 *
1492 * @param {bool} $allow Allow sticky posts for this request
1493 * @param {array} $args Query variables
1494 * @param {array} $formatted_args EP formatted args
1495 *
1496 * @return {bool} $allow
1497 */
1498 $enable_sticky_posts = apply_filters( 'ep_enable_sticky_posts', is_home(), $args, $formatted_args );
1499
1500 if ( false !== $sticky_posts
1501 && $enable_sticky_posts
1502 && empty( $args['s'] )
1503 && in_array( $args['ignore_sticky_posts'], array( 'false', 0, false ), true ) ) {
1504 $new_sort = [
1505 [
1506 '_score' => [
1507 'order' => 'desc',
1508 ],
1509 ],
1510 ];
1511
1512 $formatted_args['sort'] = array_merge( $new_sort, $formatted_args['sort'] );
1513
1514 $formatted_args_query = $formatted_args['query'];
1515 $formatted_args['query'] = array();
1516 $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1517 $formatted_args['query']['function_score']['functions'] = array(
1518 // add extra weight to sticky posts to show them on top
1519 (object) array(
1520 'filter' => array(
1521 'terms' => array( '_id' => $sticky_posts ),
1522 ),
1523 'weight' => 20,
1524 ),
1525 );
1526 }
1527
1528 /**
1529 * If not set default to post. If search and not set, default to "any".
1530 */
1531 if ( ! empty( $args['post_type'] ) ) {
1532 // should NEVER be "any" but just in case
1533 if ( 'any' !== $args['post_type'] ) {
1534 $post_types = (array) $args['post_type'];
1535 $terms_map_name = 'terms';
1536
1537 $filter['bool']['must'][] = array(
1538 $terms_map_name => array(
1539 'post_type.raw' => array_values( $post_types ),
1540 ),
1541 );
1542
1543 $use_filters = true;
1544 }
1545 } elseif ( empty( $args['s'] ) ) {
1546 $filter['bool']['must'][] = array(
1547 'term' => array(
1548 'post_type.raw' => 'post',
1549 ),
1550 );
1551
1552 $use_filters = true;
1553 }
1554
1555 /**
1556 * Like WP_Query in search context, if no post_status is specified we default to "any". To
1557 * be safe you should ALWAYS specify the post_status parameter UNLIKE with WP_Query.
1558 *
1559 * @since 2.1
1560 */
1561 if ( ! empty( $args['post_status'] ) ) {
1562 // should NEVER be "any" but just in case
1563 if ( 'any' !== $args['post_status'] ) {
1564 $post_status = (array) ( is_string( $args['post_status'] ) ? explode( ',', $args['post_status'] ) : $args['post_status'] );
1565 $post_status = array_map( 'trim', $post_status );
1566 $terms_map_name = 'terms';
1567 if ( count( $post_status ) < 2 ) {
1568 $terms_map_name = 'term';
1569 $post_status = $post_status[0];
1570 }
1571
1572 $filter['bool']['must'][] = array(
1573 $terms_map_name => array(
1574 'post_status' => $post_status,
1575 ),
1576 );
1577
1578 $use_filters = true;
1579 }
1580 } else {
1581 $statuses = get_post_stati( array( 'public' => true ) );
1582
1583 if ( is_admin() ) {
1584 /**
1585 * In the admin we will add protected and private post statuses to the default query
1586 * per WP default behavior.
1587 */
1588 $statuses = array_merge(
1589 $statuses,
1590 get_post_stati(
1591 array(
1592 'protected' => true,
1593 'show_in_admin_all_list' => true,
1594 )
1595 )
1596 );
1597
1598 if ( is_user_logged_in() ) {
1599 $statuses = array_merge( $statuses, get_post_stati( array( 'private' => true ) ) );
1600 }
1601 }
1602
1603 $statuses = array_values( $statuses );
1604
1605 $post_status_filter_type = 'terms';
1606
1607 $filter['bool']['must'][] = array(
1608 $post_status_filter_type => array(
1609 'post_status' => $statuses,
1610 ),
1611 );
1612
1613 $use_filters = true;
1614 }
1615
1616 if ( isset( $args['offset'] ) ) {
1617 $formatted_args['from'] = (int) $args['offset'];
1618 }
1619
1620 if ( isset( $args['paged'] ) && $args['paged'] > 1 ) {
1621 $formatted_args['from'] = $args['posts_per_page'] * ( $args['paged'] - 1 );
1622 }
1623
1624 /**
1625 * Fix negative offset. This happens, for example, on hierarchical post types.
1626 *
1627 * Ref: https://github.com/10up/ElasticPress/issues/2480
1628 */
1629 if ( $formatted_args['from'] < 0 ) {
1630 $formatted_args['from'] = 0;
1631 }
1632
1633 if ( $use_filters ) {
1634 $formatted_args['post_filter'] = $filter;
1635 }
1636
1637 /**
1638 * Support fields.
1639 */
1640 if ( isset( $args['fields'] ) ) {
1641 switch ( $args['fields'] ) {
1642 case 'ids':
1643 $formatted_args['_source'] = array(
1644 'includes' => array(
1645 'post_id',
1646 ),
1647 );
1648 break;
1649
1650 case 'id=>parent':
1651 $formatted_args['_source'] = array(
1652 'includes' => array(
1653 'post_id',
1654 'post_parent',
1655 ),
1656 );
1657 break;
1658 }
1659 }
1660
1661 /**
1662 * Aggregations
1663 */
1664 if ( ! empty( $args['aggs'] ) && is_array( $args['aggs'] ) ) {
1665 // Check if the array indexes are all numeric.
1666 $agg_keys = array_keys( $args['aggs'] );
1667 $agg_num_keys = array_filter( $agg_keys, 'is_int' );
1668 $has_only_num_keys = count( $agg_num_keys ) === count( $args['aggs'] );
1669
1670 if ( $has_only_num_keys ) {
1671 foreach ( $args['aggs'] as $agg ) {
1672 $formatted_args = $this->apply_aggregations( $formatted_args, $agg, $use_filters, $filter );
1673 }
1674 } else {
1675 // Single aggregation.
1676 $formatted_args = $this->apply_aggregations( $formatted_args, $args['aggs'], $use_filters, $filter );
1677 }
1678 }
1679
1680 /**
1681 * Filter formatted Elasticsearch [ost ]query (entire query)
1682 *
1683 * @hook ep_formatted_args
1684 * @param {array} $formatted_args Formatted Elasticsearch query
1685 * @param {array} $query_vars Query variables
1686 * @param {array} $query Query part
1687 * @return {array} New query
1688 */
1689 $formatted_args = apply_filters( 'ep_formatted_args', $formatted_args, $args, $wp_query );
1690
1691 /**
1692 * Filter formatted Elasticsearch [ost ]query (entire query)
1693 *
1694 * @hook ep_post_formatted_args
1695 * @param {array} $formatted_args Formatted Elasticsearch query
1696 * @param {array} $query_vars Query variables
1697 * @param {array} $query Query part
1698 * @return {array} New query
1699 */
1700 $formatted_args = apply_filters( 'ep_post_formatted_args', $formatted_args, $args, $wp_query );
1701
1702 return $formatted_args;
1703 }
1704
1705 /**
1706 * Adjust the fuzziness parameter if needed.
1707 *
1708 * If using fields with type `long`, queries should not have a fuzziness parameter.
1709 *
1710 * @param array $query Current query
1711 * @param array $query_vars Query variables
1712 * @param string $search_text Search text
1713 * @param array $search_fields Search fields
1714 * @return array New query
1715 */
1716 public function adjust_query_fuzziness( $query, $query_vars, $search_text, $search_fields ) {
1717 if ( empty( array_intersect( $search_fields, [ 'ID', 'post_id', 'post_parent' ] ) ) ) {
1718 return $query;
1719 }
1720
1721 if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['should'] ) ) {
1722 return $query;
1723 }
1724
1725 foreach ( $query['bool']['should'] as &$clause ) {
1726 if ( ! isset( $clause['multi_match'] ) ) {
1727 continue;
1728 }
1729
1730 if ( isset( $clause['multi_match']['fuzziness'] ) ) {
1731 unset( $clause['multi_match']['fuzziness'] );
1732 }
1733 }
1734
1735 return $query;
1736 }
1737
1738 /**
1739 * Parse and build out our tax query.
1740 *
1741 * @access protected
1742 *
1743 * @param array $query Tax query
1744 * @return array
1745 */
1746 protected function parse_tax_query( $query ) {
1747 $tax_query = [
1748 'tax_filter' => [],
1749 'tax_must_not_filter' => [],
1750 ];
1751 $relation = '';
1752
1753 foreach ( $query as $tax_queries ) {
1754 // If we have a nested tax query, recurse through that
1755 if ( is_array( $tax_queries ) && empty( $tax_queries['taxonomy'] ) ) {
1756 $result = $this->parse_tax_query( $tax_queries );
1757 $relation = ( ! empty( $tax_queries['relation'] ) ) ? strtolower( $tax_queries['relation'] ) : 'and';
1758 $filter_type = 'and' === $relation ? 'must' : 'should';
1759
1760 // Set the proper filter type and must_not filter, as needed
1761 if ( ! empty( $result['tax_must_not_filter'] ) ) {
1762 $tax_query['tax_filter'][] = [
1763 'bool' => [
1764 $filter_type => $result['tax_filter'],
1765 'must_not' => $result['tax_must_not_filter'],
1766 ],
1767 ];
1768 } else {
1769 $tax_query['tax_filter'][] = [
1770 'bool' => [
1771 $filter_type => $result['tax_filter'],
1772 ],
1773 ];
1774 }
1775 }
1776
1777 // Parse each individual tax query part
1778 $single_tax_query = $tax_queries;
1779 if ( ! empty( $single_tax_query['taxonomy'] ) ) {
1780 $terms = isset( $single_tax_query['terms'] ) ? (array) $single_tax_query['terms'] : array();
1781 $field = ( ! empty( $single_tax_query['field'] ) ) ? $single_tax_query['field'] : 'term_id';
1782
1783 if ( 'name' === $field ) {
1784 $field = 'name.raw';
1785 }
1786
1787 if ( 'slug' === $field ) {
1788 $terms = array_map( 'sanitize_title', $terms );
1789 }
1790
1791 // Set up our terms object
1792 $terms_obj = array(
1793 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => array_values( array_filter( $terms ) ),
1794 );
1795
1796 $operator = ( ! empty( $single_tax_query['operator'] ) ) ? strtolower( $single_tax_query['operator'] ) : 'in';
1797
1798 switch ( $operator ) {
1799 case 'exists':
1800 /**
1801 * add support for "EXISTS" operator
1802 *
1803 * @since 2.5
1804 */
1805 $tax_query['tax_filter'][]['bool'] = array(
1806 'must' => array(
1807 array(
1808 'exists' => array(
1809 'field' => key( $terms_obj ),
1810 ),
1811 ),
1812 ),
1813 );
1814
1815 break;
1816 case 'not exists':
1817 /**
1818 * add support for "NOT EXISTS" operator
1819 *
1820 * @since 2.5
1821 */
1822 $tax_query['tax_filter'][]['bool'] = array(
1823 'must_not' => array(
1824 array(
1825 'exists' => array(
1826 'field' => key( $terms_obj ),
1827 ),
1828 ),
1829 ),
1830 );
1831
1832 break;
1833 case 'not in':
1834 /**
1835 * add support for "NOT IN" operator
1836 *
1837 * @since 2.1
1838 */
1839 // If "NOT IN" than it should filter as must_not
1840 $tax_query['tax_must_not_filter'][]['terms'] = $terms_obj;
1841
1842 break;
1843 case 'and':
1844 /**
1845 * add support for "and" operator
1846 *
1847 * @since 2.4
1848 */
1849 $and_nest = array(
1850 'bool' => array(
1851 'must' => array(),
1852 ),
1853 );
1854
1855 foreach ( $terms as $term ) {
1856 $and_nest['bool']['must'][] = array(
1857 'terms' => array(
1858 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => (array) $term,
1859 ),
1860 );
1861 }
1862
1863 $tax_query['tax_filter'][] = $and_nest;
1864
1865 break;
1866 case 'in':
1867 default:
1868 /**
1869 * Default to IN operator
1870 */
1871 // Add the tax query filter
1872 $tax_query['tax_filter'][]['terms'] = $terms_obj;
1873
1874 break;
1875 }
1876 }
1877 }
1878
1879 return $tax_query;
1880 }
1881
1882 /**
1883 * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
1884 *
1885 * @since 1.1
1886 * @access protected
1887 *
1888 * @param string $order The 'order' query variable.
1889 * @return string The sanitized 'order' query variable.
1890 */
1891 protected function parse_order( $order ) {
1892 // Core will always set sort order to DESC for any invalid value,
1893 // so we can't do any automated testing of this function.
1894 // @codeCoverageIgnoreStart
1895 if ( ! is_string( $order ) || empty( $order ) ) {
1896 return 'desc';
1897 }
1898 // @codeCoverageIgnoreEnd
1899
1900 if ( 'ASC' === strtoupper( $order ) ) {
1901 return 'asc';
1902 } else {
1903 return 'desc';
1904 }
1905 }
1906
1907 /**
1908 * Convert the alias to a properly-prefixed sort value.
1909 *
1910 * @since 1.1
1911 * @access protected
1912 *
1913 * @param string $orderbys Alias or path for the field to order by.
1914 * @param string $default_order Default order direction
1915 * @param array $args Query args
1916 * @return array
1917 */
1918 protected function parse_orderby( $orderbys, $default_order, $args ) {
1919 $orderbys = $this->get_orderby_array( $orderbys );
1920
1921 $sort = [];
1922
1923 foreach ( $orderbys as $key => $value ) {
1924 if ( is_string( $key ) ) {
1925 $orderby_clause = $key;
1926 $order = $value;
1927 } else {
1928 $orderby_clause = $value;
1929 $order = $default_order;
1930 }
1931
1932 if ( ! empty( $orderby_clause ) && 'rand' !== $orderby_clause ) {
1933 if ( 'relevance' === $orderby_clause ) {
1934 $sort[] = array(
1935 '_score' => array(
1936 'order' => $order,
1937 ),
1938 );
1939 } elseif ( 'date' === $orderby_clause ) {
1940 $sort[] = array(
1941 'post_date' => array(
1942 'order' => $order,
1943 ),
1944 );
1945 } elseif ( 'type' === $orderby_clause ) {
1946 $sort[] = array(
1947 'post_type.raw' => array(
1948 'order' => $order,
1949 ),
1950 );
1951 } elseif ( 'modified' === $orderby_clause ) {
1952 $sort[] = array(
1953 'post_modified' => array(
1954 'order' => $order,
1955 ),
1956 );
1957 } elseif ( 'name' === $orderby_clause ) {
1958 $sort[] = array(
1959 'post_' . $orderby_clause . '.raw' => array(
1960 'order' => $order,
1961 ),
1962 );
1963 } elseif ( 'title' === $orderby_clause ) {
1964 $sort[] = array(
1965 'post_' . $orderby_clause . '.sortable' => array(
1966 'order' => $order,
1967 ),
1968 );
1969 } elseif ( 'meta_value' === $orderby_clause ) {
1970 if ( ! empty( $args['meta_key'] ) ) {
1971 $sort[] = array(
1972 'meta.' . $args['meta_key'] . '.raw' => array(
1973 'order' => $order,
1974 ),
1975 );
1976 }
1977 } elseif ( 'meta_value_num' === $orderby_clause ) {
1978 if ( ! empty( $args['meta_key'] ) ) {
1979 $sort[] = array(
1980 'meta.' . $args['meta_key'] . '.long' => array(
1981 'order' => $order,
1982 ),
1983 );
1984 }
1985 } else {
1986 $sort[] = array(
1987 $orderby_clause => array(
1988 'order' => $order,
1989 ),
1990 );
1991 }
1992 }
1993 }
1994
1995 return $sort;
1996 }
1997
1998 /**
1999 * Get Order by args Array
2000 *
2001 * @param string|array $orderbys Order by string or array
2002 * @since 2.1
2003 * @return array
2004 */
2005 protected function get_orderby_array( $orderbys ) {
2006 if ( ! is_array( $orderbys ) ) {
2007 $orderbys = explode( ' ', $orderbys );
2008 }
2009
2010 return $orderbys;
2011 }
2012
2013 /**
2014 * Given a mapping content, try to determine the version used.
2015 *
2016 * @since 3.6.3
2017 *
2018 * @param array $mapping Mapping content.
2019 * @param string $index Index name
2020 * @return string Version of the mapping being used.
2021 */
2022 protected function determine_mapping_version_based_on_existing( $mapping, $index ) {
2023 if ( isset( $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'] ) ) {
2024 return $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'];
2025 }
2026 if ( isset( $mapping[ $index ]['mappings']['_meta']['mapping_version'] ) ) {
2027 return $mapping[ $index ]['mappings']['_meta']['mapping_version'];
2028 }
2029
2030 /**
2031 * Check for 7-0 mapping.
2032 * If mapping has a `post` type, it can't be ES 7, as mapping types were removed in that release.
2033 *
2034 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
2035 */
2036 if ( ! isset( $mapping[ $index ]['mappings']['post'] ) ) {
2037 return '7-0.php';
2038 }
2039
2040 $post_mapping = $mapping[ $index ]['mappings']['post'];
2041
2042 /**
2043 * Starting at this point, our tests rely on the post_title.fields.sortable field.
2044 * As this field is present in all our mappings, if this field is not present in
2045 * the mapping, this is a custom mapping.
2046 *
2047 * To have this code working with custom mappings, use the `ep_post_mapping_version_determined` filter.
2048 */
2049 if ( ! isset( $post_mapping['properties']['post_title']['fields']['sortable'] ) ) {
2050 return 'unknown';
2051 }
2052
2053 $post_title_sortable = $post_mapping['properties']['post_title']['fields']['sortable'];
2054
2055 /**
2056 * Check for 5-2 mapping.
2057 * Normalizers on keyword fields were only made available in ES 5.2
2058 *
2059 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.2/release-notes-5.2.0.html
2060 */
2061 if ( isset( $post_title_sortable['normalizer'] ) ) {
2062 return '5-2.php';
2063 }
2064
2065 /**
2066 * Check for 5-0 mapping.
2067 * `keyword` fields were only made available in ES 5.0
2068 *
2069 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
2070 */
2071 if ( 'keyword' === $post_title_sortable['type'] ) {
2072 return '5-0.php';
2073 }
2074
2075 /**
2076 * Check for pre-5-0 mapping.
2077 * `string` fields were deprecated in ES 5.0 in favor of text/keyword
2078 *
2079 * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
2080 */
2081 if ( 'string' === $post_title_sortable['type'] ) {
2082 return 'pre-5-0.php';
2083 }
2084
2085 return 'unknown';
2086 }
2087
2088 /**
2089 * Given ES args, add aggregations to it.
2090 *
2091 * @since 4.1.0
2092 * @param array $formatted_args Formatted Elasticsearch query.
2093 * @param array $agg Aggregation data.
2094 * @param boolean $use_filters Whether filters should be used or not.
2095 * @param array $filter Filters defined so far.
2096 * @return array Formatted Elasticsearch query with the aggregation added.
2097 */
2098 protected function apply_aggregations( $formatted_args, $agg, $use_filters, $filter ) {
2099 if ( empty( $agg['aggs'] ) ) {
2100 return $formatted_args;
2101 }
2102
2103 // Add a name to the aggregation if it was passed through
2104 $agg_name = ( ! empty( $agg['name'] ) ) ? $agg['name'] : 'aggregation_name';
2105
2106 // Add/use the filter if warranted
2107 if ( isset( $agg['use-filter'] ) && false !== $agg['use-filter'] && $use_filters ) {
2108
2109 // If a filter is being used, use it on the aggregation as well to receive relevant information to the query
2110 $formatted_args['aggs'][ $agg_name ]['filter'] = $filter;
2111 $formatted_args['aggs'][ $agg_name ]['aggs'] = $agg['aggs'];
2112 } else {
2113 $formatted_args['aggs'][ $agg_name ] = $agg['aggs'];
2114 }
2115
2116 return $formatted_args;
2117 }
2118
2119 /**
2120 * Get the search algorithm that should be used.
2121 *
2122 * @since 4.3.0
2123 * @param string $search_text Search term(s)
2124 * @param array $search_fields Search fields
2125 * @param array $query_vars Query vars
2126 * @return SearchAlgorithm Instance of search algorithm to be used
2127 */
2128 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
2129 $search_algorithm_version_option = \ElasticPress\Utils\get_option( 'ep_search_algorithm_version', '4.0' );
2130
2131 /**
2132 * Filter the algorithm version to be used.
2133 *
2134 * @since 3.5
2135 * @hook ep_search_algorithm_version
2136 * @param {string} $search_algorithm_version Algorithm version.
2137 * @return {string} New algorithm version
2138 */
2139 $search_algorithm = apply_filters( 'ep_search_algorithm_version', $search_algorithm_version_option );
2140
2141 /**
2142 * Filter the search algorithm to be used
2143 *
2144 * @hook ep_{$indexable_slug}_search_algorithm
2145 * @since 4.3.0
2146 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
2147 * @param {string} $search_term Search term
2148 * @param {array} $search_fields Fields to be searched
2149 * @param {array} $query_vars Query variables
2150 * @return {string} New search algorithm slug
2151 */
2152 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", $search_algorithm, $search_text, $search_fields, $query_vars );
2153
2154 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
2155 }
2156 }
2157