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 / QueryIntegration.php

QueryIntegration.php in ElasticPress 5.3.5, at includes/classes/Indexable/Post/QueryIntegration.php

603 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Integrate with WP_Query
4 *
5 * @since 1.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress\Indexable\Post;
10
11 use WP_Query;
12 use ElasticPress\Indexables;
13 use ElasticPress\Utils;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 // @codeCoverageIgnoreStart
17 exit; // Exit if accessed directly.
18 // @codeCoverageIgnoreEnd
19 }
20
21 /**
22 * Query integration class
23 */
24 class QueryIntegration {
25
26 /**
27 * Is set only when we switch_to_blog in MS context
28 *
29 * @var boolean
30 */
31 private $switched = false;
32
33 /**
34 * Checks to see if we should be integrating and if so, sets up the appropriate actions and filters.
35 *
36 * @param string $indexable_slug Indexable slug. Optional.
37 *
38 * @since 0.9
39 * @since 3.6.0 Added $indexable_slug
40 */
41 public function __construct( $indexable_slug = 'post' ) {
42 /**
43 * Filter whether to enable query integration during indexing
44 *
45 * @since 4.5.2
46 * @hook ep_enable_query_integration_during_indexing
47 *
48 * @param {bool} $enable To allow query integration during indexing
49 * @param {string} $indexable_slug Indexable slug
50 * @return {bool} New value
51 */
52 $allow_query_integration_during_indexing = apply_filters( 'ep_enable_query_integration_during_indexing', false, $indexable_slug );
53
54 // Ensure that we are currently allowing ElasticPress to override the normal WP_Query
55 // Indexable->is_full_reindexing() is not available at this point yet, so using the IndexHelper version of it.
56 if ( \ElasticPress\IndexHelper::factory()->is_full_reindexing( $indexable_slug, get_current_blog_id() ) && ! $allow_query_integration_during_indexing ) {
57 return;
58 }
59
60 // Add header
61 add_action( 'pre_get_posts', array( $this, 'add_es_header' ), 5 );
62
63 // Query ES for posts
64 add_filter( 'posts_pre_query', array( $this, 'get_es_posts' ), 10, 2 );
65
66 // Properly restore blog if necessary
67 add_action( 'loop_end', array( $this, 'maybe_restore_blog' ), 10 );
68
69 // Properly switch to blog if necessary
70 add_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
71
72 // Sets the correct value for found_posts
73 add_filter( 'found_posts', array( $this, 'found_posts' ), 10, 2 );
74 }
75
76 /**
77 * Set the found_posts variable on WP_Query.
78 *
79 * @param int $found_posts Number of found posts
80 * @param WP_Query $query Query object
81 * @since 2.8.2
82 * @return int
83 */
84 public function found_posts( $found_posts, $query ) {
85 /**
86 * Filter to skip WP Query integration
87 *
88 * @hook ep_skip_query_integration
89 * @param {bool} $skip True to skip
90 * @param {WP_Query} $query WP Query to evaluate
91 * @return {bool} New skip value
92 */
93 if ( ( isset( $query->elasticsearch_success ) && false === $query->elasticsearch_success ) || ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) ) {
94 return $found_posts;
95 }
96
97 return $query->num_posts;
98 }
99
100 /**
101 * Adds header.
102 *
103 * @param WP_Query $query WP_Query instance
104 * @since 0.9
105 */
106 public function add_es_header( $query ) {
107 /**
108 * Filter to skip WP Query integration
109 *
110 * @hook ep_skip_query_integration
111 * @param {bool} $skip True to skip
112 * @param {WP_Query} $query WP Query to evaluate
113 * @return {bool} New skip value
114 */
115 if ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) {
116 return;
117 }
118
119 if ( ! headers_sent() ) {
120 /**
121 * Manually setting a header as $wp_query isn't yet initialized when we
122 * call: add_filter('wp_headers', 'filter_wp_headers');
123 */
124 // @codeCoverageIgnoreStart
125 header( 'X-ElasticPress-Query: true' );
126 // @codeCoverageIgnoreEnd
127 }
128 }
129
130 /**
131 * Gets the blog ID that the class is currently switched to.
132 *
133 * @return int
134 */
135 public function get_switched() {
136 return $this->switched;
137 }
138
139 /**
140 * Switch to the correct site if the post site id is different than the actual one.
141 *
142 * Note: This function can bring a performance penalty in multisites with a high number of sites.
143 *
144 * @param WP_Post $post Post object
145 * @param WP_Query $query WP_Query instance. If null, the global query will be used.
146 * @since 0.9
147 * @since 3.6.2 `$query` parameter added.
148 */
149 public function maybe_switch_to_blog( $post, $query = null ) {
150 global $wp_query;
151 if ( ! $query ) {
152 $query = $wp_query;
153 }
154
155 if ( ! is_multisite() ) {
156 // @codeCoverageIgnoreStart
157 return;
158 // @codeCoverageIgnoreEnd
159 }
160
161 if ( ! empty( $post->site_id ) && get_current_blog_id() !== $post->site_id ) {
162 if ( $this->switched ) {
163 restore_current_blog();
164
165 $this->switched = false;
166 }
167
168 switch_to_blog( $post->site_id );
169
170 $this->switched = $post->site_id;
171
172 remove_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
173 setup_postdata( $post );
174 add_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
175
176 if ( $this->switched && ! $query->in_the_loop ) {
177 restore_current_blog();
178
179 $this->switched = false;
180 }
181 }
182 }
183
184 /**
185 * Make sure the correct blog is restored
186 *
187 * @param WP_Query $query WP_Query instance
188 *
189 * @since 0.9
190 */
191 public function maybe_restore_blog( $query ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
192 if ( ! is_multisite() ) {
193 // @codeCoverageIgnoreStart
194 return;
195 // @codeCoverageIgnoreEnd
196 }
197
198 if ( $this->switched ) {
199 restore_current_blog();
200
201 $this->switched = false;
202 }
203 }
204
205 /**
206 * Get posts from Elasticsearch
207 *
208 * @param array $posts Array of posts
209 * @param WP_Query $query WP_Query instance
210 * @since 3.0
211 * @return string
212 */
213 public function get_es_posts( $posts, $query ) {
214 /**
215 * Filter to skip WP Query integration
216 *
217 * @hook ep_skip_query_integration
218 * @param {bool} $skip True to skip
219 * @param {WP_Query} $query WP Query to evaluate
220 * @return {bool} New skip value
221 */
222 if ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) {
223 return $posts;
224 }
225
226 $query_vars = $query->query_vars;
227
228 /**
229 * Filter post type query variables before WP Query
230 *
231 * @since 2.1
232 * @hook ep_query_post_type
233 * @param {string|array} $post_types Post types
234 * @param {WP_Query} $query WP Query object
235 * @return {string|array} New post types
236 */
237 $query_vars['post_type'] = apply_filters( 'ep_query_post_type', $query_vars['post_type'] ?? '', $query );
238
239 if ( 'any' === $query_vars['post_type'] ) {
240 unset( $query_vars['post_type'] );
241 }
242
243 /**
244 * If not search and not set, default to post. If not set and is search, use searchable post types.
245 */
246 if ( empty( $query_vars['post_type'] ) ) {
247 $tax_post_type = Utils\get_post_types_for_tax_query( $query );
248
249 if ( ! empty( $tax_post_type ) ) {
250 $query_vars['post_type'] = $tax_post_type;
251 } elseif ( empty( $query_vars['s'] ) ) {
252 $query_vars['post_type'] = 'post';
253 } else {
254 $query_vars['post_type'] = array_values( get_post_types( array( 'exclude_from_search' => false ) ) );
255 }
256 }
257
258 /**
259 * No post types so bail
260 */
261 if ( empty( $query_vars['post_type'] ) ) {
262 return [];
263 }
264
265 /**
266 * Filter cached posts pre-post query
267 *
268 * @hook ep_wp_query_cached_posts
269 * @param {array} $posts Array of posts
270 * @param {WP_Query} $query WP Query object
271 * @return {array} New cached posts
272 */
273 $new_posts = apply_filters( 'ep_wp_query_cached_posts', [], $query );
274
275 $ep_query = null;
276
277 if ( count( $new_posts ) < 1 ) {
278
279 $scope = 'current';
280
281 $site__in = '';
282 $site__not_in = '';
283
284 if ( ! empty( $query_vars['sites'] ) ) {
285 _deprecated_argument( __FUNCTION__, '4.4.0', esc_html__( 'sites is deprecated. Use site__in instead.', 'elasticpress' ) );
286 }
287
288 if ( ! empty( $query_vars['site__in'] ) || ! empty( $query_vars['sites'] ) ) {
289 $site__in = ! empty( $query_vars['site__in'] ) ? (array) $query_vars['site__in'] : (array) $query_vars['sites'];
290
291 if ( in_array( 'all', $site__in, true ) ) {
292 $scope = 'all';
293 } elseif ( in_array( 'current', $site__in, true ) ) {
294 $site__in = (array) get_current_blog_id();
295 }
296 }
297
298 if ( ! empty( $query_vars['site__not_in'] ) ) {
299 $site__not_in = (array) $query_vars['site__not_in'];
300 }
301
302 $formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_vars, $query );
303
304 /**
305 * Filter post query scope
306 *
307 * @hook ep_search_scope
308 * @param {string} $scope Current scope
309 * @return {string} New scope
310 * @since 2.1
311 */
312 $scope = apply_filters( 'ep_search_scope', $scope );
313
314 if ( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) {
315 // @codeCoverageIgnoreStart
316 $scope = 'current';
317 // @codeCoverageIgnoreEnd
318 }
319
320 /**
321 * Disable the post cache when using a persistent object cache or querying
322 * across sites, where identical post IDs can cause cache collisions.
323 */
324 if ( wp_using_ext_object_cache() || 'all' === $scope || ! empty( $site__in ) || ! empty( $site__not_in ) ) {
325 $query->set( 'cache_results', false );
326 }
327
328 $index = null;
329
330 if ( 'all' === $scope ) {
331 $index = Indexables::factory()->get( 'post' )->get_network_alias();
332 } elseif ( ! empty( $site__in ) ) {
333 $index = [];
334
335 foreach ( $site__in as $site_id ) {
336 $index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
337 }
338
339 $index = implode( ',', $index );
340 } elseif ( ! empty( $site__not_in ) ) {
341
342 $sites = \get_sites(
343 array(
344 'fields' => 'ids',
345 'site__not_in' => $site__not_in,
346 )
347 );
348 foreach ( $sites as $site_id ) {
349 if ( ! Utils\is_site_indexable( $site_id ) ) {
350 continue;
351 }
352 $index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
353 }
354
355 $index = implode( ',', $index );
356 }
357
358 $ep_query = Indexables::factory()->get( 'post' )->query_es( $formatted_args, $query->query_vars, $index, $query );
359
360 /**
361 * ES failed. Go back to MySQL.
362 */
363 if ( false === $ep_query ) {
364 $query->elasticsearch_success = false;
365 return null;
366 }
367
368 $found_documents = is_array( $ep_query['found_documents'] ) ? $ep_query['found_documents']['value'] : $ep_query['found_documents']; // 7.0+ have this as an array rather than int
369 $query->found_posts = $found_documents;
370 $query->num_posts = $query->found_posts;
371 $query->max_num_pages = -1 === $query->get( 'posts_per_page' ) ? 0 : ceil( $found_documents / $query->get( 'posts_per_page' ) );
372 $query->suggested_terms = $this->maybe_sanitize_suggestion( $ep_query );
373 $query->elasticsearch_success = true;
374
375 // Determine how we should format the results from ES based on the fields parameter.
376 $fields = $query->get( 'fields', '' );
377
378 switch ( $fields ) {
379 case 'ids':
380 $new_posts = $this->format_hits_as_ids( $ep_query['documents'], $new_posts );
381 break;
382
383 case 'id=>parent':
384 $new_posts = $this->format_hits_as_id_parents( $ep_query['documents'], $new_posts );
385 break;
386
387 default:
388 $new_posts = $this->format_hits_as_posts( $ep_query['documents'], $new_posts );
389 break;
390 }
391
392 /**
393 * Fires after non cached post query
394 *
395 * @hook ep_wp_query_non_cached_search
396 * @param {array} $new_posts Array of posts from query
397 * @param {array} $ep_query Raw Elasticsearch query
398 * @param {WP_Query} $query WordPress query
399 */
400 do_action( 'ep_wp_query_non_cached_search', $new_posts, $ep_query, $query );
401 }
402
403 /**
404 * Fires before returning posts from query
405 *
406 * @hook ep_wp_query
407 * @param {array} $new_posts Array of posts from query
408 * @param {array} $ep_query Raw Elasticsearch query
409 * @param {WP_Query} $query WordPress query
410 */
411 do_action( 'ep_wp_query', $new_posts, $ep_query, $query );
412
413 /**
414 * Fires before returning posts from query
415 *
416 * Pre-3.0 backwards compat
417 *
418 * @hook ep_wp_query_search
419 * @param {array} $new_posts Array of posts from query
420 * @param {array} $ep_query Raw Elasticsearch query
421 * @param {WP_Query} $query WordPress query
422 */
423 do_action( 'ep_wp_query_search', $new_posts, $ep_query, $query );
424
425 return $new_posts;
426 }
427
428 /**
429 * Format the ES hits/results as post objects.
430 *
431 * @since 2.4.0
432 *
433 * @param array $posts The posts that should be formatted.
434 * @param array $new_posts Array of posts from cache.
435 *
436 * @return array
437 */
438 protected function format_hits_as_posts( $posts, $new_posts ) {
439 foreach ( $posts as $post_array ) {
440 $post = new \stdClass();
441
442 $post->ID = $post_array['post_id'];
443 $post->site_id = get_current_blog_id();
444
445 if ( ! empty( $post_array['site_id'] ) ) {
446 $post->site_id = $post_array['site_id'];
447 }
448 /**
449 * Filter post object properties set after query
450 *
451 * @hook ep_search_post_return_args
452 * @param {array} $properties Post properties
453 * @return {array} New properties
454 */
455 $post_return_args = apply_filters(
456 'ep_search_post_return_args',
457 array(
458 'post_type',
459 'post_author',
460 'post_name',
461 'post_status',
462 'post_title',
463 'post_parent',
464 'post_content',
465 'post_excerpt',
466 'post_date',
467 'post_date_gmt',
468 'post_modified',
469 'post_modified_gmt',
470 'post_mime_type',
471 'comment_count',
472 'comment_status',
473 'ping_status',
474 'menu_order',
475 'permalink',
476 'terms',
477 'post_meta',
478 'meta',
479 )
480 );
481
482 foreach ( $post_return_args as $key ) {
483 if ( 'post_author' === $key ) {
484 if ( isset( $post_array[ $key ]['id'] ) ) {
485 $post->$key = $post_array[ $key ]['id'];
486 }
487 } elseif ( isset( $post_array[ $key ] ) ) {
488 if ( in_array( $key, [ 'terms', 'meta', 'post_meta' ], true ) && is_array( $post_array[ $key ] ) ) {
489 $post->$key = wp_json_encode( $post_array[ $key ] );
490 } else {
491 $post->$key = $post_array[ $key ];
492 }
493 }
494 }
495
496 /**
497 * Replace post attributes with highlighted versions if available.
498 *
499 * $post_array['highlight'] is set from $hit['highlight'] in Elasticsearch.php
500 * when going through the returned results, and that is defined by
501 * the Highlighting Feature on setup, calling ep_formatted_args to
502 * define the highlight array of fields.
503 */
504 if ( isset( $post_array['highlight'] ) ) {
505 foreach ( $post_array['highlight'] as $key => $val ) {
506 // e.g. $post->post_content
507 if ( isset( $post->$key ) ) {
508 /**
509 * e.g. replaces post content value with the highlighted value
510 * $post->post_content = implode( ' ', $post_array['highlight']['post_content'] );
511 *
512 * Depending on how highlight.fields.<field>.number_of_fragments is set in the query,
513 * Elasticsearch can return an array with N entries, with N being the number
514 * of matches found.
515 */
516 $post->$key = implode( ' ', $val );
517 }
518 }
519 }
520
521 $post->elasticsearch = true; // Super useful for debugging
522
523 if ( $post ) {
524 $new_posts[] = $post;
525 }
526 }
527
528 return $new_posts;
529 }
530
531 /**
532 * Format the ES hits/results as an array of ids.
533 *
534 * @since 2.4.0
535 *
536 * @param array $posts The posts that should be formatted.
537 * @param array $new_posts Array of posts from cache.
538 *
539 * @return array
540 */
541 protected function format_hits_as_ids( $posts, $new_posts ) {
542 foreach ( $posts as $post_array ) {
543 $new_posts[] = $post_array['post_id'];
544 }
545
546 return $new_posts;
547 }
548
549 /**
550 * Format the ES hits/results as objects containing id and parent id.
551 *
552 * @since 2.4.0
553 *
554 * @param array $posts The posts that should be formatted.
555 * @param array $new_posts Array of posts from cache.
556 *
557 * @return array
558 */
559 protected function format_hits_as_id_parents( $posts, $new_posts ) {
560 foreach ( $posts as $post_array ) {
561 $post = new \stdClass();
562 $post->ID = $post_array['post_id'];
563 $post->post_parent = $post_array['post_parent'];
564 $post->elasticsearch = true; // Super useful for debugging
565 $new_posts[] = $post;
566 }
567 return $new_posts;
568 }
569
570 /**
571 * Remove any suggestion that has a score lower than the minimum score.
572 *
573 * @since 4.6.0
574 * @param array $ep_query The query array.
575 * @return array
576 */
577 protected function maybe_sanitize_suggestion( $ep_query ) {
578 if ( ! isset( $ep_query['suggest']['ep_suggestion'], $ep_query['suggest']['ep_suggestion'][0] ) ) {
579 return [];
580 }
581
582 $suggestion = $ep_query['suggest']['ep_suggestion'][0];
583
584 /**
585 * Filter the score for a suggestion. If the score is lower than this, it will be removed.
586 *
587 * @since 4.6.0
588 * @param float $min_score The minimum score allowed.
589 * @return float
590 */
591 $min_score = (float) apply_filters( 'ep_suggestion_minimum_score', 0.0001 );
592
593 $suggestion['options'] = array_filter(
594 $suggestion['options'],
595 function ( $option ) use ( $min_score ) {
596 return number_format( $option['score'], 10 ) > $min_score;
597 }
598 );
599
600 return $suggestion;
601 }
602 }
603