PluginProbe
ElasticPress / 4.4.0
ElasticPress v4.4.0
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.4.0, at includes/classes/Indexable.php

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