PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | jetpack_vendor/automattic/jetpack-search/src/wpes/class-query-parser.php +719 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,719 @@
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 +}