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

Indexable.php in ElasticPress 4.7.2, at includes/classes/Indexable.php

1,294 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Indexable abstract class.
4 *
5 * An indexable is a type of "data" in WP e.g. post type, term, user, etc.
6 *
7 * @since 3.0
8 * @package elasticpress
9 */
10
11 namespace ElasticPress;
12
13 use ElasticPress\Elasticsearch;
14 use ElasticPress\SyncManager;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * An indexable is essentially a document type that can be indexed
22 * and queried against
23 *
24 * @since 3.0
25 */
26 abstract class Indexable {
27
28 /**
29 * Declaring an Indexable global means it won't have an index for each blog in
30 * the network. Instead it will just have one index. There will also be no
31 * network alias.
32 *
33 * @var boolean
34 * @since 3.0
35 */
36 public $global = false;
37
38 /**
39 * Instance of SyncManager. This should handle automated syncing of indexable
40 * objects.
41 *
42 * @var SyncManager
43 * @since 3.0
44 */
45 public $sync_manager;
46
47 /**
48 * Instance of QueryIntegration. This should handle integrating with a default
49 * WP query.
50 *
51 * @var object
52 * @since 3.0
53 */
54 public $query_integration;
55
56 /**
57 * Flag to indicate if the indexable has support for
58 * `id_range` pagination method during a sync.
59 *
60 * @var boolean
61 * @since 4.1.0
62 */
63 public $support_indexing_advanced_pagination = false;
64
65 /**
66 * Indexable slug
67 *
68 * @since 4.5.0
69 * @var string
70 */
71 public $slug = '';
72
73 /**
74 * Indexable labels
75 *
76 * @since 4.5.0
77 * @var array
78 */
79 public $labels = [];
80
81 /**
82 * Get number of bulk items to index per page
83 *
84 * @since 3.0
85 * @return int
86 */
87 public function get_bulk_items_per_page() {
88 /**
89 * Filter bulk items to sync per batch
90 *
91 * @hook ep_bulk_items_per_page
92 * @param {int} $number Number of items per batch
93 * @param {Indexable} $indexable Current indexable
94 * @return {int} New number of items
95 * @since 3.0
96 */
97 return apply_filters( 'ep_bulk_items_per_page', 350, $this );
98 }
99
100 /**
101 * Get the name of the index. Each indexable needs a unique index name
102 *
103 * @param int $blog_id `null` means current blog.
104 * @since 3.0
105 * @return string
106 */
107 public function get_index_name( $blog_id = null ) {
108 if ( $this->global ) {
109 $site_url = network_site_url();
110
111 if ( ! empty( $site_url ) ) {
112 $index_name = preg_replace( '#https?://(www\.)?#i', '', $site_url );
113 $index_name = preg_replace( '#[^\w]#', '', $index_name ) . '-' . $this->slug;
114 } else {
115 $index_name = false;
116 }
117 } else {
118 if ( ! $blog_id ) {
119 $blog_id = get_current_blog_id();
120 }
121
122 $site_url = get_site_url( $blog_id );
123
124 if ( ! empty( $site_url ) ) {
125 $index_name = preg_replace( '#https?://(www\.)?#i', '', $site_url );
126 $index_name = preg_replace( '#[^\w]#', '', $index_name ) . '-' . $this->slug . '-' . $blog_id;
127 } else {
128 $index_name = false;
129 }
130 }
131
132 $prefix = Utils\get_index_prefix();
133
134 if ( ! empty( $prefix ) ) {
135 $index_name = $prefix . '-' . $index_name;
136 }
137
138 $index_name = strtolower( $index_name );
139
140 /**
141 * Filter index name
142 *
143 * @hook ep_index_name
144 * @param {string} $index_name Name of index
145 * @param {int} $blog_id Blog ID
146 * @param {Indexable} $indexable Current indexable
147 * @return {string} Index name
148 * @since 3.0
149 */
150 return apply_filters( 'ep_index_name', $index_name, $blog_id, $this );
151 }
152
153 /**
154 * Get unique indexable network alias
155 *
156 * @since 3.0
157 * @return string
158 */
159 public function get_network_alias() {
160 $url = network_site_url();
161 $slug = preg_replace( '#https?://(www\.)?#i', '', $url );
162 $slug = preg_replace( '#[^\w]#', '', $slug );
163
164 $alias = $slug . '-' . $this->slug . '-global';
165
166 $prefix = Utils\get_index_prefix();
167
168 if ( ! empty( $prefix ) ) {
169 $alias = $prefix . '-' . $alias;
170 }
171
172 /**
173 * Filter global/network Elasticsearch alias
174 *
175 * @hook ep_global_alias
176 * @param {string} $number Current alias
177 * @return {string} New alias
178 */
179 return apply_filters( 'ep_global_alias', $alias );
180 }
181
182 /**
183 * Delete unique indexable network alias
184 *
185 * @since 3.0
186 * @return boolean
187 */
188 public function delete_network_alias() {
189 return Elasticsearch::factory()->delete_network_alias( $this->get_network_alias() );
190 }
191
192 /**
193 * Create unique indexable network alias
194 *
195 * @param array $indexes Array of indexes.
196 * @since 3.0
197 * @return boolean
198 */
199 public function create_network_alias( $indexes ) {
200 return Elasticsearch::factory()->create_network_alias( $indexes, $this->get_network_alias() );
201 }
202
203 /**
204 * Delete an object within the indexable
205 *
206 * @param int $object_id Object to delete.
207 * @param boolean $blocking Whether to issue blocking HTTP request or not.
208 * @since 3.0
209 * @return boolean
210 */
211 public function delete( $object_id, $blocking = true ) {
212 /**
213 * Fires before object deletion
214 *
215 * @hook ep_delete_{indexable_slug}
216 * @param {int} $object_id ID of object being deleted
217 * @param {string} $indexable_slug The slug of the indexable type that is being deleted
218 */
219 do_action( 'ep_delete_' . $this->slug, $object_id, $this->slug );
220
221 return Elasticsearch::factory()->delete_document( $this->get_index_name(), $this->slug, $object_id, $blocking );
222 }
223
224 /**
225 * Get an object within the indexable
226 *
227 * @param int $object_id Object to get.
228 * @since 3.0
229 * @return boolean|array
230 */
231 public function get( $object_id ) {
232 return Elasticsearch::factory()->get_document( $this->get_index_name(), $this->slug, $object_id );
233 }
234
235 /**
236 * Get objects within the indexable
237 *
238 * @param int $object_ids Array of object ids to get.
239 * @since 3.6.0
240 * @return boolean|array
241 */
242 public function multi_get( $object_ids ) {
243 return Elasticsearch::factory()->get_documents( $this->get_index_name(), $this->slug, $object_ids );
244 }
245
246 /**
247 * Delete an index within the indexable
248 *
249 * @param int $blog_id `null` means current blog.
250 * @since 3.0
251 * @return boolean
252 */
253 public function delete_index( $blog_id = null ) {
254 return Elasticsearch::factory()->delete_index( $this->get_index_name( $blog_id ) );
255 }
256
257 /**
258 * Index an object within the indexable. This calls prepare_document
259 *
260 * @param int $object_id Object to index.
261 * @param boolean $blocking Blocking HTTP request or not.
262 * @since 3.0
263 * @return boolean
264 */
265 public function index( $object_id, $blocking = false ) {
266 $document = $this->prepare_document( $object_id );
267
268 if ( false === $document ) {
269 return false;
270 }
271
272 /**
273 * Conditionally kill indexing on a specific object
274 *
275 * @hook ep_{indexable_slug}_index_kill
276 * @param {bool} $kill True to not index
277 * @param {int} $object_id Id of object to index
278 * @since 3.0
279 * @return {bool} New kill value
280 */
281 if ( apply_filters( 'ep_' . $this->slug . '_index_kill', false, $object_id ) ) {
282 return false;
283 }
284
285 /**
286 * Filter document before index
287 *
288 * @hook ep_pre_index_{indexable_slug}
289 * @param {array} $document Document to index
290 * @return {array} New document
291 * @since 3.0
292 */
293 $document = apply_filters( 'ep_pre_index_' . $this->slug, $document );
294
295 $return = Elasticsearch::factory()->index_document( $this->get_index_name(), $this->slug, $document, $blocking );
296
297 /**
298 * Fires after document is indexed
299 *
300 * @hook ep_after_index_{indexable_slug}
301 * @param {array} $document Document to index
302 * @param {array|boolean} $return ES response on success, false on failure
303 * @since 3.0
304 */
305 do_action( 'ep_after_index_' . $this->slug, $document, $return );
306
307 return $return;
308 }
309
310 /**
311 * Determine if indexable index exists
312 *
313 * @param int $blog_id Blog to check index for.
314 * @since 3.0
315 * @return boolean
316 */
317 public function index_exists( $blog_id = null ) {
318 return Elasticsearch::factory()->index_exists( $this->get_index_name( $blog_id ) );
319 }
320
321 /**
322 * Bulk index objects. This calls prepare_document on each object
323 *
324 * @param array $object_ids Array of object IDs.
325 * @since 3.0
326 * @return WP_Error|array
327 */
328 public function bulk_index( $object_ids ) {
329 $body = '';
330
331 foreach ( $object_ids as $object_id ) {
332 $action_args = array(
333 'index' => array(
334 '_id' => absint( $object_id ),
335 ),
336 );
337
338 $document = $this->prepare_document( $object_id );
339
340 /**
341 * Conditionally kill indexing on a specific object
342 *
343 * @hook ep_bulk_index_action_args
344 * @param {array} $action_args Bulk action arguments
345 * @param {array} $document Document to index
346 * @since 3.0
347 * @return {array} New action args
348 */
349 $body .= wp_json_encode( apply_filters( 'ep_bulk_index_action_args', $action_args, $document ) ) . "\n";
350 $body .= addcslashes( wp_json_encode( $document ), "\n" );
351
352 $body .= "\n\n";
353 }
354
355 $result = Elasticsearch::factory()->bulk_index( $this->get_index_name(), $this->slug, $body );
356
357 /**
358 * Perform actions after a bulk indexing is completed
359 *
360 * @hook ep_after_bulk_index
361 * @param {array} $object_ids List of object ids attempted to be indexed
362 * @param {string} $slug Current indexable slug
363 * @param {array|bool} $result Result of the Elasticsearch query. False on error.
364 */
365 do_action( 'ep_after_bulk_index', $object_ids, $this->slug, $result );
366
367 return $result;
368 }
369
370 /**
371 * Bulk index objects but with a dynamic size of queue.
372 *
373 * @since 4.0.0
374 * @param array $object_ids Array of object IDs.
375 * @return array[WP_Error|array] The return of each request made.
376 */
377 public function bulk_index_dynamically( $object_ids ) {
378 $documents = [];
379
380 foreach ( $object_ids as $object_id ) {
381 $action_args = array(
382 'index' => array(
383 '_id' => absint( $object_id ),
384 ),
385 );
386
387 $document = $this->prepare_document( $object_id );
388
389 /**
390 * Conditionally kill indexing on a specific object
391 *
392 * @hook ep_bulk_index_action_args
393 * @param {array} $action_args Bulk action arguments
394 * @param {array} $document Document to index
395 * @since 3.0
396 * @return {array} New action args
397 */
398 $document_str = wp_json_encode( apply_filters( 'ep_bulk_index_action_args', $action_args, $document ) ) . "\n";
399 $document_str .= addcslashes( wp_json_encode( $document ), "\n" );
400 $document_str .= "\n\n";
401
402 $documents[] = $document_str;
403 }
404
405 $results = $this->send_bulk_index_request( $documents );
406
407 /**
408 * Perform actions after a dynamic bulk indexing is completed
409 *
410 * @hook ep_after_bulk_index_dynamically
411 * @since 4.0.0
412 * @param {array} $object_ids List of object ids attempted to be indexed
413 * @param {string} $slug Current indexable slug
414 * @param {array|bool} $result Result of the Elasticsearch query. False on error.
415 */
416 do_action( 'ep_after_bulk_index_dynamically', $object_ids, $this->slug, $results );
417
418 return $results;
419 }
420
421 /**
422 * Bulk index documents through several requests with dynamic size.
423 *
424 * @param array $documents The documents to be sent to Elasticsearch (already formatted.)
425 * @return array[WP_Error|array]
426 */
427 protected function send_bulk_index_request( $documents ) {
428 static $min_buffer_size, $max_buffer_size, $current_buffer_size, $incremental_step;
429
430 if ( ! $min_buffer_size ) {
431 /**
432 * Filter the minimum buffer size for dynamic bulk index requests.
433 *
434 * @hook ep_dynamic_bulk_min_buffer_size
435 * @since 4.0.0
436 * @param {int} $min_buffer_size Min buffer size for dynamic bulk index (in bytes.)
437 * @return {int} New size.
438 */
439 $min_buffer_size = apply_filters( 'ep_dynamic_bulk_min_buffer_size', MB_IN_BYTES / 2 );
440 }
441
442 if ( ! $max_buffer_size ) {
443 /**
444 * Filter the max buffer size for dynamic bulk index requests.
445 *
446 * @hook ep_dynamic_bulk_max_buffer_size
447 * @since 4.0.0
448 * @param {int} $max_buffer_size Max buffer size for dynamic bulk index (in bytes.)
449 * @return {int} New size.
450 */
451 $max_buffer_size = apply_filters( 'ep_dynamic_bulk_max_buffer_size', 150 * MB_IN_BYTES );
452 }
453
454 if ( ! $incremental_step ) {
455 /**
456 * Filter the number of bytes the current buffer size should be incremented in case of success.
457 *
458 * @hook ep_dynamic_bulk_incremental_step
459 * @since 4.0.0
460 * @param {int} $incremental_step Number of bytes to add to the current buffer size.
461 * @return {int} New incremental step.
462 */
463 $incremental_step = apply_filters( 'ep_dynamic_bulk_incremental_step', MB_IN_BYTES / 2 );
464 }
465
466 /**
467 * Perform actions before a new batch of documents is processed.
468 *
469 * @hook ep_before_send_dynamic_bulk_requests
470 * @since 4.0.0
471 * @param {array} $documents Array of documents to be sent to Elasticsearch.
472 */
473 do_action( 'ep_before_send_dynamic_bulk_requests', $documents );
474
475 if ( ! $current_buffer_size ) {
476 $current_buffer_size = $min_buffer_size;
477 }
478
479 $results = [];
480
481 $body = [];
482
483 $requests = 0;
484
485 /*
486 * This script will use two main arrays: $body and $documents, being $body the
487 * documents to be sent in the next request and $documents the list of docs to be indexed.
488 * The do-while loop will stop if all documents are sent or if a request fails even sending
489 * a buffer as small as possible.
490 */
491 do {
492 $next_document = array_shift( $documents );
493
494 // If the next document alone takes the entire current buffer size,
495 // let's add it back to the pipe and send what we have first
496 if ( mb_strlen( $next_document ) > $current_buffer_size && count( $body ) > 0 ) {
497 array_unshift( $documents, $next_document );
498 } else {
499 if ( mb_strlen( $next_document ) > $max_buffer_size ) {
500 /**
501 * Perform actions when a post is bigger than the max buffer size.
502 *
503 * @hook ep_dynamic_bulk_post_too_big
504 * @since 4.0.0
505 * @param {string} $document JSON string of the post detected as too big.
506 */
507 do_action( 'ep_dynamic_bulk_post_too_big', $next_document );
508 $results[] = new \WP_Error( 'ep_too_big_request_skipped', 'Indexable too big. Request not sent.' );
509 continue;
510 }
511 $body[] = $next_document;
512 if ( mb_strlen( implode( '', $body ) ) < $current_buffer_size && ! empty( $documents ) ) {
513 continue;
514 }
515 if ( mb_strlen( implode( '', $body ) ) > $max_buffer_size ) {
516 // The last document added to body made it too big, so let's give it back.
517 array_unshift( $documents, array_pop( $body ) );
518 }
519 }
520
521 // Try the request.
522 timer_start();
523 $result = Elasticsearch::factory()->bulk_index( $this->get_index_name(), $this->slug, implode( '', $body ) );
524 $request_time = timer_stop();
525 $requests++;
526
527 /**
528 * Perform actions before a new batch of documents is processed.
529 *
530 * @hook ep_after_send_dynamic_bulk_request
531 * @since 4.0.0
532 * @param {WP_Error|array} $result Result of the request.
533 * @param {array} $body Array of documents sent to Elasticsearch.
534 * @param {array} $documents Array of documents to be sent to Elasticsearch.
535 * @param {int} $min_buffer_size Min buffer size for dynamic bulk index (in bytes.)
536 * @param {int} $max_buffer_size Max buffer size for dynamic bulk index (in bytes.)
537 * @param {int} $current_buffer_size Current buffer size for dynamic bulk index (in bytes.)
538 * @param {int} $request_time Total time of the request.
539 */
540 do_action( 'ep_after_send_dynamic_bulk_request', $result, $body, $documents, $min_buffer_size, $max_buffer_size, $current_buffer_size, $request_time );
541
542 // It failed, possibly adjust the buffer size and try again.
543 if ( is_wp_error( $result ) ) {
544 // Too many requests, wait and try again.
545 if ( 429 === $result->get_error_code() ) {
546 sleep( 2 );
547 }
548
549 // If the error is not a "Request too big" then we really fail this batch of documents.
550 if ( 413 !== $result->get_error_code() ) {
551 $results[] = $result;
552 continue;
553 }
554
555 if ( count( $body ) === 1 ) {
556 $max_buffer_size = min( $max_buffer_size, mb_strlen( implode( '', $body ) ) );
557 $results[] = $result;
558 $body = [];
559 continue;
560 }
561
562 // As the buffer is as small as possible, return the error.
563 if ( mb_strlen( implode( '', $body ) ) === $min_buffer_size ) {
564 $results[] = $result;
565 continue;
566 }
567
568 // We have a too big buffer. Remove one doc from the body, and set both max and current as its size.
569 array_unshift( $documents, array_pop( $body ) );
570
571 $max_buffer_size = count( $body ) ?
572 max( $min_buffer_size, mb_strlen( implode( '', $body ) ) ) :
573 $min_buffer_size;
574
575 $current_buffer_size = $max_buffer_size;
576 continue;
577 }
578
579 // Things worked so we can try to bump the buffer size.
580 if ( $current_buffer_size < $max_buffer_size && mb_strlen( implode( '', $body ) ) > $current_buffer_size ) {
581 $current_buffer_size = min( ( $current_buffer_size + $incremental_step ), $max_buffer_size );
582 }
583
584 $results[] = $result;
585
586 $body = [];
587 } while ( ! empty( $documents ) );
588
589 /**
590 * Perform actions after a batch of documents was processed.
591 *
592 * @hook ep_after_send_dynamic_bulk_requests
593 * @since 4.0.0
594 * @param {array} $results Array of results sent.
595 * @param {int} $requests Number of all requests sent.
596 */
597 do_action( 'ep_after_send_dynamic_bulk_requests', $results, $requests );
598
599 return $results;
600 }
601
602 /**
603 * Query Elasticsearch for documents
604 *
605 * @param array $formatted_args Formatted es query arguments.
606 * @param array $query_args WP_Query args.
607 * @param string $index Index(es) to query. Comma separate for multiple. Defaults to current.
608 * @param mixed $query_object Could be WP_Query, WP_User_Query, etc.
609 * @since 3.0
610 * @return array
611 */
612 public function query_es( $formatted_args, $query_args, $index = null, $query_object = null ) {
613 if ( null === $index ) {
614 $index = $this->get_index_name();
615 }
616
617 return Elasticsearch::factory()->query( $index, $this->slug, $formatted_args, $query_args, $query_object );
618 }
619
620 /**
621 * Check to see if we should allow elasticpress to override this query
622 *
623 * @param \WP_Query|\WP_User_Query|\WP_Term_Query $query WP_Query or WP_User_Query or WP_Term_Query instance
624 * @return bool
625 * @since 3.0
626 */
627 public function elasticpress_enabled( $query ) {
628 $enabled = false;
629
630 if ( ! empty( $query->query_vars['ep_integrate'] ) ) {
631 $enabled = true;
632 }
633
634 /**
635 * Determine if ElasticPress should integrate with a query
636 *
637 * @hook ep_elasticpress_enabled
638 * @param {bool} $enabled Whether to integrate with Elasticsearch or not
639 * @param {WP_Query} $query WP_Query to evaluate
640 * @return {bool} Enabled value
641 */
642 $enabled = apply_filters( 'ep_elasticpress_enabled', $enabled, $query );
643
644 if ( isset( $query->query_vars['ep_integrate'] ) && ! filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN ) ) {
645 $enabled = false;
646 }
647
648 return $enabled;
649 }
650
651 /**
652 * Prepare meta type values to send to ES
653 *
654 * @param array $meta Array of meta.
655 * @since 3.0
656 * @return array
657 */
658 public function prepare_meta_types( $meta ) {
659
660 $prepared_meta = [];
661
662 foreach ( $meta as $meta_key => $meta_values ) {
663 if ( ! is_array( $meta_values ) ) {
664 $meta_values = array( $meta_values );
665 }
666
667 $prepared_meta[ $meta_key ] = array_map( array( $this, 'prepare_meta_value_types' ), $meta_values );
668 }
669
670 return $prepared_meta;
671
672 }
673
674 /**
675 * Prepare meta types for meta value
676 *
677 * @param mixed $meta_value Meta value to prepare.
678 * @since 3.0
679 * @return array
680 */
681 public function prepare_meta_value_types( $meta_value ) {
682
683 $max_java_int_value = PHP_INT_MAX;
684
685 $meta_types = [];
686
687 if ( is_array( $meta_value ) || is_object( $meta_value ) ) {
688 $meta_value = serialize( $meta_value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
689 }
690
691 $meta_types['value'] = $meta_value;
692 $meta_types['raw'] = $meta_value;
693
694 if ( is_numeric( $meta_value ) ) {
695 $long = intval( $meta_value );
696
697 if ( $max_java_int_value < $long ) {
698 $long = $max_java_int_value;
699 }
700
701 $double = floatval( $meta_value );
702
703 if ( ! is_finite( $double ) ) {
704 $double = 0;
705 }
706
707 $meta_types['long'] = $long;
708 $meta_types['double'] = $double;
709 }
710
711 $meta_types['boolean'] = filter_var( $meta_value, FILTER_VALIDATE_BOOLEAN );
712
713 $meta_types = $this->prepare_date_meta_values( $meta_types, $meta_value );
714
715 return $meta_types;
716 }
717
718 /**
719 * Checks if a meta_value is a valid date and prepare extra meta-data.
720 *
721 * @param array $meta_types Array of currently prepared data
722 * @param string $meta_value Meta value to prepare.
723 *
724 * @return array
725 */
726 public function prepare_date_meta_values( $meta_types, $meta_value ) {
727
728 if ( empty( $meta_value ) || ! is_string( $meta_value ) ) {
729 return $meta_types;
730 }
731
732 $meta_types['date'] = '1970-01-01';
733 $meta_types['datetime'] = '1970-01-01 00:00:01';
734 $meta_types['time'] = '00:00:01';
735
736 // is this is a recognizable date format?
737 $new_date = date_create( $meta_value, \wp_timezone() );
738 if ( $new_date ) {
739 $timestamp = $new_date->getTimestamp();
740
741 /**
742 * Filter the maximum year limit for date conversion.
743 *
744 * Use default date if year is greater than max limit. EP has limitation that doesn't allow to have year greater than 2099.
745 *
746 * @see https://github.com/10up/ElasticPress/issues/2769
747 *
748 * @hook ep_max_year_limit
749 * @param {int} $year Maximum year limit.
750 * @return {int} Maximum year limit.
751 * @since 4.2.1
752 */
753 $max_year = apply_filters( 'ep_max_year_limit', 2099 );
754
755 // PHP allows DateTime to build dates with the non-existing year 0000, and this causes
756 // issues when integrating into stricter systems. This is by design:
757 // https://bugs.php.net/bug.php?id=60288
758 if ( false !== $timestamp && '0000' !== $new_date->format( 'Y' ) && $new_date->format( 'Y' ) <= $max_year ) {
759 $meta_types['date'] = $new_date->format( 'Y-m-d' );
760 $meta_types['datetime'] = $new_date->format( 'Y-m-d H:i:s' );
761 $meta_types['time'] = $new_date->format( 'H:i:s' );
762 }
763 }
764
765 return $meta_types;
766 }
767
768 /**
769 * Build Elasticsearch filter query for WP meta_query
770 *
771 * @since 2.2
772 * @param array $meta_queries Array of queries
773 * @return array
774 */
775 public function build_meta_query( $meta_queries ) {
776 $meta_filter = [];
777
778 $outer_relation = 'must';
779 if ( ! empty( $meta_queries['relation'] ) && 'or' === strtolower( $meta_queries['relation'] ) ) {
780 $outer_relation = 'should';
781 }
782
783 $meta_query_type_mapping = [
784 'numeric' => 'long',
785 'binary' => 'raw',
786 'char' => 'raw',
787 'date' => 'date',
788 'datetime' => 'datetime',
789 'decimal' => 'double',
790 'signed' => 'long',
791 'time' => 'time',
792 'unsigned' => 'long',
793 ];
794
795 foreach ( $meta_queries as $single_meta_query ) {
796 if ( ! empty( $single_meta_query['key'] ) ) {
797
798 $terms_obj = false;
799
800 $compare = '=';
801 if ( ! empty( $single_meta_query['compare'] ) ) {
802 $compare = strtolower( $single_meta_query['compare'] );
803 } elseif ( ! isset( $single_meta_query['value'] ) ) {
804 $compare = 'exists';
805 }
806
807 $type = null;
808 if ( ! empty( $single_meta_query['type'] ) ) {
809 $type = strtolower( $single_meta_query['type'] );
810 }
811
812 // Comparisons need to look at different paths
813 if ( in_array( $compare, array( 'exists', 'not exists' ), true ) ) {
814 $meta_key_path = 'meta.' . $single_meta_query['key'];
815 } elseif ( in_array( $compare, array( '=', '!=' ), true ) && ! $type ) {
816 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.raw';
817 } elseif ( in_array( $compare, array( 'like', 'not like' ), true ) ) {
818 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.value';
819 } elseif ( $type && isset( $meta_query_type_mapping[ $type ] ) ) {
820 // Map specific meta field types to different Elasticsearch core types
821 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.' . $meta_query_type_mapping[ $type ];
822 } elseif ( in_array( $compare, array( '>=', '<=', '>', '<', 'between', 'not between' ), true ) ) {
823 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.double';
824 } else {
825 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.raw';
826 }
827
828 switch ( $compare ) {
829 case 'not in':
830 case '!=':
831 if ( isset( $single_meta_query['value'] ) ) {
832 $terms_obj = array(
833 'bool' => array(
834 'must_not' => array(
835 array(
836 'terms' => array(
837 $meta_key_path => (array) $single_meta_query['value'],
838 ),
839 ),
840 ),
841 ),
842 );
843 }
844
845 break;
846 case 'exists':
847 $terms_obj = array(
848 'exists' => array(
849 'field' => $meta_key_path,
850 ),
851 );
852
853 break;
854 case 'not exists':
855 $terms_obj = array(
856 'bool' => array(
857 'must_not' => array(
858 array(
859 'exists' => array(
860 'field' => $meta_key_path,
861 ),
862 ),
863 ),
864 ),
865 );
866
867 break;
868 case '>=':
869 if ( isset( $single_meta_query['value'] ) ) {
870 $terms_obj = array(
871 'bool' => array(
872 'must' => array(
873 array(
874 'range' => array(
875 $meta_key_path => array(
876 'gte' => $single_meta_query['value'],
877 ),
878 ),
879 ),
880 ),
881 ),
882 );
883 }
884
885 break;
886 case 'between':
887 if ( isset( $single_meta_query['value'] ) && is_array( $single_meta_query['value'] ) && 2 === count( $single_meta_query['value'] ) ) {
888 $terms_obj = array(
889 'bool' => array(
890 'must' => array(
891 array(
892 'range' => array(
893 $meta_key_path => array(
894 'gte' => $single_meta_query['value'][0],
895 ),
896 ),
897 ),
898 array(
899 'range' => array(
900 $meta_key_path => array(
901 'lte' => $single_meta_query['value'][1],
902 ),
903 ),
904 ),
905 ),
906 ),
907 );
908 }
909
910 break;
911 case 'not between':
912 if ( isset( $single_meta_query['value'] ) && is_array( $single_meta_query['value'] ) && 2 === count( $single_meta_query['value'] ) ) {
913 $terms_obj = array(
914 'bool' => array(
915 'should' => array(
916 array(
917 'range' => array(
918 $meta_key_path => array(
919 'lte' => $single_meta_query['value'][0],
920 ),
921 ),
922 ),
923 array(
924 'range' => array(
925 $meta_key_path => array(
926 'gte' => $single_meta_query['value'][1],
927 ),
928 ),
929 ),
930 ),
931 ),
932 );
933 }
934
935 break;
936 case '<=':
937 if ( isset( $single_meta_query['value'] ) ) {
938 $terms_obj = array(
939 'bool' => array(
940 'must' => array(
941 array(
942 'range' => array(
943 $meta_key_path => array(
944 'lte' => $single_meta_query['value'],
945 ),
946 ),
947 ),
948 ),
949 ),
950 );
951 }
952
953 break;
954 case '>':
955 if ( isset( $single_meta_query['value'] ) ) {
956 $terms_obj = array(
957 'bool' => array(
958 'must' => array(
959 array(
960 'range' => array(
961 $meta_key_path => array(
962 'gt' => $single_meta_query['value'],
963 ),
964 ),
965 ),
966 ),
967 ),
968 );
969 }
970
971 break;
972 case '<':
973 if ( isset( $single_meta_query['value'] ) ) {
974 $terms_obj = array(
975 'bool' => array(
976 'must' => array(
977 array(
978 'range' => array(
979 $meta_key_path => array(
980 'lt' => $single_meta_query['value'],
981 ),
982 ),
983 ),
984 ),
985 ),
986 );
987 }
988
989 break;
990 case 'like':
991 if ( isset( $single_meta_query['value'] ) ) {
992 $terms_obj = array(
993 'match_phrase' => array(
994 $meta_key_path => $single_meta_query['value'],
995 ),
996 );
997 }
998 break;
999 case 'not like':
1000 if ( isset( $single_meta_query['value'] ) ) {
1001 $terms_obj = array(
1002 'bool' => array(
1003 'must_not' => array(
1004 array(
1005 'match_phrase' => array(
1006 $meta_key_path => $single_meta_query['value'],
1007 ),
1008 ),
1009 ),
1010 ),
1011 );
1012 }
1013 break;
1014 case '=':
1015 default:
1016 if ( isset( $single_meta_query['value'] ) ) {
1017 $terms_obj = array(
1018 'terms' => array(
1019 $meta_key_path => (array) $single_meta_query['value'],
1020 ),
1021 );
1022 }
1023
1024 break;
1025 }
1026
1027 // Add the meta query filter
1028 if ( false !== $terms_obj ) {
1029 $meta_filter[] = $terms_obj;
1030 }
1031 } elseif ( is_array( $single_meta_query ) ) {
1032 /**
1033 * Handle multidimensional array. Something like:
1034 *
1035 * 'meta_query' => array(
1036 * 'relation' => 'AND',
1037 * array(
1038 * 'key' => 'meta_key_1',
1039 * 'value' => '1',
1040 * ),
1041 * array(
1042 * 'relation' => 'OR',
1043 * array(
1044 * 'key' => 'meta_key_2',
1045 * 'value' => '2',
1046 * ),
1047 * array(
1048 * 'key' => 'meta_key_3',
1049 * 'value' => '4',
1050 * ),
1051 * ),
1052 * ),
1053 */
1054 $inner_relation = 'must';
1055 if ( ! empty( $single_meta_query['relation'] ) && 'or' === strtolower( $single_meta_query['relation'] ) ) {
1056 $inner_relation = 'should';
1057 }
1058
1059 $meta_filter[] = array(
1060 'bool' => array(
1061 $inner_relation => $this->build_meta_query( $single_meta_query ),
1062 ),
1063 );
1064 }
1065 }
1066
1067 if ( ! empty( $meta_filter ) ) {
1068 return [
1069 'bool' => [
1070 $outer_relation => $meta_filter,
1071 ],
1072 ];
1073 } else {
1074 return false;
1075 }
1076 }
1077
1078 /**
1079 * Get the indexable mapping.
1080 *
1081 * @since 3.6.0
1082 * @return boolean|array
1083 */
1084 public function get_mapping() {
1085 return Elasticsearch::factory()->get_mapping( $this->get_index_name() );
1086 }
1087
1088 /**
1089 * Compare the mapping generated by the plugin and the mapping stored in Elasticsearch.
1090 *
1091 * @todo properly implement the check.
1092 *
1093 * @since 3.6.0
1094 * @return bool|WP_Error
1095 */
1096 public function compare_mappings() {
1097 if ( ! method_exists( $this, 'generate_mapping' ) ) {
1098 return new \WP_Error( 'ep_generate_mapping_not_implemented' );
1099 }
1100
1101 $new_mapping = $this->generate_mapping();
1102 $stored_mapping = $this->get_mapping();
1103
1104 return ( (string) $new_mapping['settings']['index.number_of_shards'] === $stored_mapping[ $this->get_index_name() ]['settings']['index']['number_of_shards'] );
1105 }
1106
1107 /**
1108 * Utilitary function to check if the indexable is being fully reindexed, i.e.,
1109 * the index was deleted, a new mapping was sent and content is being reindexed.
1110 *
1111 * @param int|null $blog_id Blog ID
1112 * @return boolean
1113 */
1114 public function is_full_reindexing( $blog_id = null ) {
1115 if ( $this->global ) {
1116 $blog_id = null;
1117 } elseif ( ! $blog_id ) {
1118 $blog_id = get_current_blog_id();
1119 }
1120
1121 return \ElasticPress\IndexHelper::factory()->is_full_reindexing( $this->slug, $blog_id );
1122 }
1123
1124 /**
1125 * Send mapping to Elasticsearch
1126 *
1127 * @param string $return_type Desired return type. Can be either 'bool' or 'raw'
1128 * @return bool|WP_Error
1129 */
1130 public function put_mapping( $return_type = 'bool' ) {
1131 $mapping = $this->generate_mapping();
1132
1133 return Elasticsearch::factory()->put_mapping( $this->get_index_name(), $mapping, $return_type );
1134 }
1135
1136 /**
1137 * Must implement a method that given an object ID, returns a formatted Elasticsearch
1138 * document
1139 *
1140 * @param int $object_id Object to prepare.
1141 * @return array
1142 */
1143 abstract public function prepare_document( $object_id );
1144
1145 /**
1146 * Must implement a method that queries MySQL for objects and returns them
1147 * in a standardized format. This is necessary so we can genericize the index
1148 * process across indexables.
1149 *
1150 * @param array $args Array to query DB against.
1151 * @return array
1152 */
1153 abstract public function query_db( $args );
1154
1155 /**
1156 * Shim function for backwards-compatibility on custom Indexables.
1157 *
1158 * @since 4.1.0
1159 * @return array
1160 */
1161 public function generate_mapping() {
1162 _doing_it_wrong( __METHOD__, 'The Indexable class should not call generate_mapping() directly.', 'ElasticPress 4.0' );
1163
1164 return [];
1165 }
1166
1167 /**
1168 * Get the search algorithm that should be used.
1169 *
1170 * @since 4.3.0
1171 * @param string $search_text Search term(s)
1172 * @param array $search_fields Search fields
1173 * @param array $query_vars Query vars
1174 * @return SearchAlgorithm Instance of search algorithm to be used
1175 */
1176 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
1177 /**
1178 * Filter the search algorithm to be used
1179 *
1180 * @hook ep_{$indexable_slug}_search_algorithm
1181 * @since 4.3.0
1182 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1183 * @param {string} $search_term Search term
1184 * @param {array} $search_fields Fields to be searched
1185 * @param {array} $query_vars Query variables
1186 * @return {string} New search algorithm slug
1187 */
1188 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", 'basic', $search_text, $search_fields, $query_vars );
1189
1190 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1191 }
1192
1193 /**
1194 * Get all distinct meta field keys.
1195 *
1196 * @since 4.3.0
1197 * @param null|int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1198 * @return array
1199 * @throws \Exception An exception if meta fields are not available.
1200 */
1201 public function get_distinct_meta_field_keys( $blog_id = null ) {
1202 $mapping = $this->get_mapping();
1203
1204 try {
1205 if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
1206 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['post']['properties']['meta']['properties'];
1207 } else {
1208 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['properties']['meta']['properties'];
1209 }
1210 $meta_keys = array_values( array_keys( $meta_fields ) );
1211 sort( $meta_keys );
1212 } catch ( \Throwable $th ) {
1213 throw new \Exception( 'Meta fields not available.', 0 );
1214 }
1215
1216 return $meta_keys;
1217 }
1218
1219 /**
1220 * Get all distinct values for a given field.
1221 *
1222 * @since 4.3.0
1223 * @param string $field Field full name. For example: `meta.name.raw`
1224 * @param int $count (Optional) Max number of different distinct values to be returned
1225 * @param int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1226 * @return array
1227 */
1228 public function get_all_distinct_values( $field, $count = 10000, $blog_id = null ) {
1229 $aggregation_name = 'distinct_values';
1230
1231 $es_query = [
1232 '_source' => false,
1233 'size' => 0,
1234 'aggs' => [
1235 $aggregation_name => [
1236 'terms' => [
1237 /**
1238 * Filter the max. number of different distinct values to be returned by Elasticsearch.
1239 *
1240 * @since 4.3.0
1241 * @hook ep_{$indexable_slug}_all_distinct_values
1242 * @param {int} $size The number of different values. Default: 10000
1243 * @param {string} $field The meta field
1244 * @return {string} The new number of different values
1245 */
1246 'size' => apply_filters( 'ep_' . $this->slug . '_all_distinct_values', $count, $field ),
1247 'field' => $field,
1248 ],
1249 ],
1250 ],
1251 ];
1252
1253 $response = Elasticsearch::factory()->query( $this->get_index_name( $blog_id ), $this->slug, $es_query, [] );
1254
1255 if ( ! $response || empty( $response['aggregations'] ) || empty( $response['aggregations'][ $aggregation_name ] ) || empty( $response['aggregations'][ $aggregation_name ]['buckets'] ) ) {
1256 return [];
1257 }
1258
1259 $values = [];
1260 foreach ( $response['aggregations'][ $aggregation_name ]['buckets'] as $es_bucket ) {
1261 $values[] = $es_bucket['key'];
1262 }
1263
1264 return $values;
1265 }
1266
1267 /**
1268 * Should instantiate the indexable SyncManager and QueryIntegration, the main responsibles for the WP integration.
1269 *
1270 * @since 4.5.0
1271 */
1272 public function setup() {}
1273
1274 /**
1275 * Given a mapping, add the ngram analyzer to it
1276 *
1277 * @since 4.5.0
1278 * @param array $mapping The mapping
1279 * @return array
1280 */
1281 public function add_ngram_analyzer( array $mapping ) : array {
1282 $mapping['settings']['analysis']['analyzer']['edge_ngram_analyzer'] = array(
1283 'type' => 'custom',
1284 'tokenizer' => 'standard',
1285 'filter' => array(
1286 'lowercase',
1287 'edge_ngram',
1288 ),
1289 );
1290
1291 return $mapping;
1292 }
1293 }
1294