Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 164
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Query_Builder
0.00% covered (danger)
0.00%
0 / 164
0.00% covered (danger)
0.00%
0 / 20
3192
0.00% covered (danger)
0.00%
0 / 1
 get_langs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 add_filter
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_query
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 add_weighting_function
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 add_function
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_decay
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_score_mode_to_functions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_boost_mode_to_functions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_max_boost_to_functions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_boost_to_query_bool
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_aggs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 set_all_aggs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 add_aggs_sub_aggs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 add_bucketed_query
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 add_bucketed_terms
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 add_bucket_sub_aggs
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_bucket_filter
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 build_query
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
380
 build_filter
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 build_aggregation
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2/**
3 * Provides an interface for easily building a complex search query that
4 * combines multiple ranking signals.
5 *
6 * $bldr = new Query_Builder();
7 * $bldr->add_filter( ... );
8 * $bldr->add_filter( ... );
9 * $bldr->add_query( ... );
10 * $es_query = $bldr->build_query();
11 *
12 *
13 * All ES queries take a standard form with main query (with some filters),
14 *  wrapped in a function_score
15 *
16 * Most functions are chainable, e.g. $bldr->add_filter( ... )->add_query( ... )->build_query();
17 *
18 * Bucketed queries use an aggregation to diversify results. eg a bunch
19 *  of separate filters where to get different sets of results.
20 *
21 * @package    automattic/jetpack-search
22 */
23
24// Disables comment checks.
25// phpcs:disable Squiz.Commenting
26
27namespace Automattic\Jetpack\Search\WPES;
28
29/**
30 * Query builder class.
31 */
32class Query_Builder {
33    /**
34     * ElasticSerach filters.
35     *
36     * @var array
37     */
38    protected $es_filters = array();
39
40    //
41    // Variables for handling custom boosting with function_score.
42    //
43    protected $functions            = array();
44    protected $weighting_functions  = array();
45    protected $decays               = array();
46    protected $scripts              = array();
47    protected $functions_max_boost  = 2.0;
48    protected $functions_score_mode = 'multiply';
49    protected $functions_boost_mode = 'multiply';
50    protected $query_bool_boost     = null;
51
52    //
53    // General aggregations for buckets and metrics.
54    //
55    protected $aggs_query = false;
56    protected $aggs       = array();
57
58    //
59    // The set of top level text queries to combine.
60    //
61    protected $must_queries    = array();
62    protected $should_queries  = array();
63    protected $dis_max_queries = array();
64
65    protected $diverse_buckets_query = false;
66    protected $bucket_filters        = array();
67    protected $bucket_sub_aggs       = array();
68
69    /**
70     * Language codes.
71     *
72     * @var array
73     */
74    private $langs;
75
76    public function get_langs() {
77        if ( isset( $this->langs ) ) {
78            return $this->langs;
79        }
80        return false;
81    }
82
83    //
84    // Methods for building a query.
85    //
86    public function add_filter( $filter ) {
87        $this->es_filters[] = $filter;
88
89        return $this;
90    }
91
92    public function add_query( $query, $type = 'must' ) {
93        switch ( $type ) {
94            case 'dis_max':
95                $this->dis_max_queries[] = $query;
96                break;
97
98            case 'should':
99                $this->should_queries[] = $query;
100                break;
101
102            case 'must':
103            default:
104                $this->must_queries[] = $query;
105                break;
106        }
107
108        return $this;
109    }
110
111    /**
112     * Add any weighting function to the query
113     *
114     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html
115     *
116     * @param $function array A function structure to apply to the query
117     *
118     * @return void
119     */
120    public function add_weighting_function( $function ) {
121        // check for danger.
122        if ( isset( $function['random_score'] ) ) {
123            return $this;
124        }
125        if ( isset( $function['script_score'] ) ) {
126            return $this;
127        }
128
129        $this->weighting_functions[] = $function;
130
131        return $this;
132    }
133
134    /**
135     * Add a scoring function to the query
136     *
137     * NOTE: For decays (linear, exp, or gauss), use Query_Builder::add_decay() instead
138     *
139     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html
140     *
141     * @param $function string name of the function
142     * @param $params array functions parameters
143     *
144     * @return void
145     */
146    public function add_function( $function, $params ) {
147        $this->functions[ $function ][] = $params;
148
149        return $this;
150    }
151
152    /**
153     * Add a decay function to score results
154     *
155     * This method should be used instead of Query_Builder::add_function() for decays, as the internal  ES structure
156     * is slightly different for them.
157     *
158     * @see https://www.elastic.co/guide/en/elasticsearch/guide/current/decay-functions.html
159     *
160     * @param $function string name of the decay function - linear, exp, or gauss
161     * @param $params array The decay functions parameters, passed to ES directly
162     *
163     * @return void
164     */
165    public function add_decay( $function, $params ) {
166        $this->decays[ $function ][] = $params;
167
168        return $this;
169    }
170
171    /**
172     * Add a scoring mode to the query
173     *
174     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html
175     *
176     * @param $mode string name of how to score
177     *
178     * @return void
179     */
180    public function add_score_mode_to_functions( $mode = 'multiply' ) {
181        $this->functions_score_mode = $mode;
182
183        return $this;
184    }
185
186    public function add_boost_mode_to_functions( $mode = 'multiply' ) {
187        $this->functions_boost_mode = $mode;
188
189        return $this;
190    }
191
192    public function add_max_boost_to_functions( $boost ) {
193        $this->functions_max_boost = $boost;
194
195        return $this;
196    }
197
198    public function add_boost_to_query_bool( $boost ) {
199        $this->query_bool_boost = $boost;
200
201        return $this;
202    }
203
204    public function add_aggs( $aggs_name, $aggs ) {
205        $this->aggs_query         = true;
206        $this->aggs[ $aggs_name ] = $aggs;
207
208        return $this;
209    }
210
211    public function set_all_aggs( $aggs ) {
212        $this->aggs_query = true;
213        $this->aggs       = $aggs;
214
215        return $this;
216    }
217
218    public function add_aggs_sub_aggs( $aggs_name, $sub_aggs ) {
219        if ( ! array_key_exists( 'aggs', $this->aggs[ $aggs_name ] ) ) {
220            $this->aggs[ $aggs_name ]['aggs'] = array();
221        }
222        $this->aggs[ $aggs_name ]['aggs'] = $sub_aggs;
223
224        return $this;
225    }
226
227    public function add_bucketed_query( $name, $query ) {
228        $this->add_bucket_filter( $name, $query );
229
230        $this->add_query( $query, 'dis_max' );
231
232        return $this;
233    }
234
235    public function add_bucketed_terms( $name, $field, $terms, $boost = 1 ) {
236        if ( ! is_array( $terms ) ) {
237            $terms = array( $terms );
238        }
239
240        $this->add_bucket_filter(
241            $name,
242            array(
243                'terms' => array(
244                    $field => $terms,
245                ),
246            )
247        );
248
249        $this->add_query(
250            array(
251                'constant_score' => array(
252                    'filter' => array(
253                        'terms' => array(
254                            $field => $terms,
255                        ),
256                    ),
257                    'boost'  => $boost,
258                ),
259            ),
260            'dis_max'
261        );
262
263        return $this;
264    }
265
266    public function add_bucket_sub_aggs( $agg ) {
267        $this->bucket_sub_aggs = array_merge( $this->bucket_sub_aggs, $agg );
268
269        return $this;
270    }
271
272    protected function add_bucket_filter( $name, $filter ) {
273        $this->diverse_buckets_query   = true;
274        $this->bucket_filters[ $name ] = $filter;
275    }
276
277    ////////////////////////////////////
278    // Building Final Query
279
280    /**
281     * Combine all the queries, functions, decays, scripts, and max_boost into an ES query
282     *
283     * @return array Array representation of the built ES query
284     */
285    public function build_query() {
286        $query = array();
287
288        //dis_max queries just become a single must query
289        if ( ! empty( $this->dis_max_queries ) ) {
290            $this->must_queries[] = array(
291                'dis_max' => array(
292                    'queries' => $this->dis_max_queries,
293                ),
294            );
295        }
296
297        if ( empty( $this->must_queries ) ) {
298            $this->must_queries = array(
299                array(
300                    'match_all' => array(),
301                ),
302            );
303        }
304
305        if ( empty( $this->should_queries ) ) {
306            $query = array(
307                'bool' => array(
308                    'must' => $this->must_queries,
309                ),
310            );
311        } else {
312            $query = array(
313                'bool' => array(
314                    'must'   => $this->must_queries,
315                    'should' => $this->should_queries,
316                ),
317            );
318        }
319
320        if ( $this->query_bool_boost !== null ) {
321            $query['bool']['boost'] = $this->query_bool_boost;
322        }
323
324        // If there are any function score adjustments, then combine those
325        if ( $this->functions || $this->decays || $this->scripts || $this->weighting_functions ) {
326            $weighting_functions = $this->weighting_functions;
327
328            if ( $this->functions ) {
329                foreach ( $this->functions as $function_type => $configs ) {
330                    foreach ( $configs as $config ) {
331                        foreach ( $config as $field => $params ) {
332                            $func_arr = $params;
333
334                            $func_arr['field'] = $field;
335
336                            $weighting_functions[] = array(
337                                $function_type => $func_arr,
338                            );
339                        }
340                    }
341                }
342            }
343
344            if ( $this->decays ) {
345                foreach ( $this->decays as $decay_type => $configs ) {
346                    foreach ( $configs as $config ) {
347                        foreach ( $config as $field => $params ) {
348                            $weighting_functions[] = array(
349                                $decay_type => array(
350                                    $field => $params,
351                                ),
352                            );
353                        }
354                    }
355                }
356            }
357
358            if ( $this->scripts ) {
359                foreach ( $this->scripts as $script ) {
360                    $weighting_functions[] = array(
361                        'script_score' => array(
362                            'script' => $script,
363                        ),
364                    );
365                }
366            }
367
368            $query = array(
369                'function_score' => array(
370                    'query'      => $query,
371                    'functions'  => $weighting_functions,
372                    'max_boost'  => $this->functions_max_boost,
373                    'score_mode' => $this->functions_score_mode,
374                    'boost_mode' => $this->functions_boost_mode,
375                ),
376            );
377        }
378
379        return $query;
380    }
381
382    /**
383     * Assemble the 'filter' portion of an ES query, from all registered filters
384     *
385     * @return array|null Combined ES filters, or null if none have been defined
386     */
387    public function build_filter() {
388        if ( empty( $this->es_filters ) ) {
389            $filter = null;
390        } elseif ( 1 === count( $this->es_filters ) ) {
391            $filter = $this->es_filters[0];
392        } else {
393            $filter = array(
394                'and' => $this->es_filters,
395            );
396        }
397
398        return $filter;
399    }
400
401    /**
402     * Assemble the 'aggregation' portion of an ES query, from all general aggregations.
403     *
404     * @return array An aggregation query as an array of topics, filters, and bucket names
405     */
406    public function build_aggregation() {
407        if ( empty( $this->bucket_sub_aggs ) && empty( $this->aggs_query ) ) {
408            return array();
409        }
410
411        if ( ! $this->diverse_buckets_query && empty( $this->aggs_query ) ) {
412            return $this->bucket_sub_aggs;
413        }
414
415        $aggregations = array(
416            'topics' => array(
417                'filters' => array(
418                    'filters' => array(),
419                ),
420            ),
421        );
422
423        if ( ! empty( $this->bucket_sub_aggs ) ) {
424            $aggregations['topics']['aggs'] = $this->bucket_sub_aggs;
425        }
426
427        foreach ( $this->bucket_filters as $bucket_name => $filter ) {
428            $aggregations['topics']['filters']['filters'][ $bucket_name ] = $filter;
429        }
430
431        if ( ! empty( $this->aggs_query ) ) {
432            $aggregations = $this->aggs;
433        }
434
435        return $aggregations;
436    }
437}