File Coverage

blib/lib/Search/Query/Dialect/Lucy.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


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