PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-search / src / wpes / class-query-parser.php

class-query-parser.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at jetpack_vendor/automattic/jetpack-search/src/wpes/class-query-parser.php

720 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
36 namespace Automattic\Jetpack\Search\WPES;
37
38 if ( ! defined( 'ABSPATH' ) ) {
39 exit( 0 );
40 }
41
42 /**
43 * Query parser class.
44 */
45 class 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 }
720