PluginProbe
ElasticPress / 5.0.2
ElasticPress v5.0.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 5.0.2, at includes/classes/Indexable.php

1,304 lines 35.9 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 if ( empty( $document ) ) {
390 continue;
391 }
392
393 /**
394 * Conditionally kill indexing on a specific object
395 *
396 * @hook ep_bulk_index_action_args
397 * @param {array} $action_args Bulk action arguments
398 * @param {array} $document Document to index
399 * @since 3.0
400 * @return {array} New action args
401 */
402 $document_str = wp_json_encode( apply_filters( 'ep_bulk_index_action_args', $action_args, $document ) ) . "\n";
403 $document_str .= addcslashes( wp_json_encode( $document ), "\n" );
404 $document_str .= "\n\n";
405
406 $documents[] = $document_str;
407 }
408
409 if ( empty( $documents ) ) {
410 return [
411 new \WP_Error( 'ep_bulk_index_no_documents', esc_html__( 'It was not possible to create a body request with the document IDs provided.', 'elasticpress' ), $object_ids ),
412 ];
413 }
414
415 $results = $this->send_bulk_index_request( $documents );
416
417 /**
418 * Perform actions after a dynamic bulk indexing is completed
419 *
420 * @hook ep_after_bulk_index_dynamically
421 * @since 4.0.0
422 * @param {array} $object_ids List of object ids attempted to be indexed
423 * @param {string} $slug Current indexable slug
424 * @param {array|bool} $result Result of the Elasticsearch query. False on error.
425 */
426 do_action( 'ep_after_bulk_index_dynamically', $object_ids, $this->slug, $results );
427
428 return $results;
429 }
430
431 /**
432 * Bulk index documents through several requests with dynamic size.
433 *
434 * @param array $documents The documents to be sent to Elasticsearch (already formatted.)
435 * @return array[WP_Error|array]
436 */
437 protected function send_bulk_index_request( $documents ) {
438 static $min_buffer_size, $max_buffer_size, $current_buffer_size, $incremental_step;
439
440 if ( ! $min_buffer_size ) {
441 /**
442 * Filter the minimum buffer size for dynamic bulk index requests.
443 *
444 * @hook ep_dynamic_bulk_min_buffer_size
445 * @since 4.0.0
446 * @param {int} $min_buffer_size Min buffer size for dynamic bulk index (in bytes.)
447 * @return {int} New size.
448 */
449 $min_buffer_size = apply_filters( 'ep_dynamic_bulk_min_buffer_size', MB_IN_BYTES / 2 );
450 }
451
452 if ( ! $max_buffer_size ) {
453 /**
454 * Filter the max buffer size for dynamic bulk index requests.
455 *
456 * @hook ep_dynamic_bulk_max_buffer_size
457 * @since 4.0.0
458 * @param {int} $max_buffer_size Max buffer size for dynamic bulk index (in bytes.)
459 * @return {int} New size.
460 */
461 $max_buffer_size = apply_filters( 'ep_dynamic_bulk_max_buffer_size', 150 * MB_IN_BYTES );
462 }
463
464 if ( ! $incremental_step ) {
465 /**
466 * Filter the number of bytes the current buffer size should be incremented in case of success.
467 *
468 * @hook ep_dynamic_bulk_incremental_step
469 * @since 4.0.0
470 * @param {int} $incremental_step Number of bytes to add to the current buffer size.
471 * @return {int} New incremental step.
472 */
473 $incremental_step = apply_filters( 'ep_dynamic_bulk_incremental_step', MB_IN_BYTES / 2 );
474 }
475
476 /**
477 * Perform actions before a new batch of documents is processed.
478 *
479 * @hook ep_before_send_dynamic_bulk_requests
480 * @since 4.0.0
481 * @param {array} $documents Array of documents to be sent to Elasticsearch.
482 */
483 do_action( 'ep_before_send_dynamic_bulk_requests', $documents );
484
485 if ( ! $current_buffer_size ) {
486 $current_buffer_size = $min_buffer_size;
487 }
488
489 $results = [];
490
491 $body = [];
492
493 $requests = 0;
494
495 /*
496 * This script will use two main arrays: $body and $documents, being $body the
497 * documents to be sent in the next request and $documents the list of docs to be indexed.
498 * The do-while loop will stop if all documents are sent or if a request fails even sending
499 * a buffer as small as possible.
500 */
501 do {
502 $next_document = array_shift( $documents );
503
504 // If the next document alone takes the entire current buffer size,
505 // let's add it back to the pipe and send what we have first
506 if ( mb_strlen( $next_document ) > $current_buffer_size && count( $body ) > 0 ) {
507 array_unshift( $documents, $next_document );
508 } else {
509 if ( mb_strlen( $next_document ) > $max_buffer_size ) {
510 /**
511 * Perform actions when a post is bigger than the max buffer size.
512 *
513 * @hook ep_dynamic_bulk_post_too_big
514 * @since 4.0.0
515 * @param {string} $document JSON string of the post detected as too big.
516 */
517 do_action( 'ep_dynamic_bulk_post_too_big', $next_document );
518 $results[] = new \WP_Error( 'ep_too_big_request_skipped', 'Indexable too big. Request not sent.' );
519 continue;
520 }
521 $body[] = $next_document;
522 if ( mb_strlen( implode( '', $body ) ) < $current_buffer_size && ! empty( $documents ) ) {
523 continue;
524 }
525 if ( mb_strlen( implode( '', $body ) ) > $max_buffer_size ) {
526 // The last document added to body made it too big, so let's give it back.
527 array_unshift( $documents, array_pop( $body ) );
528 }
529 }
530
531 // Try the request.
532 timer_start();
533 $result = Elasticsearch::factory()->bulk_index( $this->get_index_name(), $this->slug, implode( '', $body ) );
534 $request_time = timer_stop();
535 $requests++;
536
537 /**
538 * Perform actions before a new batch of documents is processed.
539 *
540 * @hook ep_after_send_dynamic_bulk_request
541 * @since 4.0.0
542 * @param {WP_Error|array} $result Result of the request.
543 * @param {array} $body Array of documents sent to Elasticsearch.
544 * @param {array} $documents Array of documents to be sent to Elasticsearch.
545 * @param {int} $min_buffer_size Min buffer size for dynamic bulk index (in bytes.)
546 * @param {int} $max_buffer_size Max buffer size for dynamic bulk index (in bytes.)
547 * @param {int} $current_buffer_size Current buffer size for dynamic bulk index (in bytes.)
548 * @param {int} $request_time Total time of the request.
549 */
550 do_action( 'ep_after_send_dynamic_bulk_request', $result, $body, $documents, $min_buffer_size, $max_buffer_size, $current_buffer_size, $request_time );
551
552 // It failed, possibly adjust the buffer size and try again.
553 if ( is_wp_error( $result ) ) {
554 // Too many requests, wait and try again.
555 if ( 429 === $result->get_error_code() ) {
556 sleep( 2 );
557 }
558
559 // If the error is not a "Request too big" then we really fail this batch of documents.
560 if ( 413 !== $result->get_error_code() ) {
561 $results[] = $result;
562 continue;
563 }
564
565 if ( count( $body ) === 1 ) {
566 $max_buffer_size = min( $max_buffer_size, mb_strlen( implode( '', $body ) ) );
567 $results[] = $result;
568 $body = [];
569 continue;
570 }
571
572 // As the buffer is as small as possible, return the error.
573 if ( mb_strlen( implode( '', $body ) ) === $min_buffer_size ) {
574 $results[] = $result;
575 continue;
576 }
577
578 // We have a too big buffer. Remove one doc from the body, and set both max and current as its size.
579 array_unshift( $documents, array_pop( $body ) );
580
581 $max_buffer_size = count( $body ) ?
582 max( $min_buffer_size, mb_strlen( implode( '', $body ) ) ) :
583 $min_buffer_size;
584
585 $current_buffer_size = $max_buffer_size;
586 continue;
587 }
588
589 // Things worked so we can try to bump the buffer size.
590 if ( $current_buffer_size < $max_buffer_size && mb_strlen( implode( '', $body ) ) > $current_buffer_size ) {
591 $current_buffer_size = min( ( $current_buffer_size + $incremental_step ), $max_buffer_size );
592 }
593
594 $results[] = $result;
595
596 $body = [];
597 } while ( ! empty( $documents ) );
598
599 /**
600 * Perform actions after a batch of documents was processed.
601 *
602 * @hook ep_after_send_dynamic_bulk_requests
603 * @since 4.0.0
604 * @param {array} $results Array of results sent.
605 * @param {int} $requests Number of all requests sent.
606 */
607 do_action( 'ep_after_send_dynamic_bulk_requests', $results, $requests );
608
609 return $results;
610 }
611
612 /**
613 * Query Elasticsearch for documents
614 *
615 * @param array $formatted_args Formatted es query arguments.
616 * @param array $query_args WP_Query args.
617 * @param string $index Index(es) to query. Comma separate for multiple. Defaults to current.
618 * @param mixed $query_object Could be WP_Query, WP_User_Query, etc.
619 * @since 3.0
620 * @return array
621 */
622 public function query_es( $formatted_args, $query_args, $index = null, $query_object = null ) {
623 if ( null === $index ) {
624 $index = $this->get_index_name();
625 }
626
627 return Elasticsearch::factory()->query( $index, $this->slug, $formatted_args, $query_args, $query_object );
628 }
629
630 /**
631 * Check to see if we should allow elasticpress to override this query
632 *
633 * @param \WP_Query|\WP_User_Query|\WP_Term_Query $query WP_Query or WP_User_Query or WP_Term_Query instance
634 * @return bool
635 * @since 3.0
636 */
637 public function elasticpress_enabled( $query ) {
638 $enabled = false;
639
640 if ( ! empty( $query->query_vars['ep_integrate'] ) ) {
641 $enabled = true;
642 }
643
644 /**
645 * Determine if ElasticPress should integrate with a query
646 *
647 * @hook ep_elasticpress_enabled
648 * @param {bool} $enabled Whether to integrate with Elasticsearch or not
649 * @param {WP_Query} $query WP_Query to evaluate
650 * @return {bool} Enabled value
651 */
652 $enabled = apply_filters( 'ep_elasticpress_enabled', $enabled, $query );
653
654 if ( isset( $query->query_vars['ep_integrate'] ) && ! filter_var( $query->query_vars['ep_integrate'], FILTER_VALIDATE_BOOLEAN ) ) {
655 $enabled = false;
656 }
657
658 return $enabled;
659 }
660
661 /**
662 * Prepare meta type values to send to ES
663 *
664 * @param array $meta Array of meta.
665 * @since 3.0
666 * @return array
667 */
668 public function prepare_meta_types( $meta ) {
669
670 $prepared_meta = [];
671
672 foreach ( $meta as $meta_key => $meta_values ) {
673 if ( ! is_array( $meta_values ) ) {
674 $meta_values = array( $meta_values );
675 }
676
677 $prepared_meta[ $meta_key ] = array_map( array( $this, 'prepare_meta_value_types' ), $meta_values );
678 }
679
680 return $prepared_meta;
681
682 }
683
684 /**
685 * Prepare meta types for meta value
686 *
687 * @param mixed $meta_value Meta value to prepare.
688 * @since 3.0
689 * @return array
690 */
691 public function prepare_meta_value_types( $meta_value ) {
692
693 $max_java_int_value = PHP_INT_MAX;
694
695 $meta_types = [];
696
697 if ( is_array( $meta_value ) || is_object( $meta_value ) ) {
698 $meta_value = serialize( $meta_value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
699 }
700
701 $meta_types['value'] = $meta_value;
702 $meta_types['raw'] = $meta_value;
703
704 if ( is_numeric( $meta_value ) ) {
705 $long = intval( $meta_value );
706
707 if ( $max_java_int_value < $long ) {
708 $long = $max_java_int_value;
709 }
710
711 $double = floatval( $meta_value );
712
713 if ( ! is_finite( $double ) ) {
714 $double = 0;
715 }
716
717 $meta_types['long'] = $long;
718 $meta_types['double'] = $double;
719 }
720
721 $meta_types['boolean'] = filter_var( $meta_value, FILTER_VALIDATE_BOOLEAN );
722
723 $meta_types = $this->prepare_date_meta_values( $meta_types, $meta_value );
724
725 return $meta_types;
726 }
727
728 /**
729 * Checks if a meta_value is a valid date and prepare extra meta-data.
730 *
731 * @param array $meta_types Array of currently prepared data
732 * @param string $meta_value Meta value to prepare.
733 *
734 * @return array
735 */
736 public function prepare_date_meta_values( $meta_types, $meta_value ) {
737
738 if ( empty( $meta_value ) || ! is_string( $meta_value ) ) {
739 return $meta_types;
740 }
741
742 $meta_types['date'] = '1970-01-01';
743 $meta_types['datetime'] = '1970-01-01 00:00:01';
744 $meta_types['time'] = '00:00:01';
745
746 // is this is a recognizable date format?
747 $new_date = date_create( $meta_value, \wp_timezone() );
748 if ( $new_date ) {
749 $timestamp = $new_date->getTimestamp();
750
751 /**
752 * Filter the maximum year limit for date conversion.
753 *
754 * Use default date if year is greater than max limit. EP has limitation that doesn't allow to have year greater than 2099.
755 *
756 * @see https://github.com/10up/ElasticPress/issues/2769
757 *
758 * @hook ep_max_year_limit
759 * @param {int} $year Maximum year limit.
760 * @return {int} Maximum year limit.
761 * @since 4.2.1
762 */
763 $max_year = apply_filters( 'ep_max_year_limit', 2099 );
764
765 // PHP allows DateTime to build dates with the non-existing year 0000, and this causes
766 // issues when integrating into stricter systems. This is by design:
767 // https://bugs.php.net/bug.php?id=60288
768 if ( false !== $timestamp && '0000' !== $new_date->format( 'Y' ) && $new_date->format( 'Y' ) <= $max_year ) {
769 $meta_types['date'] = $new_date->format( 'Y-m-d' );
770 $meta_types['datetime'] = $new_date->format( 'Y-m-d H:i:s' );
771 $meta_types['time'] = $new_date->format( 'H:i:s' );
772 }
773 }
774
775 return $meta_types;
776 }
777
778 /**
779 * Build Elasticsearch filter query for WP meta_query
780 *
781 * @since 2.2
782 * @param array $meta_queries Array of queries
783 * @return array
784 */
785 public function build_meta_query( $meta_queries ) {
786 $meta_filter = [];
787
788 $outer_relation = 'must';
789 if ( ! empty( $meta_queries['relation'] ) && 'or' === strtolower( $meta_queries['relation'] ) ) {
790 $outer_relation = 'should';
791 }
792
793 $meta_query_type_mapping = [
794 'numeric' => 'long',
795 'binary' => 'raw',
796 'char' => 'raw',
797 'date' => 'date',
798 'datetime' => 'datetime',
799 'decimal' => 'double',
800 'signed' => 'long',
801 'time' => 'time',
802 'unsigned' => 'long',
803 ];
804
805 foreach ( $meta_queries as $single_meta_query ) {
806 if ( ! empty( $single_meta_query['key'] ) ) {
807
808 $terms_obj = false;
809
810 $compare = '=';
811 if ( ! empty( $single_meta_query['compare'] ) ) {
812 $compare = strtolower( $single_meta_query['compare'] );
813 } elseif ( ! isset( $single_meta_query['value'] ) ) {
814 $compare = 'exists';
815 }
816
817 $type = null;
818 if ( ! empty( $single_meta_query['type'] ) ) {
819 $type = strtolower( $single_meta_query['type'] );
820 }
821
822 // Comparisons need to look at different paths
823 if ( in_array( $compare, array( 'exists', 'not exists' ), true ) ) {
824 $meta_key_path = 'meta.' . $single_meta_query['key'];
825 } elseif ( in_array( $compare, array( '=', '!=' ), true ) && ! $type ) {
826 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.raw';
827 } elseif ( in_array( $compare, array( 'like', 'not like' ), true ) ) {
828 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.value';
829 } elseif ( $type && isset( $meta_query_type_mapping[ $type ] ) ) {
830 // Map specific meta field types to different Elasticsearch core types
831 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.' . $meta_query_type_mapping[ $type ];
832 } elseif ( in_array( $compare, array( '>=', '<=', '>', '<', 'between', 'not between' ), true ) ) {
833 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.double';
834 } else {
835 $meta_key_path = 'meta.' . $single_meta_query['key'] . '.raw';
836 }
837
838 switch ( $compare ) {
839 case 'not in':
840 case '!=':
841 if ( isset( $single_meta_query['value'] ) ) {
842 $terms_obj = array(
843 'bool' => array(
844 'must_not' => array(
845 array(
846 'terms' => array(
847 $meta_key_path => (array) $single_meta_query['value'],
848 ),
849 ),
850 ),
851 ),
852 );
853 }
854
855 break;
856 case 'exists':
857 $terms_obj = array(
858 'exists' => array(
859 'field' => $meta_key_path,
860 ),
861 );
862
863 break;
864 case 'not exists':
865 $terms_obj = array(
866 'bool' => array(
867 'must_not' => array(
868 array(
869 'exists' => array(
870 'field' => $meta_key_path,
871 ),
872 ),
873 ),
874 ),
875 );
876
877 break;
878 case '>=':
879 if ( isset( $single_meta_query['value'] ) ) {
880 $terms_obj = array(
881 'bool' => array(
882 'must' => array(
883 array(
884 'range' => array(
885 $meta_key_path => array(
886 'gte' => $single_meta_query['value'],
887 ),
888 ),
889 ),
890 ),
891 ),
892 );
893 }
894
895 break;
896 case 'between':
897 if ( isset( $single_meta_query['value'] ) && is_array( $single_meta_query['value'] ) && 2 === count( $single_meta_query['value'] ) ) {
898 $terms_obj = array(
899 'bool' => array(
900 'must' => array(
901 array(
902 'range' => array(
903 $meta_key_path => array(
904 'gte' => $single_meta_query['value'][0],
905 ),
906 ),
907 ),
908 array(
909 'range' => array(
910 $meta_key_path => array(
911 'lte' => $single_meta_query['value'][1],
912 ),
913 ),
914 ),
915 ),
916 ),
917 );
918 }
919
920 break;
921 case 'not between':
922 if ( isset( $single_meta_query['value'] ) && is_array( $single_meta_query['value'] ) && 2 === count( $single_meta_query['value'] ) ) {
923 $terms_obj = array(
924 'bool' => array(
925 'should' => array(
926 array(
927 'range' => array(
928 $meta_key_path => array(
929 'lte' => $single_meta_query['value'][0],
930 ),
931 ),
932 ),
933 array(
934 'range' => array(
935 $meta_key_path => array(
936 'gte' => $single_meta_query['value'][1],
937 ),
938 ),
939 ),
940 ),
941 ),
942 );
943 }
944
945 break;
946 case '<=':
947 if ( isset( $single_meta_query['value'] ) ) {
948 $terms_obj = array(
949 'bool' => array(
950 'must' => array(
951 array(
952 'range' => array(
953 $meta_key_path => array(
954 'lte' => $single_meta_query['value'],
955 ),
956 ),
957 ),
958 ),
959 ),
960 );
961 }
962
963 break;
964 case '>':
965 if ( isset( $single_meta_query['value'] ) ) {
966 $terms_obj = array(
967 'bool' => array(
968 'must' => array(
969 array(
970 'range' => array(
971 $meta_key_path => array(
972 'gt' => $single_meta_query['value'],
973 ),
974 ),
975 ),
976 ),
977 ),
978 );
979 }
980
981 break;
982 case '<':
983 if ( isset( $single_meta_query['value'] ) ) {
984 $terms_obj = array(
985 'bool' => array(
986 'must' => array(
987 array(
988 'range' => array(
989 $meta_key_path => array(
990 'lt' => $single_meta_query['value'],
991 ),
992 ),
993 ),
994 ),
995 ),
996 );
997 }
998
999 break;
1000 case 'like':
1001 if ( isset( $single_meta_query['value'] ) ) {
1002 $terms_obj = array(
1003 'match_phrase' => array(
1004 $meta_key_path => $single_meta_query['value'],
1005 ),
1006 );
1007 }
1008 break;
1009 case 'not like':
1010 if ( isset( $single_meta_query['value'] ) ) {
1011 $terms_obj = array(
1012 'bool' => array(
1013 'must_not' => array(
1014 array(
1015 'match_phrase' => array(
1016 $meta_key_path => $single_meta_query['value'],
1017 ),
1018 ),
1019 ),
1020 ),
1021 );
1022 }
1023 break;
1024 case '=':
1025 default:
1026 if ( isset( $single_meta_query['value'] ) ) {
1027 $terms_obj = array(
1028 'terms' => array(
1029 $meta_key_path => (array) $single_meta_query['value'],
1030 ),
1031 );
1032 }
1033
1034 break;
1035 }
1036
1037 // Add the meta query filter
1038 if ( false !== $terms_obj ) {
1039 $meta_filter[] = $terms_obj;
1040 }
1041 } elseif ( is_array( $single_meta_query ) ) {
1042 /**
1043 * Handle multidimensional array. Something like:
1044 *
1045 * 'meta_query' => array(
1046 * 'relation' => 'AND',
1047 * array(
1048 * 'key' => 'meta_key_1',
1049 * 'value' => '1',
1050 * ),
1051 * array(
1052 * 'relation' => 'OR',
1053 * array(
1054 * 'key' => 'meta_key_2',
1055 * 'value' => '2',
1056 * ),
1057 * array(
1058 * 'key' => 'meta_key_3',
1059 * 'value' => '4',
1060 * ),
1061 * ),
1062 * ),
1063 */
1064 $inner_relation = 'must';
1065 if ( ! empty( $single_meta_query['relation'] ) && 'or' === strtolower( $single_meta_query['relation'] ) ) {
1066 $inner_relation = 'should';
1067 }
1068
1069 $meta_filter[] = array(
1070 'bool' => array(
1071 $inner_relation => $this->build_meta_query( $single_meta_query ),
1072 ),
1073 );
1074 }
1075 }
1076
1077 if ( ! empty( $meta_filter ) ) {
1078 return [
1079 'bool' => [
1080 $outer_relation => $meta_filter,
1081 ],
1082 ];
1083 } else {
1084 return false;
1085 }
1086 }
1087
1088 /**
1089 * Get the indexable mapping.
1090 *
1091 * @since 3.6.0
1092 * @return boolean|array
1093 */
1094 public function get_mapping() {
1095 return Elasticsearch::factory()->get_mapping( $this->get_index_name() );
1096 }
1097
1098 /**
1099 * Compare the mapping generated by the plugin and the mapping stored in Elasticsearch.
1100 *
1101 * @todo properly implement the check.
1102 *
1103 * @since 3.6.0
1104 * @return bool|WP_Error
1105 */
1106 public function compare_mappings() {
1107 if ( ! method_exists( $this, 'generate_mapping' ) ) {
1108 return new \WP_Error( 'ep_generate_mapping_not_implemented' );
1109 }
1110
1111 $new_mapping = $this->generate_mapping();
1112 $stored_mapping = $this->get_mapping();
1113
1114 return ( (string) $new_mapping['settings']['index.number_of_shards'] === $stored_mapping[ $this->get_index_name() ]['settings']['index']['number_of_shards'] );
1115 }
1116
1117 /**
1118 * Utilitary function to check if the indexable is being fully reindexed, i.e.,
1119 * the index was deleted, a new mapping was sent and content is being reindexed.
1120 *
1121 * @param int|null $blog_id Blog ID
1122 * @return boolean
1123 */
1124 public function is_full_reindexing( $blog_id = null ) {
1125 if ( $this->global ) {
1126 $blog_id = null;
1127 } elseif ( ! $blog_id ) {
1128 $blog_id = get_current_blog_id();
1129 }
1130
1131 return \ElasticPress\IndexHelper::factory()->is_full_reindexing( $this->slug, $blog_id );
1132 }
1133
1134 /**
1135 * Send mapping to Elasticsearch
1136 *
1137 * @param string $return_type Desired return type. Can be either 'bool' or 'raw'
1138 * @return bool|WP_Error
1139 */
1140 public function put_mapping( $return_type = 'bool' ) {
1141 $mapping = $this->generate_mapping();
1142
1143 return Elasticsearch::factory()->put_mapping( $this->get_index_name(), $mapping, $return_type );
1144 }
1145
1146 /**
1147 * Must implement a method that given an object ID, returns a formatted Elasticsearch
1148 * document
1149 *
1150 * @param int $object_id Object to prepare.
1151 * @return array
1152 */
1153 abstract public function prepare_document( $object_id );
1154
1155 /**
1156 * Must implement a method that queries MySQL for objects and returns them
1157 * in a standardized format. This is necessary so we can genericize the index
1158 * process across indexables.
1159 *
1160 * @param array $args Array to query DB against.
1161 * @return array
1162 */
1163 abstract public function query_db( $args );
1164
1165 /**
1166 * Shim function for backwards-compatibility on custom Indexables.
1167 *
1168 * @since 4.1.0
1169 * @return array
1170 */
1171 public function generate_mapping() {
1172 _doing_it_wrong( __METHOD__, 'The Indexable class should not call generate_mapping() directly.', 'ElasticPress 4.0' );
1173
1174 return [];
1175 }
1176
1177 /**
1178 * Get the search algorithm that should be used.
1179 *
1180 * @since 4.3.0
1181 * @param string $search_text Search term(s)
1182 * @param array $search_fields Search fields
1183 * @param array $query_vars Query vars
1184 * @return SearchAlgorithm Instance of search algorithm to be used
1185 */
1186 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
1187 /**
1188 * Filter the search algorithm to be used
1189 *
1190 * @hook ep_{$indexable_slug}_search_algorithm
1191 * @since 4.3.0
1192 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1193 * @param {string} $search_term Search term
1194 * @param {array} $search_fields Fields to be searched
1195 * @param {array} $query_vars Query variables
1196 * @return {string} New search algorithm slug
1197 */
1198 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", 'basic', $search_text, $search_fields, $query_vars );
1199
1200 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1201 }
1202
1203 /**
1204 * Get all distinct meta field keys.
1205 *
1206 * @since 4.3.0
1207 * @param null|int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1208 * @return array
1209 * @throws \Exception An exception if meta fields are not available.
1210 */
1211 public function get_distinct_meta_field_keys( $blog_id = null ) {
1212 $mapping = $this->get_mapping();
1213
1214 try {
1215 if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
1216 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['post']['properties']['meta']['properties'];
1217 } else {
1218 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['properties']['meta']['properties'];
1219 }
1220 $meta_keys = array_values( array_keys( $meta_fields ) );
1221 sort( $meta_keys );
1222 } catch ( \Throwable $th ) {
1223 throw new \Exception( 'Meta fields not available.', 0 );
1224 }
1225
1226 return $meta_keys;
1227 }
1228
1229 /**
1230 * Get all distinct values for a given field.
1231 *
1232 * @since 4.3.0
1233 * @param string $field Field full name. For example: `meta.name.raw`
1234 * @param int $count (Optional) Max number of different distinct values to be returned
1235 * @param int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1236 * @return array
1237 */
1238 public function get_all_distinct_values( $field, $count = 10000, $blog_id = null ) {
1239 $aggregation_name = 'distinct_values';
1240
1241 $es_query = [
1242 '_source' => false,
1243 'size' => 0,
1244 'aggs' => [
1245 $aggregation_name => [
1246 'terms' => [
1247 /**
1248 * Filter the max. number of different distinct values to be returned by Elasticsearch.
1249 *
1250 * @since 4.3.0
1251 * @hook ep_{$indexable_slug}_all_distinct_values
1252 * @param {int} $size The number of different values. Default: 10000
1253 * @param {string} $field The meta field
1254 * @return {string} The new number of different values
1255 */
1256 'size' => apply_filters( 'ep_' . $this->slug . '_all_distinct_values', $count, $field ),
1257 'field' => $field,
1258 ],
1259 ],
1260 ],
1261 ];
1262
1263 $response = Elasticsearch::factory()->query( $this->get_index_name( $blog_id ), $this->slug, $es_query, [] );
1264
1265 if ( ! $response || empty( $response['aggregations'] ) || empty( $response['aggregations'][ $aggregation_name ] ) || empty( $response['aggregations'][ $aggregation_name ]['buckets'] ) ) {
1266 return [];
1267 }
1268
1269 $values = [];
1270 foreach ( $response['aggregations'][ $aggregation_name ]['buckets'] as $es_bucket ) {
1271 $values[] = $es_bucket['key'];
1272 }
1273
1274 return $values;
1275 }
1276
1277 /**
1278 * Should instantiate the indexable SyncManager and QueryIntegration, the main responsibles for the WP integration.
1279 *
1280 * @since 4.5.0
1281 */
1282 public function setup() {}
1283
1284 /**
1285 * Given a mapping, add the ngram analyzer to it
1286 *
1287 * @since 4.5.0
1288 * @param array $mapping The mapping
1289 * @return array
1290 */
1291 public function add_ngram_analyzer( array $mapping ) : array {
1292 $mapping['settings']['analysis']['analyzer']['edge_ngram_analyzer'] = array(
1293 'type' => 'custom',
1294 'tokenizer' => 'standard',
1295 'filter' => array(
1296 'lowercase',
1297 'edge_ngram',
1298 ),
1299 );
1300
1301 return $mapping;
1302 }
1303 }
1304