Completed
Push — fix/jetpack-search-sync ( b948ef )
by
unknown
55:29 queued 37:22
created

Jetpack_Search::get_search_facet_data()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 5
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Jetpack Search: Main Jetpack_Search class
4
 *
5
 * @package    Jetpack
6
 * @subpackage Jetpack Search
7
 * @since      5.0.0
8
 */
9
10
/**
11
 * The main class for the Jetpack Search module.
12
 *
13
 * @since 5.0.0
14
 */
15
class Jetpack_Search {
16
17
	/**
18
	 * The number of found posts.
19
	 *
20
	 * @since 5.0.0
21
	 *
22
	 * @var int
23
	 */
24
	protected $found_posts = 0;
25
26
	/**
27
	 * The search result, as returned by the WordPress.com REST API.
28
	 *
29
	 * @since 5.0.0
30
	 *
31
	 * @var array
32
	 */
33
	protected $search_result;
34
35
	/**
36
	 * This site's blog ID on WordPress.com.
37
	 *
38
	 * @since 5.0.0
39
	 *
40
	 * @var int
41
	 */
42
	protected $jetpack_blog_id;
43
44
	/**
45
	 * The Elasticsearch aggregations (filters).
46
	 *
47
	 * @since 5.0.0
48
	 *
49
	 * @var array
50
	 */
51
	protected $aggregations = array();
52
53
	/**
54
	 * The maximum number of aggregations allowed.
55
	 *
56
	 * @since 5.0.0
57
	 *
58
	 * @var int
59
	 */
60
	protected $max_aggregations_count = 100;
61
62
	/**
63
	 * Statistics about the last Elasticsearch query.
64
	 *
65
	 * @since 5.6.0
66
	 *
67
	 * @var array
68
	 */
69
	protected $last_query_info = array();
70
71
	/**
72
	 * Statistics about the last Elasticsearch query failure.
73
	 *
74
	 * @since 5.6.0
75
	 *
76
	 * @var array
77
	 */
78
	protected $last_query_failure_info = array();
79
80
	/**
81
	 * The singleton instance of this class.
82
	 *
83
	 * @since 5.0.0
84
	 *
85
	 * @var Jetpack_Search
86
	 */
87
	protected static $instance;
88
89
	/**
90
	 * Languages with custom analyzers. Other languages are supported, but are analyzed with the default analyzer.
91
	 *
92
	 * @since 5.0.0
93
	 *
94
	 * @var array
95
	 */
96
	public static $analyzed_langs = array( 'ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'eu', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'hy', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' );
97
98
	/**
99
	 * Jetpack_Search constructor.
100
	 *
101
	 * @since 5.0.0
102
	 *
103
	 * Doesn't do anything. This class needs to be initialized via the instance() method instead.
104
	 */
105
	protected function __construct() {
106
	}
107
108
	/**
109
	 * Prevent __clone()'ing of this class.
110
	 *
111
	 * @since 5.0.0
112
	 */
113
	public function __clone() {
114
		wp_die( "Please don't __clone Jetpack_Search" );
115
	}
116
117
	/**
118
	 * Prevent __wakeup()'ing of this class.
119
	 *
120
	 * @since 5.0.0
121
	 */
122
	public function __wakeup() {
123
		wp_die( "Please don't __wakeup Jetpack_Search" );
124
	}
125
126
	/**
127
	 * Get singleton instance of Jetpack_Search.
128
	 *
129
	 * Instantiates and sets up a new instance if needed, or returns the singleton.
130
	 *
131
	 * @since 5.0.0
132
	 *
133
	 * @return Jetpack_Search The Jetpack_Search singleton.
134
	 */
135
	public static function instance() {
136
		if ( ! isset( self::$instance ) ) {
137
			self::$instance = new Jetpack_Search();
138
139
			self::$instance->setup();
140
		}
141
142
		return self::$instance;
143
	}
144
145
	/**
146
	 * Perform various setup tasks for the class.
147
	 *
148
	 * Checks various pre-requisites and adds hooks.
149
	 *
150
	 * @since 5.0.0
151
	 */
152
	public function setup() {
153
		if ( ! Jetpack::is_active() || ! $this->is_search_supported() ) {
154
			return;
155
		}
156
157
		$this->jetpack_blog_id = Jetpack::get_option( 'id' );
158
159
		if ( ! $this->jetpack_blog_id ) {
160
			return;
161
		}
162
163
		require_once( dirname( __FILE__ ) . '/class.jetpack-search-helpers.php' );
164
		require_once( dirname( __FILE__ ) . '/class.jetpack-search-template-tags.php' );
165
		require_once( JETPACK__PLUGIN_DIR . 'modules/widgets/search.php' );
166
167
		$this->init_hooks();
168
	}
169
170
	/**
171
	 * Setup the various hooks needed for the plugin to take over search duties.
172
	 *
173
	 * @since 5.0.0
174
	 */
175
	public function init_hooks() {
176
		if ( ! is_admin() ) {
177
			add_filter( 'posts_pre_query', array( $this, 'filter__posts_pre_query' ), 10, 2 );
178
179
			add_filter( 'jetpack_search_es_wp_query_args', array( $this, 'filter__add_date_filter_to_query' ), 10, 2 );
180
181
			add_action( 'did_jetpack_search_query', array( $this, 'store_last_query_info' ) );
182
			add_action( 'failed_jetpack_search_query', array( $this, 'store_query_failure' ) );
183
184
			add_action( 'init', array( $this, 'set_filters_from_widgets' ) );
185
186
			add_action( 'pre_get_posts', array( $this, 'maybe_add_post_type_as_var' ) );
187
		} else {
188
			add_action( 'update_option', array( $this, 'track_widget_updates' ), 10, 3 );
189
		}
190
191
		add_action( 'jetpack_deactivate_module_search', array( $this, 'move_search_widgets_to_inactive' ) );
192
	}
193
194
	/**
195
	 * Is search supported on the current plan
196
	 *
197
	 * @since 6.0
198
	 */
199
	public function is_search_supported() {
200
		return Jetpack::active_plan_supports( 'search' );
201
	}
202
203
	/**
204
	 * Does this site have a VIP index
205
	 *
206
	 * @since 6.0
207
	 */
208
	public function has_vip_index() {
209
		return defined( 'JETPACK_SEARCH_VIP_INDEX' ) && JETPACK_SEARCH_VIP_INDEX;
210
	}
211
212
	/**
213
	 * When an Elasticsearch query fails, this stores it and enqueues some debug information in the footer.
214
	 *
215
	 * @since 5.6.0
216
	 *
217
	 * @param array $meta Information about the failure.
218
	 */
219
	public function store_query_failure( $meta ) {
220
		$this->last_query_failure_info = $meta;
221
		add_action( 'wp_footer', array( $this, 'print_query_failure' ) );
222
	}
223
224
	/**
225
	 * Outputs information about the last Elasticsearch failure.
226
	 *
227
	 * @since 5.6.0
228
	 */
229
	public function print_query_failure() {
230
		if ( $this->last_query_failure_info ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->last_query_failure_info of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
231
			printf(
232
				'<!-- Jetpack Search failed with code %s: %s - %s -->',
233
				esc_html( $this->last_query_failure_info['response_code'] ),
234
				esc_html( $this->last_query_failure_info['json']['error'] ),
235
				esc_html( $this->last_query_failure_info['json']['message'] )
236
			);
237
		}
238
	}
239
240
	/**
241
	 * Stores information about the last Elasticsearch query and enqueues some debug information in the footer.
242
	 *
243
	 * @since 5.6.0
244
	 *
245
	 * @param array $meta Information about the query.
246
	 */
247
	public function store_last_query_info( $meta ) {
248
		$this->last_query_info = $meta;
249
		add_action( 'wp_footer', array( $this, 'print_query_success' ) );
250
	}
251
252
	/**
253
	 * Outputs information about the last Elasticsearch search.
254
	 *
255
	 * @since 5.6.0
256
	 */
257
	public function print_query_success() {
258
		if ( $this->last_query_info ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->last_query_info of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
259
			printf(
260
				'<!-- Jetpack Search took %s ms, ES time %s ms -->',
261
				intval( $this->last_query_info['elapsed_time'] ),
262
				esc_html( $this->last_query_info['es_time'] )
263
			);
264
		}
265
	}
266
267
	/**
268
	 * Returns the last query information, or false if no information was stored.
269
	 *
270
	 * @since 5.8.0
271
	 *
272
	 * @return bool|array
273
	 */
274
	public function get_last_query_info() {
275
		return empty( $this->last_query_info ) ? false : $this->last_query_info;
276
	}
277
278
	/**
279
	 * Returns the last query failure information, or false if no failure information was stored.
280
	 *
281
	 * @since 5.8.0
282
	 *
283
	 * @return bool|array
284
	 */
285
	public function get_last_query_failure_info() {
286
		return empty( $this->last_query_failure_info ) ? false : $this->last_query_failure_info;
287
	}
288
289
	/**
290
	 * Wraps a WordPress filter called "jetpack_search_disable_widget_filters" that allows
291
	 * developers to disable filters supplied by the search widget. Useful if filters are
292
	 * being defined at the code level.
293
	 *
294
	 * @since      5.7.0
295
	 * @deprecated 5.8.0 Use Jetpack_Search_Helpers::are_filters_by_widget_disabled() directly.
296
	 *
297
	 * @return bool
298
	 */
299
	public function are_filters_by_widget_disabled() {
300
		return Jetpack_Search_Helpers::are_filters_by_widget_disabled();
301
	}
302
303
	/**
304
	 * Retrieves a list of known Jetpack search filters widget IDs, gets the filters for each widget,
305
	 * and applies those filters to this Jetpack_Search object.
306
	 *
307
	 * @since 5.7.0
308
	 */
309
	public function set_filters_from_widgets() {
310
		if ( Jetpack_Search_Helpers::are_filters_by_widget_disabled() ) {
311
			return;
312
		}
313
314
		$filters = Jetpack_Search_Helpers::get_filters_from_widgets();
315
316
		if ( ! empty( $filters ) ) {
317
			$this->set_filters( $filters );
318
		}
319
	}
320
321
	/**
322
	 * Restricts search results to certain post types via a GET argument.
323
	 *
324
	 * @since 5.8.0
325
	 *
326
	 * @param WP_Query $query A WP_Query instance.
327
	 */
328
	public function maybe_add_post_type_as_var( WP_Query $query ) {
329
		if ( $query->is_main_query() && $query->is_search && ! empty( $_GET['post_type'] ) ) {
330
			$post_types = ( is_string( $_GET['post_type'] ) && false !== strpos( $_GET['post_type'], ',' ) )
331
				? $post_type = explode( ',', $_GET['post_type'] )
332
				: (array) $_GET['post_type'];
333
			$post_types = array_map( 'sanitize_key', $post_types );
334
			$query->set( 'post_type', $post_types );
335
		}
336
	}
337
338
	/*
339
	 * Run a search on the WordPress.com public API.
340
	 *
341
	 * @since 5.0.0
342
	 *
343
	 * @param array $es_args Args conforming to the WP.com /sites/<blog_id>/search endpoint.
344
	 *
345
	 * @return object|WP_Error The response from the public API, or a WP_Error.
346
	 */
347
	public function search( array $es_args ) {
348
		$endpoint    = sprintf( '/sites/%s/search', $this->jetpack_blog_id );
349
		$service_url = 'https://public-api.wordpress.com/rest/v1' . $endpoint;
350
351
		$do_authenticated_request = false;
352
353
		if ( class_exists( 'Jetpack_Client' ) &&
354
			isset( $es_args['authenticated_request'] ) &&
355
			true === $es_args['authenticated_request'] ) {
356
			$do_authenticated_request = true;
357
		}
358
359
		unset( $es_args['authenticated_request'] );
360
361
		$request_args = array(
362
			'headers' => array(
363
				'Content-Type' => 'application/json',
364
			),
365
			'timeout'    => 10,
366
			'user-agent' => 'jetpack_search',
367
		);
368
369
		$request_body = wp_json_encode( $es_args );
370
371
		$start_time = microtime( true );
372
373
		if ( $do_authenticated_request ) {
374
			$request_args['method'] = 'POST';
375
376
			$request = Jetpack_Client::wpcom_json_api_request_as_blog( $endpoint, Jetpack_Client::WPCOM_JSON_API_VERSION, $request_args, $request_body );
377
		} else {
378
			$request_args = array_merge( $request_args, array(
379
				'body' => $request_body,
380
			) );
381
382
			$request = wp_remote_post( $service_url, $request_args );
383
		}
384
385
		$end_time = microtime( true );
386
387
		if ( is_wp_error( $request ) ) {
388
			return $request;
389
		}
390
391
		$response_code = wp_remote_retrieve_response_code( $request );
392
393
		$response = json_decode( wp_remote_retrieve_body( $request ), true );
394
395
		$took = is_array( $response ) && ! empty( $response['took'] )
396
			? $response['took']
397
			: null;
398
399
		$query = array(
400
			'args'          => $es_args,
401
			'response'      => $response,
402
			'response_code' => $response_code,
403
			'elapsed_time'  => ( $end_time - $start_time ) * 1000, // Convert from float seconds to ms.
404
			'es_time'       => $took,
405
			'url'           => $service_url,
406
		);
407
408
		/**
409
		 * Fires after a search request has been performed.
410
		 *
411
		 * Includes the following info in the $query parameter:
412
		 *
413
		 * array args Array of Elasticsearch arguments for the search
414
		 * array response Raw API response, JSON decoded
415
		 * int response_code HTTP response code of the request
416
		 * float elapsed_time Roundtrip time of the search request, in milliseconds
417
		 * float es_time Amount of time Elasticsearch spent running the request, in milliseconds
418
		 * string url API url that was queried
419
		 *
420
		 * @module search
421
		 *
422
		 * @since  5.0.0
423
		 * @since  5.8.0 This action now fires on all queries instead of just successful queries.
424
		 *
425
		 * @param array $query Array of information about the query performed
426
		 */
427
		do_action( 'did_jetpack_search_query', $query );
428
429
		if ( ! $response_code || $response_code < 200 || $response_code >= 300 ) {
430
			/**
431
			 * Fires after a search query request has failed
432
			 *
433
			 * @module search
434
			 *
435
			 * @since  5.6.0
436
			 *
437
			 * @param array Array containing the response code and response from the failed search query
438
			 */
439
			do_action( 'failed_jetpack_search_query', array(
440
				'response_code' => $response_code,
441
				'json'          => $response,
442
			) );
443
444
			return new WP_Error( 'invalid_search_api_response', 'Invalid response from API - ' . $response_code );
445
		}
446
447
		return $response;
448
	}
449
450
	/**
451
	 * Bypass the normal Search query and offload it to Jetpack servers.
452
	 *
453
	 * This is the main hook of the plugin and is responsible for returning the posts that match the search query.
454
	 *
455
	 * @since 5.0.0
456
	 *
457
	 * @param array    $posts Current array of posts (still pre-query).
458
	 * @param WP_Query $query The WP_Query being filtered.
459
	 *
460
	 * @return array Array of matching posts.
461
	 */
462
	public function filter__posts_pre_query( $posts, $query ) {
463
		/**
464
		 * Determine whether a given WP_Query should be handled by ElasticSearch.
465
		 *
466
		 * @module search
467
		 *
468
		 * @since  5.6.0
469
		 *
470
		 * @param bool     $should_handle Should be handled by Jetpack Search.
471
		 * @param WP_Query $query         The WP_Query object.
472
		 */
473
		if ( ! apply_filters( 'jetpack_search_should_handle_query', ( $query->is_main_query() && $query->is_search() ), $query ) ) {
474
			return $posts;
475
		}
476
477
		$this->do_search( $query );
478
479
		if ( ! is_array( $this->search_result ) ) {
480
			return $posts;
481
		}
482
483
		// If no results, nothing to do
484
		if ( ! count( $this->search_result['results']['hits'] ) ) {
485
			return array();
486
		}
487
488
		$post_ids = array();
489
490
		foreach ( $this->search_result['results']['hits'] as $result ) {
491
			$post_ids[] = (int) $result['fields']['post_id'];
492
		}
493
494
		// Query all posts now
495
		$args = array(
496
			'post__in'            => $post_ids,
497
			'orderby'             => 'post__in',
498
			'perm'                => 'readable',
499
			'post_type'           => 'any',
500
			'ignore_sticky_posts' => true,
501
			'suppress_filters'    => true,
502
		);
503
504
		$posts_query = new WP_Query( $args );
505
506
		// WP Core doesn't call the set_found_posts and its filters when filtering posts_pre_query like we do, so need to do these manually.
507
		$query->found_posts   = $this->found_posts;
508
		$query->max_num_pages = ceil( $this->found_posts / $query->get( 'posts_per_page' ) );
509
510
		return $posts_query->posts;
511
	}
512
513
	/**
514
	 * Build up the search, then run it against the Jetpack servers.
515
	 *
516
	 * @since 5.0.0
517
	 *
518
	 * @param WP_Query $query The original WP_Query to use for the parameters of our search.
519
	 */
520
	public function do_search( WP_Query $query ) {
521
		if ( ! $query->is_main_query() || ! $query->is_search() ) {
522
			return;
523
		}
524
525
		$page = ( $query->get( 'paged' ) ) ? absint( $query->get( 'paged' ) ) : 1;
526
527
		// Get maximum allowed offset and posts per page values for the API.
528
		$max_offset         = Jetpack_Search_Helpers::get_max_offset();
529
		$max_posts_per_page = Jetpack_Search_Helpers::get_max_posts_per_page();
530
531
		$posts_per_page = $query->get( 'posts_per_page' );
532
		if ( $posts_per_page > $max_posts_per_page ) {
533
			$posts_per_page = $max_posts_per_page;
534
		}
535
536
		// Start building the WP-style search query args.
537
		// They'll be translated to ES format args later.
538
		$es_wp_query_args = array(
539
			'query'          => $query->get( 's' ),
540
			'posts_per_page' => $posts_per_page,
541
			'paged'          => $page,
542
			'orderby'        => $query->get( 'orderby' ),
543
			'order'          => $query->get( 'order' ),
544
		);
545
546
		if ( ! empty( $this->aggregations ) ) {
547
			$es_wp_query_args['aggregations'] = $this->aggregations;
548
		}
549
550
		// Did we query for authors?
551
		if ( $query->get( 'author_name' ) ) {
552
			$es_wp_query_args['author_name'] = $query->get( 'author_name' );
553
		}
554
555
		$es_wp_query_args['post_type'] = $this->get_es_wp_query_post_type_for_query( $query );
556
		$es_wp_query_args['terms']     = $this->get_es_wp_query_terms_for_query( $query );
557
558
		/**
559
		 * Modify the search query parameters, such as controlling the post_type.
560
		 *
561
		 * These arguments are in the format of WP_Query arguments
562
		 *
563
		 * @module search
564
		 *
565
		 * @since  5.0.0
566
		 *
567
		 * @param array    $es_wp_query_args The current query args, in WP_Query format.
568
		 * @param WP_Query $query            The original WP_Query object.
569
		 */
570
		$es_wp_query_args = apply_filters( 'jetpack_search_es_wp_query_args', $es_wp_query_args, $query );
571
572
		// If page * posts_per_page is greater than our max offset, send a 404. This is necessary because the offset is
573
		// capped at Jetpack_Search_Helpers::get_max_offset(), so a high page would always return the last page of results otherwise.
574
		if ( ( $es_wp_query_args['paged'] * $es_wp_query_args['posts_per_page'] ) > $max_offset ) {
575
			$query->set_404();
576
577
			return;
578
		}
579
580
		// If there were no post types returned, then 404 to avoid querying against non-public post types, which could
581
		// happen if we don't add the post type restriction to the ES query.
582
		if ( empty( $es_wp_query_args['post_type'] ) ) {
583
			$query->set_404();
584
585
			return;
586
		}
587
588
		// Convert the WP-style args into ES args.
589
		$es_query_args = $this->convert_wp_es_to_es_args( $es_wp_query_args );
590
591
		//Only trust ES to give us IDs, not the content since it is a mirror
592
		$es_query_args['fields'] = array(
593
			'post_id',
594
		);
595
596
		/**
597
		 * Modify the underlying ES query that is passed to the search endpoint. The returned args must represent a valid ES query
598
		 *
599
		 * This filter is harder to use if you're unfamiliar with ES, but allows complete control over the query
600
		 *
601
		 * @module search
602
		 *
603
		 * @since  5.0.0
604
		 *
605
		 * @param array    $es_query_args The raw Elasticsearch query args.
606
		 * @param WP_Query $query         The original WP_Query object.
607
		 */
608
		$es_query_args = apply_filters( 'jetpack_search_es_query_args', $es_query_args, $query );
609
610
		// Do the actual search query!
611
		$this->search_result = $this->search( $es_query_args );
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->search($es_query_args) of type object is incompatible with the declared type array of property $search_result.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
612
613
		if ( is_wp_error( $this->search_result ) || ! is_array( $this->search_result ) || empty( $this->search_result['results'] ) || empty( $this->search_result['results']['hits'] ) ) {
614
			$this->found_posts = 0;
615
616
			return;
617
		}
618
619
		// If we have aggregations, fix the ordering to match the input order (ES doesn't guarantee the return order).
620
		if ( isset( $this->search_result['results']['aggregations'] ) && ! empty( $this->search_result['results']['aggregations'] ) ) {
621
			$this->search_result['results']['aggregations'] = $this->fix_aggregation_ordering( $this->search_result['results']['aggregations'], $this->aggregations );
622
		}
623
624
		// Total number of results for paging purposes. Capped at $max_offset + $posts_per_page, as deep paging gets quite expensive.
625
		$this->found_posts = min( $this->search_result['results']['total'], $max_offset + $posts_per_page );
626
	}
627
628
	/**
629
	 * If the query has already been run before filters have been updated, then we need to re-run the query
630
	 * to get the latest aggregations.
631
	 *
632
	 * This is especially useful for supporting widget management in the customizer.
633
	 *
634
	 * @since 5.8.0
635
	 *
636
	 * @return bool Whether the query was successful or not.
637
	 */
638
	public function update_search_results_aggregations() {
639
		if ( empty( $this->last_query_info ) || empty( $this->last_query_info['args'] ) ) {
640
			return false;
641
		}
642
643
		$es_args = $this->last_query_info['args'];
644
		$builder = new Jetpack_WPES_Query_Builder();
645
		$this->add_aggregations_to_es_query_builder( $this->aggregations, $builder );
646
		$es_args['aggregations'] = $builder->build_aggregation();
647
648
		$this->search_result = $this->search( $es_args );
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->search($es_args) of type object is incompatible with the declared type array of property $search_result.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
649
650
		return ! is_wp_error( $this->search_result );
651
	}
652
653
	/**
654
	 * Given a WP_Query, convert its WP_Tax_Query (if present) into the WP-style Elasticsearch term arguments for the search.
655
	 *
656
	 * @since 5.0.0
657
	 *
658
	 * @param WP_Query $query The original WP_Query object for which to parse the taxonomy query.
659
	 *
660
	 * @return array The new WP-style Elasticsearch arguments (that will be converted into 'real' Elasticsearch arguments).
661
	 */
662
	public function get_es_wp_query_terms_for_query( WP_Query $query ) {
663
		$args = array();
664
665
		$the_tax_query = $query->tax_query;
666
667
		if ( ! $the_tax_query ) {
668
			return $args;
669
		}
670
671
672
		if ( ! $the_tax_query instanceof WP_Tax_Query || empty( $the_tax_query->queried_terms ) || ! is_array( $the_tax_query->queried_terms ) ) {
0 ignored issues
show
Bug introduced by
The class WP_Tax_Query does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
673
			return $args;
674
		}
675
676
		$args = array();
677
678
		foreach ( $the_tax_query->queries as $tax_query ) {
679
			// Right now we only support slugs...see note above
680
			if ( ! is_array( $tax_query ) || 'slug' !== $tax_query['field'] ) {
681
				continue;
682
			}
683
684
			$taxonomy = $tax_query['taxonomy'];
685
686 View Code Duplication
			if ( ! isset( $args[ $taxonomy ] ) || ! is_array( $args[ $taxonomy ] ) ) {
687
				$args[ $taxonomy ] = array();
688
			}
689
690
			$args[ $taxonomy ] = array_merge( $args[ $taxonomy ], $tax_query['terms'] );
691
		}
692
693
		return $args;
694
	}
695
696
	/**
697
	 * Parse out the post type from a WP_Query.
698
	 *
699
	 * Only allows post types that are not marked as 'exclude_from_search'.
700
	 *
701
	 * @since 5.0.0
702
	 *
703
	 * @param WP_Query $query Original WP_Query object.
704
	 *
705
	 * @return array Array of searchable post types corresponding to the original query.
706
	 */
707
	public function get_es_wp_query_post_type_for_query( WP_Query $query ) {
708
		$post_types = $query->get( 'post_type' );
709
710
		// If we're searching 'any', we want to only pass searchable post types to Elasticsearch.
711
		if ( 'any' === $post_types ) {
712
			$post_types = array_values( get_post_types( array(
713
				'exclude_from_search' => false,
714
			) ) );
715
		}
716
717
		if ( ! is_array( $post_types ) ) {
718
			$post_types = array( $post_types );
719
		}
720
721
		$post_types = array_unique( $post_types );
722
723
		$sanitized_post_types = array();
724
725
		// Make sure the post types are queryable.
726
		foreach ( $post_types as $post_type ) {
727
			if ( ! $post_type ) {
728
				continue;
729
			}
730
731
			$post_type_object = get_post_type_object( $post_type );
732
			if ( ! $post_type_object || $post_type_object->exclude_from_search ) {
733
				continue;
734
			}
735
736
			$sanitized_post_types[] = $post_type;
737
		}
738
739
		return $sanitized_post_types;
740
	}
741
742
	/**
743
	 * Get the Elasticsearch result.
744
	 *
745
	 * @since 5.0.0
746
	 *
747
	 * @param bool $raw If true, does not check for WP_Error or return the 'results' array - the JSON decoded HTTP response.
748
	 *
749
	 * @return array|bool The search results, or false if there was a failure.
750
	 */
751
	public function get_search_result( $raw = false ) {
752
		if ( $raw ) {
753
			return $this->search_result;
754
		}
755
756
		return ( ! empty( $this->search_result ) && ! is_wp_error( $this->search_result ) && is_array( $this->search_result ) && ! empty( $this->search_result['results'] ) ) ? $this->search_result['results'] : false;
757
	}
758
759
	/**
760
	 * Add the date portion of a WP_Query onto the query args.
761
	 *
762
	 * @since 5.0.0
763
	 *
764
	 * @param array    $es_wp_query_args The Elasticsearch query arguments in WordPress form.
765
	 * @param WP_Query $query            The original WP_Query.
766
	 *
767
	 * @return array The es wp query args, with date filters added (as needed).
768
	 */
769
	public function filter__add_date_filter_to_query( array $es_wp_query_args, WP_Query $query ) {
770
		if ( $query->get( 'year' ) ) {
771
			if ( $query->get( 'monthnum' ) ) {
772
				// Padding
773
				$date_monthnum = sprintf( '%02d', $query->get( 'monthnum' ) );
774
775
				if ( $query->get( 'day' ) ) {
776
					// Padding
777
					$date_day = sprintf( '%02d', $query->get( 'day' ) );
778
779
					$date_start = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $date_day . ' 00:00:00';
780
					$date_end   = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $date_day . ' 23:59:59';
781
				} else {
782
					$days_in_month = date( 't', mktime( 0, 0, 0, $query->get( 'monthnum' ), 14, $query->get( 'year' ) ) ); // 14 = middle of the month so no chance of DST issues
783
784
					$date_start = $query->get( 'year' ) . '-' . $date_monthnum . '-01 00:00:00';
785
					$date_end   = $query->get( 'year' ) . '-' . $date_monthnum . '-' . $days_in_month . ' 23:59:59';
786
				}
787
			} else {
788
				$date_start = $query->get( 'year' ) . '-01-01 00:00:00';
789
				$date_end   = $query->get( 'year' ) . '-12-31 23:59:59';
790
			}
791
792
			$es_wp_query_args['date_range'] = array(
793
				'field' => 'date',
794
				'gte'   => $date_start,
795
				'lte'   => $date_end,
796
			);
797
		}
798
799
		return $es_wp_query_args;
800
	}
801
802
	/**
803
	 * Converts WP_Query style args to Elasticsearch args.
804
	 *
805
	 * @since 5.0.0
806
	 *
807
	 * @param array $args Array of WP_Query style arguments.
808
	 *
809
	 * @return array Array of ES style query arguments.
810
	 */
811
	public function convert_wp_es_to_es_args( array $args ) {
812
		jetpack_require_lib( 'jetpack-wpes-query-builder/jetpack-wpes-query-parser' );
813
814
		$defaults = array(
815
			'blog_id'        => get_current_blog_id(),
816
			'query'          => null,    // Search phrase
817
			'query_fields'   => array(), // list of fields to search
818
			'excess_boost'   => array(), // map of field to excess boost values (multiply)
819
			'post_type'      => null,    // string or an array
820
			'terms'          => array(), // ex: array( 'taxonomy-1' => array( 'slug' ), 'taxonomy-2' => array( 'slug-a', 'slug-b' ) )
821
			'author'         => null,    // id or an array of ids
822
			'author_name'    => array(), // string or an array
823
			'date_range'     => null,    // array( 'field' => 'date', 'gt' => 'YYYY-MM-dd', 'lte' => 'YYYY-MM-dd' ); date formats: 'YYYY-MM-dd' or 'YYYY-MM-dd HH:MM:SS'
824
			'orderby'        => null,    // Defaults to 'relevance' if query is set, otherwise 'date'. Pass an array for multiple orders.
825
			'order'          => 'DESC',
826
			'posts_per_page' => 10,
827
			'offset'         => null,
828
			'paged'          => null,
829
			/**
830
			 * Aggregations. Examples:
831
			 * array(
832
			 *     'Tag'       => array( 'type' => 'taxonomy', 'taxonomy' => 'post_tag', 'count' => 10 ) ),
833
			 *     'Post Type' => array( 'type' => 'post_type', 'count' => 10 ) ),
834
			 * );
835
			 */
836
			'aggregations'   => null,
837
		);
838
839
		$args = wp_parse_args( $args, $defaults );
840
841
		$parser = new Jetpack_WPES_Search_Query_Parser( $args['query'], array( get_locale() ) );
842
843
		if ( empty( $args['query_fields'] ) ) {
844
			if ( $this->has_vip_index() ) {
845
				// VIP indices do not have per language fields
846
				$match_fields = $this->_get_caret_boosted_fields(
847
					array(
848
						'title'         => 0.1,
849
						'content'       => 0.1,
850
						'excerpt'       => 0.1,
851
						'tag.name'      => 0.1,
852
						'category.name' => 0.1,
853
						'author_login'  => 0.1,
854
						'author'        => 0.1,
855
					)
856
				);
857
858
				$boost_fields = $this->_get_caret_boosted_fields(
859
					$this->_apply_boosts_multiplier( array(
860
						'title'         => 2,
861
						'tag.name'      => 1,
862
						'category.name' => 1,
863
						'author_login'  => 1,
864
						'author'        => 1,
865
					), $args['excess_boost'] )
866
				);
867
868
				$boost_phrase_fields = $this->_get_caret_boosted_fields(
869
					array(
870
						'title'         => 1,
871
						'content'       => 1,
872
						'excerpt'       => 1,
873
						'tag.name'      => 1,
874
						'category.name' => 1,
875
						'author'        => 1,
876
					)
877
				);
878
			} else {
879
				$match_fields = $parser->merge_ml_fields(
880
					array(
881
						'title'         => 0.1,
882
						'content'       => 0.1,
883
						'excerpt'       => 0.1,
884
						'tag.name'      => 0.1,
885
						'category.name' => 0.1,
886
					),
887
					$this->_get_caret_boosted_fields( array(
888
						'author_login'  => 0.1,
889
						'author'        => 0.1,
890
					) )
891
				);
892
893
				$boost_fields = $parser->merge_ml_fields(
894
					$this->_apply_boosts_multiplier( array(
895
						'title'         => 2,
896
						'tag.name'      => 1,
897
						'category.name' => 1,
898
					), $args['excess_boost'] ),
899
					$this->_get_caret_boosted_fields( $this->_apply_boosts_multiplier( array(
900
						'author_login'  => 1,
901
						'author'        => 1,
902
					), $args['excess_boost'] ) )
903
				);
904
905
				$boost_phrase_fields = $parser->merge_ml_fields(
906
					array(
907
						'title'         => 1,
908
						'content'       => 1,
909
						'excerpt'       => 1,
910
						'tag.name'      => 1,
911
						'category.name' => 1,
912
					),
913
					$this->_get_caret_boosted_fields( array(
914
						'author'        => 1,
915
					) )
916
				);
917
			}
918
		} else {
919
			// If code is overriding the fields, then use that. Important for backwards compatibility.
920
			$match_fields        = $args['query_fields'];
921
			$boost_phrase_fields = $match_fields;
922
			$boost_fields        = null;
923
		}
924
925
		$parser->phrase_filter( array(
926
			'must_query_fields'  => $match_fields,
927
			'boost_query_fields' => null,
928
		) );
929
		$parser->remaining_query( array(
930
			'must_query_fields'  => $match_fields,
931
			'boost_query_fields' => $boost_fields,
932
		) );
933
934
		// Boost on phrase matches
935
		$parser->remaining_query( array(
936
			'boost_query_fields' => $boost_phrase_fields,
937
			'boost_query_type'   => 'phrase',
938
		) );
939
940
		/**
941
		 * Modify the recency decay parameters for the search query.
942
		 *
943
		 * The recency decay lowers the search scores based on the age of a post relative to an origin date. Basic adjustments:
944
		 *  - origin: A date. Posts with this date will have the highest score and no decay applied. Default is today.
945
		 *  - offset: Number of days/months/years (eg 30d). All posts within this time range of the origin (before and after) will have no decay applied. Default is no offset.
946
		 *  - scale: The number of days/months/years from the origin+offset at which the decay will equal the decay param. Default 360d
947
		 *  - decay: The amount of decay applied at offset+scale. Default 0.9.
948
		 *
949
		 * The curve applied is a Gaussian. More details available at {@see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-decay}
950
		 *
951
		 * @module search
952
		 *
953
		 * @since  5.8.0
954
		 *
955
		 * @param array $decay_params The decay parameters.
956
		 * @param array $args         The WP query parameters.
957
		 */
958
		$decay_params = apply_filters(
959
			'jetpack_search_recency_score_decay',
960
			array(
961
				'origin' => date( 'Y-m-d' ),
962
				'scale'  => '360d',
963
				'decay'  => 0.9,
964
			),
965
			$args
966
		);
967
968
		if ( ! empty( $decay_params ) ) {
969
			// Newer content gets weighted slightly higher
970
			$parser->add_decay( 'gauss', array(
971
				'date_gmt' => $decay_params
972
			) );
973
		}
974
975
		$es_query_args = array(
976
			'blog_id' => absint( $args['blog_id'] ),
977
			'size'    => absint( $args['posts_per_page'] ),
978
		);
979
980
		// ES "from" arg (offset)
981
		if ( $args['offset'] ) {
982
			$es_query_args['from'] = absint( $args['offset'] );
983
		} elseif ( $args['paged'] ) {
984
			$es_query_args['from'] = max( 0, ( absint( $args['paged'] ) - 1 ) * $es_query_args['size'] );
985
		}
986
987
		$es_query_args['from'] = min( $es_query_args['from'], Jetpack_Search_Helpers::get_max_offset() );
988
989
		if ( ! is_array( $args['author_name'] ) ) {
990
			$args['author_name'] = array( $args['author_name'] );
991
		}
992
993
		// ES stores usernames, not IDs, so transform
994
		if ( ! empty( $args['author'] ) ) {
995
			if ( ! is_array( $args['author'] ) ) {
996
				$args['author'] = array( $args['author'] );
997
			}
998
999
			foreach ( $args['author'] as $author ) {
1000
				$user = get_user_by( 'id', $author );
1001
1002
				if ( $user && ! empty( $user->user_login ) ) {
1003
					$args['author_name'][] = $user->user_login;
1004
				}
1005
			}
1006
		}
1007
1008
		//////////////////////////////////////////////////
1009
		// Build the filters from the query elements.
1010
		// Filters rock because they are cached from one query to the next
1011
		// but they are cached as individual filters, rather than all combined together.
1012
		// May get performance boost by also caching the top level boolean filter too.
1013
1014
		if ( $args['post_type'] ) {
1015
			if ( ! is_array( $args['post_type'] ) ) {
1016
				$args['post_type'] = array( $args['post_type'] );
1017
			}
1018
1019
			$parser->add_filter( array(
1020
				'terms' => array(
1021
					'post_type' => $args['post_type'],
1022
				),
1023
			) );
1024
		}
1025
1026
		if ( $args['author_name'] ) {
1027
			$parser->add_filter( array(
1028
				'terms' => array(
1029
					'author_login' => $args['author_name'],
1030
				),
1031
			) );
1032
		}
1033
1034
		if ( ! empty( $args['date_range'] ) && isset( $args['date_range']['field'] ) ) {
1035
			$field = $args['date_range']['field'];
1036
1037
			unset( $args['date_range']['field'] );
1038
1039
			$parser->add_filter( array(
1040
				'range' => array(
1041
					$field => $args['date_range'],
1042
				),
1043
			) );
1044
		}
1045
1046
		if ( is_array( $args['terms'] ) ) {
1047
			foreach ( $args['terms'] as $tax => $terms ) {
1048
				$terms = (array) $terms;
1049
1050
				if ( count( $terms ) && mb_strlen( $tax ) ) {
1051 View Code Duplication
					switch ( $tax ) {
1052
						case 'post_tag':
1053
							$tax_fld = 'tag.slug';
1054
1055
							break;
1056
1057
						case 'category':
1058
							$tax_fld = 'category.slug';
1059
1060
							break;
1061
1062
						default:
1063
							$tax_fld = 'taxonomy.' . $tax . '.slug';
1064
1065
							break;
1066
					}
1067
1068
					foreach ( $terms as $term ) {
1069
						$parser->add_filter( array(
1070
							'term' => array(
1071
								$tax_fld => $term,
1072
							),
1073
						) );
1074
					}
1075
				}
1076
			}
1077
		}
1078
1079
		if ( ! $args['orderby'] ) {
1080
			if ( $args['query'] ) {
1081
				$args['orderby'] = array( 'relevance' );
1082
			} else {
1083
				$args['orderby'] = array( 'date' );
1084
			}
1085
		}
1086
1087
		// Validate the "order" field
1088
		switch ( strtolower( $args['order'] ) ) {
1089
			case 'asc':
1090
				$args['order'] = 'asc';
1091
				break;
1092
1093
			case 'desc':
1094
			default:
1095
				$args['order'] = 'desc';
1096
				break;
1097
		}
1098
1099
		$es_query_args['sort'] = array();
1100
1101
		foreach ( (array) $args['orderby'] as $orderby ) {
1102
			// Translate orderby from WP field to ES field
1103
			switch ( $orderby ) {
1104
				case 'relevance' :
1105
					//never order by score ascending
1106
					$es_query_args['sort'][] = array(
1107
						'_score' => array(
1108
							'order' => 'desc',
1109
						),
1110
					);
1111
1112
					break;
1113
1114 View Code Duplication
				case 'date' :
1115
					$es_query_args['sort'][] = array(
1116
						'date' => array(
1117
							'order' => $args['order'],
1118
						),
1119
					);
1120
1121
					break;
1122
1123 View Code Duplication
				case 'ID' :
1124
					$es_query_args['sort'][] = array(
1125
						'id' => array(
1126
							'order' => $args['order'],
1127
						),
1128
					);
1129
1130
					break;
1131
1132
				case 'author' :
1133
					$es_query_args['sort'][] = array(
1134
						'author.raw' => array(
1135
							'order' => $args['order'],
1136
						),
1137
					);
1138
1139
					break;
1140
			} // End switch().
1141
		} // End foreach().
1142
1143
		if ( empty( $es_query_args['sort'] ) ) {
1144
			unset( $es_query_args['sort'] );
1145
		}
1146
1147
		// Aggregations
1148
		if ( ! empty( $args['aggregations'] ) ) {
1149
			$this->add_aggregations_to_es_query_builder( $args['aggregations'], $parser );
0 ignored issues
show
Documentation introduced by
$args['aggregations'] is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
1150
		}
1151
1152
		$es_query_args['filter']       = $parser->build_filter();
1153
		$es_query_args['query']        = $parser->build_query();
1154
		$es_query_args['aggregations'] = $parser->build_aggregation();
1155
1156
		return $es_query_args;
1157
	}
1158
1159
	/**
1160
	 * Given an array of aggregations, parse and add them onto the Jetpack_WPES_Query_Builder object for use in Elasticsearch.
1161
	 *
1162
	 * @since 5.0.0
1163
	 *
1164
	 * @param array                      $aggregations Array of aggregations (filters) to add to the Jetpack_WPES_Query_Builder.
1165
	 * @param Jetpack_WPES_Query_Builder $builder      The builder instance that is creating the Elasticsearch query.
1166
	 */
1167
	public function add_aggregations_to_es_query_builder( array $aggregations, Jetpack_WPES_Query_Builder $builder ) {
1168
		foreach ( $aggregations as $label => $aggregation ) {
1169
			switch ( $aggregation['type'] ) {
1170
				case 'taxonomy':
1171
					$this->add_taxonomy_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1172
1173
					break;
1174
1175
				case 'post_type':
1176
					$this->add_post_type_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1177
1178
					break;
1179
1180
				case 'date_histogram':
1181
					$this->add_date_histogram_aggregation_to_es_query_builder( $aggregation, $label, $builder );
1182
1183
					break;
1184
			}
1185
		}
1186
	}
1187
1188
	/**
1189
	 * Given an individual taxonomy aggregation, add it to the Jetpack_WPES_Query_Builder object for use in Elasticsearch.
1190
	 *
1191
	 * @since 5.0.0
1192
	 *
1193
	 * @param array                      $aggregation The aggregation to add to the query builder.
1194
	 * @param string                     $label       The 'label' (unique id) for this aggregation.
1195
	 * @param Jetpack_WPES_Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1196
	 */
1197
	public function add_taxonomy_aggregation_to_es_query_builder( array $aggregation, $label, Jetpack_WPES_Query_Builder $builder ) {
1198
		$field = null;
0 ignored issues
show
Unused Code introduced by
$field is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1199
1200
		switch ( $aggregation['taxonomy'] ) {
1201
			case 'post_tag':
1202
				$field = 'tag';
1203
				break;
1204
1205
			case 'category':
1206
				$field = 'category';
1207
				break;
1208
1209
			default:
1210
				$field = 'taxonomy.' . $aggregation['taxonomy'];
1211
				break;
1212
		}
1213
1214
		$builder->add_aggs( $label, array(
1215
			'terms' => array(
1216
				'field' => $field . '.slug',
1217
				'size'  => min( (int) $aggregation['count'], $this->max_aggregations_count ),
1218
			),
1219
		) );
1220
	}
1221
1222
	/**
1223
	 * Given an individual post_type aggregation, add it to the Jetpack_WPES_Query_Builder object for use in Elasticsearch.
1224
	 *
1225
	 * @since 5.0.0
1226
	 *
1227
	 * @param array                      $aggregation The aggregation to add to the query builder.
1228
	 * @param string                     $label       The 'label' (unique id) for this aggregation.
1229
	 * @param Jetpack_WPES_Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1230
	 */
1231
	public function add_post_type_aggregation_to_es_query_builder( array $aggregation, $label, Jetpack_WPES_Query_Builder $builder ) {
1232
		$builder->add_aggs( $label, array(
1233
			'terms' => array(
1234
				'field' => 'post_type',
1235
				'size'  => min( (int) $aggregation['count'], $this->max_aggregations_count ),
1236
			),
1237
		) );
1238
	}
1239
1240
	/**
1241
	 * Given an individual date_histogram aggregation, add it to the Jetpack_WPES_Query_Builder object for use in Elasticsearch.
1242
	 *
1243
	 * @since 5.0.0
1244
	 *
1245
	 * @param array                      $aggregation The aggregation to add to the query builder.
1246
	 * @param string                     $label       The 'label' (unique id) for this aggregation.
1247
	 * @param Jetpack_WPES_Query_Builder $builder     The builder instance that is creating the Elasticsearch query.
1248
	 */
1249
	public function add_date_histogram_aggregation_to_es_query_builder( array $aggregation, $label, Jetpack_WPES_Query_Builder $builder ) {
1250
		$args = array(
1251
			'interval' => $aggregation['interval'],
1252
			'field'    => ( ! empty( $aggregation['field'] ) && 'post_date_gmt' == $aggregation['field'] ) ? 'date_gmt' : 'date',
1253
		);
1254
1255
		if ( isset( $aggregation['min_doc_count'] ) ) {
1256
			$args['min_doc_count'] = intval( $aggregation['min_doc_count'] );
1257
		} else {
1258
			$args['min_doc_count'] = 1;
1259
		}
1260
1261
		$builder->add_aggs( $label, array(
1262
			'date_histogram' => $args,
1263
		) );
1264
	}
1265
1266
	/**
1267
	 * And an existing filter object with a list of additional filters.
1268
	 *
1269
	 * Attempts to optimize the filters somewhat.
1270
	 *
1271
	 * @since 5.0.0
1272
	 *
1273
	 * @param array $curr_filter The existing filters to build upon.
1274
	 * @param array $filters     The new filters to add.
1275
	 *
1276
	 * @return array The resulting merged filters.
1277
	 */
1278
	public static function and_es_filters( array $curr_filter, array $filters ) {
1279
		if ( ! is_array( $curr_filter ) || isset( $curr_filter['match_all'] ) ) {
1280
			if ( 1 === count( $filters ) ) {
1281
				return $filters[0];
1282
			}
1283
1284
			return array(
1285
				'and' => $filters,
1286
			);
1287
		}
1288
1289
		return array(
1290
			'and' => array_merge( array( $curr_filter ), $filters ),
1291
		);
1292
	}
1293
1294
	/**
1295
	 * Set the available filters for the search.
1296
	 *
1297
	 * These get rendered via the Jetpack_Search_Widget() widget.
1298
	 *
1299
	 * Behind the scenes, these are implemented using Elasticsearch Aggregations.
1300
	 *
1301
	 * If you do not require counts of how many documents match each filter, please consider using regular WP Query
1302
	 * arguments instead, such as via the jetpack_search_es_wp_query_args filter
1303
	 *
1304
	 * @see    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
1305
	 *
1306
	 * @since  5.0.0
1307
	 *
1308
	 * @param array $aggregations Array of filters (aggregations) to apply to the search
1309
	 */
1310
	public function set_filters( array $aggregations ) {
1311
		foreach ( (array) $aggregations as $key => $agg ) {
1312
			if ( empty( $agg['name'] ) ) {
1313
				$aggregations[ $key ]['name'] = $key;
1314
			}
1315
		}
1316
		$this->aggregations = $aggregations;
1317
	}
1318
1319
	/**
1320
	 * Set the search's facets (deprecated).
1321
	 *
1322
	 * @deprecated 5.0 Please use Jetpack_Search::set_filters() instead.
1323
	 *
1324
	 * @see        Jetpack_Search::set_filters()
1325
	 *
1326
	 * @param array $facets Array of facets to apply to the search.
1327
	 */
1328
	public function set_facets( array $facets ) {
1329
		_deprecated_function( __METHOD__, 'jetpack-5.0', 'Jetpack_Search::set_filters()' );
1330
1331
		$this->set_filters( $facets );
1332
	}
1333
1334
	/**
1335
	 * Get the raw Aggregation results from the Elasticsearch response.
1336
	 *
1337
	 * @since  5.0.0
1338
	 *
1339
	 * @see    https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html
1340
	 *
1341
	 * @return array Array of Aggregations performed on the search.
1342
	 */
1343
	public function get_search_aggregations_results() {
1344
		$aggregations = array();
1345
1346
		$search_result = $this->get_search_result();
1347
1348
		if ( ! empty( $search_result ) && ! empty( $search_result['aggregations'] ) ) {
1349
			$aggregations = $search_result['aggregations'];
1350
		}
1351
1352
		return $aggregations;
1353
	}
1354
1355
	/**
1356
	 * Get the raw Facet results from the Elasticsearch response.
1357
	 *
1358
	 * @deprecated 5.0 Please use Jetpack_Search::get_search_aggregations_results() instead.
1359
	 *
1360
	 * @see        Jetpack_Search::get_search_aggregations_results()
1361
	 *
1362
	 * @return array Array of Facets performed on the search.
1363
	 */
1364
	public function get_search_facets() {
1365
		_deprecated_function( __METHOD__, 'jetpack-5.0', 'Jetpack_Search::get_search_aggregations_results()' );
1366
1367
		return $this->get_search_aggregations_results();
1368
	}
1369
1370
	/**
1371
	 * Get the results of the Filters performed, including the number of matching documents.
1372
	 *
1373
	 * Returns an array of Filters (keyed by $label, as passed to Jetpack_Search::set_filters()), containing the Filter and all resulting
1374
	 * matching buckets, the url for applying/removing each bucket, etc.
1375
	 *
1376
	 * NOTE - if this is called before the search is performed, an empty array will be returned. Use the $aggregations class
1377
	 * member if you need to access the raw filters set in Jetpack_Search::set_filters().
1378
	 *
1379
	 * @since 5.0.0
1380
	 *
1381
	 * @param WP_Query $query The optional original WP_Query to use for determining which filters are active. Defaults to the main query.
0 ignored issues
show
Documentation introduced by
Should the type for parameter $query not be null|WP_Query?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
1382
	 *
1383
	 * @return array Array of filters applied and info about them.
1384
	 */
1385
	public function get_filters( WP_Query $query = null ) {
1386
		if ( ! $query instanceof WP_Query ) {
0 ignored issues
show
Bug introduced by
The class WP_Query does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1387
			global $wp_query;
1388
1389
			$query = $wp_query;
1390
		}
1391
1392
		$aggregation_data = $this->aggregations;
1393
1394
		if ( empty( $aggregation_data ) ) {
1395
			return $aggregation_data;
1396
		}
1397
1398
		$aggregation_results = $this->get_search_aggregations_results();
1399
1400
		if ( ! $aggregation_results ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $aggregation_results of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1401
			return $aggregation_data;
1402
		}
1403
1404
		// NOTE - Looping over the _results_, not the original configured aggregations, so we get the 'real' data from ES
1405
		foreach ( $aggregation_results as $label => $aggregation ) {
1406
			if ( empty( $aggregation ) ) {
1407
				continue;
1408
			}
1409
1410
			$type = $this->aggregations[ $label ]['type'];
1411
1412
			$aggregation_data[ $label ]['buckets'] = array();
1413
1414
			$existing_term_slugs = array();
1415
1416
			$tax_query_var = null;
1417
1418
			// Figure out which terms are active in the query, for this taxonomy
1419
			if ( 'taxonomy' === $this->aggregations[ $label ]['type'] ) {
1420
				$tax_query_var = $this->get_taxonomy_query_var( $this->aggregations[ $label ]['taxonomy'] );
1421
1422
				if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) && is_array( $query->tax_query->queries ) ) {
1423
					foreach ( $query->tax_query->queries as $tax_query ) {
1424
						if ( is_array( $tax_query ) && $this->aggregations[ $label ]['taxonomy'] === $tax_query['taxonomy'] &&
1425
						     'slug' === $tax_query['field'] &&
1426
						     is_array( $tax_query['terms'] ) ) {
1427
							$existing_term_slugs = array_merge( $existing_term_slugs, $tax_query['terms'] );
1428
						}
1429
					}
1430
				}
1431
			}
1432
1433
			// Now take the resulting found aggregation items and generate the additional info about them, such as activation/deactivation url, name, count, etc.
1434
			$buckets = array();
1435
1436
			if ( ! empty( $aggregation['buckets'] ) ) {
1437
				$buckets = (array) $aggregation['buckets'];
1438
			}
1439
1440
			if ( 'date_histogram' == $type ) {
1441
				//re-order newest to oldest
1442
				$buckets = array_reverse( $buckets );
1443
			}
1444
1445
			// Some aggregation types like date_histogram don't support the max results parameter
1446
			if ( is_int( $this->aggregations[ $label ]['count'] ) && count( $buckets ) > $this->aggregations[ $label ]['count'] ) {
1447
				$buckets = array_slice( $buckets, 0, $this->aggregations[ $label ]['count'] );
1448
			}
1449
1450
			foreach ( $buckets as $item ) {
1451
				$query_vars = array();
1452
				$active     = false;
1453
				$remove_url = null;
1454
				$name       = '';
1455
1456
				// What type was the original aggregation?
1457
				switch ( $type ) {
1458
					case 'taxonomy':
1459
						$taxonomy = $this->aggregations[ $label ]['taxonomy'];
1460
1461
						$term = get_term_by( 'slug', $item['key'], $taxonomy );
1462
1463
						if ( ! $term || ! $tax_query_var ) {
1464
							continue 2; // switch() is considered a looping structure
1465
						}
1466
1467
						$query_vars = array(
1468
							$tax_query_var => implode( '+', array_merge( $existing_term_slugs, array( $term->slug ) ) ),
1469
						);
1470
1471
						$name = $term->name;
1472
1473
						// Let's determine if this term is active or not
1474
1475
						if ( in_array( $item['key'], $existing_term_slugs, true ) ) {
1476
							$active = true;
1477
1478
							$slug_count = count( $existing_term_slugs );
1479
1480 View Code Duplication
							if ( $slug_count > 1 ) {
1481
								$remove_url = Jetpack_Search_Helpers::add_query_arg(
1482
									$tax_query_var,
0 ignored issues
show
Bug introduced by
It seems like $tax_query_var can also be of type boolean; however, Jetpack_Search_Helpers::add_query_arg() does only seem to accept string|array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
1483
									rawurlencode( implode( '+', array_diff( $existing_term_slugs, array( $item['key'] ) ) ) )
1484
								);
1485
							} else {
1486
								$remove_url = Jetpack_Search_Helpers::remove_query_arg( $tax_query_var );
0 ignored issues
show
Bug introduced by
It seems like $tax_query_var can also be of type boolean; however, Jetpack_Search_Helpers::remove_query_arg() does only seem to accept string|array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
1487
							}
1488
						}
1489
1490
						break;
1491
1492
					case 'post_type':
1493
						$post_type = get_post_type_object( $item['key'] );
1494
1495
						if ( ! $post_type || $post_type->exclude_from_search ) {
1496
							continue 2;  // switch() is considered a looping structure
1497
						}
1498
1499
						$query_vars = array(
1500
							'post_type' => $item['key'],
1501
						);
1502
1503
						$name = $post_type->labels->singular_name;
1504
1505
						// Is this post type active on this search?
1506
						$post_types = $query->get( 'post_type' );
1507
1508
						if ( ! is_array( $post_types ) ) {
1509
							$post_types = array( $post_types );
1510
						}
1511
1512
						if ( in_array( $item['key'], $post_types ) ) {
1513
							$active = true;
1514
1515
							$post_type_count = count( $post_types );
1516
1517
							// For the right 'remove filter' url, we need to remove the post type from the array, or remove the param entirely if it's the only one
1518 View Code Duplication
							if ( $post_type_count > 1 ) {
1519
								$remove_url = Jetpack_Search_Helpers::add_query_arg(
1520
									'post_type',
1521
									rawurlencode( implode( ',', array_diff( $post_types, array( $item['key'] ) ) ) )
1522
								);
1523
							} else {
1524
								$remove_url = Jetpack_Search_Helpers::remove_query_arg( 'post_type' );
1525
							}
1526
						}
1527
1528
						break;
1529
1530
					case 'date_histogram':
1531
						$timestamp = $item['key'] / 1000;
1532
1533
						$current_year  = $query->get( 'year' );
1534
						$current_month = $query->get( 'monthnum' );
1535
						$current_day   = $query->get( 'day' );
1536
1537
						switch ( $this->aggregations[ $label ]['interval'] ) {
1538
							case 'year':
1539
								$year = (int) date( 'Y', $timestamp );
1540
1541
								$query_vars = array(
1542
									'year'     => $year,
1543
									'monthnum' => false,
1544
									'day'      => false,
1545
								);
1546
1547
								$name = $year;
1548
1549
								// Is this year currently selected?
1550
								if ( ! empty( $current_year ) && (int) $current_year === $year ) {
1551
									$active = true;
1552
1553
									$remove_url = Jetpack_Search_Helpers::remove_query_arg( array( 'year', 'monthnum', 'day' ) );
1554
								}
1555
1556
								break;
1557
1558
							case 'month':
1559
								$year  = (int) date( 'Y', $timestamp );
1560
								$month = (int) date( 'n', $timestamp );
1561
1562
								$query_vars = array(
1563
									'year'     => $year,
1564
									'monthnum' => $month,
1565
									'day'      => false,
1566
								);
1567
1568
								$name = date( 'F Y', $timestamp );
1569
1570
								// Is this month currently selected?
1571
								if ( ! empty( $current_year ) && (int) $current_year === $year &&
1572
								     ! empty( $current_month ) && (int) $current_month === $month ) {
1573
									$active = true;
1574
1575
									$remove_url = Jetpack_Search_Helpers::remove_query_arg( array( 'year', 'monthnum' ) );
1576
								}
1577
1578
								break;
1579
1580
							case 'day':
1581
								$year  = (int) date( 'Y', $timestamp );
1582
								$month = (int) date( 'n', $timestamp );
1583
								$day   = (int) date( 'j', $timestamp );
1584
1585
								$query_vars = array(
1586
									'year'     => $year,
1587
									'monthnum' => $month,
1588
									'day'      => $day,
1589
								);
1590
1591
								$name = date( 'F jS, Y', $timestamp );
1592
1593
								// Is this day currently selected?
1594
								if ( ! empty( $current_year ) && (int) $current_year === $year &&
1595
								     ! empty( $current_month ) && (int) $current_month === $month &&
1596
								     ! empty( $current_day ) && (int) $current_day === $day ) {
1597
									$active = true;
1598
1599
									$remove_url = Jetpack_Search_Helpers::remove_query_arg( array( 'day' ) );
1600
								}
1601
1602
								break;
1603
1604
							default:
1605
								continue 3; // switch() is considered a looping structure
1606
						} // End switch().
1607
1608
						break;
1609
1610
					default:
1611
						//continue 2; // switch() is considered a looping structure
1612
				} // End switch().
1613
1614
				// Need to urlencode param values since add_query_arg doesn't
1615
				$url_params = urlencode_deep( $query_vars );
1616
1617
				$aggregation_data[ $label ]['buckets'][] = array(
1618
					'url'        => Jetpack_Search_Helpers::add_query_arg( $url_params ),
1619
					'query_vars' => $query_vars,
1620
					'name'       => $name,
1621
					'count'      => $item['doc_count'],
1622
					'active'     => $active,
1623
					'remove_url' => $remove_url,
1624
					'type'       => $type,
1625
					'type_label' => $aggregation_data[ $label ]['name'],
1626
					'widget_id'  => ! empty( $aggregation_data[ $label ]['widget_id'] ) ? $aggregation_data[ $label ]['widget_id'] : 0
1627
				);
1628
			} // End foreach().
1629
		} // End foreach().
