PluginProbe
ElasticPress / 4.7.2
ElasticPress v4.7.2
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 4.7.2, at includes/classes/Indexable/Post/QueryIntegration.php

599 lines 16.2 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, 1 );
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 * Disables cache_results, 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 /**
120 * `cache_results` defaults to false but can be enabled.
121 *
122 * @since 1.5
123 */
124 $query->set( 'cache_results', false );
125 if ( ! empty( $query->query['cache_results'] ) ) {
126 $query->set( 'cache_results', true );
127 }
128
129 if ( ! headers_sent() ) {
130 /**
131 * Manually setting a header as $wp_query isn't yet initialized when we
132 * call: add_filter('wp_headers', 'filter_wp_headers');
133 */
134 // @codeCoverageIgnoreStart
135 header( 'X-ElasticPress-Query: true' );
136 // @codeCoverageIgnoreEnd
137 }
138 }
139
140 /**
141 * Gets the blog ID that the class is currently switched to.
142 *
143 * @return int
144 */
145 public function get_switched() {
146 return $this->switched;
147 }
148
149 /**
150 * Switch to the correct site if the post site id is different than the actual one.
151 *
152 * Note: This function can bring a performance penalty in multisites with a high number of sites.
153 *
154 * @param WP_Post $post Post object
155 * @param WP_Query $query WP_Query instance. If null, the global query will be used.
156 * @since 0.9
157 * @since 3.6.2 `$query` parameter added.
158 */
159 public function maybe_switch_to_blog( $post, $query = null ) {
160 global $wp_query;
161 if ( ! $query ) {
162 $query = $wp_query;
163 }
164
165 if ( ! is_multisite() ) {
166 // @codeCoverageIgnoreStart
167 return;
168 // @codeCoverageIgnoreEnd
169 }
170
171 if ( ! empty( $post->site_id ) && get_current_blog_id() !== $post->site_id ) {
172 if ( $this->switched ) {
173 restore_current_blog();
174
175 $this->switched = false;
176 }
177
178 switch_to_blog( $post->site_id );
179
180 $this->switched = $post->site_id;
181
182 remove_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
183 setup_postdata( $post );
184 add_action( 'the_post', array( $this, 'maybe_switch_to_blog' ), 10, 2 );
185
186 if ( $this->switched && ! $query->in_the_loop ) {
187 restore_current_blog();
188
189 $this->switched = false;
190 }
191 }
192
193 }
194
195 /**
196 * Make sure the correct blog is restored
197 *
198 * @param WP_Query $query WP_Query instance
199 * @since 0.9
200 */
201 public function maybe_restore_blog( $query ) {
202 if ( ! is_multisite() ) {
203 // @codeCoverageIgnoreStart
204 return;
205 // @codeCoverageIgnoreEnd
206 }
207
208 if ( $this->switched ) {
209 restore_current_blog();
210
211 $this->switched = false;
212 }
213 }
214
215 /**
216 * Get posts from Elasticsearch
217 *
218 * @param array $posts Array of posts
219 * @param WP_Query $query WP_Query instance
220 * @since 3.0
221 * @return string
222 */
223 public function get_es_posts( $posts, $query ) {
224 global $wpdb;
225
226 /**
227 * Filter to skip WP Query integration
228 *
229 * @hook ep_skip_query_integration
230 * @param {bool} $skip True to skip
231 * @param {WP_Query} $query WP Query to evaluate
232 * @return {bool} New skip value
233 */
234 if ( ! Indexables::factory()->get( 'post' )->elasticpress_enabled( $query ) || apply_filters( 'ep_skip_query_integration', false, $query ) ) {
235 return $posts;
236 }
237
238 $query_vars = $query->query_vars;
239
240 /**
241 * Filter post type query variables before WP Query
242 *
243 * @since 2.1
244 * @hook ep_query_post_type
245 * @param {string|array} $post_types Post types
246 * @param {WP_Query} $query WP Query object
247 * @return {string|array} New post types
248 */
249 $query_vars['post_type'] = apply_filters( 'ep_query_post_type', $query_vars['post_type'], $query );
250
251 if ( 'any' === $query_vars['post_type'] ) {
252 unset( $query_vars['post_type'] );
253 }
254
255 /**
256 * If not search and not set default to post. If not set and is search, use searchable post types
257 */
258 if ( empty( $query_vars['post_type'] ) ) {
259 if ( $query->is_tax() ) {
260 $query_vars['post_type'] = get_taxonomy( $query->get_queried_object()->taxonomy )->object_type;
261 } elseif ( empty( $query_vars['s'] ) ) {
262 $query_vars['post_type'] = 'post';
263 } else {
264 $query_vars['post_type'] = array_values( get_post_types( array( 'exclude_from_search' => false ) ) );
265 }
266 }
267
268 /**
269 * No post types so bail
270 */
271 if ( empty( $query_vars['post_type'] ) ) {
272 return [];
273 }
274
275 /**
276 * Filter cached posts pre-post query
277 *
278 * @hook ep_wp_query_cached_posts
279 * @param {array} $posts Array of posts
280 * @param {WP_Query} $query WP Query object
281 * @return {array} New cached posts
282 */
283 $new_posts = apply_filters( 'ep_wp_query_cached_posts', [], $query );
284
285 $ep_query = null;
286
287 if ( count( $new_posts ) < 1 ) {
288
289 $scope = 'current';
290
291 $site__in = '';
292 $site__not_in = '';
293
294 if ( ! empty( $query_vars['sites'] ) ) {
295 _deprecated_argument( __FUNCTION__, '4.4.0', esc_html__( 'sites is deprecated. Use site__in instead.', 'elasticpress' ) );
296 }
297
298 if ( ! empty( $query_vars['site__in'] ) || ! empty( $query_vars['sites'] ) ) {
299 $site__in = ! empty( $query_vars['site__in'] ) ? (array) $query_vars['site__in'] : (array) $query_vars['sites'];
300
301 if ( in_array( 'all', $site__in, true ) ) {
302 $scope = 'all';
303 } elseif ( in_array( 'current', $site__in, true ) ) {
304 $site__in = (array) get_current_blog_id();
305 }
306 }
307
308 if ( ! empty( $query_vars['site__not_in'] ) ) {
309 $site__not_in = (array) $query_vars['site__not_in'];
310 }
311
312 $formatted_args = Indexables::factory()->get( 'post' )->format_args( $query_vars, $query );
313
314 /**
315 * Filter post query scope
316 *
317 * @hook ep_search_scope
318 * @param {string} $scope Current scope
319 * @return {string} New scope
320 * @since 2.1
321 */
322 $scope = apply_filters( 'ep_search_scope', $scope );
323
324 if ( ! defined( 'EP_IS_NETWORK' ) || ! EP_IS_NETWORK ) {
325 // @codeCoverageIgnoreStart
326 $scope = 'current';
327 // @codeCoverageIgnoreEnd
328 }
329
330 $index = null;
331
332 if ( 'all' === $scope ) {
333 $index = Indexables::factory()->get( 'post' )->get_network_alias();
334 } elseif ( ! empty( $site__in ) ) {
335 $index = [];
336
337 foreach ( $site__in as $site_id ) {
338 $index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
339 }
340
341 $index = implode( ',', $index );
342 } elseif ( ! empty( $site__not_in ) ) {
343
344 $sites = \get_sites(
345 array(
346 'fields' => 'ids',
347 'site__not_in' => $site__not_in,
348 )
349 );
350 foreach ( $sites as $site_id ) {
351 if ( ! Utils\is_site_indexable( $site_id ) ) {
352 continue;
353 }
354 $index[] = Indexables::factory()->get( 'post' )->get_index_name( $site_id );
355 }
356
357 $index = implode( ',', $index );
358 }
359
360 $ep_query = Indexables::factory()->get( 'post' )->query_es( $formatted_args, $query->query_vars, $index, $query );
361
362 /**
363 * ES failed. Go back to MySQL.
364 */
365 if ( false === $ep_query ) {
366 $query->elasticsearch_success = false;
367 return null;
368 }
369
370 $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
371 $query->found_posts = $found_documents;
372 $query->num_posts = $query->found_posts;
373 $query->max_num_pages = ceil( $found_documents / $query->get( 'posts_per_page' ) );
374 $query->suggested_terms = $this->maybe_sanitize_suggestion( $ep_query );
375 $query->elasticsearch_success = true;
376
377 // Determine how we should format the results from ES based on the fields parameter.
378 $fields = $query->get( 'fields', '' );
379
380 switch ( $fields ) {
381 case 'ids':
382 $new_posts = $this->format_hits_as_ids( $ep_query['documents'], $new_posts );
383 break;
384
385 case 'id=>parent':
386 $new_posts = $this->format_hits_as_id_parents( $ep_query['documents'], $new_posts );
387 break;
388
389 default:
390 $new_posts = $this->format_hits_as_posts( $ep_query['documents'], $new_posts );
391 break;
392 }
393
394 /**
395 * Fires after non cached post query
396 *
397 * @hook ep_wp_query_non_cached_search
398 * @param {array} $new_posts Array of posts from query
399 * @param {array} $ep_query Raw Elasticsearch query
400 * @param {WP_Query} $query WordPress query
401 */
402 do_action( 'ep_wp_query_non_cached_search', $new_posts, $ep_query, $query );
403 }
404
405 /**
406 * Fires before returning posts from query
407 *
408 * @hook ep_wp_query
409 * @param {array} $new_posts Array of posts from query
410 * @param {array} $ep_query Raw Elasticsearch query
411 * @param {WP_Query} $query WordPress query
412 */
413 do_action( 'ep_wp_query', $new_posts, $ep_query, $query );
414
415 /**
416 * Fires before returning posts from query
417 *
418 * Pre-3.0 backwards compat
419 *
420 * @hook ep_wp_query_search
421 * @param {array} $new_posts Array of posts from query
422 * @param {array} $ep_query Raw Elasticsearch query
423 * @param {WP_Query} $query WordPress query
424 */
425 do_action( 'ep_wp_query_search', $new_posts, $ep_query, $query );
426
427 return $new_posts;
428 }
429
430 /**
431 * Format the ES hits/results as post objects.
432 *
433 * @since 2.4.0
434 *
435 * @param array $posts The posts that should be formatted.
436 * @param array $new_posts Array of posts from cache.
437 *
438 * @return array
439 */
440 protected function format_hits_as_posts( $posts, $new_posts ) {
441 foreach ( $posts as $post_array ) {
442 $post = new \stdClass();
443
444 $post->ID = $post_array['post_id'];
445 $post->site_id = get_current_blog_id();
446
447 if ( ! empty( $post_array['site_id'] ) ) {
448 $post->site_id = $post_array['site_id'];
449 }
450 /**
451 * Filter post object properties set after query
452 *
453 * @hook ep_search_post_return_args
454 * @param {array} $properties Post properties
455 * @return {array} New properties
456 */
457 $post_return_args = apply_filters(
458 'ep_search_post_return_args',
459 array(
460 'post_type',
461 'post_author',
462 'post_name',
463 'post_status',
464 'post_title',
465 'post_parent',
466 'post_content',
467 'post_excerpt',
468 'post_date',
469 'post_date_gmt',
470 'post_modified',
471 'post_modified_gmt',
472 'post_mime_type',
473 'comment_count',
474 'comment_status',
475 'ping_status',
476 'menu_order',
477 'permalink',
478 'terms',
479 'post_meta',
480 'meta',
481 )
482 );
483
484 foreach ( $post_return_args as $key ) {
485 if ( 'post_author' === $key ) {
486 $post->$key = $post_array[ $key ]['id'];
487 } elseif ( isset( $post_array[ $key ] ) ) {
488 $post->$key = $post_array[ $key ];
489 }
490 }
491
492 /**
493 * Replace post attributes with highlighted versions if available.
494 *
495 * $post_array['highlight'] is set from $hit['highlight'] in Elasticsearch.php
496 * when going through the returned results, and that is defined by
497 * the Highlighting Feature on setup, calling ep_formatted_args to
498 * define the highlight array of fields.
499 */
500 if ( isset( $post_array['highlight'] ) ) {
501 foreach ( $post_array['highlight'] as $key => $val ) {
502 // e.g. $post->post_content
503 if ( isset( $post->$key ) ) {
504 /**
505 * e.g. replaces post content value with the highlighted value
506 * $post->post_content = implode( ' ', $post_array['highlight']['post_content'] );
507 *
508 * Depending on how highlight.fields.<field>.number_of_fragments is set in the query,
509 * Elasticsearch can return an array with N entries, with N being the number
510 * of matches found.
511 */
512 $post->$key = implode( ' ', $val );
513 }
514 }
515 }
516
517 $post->elasticsearch = true; // Super useful for debugging
518
519 if ( $post ) {
520 $new_posts[] = $post;
521 }
522 }
523
524 return $new_posts;
525 }
526
527 /**
528 * Format the ES hits/results as an array of ids.
529 *
530 * @since 2.4.0
531 *
532 * @param array $posts The posts that should be formatted.
533 * @param array $new_posts Array of posts from cache.
534 *
535 * @return array
536 */
537 protected function format_hits_as_ids( $posts, $new_posts ) {
538 foreach ( $posts as $post_array ) {
539 $new_posts[] = $post_array['post_id'];
540 }
541
542 return $new_posts;
543 }
544
545 /**
546 * Format the ES hits/results as objects containing id and parent id.
547 *
548 * @since 2.4.0
549 *
550 * @param array $posts The posts that should be formatted.
551 * @param array $new_posts Array of posts from cache.
552 *
553 * @return array
554 */
555 protected function format_hits_as_id_parents( $posts, $new_posts ) {
556 foreach ( $posts as $post_array ) {
557 $post = new \stdClass();
558 $post->ID = $post_array['post_id'];
559 $post->post_parent = $post_array['post_parent'];
560 $post->elasticsearch = true; // Super useful for debugging
561 $new_posts[] = $post;
562 }
563 return $new_posts;
564 }
565
566 /**
567 * Remove any suggestion that has a score lower than the minimum score.
568 *
569 * @since 4.6.0
570 * @param array $ep_query The query array.
571 * @return array
572 */
573 protected function maybe_sanitize_suggestion( $ep_query ) {
574 if ( ! isset( $ep_query['suggest']['ep_suggestion'], $ep_query['suggest']['ep_suggestion'][0] ) ) {
575 return [];
576 }
577
578 $suggestion = $ep_query['suggest']['ep_suggestion'][0];
579
580 /**
581 * Filter the score for a suggestion. If the score is lower than this, it will be removed.
582 *
583 * @since 4.6.0
584 * @param float $min_score The minimum score allowed.
585 * @return float
586 */
587 $min_score = (float) apply_filters( 'ep_suggestion_minimum_score', 0.0001 );
588
589 $suggestion['options'] = array_filter(
590 $suggestion['options'],
591 function( $option ) use ( $min_score ) {
592 return number_format( $option['score'], 10 ) > $min_score;
593 }
594 );
595
596 return $suggestion;
597 }
598 }
599