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

1,275 lines 35.4 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
782 /**
783 * There is a strange case where meta_query looks like this:
784 * array(
785 * "something" => array(
786 * array(
787 * 'key' => ...
788 * ...
789 * )
790 * )
791 * )
792 *
793 * Somehow WordPress (WooCommerce) handles that case so we need to as well.
794 *
795 * @since 2.1
796 */
797 if ( is_array( $single_meta_query ) && empty( $single_meta_query['key'] ) ) {
798 reset( $single_meta_query );
799 $first_key = key( $single_meta_query );
800
801 if ( is_array( $single_meta_query[ $first_key ] ) ) {
802 $single_meta_query = $single_meta_query[ $first_key ];
803 }
804 }
805
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 * @return boolean
1138 */
1139 public function put_mapping() {
1140 $mapping = $this->generate_mapping();
1141
1142 return Elasticsearch::factory()->put_mapping( $this->get_index_name(), $mapping );
1143 }
1144
1145 /**
1146 * Must implement a method that given an object ID, returns a formatted Elasticsearch
1147 * document
1148 *
1149 * @param int $object_id Object to prepare.
1150 * @return array
1151 */
1152 abstract public function prepare_document( $object_id );
1153
1154 /**
1155 * Must implement a method that queries MySQL for objects and returns them
1156 * in a standardized format. This is necessary so we can genericize the index
1157 * process across indexables.
1158 *
1159 * @param array $args Array to query DB against.
1160 * @return boolean
1161 */
1162 abstract public function query_db( $args );
1163
1164 /**
1165 * Shim function for backwards-compatibility on custom Indexables.
1166 *
1167 * @since 4.1.0
1168 * @return array
1169 */
1170 public function generate_mapping() {
1171 _doing_it_wrong( __METHOD__, 'The Indexable class should not call generate_mapping() directly.', 'ElasticPress 4.0' );
1172
1173 return [];
1174 }
1175
1176 /**
1177 * Get the search algorithm that should be used.
1178 *
1179 * @since 4.3.0
1180 * @param string $search_text Search term(s)
1181 * @param array $search_fields Search fields
1182 * @param array $query_vars Query vars
1183 * @return SearchAlgorithm Instance of search algorithm to be used
1184 */
1185 public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ) : \ElasticPress\SearchAlgorithm {
1186 /**
1187 * Filter the search algorithm to be used
1188 *
1189 * @hook ep_{$indexable_slug}_search_algorithm
1190 * @since 4.3.0
1191 * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1192 * @param {string} $search_term Search term
1193 * @param {array} $search_fields Fields to be searched
1194 * @param {array} $query_vars Query variables
1195 * @return {string} New search algorithm slug
1196 */
1197 $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", 'basic', $search_text, $search_fields, $query_vars );
1198
1199 return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1200 }
1201
1202 /**
1203 * Get all distinct meta field keys.
1204 *
1205 * @since 4.3.0
1206 * @param null|int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1207 * @return array
1208 */
1209 public function get_distinct_meta_field_keys( $blog_id = null ) {
1210 $mapping = $this->get_mapping();
1211
1212 try {
1213 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
1214 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['post']['properties']['meta']['properties'];
1215 } else {
1216 $meta_fields = $mapping[ $this->get_index_name( $blog_id ) ]['mappings']['properties']['meta']['properties'];
1217 }
1218 $meta_keys = array_values( array_keys( $meta_fields ) );
1219 sort( $meta_keys );
1220 } catch ( \Throwable $th ) {
1221 return new \Exception( 'Meta fields not available.', 0 );
1222 }
1223
1224 return $meta_keys;
1225 }
1226
1227 /**
1228 * Get all distinct values for a given field.
1229 *
1230 * @since 4.3.0
1231 * @param string $field Field full name. For example: `meta.name.raw`
1232 * @param int $count (Optional) Max number of different distinct values to be returned
1233 * @param int $blog_id (Optional) The blog ID. Sending `null` will use the current blog ID.
1234 * @return array
1235 */
1236 public function get_all_distinct_values( $field, $count = 10000, $blog_id = null ) {
1237 $aggregation_name = 'distinct_values';
1238
1239 $es_query = [
1240 '_source' => false,
1241 'size' => 0,
1242 'aggs' => [
1243 $aggregation_name => [
1244 'terms' => [
1245 /**
1246 * Filter the max. number of different distinct values to be returned by Elasticsearch.
1247 *
1248 * @since 4.3.0
1249 * @hook ep_{$indexable_slug}_all_distinct_values
1250 * @param {int} $size The number of different values. Default: 10000
1251 * @param {string} $field The meta field
1252 * @return {string} The new number of different values
1253 */
1254 'size' => apply_filters( 'ep_' . $this->slug . '_all_distinct_values', $count, $field ),
1255 'field' => $field,
1256 ],
1257 ],
1258 ],
1259 ];
1260
1261 $response = Elasticsearch::factory()->query( $this->get_index_name( $blog_id ), $this->slug, $es_query, [] );
1262
1263 if ( ! $response || empty( $response['aggregations'] ) || empty( $response['aggregations'][ $aggregation_name ] ) || empty( $response['aggregations'][ $aggregation_name ]['buckets'] ) ) {
1264 return [];
1265 }
1266
1267 $values = [];
1268 foreach ( $response['aggregations'][ $aggregation_name ]['buckets'] as $es_bucket ) {
1269 $values[] = $es_bucket['key'];
1270 }
1271
1272 return $values;
1273 }
1274 }
1275