1630
1631
		return $aggregation_data;
1632
	}
1633
1634
	/**
1635
	 * Get the results of the facets performed.
1636
	 *
1637
	 * @deprecated 5.0 Please use Jetpack_Search::get_filters() instead.
1638
	 *
1639
	 * @see        Jetpack_Search::get_filters()
1640
	 *
1641
	 * @return array $facets Array of facets applied and info about them.
1642
	 */
1643
	public function get_search_facet_data() {
1644
		_deprecated_function( __METHOD__, 'jetpack-5.0', 'Jetpack_Search::get_filters()' );
1645
1646
		return $this->get_filters();
1647
	}
1648
1649
	/**
1650
	 * Get the filters that are currently applied to this search.
1651
	 *
1652
	 * @since 5.0.0
1653
	 *
1654
	 * @return array Array of filters that were applied.
1655
	 */
1656
	public function get_active_filter_buckets() {
1657
		$active_buckets = array();
1658
1659
		$filters = $this->get_filters();
1660
1661
		if ( ! is_array( $filters ) ) {
1662
			return $active_buckets;
1663
		}
1664
1665
		foreach ( $filters as $filter ) {
1666
			if ( isset( $filter['buckets'] ) && is_array( $filter['buckets'] ) ) {
1667
				foreach ( $filter['buckets'] as $item ) {
1668
					if ( isset( $item['active'] ) && $item['active'] ) {
1669
						$active_buckets[] = $item;
1670
					}
1671
				}
1672
			}
1673
		}
1674
1675
		return $active_buckets;
1676
	}
