PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
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 5.3.5, at includes/classes/Indexable/Post/Post.php

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