File Coverage

blib/lib/Plucene/QueryParser.pm
Criterion Covered Total %
statement 132 134 98.5
branch 54 64 84.3
condition 14 15 93.3
subroutine 17 17 100.0
pod 2 2 100.0
total 219 232 94.4


line stmt bran cond sub pod time code
1             package Plucene::QueryParser;
2              
3 15     15   3159 use strict;
  15         36  
  15         570  
4 15     15   78 use warnings;
  15         27  
  15         420  
5              
6 15     15   77 use base 'Class::Accessor::Fast';
  15         30  
  15         4120  
7              
8 15     15   14727 use Carp 'croak';
  15         35  
  15         1294  
9 15     15   3389 use IO::Scalar;
  15         65339  
  15         648  
10 15     15   20407 use Text::Balanced qw(extract_delimited extract_bracketed);
  15         231502  
  15         32031  
11             our $DefaultOperator = "OR";
12              
13             __PACKAGE__->mk_accessors(qw(analyzer default));
14              
15             =head1 NAME
16              
17             Plucene::QueryParser - Turn query strings into Plucene::Search::Query objects
18              
19             =head1 SYNOPSIS
20              
21             my $p = Plucene::QueryParser->new({
22             analyzer => Plucene::Analysis::Analyzer $a,
23             default => "text"
24             });
25              
26             my Plucene::Search::Query $q = $p->parse("foo bar:baz");
27              
28             =head1 DESCRIPTION
29              
30             This module is responsible for turning a query string into a
31             Plucene::Query object. It needs to have an Analyzer object to help it
32             tokenize incoming queries, and it also needs to know the default field
33             to be used if no field is given in the query string.
34              
35             =head1 METHODS
36              
37             =head2 new
38              
39             my $p = Plucene::QueryParser->new({
40             analyzer => Plucene::Analysis::Analyzer $a,
41             default => "text"
42             });
43              
44             Construct a new query parser
45              
46             =cut
47              
48             sub new {
49 175     175 1 1361 my $self = shift->SUPER::new(@_);
50 175 50       3482 croak "You need to pass an analyzer"
51             unless UNIVERSAL::isa($self->{analyzer}, "Plucene::Analysis::Analyzer");
52 175 50       722 croak "No default field name supplied!" unless $self->{default};
53 175         580 return $self;
54             }
55              
56             =head2 parse
57              
58             my Plucene::Search::Query $q = $p->parse("foo bar:baz");
59              
60             Turns the string into a query object.
61              
62             =cut
63              
64             sub parse {
65 209     209 1 38220 my $self = shift;
66 209         474 local $_ = shift;
67 209         369 my $ast = shift;
68 209         559 my @rv;
69 209         747 while ($_) {
70 277 100       1275 s/^\s+// and next;
71 243         369 my $item;
72 243         761 $item->{conj} = "NONE";
73 243         1066 s/^(AND|OR|\|\|)\s+//i;
74 243 100       951 if ($1) {
75 19         63 $item->{conj} = uc $1;
76 19 50       67 $item->{conj} = "OR"
77             if $item->{conj} eq "||";
78             }
79 243 100       1661 if (s/^\+//) { $item->{mods} = "REQ"; }
  4 100       11  
80 6         21 elsif (s/^(-|!|NOT(?=[^\w:]))\s*//i) { $item->{mods} = "NOT"; }
81 233         807 else { $item->{mods} = "NONE"; }
82              
83 243 100       1488 if (s/^([^\s(":]+)://) { $item->{field} = $1 }
  148         520  
84              
85             # Subquery
86 243 100       1797 if (/^\(/) {
    100          
    100          
87 7         36 my ($extracted, $remainer) = extract_bracketed($_, "(");
88 7 50       1251 if (!$extracted) { croak "Unbalanced subquery" }
  0         0  
89 7         15 $_ = $remainer;
90 7         28 $extracted =~ s/^\(//;
91 7         27 $extracted =~ s/\)$//;
92 7         18 $item->{query} = "SUBQUERY";
93 7         31 $item->{subquery} = $self->parse($extracted, 1);
94             } elsif (/^"/) {
95 9         59 my ($extracted, $remainer) = extract_delimited($_, '"');
96 9 50       1065 if (!$extracted) { croak "Unbalanced phrase" }
  0         0  
97 9         25 $_ = $remainer;
98 9         44 $extracted =~ s/^"//;
99 9         42 $extracted =~ s/"$//;
100 9         31 $item->{query} = "PHRASE";
101 9         35 $item->{term} = $self->_tokenize($extracted);
102             } elsif (s/^(\S+)\*//) {
103 2         9 $item->{query} = "PREFIX";
104 2         12 $item->{term} = $self->_tokenize($1);
105             } else {
106 225 50       1401 s/([^\s\^]+)// or croak "Malformed query";
107 225         728 $item->{query} = "TERM";
108 225         780 $item->{term} = $self->_tokenize($1);
109 225 100       3674 if ($item->{term} =~ / /) { $item->{query} = "PHRASE"; }
  2         5  
110             }
111 243 100       983 s/^~(\d+)// and $item->{slop} = $1;
112 243 100       743 if (s/^\^(\d+(?:.\d+)?)//) { $item->{boost} = $1 }
  2         5  
113              
114 243         2179 push @rv, bless $item,
115             "Plucene::QueryParser::" . ucfirst lc $item->{query};
116             }
117 209         835 my $obj = bless \@rv, "Plucene::QueryParser::TopLevel";
118              
119             # If we only want the AST, don't convert to a Search::Query.
120 209 100       763 if ($ast) { return $obj }
  7         30  
121 202         888 return $obj->to_plucene($self->{default});
122             }
123              
124             sub _tokenize {
125 236     236   613 my ($self, $image) = @_;
126 236         2596 my $stream = $self->{analyzer}->tokenstream(
127             {
128             field => $self->{default},
129             reader => IO::Scalar->new(\$image)
130             }
131             );
132 236         2829 my @words;
133 236         1321 while (my $x = $stream->next) { push @words, $x->text }
  247         991  
134 236         4332 join(" ", @words);
135             }
136              
137             package Plucene::QueryParser::TopLevel;
138              
139             sub to_plucene {
140 209     209   541 my ($self, $field) = @_;
141 209 100 100     2169 return $self->[0]->to_plucene($field)
142             if @$self == 1
143             and $self->[0]->{mods} eq "NONE";
144              
145 35         57 my @clauses;
146 35         166 $self->add_clause(\@clauses, $_, $field) for @$self;
147 35         2329 require Plucene::Search::BooleanQuery;
148 35         198 my $query = new Plucene::Search::BooleanQuery;
149 35         470 $query->add_clause($_) for @clauses;
150              
151 35         322 $query;
152             }
153              
154             sub add_clause {
155 69     69   512 my ($self, $clauses, $term, $field) = @_;
156 69         182 my $q = $term->to_plucene($field);
157 69 100 66     295 if ($term->{conj} eq "AND" and @$clauses) {
158              
159             # The previous term needs to become required
160 12 50       56 $clauses->[-1]->required(1) unless $clauses->[-1]->prohibited;
161             }
162              
163 69 100 100     373 if ( $Plucene::QueryParser::DefaultOperator eq "AND"
164             and $term->{conj} eq "OR") {
165 2 50       7 $clauses->[-1]->required(0) unless $clauses->[-1]->prohibited;
166             }
167              
168 69 50       199 return unless $q; # Shouldn't happen yet
169 69         92 my $prohibited;
170             my $required;
171 69 100       172 if ($Plucene::QueryParser::DefaultOperator eq "OR") {
172              
173             # We set REQUIRED if we're introduced by AND or +; PROHIBITED if
174             # introduced by NOT or -; make sure not to set both.
175 49         100 $prohibited = ($term->{mods} eq "NOT");
176 49         81 $required = ($term->{mods} eq "REQ");
177              
178 49 100 100     196 $required = 1 if $term->{conj} eq "AND" and !$prohibited;
179             } else {
180              
181             # We set PROHIBITED if we're introduced by NOT or -; We set
182             # REQUIRED if not PROHIBITED and not introduced by OR
183 20         27 $prohibited = ($term->{mods} eq "NOT");
184 20   100     70 $required = (!$prohibited and $term->{conj} ne "OR");
185             }
186 69         2143 require Plucene::Search::BooleanClause;
187 69         480 push @$clauses,
188             Plucene::Search::BooleanClause->new(
189             {
190             prohibited => $prohibited,
191             required => $required,
192             query => $q
193             }
194             );
195             }
196              
197             package Plucene::QueryParser::Term;
198              
199             sub to_plucene {
200 224     224   8056 require Plucene::Search::TermQuery;
201 224         2095 require Plucene::Index::Term;
202 224         507 my ($self, $field) = @_;
203 224         885 $self->set_term($field);
204 224         4923 my $q = Plucene::Search::TermQuery->new({ term => $self->{pl_term} });
205 224         21093 $self->set_boost($q);
206 224         2109 return $q;
207             }
208              
209             sub set_term {
210 226     226   506 my ($self, $field) = @_;
211 226 100       2357 $self->{pl_term} = Plucene::Index::Term->new(
212             {
213             field => (exists $self->{field} ? $self->{field} : $field),
214             text => $self->{term}
215             }
216             );
217             }
218              
219             sub set_boost {
220 236     236   486 my ($self, $q) = @_;
221 236 100       960 $q->boost($self->{boost}) if exists $self->{boost};
222             }
223              
224             package Plucene::QueryParser::Phrase;
225             our @ISA = qw(Plucene::QueryParser::Term);
226              
227             # This corresponds to the rules for "PHRASE" in the Plucene grammar
228              
229             sub to_plucene {
230 11     11   2710 require Plucene::Search::PhraseQuery;
231 11         68 require Plucene::Index::Term;
232 11         34 my ($self, $field) = @_;
233 11         78 my @words = split /\s+/, $self->{term};
234 11 100       61 return $self->SUPER::to_plucene($field) if @words == 1;
235              
236 10         77 my $phrase = Plucene::Search::PhraseQuery->new;
237 10         28 for my $word (@words) {
238 21 100       227 my $term = Plucene::Index::Term->new(
239             {
240             field => (exists $self->{field} ? $self->{field} : $field),
241             text => $word
242             }
243             );
244 21         257 $phrase->add($term);
245             }
246 10 100       93 if (exists $self->{slop}) {
247 2         8 $phrase->slop($self->{slop});
248             }
249 10         69 $self->set_boost($phrase);
250 10         103 return $phrase;
251             }
252              
253             package Plucene::QueryParser::Subquery;
254              
255             sub to_plucene {
256 7     7   13 my ($self, $field) = @_;
257 7 50       50 $self->{subquery}
258             ->to_plucene(exists $self->{field} ? $self->{field} : $field);
259             }
260              
261             package Plucene::QueryParser::Prefix;
262             our @ISA = qw(Plucene::QueryParser::Term);
263              
264             sub to_plucene {
265 2     2   1408 require Plucene::Search::PrefixQuery;
266 2         11 my ($self, $field) = @_;
267 2         17 $self->set_term($field);
268 2         42 my $q = Plucene::Search::PrefixQuery->new({ prefix => $self->{pl_term} });
269 2         32 $self->set_boost($q);
270 2         35 return $q;
271             }
272              
273             1;