1677
1678
	/**
1679
	 * Get the filters that are currently applied to this search.
1680
	 *
1681
	 * @deprecated 5.0 Please use Jetpack_Search::get_active_filter_buckets() instead.
1682
	 *
1683
	 * @see        Jetpack_Search::get_active_filter_buckets()
1684
	 *
1685
	 * @return array Array of filters that were applied.
1686
	 */
1687
	public function get_current_filters() {
1688
		_deprecated_function( __METHOD__, 'jetpack-5.0', 'Jetpack_Search::get_active_filter_buckets()' );
1689
1690
		return $this->get_active_filter_buckets();
1691
	}
1692
1693
	/**
1694
	 * Calculate the right query var to use for a given taxonomy.
1695
	 *
1696
	 * Allows custom code to modify the GET var that is used to represent a given taxonomy, via the jetpack_search_taxonomy_query_var filter.
1697
	 *
1698
	 * @since 5.0.0
1699
	 *
1700
	 * @param string $taxonomy_name The name of the taxonomy for which to get the query var.
1701
	 *
1702
	 * @return bool|string The query var to use for this taxonomy, or false if none found.
1703
	 */
1704
	public function get_taxonomy_query_var( $taxonomy_name ) {
1705
		$taxonomy = get_taxonomy( $taxonomy_name );
1706
1707
		if ( ! $taxonomy || is_wp_error( $taxonomy ) ) {
1708
			return false;
1709
		}
1710
1711
		/**
1712
		 * Modify the query var to use for a given taxonomy
1713
		 *
1714
		 * @module search
1715
		 *
1716
		 * @since  5.0.0
1717
		 *
1718
		 * @param string $query_var     The current query_var for the taxonomy
1719
		 * @param string $taxonomy_name The taxonomy name
1720
		 */
1721
		return apply_filters( 'jetpack_search_taxonomy_query_var', $taxonomy->query_var, $taxonomy_name );
1722
	}
