File Coverage

blib/lib/Search/Query/Dialect/KSx.pm
Criterion Covered Total %
statement 242 259 93.4
branch 143 176 81.2
condition 57 80 71.2
subroutine 23 23 100.0
pod 5 5 100.0
total 470 543 86.5


line stmt bran cond sub pod time code
1             package Search::Query::Dialect::KSx;
2 3     3   547312 use Moo;
  3         37987  
  3         28  
3             extends 'Search::Query::Dialect::Native';
4 3     3   3469 use Carp;
  3         7  
  3         246  
5 3     3   1138 use Data::Dump qw( dump );
  3         17190  
  3         175  
6 3     3   22 use Scalar::Util qw( blessed );
  3         5  
  3         174  
7 3     3   2125 use Search::Query::Field::KSx;
  3         12  
  3         113  
8 3     3   2809 use KinoSearch::Search::ANDQuery;
  3         91788  
  3         158  
9 3     3   2662 use KinoSearch::Search::NoMatchQuery;
  3         161  
  3         154  
10 3     3   3244 use KinoSearch::Search::NOTQuery;
  3         191  
  3         121  
11 3     3   3062 use KinoSearch::Search::ORQuery;
  3         186  
  3         116  
12 3     3   2747 use KinoSearch::Search::PhraseQuery;
  3         132  
  3         95  
13 3     3   2137 use KinoSearch::Search::RangeQuery;
  3         130  
  3         109  
14 3     3   2096 use KinoSearch::Search::TermQuery;
  3         138  
  3         86  
15 3     3   1991 use KSx::Search::ProximityQuery;
  3         114  
  3         83  
16 3     3   2283 use Search::Query::Dialect::KSx::NOTWildcardQuery;
  3         9  
  3         101  
17 3     3   16 use Search::Query::Dialect::KSx::WildcardQuery;
  3         7  
  3         11062  
