Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 373
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Query_Parser
0.00% covered (danger)
0.00%
0 / 371
0.00% covered (danger)
0.00%
0 / 15
6006
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_current_query
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_current_query
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 norm_langs
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 get_lang_field_suffix
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 merge_ml_fields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 author_field_filter
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
240
 text_field_filter
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
156
 phrase_filter
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
132
 remaining_query
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 remaining_prefix_query
0.00% covered (danger)
0.00%
0 / 109
0.00% covered (danger)
0.00%
0 / 1
132
 boost_lang_probs
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 get_fields
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 remove_fields
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 truncate_string
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Parse a pure text query into WordPress Elasticsearch query. This builds on
4 * the Query_Builder() to provide search query parsing.
5 *
6 * The key part of this parser is taking a user's query string typed into a box
7 * and converting it into an ES search query.
8 *
9 * This varies by application, but roughly it means extracting some parts of the query
10 * (authors, tags, and phrases) that are treated as a filter. Then taking the
11 * remaining words and building the correct query (possibly with prefix searching
12 * if we are doing search as you type)
13 *
14 * This class only supports ES 2.x+
15 *
16 * Disables comment chehcks.
17 * phpcs:disable Squiz.Commenting
18 *
19 * This parser builds queries of the form:
20 *   bool:
21 *     must:
22 *       AND match of a single field (ideally an edgengram field)
23 *     filter:
24 *       filter clauses from context (eg @gibrown, #news, etc)
25 *     should:
26 *       boosting of results by various fields
27 *
28 * Features supported:
29 *  - search as you type
30 *  - phrases
31 *  - supports querying across multiple languages at once
32 *
33 * @package    automattic/jetpack-search
34 */
35
36namespace Automattic\Jetpack\Search\WPES;
37
38if ( ! defined( 'ABSPATH' ) ) {
39    exit( 0 );
40}
41
42/**
43 * Query parser class.
44 */
45class Query_Parser extends Query_Builder {
46    protected $orig_query    = '';
47    protected $current_query = '';
48    protected $langs;
49    protected $avail_langs = array( 'ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'eu', 'fa', 'fi', 'fr', 'he', 'hi', 'hu', 'hy', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' );
50
51    /**
52     * All fields.
53     *
54     * @var array
55     */
56    public $all_fields;
57
58    /**
59     * Phrase fields.
60     *
61     * @var array
62     */
63    public $phrase_fields;
64
65    public function __construct( $user_query, $langs ) {
66        $this->orig_query    = $user_query;
67        $this->current_query = $this->orig_query;
68        $this->langs         = $this->norm_langs( $langs );
69    }
70
71    protected $extracted_phrases = array();
72
73    public function get_current_query() {
74        return $this->current_query;
75    }
76
77    public function set_current_query( $q ) {
78        $this->current_query = $q;
79    }
80
81    ///////////////////////////////////////////////////////
82    // Methods for Building arrays of multilingual fields
83
84    /*
85     * Normalize language codes
86     */
87    public function norm_langs( $langs ) {
88        $lst = array();
89        foreach ( $langs as $l ) {
90            $l = strtok( $l, '-_' );
91            if ( in_array( $l, $this->avail_langs, true ) ) {
92                $lst[ $l ] = true;
93            } else {
94                $lst['default'] = true;
95            }
96        }
97        return array_keys( $lst );
98    }
99
100    public function get_lang_field_suffix() {
101        if ( ! is_array( $this->langs ) || empty( $this->langs ) ) {
102            return;
103        }
104
105        // Returns the first language only
106        return $this->langs[0];
107    }
108
109    /*
110     * Take a list of field prefixes and expand them for multi-lingual
111     * with the provided boostings.
112     */
113    public function merge_ml_fields( $fields2boosts, $additional_fields ) {
114        $flds = array();
115        foreach ( $fields2boosts as $f => $b ) {
116            foreach ( $this->langs as $l ) {
117                $flds[] = $f . '.' . $l . '^' . $b;
118            }
119        }
120        foreach ( $additional_fields as $f ) {
121            $flds[] = $f;
122        }
123        return $flds;
124    }
125
126    ////////////////////////////////////
127    // Extract Fields for Filtering on
128
129    /*
130     * Extract any @mentions from the user query
131     *  use them as a filter if we can find a wp.com id
132     *  otherwise use them as a
133     *
134     *  args:
135     *    wpcom_id_field: wp.com id field
136     *    must_query_fields: array of fields to search for matching results (optional)
137     *    boost_query_fields: array of fields to search in for boosting results (optional)
138     *    prefixes: array of prefixes that the user can use to indicate an author
139     *
140     *  returns true/false of whether any were found
141     *
142     * See also: https://github.com/twitter/twitter-text/blob/master/java/src/com/twitter/Regex.java
143     */
144    public function author_field_filter( $args ) {
145        $defaults = array(
146            'wpcom_id_field'     => 'author_id',
147            'must_query_fields'  => null,
148            'boost_query_fields' => null,
149            'prefixes'           => array( '@' ),
150        );
151        $args     = wp_parse_args( $args, $defaults );
152
153        $names = array();
154        foreach ( $args['prefixes'] as $p ) {
155            $found = $this->get_fields( $p );
156            if ( $found ) {
157                foreach ( $found as $f ) {
158                    $names[] = $f;
159                }
160            }
161        }
162
163        if ( empty( $names ) ) {
164            return false;
165        }
166
167        foreach ( $args['prefixes'] as $p ) {
168            $this->remove_fields( $p );
169        }
170
171        $user_ids = array();
172
173        //loop through the matches and separate into filters and queries
174        foreach ( $names as $n ) {
175            //check for exact match on login
176            $userdata  = get_user_by( 'login', strtolower( $n ) );
177            $filtering = false;
178            if ( $userdata ) {
179                $user_ids[ $userdata->ID ] = true;
180                $filtering                 = true;
181            }
182
183            $is_phrase = false;
184            if ( preg_match( '/"/', $n ) ) {
185                $is_phrase = true;
186                $n         = preg_replace( '/"/', '', $n );
187            }
188
189            if ( ! empty( $args['must_query_fields'] ) && ! $filtering ) {
190                if ( $is_phrase ) {
191                    $this->add_query(
192                        array(
193                            'multi_match' => array(
194                                'fields' => $args['must_query_fields'],
195                                'query'  => $n,
196                                'type'   => 'phrase',
197                            ),
198                        )
199                    );
200                } else {
201                    $this->add_query(
202                        array(
203                            'multi_match' => array(
204                                'fields' => $args['must_query_fields'],
205                                'query'  => $n,
206                            ),
207                        )
208                    );
209                }
210            }
211
212            if ( ! empty( $args['boost_query_fields'] ) ) {
213                if ( $is_phrase ) {
214                    $this->add_query(
215                        array(
216                            'multi_match' => array(
217                                'fields' => $args['boost_query_fields'],
218                                'query'  => $n,
219                                'type'   => 'phrase',
220                            ),
221                        ),
222                        'should'
223                    );
224                } else {
225                    $this->add_query(
226                        array(
227                            'multi_match' => array(
228                                'fields' => $args['boost_query_fields'],
229                                'query'  => $n,
230                            ),
231                        ),
232                        'should'
233                    );
234                }
235            }
236        }
237
238        if ( ! empty( $user_ids ) ) {
239            $user_ids = array_keys( $user_ids );
240            $this->add_filter( array( 'terms' => array( $args['wpcom_id_field'] => $user_ids ) ) );
241        }
242
243        return true;
244    }
245
246    /*
247     * Extract any prefix followed by text use them as a must clause,
248     *   and optionally as a boost to the should query
249     *   This can be used for hashtags. eg #News, or #"current events",
250     *   but also works for any arbitrary field. eg from:Greg
251     *
252     *  args:
253     *    must_query_fields: array of fields that must match the tag (optional)
254     *    boost_query_fields: array of fields to boost search on (optional)
255     *    prefixes: array of prefixes that the user can use to indicate a tag
256     *
257     *  returns true/false of whether any were found
258     *
259     */
260    public function text_field_filter( $args ) {
261        $defaults = array(
262            'must_query_fields'  => array( 'tag.name' ),
263            'boost_query_fields' => array( 'tag.name' ),
264            'prefixes'           => array( '#' ),
265        );
266        $args     = wp_parse_args( $args, $defaults );
267
268        $tags = array();
269        foreach ( $args['prefixes'] as $p ) {
270            $found = $this->get_fields( $p );
271            if ( $found ) {
272                foreach ( $found as $f ) {
273                    $tags[] = $f;
274                }
275            }
276        }
277
278        if ( empty( $tags ) ) {
279            return false;
280        }
281
282        foreach ( $args['prefixes'] as $p ) {
283            $this->remove_fields( $p );
284        }
285
286        foreach ( $tags as $t ) {
287            $is_phrase = false;
288            if ( preg_match( '/"/', $t ) ) {
289                $is_phrase = true;
290                $t         = preg_replace( '/"/', '', $t );
291            }
292
293            if ( ! empty( $args['must_query_fields'] ) ) {
294                if ( $is_phrase ) {
295                    $this->add_query(
296                        array(
297                            'multi_match' => array(
298                                'fields' => $args['must_query_fields'],
299                                'query'  => $t,
300                                'type'   => 'phrase',
301                            ),
302                        )
303                    );
304                } else {
305                    $this->add_query(
306                        array(
307                            'multi_match' => array(
308                                'fields' => $args['must_query_fields'],
309                                'query'  => $t,
310                            ),
311                        )
312                    );
313                }
314            }
315
316            if ( ! empty( $args['boost_query_fields'] ) ) {
317                if ( $is_phrase ) {
318                    $this->add_query(
319                        array(
320                            'multi_match' => array(
321                                'fields' => $args['boost_query_fields'],
322                                'query'  => $t,
323                                'type'   => 'phrase',
324                            ),
325                        ),
326                        'should'
327                    );
328                } else {
329                    $this->add_query(
330                        array(
331                            'multi_match' => array(
332                                'fields' => $args['boost_query_fields'],
333                                'query'  => $t,
334                            ),
335                        ),
336                        'should'
337                    );
338                }
339            }
340        }
341
342        return true;
343    }
344
345    /*
346     * Extract anything surrounded by quotes or if there is an opening quote
347     *   that is not complete, and add them to the query as a phrase query.
348     *   Quotes can be either '' or ""
349     *
350     *  args:
351     *    must_query_fields: array of fields that must match the phrases
352     *    boost_query_fields: array of fields to boost the phrases on (optional)
353     *
354     *  returns true/false of whether any were found
355     *
356     */
357    public function phrase_filter( $args ) {
358        $defaults = array(
359            'must_query_fields'  => array( 'all_content' ),
360            'boost_query_fields' => array( 'title' ),
361        );
362        $args     = wp_parse_args( $args, $defaults );
363
364        $phrases = array();
365        if ( preg_match_all( '/"([^"]+)"/', $this->current_query, $matches ) ) {
366            foreach ( $matches[1] as $match ) {
367                $phrases[] = $match;
368            }
369            $this->current_query = preg_replace( '/"([^"]+)"/', '', $this->current_query );
370        }
371
372        if ( preg_match_all( "/'([^']+)'/", $this->current_query, $matches ) ) {
373            foreach ( $matches[1] as $match ) {
374                $phrases[] = $match;
375            }
376            $this->current_query = preg_replace( "/'([^']+)'/", '', $this->current_query );
377        }
378
379        //look for a final, uncompleted phrase
380        $phrase_prefix = false;
381        if ( preg_match_all( '/"([^"]+)$/', $this->current_query, $matches ) ) {
382            $phrase_prefix       = $matches[1][0];
383            $this->current_query = preg_replace( '/"([^"]+)$/', '', $this->current_query );
384        }
385        if ( preg_match_all( "/(?:'\B|\B')([^']+)$/", $this->current_query, $matches ) ) {
386            $phrase_prefix       = $matches[1][0];
387            $this->current_query = preg_replace( "/(?:'\B|\B')([^']+)$/", '', $this->current_query );
388        }
389
390        if ( $phrase_prefix ) {
391            $phrases[] = $phrase_prefix;
392        }
393        if ( empty( $phrases ) ) {
394            return false;
395        }
396
397        foreach ( $phrases as $p ) {
398            $this->add_query(
399                array(
400                    'multi_match' => array(
401                        'fields' => $args['must_query_fields'],
402                        'query'  => $p,
403                        'type'   => 'phrase',
404                    ),
405                )
406            );
407
408            if ( ! empty( $args['boost_query_fields'] ) ) {
409                $this->add_query(
410                    array(
411                        'multi_match' => array(
412                            'fields'   => $args['boost_query_fields'],
413                            'query'    => $p,
414                            'operator' => 'and',
415                        ),
416                    ),
417                    'should'
418                );
419            }
420        }
421
422        return true;
423    }
424
425    /*
426     * Query fields based on the remaining parts of the query
427     *   This could be the final AND part of the query terms to match, or it
428     *   could be boosting certain elements of the query
429     *
430     *  args:
431     *    must_query_fields: array of fields that must match the remaining terms (optional)
432     *    boost_query_fields: array of fields to boost the remaining terms on (optional)
433     *
434     */
435    public function remaining_query( $args ) {
436        $defaults = array(
437            'must_query_fields'  => null,
438            'boost_query_fields' => null,
439            'boost_operator'     => 'and',
440            'boost_query_type'   => 'best_fields',
441        );
442        $args     = wp_parse_args( $args, $defaults );
443
444        if ( empty( $this->current_query ) || ctype_space( $this->current_query ) ) {
445            return;
446        }
447
448        if ( ! empty( $args['must_query_fields'] ) ) {
449            $this->add_query(
450                array(
451                    'multi_match' => array(
452                        'fields'   => $args['must_query_fields'],
453                        'query'    => $this->current_query,
454                        'operator' => 'and',
455                    ),
456                )
457            );
458        }
459
460        if ( ! empty( $args['boost_query_fields'] ) ) {
461            $this->add_query(
462                array(
463                    'multi_match' => array(
464                        'fields'   => $args['boost_query_fields'],
465                        'query'    => $this->current_query,
466                        'operator' => $args['boost_operator'],
467                        'type'     => $args['boost_query_type'],
468                    ),
469                ),
470                'should'
471            );
472        }
473    }
474
475    /*
476     * Query fields using a prefix query (alphabetical expansions on the index).
477     *   This is not recommended. Slower performance and worse relevancy.
478     *
479     *  (UNTESTED! Copied from old prefix expansion code)
480     *
481     *  args:
482     *    must_query_fields: array of fields that must match the remaining terms (optional)
483     *    boost_query_fields: array of fields to boost the remaining terms on (optional)
484     *
485     */
486    public function remaining_prefix_query( $args ) {
487        $defaults = array(
488            'must_query_fields'  => array( 'all_content' ),
489            'boost_query_fields' => array( 'title' ),
490            'boost_operator'     => 'and',
491            'boost_query_type'   => 'best_fields',
492        );
493        $args     = wp_parse_args( $args, $defaults );
494
495        if ( empty( $this->current_query ) || ctype_space( $this->current_query ) ) {
496            return;
497        }
498
499        //////////////////////////////////
500        // Example cases to think about:
501        // "elasticse"
502        // "elasticsearch"
503        // "elasticsearch "
504        // "elasticsearch lucen"
505        // "elasticsearch lucene"
506        // "the future"  - note the stopword which will match nothing!
507        // "F1" - an exact match that also has tons of expansions
508        // "こんにちは" ja "hello"
509        // "こんにちは友人" ja "hello friend" - we just rely on the prefix phrase and ES to split words
510        //   - this could still be better I bet. Maybe we need to analyze with ES first?
511        //
512
513        /////////////////////////////
514        //extract pieces of query
515        // eg: "PREFIXREMAINDER PREFIXWORD"
516        //     "elasticsearch lucen"
517
518        $prefix_word      = false;
519        $prefix_remainder = false;
520        if ( preg_match_all( '/([^ ]+)$/', $this->current_query, $matches ) ) {
521            $prefix_word = $matches[1][0];
522        }
523
524        $prefix_remainder = preg_replace( '/([^ ]+)$/', '', $this->current_query );
525        if ( ctype_space( $prefix_remainder ) ) {
526            $prefix_remainder = false;
527        }
528
529        if ( ! $prefix_word ) {
530            //Space at the end of the query, so skip using a prefix query
531            if ( ! empty( $args['must_query_fields'] ) ) {
532                $this->add_query(
533                    array(
534                        'multi_match' => array(
535                            'fields'   => $args['must_query_fields'],
536                            'query'    => $this->current_query,
537                            'operator' => 'and',
538                        ),
539                    )
540                );
541            }
542
543            if ( ! empty( $args['boost_query_fields'] ) ) {
544                $this->add_query(
545                    array(
546                        'multi_match' => array(
547                            'fields'   => $args['boost_query_fields'],
548                            'query'    => $this->current_query,
549                            'operator' => $args['boost_operator'],
550                            'type'     => $args['boost_query_type'],
551                        ),
552                    ),
553                    'should'
554                );
555            }
556        } else {
557
558            //must match the prefix word and the prefix remainder
559            if ( ! empty( $args['must_query_fields'] ) ) {
560                //need to do an OR across a few fields to handle all cases
561                $must_q = array(
562                    'bool' => array(
563                        'should'               => array(),
564                        'minimum_should_match' => 1,
565                    ),
566                );
567
568                //treat all words as an exact search (boosts complete word like "news"
569                //from prefixes of "newspaper")
570                $must_q['bool']['should'][] = array(
571                    'multi_match' => array(
572                        'fields'   => $this->all_fields,
573                        // NOTE: This line has been disabled since $full_text is not available.
574                        // 'query'    => $full_text,
575                        'operator' => 'and',
576                        'type'     => 'cross_fields',
577                    ),
578                );
579
580                //always optimistically try and match the full text as a phrase
581                //prefix "the futu" should try to match "the future"
582                //otherwise the first stopword kinda breaks
583                //This also works as the prefix match for a single word "elasticsea"
584                $must_q['bool']['should'][] = array(
585                    'multi_match' => array(
586                        'fields'         => $this->phrase_fields,
587                        // NOTE: This line has been disabled since $full_text is not available.
588                        // 'query'          => $full_text,
589                        'operator'       => 'and',
590                        'type'           => 'phrase_prefix',
591                        'max_expansions' => 100,
592                    ),
593                );
594
595                if ( $prefix_remainder ) {
596                    //Multiple words found, so treat each word on its own and not just as
597                    //a part of a phrase
598                    //"elasticsearch lucen" => "elasticsearch" exact AND "lucen" prefix
599                    $must_q['bool']['should'][] = array(
600                        'bool' => array(
601                            'must' => array(
602                                array(
603                                    'multi_match' => array(
604                                        'fields'         => $this->phrase_fields,
605                                        'query'          => $prefix_word,
606                                        'operator'       => 'and',
607                                        'type'           => 'phrase_prefix',
608                                        'max_expansions' => 100,
609                                    ),
610                                ),
611                                array(
612                                    'multi_match' => array(
613                                        'fields'   => $this->all_fields,
614                                        'query'    => $prefix_remainder,
615                                        'operator' => 'and',
616                                        'type'     => 'cross_fields',
617                                    ),
618                                ),
619                            ),
620                        ),
621                    );
622                }
623
624                $this->add_query( $must_q );
625            }
626
627            //Now add any boosting of the query
628            if ( ! empty( $args['boost_query_fields'] ) ) {
629                //treat all words as an exact search (boosts complete word like "news"
630                //from prefixes of "newspaper")
631                $this->add_query(
632                    array(
633                        'multi_match' => array(
634                            'fields'   => $args['boost_query_fields'],
635                            'query'    => $this->current_query,
636                            'operator' => $args['boost_query_operator'],
637                            'type'     => $args['boost_query_type'],
638                        ),
639                    ),
640                    'should'
641                );
642
643                //optimistically boost the full phrase prefix match
644                $this->add_query(
645                    array(
646                        'multi_match' => array(
647                            'fields'         => $args['boost_query_fields'],
648                            'query'          => $this->current_query,
649                            'operator'       => 'and',
650                            'type'           => 'phrase_prefix',
651                            'max_expansions' => 100,
652                        ),
653                    )
654                );
655            }
656        }
657    }
658
659    /*
660     * Boost results based on the lang probability overlaps
661     *
662     *  args:
663     *    langs2prob: list of languages to search in with associated boosts
664     */
665    public function boost_lang_probs( $langs2prob ) {
666        foreach ( $langs2prob as $p ) {
667            $this->add_function(
668                'field_value_factor',
669                array(
670                    'modifier' => 'none',
671                    'factor'   => $p,
672                    'missing'  => 0.01, //1% chance doc did not have right lang detected
673                )
674            );
675        }
676    }
677
678    ////////////////////////////////////
679    // Helper Methods
680
681    //Get the text after some prefix. eg @gibrown, or @"Greg Brown"
682    protected function get_fields( $field_prefix ) {
683        $regex = '/' . $field_prefix . '(("[^"]+")|([^\\p{Z}]+))/';
684        if ( preg_match_all( $regex, $this->current_query, $match ) ) {
685            return $match[1];
686        }
687        return false;
688    }
689
690    //Remove the prefix and text from the query
691    protected function remove_fields( $field_name ) {
692        $regex               = '/' . $field_name . '(("[^"]+")|([^\\p{Z}]+))/';
693        $this->current_query = preg_replace( $regex, '', $this->current_query );
694    }
695
696    //Best effort string truncation that splits on word breaks
697    protected function truncate_string( $string, $limit, $break = ' ' ) {
698        if ( mb_strwidth( $string ) <= $limit ) {
699            return $string;
700        }
701
702        // walk backwards from $limit to find first break
703        $breakpoint = $limit;
704        $broken     = false;
705        while ( $breakpoint > 0 ) {
706            if ( mb_strimwidth( $string, $breakpoint, 1 ) === $break ) {
707                $string = mb_strimwidth( $string, 0, $breakpoint );
708                $broken = true;
709                break;
710            }
711            --$breakpoint;
712        }
713        // if we weren't able to find a break, need to chop mid-word
714        if ( ! $broken ) {
715            $string = mb_strimwidth( $string, 0, $limit );
716        }
717        return $string;
718    }
719}