1723
1724
	/**
1725
	 * Takes an array of aggregation results, and ensures the array key ordering matches the key order in $desired
1726
	 * which is the input order.
1727
	 *
1728
	 * Necessary because ES does not always return aggregations in the same order that you pass them in,
1729
	 * and it should be possible to control the display order easily.
1730
	 *
1731
	 * @since 5.0.0
1732
	 *
1733
	 * @param array $aggregations Aggregation results to be reordered.
1734
	 * @param array $desired      Array with keys representing the desired ordering.
1735
	 *
1736
	 * @return array A new array with reordered keys, matching those in $desired.
1737
	 */
1738
	public function fix_aggregation_ordering( array $aggregations, array $desired ) {
1739
		if ( empty( $aggregations ) || empty( $desired ) ) {
1740
			return $aggregations;
1741
		}
1742
1743
		$reordered = array();
1744
1745
		foreach ( array_keys( $desired ) as $agg_name ) {
1746
			if ( isset( $aggregations[ $agg_name ] ) ) {
1747
				$reordered[ $agg_name ] = $aggregations[ $agg_name ];
1748
			}
1749
		}
1750
1751
		return $reordered;
1752
	}
1753
1754
	/**
1755
	 * Sends events to Tracks when a search filters widget is updated.
1756
	 *
1757
	 * @since 5.8.0
1758
	 *
1759
	 * @param string $option    The option name. Only "widget_jetpack-search-filters" is cared about.
1760
	 * @param array  $old_value The old option value.
1761
	 * @param array  $new_value The new option value.
1762
	 */