18              
19             our $VERSION = '0.201';
20              
21             has 'wildcard' => ( is => 'rw', default => sub {'*'} );
22             has 'fuzzify' => ( is => 'rw', default => sub {0} );
23             has 'ignore_order_in_proximity' => ( is => 'rw', default => sub {0} );
24             has 'allow_single_wildcards' => ( is => 'rw', default => sub {0} );
25              
26             =head1 NAME
27              
28             Search::Query::Dialect::KSx - KinoSearch query dialect
29              
30             =head1 SYNOPSIS
31              
32             my $query = Search::Query->parser( dialect => 'KSx' )->parse('foo');
33             print $query;
34             my $ks_query = $query->as_ks_query();
35             my $hits = $ks_searcher->hits( query => $ks_query );
36              
37             =head1 DESCRIPTION
38              
39             Search::Query::Dialect::KSx extends the KinoSearch::QueryParser syntax
40             to support wildcards, proximity and ranges, in addition to the standard
41             Search::Query features.
42              
43             =head1 METHODS
44              
45             This class is a subclass of Search::Query::Dialect. Only new or overridden
46             methods are documented here.
47              
48             =cut
49              
50             =head2 BUILD
51              
52             Overrides base method and sets appropriate defaults.
53             Can take the following params, also available as standard attribute
54             methods.
55              
56             =over
57              
58             =item wildcard
59              
60             Default is '*'.
61              
62             =item allow_single_wildcards
63              
64             If true, terms like '*' and '?' are allowed as valid. If false,
65             the Parser will croak if any term consists solely of a wildcard.
66              
67             The default is false.
68              
69             =item fuzzify
70              
71             If true, a wildcard is automatically appended to each query term.
72              
73             =item ignore_order_in_proximity
74              
75             If true, the terms in a proximity query will be evaluated for
76             matches regardless of the order in which they appear. For example,
77             given a document excerpt like:
78              
79             foo bar bing
80              
81             and a query like:
82              
83             "bing foo"~5
84              
85             if ignore_order_in_proximity is true, the document would match.
86             If ignore_order_in_proximity is false (the default), the document would
87             not match.
88              
89             =back
90              
91             =cut
92              
93             sub BUILD {
94 179     179 1 979 my $self = shift;
95              
96 179 100 100     896 if ( $self->{default_field} and !ref( $self->{default_field} ) ) {
97 3         14 $self->{default_field} = [ $self->{default_field} ];
98             }
99              
100 179         3472 return $self;
101             }
102              
103             =head2 stringify
104              
105             Returns the Query object as a normalized string.
106              
107             =cut
108              
109             my %op_map = (
110             '+' => ' AND ',
111             '' => ' OR ',
112             '-' => ' ',
113             );
114              
115             sub _get_clause_joiner {
116 156     156   314 my $self = shift;
117 156 100       592 if ( $self->parser->default_boolop eq '+' ) {
118 152         334 return 'AND';
119             }
120             else {
121 4         11 return 'OR';
122             }
123             }
124              
125             sub stringify {
126 108     108 1 27166 my $self = shift;
127 108   66     638 my $tree = shift || $self;
128              
129 108         561 my @q;
130 108         184 foreach my $prefix ( '+', '', '-' ) {
131 322         343 my @clauses;
132 322         608 my $joiner = $op_map{$prefix};
133 322 100       1070 next unless exists $tree->{$prefix};
134 112         126 for my $clause ( @{ $tree->{$prefix} } ) {
  112         236  
135 149         412 push( @clauses, $self->stringify_clause( $clause, $prefix ) );
136             }
137 111 50       307 next if !@clauses;
138              
139 111 50       232 push @q, join( $joiner, grep { defined and length } @clauses );
  147         852  
140             }
141 107         293 my $clause_joiner = $self->_get_clause_joiner;
142 107         721 return join " $clause_joiner ", @q;
143             }
144              
145             sub _doctor_value {
146 152     152   238 my ( $self, $clause ) = @_;
147              
148 152         244 my $value = $clause->{value};
149              
150 152 100       414 if ( $self->fuzzify ) {
151 8 100       29 $value .= '*' unless $value =~ m/[\*]/;
152             }
153              
154 152         342 return $value;
155             }
156              
157             =head2 stringify_clause( I, I )
158              
159             Called by stringify() to handle each Clause in the Query tree.
160              
161             =cut
162              
163             sub stringify_clause {
164 149     149 1 518 my $self = shift;
165 149         171 my $clause = shift;
166 149         229 my $prefix = shift;
167              
168             #warn '=' x 80;
169             #warn dump $clause;
170             #warn "prefix = '$prefix'";
171              
172 149 100       529 if ( $clause->{op} eq '()' ) {
173 32         100 my $str = $self->stringify( $clause->{value} );
174 32 100 66     111 if ( $clause->has_children and $clause->has_children == 1 ) {
175 17 100       1430 if ( $prefix eq '-' ) {
176 1         5 return "(NOT ($str))";
177             }
178             else {
179              
180             # try not to double up the () unnecessarily
181 16 100       51 if ( $str =~ m/^\(/ ) {
182 4         13 return $str;
183             }
184             else {
185 12         54 return "($str)";
186             }
187             }
188             }
189             else {
190 15 100       1510 if ( $prefix eq '-' ) {
191 2 50       13 if ( $str =~ m/^\(/ ) {
192 0         0 return "(NOT $str)";
193             }
194             else {
195 2         11 return "(NOT ($str))";
196             }
197             }
198             else {
199 13         65 return "($str)";
200             }
201             }
202             }
203              
204 117   100     567 my $quote = $clause->quote || '';
205 117   100     493 my $proximity = $clause->proximity || '';
206 117 100       229 if ($proximity) {
207 3         7 $proximity = '~' . $proximity;
208             }
209              
210             # make sure we have a field
211 117   100     652 my $default_field
212             = $self->default_field
213             || $self->parser->default_field
214             || undef; # not empty string or 0
215             my @fields
216 117 100       484 = $clause->{field}
    100          
217             ? ( $clause->{field} )
218             : ( defined $default_field ? @$default_field : () );
219              
220             # what value
221 117 100       376 my $value
222             = ref $clause->{value}
223             ? $clause->{value}
224             : $self->_doctor_value($clause);
225              
226             # if we have no fields, then operator is ignored.
227 117 100       283 if ( !@fields ) {
228 14 50       348 $self->debug and warn "no fields for " . dump($clause);
229 14         1061 my $str = qq/$quote$value$quote$proximity/;
230 14 100       74 return $prefix eq '-' ? ( 'NOT ' . $str ) : $str;
231             }
232              
233 103         5356 my $wildcard = $self->wildcard;
234              
235             # normalize operator
236 103   100     314 my $op = $clause->{op} || ":";
237 103         215 $op =~ s/=/:/g;
238 103 100       228 if ( $prefix eq '-' ) {
239 20 50       72 $op = '!' . $op unless $op =~ m/^!/;
240             }
241 103 100       659 if ( $value =~ m/[\*\?]|\Q$wildcard/ ) {
242 23         80 $op =~ s/:/~/g;
243 23 100 66     146 if ( $value eq '*' or $value eq '?' ) {
244 1 50       7 if ( !$self->allow_single_wildcards ) {
245 1         7 croak "single wildcards are not allowed: $clause";
246             }
247             }
248             }
249              
250 102         134 my @buf;
251 102         149 NAME: for my $name (@fields) {
252 147         625 my $field = $self->get_field($name);
253              
254 147 50       5615 if ( defined $field->callback ) {
255 0         0 push( @buf, $field->callback->( $field, $op, $value ) );
256 0         0 next NAME;
257             }
258              
259             $self->debug
260 147 50       3861 and warn "ks string: "
261             . dump [ $name, $op, $prefix, $quote, $value ];
262              
263             # invert fuzzy
264 147 100       10096 if ( $op eq '!~' ) {
    100          
    100          
    100          
    100          
265 2 50       21 $value .= $wildcard unless $value =~ m/\Q$wildcard/;
266 2         12 push(
267             @buf,
268             join( '',
269             '(NOT ', $name, ':', qq/$quote$value$quote$proximity/,
270             ')' )
271             );
272             }
273              
274             # fuzzy
275             elsif ( $op eq '~' ) {
276 35 100       216 $value .= $wildcard unless $value =~ m/\Q$wildcard/;
277 35         150 push( @buf,
278             join( '', $name, ':', qq/$quote$value$quote$proximity/ ) );
279             }
280              
281             # invert
282             elsif ( $op eq '!:' ) {
283 27         117 push(
284             @buf,
285             join( '',
286             '(NOT ', $name, ':', qq/$quote$value$quote$proximity/,
287             ')' )
288             );
289             }
290              
291             # range
292             elsif ( $op eq '..' ) {
293 5 50 33     41 if ( ref $value ne 'ARRAY' or @$value != 2 ) {
294 0         0 croak "range of values must be a 2-element ARRAY";
295             }
296              
297             push(
298 5         29 @buf,
299             join( '',
300             $name, ':', '(', $value->[0], '..', $value->[1], ')' )
301             );
302              
303             }
304              
305             # invert range
306             elsif ( $op eq '!..' ) {
307 6 50 33     47 if ( ref $value ne 'ARRAY' or @$value != 2 ) {
308 0         0 croak "range of values must be a 2-element ARRAY";
309             }
310              
311             push(
312 6         33 @buf,
313             join( '',
314             $name, '!:', '(', $value->[0], '..', $value->[1], ')' )
315             );
316             }
317              
318             # standard
319             else {
320 72         301 push( @buf,
321             join( '', $name, ':', qq/$quote$value$quote$proximity/ ) );
322             }
323             }
324 102 100       288 my $joiner = $prefix eq '-' ? ' AND ' : ' OR ';
325             return
326 102 100       763 ( scalar(@buf) > 1 ? '(' : '' )
    100          
327             . join( $joiner, @buf )
328             . ( scalar(@buf) > 1 ? ')' : '' );
329             }
330              
331             =head2 as_ks_query
332              
333             Returns the Dialect object as a KinoSearch::Search::Query-based object.
334             The Dialect object is walked and converted to a
335             KinoSearch::Searcher-compatible tree.
336              
337             =cut
338              
339             my %ks_class_map = (
340             '+' => 'AND',
341             '' => 'OR',
342             '-' => 'NOT',
343             );
344              
345             sub as_ks_query {
346 49     49 1 5255 my $self = shift;
347 49   66     246 my $tree = shift || $self;
348              
349 49         242 my @q;
350 49         86 foreach my $prefix ( '+', '', '-' ) {
351 147         151 my @clauses;
352 147         227 my $joiner = $ks_class_map{$prefix};
353 147 100       602 next unless exists $tree->{$prefix};
354 51         56 my $has_explicit_fields = 0;
355 51         66 for my $clause ( @{ $tree->{$prefix} } ) {
  51         106  
356 70         209 push( @clauses, $self->_ks_clause( $clause, $prefix ) );
357 70 100       262 if ( defined $clause->{field} ) {
358 55         446 $has_explicit_fields++;
359             }
360             }
361 51 50       120 next if !@clauses;
362              
363 51         107 my $ks_class = 'KinoSearch::Search::' . $joiner . 'Query';
364 51 100       271 my $ks_param_name = $joiner eq 'NOT' ? 'negated_query' : 'children';
365 51         81 @clauses = grep {defined} @clauses;
  70         316  
366 51 100 100     326 if ( $prefix eq '-' and @clauses > 1 ) {
367 1         2 $ks_class = 'KinoSearch::Search::ANDQuery';
368 1         2 $ks_param_name = 'children';
369             }
370              
371             #warn "$ks_class -> new( $ks_param_name => " . dump \@clauses;
372             #warn "has_explicit_fields=$has_explicit_fields";
373              
374 51 100 66     307 if ( @clauses == 1 ) {
    50          
375 38 100 100     389 if ( $prefix eq '-'
      100        
376             and $has_explicit_fields
377             and !$clauses[0]->isa($ks_class) )
378             {
379 3         52 push @q, $ks_class->new( $ks_param_name => $clauses[0] );
380             }
381             else {
382 35         291 push @q, $clauses[0];
383             }
384             }
385             elsif ( !$has_explicit_fields and $prefix eq '-' ) {
386              
387 0         0 warn "do not wrap \@clauses in a $ks_class";
388 0         0 push @q, @clauses;
389              
390             }
391             else {
392 13         828 push @q, $ks_class->new( $ks_param_name => \@clauses );
393             }
394              
395             }
396              
397 49         886 my $clause_joiner = $self->_get_clause_joiner;
398 49         104 my $ks_class_joiner = 'KinoSearch::Search::' . $clause_joiner . 'Query';
399              
400 49 100       2516 return @q == 1
401             ? $q[0]
402             : $ks_class_joiner->new( children => \@q );
403             }
404              
405             sub _ks_clause {
406 70     70   86 my $self = shift;
407 70         83 my $clause = shift;
408 70         70 my $prefix = shift;
409              
410             #warn dump $clause;
411             #warn "prefix = '$prefix'";
412              
413 70 100       180 if ( $clause->{op} eq '()' ) {
414 17         81 return $self->as_ks_query( $clause->{value} );
415             }
416              
417             # make sure we have a field
418 53   100     164 my $default_field = $self->default_field || $self->parser->default_field;
419             my @fields
420 53 50       200 = $clause->{field}
    100          
421             ? ( $clause->{field} )
422             : ( defined $default_field ? @$default_field : () );
423              
424             # what value
425 53 100       175 my $value
426             = ref $clause->{value}
427             ? $clause->{value}
428             : $self->_doctor_value($clause);
429              
430             # if we have no fields, we can't proceed, because KS
431             # requires a field for every term.
432 53 50       120 if ( !@fields ) {
433 0         0 croak
434             "No field specified for term '$value' -- set a default_field in Parser or Dialect";
435             }
436              
437 53         125 my $wildcard = $self->wildcard;
438              
439             # normalize operator
440 53   100     218 my $op = $clause->{op} || ":";
441 53         105 $op =~ s/=/:/g;
442 53 100       117 if ( $prefix eq '-' ) {
443 12 50       40 $op = '!' . $op unless $op =~ m/^!/;
444             }
445 53 100       363 if ( $value =~ m/[\*\?]|\Q$wildcard/ ) {
446 13         47 $op =~ s/:/~/;
447 13 50 33     109 if ( $value eq '*' or $value eq '?' ) {
448 0 0       0 if ( !$self->allow_single_wildcards ) {
449 0         0 croak "single wildcards are not allowed: $clause";
450             }
451             }
452             }
453              
454 53   100     836 my $quote = $clause->quote || '';
455 53 100       108 my $is_phrase = $quote eq '"' ? 1 : 0;
456 53   100     366 my $proximity = $clause->proximity || '';
457              
458 53         52 my @buf;
459 53         85 FIELD: for my $name (@fields) {
460 95         283 my $field = $self->get_field($name);
461              
462 95 50       1749 if ( defined $field->callback ) {
463 0         0 push( @buf, $field->callback->( $field, $op, $value ) );
464 0         0 next FIELD;
465             }
466              
467             $self->debug
468 95 50       2745 and warn "as_ks_query: "
469             . dump [ $name, $op, $prefix, $quote, $value ];
470              
471             # range is un-analyzed
472 95 100       824 if ( $op eq '..' ) {
    100          
473 3 50 33     22 if ( ref $value ne 'ARRAY' or @$value != 2 ) {
474 0         0 croak "range of values must be a 2-element ARRAY";
475             }
476              
477 3         56 my $range_query = KinoSearch::Search::RangeQuery->new(
478             field => $name,
479             lower_term => $value->[0],
480             upper_term => $value->[1],
481             include_lower => 1,
482             include_upper => 1,
483             );
484              
485 3         12 push( @buf, $range_query );
486 3         9 next FIELD;
487              
488             }
489              
490             # invert range
491             elsif ( $op eq '!..' ) {
492 4 50 33     215 if ( ref $value ne 'ARRAY' or @$value != 2 ) {
493 0         0 croak "range of values must be a 2-element ARRAY";
494             }
495              
496 4         586 my $range_query = KinoSearch::Search::RangeQuery->new(
497             field => $name,
498             lower_term => $value->[0],
499             upper_term => $value->[1],
500             include_lower => 1,
501             include_upper => 1,
502             );
503 4         785 push(
504             @buf,
505             KinoSearch::Search::NOTQuery->new(
506             negated_query => $range_query
507             )
508             );
509 4         18 next FIELD;
510             }
511              
512 88 50       1936 $self->debug and warn "value before:$value";
513 88         679 my @values = ($value);
514              
515             # if the field has an analyzer, use it on $value
516 88 100 66     706 if ( blessed( $field->analyzer ) && !ref $value ) {
517              
518             # preserve any wildcards
519 83 100       376 if ( $value =~ m/[$wildcard\*\?]/ ) {
520              
521             # can't use full PolyAnalyzer since it will tokenize
522             # and strip the wildcards off.
523              
524             # assume CaseFolder
525 27         51 $value = lc($value);
526              
527             # split on whitespace, not token regex
528 27         67 my @tok = split( m/\s+/, $value );
529              
530             # if stemmer, apply only to prefix if at all.
531 27         46 my $stemmer;
532 27 50       124 if ($field->analyzer->isa(
    0          
533             'KinoSearch::Analysis::PolyAnalyzer')
534             )
535             {
536 27         188 my $analyzers = $field->analyzer->get_analyzers();
537 27         50 for my $ana (@$analyzers) {
538 81 100       434 if ( $ana->isa('KinoSearch::Analysis::Stemmer') ) {
539 27         32 $stemmer = $ana;
540 27         56 last;
541             }
542             }
543             }
544             elsif (
545             $field->analyzer->isa('KinoSearch::Analysis::Stemmer') )
546             {
547 0         0 $stemmer = $field->analyzer;
548             }
549              
550 27 50       62 if ($stemmer) {
551 27         41 for my $tok (@tok) {
552 27 100       143 if ( $tok =~ s/^(\w+)\*$/$1/ ) {
553 18         205 my $stemmed = $stemmer->split($tok);
554              
555             # re-append the wildcard
556             # TODO ever have multiple?
557 18         68 $tok = $stemmed->[0] . '*';
558             }
559             }
560             }
561              
562 27         80 @values = @tok;
563              
564             }
565             else {
566 69 50       456 @values = grep { defined and length }
  56         1181  
567 56         60 @{ $field->analyzer->split($value) };
568             }
569             }
570              
571 88 50       1989 $self->debug and warn "value after :" . dump( \@values );
572              
573 88 100 66     907 if ( $is_phrase or @values > 1 ) {
574 16 100       29 if ($proximity) {
575              
576 12 100       32 if ( $self->ignore_order_in_proximity ) {
577 4         6 my $n_values = scalar @values;
578 4         5 my @permutations;
579 4         12 while ( $n_values-- > 0 ) {
580 8         134 push(
581             @permutations,
582             KSx::Search::ProximityQuery->new(
583             field => $name,
584             terms => [@values], # new array
585             within => $proximity,
586             )
587             );
588 8         55 push( @values, shift(@values) ); # shuffle
589              
590             }
591             $self->debug
592 4 50       94 and dump [ map { $_->get_terms } @permutations ];
  0         0  
593 4         94 push(
594             @buf,
595             KinoSearch::Search::ORQuery->new(
596             children => \@permutations,
597             )
598             );
599             }
600             else {
601 8         165 push(
602             @buf,
603             KSx::Search::ProximityQuery->new(
604             field => $name,
605             terms => \@values,
606             within => $proximity,
607             )
608             );
609             }
610             }
611             else {
612 4         106 push(
613             @buf,
614             KinoSearch::Search::PhraseQuery->new(
615             field => $name,
616             terms => \@values,
617             )
618             );
619             }
620             }
621             else {
622 72         121 my $term = $values[0];
623              
624             # TODO why would this happen?
625 72 50 33     276 if ( !defined $term or !length $term ) {
626 0         0 next FIELD;
627             }
628              
629             # invert fuzzy
630 72 100 66     696 if ( $op eq '!~'
    100 66        
    100 66        
      66        
631             || ( $op eq '!:' and $term =~ m/[$wildcard\*\?]/ ) )
632             {
633 2 50       16 $term .= $wildcard unless $term =~ m/\Q$wildcard/;
634              
635             # instead of a NOTWildcardQuery, wrap the WildcardQuery
636             # in a NOTQuery. This is for matching things like:
637             #
638             # somefield!=?*
639             #
640             # where a NOTWildcardQuery would naturally only look
641             # at terms that exist in the lexicon, and not terms
642             # that do not.
643             #
644              
645             # if the prefix is already NOT do not apply a double negative
646 2 100       7 if ( $prefix eq '-' ) {
647 1         8 push(
648             @buf,
649             Search::Query::Dialect::KSx::WildcardQuery->new(
650             field => $name,
651             term => $term,
652             )
653             );
654             }
655             else {
656 1         9 push(
657             @buf,
658             KinoSearch::Search::NOTQuery->new(
659             negated_query =>
660             Search::Query::Dialect::KSx::WildcardQuery
661             ->new(
662             field => $name,
663             term => $term,
664             )
665             )
666             );
667              
668             }
669             }
670              
671             # fuzzy
672             elsif ( $op eq '~'
673             || ( $op eq ':' and $term =~ m/[$wildcard\*\?]/ ) )
674             {
675 26 100       114 $term .= $wildcard unless $term =~ m/\Q$wildcard/;
676              
677 26         137 push(
678             @buf,
679             Search::Query::Dialect::KSx::WildcardQuery->new(
680             field => $name,
681             term => $term,
682             )
683             );
684             }
685              
686             # invert
687             elsif ( $op eq '!:' ) {
688 16         359 push(
689             @buf,
690             KinoSearch::Search::NOTQuery->new(
691             negated_query => KinoSearch::Search::TermQuery->new(
692             field => $name,
693             term => $term,
694             )
695             )
696             );
697             }
698              
699             # standard
700             else {
701 28         424 push(
702             @buf,
703             KinoSearch::Search::TermQuery->new(
704             field => $name,
705             term => $term,
706             )
707             );
708             }
709              
710             } # TERM
711             }
712 53 100       164 if ( @buf == 1 ) {
713 39         296 return $buf[0];
714             }
715 14 100       33 my $joiner = $prefix eq '-' ? 'AND' : 'OR';
716 14         31 my $ks_class = 'KinoSearch::Search::' . $joiner . 'Query';
717 14         256 return $ks_class->new( children => \@buf );
718             }
719              
720             =head2 field_class
721              
722             Returns "Search::Query::Field::KSx".
723              
724             =cut
725              
726 24     24 1 113414 sub field_class {'Search::Query::Field::KSx'}
727              
728             1;
729              
730             __END__