PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Indexable.php

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

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