1763
	public function track_widget_updates( $option, $old_value, $new_value ) {
1764
		if ( 'widget_jetpack-search-filters' !== $option ) {
1765
			return;
1766
		}
1767
1768
		$event = Jetpack_Search_Helpers::get_widget_tracks_value( $old_value, $new_value );
1769
		if ( ! $event ) {
1770
			return;
1771
		}
1772
1773
		jetpack_tracks_record_event(
1774
			wp_get_current_user(),
1775
			sprintf( 'jetpack_search_widget_%s', $event['action'] ),
1776
			$event['widget']
1777
		);
1778
	}
1779
1780
	/**
1781
	 * Moves any active search widgets to the inactive category.
1782
	 *
1783
	 * @since 5.9.0
1784
	 *
1785
	 * @param string $module Unused. The Jetpack module being disabled.
1786
	 */
1787
	public function move_search_widgets_to_inactive( $module ) {
1788
		if ( ! is_active_widget( false, false, Jetpack_Search_Helpers::FILTER_WIDGET_BASE, true ) ) {
1789
			return;
1790
		}
1791
1792
		$sidebars_widgets = wp_get_sidebars_widgets();
1793
1794
		if ( ! is_array( $sidebars_widgets ) ) {
1795
			return;
1796
		}
1797
1798
		$changed = false;
1799
1800
		foreach ( $sidebars_widgets as $sidebar => $widgets ) {
1801
			if ( 'wp_inactive_widgets' === $sidebar || 'orphaned_widgets' === substr( $sidebar, 0, 16 ) ) {
1802
				continue;
1803
			}
1804
1805
			if ( is_array( $widgets ) ) {
1806
				foreach ( $widgets as $key => $widget ) {
1807
					if ( _get_widget_id_base( $widget ) == Jetpack_Search_Helpers::FILTER_WIDGET_BASE ) {
1808
						$changed = true;
1809
1810
						array_unshift( $sidebars_widgets['wp_inactive_widgets'], $widget );
1811
						unset( $sidebars_widgets[ $sidebar ][ $key ] );
1812
					}
1813
				}
1814
			}
1815
		}
1816
1817
		if ( $changed ) {
1818
			wp_set_sidebars_widgets( $sidebars_widgets );
1819
		}
1820
	}
