PluginProbe
ElasticPress / 4.5.1
ElasticPress v4.5.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.5.1, at includes/classes/Indexable.php

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