PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
← All changes | includes/classes/Indexable/Post/Post.php +1834 -1013 4.2.25.3.5 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * User indexable
3 + * Post indexable
4 4 *
5 5 * @since 3.0
6 6 * @package elasticpress
7 7 */
@@ -7,12 +7,12 @@
7 7 */
8 8
9 9 namespace ElasticPress\Indexable\Post;
10 10
11 -use ElasticPress\Indexable as Indexable;
12 -use ElasticPress\Elasticsearch as Elasticsearch;
13 -use \WP_Query as WP_Query;
14 -use \WP_User as WP_User;
11 +use WP_Query;
12 +use WP_User;
13 +use ElasticPress\Elasticsearch;
14 +use ElasticPress\Indexable;
15 15
16 16 if ( ! defined( 'ABSPATH' ) ) {
17 17 // @codeCoverageIgnoreStart
18 18 exit; // Exit if accessed directly.
@@ -41,13 +41,14 @@
41 41 */
42 42 public $support_indexing_advanced_pagination = true;
43 43
44 44 /**
45 - * Create indexable and initialize dependencies
45 + * Instantiate the indexable SyncManager and QueryIntegration
46 46 *
47 - * @since 3.0
47 + * @since 5.2.0
48 + * @return void
48 49 */
49 - public function __construct() {
50 + public function setup() {
50 51 $this->labels = [
51 52 'plural' => esc_html__( 'Posts', 'elasticpress' ),
52 53 'singular' => esc_html__( 'Post', 'elasticpress' ),
53 54 ];
@@ -102,8 +103,11 @@
102 103 // Disable advanced pagination. Not useful if only indexing specific IDs.
103 104 $args['ep_indexing_advanced_pagination'] = false;
104 105 }
105 106
107 + // Explicitly set the orderby to ID to prevent accidental modifications by other code.
108 + add_filter( 'posts_orderby', [ $this, 'set_posts_orderby' ], 9999, 2 );
109 +
106 110 // Enforce the following query args during advanced pagination to ensure things work correctly.
107 111 if ( $args['ep_indexing_advanced_pagination'] ) {
108 112 $args = array_merge(
109 113 $args,
@@ -115,19 +119,21 @@
115 119 'offset' => 0,
116 120 'no_found_rows' => true,
117 121 ]
118 122 );
119 - add_filter( 'posts_where', array( $this, 'bulk_indexing_filter_posts_where' ), 9999, 2 );
123 + add_filter( 'posts_where', [ $this, 'bulk_indexing_filter_posts_where' ], 9999, 2 );
120 124
121 125 $query = new WP_Query( $args );
122 126 $total_objects = $this->get_total_objects_for_query( $args );
123 127
124 - remove_filter( 'posts_where', array( $this, 'bulk_indexing_filter_posts_where' ), 9999, 2 );
128 + remove_filter( 'posts_where', [ $this, 'bulk_indexing_filter_posts_where' ], 9999, 2 );
125 129 } else {
126 130 $query = new WP_Query( $args );
127 131 $total_objects = $query->found_posts;
128 132 }
129 133
134 + remove_filter( 'posts_orderby', [ $this, 'set_posts_orderby' ], 9999, 2 );
135 +
130 136 return [
131 137 'objects' => $query->posts,
132 138 'total_objects' => $total_objects,
133 139 ];
@@ -132,16 +138,18 @@
132 138 'total_objects' => $total_objects,
133 139 ];
134 140 }
135 141
136 - /**
137 - * Manipulate the WHERE clause of the bulk indexing query to paginate by ID in order to avoid performance issues with SQL offset.
138 - *
139 - * @param string $where The current $where clause.
140 - * @param WP_Query $query WP_Query object.
141 - * @return string WHERE clause with our pagination added if needed.
142 - */
142 + /**
143 + * Manipulate the WHERE clause of the bulk indexing query to paginate by ID in order to avoid performance issues with SQL offset.
144 + *
145 + * @param string $where The current $where clause.
146 + * @param WP_Query $query WP_Query object.
147 + * @return string WHERE clause with our pagination added if needed.
148 + */
143 149 public function bulk_indexing_filter_posts_where( $where, $query ) {
150 + global $wpdb;
151 +
144 152 $using_advanced_pagination = $query->get( 'ep_indexing_advanced_pagination', false );
145 153
146 154 if ( $using_advanced_pagination ) {
147 155 $requested_upper_limit_id = $query->get( 'ep_indexing_upper_limit_object_id', PHP_INT_MAX );
@@ -159,10 +167,10 @@
159 167 return $where;
160 168 }
161 169
162 170 $range = [
163 - 'upper_limit' => "{$GLOBALS['wpdb']->posts}.ID <= {$upper_limit_range_post_id}",
164 - 'lower_limit' => "{$GLOBALS['wpdb']->posts}.ID >= {$requested_lower_limit_post_id}",
171 + 'upper_limit' => "{$wpdb->posts}.ID <= {$upper_limit_range_post_id}",
172 + 'lower_limit' => "{$wpdb->posts}.ID >= {$requested_lower_limit_post_id}",
165 173 ];
166 174
167 175 // Skip the end range if it's unnecessary.
168 176 $skip_ending_range = 0 === $requested_lower_limit_post_id;
@@ -218,8 +226,10 @@
218 226 * @since 4.0.0
219 227 * @return int The total posts.
220 228 */
221 229 protected function get_total_objects_for_query_from_db( $query_args ) {
230 + global $wpdb;
231 +
222 232 $post_count = 0;
223 233
224 234 if ( ! isset( $query_args['post_type'] ) || isset( $query_args['ep_indexing_upper_limit_object_id'] )
225 235 || isset( $query_args['ep_indexing_lower_limit_object_id'] ) ) {
@@ -235,8 +245,20 @@
235 245 $post_count += $post_status_count;
236 246 }
237 247 }
238 248
249 + /**
250 + * As `wp_count_posts` will also count posts with password, we need to remove
251 + * them from the final count if they will not be used.
252 + *
253 + * The if below will pass if `has_password` is false but not null.
254 + */
255 + if ( isset( $query_args['has_password'] ) && ! $query_args['has_password'] ) {
256 + $posts_with_password = (int) $wpdb->get_var( "SELECT COUNT(1) AS posts_with_password FROM {$wpdb->posts} WHERE post_password != ''" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
257 +
258 + $post_count -= $posts_with_password;
259 + }
260 +
239 261 return $post_count;
240 262 }
241 263
242 264 /**
@@ -300,19 +322,14 @@
300 322 * @return {string} New version
301 323 */
302 324 $es_version = apply_filters( 'ep_fallback_elasticsearch_version', '2.0' );
303 325 }
326 + $es_version = (string) $es_version;
304 327
305 - $mapping_file = '5-2.php';
328 + $mapping_file = '7-0.php';
306 329
307 - if ( ! $es_version || version_compare( $es_version, '5.0' ) < 0 ) {
308 - $mapping_file = 'pre-5-0.php';
309 - } elseif ( version_compare( $es_version, '5.0', '>=' ) && version_compare( $es_version, '5.2', '<' ) ) {
310 - $mapping_file = '5-0.php';
311 - } elseif ( version_compare( $es_version, '5.2', '>=' ) && version_compare( $es_version, '7.0', '<' ) ) {
330 + if ( version_compare( $es_version, '7.0', '<' ) ) {
312 331 $mapping_file = '5-2.php';
313 - } elseif ( version_compare( $es_version, '7.0', '>=' ) ) {
314 - $mapping_file = '7-0.php';
315 332 }
316 333
317 334 return apply_filters( 'ep_post_mapping_version', $mapping_file );
318 335 }
@@ -405,9 +422,11 @@
405 422 * @since 0.9.1
406 423 * @return bool|array
407 424 */
408 425 public function prepare_document( $post_id ) {
426 + global $post;
409 427 $post = get_post( $post_id );
428 + setup_postdata( $post );
410 429
411 430 if ( empty( $post ) ) {
412 431 return false;
413 432 }
@@ -436,9 +455,9 @@
436 455 $post_modified_gmt = $post->post_modified_gmt;
437 456 $comment_count = absint( $post->comment_count );
438 457 $comment_status = $post->comment_status;
439 458 $ping_status = $post->ping_status;
440 - $menu_order = absint( $post->menu_order );
459 + $menu_order = (int) $post->menu_order;
441 460
442 461 /**
443 462 * Filter to ignore invalid dates
444 463 *
@@ -548,11 +567,8 @@
548 567
549 568 /**
550 569 * Filters the image size to use when indexing the post thumbnail.
551 570 *
552 - * Defaults to the `woocommerce_thumbnail` size if WooCommerce is in
553 - * use. Otherwise the `thumbnail` size is used.
554 - *
555 571 * @hook ep_thumbnail_image_size
556 572 * @since 4.0.0
557 573 * @param {string|int[]} $image_size Image size. Can be any registered
558 574 * image size name, or an array of
@@ -562,25 +578,31 @@
562 578 * @return {array} Image size to pass to wp_get_attachment_image_src().
563 579 */
564 580 $image_size = apply_filters(
565 581 'ep_post_thumbnail_image_size',
566 - function_exists( 'WC' ) ? 'woocommerce_thumbnail' : 'thumbnail',
582 + 'medium',
567 583 $post
568 584 );
569 585
570 - $image_src = wp_get_attachment_image_src( $attachment_id, $image_size );
586 + $image = wp_get_attachment_image_src( $attachment_id, $image_size );
571 587 $image_alt = trim( wp_strip_all_tags( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) );
572 588
573 - if ( ! $image_src ) {
589 + if ( ! $image ) {
574 590 return null;
575 591 }
576 592
593 + list( $src, $width, $height ) = $image;
594 +
595 + $image_meta = wp_get_attachment_metadata( $attachment_id );
596 + $srcset = wp_calculate_image_srcset( [ absint( $width ), absint( $height ) ], $src, $image_meta, $attachment_id );
597 +
577 598 return [
578 599 'ID' => $attachment_id,
579 - 'src' => $image_src[0],
580 - 'width' => $image_src[1],
581 - 'height' => $image_src[2],
600 + 'src' => $src,
601 + 'width' => $width,
602 + 'height' => $height,
582 603 'alt' => $image_alt,
604 + 'srcset' => $srcset,
583 605 ];
584 606 }
585 607
586 608 /**
@@ -585,9 +607,9 @@
585 607
586 608 /**
587 609 * Prepare date terms to send to ES.
588 610 *
589 - * @param string $date_to_prepare Post date
611 + * @param null|string $date_to_prepare Post date
590 612 * @since 0.1.4
591 613 * @return array
592 614 */
593 615 public function prepare_date_terms( $date_to_prepare ) {
@@ -606,9 +628,9 @@
606 628 ];
607 629
608 630 // Combine all the date term formats and perform one single call to date_i18n() for performance.
609 631 $date_format = implode( '||', array_values( $terms_to_prepare ) );
610 - $combined_dates = explode( '||', date_i18n( $date_format, strtotime( $date_to_prepare ) ) );
632 + $combined_dates = explode( '||', date_i18n( $date_format, strtotime( (string) $date_to_prepare ) ) );
611 633
612 634 // Then split up the results for individual indexing.
613 635 $date_terms = [];
614 636 foreach ( $terms_to_prepare as $term_name => $date_format ) {
@@ -700,19 +722,10 @@
700 722 $terms_dic = [];
701 723
702 724 foreach ( $object_terms as $term ) {
703 725 if ( ! isset( $terms_dic[ $term->term_id ] ) ) {
704 - $terms_dic[ $term->term_id ] = array(
705 - 'term_id' => $term->term_id,
706 - 'slug' => $term->slug,
707 - 'name' => $term->name,
708 - 'parent' => $term->parent,
709 - 'term_taxonomy_id' => $term->term_taxonomy_id,
710 - 'term_order' => (int) $this->get_term_order( $term->term_taxonomy_id, $post->ID ),
711 - );
726 + $terms_dic[ $term->term_id ] = $this->get_formatted_term( $term, $post->ID );
712 727
713 - $terms_dic[ $term->term_id ]['facet'] = wp_json_encode( $terms_dic[ $term->term_id ] );
714 -
715 728 if ( $allow_hierarchy ) {
716 729 $terms_dic = $this->get_parent_terms( $terms_dic, $term, $taxonomy->name, $post->ID );
717 730 }
718 731 }
@@ -738,26 +751,47 @@
738 751 if ( ! $parent_term || is_wp_error( $parent_term ) ) {
739 752 return $terms;
740 753 }
741 754 if ( ! isset( $terms[ $parent_term->term_id ] ) ) {
742 - $terms[ $parent_term->term_id ] = array(
743 - 'term_id' => $parent_term->term_id,
744 - 'slug' => $parent_term->slug,
745 - 'name' => $parent_term->name,
746 - 'parent' => $parent_term->parent,
747 - 'term_taxonomy_id' => $parent_term->term_taxonomy_id,
748 - 'term_order' => $this->get_term_order( $parent_term->term_taxonomy_id, $object_id ),
749 - );
755 + $terms[ $parent_term->term_id ] = $this->get_formatted_term( $parent_term, $object_id );
750 756
751 - $terms[ $parent_term->term_id ]['facet'] = wp_json_encode( $terms[ $parent_term->term_id ] );
752 -
753 757 }
754 758 return $this->get_parent_terms( $terms, $parent_term, $tax_name, $object_id );
755 759 }
756 760
757 761 /**
758 - * Retreives term order for the object/term_taxonomy_id combination
762 + * Given a term, format it to be appended to the post ES document.
759 763 *
764 + * @since 4.5.0
765 + * @param \WP_Term $term Term to be formatted
766 + * @param int $post_id The post ID
767 + * @return array
768 + */
769 + private function get_formatted_term( \WP_Term $term, int $post_id ): array {
770 + $formatted_term = [
771 + 'term_id' => $term->term_id,
772 + 'slug' => $term->slug,
773 + 'name' => $term->name,
774 + 'parent' => $term->parent,
775 + 'term_taxonomy_id' => $term->term_taxonomy_id,
776 + 'term_order' => (int) $this->get_term_order( $term->term_taxonomy_id, $post_id ),
777 + ];
778 +
779 + /**
780 + * As the name implies, the facet attribute is used to list all terms in facets.
781 + * As in facets, the term_order associated with a post does not matter, we set it as 0 here.
782 + * Note that this is set as 0 instead of simply removed to keep backward compatibility.
783 + */
784 + $term_facet = $formatted_term;
785 + $term_facet['term_order'] = 0;
786 + $formatted_term['facet'] = wp_json_encode( $term_facet );
787 +
788 + return $formatted_term;
789 + }
790 +
791 + /**
792 + * Retrieves term order for the object/term_taxonomy_id combination
793 + *
760 794 * @param int $term_taxonomy_id Term Taxonomy ID
761 795 * @param int $object_id Post ID
762 796 *
763 797 * @return int Term Order
@@ -768,9 +802,9 @@
768 802 $cache_key = "{$object_id}_term_order";
769 803 $term_orders = wp_cache_get( $cache_key );
770 804
771 805 if ( false === $term_orders ) {
772 - $results = $wpdb->get_results(
806 + $results = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
773 807 $wpdb->prepare(
774 808 "SELECT term_taxonomy_id, term_order from $wpdb->term_relationships where object_id=%d;",
775 809 $object_id
776 810 ),
@@ -786,12 +820,50 @@
786 820 wp_cache_set( $cache_key, $term_orders );
787 821 }
788 822
789 823 return isset( $term_orders[ $term_taxonomy_id ] ) ? (int) $term_orders[ $term_taxonomy_id ] : 0;
824 + }
790 825
826 + /**
827 + * Checks if meta key is allowed
828 + *
829 + * @param string $meta_key meta key to check
830 + * @param WP_Post $post Post object
831 + * @since 4.3.0
832 + * @return boolean
833 + */
834 + public function is_meta_allowed( $meta_key, $post ) {
835 + $test_metas = [
836 + $meta_key => true,
837 + ];
838 +
839 + $filtered_test_metas = $this->filter_allowed_metas( $test_metas, $post );
840 +
841 + return array_key_exists( $meta_key, $filtered_test_metas );
791 842 }
792 843
793 844 /**
845 + * Filter post meta to only the allowed ones to be send to ES
846 + *
847 + * @param array $metas Key => value pairs of post meta
848 + * @param WP_Post $post Post object
849 + * @since 4.3.0
850 + * @return array
851 + */
852 + public function filter_allowed_metas( $metas, $post ) {
853 + $filtered_metas = [];
854 +
855 + $search = \ElasticPress\Features::factory()->get_registered_feature( 'search' );
856 + if ( $search && ! empty( $search->weighting ) && 'manual' === $search->weighting->get_meta_mode() ) {
857 + $filtered_metas = $this->filter_allowed_metas_manual( $metas, $post );
858 + } else {
859 + $filtered_metas = $this->filter_allowed_metas_auto( $metas, $post );
860 + }
861 +
862 + return $filtered_metas;
863 + }
864 +
865 + /**
794 866 * Prepare post meta to send to ES
795 867 *
796 868 * @param WP_Post $post Post object
797 869 * @since 0.1.0
@@ -820,120 +892,741 @@
820 892 */
821 893 return apply_filters( 'ep_prepared_post_meta', [], $post );
822 894 }
823 895
824 - $prepared_meta = [];
896 + $filtered_metas = $this->filter_allowed_metas( $meta, $post );
897 + $prepared_meta = [];
825 898
899 + foreach ( $filtered_metas as $key => $value ) {
900 + if ( ! empty( $key ) ) {
901 + $prepared_meta[ $key ] = maybe_unserialize( $value );
902 + }
903 + }
904 +
826 905 /**
827 - * Filter indexable protected meta keys for posts
906 + * Filter final list of prepared meta.
828 907 *
829 - * @hook ep_prepare_meta_allowed_protected_keys
830 - * @param {array} $keys Allowed protected keys
908 + * @hook ep_prepared_post_meta
909 + * @param {array} $prepared_meta Prepared meta
831 910 * @param {WP_Post} $post Post object
832 - * @since 1.7
833 - * @return {array} New keys
911 + * @since 3.4
912 + * @return {array} Prepared meta
834 913 */
835 - $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], $post );
914 + return apply_filters( 'ep_prepared_post_meta', $prepared_meta, $post );
915 + }
836 916
917 + /**
918 + * Format WP query args for ES
919 + *
920 + * @param array $args WP_Query arguments.
921 + * @param WP_Query $wp_query WP_Query object
922 + * @since 0.9.0
923 + * @return array
924 + */
925 + public function format_args( $args, $wp_query ) {
926 + $args = $this->sanitize_wp_query_args( $args );
927 +
928 + $formatted_args = [
929 + 'from' => $this->parse_from( $args ),
930 + 'size' => $this->parse_size( $args ),
931 + ];
932 +
933 + $filters = $this->parse_filters( $args, $wp_query );
934 +
935 + if ( ! empty( $filters ) ) {
936 + $formatted_args['post_filter'] = $filters;
937 + }
938 +
939 + $formatted_args = $this->maybe_set_search_fields( $formatted_args, $args );
940 + $formatted_args = $this->maybe_set_fields( $formatted_args, $args );
941 + $formatted_args = $this->maybe_orderby( $formatted_args, $args );
942 + $formatted_args = $this->maybe_add_sticky_posts( $formatted_args, $args );
943 + $formatted_args = $this->maybe_set_aggs( $formatted_args, $args, $filters );
944 +
837 945 /**
838 - * Filter public keys to exclude from indexed post
946 + * Filter formatted Elasticsearch query (entire query)
839 947 *
840 - * @hook ep_prepare_meta_excluded_public_keys
841 - * @param {array} $keys Excluded protected keys
842 - * @param {WP_Post} $post Post object
843 - * @since 1.7
844 - * @return {array} New keys
948 + * @hook ep_formatted_args
949 + * @param {array} $formatted_args Formatted Elasticsearch query
950 + * @param {array} $args WP_Query variables
951 + * @param {object} $wp_query WP_Query object
952 + * @return {array} New query
845 953 */
846 - $excluded_public_keys = apply_filters( 'ep_prepare_meta_excluded_public_keys', [], $post );
954 + $formatted_args = apply_filters( 'ep_formatted_args', $formatted_args, $args, $wp_query );
847 955
848 - foreach ( $meta as $key => $value ) {
956 + /**
957 + * Filter formatted Elasticsearch post query (entire query)
958 + *
959 + * @hook ep_post_formatted_args
960 + * @param {array} $formatted_args Formatted Elasticsearch query
961 + * @param {array} $args WP_Query variables
962 + * @param {object} $wp_query WP_Query object
963 + * @return {array} New query
964 + */
965 + $formatted_args = apply_filters( 'ep_post_formatted_args', $formatted_args, $args, $wp_query );
849 966
850 - $allow_index = false;
967 + return $formatted_args;
968 + }
851 969
852 - if ( is_protected_meta( $key ) ) {
970 + /**
971 + * Adjust the fuzziness parameter if needed.
972 + *
973 + * If using fields with type `long`, queries should not have a fuzziness parameter.
974 + *
975 + * @param array $query Current query
976 + * @param array $query_vars Query variables
977 + * @param string $search_text Search text
978 + * @param array $search_fields Search fields
979 + * @return array New query
980 + */
981 + public function adjust_query_fuzziness( $query, $query_vars, $search_text, $search_fields ) {
982 + if ( empty( array_intersect( $search_fields, [ 'ID', 'post_id', 'post_parent' ] ) ) ) {
983 + return $query;
984 + }
853 985
854 - if ( true === $allowed_protected_keys || in_array( $key, $allowed_protected_keys, true ) ) {
855 - $allow_index = true;
986 + if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['should'] ) ) {
987 + return $query;
988 + }
989 +
990 + foreach ( $query['bool']['should'] as &$clause ) {
991 + if ( ! isset( $clause['multi_match'] ) ) {
992 + continue;
993 + }
994 +
995 + if ( isset( $clause['multi_match']['fuzziness'] ) ) {
996 + unset( $clause['multi_match']['fuzziness'] );
997 + }
998 + }
999 +
1000 + return $query;
1001 + }
1002 +
1003 + /**
1004 + * Parse and build out our tax query.
1005 + *
1006 + * @access protected
1007 + *
1008 + * @param array $query Tax query
1009 + * @return array
1010 + */
1011 + protected function parse_tax_query( $query ) {
1012 + $tax_query = [
1013 + 'tax_filter' => [],
1014 + 'tax_must_not_filter' => [],
1015 + ];
1016 + $relation = '';
1017 +
1018 + foreach ( $query as $tax_queries ) {
1019 + // If we have a nested tax query, recurse through that
1020 + if ( is_array( $tax_queries ) && empty( $tax_queries['taxonomy'] ) ) {
1021 + $result = $this->parse_tax_query( $tax_queries );
1022 + $relation = ( ! empty( $tax_queries['relation'] ) ) ? strtolower( $tax_queries['relation'] ) : 'and';
1023 + $filter_type = 'and' === $relation ? 'must' : 'should';
1024 +
1025 + // Set the proper filter type and must_not filter, as needed
1026 + if ( ! empty( $result['tax_must_not_filter'] ) ) {
1027 + $tax_query['tax_filter'][] = [
1028 + 'bool' => [
1029 + $filter_type => $result['tax_filter'],
1030 + 'must_not' => $result['tax_must_not_filter'],
1031 + ],
1032 + ];
1033 + } else {
1034 + $tax_query['tax_filter'][] = [
1035 + 'bool' => [
1036 + $filter_type => $result['tax_filter'],
1037 + ],
1038 + ];
856 1039 }
1040 + }
1041 +
1042 + // Parse each individual tax query part
1043 + $single_tax_query = $tax_queries;
1044 + if ( ! empty( $single_tax_query['taxonomy'] ) ) {
1045 + $terms = isset( $single_tax_query['terms'] ) ? (array) $single_tax_query['terms'] : array();
1046 + $field = $this->parse_tax_query_field( $single_tax_query['field'] );
1047 +
1048 + if ( 'slug' === $field ) {
1049 + $terms = array_map( 'sanitize_title', $terms );
1050 + }
1051 +
1052 + // Set up our terms object
1053 + $terms_obj = array(
1054 + 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => array_values( array_filter( $terms ) ),
1055 + );
1056 +
1057 + $operator = ( ! empty( $single_tax_query['operator'] ) ) ? strtolower( $single_tax_query['operator'] ) : 'in';
1058 +
1059 + switch ( $operator ) {
1060 + case 'exists':
1061 + /**
1062 + * add support for "EXISTS" operator
1063 + *
1064 + * @since 2.5
1065 + */
1066 + $tax_query['tax_filter'][]['bool'] = array(
1067 + 'must' => array(
1068 + array(
1069 + 'exists' => array(
1070 + 'field' => key( $terms_obj ),
1071 + ),
1072 + ),
1073 + ),
1074 + );
1075 +
1076 + break;
1077 + case 'not exists':
1078 + /**
1079 + * add support for "NOT EXISTS" operator
1080 + *
1081 + * @since 2.5
1082 + */
1083 + $tax_query['tax_filter'][]['bool'] = array(
1084 + 'must_not' => array(
1085 + array(
1086 + 'exists' => array(
1087 + 'field' => key( $terms_obj ),
1088 + ),
1089 + ),
1090 + ),
1091 + );
1092 +
1093 + break;
1094 + case 'not in':
1095 + /**
1096 + * add support for "NOT IN" operator
1097 + *
1098 + * @since 2.1
1099 + */
1100 + // If "NOT IN" than it should filter as must_not
1101 + $tax_query['tax_must_not_filter'][]['terms'] = $terms_obj;
1102 +
1103 + break;
1104 + case 'and':
1105 + /**
1106 + * add support for "and" operator
1107 + *
1108 + * @since 2.4
1109 + */
1110 + $and_nest = array(
1111 + 'bool' => array(
1112 + 'must' => array(),
1113 + ),
1114 + );
1115 +
1116 + foreach ( $terms as $term ) {
1117 + $and_nest['bool']['must'][] = array(
1118 + 'terms' => array(
1119 + 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => (array) $term,
1120 + ),
1121 + );
1122 + }
1123 +
1124 + $tax_query['tax_filter'][] = $and_nest;
1125 +
1126 + break;
1127 + case 'in':
1128 + default:
1129 + /**
1130 + * Default to IN operator
1131 + */
1132 + // Add the tax query filter
1133 + $tax_query['tax_filter'][]['terms'] = $terms_obj;
1134 +
1135 + break;
1136 + }
1137 + }
1138 + }
1139 +
1140 + return $tax_query;
1141 + }
1142 +
1143 + /**
1144 + * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
1145 + *
1146 + * @since 1.1
1147 + * @access protected
1148 + *
1149 + * @param string $order The 'order' query variable.
1150 + * @return string The sanitized 'order' query variable.
1151 + */
1152 + protected function parse_order( $order ) {
1153 + // Core will always set sort order to DESC for any invalid value,
1154 + // so we can't do any automated testing of this function.
1155 + // @codeCoverageIgnoreStart
1156 + if ( ! is_string( $order ) || empty( $order ) ) {
1157 + return 'desc';
1158 + }
1159 + // @codeCoverageIgnoreEnd
1160 +
1161 + if ( 'ASC' === strtoupper( $order ) ) {
1162 + return 'asc';
1163 + } else {
1164 + return 'desc';
1165 + }
1166 + }
1167 +
1168 + /**
1169 + * Convert the alias to a properly-prefixed sort value.
1170 + *
1171 + * @since 1.1
1172 + * @access protected
1173 + *
1174 + * @param string $orderbys Alias or path for the field to order by.
1175 + * @param string $default_order Default order direction
1176 + * @param array $args Query args
1177 + * @return array
1178 + */
1179 + protected function parse_orderby( $orderbys, $default_order, $args ) {
1180 + $orderbys = $this->get_orderby_array( $orderbys );
1181 +
1182 + $from_to = [
1183 + 'relevance' => '_score',
1184 + 'date' => 'post_date',
1185 + 'type' => 'post_type.raw',
1186 + 'modified' => 'post_modified',
1187 + 'name' => 'post_name.raw',
1188 + 'title' => 'post_title.sortable',
1189 + ];
1190 +
1191 + $sort = [];
1192 +
1193 + foreach ( $orderbys as $key => $value ) {
1194 + if ( is_string( $key ) ) {
1195 + $orderby_clause = $key;
1196 + $order = $value;
857 1197 } else {
1198 + $orderby_clause = $value;
1199 + $order = $default_order;
1200 + }
858 1201
859 - if ( true !== $excluded_public_keys && ! in_array( $key, $excluded_public_keys, true ) ) {
860 - $allow_index = true;
861 - }
1202 + if ( empty( $orderby_clause ) || 'rand' === $orderby_clause || preg_match( '/^rand\((\d+)\)$/i', $orderby_clause ) ) {
1203 + continue;
862 1204 }
863 1205
864 1206 /**
865 - * Filter force whitelisting a meta key
866 - *
867 - * @hook ep_prepare_meta_whitelist_key
868 - * @param {bool} $whitelist True to whitelist key
869 - * @param {string} $key Meta key
870 - * @param {WP_Post} $post Post object
871 - * @return {bool} New whitelist value
1207 + * If `orderby` is 'none', WordPress will let the database decide on what should be used to order.
1208 + * It will use the primary key ASC.
872 1209 */
873 - if ( true === $allow_index || apply_filters( 'ep_prepare_meta_whitelist_key', false, $key, $post ) ) {
874 - $prepared_meta[ $key ] = maybe_unserialize( $value );
1210 + if ( 'none' === $orderby_clause ) {
1211 + $orderby_clause = 'ID';
1212 + $order = 'asc';
875 1213 }
1214 +
1215 + if ( ! empty( $from_to[ $orderby_clause ] ) ) {
1216 + $orderby_clause = $from_to[ $orderby_clause ];
1217 + } else {
1218 + $orderby_clause = $this->parse_orderby_meta_fields( $orderby_clause, $args );
1219 + }
1220 +
1221 + $sort[] = array(
1222 + $orderby_clause => array(
1223 + 'order' => $order,
1224 + ),
1225 + );
876 1226 }
877 1227
1228 + return $sort;
1229 + }
1230 +
1231 + /**
1232 + * Try to parse orderby meta fields
1233 + *
1234 + * @since 4.6.0
1235 + * @param string $orderby_clause Current orderby value
1236 + * @param array $args Query args
1237 + * @return string New orderby value
1238 + */
1239 + protected function parse_orderby_meta_fields( $orderby_clause, $args ) {
1240 + global $wpdb;
1241 +
1242 + $from_to_metatypes = [
1243 + 'num' => 'long',
1244 + 'numeric' => 'long',
1245 + 'binary' => 'value.sortable',
1246 + 'char' => 'value.sortable',
1247 + 'date' => 'date',
1248 + 'datetime' => 'datetime',
1249 + 'decimal' => 'double',
1250 + 'signed' => 'long',
1251 + 'time' => 'time',
1252 + 'unsigned' => 'long',
1253 + ];
1254 +
1255 + // Code is targeting Elasticsearch directly
1256 + if ( preg_match( '/^meta\.(.*?)\.(.*)/', $orderby_clause, $match_meta ) ) {
1257 + return $orderby_clause;
1258 + }
1259 +
1260 + // WordPress meta_value_* compatibility
1261 + if ( preg_match( '/^meta_value_?(.*)/', $orderby_clause, $match_type ) ) {
1262 + $meta_type = $from_to_metatypes[ strtolower( $match_type[1] ) ] ?? 'value.sortable';
1263 + }
1264 +
1265 + if ( ! empty( $args['meta_key'] ) ) {
1266 + $meta_field = $args['meta_key'];
1267 + }
1268 +
1269 + // Already have everything needed
1270 + if ( isset( $meta_type ) && isset( $meta_field ) ) {
1271 + return "meta.{$meta_field}.{$meta_type}";
1272 + }
1273 +
1274 + // Don't have any other ways to guess
1275 + if ( empty( $args['meta_query'] ) ) {
1276 + return $orderby_clause;
1277 + }
1278 +
1279 + $meta_query = new \WP_Meta_Query( $args['meta_query'] );
1280 + // Calling get_sql() to populate the WP_Meta_Query->clauses attribute
1281 + $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
1282 +
1283 + $clauses = $meta_query->get_clauses();
1284 +
1285 + // If it refers to a named meta_query clause
1286 + if ( ! empty( $clauses[ $orderby_clause ] ) ) {
1287 + $meta_field = $clauses[ $orderby_clause ]['key'];
1288 + $clause_meta_type = strtolower( $clauses[ $orderby_clause ]['type'] ?? $clauses[ $orderby_clause ]['cast'] );
1289 + } else {
1290 + /**
1291 + * At this point we:
1292 + * 1. Try to find the meta key in any meta_query clause and use the type WP found
1293 + * 2. If ordering by `meta_value*`, use the first meta_query clause
1294 + * 3. Give up and use the orderby clause as is (code could be capturing it later on)
1295 + */
1296 + $meta_keys_and_types = wp_list_pluck( $clauses, 'cast', 'key' );
1297 + if ( isset( $meta_keys_and_types[ $orderby_clause ] ) ) {
1298 + $meta_field = $orderby_clause;
1299 + $clause_meta_type = strtolower( $meta_keys_and_types[ $orderby_clause ] ?? $meta_keys_and_types[ $orderby_clause ] );
1300 + } elseif ( isset( $meta_type ) ) {
1301 + $primary_clause = reset( $clauses );
1302 + $meta_field = $primary_clause['key'];
1303 + } else {
1304 + unset( $meta_type );
1305 + unset( $meta_field );
1306 + }
1307 + }
1308 +
1309 + if ( ! isset( $meta_type ) && isset( $clause_meta_type ) ) {
1310 + $meta_type = $from_to_metatypes[ $clause_meta_type ] ?? 'value.sortable';
1311 + }
1312 +
1313 + if ( isset( $meta_type ) && isset( $meta_field ) ) {
1314 + $orderby_clause = "meta.{$meta_field}.{$meta_type}";
1315 + }
1316 +
1317 + return $orderby_clause;
1318 + }
1319 +
1320 + /**
1321 + * Get Order by args Array
1322 + *
1323 + * @param string|array $orderbys Order by string or array
1324 + * @since 2.1
1325 + * @return array
1326 + */
1327 + protected function get_orderby_array( $orderbys ) {
1328 + if ( ! is_array( $orderbys ) ) {
1329 + $orderbys = explode( ' ', $orderbys );
1330 + }
1331 +
1332 + return $orderbys;
1333 + }
1334 +
1335 + /**
1336 + * Given a mapping content, try to determine the version used.
1337 + *
1338 + * @since 3.6.3
1339 + *
1340 + * @param array $mapping Mapping content.
1341 + * @param string $index Index name
1342 + * @return string Version of the mapping being used.
1343 + */
1344 + protected function determine_mapping_version_based_on_existing( $mapping, $index ) {
1345 + if ( isset( $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'] ) ) {
1346 + return $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'];
1347 + }
1348 + if ( isset( $mapping[ $index ]['mappings']['_meta']['mapping_version'] ) ) {
1349 + return $mapping[ $index ]['mappings']['_meta']['mapping_version'];
1350 + }
1351 +
878 1352 /**
879 - * Filter final list of prepared meta.
1353 + * Check for 7-0 mapping.
1354 + * If mapping has a `post` type, it can't be ES 7, as mapping types were removed in that release.
880 1355 *
881 - * @hook ep_prepared_post_meta
882 - * @param {array} $prepared_meta Prepared meta
883 - * @param {WP_Post} $post Post object
884 - * @since 3.4
885 - * @return {array} Prepared meta
1356 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
886 1357 */
887 - return apply_filters( 'ep_prepared_post_meta', $prepared_meta, $post );
1358 + if ( ! isset( $mapping[ $index ]['mappings']['post'] ) ) {
1359 + return '7-0.php';
1360 + }
888 1361
1362 + $post_mapping = $mapping[ $index ]['mappings']['post'];
1363 +
1364 + /**
1365 + * Starting at this point, our tests rely on the post_title.fields.sortable field.
1366 + * As this field is present in all our mappings, if this field is not present in
1367 + * the mapping, this is a custom mapping.
1368 + *
1369 + * To have this code working with custom mappings, use the `ep_post_mapping_version_determined` filter.
1370 + */
1371 + if ( ! isset( $post_mapping['properties']['post_title']['fields']['sortable'] ) ) {
1372 + return 'unknown';
1373 + }
1374 +
1375 + $post_title_sortable = $post_mapping['properties']['post_title']['fields']['sortable'];
1376 +
1377 + /**
1378 + * Check for 5-2 mapping.
1379 + * Normalizers on keyword fields were only made available in ES 5.2
1380 + *
1381 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.2/release-notes-5.2.0.html
1382 + */
1383 + if ( isset( $post_title_sortable['normalizer'] ) ) {
1384 + return '5-2.php';
1385 + }
1386 +
1387 + return 'unknown';
889 1388 }
890 1389
891 1390 /**
892 - * Format WP query args for ES
1391 + * Given ES args, add aggregations to it.
893 1392 *
894 - * @param array $args WP_Query arguments.
895 - * @param WP_Query $wp_query WP_Query object
896 - * @since 0.9.0
1393 + * @since 4.1.0
1394 + * @param array $formatted_args Formatted Elasticsearch query
1395 + * @param array $agg Aggregation data.
1396 + * @param boolean $use_filters Whether filters should be used or not.
1397 + * @param array $filter Filters defined so far.
1398 + * @return array Formatted Elasticsearch query with the aggregation added.
1399 + */
1400 + protected function apply_aggregations( $formatted_args, $agg, $use_filters, $filter ) {
1401 + if ( empty( $agg['aggs'] ) ) {
1402 + return $formatted_args;
1403 + }
1404 +
1405 + // Add a name to the aggregation if it was passed through
1406 + $agg_name = ( ! empty( $agg['name'] ) ) ? $agg['name'] : 'aggregation_name';
1407 +
1408 + // Add/use the filter if warranted
1409 + if ( isset( $agg['use-filter'] ) && false !== $agg['use-filter'] && $use_filters ) {
1410 +
1411 + // If a filter is being used, use it on the aggregation as well to receive relevant information to the query
1412 + $formatted_args['aggs'][ $agg_name ]['filter'] = $filter;
1413 + $formatted_args['aggs'][ $agg_name ]['aggs'] = $agg['aggs'];
1414 + } else {
1415 + $formatted_args['aggs'][ $agg_name ] = $agg['aggs'];
1416 + }
1417 +
1418 + return $formatted_args;
1419 + }
1420 +
1421 + /**
1422 + * Get the search algorithm that should be used.
1423 + *
1424 + * @since 4.3.0
1425 + * @param string $search_text Search term(s)
1426 + * @param array $search_fields Search fields
1427 + * @param array $query_vars Query vars
1428 + * @return SearchAlgorithm Instance of search algorithm to be used
1429 + */
1430 + public function get_search_algorithm( string $search_text, array $search_fields, array $query_vars ): \ElasticPress\SearchAlgorithm {
1431 + $search_algorithm_version_option = \ElasticPress\Utils\get_option( 'ep_search_algorithm_version', '4.0' );
1432 +
1433 + /**
1434 + * Filter the algorithm version to be used.
1435 + *
1436 + * @since 3.5
1437 + * @hook ep_search_algorithm_version
1438 + * @param {string} $search_algorithm_version Algorithm version.
1439 + * @return {string} New algorithm version
1440 + */
1441 + $search_algorithm = apply_filters( 'ep_search_algorithm_version', $search_algorithm_version_option );
1442 +
1443 + /**
1444 + * Filter the search algorithm to be used
1445 + *
1446 + * @hook ep_{$indexable_slug}_search_algorithm
1447 + * @since 4.3.0
1448 + * @param {string} $search_algorithm Slug of the search algorithm used as fallback
1449 + * @param {string} $search_term Search term
1450 + * @param {array} $search_fields Fields to be searched
1451 + * @param {array} $query_vars Query variables
1452 + * @return {string} New search algorithm slug
1453 + */
1454 + $search_algorithm = apply_filters( "ep_{$this->slug}_search_algorithm", $search_algorithm, $search_text, $search_fields, $query_vars );
1455 +
1456 + return \ElasticPress\SearchAlgorithms::factory()->get( $search_algorithm );
1457 + }
1458 +
1459 + /**
1460 + * Based on WP_Query arguments, parses the various filters that could be applied into the ES query.
1461 + *
1462 + * @since 4.4.0
1463 + * @param array $args WP_Query arguments
1464 + * @param WP_Query $query WP_Query object
897 1465 * @return array
898 1466 */
899 - public function format_args( $args, $wp_query ) {
900 - if ( ! empty( $args['posts_per_page'] ) ) {
901 - $posts_per_page = (int) $args['posts_per_page'];
1467 + protected function parse_filters( $args, $query ) {
1468 + /**
1469 + * A note about the order of this array indices:
1470 + * As previously there was no way to access each part, some snippets might be accessing
1471 + * these filters by its usual numeric indices (see the array_values() call below.)
1472 + */
1473 + $filters = [
1474 + 'tax_query' => $this->parse_tax_queries( $args, $query ),
1475 + 'post_parent' => $this->parse_post_parent( $args ),
1476 + 'post_parent__in' => $this->parse_post_parent__in( $args ),
1477 + 'post_parent__not_in' => $this->parse_post_parent__not_in( $args ),
1478 + 'post__in' => $this->parse_post__in( $args ),
1479 + 'post_name__in' => $this->parse_post_name__in( $args ),
1480 + 'post__not_in' => $this->parse_post__not_in( $args ),
1481 + 'category__not_in' => $this->parse_category__not_in( $args ),
1482 + 'tag__not_in' => $this->parse_tag__not_in( $args ),
1483 + 'author' => $this->parse_author( $args ),
1484 + 'post_mime_type' => $this->parse_post_mime_type( $args ),
1485 + 'date' => $this->parse_date( $args ),
1486 + 'meta_query' => $this->parse_meta_queries( $args ),
1487 + 'post_type' => $this->parse_post_type( $args ),
1488 + 'post_status' => $this->parse_post_status( $args ),
1489 + 'painless_script' => $this->painless_script_query( $args ),
1490 + ];
902 1491
903 - // ES have a maximum size allowed so we have to convert "-1" to a maximum size.
904 - if ( -1 === $posts_per_page ) {
905 - /**
906 - * Set the maximum results window size.
907 - *
908 - * The request will return a HTTP 500 Internal Error if the size of the
909 - * request is larger than the [index.max_result_window] parameter in ES.
910 - * See the scroll api for a more efficient way to request large data sets.
911 - *
912 - * @return int The max results window size.
913 - *
914 - * @since 2.3.0
915 - */
1492 + /**
1493 + * Filter the ES filters that will be applied to the ES query.
1494 + *
1495 + * Although each index of the `$filters` array contains the related WP Query argument,
1496 + * it will be removed before applied to the ES query.
1497 + *
1498 + * @hook ep_post_filters
1499 + * @param {array} Current filters
1500 + * @param {array} WP Query args
1501 + * @param {WP_Query} WP Query object
1502 + * @return {array} New filters
1503 + */
1504 + $filters = apply_filters( 'ep_post_filters', $filters, $args, $query );
916 1505
917 - /**
918 - * Filter max result size if set to -1
919 - *
920 - * @hook ep_max_results_window
921 - * @param {int} Max result window
922 - * @return {int} New window
923 - */
924 - $posts_per_page = apply_filters( 'ep_max_results_window', 10000 );
1506 + $filters = array_values( array_filter( $filters ) );
1507 +
1508 + if ( ! empty( $filters ) ) {
1509 + $filters = [
1510 + 'bool' => [
1511 + 'must' => $filters,
1512 + ],
1513 + ];
1514 + }
1515 +
1516 + return $filters;
1517 + }
1518 +
1519 + /**
1520 + * Sanitize WP_Query arguments to be used to create the ES query.
1521 + *
1522 + * Elasticsearch will error if a terms query contains empty items like an empty string.
1523 + *
1524 + * @since 4.4.0
1525 + * @param array $args WP_Query arguments
1526 + * @return array
1527 + */
1528 + protected function sanitize_wp_query_args( $args ) {
1529 + $keys_to_sanitize = [
1530 + 'author__in',
1531 + 'author__not_in',
1532 + 'category__and',
1533 + 'category__in',
1534 + 'category__not_in',
1535 + 'tag__and',
1536 + 'tag__in',
1537 + 'tag__not_in',
1538 + 'tag_slug__and',
1539 + 'tag_slug__in',
1540 + 'post_parent__in',
1541 + 'post_parent__not_in',
1542 + 'post__in',
1543 + 'post__not_in',
1544 + 'post_name__in',
1545 + ];
1546 + foreach ( $keys_to_sanitize as $key ) {
1547 + if ( ! isset( $args[ $key ] ) ) {
1548 + continue;
925 1549 }
926 - } else {
927 - $posts_per_page = (int) get_option( 'posts_per_page' );
1550 + $args[ $key ] = array_filter( (array) $args[ $key ] );
928 1551 }
929 1552
930 - $formatted_args = array(
931 - 'from' => 0,
932 - 'size' => $posts_per_page,
933 - );
1553 + return $args;
1554 + }
934 1555
1556 + /**
1557 + * Parse the `from` clause of the ES Query.
1558 + *
1559 + * @since 4.4.0
1560 + * @param array $args WP_Query arguments
1561 + * @return int
1562 + */
1563 + protected function parse_from( $args ) {
1564 + $from = 0;
1565 +
1566 + if ( isset( $args['offset'] ) ) {
1567 + $from = (int) $args['offset'];
1568 + }
1569 +
1570 + if ( isset( $args['paged'] ) && $args['paged'] > 1 ) {
1571 + $from = $args['posts_per_page'] * ( $args['paged'] - 1 );
1572 + }
1573 +
935 1574 /**
1575 + * Fix negative offset. This happens, for example, on hierarchical post types.
1576 + *
1577 + * Ref: https://github.com/10up/ElasticPress/issues/2480
1578 + */
1579 + if ( $from < 0 ) {
1580 + $from = 0;
1581 + }
1582 +
1583 + return $from;
1584 + }
1585 +
1586 + /**
1587 + * Parse the `size` clause of the ES Query.
1588 + *
1589 + * @since 4.4.0
1590 + * @param array $args WP_Query arguments
1591 + * @return int
1592 + */
1593 + protected function parse_size( $args ) {
1594 + if ( empty( $args['posts_per_page'] ) ) {
1595 + return (int) get_option( 'posts_per_page' );
1596 + }
1597 +
1598 + $posts_per_page = (int) $args['posts_per_page'];
1599 +
1600 + // ES have a maximum size allowed so we have to convert "-1" to a maximum size.
1601 + if ( -1 === $posts_per_page ) {
1602 + /**
1603 + * Filter max result size if set to -1
1604 + *
1605 + * The request will return a HTTP 500 Internal Error if the size of the
1606 + * request is larger than the [index.max_result_window] parameter in ES.
1607 + * See the scroll api for a more efficient way to request large data sets.
1608 + *
1609 + * @hook ep_max_results_window
1610 + * @param {int} Max result window
1611 + * @return {int} New window
1612 + */
1613 + $posts_per_page = apply_filters( 'ep_max_results_window', 10000 );
1614 + }
1615 +
1616 + return $posts_per_page;
1617 + }
1618 +
1619 + /**
1620 + * Parse the order of results in the ES query. It could simply be a `sort` clause or a function score query if using RAND.
1621 + *
1622 + * @since 4.4.0
1623 + * @param array $formatted_args Formatted Elasticsearch query
1624 + * @param array $args WP_Query arguments
1625 + * @return array
1626 + */
1627 + protected function maybe_orderby( $formatted_args, $args ) {
1628 + /**
936 1629 * Order and Orderby arguments
937 1630 *
938 1631 * Used for how Elasticsearch will sort results
939 1632 *
@@ -997,41 +1690,51 @@
997 1690
998 1691 $formatted_args['sort'] = $default_sort;
999 1692 }
1000 1693
1001 - $filter = array(
1002 - 'bool' => array(
1003 - 'must' => [],
1004 - ),
1005 - );
1006 - $use_filters = false;
1694 + /**
1695 + * Order by 'rand' and 'Rand(x)' support
1696 + *
1697 + * Ref: https://github.com/elastic/elasticsearch/issues/1170
1698 + */
1699 + if ( ! empty( $args['orderby'] ) ) {
1700 + $orderbys = $this->get_orderby_array( $args['orderby'] );
1701 + $random_orderbys = preg_grep( '/^rand(?:\((\d+)\))?$/i', $orderbys );
1007 1702
1008 - // Sanitize array query args. Elasticsearch will error if a terms query contains empty items like an
1009 - // empty string.
1010 - $keys_to_sanitize = [
1011 - 'author__in',
1012 - 'author__not_in',
1013 - 'category__and',
1014 - 'category__in',
1015 - 'category__not_in',
1016 - 'tag__and',
1017 - 'tag__in',
1018 - 'tag__not_in',
1019 - 'tag_slug__and',
1020 - 'tag_slug__in',
1021 - 'post_parent__in',
1022 - 'post_parent__not_in',
1023 - 'post__in',
1024 - 'post__not_in',
1025 - 'post_name__in',
1026 - ];
1027 - foreach ( $keys_to_sanitize as $key ) {
1028 - if ( ! isset( $args[ $key ] ) ) {
1029 - continue;
1703 + if ( ! empty( $random_orderbys ) ) {
1704 + $formatted_args_query = $formatted_args['query'];
1705 + $formatted_args['query'] = [];
1706 + $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1707 +
1708 + // Get the first random orderby found.
1709 + $random_orderby = reset( $random_orderbys );
1710 +
1711 + // check if rand with seed.
1712 + if ( preg_match( '/^rand\((\d+)\)$/i', $random_orderby, $matches ) ) {
1713 + $formatted_args['query']['function_score']['random_score'] = (object) [
1714 + 'seed' => (int) $matches[1],
1715 + 'field' => '_seq_no',
1716 + ];
1717 + } else {
1718 + $formatted_args['query']['function_score']['random_score'] = (object) [];
1719 + }
1030 1720 }
1031 - $args[ $key ] = array_filter( (array) $args[ $key ] );
1032 1721 }
1033 1722
1723 + return $formatted_args;
1724 + }
1725 +
1726 + /**
1727 + * Parse all taxonomy queries.
1728 + *
1729 + * Although the name may be misleading, it handles the `tax_query` argument. There is a `parse_tax_query` that handles each "small" query.
1730 + *
1731 + * @since 4.4.0
1732 + * @param array $args WP_Query arguments
1733 + * @param WP_Query $query WP_Query object
1734 + * @return array
1735 + */
1736 + protected function parse_tax_queries( $args, $query ) {
1034 1737 /**
1035 1738 * Tax Query support
1036 1739 *
1037 1740 * Support for the tax_query argument of WP_Query. Currently only provides support for the 'AND' relation
@@ -1040,232 +1743,350 @@
1040 1743 * @use field = slug
1041 1744 * terms array
1042 1745 * @since 0.9.1
1043 1746 */
1044 - if ( ! empty( $wp_query->tax_query ) && ! empty( $wp_query->tax_query->queries ) ) {
1045 - $args['tax_query'] = $wp_query->tax_query->queries;
1747 + if ( ! empty( $query->tax_query ) && ! empty( $query->tax_query->queries ) ) {
1748 + $args['tax_query'] = $query->tax_query->queries;
1046 1749 }
1047 1750
1048 - if ( ! empty( $args['tax_query'] ) ) {
1049 - // Main tax_query array for ES.
1050 - $es_tax_query = [];
1751 + if ( empty( $args['tax_query'] ) ) {
1752 + return [];
1753 + }
1051 1754
1052 - $tax_queries = $this->parse_tax_query( $args['tax_query'] );
1755 + // Main tax_query array for ES.
1756 + $es_tax_query = [];
1053 1757
1054 - if ( ! empty( $tax_queries['tax_filter'] ) ) {
1055 - $relation = 'must';
1758 + $tax_queries = $this->parse_tax_query( $args['tax_query'] );
1056 1759
1057 - if ( ! empty( $args['tax_query']['relation'] ) && 'or' === strtolower( $args['tax_query']['relation'] ) ) {
1058 - $relation = 'should';
1059 - }
1760 + if ( ! empty( $tax_queries['tax_filter'] ) ) {
1761 + $relation = 'must';
1060 1762
1061 - $es_tax_query[ $relation ] = $tax_queries['tax_filter'];
1763 + if ( ! empty( $args['tax_query']['relation'] ) && 'or' === strtolower( $args['tax_query']['relation'] ) ) {
1764 + $relation = 'should';
1062 1765 }
1063 1766
1064 - if ( ! empty( $tax_queries['tax_must_not_filter'] ) ) {
1065 - $es_tax_query['must_not'] = $tax_queries['tax_must_not_filter'];
1066 - }
1767 + $es_tax_query[ $relation ] = $tax_queries['tax_filter'];
1768 + }
1067 1769
1068 - if ( ! empty( $es_tax_query ) ) {
1069 - $filter['bool']['must'][]['bool'] = $es_tax_query;
1070 - }
1770 + if ( ! empty( $tax_queries['tax_must_not_filter'] ) ) {
1771 + $es_tax_query['must_not'] = $tax_queries['tax_must_not_filter'];
1772 + }
1071 1773
1072 - $use_filters = true;
1774 + if ( ! empty( $es_tax_query ) ) {
1775 + return [ 'bool' => $es_tax_query ];
1073 1776 }
1074 1777
1075 - /**
1076 - * 'post_parent' arg support.
1077 - *
1078 - * @since 2.0
1079 - */
1080 - if ( isset( $args['post_parent'] ) && '' !== $args['post_parent'] && 'any' !== strtolower( $args['post_parent'] ) ) {
1081 - $filter['bool']['must'][]['bool']['must'] = array(
1082 - 'term' => array(
1083 - 'post_parent' => $args['post_parent'],
1084 - ),
1085 - );
1778 + return [];
1779 + }
1086 1780
1087 - $use_filters = true;
1781 + /**
1782 + * Parse the `post_parent` WP Query arg and transform it into an ES query clause.
1783 + *
1784 + * @since 4.4.0
1785 + * @param array $args WP_Query arguments
1786 + * @return array
1787 + */
1788 + protected function parse_post_parent( $args ) {
1789 + $has_post_parent = isset( $args['post_parent'] ) && ( in_array( $args['post_parent'], [ 0, '0' ], true ) || ! empty( $args['post_parent'] ) );
1790 + if ( ! $has_post_parent || 'any' === strtolower( $args['post_parent'] ) ) {
1791 + return [];
1088 1792 }
1089 1793
1090 - /**
1091 - * 'post__in' arg support.
1092 - *
1093 - * @since x.x
1094 - */
1095 - if ( ! empty( $args['post__in'] ) ) {
1096 - $filter['bool']['must'][]['bool']['must'] = array(
1097 - 'terms' => array(
1098 - 'post_id' => array_values( (array) $args['post__in'] ),
1099 - ),
1100 - );
1794 + return [
1795 + 'bool' => [
1796 + 'must' => [
1797 + 'term' => [
1798 + 'post_parent' => (int) $args['post_parent'],
1799 + ],
1800 + ],
1801 + ],
1802 + ];
1803 + }
1101 1804
1102 - $use_filters = true;
1805 + /**
1806 + * Parse the `post_parent__in` WP Query arg and transform it into an ES query clause.
1807 + *
1808 + * @since 4.5.0
1809 + * @param array $args WP_Query arguments
1810 + * @return array
1811 + */
1812 + protected function parse_post_parent__in( $args ) {
1813 + if ( empty( $args['post_parent__in'] ) ) {
1814 + return [];
1103 1815 }
1104 1816
1105 - /**
1106 - * 'post_name__in' arg support.
1107 - *
1108 - * @since 3.6.0
1109 - */
1110 - if ( ! empty( $args['post_name__in'] ) ) {
1111 - $filter['bool']['must'][]['bool']['must'] = array(
1112 - 'terms' => array(
1113 - 'post_name.raw' => array_values( (array) $args['post_name__in'] ),
1114 - ),
1115 - );
1817 + return [
1818 + 'bool' => [
1819 + 'must' => [
1820 + 'terms' => [
1821 + 'post_parent' => array_values( (array) $args['post_parent__in'] ),
1822 + ],
1823 + ],
1824 + ],
1825 + ];
1826 + }
1116 1827
1117 - $use_filters = true;
1828 + /**
1829 + * Parse the `post_parent__not_in` WP Query arg and transform it into an ES query clause.
1830 + *
1831 + * @since 4.5.0
1832 + * @param array $args WP_Query arguments
1833 + * @return array
1834 + */
1835 + protected function parse_post_parent__not_in( $args ) {
1836 + if ( empty( $args['post_parent__not_in'] ) ) {
1837 + return [];
1118 1838 }
1119 1839
1120 - /**
1121 - * 'post__not_in' arg support.
1122 - *
1123 - * @since x.x
1124 - */
1125 - if ( ! empty( $args['post__not_in'] ) ) {
1126 - $filter['bool']['must'][]['bool']['must_not'] = array(
1127 - 'terms' => array(
1128 - 'post_id' => (array) $args['post__not_in'],
1129 - ),
1130 - );
1840 + return [
1841 + 'bool' => [
1842 + 'must_not' => [
1843 + 'terms' => [
1844 + 'post_parent' => array_values( (array) $args['post_parent__not_in'] ),
1845 + ],
1846 + ],
1847 + ],
1848 + ];
1849 + }
1131 1850
1132 - $use_filters = true;
1851 + /**
1852 + * Parse the `post__in` WP Query arg and transform it into an ES query clause.
1853 + *
1854 + * @since 4.4.0
1855 + * @param array $args WP_Query arguments
1856 + * @return array
1857 + */
1858 + protected function parse_post__in( $args ) {
1859 + if ( empty( $args['post__in'] ) ) {
1860 + return [];
1133 1861 }
1134 1862
1135 - /**
1136 - * 'category__not_in' arg support.
1137 - *
1138 - * @since 3.6.0
1139 - */
1140 - if ( ! empty( $args['category__not_in'] ) ) {
1141 - $filter['bool']['must'][]['bool']['must_not'] = array(
1142 - 'terms' => array(
1143 - 'terms.category.term_id' => array_values( (array) $args['category__not_in'] ),
1144 - ),
1145 - );
1863 + return [
1864 + 'bool' => [
1865 + 'must' => [
1866 + 'terms' => [
1867 + 'post_id' => array_values( (array) $args['post__in'] ),
1868 + ],
1869 + ],
1870 + ],
1871 + ];
1872 + }
1146 1873
1147 - $use_filters = true;
1874 + /**
1875 + * Parse the `post_name__in` WP Query arg and transform it into an ES query clause.
1876 + *
1877 + * @since 4.4.0
1878 + * @param array $args WP_Query arguments
1879 + * @return array
1880 + */
1881 + protected function parse_post_name__in( $args ) {
1882 + if ( empty( $args['post_name__in'] ) ) {
1883 + return [];
1148 1884 }
1149 1885
1150 - /**
1151 - * 'tag__not_in' arg support.
1152 - *
1153 - * @since 3.6.0
1154 - */
1155 - if ( ! empty( $args['tag__not_in'] ) ) {
1156 - $filter['bool']['must'][]['bool']['must_not'] = array(
1157 - 'terms' => array(
1158 - 'terms.post_tag.term_id' => array_values( (array) $args['tag__not_in'] ),
1159 - ),
1160 - );
1886 + return [
1887 + 'bool' => [
1888 + 'must' => [
1889 + 'terms' => [
1890 + 'post_name.raw' => array_values( (array) $args['post_name__in'] ),
1891 + ],
1892 + ],
1893 + ],
1894 + ];
1895 + }
1161 1896
1162 - $use_filters = true;
1897 + /**
1898 + * Parse the `post__not_in` WP Query arg and transform it into an ES query clause.
1899 + *
1900 + * @since 4.4.0
1901 + * @param array $args WP_Query arguments
1902 + * @return array
1903 + */
1904 + protected function parse_post__not_in( $args ) {
1905 + if ( empty( $args['post__not_in'] ) ) {
1906 + return [];
1163 1907 }
1164 1908
1165 - /**
1166 - * Author query support
1167 - *
1168 - * @since 1.0
1169 - */
1909 + return [
1910 + 'bool' => [
1911 + 'must_not' => [
1912 + 'terms' => [
1913 + 'post_id' => array_values( (array) $args['post__not_in'] ),
1914 + ],
1915 + ],
1916 + ],
1917 + ];
1918 + }
1919 +
1920 + /**
1921 + * Parse the `category__not_in` WP Query arg and transform it into an ES query clause.
1922 + *
1923 + * @since 4.4.0
1924 + * @param array $args WP_Query arguments
1925 + * @return array
1926 + */
1927 + protected function parse_category__not_in( $args ) {
1928 + if ( empty( $args['category__not_in'] ) ) {
1929 + return [];
1930 + }
1931 +
1932 + return [
1933 + 'bool' => [
1934 + 'must_not' => [
1935 + 'terms' => [
1936 + 'terms.category.term_id' => array_values( (array) $args['category__not_in'] ),
1937 + ],
1938 + ],
1939 + ],
1940 + ];
1941 + }
1942 +
1943 + /**
1944 + * Parse the `tag__not_in` WP Query arg and transform it into an ES query clause.
1945 + *
1946 + * @since 4.4.0
1947 + * @param array $args WP_Query arguments
1948 + * @return array
1949 + */
1950 + protected function parse_tag__not_in( $args ) {
1951 + if ( empty( $args['tag__not_in'] ) ) {
1952 + return [];
1953 + }
1954 +
1955 + return [
1956 + 'bool' => [
1957 + 'must_not' => [
1958 + 'terms' => [
1959 + 'terms.post_tag.term_id' => array_values( (array) $args['tag__not_in'] ),
1960 + ],
1961 + ],
1962 + ],
1963 + ];
1964 + }
1965 +
1966 + /**
1967 + * Parse the various author-related WP Query args and transform them into ES query clauses.
1968 + *
1969 + * @since 4.4.0
1970 + * @param array $args WP_Query arguments
1971 + * @return array
1972 + */
1973 + protected function parse_author( $args ) {
1170 1974 if ( ! empty( $args['author'] ) ) {
1171 - $filter['bool']['must'][] = array(
1172 - 'term' => array(
1975 + return [
1976 + 'term' => [
1173 1977 'post_author.id' => $args['author'],
1174 - ),
1175 - );
1978 + ],
1979 + ];
1980 + }
1176 1981
1177 - $use_filters = true;
1178 - } elseif ( ! empty( $args['author_name'] ) ) {
1982 + if ( ! empty( $args['author_name'] ) ) {
1179 1983 // Since this was set to use the display name initially, there might be some code that used this feature.
1180 1984 // Let's ensure that any query vars coming in using author_name are in fact slugs.
1181 1985 // This was changed back in ticket #1622 to use the display name, so we removed the sanitize_user() call.
1182 - $filter['bool']['must'][] = array(
1183 - 'term' => array(
1986 + return [
1987 + 'term' => [
1184 1988 'post_author.display_name' => $args['author_name'],
1185 - ),
1186 - );
1989 + ],
1990 + ];
1991 + }
1187 1992
1188 - $use_filters = true;
1189 - } elseif ( ! empty( $args['author__in'] ) ) {
1190 - $filter['bool']['must'][]['bool']['must'] = array(
1191 - 'terms' => array(
1192 - 'post_author.id' => array_values( (array) $args['author__in'] ),
1193 - ),
1194 - );
1993 + if ( ! empty( $args['author__in'] ) ) {
1994 + return [
1995 + 'bool' => [
1996 + 'must' => [
1997 + 'terms' => [
1998 + 'post_author.id' => array_values( (array) $args['author__in'] ),
1999 + ],
2000 + ],
2001 + ],
2002 + ];
2003 + }
1195 2004
1196 - $use_filters = true;
1197 - } elseif ( ! empty( $args['author__not_in'] ) ) {
1198 - $filter['bool']['must'][]['bool']['must_not'] = array(
1199 - 'terms' => array(
1200 - 'post_author.id' => array_values( (array) $args['author__not_in'] ),
1201 - ),
1202 - );
2005 + if ( ! empty( $args['author__not_in'] ) ) {
2006 + return [
2007 + 'bool' => [
2008 + 'must_not' => [
2009 + 'terms' => [
2010 + 'post_author.id' => array_values( (array) $args['author__not_in'] ),
2011 + ],
2012 + ],
2013 + ],
2014 + ];
2015 + }
1203 2016
1204 - $use_filters = true;
2017 + return [];
2018 + }
2019 +
2020 + /**
2021 + * Parse the `post_mime_type` WP Query arg and transform it into an ES query clause.
2022 + *
2023 + * If we have array, it will be fool text search filter.
2024 + * If we have string(like filter images in media screen), we will have mime type "image" so need to check it as
2025 + * regexp filter.
2026 + *
2027 + * @since 4.4.0
2028 + * @param array $args WP_Query arguments
2029 + * @return array
2030 + */
2031 + protected function parse_post_mime_type( $args ) {
2032 + if ( empty( $args['post_mime_type'] ) ) {
2033 + return [];
1205 2034 }
1206 2035
1207 - /**
1208 - * Add support for post_mime_type
1209 - *
1210 - * If we have array, it will be fool text search filter.
1211 - * If we have string(like filter images in media screen), we will have mime type "image" so need to check it as
1212 - * regexp filter.
1213 - *
1214 - * @since 2.3
1215 - */
1216 - if ( ! empty( $args['post_mime_type'] ) ) {
1217 - if ( is_array( $args['post_mime_type'] ) ) {
2036 + if ( is_array( $args['post_mime_type'] ) ) {
1218 2037
1219 - $args_post_mime_type = [];
2038 + $args_post_mime_type = [];
1220 2039
1221 - foreach ( $args['post_mime_type'] as $mime_type ) {
1222 - /**
1223 - * check if matches the MIME type pattern: type/subtype and
1224 - * leave an empty string as posts, pages and CPTs don't have a MIME type
1225 - */
1226 - if ( preg_match( '/^[-._a-z0-9]+\/[-._a-z0-9]+$/i', $mime_type ) || empty( $mime_type ) ) {
1227 - $args_post_mime_type[] = $mime_type;
1228 - } else {
1229 - $filtered_mime_type_by_type = wp_match_mime_types( $mime_type, wp_get_mime_types() );
2040 + foreach ( $args['post_mime_type'] as $mime_type ) {
2041 + /**
2042 + * check if matches the MIME type pattern: type/subtype and
2043 + * leave an empty string as posts, pages and CPTs don't have a MIME type
2044 + */
2045 + if ( preg_match( '/^[-._a-z0-9]+\/[-._a-z0-9]+$/i', $mime_type ) || empty( $mime_type ) ) {
2046 + $args_post_mime_type[] = $mime_type;
2047 + } else {
2048 + $filtered_mime_type_by_type = wp_match_mime_types( $mime_type, wp_get_mime_types() );
1230 2049
1231 - $args_post_mime_type = array_merge( $args_post_mime_type, $filtered_mime_type_by_type[ $mime_type ] );
1232 - }
2050 + $args_post_mime_type = array_merge(
2051 + $args_post_mime_type,
2052 + $filtered_mime_type_by_type[ $mime_type ] ?? [ $mime_type ]
2053 + );
1233 2054 }
2055 + }
1234 2056
1235 - $filter['bool']['must'][] = array(
1236 - 'terms' => array(
1237 - 'post_mime_type' => $args_post_mime_type,
1238 - ),
1239 - );
2057 + return [
2058 + 'terms' => [
2059 + 'post_mime_type' => $args_post_mime_type,
2060 + ],
2061 + ];
2062 + }
1240 2063
1241 - $use_filters = true;
1242 - } elseif ( is_string( $args['post_mime_type'] ) ) {
1243 - $filter['bool']['must'][] = array(
1244 - 'regexp' => array(
1245 - 'post_mime_type' => $args['post_mime_type'] . '.*',
1246 - ),
1247 - );
2064 + if ( is_string( $args['post_mime_type'] ) ) {
2065 + return [
2066 + 'regexp' => array(
2067 + 'post_mime_type' => $args['post_mime_type'] . '.*',
2068 + ),
2069 + ];
2070 + }
1248 2071
1249 - $use_filters = true;
1250 - }
1251 - }
2072 + return [];
2073 + }
1252 2074
1253 - /**
1254 - * Simple date params support
1255 - *
1256 - * @since 1.3
1257 - */
2075 + /**
2076 + * Parse the various date-related WP Query args and transform them into ES query clauses.
2077 + *
2078 + * @since 4.4.0
2079 + * @param array $args WP_Query arguments
2080 + * @return array
2081 + */
2082 + protected function parse_date( $args ) {
1258 2083 $date_filter = DateQuery::simple_es_date_filter( $args );
1259 2084
1260 2085 if ( ! empty( $date_filter ) ) {
1261 - $filter['bool']['must'][] = $date_filter;
1262 - $use_filters = true;
2086 + return $date_filter;
1263 2087 }
1264 2088
1265 - /**
1266 - * 'date_query' arg support.
1267 - */
1268 2089 if ( ! empty( $args['date_query'] ) ) {
1269 2090
1270 2091 $date_query = new DateQuery( $args['date_query'] );
1271 2092
@@ -1271,16 +2092,41 @@
1271 2092
1272 2093 $date_filter = $date_query->get_es_filter();
1273 2094
1274 2095 if ( array_key_exists( 'and', $date_filter ) ) {
1275 - $filter['bool']['must'][] = $date_filter['and'];
1276 - $use_filters = true;
2096 + return $date_filter['and'];
2097 + } elseif ( array_key_exists( 'or', $date_filter ) ) {
2098 + return $date_filter['or'];
1277 2099 }
1278 2100 }
2101 + }
1279 2102
1280 - $meta_queries = [];
2103 + /**
2104 + * Parse all meta queries.
2105 + *
2106 + * Although the name may be misleading, it handles the `meta_query` argument. There is a `build_meta_query` that handles each "small" query.
2107 + *
2108 + * @since 4.4.0
2109 + * @param array $args WP_Query arguments
2110 + * @return array
2111 + */
2112 + protected function parse_meta_queries( $args ) {
2113 + /**
2114 + * 'meta_query' arg support.
2115 + *
2116 + * Relation supports 'AND' and 'OR'. 'AND' is the default. For each individual query, the
2117 + * following 'compare' values are supported: =, !=, EXISTS, NOT EXISTS. '=' is the default.
2118 + *
2119 + * @since 1.3
2120 + */
2121 + $meta_queries = ( ! empty( $args['meta_query'] ) ) ? $args['meta_query'] : [];
2122 + $meta_queries = ( new \WP_Meta_Query() )->sanitize_query( $meta_queries );
1281 2123
1282 2124 /**
2125 + * Todo: Support meta_type
2126 + */
2127 +
2128 + /**
1283 2129 * Support `meta_key`, `meta_value`, `meta_value_num`, and `meta_compare` query args
1284 2130 */
1285 2131 if ( ! empty( $args['meta_key'] ) ) {
1286 2132 $meta_query_array = [
@@ -1296,44 +2142,168 @@
1296 2142 if ( isset( $args['meta_compare'] ) ) {
1297 2143 $meta_query_array['compare'] = $args['meta_compare'];
1298 2144 }
1299 2145
1300 - $meta_queries[] = $meta_query_array;
2146 + if ( ! empty( $meta_queries ) ) {
2147 + $meta_queries = [
2148 + 'relation' => 'AND',
2149 + $meta_query_array,
2150 + $meta_queries,
2151 + ];
2152 + } else {
2153 + $meta_queries = [ $meta_query_array ];
2154 + }
1301 2155 }
1302 2156
2157 + if ( ! empty( $meta_queries ) ) {
2158 + // get meta query filter
2159 + $meta_filter = $this->build_meta_query( $meta_queries );
2160 +
2161 + if ( ! empty( $meta_filter ) ) {
2162 + return $meta_filter;
2163 + }
2164 + }
2165 +
2166 + return [];
2167 + }
2168 +
2169 + /**
2170 + * Parse the `post_type` WP Query arg and transform it into an ES query clause.
2171 + *
2172 + * @since 4.4.0
2173 + * @param array $args WP_Query arguments
2174 + * @return array
2175 + */
2176 + protected function parse_post_type( $args ) {
1303 2177 /**
1304 - * Todo: Support meta_type
2178 + * If not set default to post. If search and not set, default to "any".
1305 2179 */
2180 + if ( ! empty( $args['post_type'] ) ) {
2181 + // should NEVER be "any" but just in case
2182 + if ( 'any' !== $args['post_type'] ) {
2183 + $post_types = (array) $args['post_type'];
2184 + $terms_map_name = 'terms';
1306 2185
2186 + return [
2187 + $terms_map_name => [
2188 + 'post_type.raw' => array_values( $post_types ),
2189 + ],
2190 + ];
2191 + }
2192 + } elseif ( empty( $args['s'] ) ) {
2193 + return [
2194 + 'term' => [
2195 + 'post_type.raw' => 'post',
2196 + ],
2197 + ];
2198 + }
2199 +
2200 + return [];
2201 + }
2202 +
2203 + /**
2204 + * Parse the `post_status` WP Query arg and transform it into an ES query clause.
2205 + *
2206 + * @since 4.4.0
2207 + * @param array $args WP_Query arguments
2208 + * @return array
2209 + */
2210 + protected function parse_post_status( $args ) {
1307 2211 /**
1308 - * 'meta_query' arg support.
2212 + * Like WP_Query in search context, if no post_status is specified we default to "any". To
2213 + * be safe you should ALWAYS specify the post_status parameter UNLIKE with WP_Query.
1309 2214 *
1310 - * Relation supports 'AND' and 'OR'. 'AND' is the default. For each individual query, the
1311 - * following 'compare' values are supported: =, !=, EXISTS, NOT EXISTS. '=' is the default.
1312 - *
1313 - * @since 1.3
2215 + * @since 2.1
1314 2216 */
1315 - if ( ! empty( $args['meta_query'] ) ) {
1316 - $meta_queries = array_merge( $meta_queries, $args['meta_query'] );
2217 + if ( ! empty( $args['post_status'] ) ) {
2218 + // should NEVER be "any" but just in case
2219 + if ( 'any' !== $args['post_status'] ) {
2220 + $post_status = (array) ( is_string( $args['post_status'] ) ? explode( ',', $args['post_status'] ) : $args['post_status'] );
2221 + $post_status = array_map( 'trim', $post_status );
2222 + $terms_map_name = 'terms';
2223 + if ( count( $post_status ) < 2 ) {
2224 + $terms_map_name = 'term';
2225 + $post_status = $post_status[0];
2226 + }
2227 +
2228 + return [
2229 + $terms_map_name => [
2230 + 'post_status' => is_array( $post_status ) ? array_values( $post_status ) : $post_status,
2231 + ],
2232 + ];
2233 + }
2234 + } elseif ( ! is_admin() ) {
2235 + $statuses = get_post_stati( array( 'public' => true ) );
2236 + $statuses = array_values( $statuses );
2237 +
2238 + $post_status_filter_type = 'terms';
2239 +
2240 + return [
2241 + $post_status_filter_type => [
2242 + 'post_status' => $statuses,
2243 + ],
2244 + ];
1317 2245 }
1318 2246
1319 - if ( ! empty( $meta_queries ) ) {
2247 + return [];
2248 + }
1320 2249
1321 - $relation = 'must';
1322 - if ( ! empty( $args['meta_query'] ) && ! empty( $args['meta_query']['relation'] ) && 'or' === strtolower( $args['meta_query']['relation'] ) ) {
1323 - $relation = 'should';
1324 - }
2250 + /**
2251 + * Parse the `painless_script` WP Query arg and transform it into an ES query clause.
2252 + *
2253 + * @since 5.3.0
2254 + * @param array $args WP_Query arguments
2255 + * @return array
2256 + */
2257 + protected function painless_script_query( $args ) {
2258 + $scripts = $args['painless_script'] ?? [];
1325 2259
1326 - // get meta query filter
1327 - $meta_filter = $this->build_meta_query( $meta_queries );
2260 + if ( empty( $scripts ) ) {
2261 + return [];
2262 + }
1328 2263
1329 - if ( ! empty( $meta_filter ) ) {
1330 - $filter['bool']['must'][] = $meta_filter;
2264 + $normalized_scripts = array_map(
2265 + function ( $script_config ) {
2266 + return is_string( $script_config )
2267 + ? [ 'source' => $script_config ]
2268 + : $script_config;
2269 + },
2270 + $scripts
2271 + );
1331 2272
1332 - $use_filters = true;
2273 + $normalized_scripts = array_filter( $normalized_scripts );
2274 +
2275 + $filters = [];
2276 + foreach ( $normalized_scripts as $script_config ) {
2277 + $script_query = [ 'source' => $script_config['source'] ];
2278 +
2279 + if ( ! empty( $script_config['params'] ) && is_array( $script_config['params'] ) ) {
2280 + $script_query['params'] = $script_config['params'];
1333 2281 }
2282 +
2283 + $filters['bool']['must'][] = [
2284 + 'bool' => [
2285 + 'filter' => [
2286 + 'script' => [
2287 + 'script' => $script_query,
2288 + ],
2289 + ],
2290 + ],
2291 + ];
1334 2292 }
1335 2293
2294 + return $filters;
2295 + }
2296 +
2297 + /**
2298 + * If in a search context set search fields, otherwise query everything.
2299 + *
2300 + * @since 4.4.0
2301 + * @param array $formatted_args Formatted Elasticsearch query
2302 + * @param array $args WP_Query arguments
2303 + * @return array
2304 + */
2305 + protected function maybe_set_search_fields( $formatted_args, $args ) {
1336 2306 /**
1337 2307 * Allow for search field specification
1338 2308 *
1339 2309 * @since 1.0
@@ -1390,192 +2360,10 @@
1390 2360 * @return {array} New defaults
1391 2361 */
1392 2362 $search_fields = apply_filters( 'ep_search_fields', $search_fields, $args );
1393 2363
1394 - $default_algorithm_version = '4.0';
1395 - if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
1396 - $search_algorithm_version_option = get_site_option( 'ep_search_algorithm_version', $default_algorithm_version );
1397 - } else {
1398 - $search_algorithm_version_option = get_option( 'ep_search_algorithm_version', $default_algorithm_version );
1399 - }
1400 -
1401 - /**
1402 - * Filter the algorithm version to be used.
1403 - *
1404 - * @since 3.5
1405 - * @hook ep_search_algorithm_version
1406 - * @param {string} $search_algorithm_version Algorithm version.
1407 - * @return {string} New algorithm version
1408 - */
1409 - $search_algorithm_version = apply_filters( 'ep_search_algorithm_version', $search_algorithm_version_option );
1410 -
1411 2364 $search_text = ( ! empty( $args['s'] ) ) ? $args['s'] : '';
1412 2365
1413 - if ( '4.0' === $search_algorithm_version ) {
1414 - $query = array(
1415 - 'bool' => array(
1416 - 'should' => array(
1417 - array(
1418 - 'multi_match' => array(
1419 - 'query' => $search_text,
1420 - 'type' => 'phrase',
1421 - 'fields' => $search_fields,
1422 - /**
1423 - * Filter boost for post match phrase query
1424 - *
1425 - * @hook ep_match_phrase_boost
1426 - * @param {int} $boost Phrase boost
1427 - * @param {array} $prepared_search_fields Search fields
1428 - * @param {array} $query_vars Query variables
1429 - * @return {int} New phrase boost
1430 - */
1431 - 'boost' => apply_filters( 'ep_match_phrase_boost', 3, $search_fields, $args ),
1432 - ),
1433 - ),
1434 - array(
1435 - 'multi_match' => array(
1436 - 'query' => $search_text,
1437 - 'fields' => $search_fields,
1438 - /**
1439 - * Filter boost for post match query
1440 - *
1441 - * @hook ep_match_boost
1442 - * @param {int} $boost Boost
1443 - * @param {array} $prepared_search_fields Search fields
1444 - * @param {array} $query_vars Query variables
1445 - * @return {int} New boost
1446 - */
1447 - 'boost' => apply_filters( 'ep_match_boost', 1, $search_fields, $args ),
1448 - /**
1449 - * Filter fuzziness for post match query
1450 - *
1451 - * @hook ep_match_fuzziness
1452 - * @since 4.0.0
1453 - * @param {string|int} $fuzziness Fuzziness
1454 - * @param {array} $prepared_search_fields Search fields
1455 - * @param {array} $query_vars Query variables
1456 - * @return {string} New boost
1457 - */
1458 - 'fuzziness' => apply_filters( 'ep_match_fuzziness', 'auto', $search_fields, $args ),
1459 - 'operator' => 'and',
1460 - ),
1461 - ),
1462 - array(
1463 - 'multi_match' => [
1464 - 'query' => $search_text,
1465 - 'type' => 'cross_fields',
1466 - 'fields' => $search_fields,
1467 - /**
1468 - * Filter boost for post match query
1469 - *
1470 - * @hook ep_match_cross_fields_boost
1471 - * @since 4.0.0
1472 - * @param {int} $boost Boost
1473 - * @param {array} $prepared_search_fields Search fields
1474 - * @param {array} $query_vars Query variables
1475 - * @return {int} New boost
1476 - */
1477 - 'boost' => apply_filters( 'ep_match_cross_fields_boost', 1, $search_fields, $args ),
1478 - 'analyzer' => 'standard',
1479 - 'tie_breaker' => 0.5,
1480 - 'operator' => 'and',
1481 - ],
1482 - ),
1483 - ),
1484 - ),
1485 - );
1486 - } elseif ( '3.5' === $search_algorithm_version ) {
1487 - $query = array(
1488 - 'bool' => array(
1489 - 'should' => array(
1490 - array(
1491 - 'multi_match' => array(
1492 - 'query' => $search_text,
1493 - 'type' => 'phrase',
1494 - 'fields' => $search_fields,
1495 - /**
1496 - * Filter boost for post match phrase query
1497 - *
1498 - * @hook ep_match_phrase_boost
1499 - * @param {int} $boost Phrase boost
1500 - * @param {array} $prepared_search_fields Search fields
1501 - * @param {array} $query_vars Query variables
1502 - * @return {int} New phrase boost
1503 - */
1504 - 'boost' => apply_filters( 'ep_match_phrase_boost', 3, $search_fields, $args ),
1505 - ),
1506 - ),
1507 - array(
1508 - 'multi_match' => array(
1509 - 'query' => $search_text,
1510 - 'fields' => $search_fields,
1511 - 'type' => 'phrase',
1512 - 'slop' => 5,
1513 - ),
1514 - ),
1515 - ),
1516 - ),
1517 - );
1518 - } else {
1519 - $query = array(
1520 - 'bool' => array(
1521 - 'should' => array(
1522 - array(
1523 - 'multi_match' => array(
1524 - 'query' => $search_text,
1525 - 'type' => 'phrase',
1526 - 'fields' => $search_fields,
1527 - /**
1528 - * Filter boost for post match phrase query
1529 - *
1530 - * @hook ep_match_phrase_boost
1531 - * @param {int} $boost Phrase boost
1532 - * @param {array} $prepared_search_fields Search fields
1533 - * @param {array} $query_vars Query variables
1534 - * @return {int} New phrase boost
1535 - */
1536 - 'boost' => apply_filters( 'ep_match_phrase_boost', 4, $search_fields, $args ),
1537 - ),
1538 - ),
1539 - array(
1540 - 'multi_match' => array(
1541 - 'query' => $search_text,
1542 - 'fields' => $search_fields,
1543 - /**
1544 - * Filter boost for post match query
1545 - *
1546 - * @hook ep_match_boost
1547 - * @param {int} $boost Boost
1548 - * @param {array} $prepared_search_fields Search fields
1549 - * @param {array} $query_vars Query variables
1550 - * @return {int} New boost
1551 - */
1552 - 'boost' => apply_filters( 'ep_match_boost', 2, $search_fields, $args ),
1553 - 'fuzziness' => 0,
1554 - 'operator' => 'and',
1555 - ),
1556 - ),
1557 - array(
1558 - 'multi_match' => array(
1559 - 'query' => $search_text,
1560 - 'fields' => $search_fields,
1561 - /**
1562 - * Filter fuzziness for post query
1563 - *
1564 - * @hook ep_fuzziness_arg
1565 - * @param {int} $fuzziness Fuzziness
1566 - * @param {array} $prepared_search_fields Search fields
1567 - * @param {array} $query_vars Query variables
1568 - * @return {int} New fuzziness
1569 - */
1570 - 'fuzziness' => apply_filters( 'ep_fuzziness_arg', 1, $search_fields, $args ),
1571 - ),
1572 - ),
1573 - ),
1574 - ),
1575 - );
1576 - }
1577 -
1578 2366 /**
1579 2367 * We are using ep_integrate instead of ep_match_all. ep_match_all will be
1580 2368 * supported for legacy code but may be deprecated and removed eventually.
1581 2369 *
@@ -1581,30 +2369,13 @@
1581 2369 *
1582 2370 * @since 1.3
1583 2371 */
1584 2372
1585 - if ( ! empty( $args['s'] ) ) {
1586 - add_filter( 'ep_formatted_args_query', [ $this, 'adjust_query_fuzziness' ], 100, 4 );
2373 + if ( ! empty( $search_text ) ) {
2374 + add_filter( 'ep_post_formatted_args_query', [ $this, 'adjust_query_fuzziness' ], 100, 4 );
1587 2375
1588 - /**
1589 - * Filter formatted Elasticsearch post query (only contains query part)
1590 - *
1591 - * @hook ep_formatted_args_query
1592 - * @param {array} $query Current query
1593 - * @param {array} $query_vars Query variables
1594 - * @param {string} $search_text Search text
1595 - * @param {array} $search_fields Search fields
1596 - * @return {array} New query
1597 - *
1598 - * @since 3.5.5 $search_text and $search_fields parameters added.
1599 - */
1600 - $formatted_args['query'] = apply_filters(
1601 - 'ep_formatted_args_query',
1602 - $query,
1603 - $args,
1604 - $search_text,
1605 - $search_fields
1606 - );
2376 + $search_algorithm = $this->get_search_algorithm( $search_text, $search_fields, $args );
2377 + $formatted_args['query'] = $search_algorithm->get_query( 'post', $search_text, $search_fields, $args );
1607 2378 } elseif ( ! empty( $args['ep_match_all'] ) || ! empty( $args['ep_integrate'] ) ) {
1608 2379 $formatted_args['query']['match_all'] = array(
1609 2380 'boost' => 1,
1610 2381 );
@@ -1609,23 +2380,20 @@
1609 2380 'boost' => 1,
1610 2381 );
1611 2382 }
1612 2383
1613 - /**
1614 - * Order by 'rand' support
1615 - *
1616 - * Ref: https://github.com/elastic/elasticsearch/issues/1170
1617 - */
1618 - if ( ! empty( $args['orderby'] ) ) {
1619 - $orderbys = $this->get_orderby_array( $args['orderby'] );
1620 - if ( in_array( 'rand', $orderbys, true ) ) {
1621 - $formatted_args_query = $formatted_args['query'];
1622 - $formatted_args['query'] = [];
1623 - $formatted_args['query']['function_score']['query'] = $formatted_args_query;
1624 - $formatted_args['query']['function_score']['random_score'] = (object) [];
1625 - }
1626 - }
2384 + return $formatted_args;
2385 + }
1627 2386
2387 + /**
2388 + * If needed bring sticky posts and order them.
2389 + *
2390 + * @since 4.4.0
2391 + * @param array $formatted_args Formatted Elasticsearch query
2392 + * @param array $args WP_Query arguments
2393 + * @return array
2394 + */
2395 + protected function maybe_add_sticky_posts( $formatted_args, $args ) {
1628 2396 /**
1629 2397 * Sticky posts support
1630 2398 */
1631 2399
@@ -1673,118 +2441,21 @@
1673 2441 ),
1674 2442 );
1675 2443 }
1676 2444
1677 - /**
1678 - * If not set default to post. If search and not set, default to "any".
1679 - */
1680 - if ( ! empty( $args['post_type'] ) ) {
1681 - // should NEVER be "any" but just in case
1682 - if ( 'any' !== $args['post_type'] ) {
1683 - $post_types = (array) $args['post_type'];
1684 - $terms_map_name = 'terms';
2445 + return $formatted_args;
2446 + }
1685 2447
1686 - $filter['bool']['must'][] = array(
1687 - $terms_map_name => array(
1688 - 'post_type.raw' => array_values( $post_types ),
1689 - ),
1690 - );
1691 -
1692 - $use_filters = true;
1693 - }
1694 - } elseif ( empty( $args['s'] ) ) {
1695 - $filter['bool']['must'][] = array(
1696 - 'term' => array(
1697 - 'post_type.raw' => 'post',
1698 - ),
1699 - );
1700 -
1701 - $use_filters = true;
1702 - }
1703 -
2448 + /**
2449 + * If needed set the `fields` ES query clause.
2450 + *
2451 + * @since 4.4.0
2452 + * @param array $formatted_args Formatted Elasticsearch query
2453 + * @param array $args WP_Query arguments
2454 + * @return array
2455 + */
2456 + protected function maybe_set_fields( $formatted_args, $args ) {
1704 2457 /**
1705 - * Like WP_Query in search context, if no post_status is specified we default to "any". To
1706 - * be safe you should ALWAYS specify the post_status parameter UNLIKE with WP_Query.
1707 - *
1708 - * @since 2.1
1709 - */
1710 - if ( ! empty( $args['post_status'] ) ) {
1711 - // should NEVER be "any" but just in case
1712 - if ( 'any' !== $args['post_status'] ) {
1713 - $post_status = (array) ( is_string( $args['post_status'] ) ? explode( ',', $args['post_status'] ) : $args['post_status'] );
1714 - $post_status = array_map( 'trim', $post_status );
1715 - $terms_map_name = 'terms';
1716 - if ( count( $post_status ) < 2 ) {
1717 - $terms_map_name = 'term';
1718 - $post_status = $post_status[0];
1719 - }
1720 -
1721 - $filter['bool']['must'][] = array(
1722 - $terms_map_name => array(
1723 - 'post_status' => $post_status,
1724 - ),
1725 - );
1726 -
1727 - $use_filters = true;
1728 - }
1729 - } else {
1730 - $statuses = get_post_stati( array( 'public' => true ) );
1731 -
1732 - if ( is_admin() ) {
1733 - /**
1734 - * In the admin we will add protected and private post statuses to the default query
1735 - * per WP default behavior.
1736 - */
1737 - $statuses = array_merge(
1738 - $statuses,
1739 - get_post_stati(
1740 - array(
1741 - 'protected' => true,
1742 - 'show_in_admin_all_list' => true,
1743 - )
1744 - )
1745 - );
1746 -
1747 - if ( is_user_logged_in() ) {
1748 - $statuses = array_merge( $statuses, get_post_stati( array( 'private' => true ) ) );
1749 - }
1750 - }
1751 -
1752 - $statuses = array_values( $statuses );
1753 -
1754 - $post_status_filter_type = 'terms';
1755 -
1756 - $filter['bool']['must'][] = array(
1757 - $post_status_filter_type => array(
1758 - 'post_status' => $statuses,
1759 - ),
1760 - );
1761 -
1762 - $use_filters = true;
1763 - }
1764 -
1765 - if ( isset( $args['offset'] ) ) {
1766 - $formatted_args['from'] = (int) $args['offset'];
1767 - }
1768 -
1769 - if ( isset( $args['paged'] ) && $args['paged'] > 1 ) {
1770 - $formatted_args['from'] = $args['posts_per_page'] * ( $args['paged'] - 1 );
1771 - }
1772 -
1773 - /**
1774 - * Fix negative offset. This happens, for example, on hierarchical post types.
1775 - *
1776 - * Ref: https://github.com/10up/ElasticPress/issues/2480
1777 - */
1778 - if ( $formatted_args['from'] < 0 ) {
1779 - $formatted_args['from'] = 0;
1780 - }
1781 -
1782 - if ( $use_filters ) {
1783 - $formatted_args['post_filter'] = $filter;
1784 - }
1785 -
1786 - /**
1787 2458 * Support fields.
1788 2459 */
1789 2460 if ( isset( $args['fields'] ) ) {
1790 2461 switch ( $args['fields'] ) {
@@ -1806,8 +2477,21 @@
1806 2477 break;
1807 2478 }
1808 2479 }
1809 2480
2481 + return $formatted_args;
2482 + }
2483 +
2484 + /**
2485 + * If needed set the `aggs` ES query clause.
2486 + *
2487 + * @since 4.4.0
2488 + * @param array $formatted_args Formatted Elasticsearch query.
2489 + * @param array $args WP_Query arguments
2490 + * @param array $filters Filters to be applied to the ES query
2491 + * @return array
2492 + */
2493 + protected function maybe_set_aggs( $formatted_args, $args, $filters ) {
1810 2494 /**
1811 2495 * Aggregations
1812 2496 */
1813 2497 if ( ! empty( $args['aggs'] ) && is_array( $args['aggs'] ) ) {
@@ -1817,447 +2501,584 @@
1817 2501 $has_only_num_keys = count( $agg_num_keys ) === count( $args['aggs'] );
1818 2502
1819 2503 if ( $has_only_num_keys ) {
1820 2504 foreach ( $args['aggs'] as $agg ) {
1821 - $formatted_args = $this->apply_aggregations( $formatted_args, $agg, $use_filters, $filter );
2505 + $formatted_args = $this->apply_aggregations( $formatted_args, $agg, ! empty( $filters ), $filters );
1822 2506 }
1823 2507 } else {
1824 2508 // Single aggregation.
1825 - $formatted_args = $this->apply_aggregations( $formatted_args, $args['aggs'], $use_filters, $filter );
2509 + $formatted_args = $this->apply_aggregations( $formatted_args, $args['aggs'], ! empty( $filters ), $filters );
1826 2510 }
1827 2511 }
1828 2512
1829 - /**
1830 - * Filter formatted Elasticsearch [ost ]query (entire query)
1831 - *
1832 - * @hook ep_formatted_args
1833 - * @param {array} $formatted_args Formatted Elasticsearch query
1834 - * @param {array} $query_vars Query variables
1835 - * @param {array} $query Query part
1836 - * @return {array} New query
1837 - */
1838 - $formatted_args = apply_filters( 'ep_formatted_args', $formatted_args, $args, $wp_query );
2513 + return $formatted_args;
2514 + }
1839 2515
1840 - /**
1841 - * Filter formatted Elasticsearch [ost ]query (entire query)
1842 - *
1843 - * @hook ep_post_formatted_args
1844 - * @param {array} $formatted_args Formatted Elasticsearch query
1845 - * @param {array} $query_vars Query variables
1846 - * @param {array} $query Query part
1847 - * @return {array} New query
1848 - */
1849 - $formatted_args = apply_filters( 'ep_post_formatted_args', $formatted_args, $args, $wp_query );
2516 + /**
2517 + * Parse tax query field value.
2518 + *
2519 + * @since 4.4.0
2520 + * @param string $field Field name
2521 + * @return string
2522 + */
2523 + protected function parse_tax_query_field( string $field ): string {
1850 2524
1851 - return $formatted_args;
2525 + $from_to = [
2526 + 'name' => 'name.raw',
2527 + 'slug' => 'slug',
2528 + 'term_taxonomy_id' => 'term_taxonomy_id',
2529 + ];
2530 +
2531 + return $from_to[ $field ] ?? 'term_id';
1852 2532 }
1853 2533
1854 2534 /**
1855 - * Adjust the fuzziness parameter if needed.
2535 + * Filter a list of meta keys down to those chosen by the user or
2536 + * allowed via a hook.
1856 2537 *
1857 - * If using fields with type `long`, queries should not have a fuzziness parameter.
2538 + * This function is used when manual management of metadata fields is
2539 + * enabled. This is the default behaviour as of 5.0.0 and controlled by the
2540 + * `ep_meta_mode` filter.
1858 2541 *
1859 - * @param array $query Current query
1860 - * @param array $query_vars Query variables
1861 - * @param string $search_text Search text
1862 - * @param array $search_fields Search fields
1863 - * @return array New query
2542 + * @param array $metas Key => value pairs of post meta
2543 + * @param WP_Post $post Post object
2544 + * @since 5.0.0
2545 + * @return array
1864 2546 */
1865 - public function adjust_query_fuzziness( $query, $query_vars, $search_text, $search_fields ) {
1866 - if ( empty( array_intersect( $search_fields, [ 'ID', 'post_id', 'post_parent' ] ) ) ) {
1867 - return $query;
2547 + protected function filter_allowed_metas_manual( $metas, $post ) {
2548 + $filtered_metas = [];
2549 + $search_feature = \ElasticPress\Features::factory()->get_registered_feature( 'search' );
2550 +
2551 + if ( empty( $post->post_type ) ) {
2552 + return $filtered_metas;
1868 2553 }
1869 2554
1870 - if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['should'] ) ) {
1871 - return $query;
2555 + $weighting = $search_feature->weighting->get_weighting_configuration_with_defaults();
2556 + $is_searchable = in_array( $search_feature, $search_feature->get_searchable_post_types(), true );
2557 + if ( empty( $weighting[ $post->post_type ] ) && $is_searchable ) {
2558 + return $filtered_metas;
1872 2559 }
1873 2560
1874 - foreach ( $query['bool']['should'] as &$clause ) {
1875 - if ( ! isset( $clause['multi_match'] ) ) {
2561 + /** This filter is documented in includes/classes/Indexable/Post/Post.php */
2562 + $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], $post );
2563 +
2564 + $selected_keys = [];
2565 + if ( ! empty( $weighting[ $post->post_type ] ) ) {
2566 + $selected_keys = array_map(
2567 + function ( $field ) {
2568 + if ( false === strpos( $field, 'meta.' ) ) {
2569 + return null;
2570 + }
2571 + $field_name_parts = explode( '.', $field );
2572 + return $field_name_parts[1];
2573 + },
2574 + array_keys( $weighting[ $post->post_type ] )
2575 + );
2576 + $selected_keys = array_filter( $selected_keys );
2577 + }
2578 +
2579 + /**
2580 + * Filter indexable meta keys for posts
2581 + *
2582 + * @hook ep_prepare_meta_allowed_keys
2583 + * @param {array} $keys Allowed keys
2584 + * @param {WP_Post} $post Post object
2585 + * @since 5.0.0
2586 + * @return {array} New keys
2587 + */
2588 + $allowed_keys = apply_filters( 'ep_prepare_meta_allowed_keys', array_merge( $allowed_protected_keys, $selected_keys ), $post );
2589 +
2590 + foreach ( $metas as $key => $value ) {
2591 + if ( ! in_array( $key, $allowed_keys, true ) ) {
1876 2592 continue;
1877 2593 }
1878 2594
1879 - if ( isset( $clause['multi_match']['fuzziness'] ) ) {
1880 - unset( $clause['multi_match']['fuzziness'] );
1881 - }
2595 + $filtered_metas[ $key ] = $value;
1882 2596 }
1883 2597
1884 - return $query;
2598 + return $filtered_metas;
1885 2599 }
1886 2600
1887 2601 /**
1888 - * Parse and build out our tax query.
2602 + * Filter a list of meta keys down to public keys or protected keys
2603 + * allowed via a hook.
1889 2604 *
1890 - * @access protected
2605 + * This function is used to filter meta keys when ElasticPress is in
2606 + * network mode or when the meta mode is set to `auto` via the
2607 + * `ep_meta_mode` hook. This was the default behaviour prior to 5.0.0.
1891 2608 *
1892 - * @param array $query Tax query
2609 + * @param array $metas Key => value pairs of post meta
2610 + * @param WP_Post $post Post object
2611 + * @since 5.0.0
1893 2612 * @return array
1894 2613 */
1895 - protected function parse_tax_query( $query ) {
1896 - $tax_query = [
1897 - 'tax_filter' => [],
1898 - 'tax_must_not_filter' => [],
1899 - ];
1900 - $relation = '';
2614 + protected function filter_allowed_metas_auto( $metas, $post ) {
2615 + $filtered_metas = [];
1901 2616
1902 - foreach ( $query as $tax_queries ) {
1903 - // If we have a nested tax query, recurse through that
1904 - if ( is_array( $tax_queries ) && empty( $tax_queries['taxonomy'] ) ) {
1905 - $result = $this->parse_tax_query( $tax_queries );
1906 - $relation = ( ! empty( $tax_queries['relation'] ) ) ? strtolower( $tax_queries['relation'] ) : 'and';
1907 - $filter_type = 'and' === $relation ? 'must' : 'should';
2617 + /**
2618 + * Filter indexable protected meta keys for posts
2619 + *
2620 + * @hook ep_prepare_meta_allowed_protected_keys
2621 + * @param {array} $keys Allowed protected keys
2622 + * @param {WP_Post} $post Post object
2623 + * @since 1.7
2624 + * @return {array} New keys
2625 + */
2626 + $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], $post );
1908 2627
1909 - // Set the proper filter type and must_not filter, as needed
1910 - if ( ! empty( $result['tax_must_not_filter'] ) ) {
1911 - $tax_query['tax_filter'][] = [
1912 - 'bool' => [
1913 - $filter_type => $result['tax_filter'],
1914 - 'must_not' => $result['tax_must_not_filter'],
1915 - ],
1916 - ];
1917 - } else {
1918 - $tax_query['tax_filter'][] = [
1919 - 'bool' => [
1920 - $filter_type => $result['tax_filter'],
1921 - ],
1922 - ];
2628 + /**
2629 + * Filter public keys to exclude from indexed post
2630 + *
2631 + * @hook ep_prepare_meta_excluded_public_keys
2632 + * @param {array} $keys Excluded protected keys
2633 + * @param {WP_Post} $post Post object
2634 + * @since 1.7
2635 + * @return {array} New keys
2636 + */
2637 + $excluded_public_keys = apply_filters( 'ep_prepare_meta_excluded_public_keys', [], $post );
2638 +
2639 + foreach ( $metas as $key => $value ) {
2640 +
2641 + $allow_index = false;
2642 +
2643 + if ( is_protected_meta( $key ) ) {
2644 +
2645 + if ( true === $allowed_protected_keys || in_array( $key, $allowed_protected_keys, true ) ) {
2646 + $allow_index = true;
1923 2647 }
2648 + } elseif ( true !== $excluded_public_keys && ! in_array( $key, $excluded_public_keys, true ) ) {
2649 +
2650 + $allow_index = true;
1924 2651 }
1925 2652
1926 - // Parse each individual tax query part
1927 - $single_tax_query = $tax_queries;
1928 - if ( ! empty( $single_tax_query['taxonomy'] ) ) {
1929 - $terms = isset( $single_tax_query['terms'] ) ? (array) $single_tax_query['terms'] : array();
1930 - $field = ( ! empty( $single_tax_query['field'] ) ) ? $single_tax_query['field'] : 'term_id';
2653 + /**
2654 + * Filter force whitelisting a meta key
2655 + *
2656 + * @hook ep_prepare_meta_whitelist_key
2657 + * @param {bool} $whitelist True to whitelist key
2658 + * @param {string} $key Meta key
2659 + * @param {WP_Post} $post Post object
2660 + * @return {bool} New whitelist value
2661 + */
2662 + if ( true === $allow_index || apply_filters( 'ep_prepare_meta_whitelist_key', false, $key, $post ) ) {
2663 + $filtered_metas[ $key ] = $value;
2664 + }
2665 + }
2666 + return $filtered_metas;
2667 + }
1931 2668
1932 - if ( 'name' === $field ) {
1933 - $field = 'name.raw';
1934 - }
2669 + /**
2670 + * Return all distinct meta fields in the database.
2671 + *
2672 + * @since 4.4.0
2673 + * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2674 + * @return array
2675 + */
2676 + public function get_distinct_meta_field_keys_db( bool $force_refresh = false ): array {
2677 + global $wpdb;
1935 2678
1936 - // Set up our terms object
1937 - $terms_obj = array(
1938 - 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => array_values( array_filter( $terms ) ),
1939 - );
2679 + /**
2680 + * Short-circuits the process of getting distinct meta keys from the database.
2681 + *
2682 + * Returning a non-null value will effectively short-circuit the function.
2683 + *
2684 + * @since 4.4.0
2685 + * @hook ep_post_pre_meta_keys_db
2686 + * @param {null} $meta_keys Distinct meta keys array
2687 + * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2688 + */
2689 + $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db', null );
2690 + if ( null !== $pre_meta_keys ) {
2691 + return $pre_meta_keys;
2692 + }
1940 2693
1941 - $operator = ( ! empty( $single_tax_query['operator'] ) ) ? strtolower( $single_tax_query['operator'] ) : 'in';
2694 + $cache_key = 'ep_meta_field_keys';
1942 2695
1943 - switch ( $operator ) {
1944 - case 'exists':
1945 - /**
1946 - * add support for "EXISTS" operator
1947 - *
1948 - * @since 2.5
1949 - */
1950 - $tax_query['tax_filter'][]['bool'] = array(
1951 - 'must' => array(
1952 - array(
1953 - 'exists' => array(
1954 - 'field' => key( $terms_obj ),
1955 - ),
1956 - ),
1957 - ),
1958 - );
2696 + if ( ! $force_refresh ) {
2697 + $cached = get_transient( $cache_key );
2698 + if ( false !== $cached ) {
2699 + $cached = (array) json_decode( (string) $cached );
2700 + /* this filter is documented below */
2701 + return (array) apply_filters( 'ep_post_meta_keys_db', $cached );
2702 + }
2703 + }
1959 2704
1960 - break;
1961 - case 'not exists':
1962 - /**
1963 - * add support for "NOT EXISTS" operator
1964 - *
1965 - * @since 2.5
1966 - */
1967 - $tax_query['tax_filter'][]['bool'] = array(
1968 - 'must_not' => array(
1969 - array(
1970 - 'exists' => array(
1971 - 'field' => key( $terms_obj ),
1972 - ),
1973 - ),
1974 - ),
1975 - );
2705 + /**
2706 + * To avoid running a too expensive SQL query, we run a query getting all public keys
2707 + * and only the private keys allowed by the `ep_prepare_meta_allowed_protected_keys` filter.
2708 + * This query does not order by on purpose, as that also brings a performance penalty.
2709 + */
2710 + $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], new \WP_Post( (object) [] ) );
2711 + $allowed_protected_keys_sql = '';
2712 + if ( ! empty( $allowed_protected_keys ) ) {
2713 + $placeholders = implode( ',', array_fill( 0, count( $allowed_protected_keys ), '%s' ) );
2714 + $allowed_protected_keys_sql = " OR meta_key IN ( {$placeholders} ) ";
2715 + }
1976 2716
1977 - break;
1978 - case 'not in':
1979 - /**
1980 - * add support for "NOT IN" operator
1981 - *
1982 - * @since 2.1
1983 - */
1984 - // If "NOT IN" than it should filter as must_not
1985 - $tax_query['tax_must_not_filter'][]['terms'] = $terms_obj;
2717 + // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
2718 + $meta_keys = $wpdb->get_col(
2719 + $wpdb->prepare(
2720 + "SELECT DISTINCT meta_key
2721 + FROM {$wpdb->postmeta}
2722 + WHERE meta_key NOT LIKE %s {$allowed_protected_keys_sql}
2723 + LIMIT 800",
2724 + '\_%',
2725 + ...$allowed_protected_keys
2726 + )
2727 + );
2728 + // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
1986 2729
1987 - break;
1988 - case 'and':
1989 - /**
1990 - * add support for "and" operator
1991 - *
1992 - * @since 2.4
1993 - */
1994 - $and_nest = array(
1995 - 'bool' => array(
1996 - 'must' => array(),
1997 - ),
1998 - );
2730 + sort( $meta_keys );
1999 2731
2000 - foreach ( $terms as $term ) {
2001 - $and_nest['bool']['must'][] = array(
2002 - 'terms' => array(
2003 - 'terms.' . $single_tax_query['taxonomy'] . '.' . $field => (array) $term,
2004 - ),
2005 - );
2006 - }
2732 + // Make sure the size of the transient will not be bigger than 1MB
2733 + do {
2734 + $transient_size = strlen( wp_json_encode( $meta_keys ) );
2735 + if ( $transient_size >= MB_IN_BYTES ) {
2736 + array_pop( $meta_keys );
2737 + } else {
2738 + break;
2739 + }
2740 + } while ( true );
2741 + set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2007 2742
2008 - $tax_query['tax_filter'][] = $and_nest;
2743 + /**
2744 + * Filter the distinct meta keys fetched from the database.
2745 + *
2746 + * @since 4.4.0
2747 + * @hook ep_post_meta_keys_db
2748 + * @param {array} $meta_keys Distinct meta keys array
2749 + * @return {array} New distinct meta keys array
2750 + */
2751 + return (array) apply_filters( 'ep_post_meta_keys_db', $meta_keys );
2752 + }
2009 2753
2010 - break;
2011 - case 'in':
2012 - default:
2013 - /**
2014 - * Default to IN operator
2015 - */
2016 - // Add the tax query filter
2017 - $tax_query['tax_filter'][]['terms'] = $terms_obj;
2754 + /**
2755 + * Return all distinct meta fields in the database per post type.
2756 + *
2757 + * @since 4.4.0
2758 + * @param string $post_type Post type slug
2759 + * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2760 + * @return array
2761 + */
2762 + public function get_distinct_meta_field_keys_db_per_post_type( string $post_type, bool $force_refresh = false ): array {
2763 + $allowed_screen = 'status-report' === \ElasticPress\Screen::factory()->get_current_screen();
2018 2764
2019 - break;
2020 - }
2765 + /**
2766 + * Filter if the current screen is allowed or not to use the function.
2767 + *
2768 + * This method can be too resource intensive, use it with caution.
2769 + *
2770 + * @since 4.4.0
2771 + * @hook ep_post_meta_keys_db_per_post_type_allowed_screen
2772 + * @param {bool} $allowed_screen Whether this is an allowed screen or not.
2773 + * @return {bool} New value of $allowed_screen
2774 + */
2775 + if ( ! apply_filters( 'ep_post_meta_keys_db_per_post_type_allowed_screen', $allowed_screen ) ) {
2776 + _doing_it_wrong(
2777 + __METHOD__,
2778 + esc_html__( 'This method should not be called outside specific pages. Use the `ep_post_meta_keys_db_per_post_type_allowed_screen` filter if you need to use it in your custom screen.' ),
2779 + 'ElasticPress 4.4.0'
2780 + );
2781 + return [];
2782 + }
2783 +
2784 + /**
2785 + * Short-circuits the process of getting distinct meta keys from the database per post type.
2786 + *
2787 + * Returning a non-null value will effectively short-circuit the function.
2788 + *
2789 + * @since 4.4.0
2790 + * @hook ep_post_pre_meta_keys_db_per_post_type
2791 + * @param {null} $meta_keys Distinct meta keys array
2792 + * @param {string} $post_type Post type slug
2793 + * @return {null|array} Distinct meta keys array or `null` to keep default behavior
2794 + */
2795 + $pre_meta_keys = apply_filters( 'ep_post_pre_meta_keys_db_per_post_type', null, $post_type );
2796 + if ( null !== $pre_meta_keys ) {
2797 + return $pre_meta_keys;
2798 + }
2799 +
2800 + $cache_key = 'ep_meta_field_keys_' . $post_type;
2801 +
2802 + if ( ! $force_refresh ) {
2803 + $cached = get_transient( $cache_key );
2804 + if ( false !== $cached ) {
2805 + $cached = (array) json_decode( (string) $cached );
2806 + /* this filter is documented below */
2807 + return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $cached, $post_type );
2021 2808 }
2022 2809 }
2023 2810
2024 - return $tax_query;
2811 + $meta_keys = [];
2812 + $post_ids_batches = $this->get_lazy_post_type_ids( $post_type );
2813 + foreach ( $post_ids_batches as $post_ids ) {
2814 + $new_meta_keys = $this->get_meta_keys_from_post_ids( $post_ids );
2815 +
2816 + $meta_keys = array_unique( array_merge( $meta_keys, $new_meta_keys ) );
2817 + }
2818 +
2819 + // Make sure the size of the transient will not be bigger than 1MB
2820 + do {
2821 + $transient_size = strlen( wp_json_encode( $meta_keys ) );
2822 + if ( $transient_size >= MB_IN_BYTES ) {
2823 + array_pop( $meta_keys );
2824 + } else {
2825 + break;
2826 + }
2827 + } while ( true );
2828 + set_transient( $cache_key, wp_json_encode( $meta_keys ), DAY_IN_SECONDS );
2829 +
2830 + /**
2831 + * Filter the distinct meta keys fetched from the database per post type.
2832 + *
2833 + * @since 4.4.0
2834 + * @hook ep_post_meta_keys_db_per_post_type
2835 + * @param {array} $meta_keys Distinct meta keys array
2836 + * @param {string} $post_type Post type slug
2837 + * @return {array} New distinct meta keys array
2838 + */
2839 + return (array) apply_filters( 'ep_post_meta_keys_db_per_post_type', $meta_keys, $post_type );
2025 2840 }
2026 2841
2027 2842 /**
2028 - * Parse an 'order' query variable and cast it to ASC or DESC as necessary.
2843 + * Return all distinct meta fields in the database per post type.
2029 2844 *
2030 - * @since 1.1
2031 - * @access protected
2032 - *
2033 - * @param string $order The 'order' query variable.
2034 - * @return string The sanitized 'order' query variable.
2845 + * @since 4.4.0
2846 + * @param string $post_type Post type slug
2847 + * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2848 + * @return array
2035 2849 */
2036 - protected function parse_order( $order ) {
2037 - // Core will always set sort order to DESC for any invalid value,
2038 - // so we can't do any automated testing of this function.
2039 - // @codeCoverageIgnoreStart
2040 - if ( ! is_string( $order ) || empty( $order ) ) {
2041 - return 'desc';
2042 - }
2043 - // @codeCoverageIgnoreEnd
2850 + public function get_indexable_meta_keys_per_post_type( string $post_type, bool $force_refresh = false ): array {
2851 + $mock_post = new \WP_Post( (object) [ 'post_type' => $post_type ] );
2852 + $meta_keys = $this->get_distinct_meta_field_keys_db_per_post_type( $post_type, $force_refresh );
2044 2853
2045 - if ( 'ASC' === strtoupper( $order ) ) {
2046 - return 'asc';
2047 - } else {
2048 - return 'desc';
2049 - }
2854 + $fake_meta_values = array_combine( $meta_keys, array_fill( 0, count( $meta_keys ), 'test-value' ) );
2855 + $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $mock_post );
2856 +
2857 + return array_filter(
2858 + array_keys( $filtered_meta ),
2859 + function ( $meta_key ) use ( $mock_post ) {
2860 + return $this->is_meta_allowed( $meta_key, $mock_post );
2861 + }
2862 + );
2050 2863 }
2051 2864
2052 2865 /**
2053 - * Convert the alias to a properly-prefixed sort value.
2866 + * Return the meta keys that will (possibly) be indexed.
2054 2867 *
2055 - * @since 1.1
2056 - * @access protected
2868 + * This function gets all the meta keys in the database, creates a fake post without a type and with all the meta fields,
2869 + * runs the `ep_prepare_meta_data` filter against it and checks if meta keys are allowed or not.
2870 + * Although it provides a good indicator, it is not 100% correct as developers could create code using the
2871 + * `ep_prepare_meta_data` filter that would depend on "real" data.
2057 2872 *
2058 - * @param string $orderbys Alias or path for the field to order by.
2059 - * @param string $default_order Default order direction
2060 - * @param array $args Query args
2873 + * @since 4.4.0
2874 + * @param bool $force_refresh Whether to use or not a cached value. Default false, use cached.
2061 2875 * @return array
2062 2876 */
2063 - protected function parse_orderby( $orderbys, $default_order, $args ) {
2064 - $orderbys = $this->get_orderby_array( $orderbys );
2877 + public function get_predicted_indexable_meta_keys( bool $force_refresh = false ): array {
2878 + $empty_post = new \WP_Post( (object) [] );
2879 + $meta_keys = $this->get_distinct_meta_field_keys_db( $force_refresh );
2065 2880
2066 - $sort = [];
2881 + $fake_meta_values = array_combine(
2882 + $meta_keys,
2883 + array_fill( 0, count( $meta_keys ), $this->get_test_meta_value() )
2884 + );
2885 + $filtered_meta = apply_filters( 'ep_prepare_meta_data', $fake_meta_values, $empty_post );
2067 2886
2068 - foreach ( $orderbys as $key => $value ) {
2069 - if ( is_string( $key ) ) {
2070 - $orderby_clause = $key;
2071 - $order = $value;
2072 - } else {
2073 - $orderby_clause = $value;
2074 - $order = $default_order;
2887 + $all_keys = array_filter(
2888 + array_keys( $filtered_meta ),
2889 + function ( $meta_key ) use ( $empty_post ) {
2890 + return $this->is_meta_allowed( $meta_key, $empty_post );
2075 2891 }
2892 + );
2076 2893
2077 - if ( ! empty( $orderby_clause ) && 'rand' !== $orderby_clause ) {
2078 - if ( 'relevance' === $orderby_clause ) {
2079 - $sort[] = array(
2080 - '_score' => array(
2081 - 'order' => $order,
2082 - ),
2083 - );
2084 - } elseif ( 'date' === $orderby_clause ) {
2085 - $sort[] = array(
2086 - 'post_date' => array(
2087 - 'order' => $order,
2088 - ),
2089 - );
2090 - } elseif ( 'type' === $orderby_clause ) {
2091 - $sort[] = array(
2092 - 'post_type.raw' => array(
2093 - 'order' => $order,
2094 - ),
2095 - );
2096 - } elseif ( 'modified' === $orderby_clause ) {
2097 - $sort[] = array(
2098 - 'post_modified' => array(
2099 - 'order' => $order,
2100 - ),
2101 - );
2102 - } elseif ( 'name' === $orderby_clause ) {
2103 - $sort[] = array(
2104 - 'post_' . $orderby_clause . '.raw' => array(
2105 - 'order' => $order,
2106 - ),
2107 - );
2108 - } elseif ( 'title' === $orderby_clause ) {
2109 - $sort[] = array(
2110 - 'post_' . $orderby_clause . '.sortable' => array(
2111 - 'order' => $order,
2112 - ),
2113 - );
2114 - } elseif ( 'meta_value' === $orderby_clause ) {
2115 - if ( ! empty( $args['meta_key'] ) ) {
2116 - $sort[] = array(
2117 - 'meta.' . $args['meta_key'] . '.raw' => array(
2118 - 'order' => $order,
2119 - ),
2120 - );
2121 - }
2122 - } elseif ( 'meta_value_num' === $orderby_clause ) {
2123 - if ( ! empty( $args['meta_key'] ) ) {
2124 - $sort[] = array(
2125 - 'meta.' . $args['meta_key'] . '.long' => array(
2126 - 'order' => $order,
2127 - ),
2128 - );
2129 - }
2130 - } else {
2131 - $sort[] = array(
2132 - $orderby_clause => array(
2133 - 'order' => $order,
2134 - ),
2135 - );
2136 - }
2137 - }
2138 - }
2894 + sort( $all_keys );
2139 2895
2140 - return $sort;
2896 + return $all_keys;
2141 2897 }
2142 2898
2143 2899 /**
2144 - * Get Order by args Array
2900 + * Return the value used to fill meta fields while predicting indexable content.
2145 2901 *
2146 - * @param string|array $orderbys Order by string or array
2147 - * @since 2.1
2148 - * @return array
2902 + * @since 5.1.0
2903 + * @return string
2149 2904 */
2150 - protected function get_orderby_array( $orderbys ) {
2151 - if ( ! is_array( $orderbys ) ) {
2152 - $orderbys = explode( ' ', $orderbys );
2153 - }
2154 -
2155 - return $orderbys;
2905 + public function get_test_meta_value(): string {
2906 + /**
2907 + * Filter the value used to fill meta fields while predicting indexable content.
2908 + *
2909 + * @hook ep_post_test_meta_value
2910 + * @since 5.1.0
2911 + * @param {string} $test_meta_value The test meta value. Default: test-value
2912 + * @return {string} New test meta value
2913 + */
2914 + return (string) apply_filters( 'ep_post_test_meta_value', 'test-value' );
2156 2915 }
2157 2916
2158 2917 /**
2159 - * Given a mapping content, try to determine the version used.
2918 + * Given a post type, *yields* their Post IDs.
2160 2919 *
2161 - * @since 3.6.3
2920 + * If post IDs are found, this function will return a PHP Generator. To avoid timeout, it will yield 8 groups or 11,000 IDs.
2162 2921 *
2163 - * @param array $mapping Mapping content.
2164 - * @param string $index Index name
2165 - * @return string Version of the mapping being used.
2922 + * @since 4.4.0
2923 + * @see https://www.php.net/manual/en/language.generators.overview.php
2924 + * @param string $post_type The post type slug
2925 + * @return iterator
2166 2926 */
2167 - protected function determine_mapping_version_based_on_existing( $mapping, $index ) {
2168 - if ( isset( $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'] ) ) {
2169 - return $mapping[ $index ]['mappings']['post']['_meta']['mapping_version'];
2927 + protected function get_lazy_post_type_ids( string $post_type ) {
2928 + global $wpdb;
2929 +
2930 + $total = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2931 + $wpdb->prepare(
2932 + "SELECT count(*) FROM {$wpdb->posts} WHERE post_type = %s",
2933 + $post_type
2934 + )
2935 + );
2936 +
2937 + if ( ! $total ) {
2938 + return [];
2170 2939 }
2171 - if ( isset( $mapping[ $index ]['mappings']['_meta']['mapping_version'] ) ) {
2172 - return $mapping[ $index ]['mappings']['_meta']['mapping_version'];
2173 - }
2174 2940
2175 2941 /**
2176 - * Check for 7-0 mapping.
2177 - * If mapping has a `post` type, it can't be ES 7, as mapping types were removed in that release.
2942 + * Filter the number of IDs to be fetched per page to discover distinct meta fields per post type.
2178 2943 *
2179 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html
2944 + * @hook ep_post_meta_by_type_ids_per_page
2945 + * @since 4.4.0
2946 + * @param {int} $per_page Number of IDs
2947 + * @param {string} $post_type The post type slug
2948 + * @return {string} New number of IDs
2180 2949 */
2181 - if ( ! isset( $mapping[ $index ]['mappings']['post'] ) ) {
2182 - return '7-0.php';
2183 - }
2950 + $per_page = apply_filters( 'ep_post_meta_by_type_ids_per_page', 11000, $post_type );
2184 2951
2185 - $post_mapping = $mapping[ $index ]['mappings']['post'];
2952 + $pages = min( ceil( $total / $per_page ), 8 );
2186 2953
2187 2954 /**
2188 - * Starting at this point, our tests rely on the post_title.fields.sortable field.
2189 - * As this field is present in all our mappings, if this field is not present in
2190 - * the mapping, this is a custom mapping.
2955 + * Filter the number of times EP will fetch IDs from the database
2191 2956 *
2192 - * To have this code working with custom mappings, use the `ep_post_mapping_version_determined` filter.
2957 + * @hook ep_post_meta_by_type_number_of_pages
2958 + * @since 4.4.0
2959 + * @param {int} $pages Number of "pages" (not WP post type)
2960 + * @param {int} $per_page Number of IDs per page
2961 + * @param {string} $post_type The post type slug
2962 + * @return {string} New number of pages
2193 2963 */
2194 - if ( ! isset( $post_mapping['properties']['post_title']['fields']['sortable'] ) ) {
2195 - return 'unknown';
2964 + $pages = apply_filters( 'ep_post_meta_by_type_number_of_pages', $pages, $per_page, $post_type );
2965 +
2966 + for ( $page = 0; $page < $pages; $page++ ) {
2967 + $start = $per_page * $page;
2968 + $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2969 + $wpdb->prepare(
2970 + "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s LIMIT %d, %d",
2971 + $post_type,
2972 + $start,
2973 + $per_page
2974 + )
2975 + );
2976 + yield $ids;
2196 2977 }
2978 + }
2197 2979
2198 - $post_title_sortable = $post_mapping['properties']['post_title']['fields']['sortable'];
2980 + /**
2981 + * Given a set of post IDs, return distinct meta keys associated with them.
2982 + *
2983 + * @since 4.4.0
2984 + * @param array $post_ids Set of post IDs
2985 + * @return array
2986 + */
2987 + protected function get_meta_keys_from_post_ids( array $post_ids ): array {
2988 + global $wpdb;
2199 2989
2200 - /**
2201 - * Check for 5-2 mapping.
2202 - * Normalizers on keyword fields were only made available in ES 5.2
2203 - *
2204 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.2/release-notes-5.2.0.html
2205 - */
2206 - if ( isset( $post_title_sortable['normalizer'] ) ) {
2207 - return '5-2.php';
2990 + if ( empty( $post_ids ) ) {
2991 + return [];
2208 2992 }
2209 2993
2210 - /**
2211 - * Check for 5-0 mapping.
2212 - * `keyword` fields were only made available in ES 5.0
2213 - *
2214 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
2215 - */
2216 - if ( 'keyword' === $post_title_sortable['type'] ) {
2217 - return '5-0.php';
2994 + $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
2995 + $meta_keys = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
2996 + $wpdb->prepare(
2997 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
2998 + "SELECT DISTINCT meta_key FROM {$wpdb->postmeta} WHERE post_id IN ( {$placeholders} )",
2999 + $post_ids
3000 + )
3001 + );
3002 +
3003 + return $meta_keys;
3004 + }
3005 +
3006 + /**
3007 + * Add a `term_suggest` field to the mapping.
3008 + *
3009 + * This method assumes the `edge_ngram_analyzer` analyzer was already added to the mapping.
3010 + *
3011 + * @since 4.5.0
3012 + * @param array $mapping The mapping array
3013 + * @return array
3014 + */
3015 + public function add_term_suggest_field( array $mapping ): array {
3016 + if ( version_compare( (string) Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
3017 + $mapping_properties = &$mapping['mappings']['post']['properties'];
3018 + } else {
3019 + $mapping_properties = &$mapping['mappings']['properties'];
2218 3020 }
2219 3021
2220 - /**
2221 - * Check for pre-5-0 mapping.
2222 - * `string` fields were deprecated in ES 5.0 in favor of text/keyword
2223 - *
2224 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/5.0/release-notes-5.0.0.html
2225 - */
2226 - if ( 'string' === $post_title_sortable['type'] ) {
2227 - return 'pre-5-0.php';
2228 - }
3022 + $text_type = $mapping_properties['post_content']['type'];
2229 3023
2230 - return 'unknown';
3024 + $mapping_properties['term_suggest'] = array(
3025 + 'type' => $text_type,
3026 + 'analyzer' => 'edge_ngram_analyzer',
3027 + 'search_analyzer' => 'standard',
3028 + );
3029 +
3030 + return $mapping;
2231 3031 }
2232 3032
2233 3033 /**
2234 - * Given ES args, add aggregations to it.
3034 + * Return all meta data added to the Weighting Dashboard plus all allowed keys via code.
2235 3035 *
2236 - * @since 4.1.0
2237 - * @param array $formatted_args Formatted Elasticsearch query.
2238 - * @param array $agg Aggregation data.
2239 - * @param boolean $use_filters Whether filters should be used or not.
2240 - * @param array $filter Filters defined so far.
2241 - * @return array Formatted Elasticsearch query with the aggregation added.
3036 + * @since 5.1.4
3037 + * @return array
2242 3038 */
2243 - protected function apply_aggregations( $formatted_args, $agg, $use_filters, $filter ) {
2244 - if ( empty( $agg['aggs'] ) ) {
2245 - return $formatted_args;
2246 - }
3039 + public function get_all_allowed_metas_manual(): array {
3040 + $post_types = \ElasticPress\Indexables::factory()->get( 'post' )->get_indexable_post_types();
3041 + $search_feature = \ElasticPress\Features::factory()->get_registered_feature( 'search' );
3042 + $weighting = $search_feature->weighting->get_weighting_configuration_with_defaults();
3043 + $fake_post = new \WP_Post( new \stdClass() );
2247 3044
2248 - // Add a name to the aggregation if it was passed through
2249 - $agg_name = ( ! empty( $agg['name'] ) ) ? $agg['name'] : 'aggregation_name';
3045 + $all_allowed_metas = [];
3046 + foreach ( $post_types as $post_type ) {
3047 + $fake_post->post_type = $post_type;
3048 + $allowed_protected_keys = apply_filters( 'ep_prepare_meta_allowed_protected_keys', [], $fake_post );
2250 3049
2251 - // Add/use the filter if warranted
2252 - if ( isset( $agg['use-filter'] ) && false !== $agg['use-filter'] && $use_filters ) {
3050 + $selected_keys = [];
3051 + if ( ! empty( $weighting[ $post_type ] ) ) {
3052 + $selected_keys = array_map(
3053 + function ( $field ) {
3054 + if ( false === strpos( $field, 'meta.' ) ) {
3055 + return null;
3056 + }
3057 + $field_name_parts = explode( '.', $field );
3058 + return $field_name_parts[1];
3059 + },
3060 + array_keys( $weighting[ $post_type ] )
3061 + );
3062 + $selected_keys = array_filter( $selected_keys );
3063 + }
2253 3064
2254 - // If a filter is being used, use it on the aggregation as well to receive relevant information to the query
2255 - $formatted_args['aggs'][ $agg_name ]['filter'] = $filter;
2256 - $formatted_args['aggs'][ $agg_name ]['aggs'] = $agg['aggs'];
2257 - } else {
2258 - $formatted_args['aggs'][ $agg_name ] = $agg['aggs'];
3065 + $allowed_keys = apply_filters( 'ep_prepare_meta_allowed_keys', array_merge( $allowed_protected_keys, $selected_keys ), $fake_post );
3066 +
3067 + $all_allowed_metas = array_merge( $all_allowed_metas, $allowed_keys );
2259 3068 }
2260 3069
2261 - return $formatted_args;
3070 + return array_unique( $all_allowed_metas );
3071 + }
3072 +
3073 + /**
3074 + * Sets the ORDER BY clause to sort posts by post ID in descending order.
3075 + *
3076 + * @return string The modified order by clause.
3077 + *
3078 + * @since 5.2.0
3079 + */
3080 + public function set_posts_orderby(): string {
3081 + global $wpdb;
3082 + return "{$wpdb->posts}.ID DESC";
2262 3083 }
2263 3084 }