1821
1822
	/**
1823
	 * Transforms an array with fields name as keys and boosts as value into
1824
	 * shorthand "caret" format.
1825
	 *
1826
	 * @param array $fields_boost [ "title" => "2", "content" => "1" ]
1827
	 *
1828
	 * @return array [ "title^2", "content^1" ]
1829
	 */
1830
	private function _get_caret_boosted_fields( array $fields_boost ) {
1831
		$caret_boosted_fields = array();
1832
		foreach ( $fields_boost as $field => $boost ) {
1833
			$caret_boosted_fields[] = "$field^$boost";
1834
		}
1835
		return $caret_boosted_fields;
1836
	}
1837
1838
	/**
1839
	 * Apply a multiplier to boost values.
1840
	 *
1841
	 * @param array $fields_boost [ "title" => 2, "content" => 1 ]
1842
	 * @param array $fields_boost_multiplier [ "title" => 0.1234 ]
1843
	 *
1844
	 * @return array [ "title" => "0.247", "content" => "1.000" ]
1845
	 */
1846
	private function _apply_boosts_multiplier( array $fields_boost, array $fields_boost_multiplier ) {
1847
		foreach( $fields_boost as $field_name => $field_boost ) {
1848
			if ( isset( $fields_boost_multiplier[ $field_name ] ) ) {
1849
				$fields_boost[ $field_name ] *= $fields_boost_multiplier[ $field_name ];
1850
			}
1851
1852
			// Set a floor and format the number as string
1853
			$fields_boost[ $field_name ] = number_format(
1854
				max( 0.001, $fields_boost[ $field_name ] ),
1855
				3, '.', ''
1856
			);
1857
		}
1858
1859
		return $fields_boost;
1860
	}
1861
}
1862