File Coverage

blib/lib/Catmandu/Store/Solr/CQL.pm
Criterion Covered Total %
statement 15 135 11.1
branch 0 92 0.0
condition 0 24 0.0
subroutine 5 10 50.0
pod 2 4 50.0
total 22 265 8.3


line stmt bran cond sub pod time code
1             package Catmandu::Store::Solr::CQL
2             ; #TODO see Catmandu::Store::ElasticSearch::CQL
3              
4 3     3   18 use Catmandu::Sane;
  3         5  
  3         16  
5 3     3   1267 use CQL::Parser;
  3         50520  
  3         111  
6 3     3   26 use Carp qw(confess);
  3         7  
  3         153  
7 3     3   20 use Catmandu::Util qw(:is require_package);
  3         7  
  3         860  
8 3     3   20 use Moo;
  3         5  
  3         20  
9              
10             with 'Catmandu::Logger';
11              
12             our $VERSION = "0.0303";
13              
14             has parser => (is => 'ro', lazy => 1, builder => '_build_parser');
15             has mapping => (is => 'ro');
16              
17             my $any_field = qr'^(srw|cql)\.(serverChoice|anywhere)$'i;
18             my $match_all = qr'^(srw|cql)\.allRecords$'i;
19             my $distance_modifier = qr'\s*\/\s*distance\s*<\s*(\d+)'i;
20             my $reserved = qr'[\+\-\&\|\!\(\)\{\}\[\]\^\"\~\*\?\:\\]';
21              
22             sub _build_parser {
23 0     0     CQL::Parser->new;
24             }
25              
26             sub parse {
27 0     0 1   my ($self, $query) = @_;
28 0           $self->log->debug("cql query: $query");
29 0 0         my $node = eval {$self->parser->parse($query)} or do {
  0            
30 0           my $error = $@;
31 0           $self->log->error("cql error: $error");
32 0           die "cql error: $error";
33             };
34 0           my $solr_query = $self->visit($node);
35 0           $self->log->debug("solr query: $solr_query");
36 0           $solr_query;
37             }
38              
39             sub escape_term {
40 0     0 0   my $term = $_[0];
41 0           $term =~ s/($reserved)/\\$1/g;
42 0           $term;
43             }
44              
45             sub quote_term {
46 0     0 0   my $term = $_[0];
47 0 0         $term = qq("$term") if $term =~ /\s/;
48 0           $term;
49             }
50              
51             sub visit {
52 0     0 1   my ($self, $node) = @_;
53              
54 0           my $mapping = $self->mapping;
55 0 0         my $indexes = $mapping ? $mapping->{indexes} : undef;
56              
57 0 0         if ($node->isa('CQL::TermNode')) {
    0          
    0          
58              
59 0           my $term = escape_term($node->getTerm);
60              
61 0 0         if ($term =~ $match_all) {
62 0           return "*:*";
63             }
64              
65 0           my $qualifier = $node->getQualifier;
66 0           my $relation = $node->getRelation;
67 0           my @modifiers = $relation->getModifiers;
68 0           my $base = lc $relation->getBase;
69              
70 0 0         if ($base eq 'scr') {
71 0 0 0       if ($mapping && $mapping->{default_relation}) {
72 0           $base = $mapping->{default_relation};
73             }
74             else {
75 0           $base = '=';
76             }
77             }
78              
79             #default field
80 0 0         if ($qualifier =~ $any_field) {
81              
82             #set default field explicitely
83 0 0 0       if ($mapping && $mapping->{default_index}) {
84 0           $qualifier = $mapping->{default_index};
85             }
86              
87             #make solr decide what the default field should be
88             else {
89 0           $qualifier = "";
90             }
91             }
92              
93             #field search: new way
94 0 0         if ($indexes) {
95              
96             #empty qualifier: Solr should decide how to query
97 0 0         if (is_string($qualifier)) {
98              
99             #change qualifier
100 0           $qualifier = lc $qualifier;
101 0           my $old_qualifier = $qualifier;
102             $qualifier =~ s/(?<=[^_])_(?=[^_])//g
103 0 0         if $mapping->{strip_separating_underscores};
104 0 0         unless ($qualifier eq $old_qualifier) {
105 0           $self->log->debug(
106             "value of qualifier '$old_qualifier' reset to '$qualifier' because of setting 'strip_separating_underscores'"
107             );
108             }
109 0 0         my $q_mapping = $indexes->{$qualifier}
110             or confess "cql error: unknown index $qualifier";
111 0 0         $q_mapping->{op}->{$base}
112             or confess "cql error: relation $base not allowed";
113              
114 0           my $op = $q_mapping->{op}->{$base};
115              
116 0           $old_qualifier = $qualifier;
117 0 0 0       if (ref $op && $op->{field}) {
    0          
118              
119 0           $qualifier = $op->{field};
120              
121             }
122             elsif ($q_mapping->{field}) {
123              
124 0           $qualifier = $q_mapping->{field};
125              
126             }
127              
128 0 0         unless ($qualifier eq $old_qualifier) {
129 0           $self->log->debug(
130             "value of qualifier '$old_qualifier' reset to '$qualifier' because of field mapping"
131             );
132             }
133              
134             #add solr ':'
135 0           $qualifier = "$qualifier:";
136              
137             #change term using filters
138 0           my $filters;
139 0 0 0       if (ref $op && $op->{filter}) {
    0          
140              
141 0           $filters = $op->{filter};
142              
143             }
144             elsif ($q_mapping->{filter}) {
145              
146 0           $filters = $q_mapping->{filter};
147              
148             }
149 0 0         if ($filters) {
150 0           for my $filter (@$filters) {
151 0 0         if ($filter eq 'lowercase') {
152 0           $self->log->debug(
153             "term '$term' filtered to lowercase");
154 0           $term = lc $term;
155             }
156             }
157             }
158              
159             #change term using callbacks
160 0 0 0       if (ref $op && $op->{cb}) {
    0          
161 0           my ($pkg, $sub) = @{$op->{cb}};
  0            
162 0           $self->log->debug(
163             "term '$term' changed to ${pkg}->${sub}");
164 0           $term = require_package($pkg)->$sub($term);
165             }
166             elsif ($q_mapping->{cb}) {
167 0           my ($pkg, $sub) = @{$q_mapping->{cb}};
  0            
168 0           $self->log->debug(
169             "term '$term' changed to ${pkg}->${sub}");
170 0           $term = require_package($pkg)->$sub($term);
171             }
172              
173             }
174              
175             }
176              
177             #field search: old way
178             else {
179             #add solr ':'
180 0 0         $qualifier = "$qualifier:" if is_string $qualifier;
181             }
182              
183 0 0 0       if ($base eq '=' or $base eq 'scr') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
184 0           $term = quote_term($term);
185 0           for my $m (@modifiers) {
186 0 0         if ($m->[1] eq 'fuzzy') {
187 0           return "$qualifier$term~";
188             }
189             }
190 0           return $qualifier . $term;
191             }
192             elsif ($base eq '<') {
193 0           $term = quote_term($term);
194 0           return $qualifier . "{* TO $term}";
195             }
196             elsif ($base eq '>') {
197 0           $term = quote_term($term);
198 0           return $qualifier . "{$term TO *}";
199             }
200             elsif ($base eq '<=') {
201 0           $term = quote_term($term);
202 0           return $qualifier . "[* TO $term]";
203             }
204             elsif ($base eq '>=') {
205 0           $term = quote_term($term);
206 0           return $qualifier . "[$term TO *]";
207             }
208             elsif ($base eq '<>') {
209 0           $term = quote_term($term);
210 0           return "-$qualifier$term";
211             }
212             elsif ($base eq 'exact') {
213 0           return $qualifier . quote_term($term);
214             }
215             elsif ($base eq 'all') {
216 0           my @terms = split /\s+/, $term;
217 0 0         if (@terms == 1) {
218 0           return $qualifier . $term;
219             }
220 0           $term = join ' ', map {"+$_"} @terms;
  0            
221 0 0         if ($qualifier) {
222 0           return "$qualifier($term)";
223             }
224 0           return $term;
225             }
226             elsif ($base eq 'any') {
227 0           $term = join ' OR ', map {$qualifier . $_} split /\s+/, $term;
  0            
228 0           return "( $term)";
229             }
230             elsif ($base eq 'within') {
231 0           my @range = split /\s+/, $term;
232 0 0         if (@range == 1) {
233 0           return $qualifier . $term;
234             }
235             else {
236 0           return $qualifier . "[$range[0] TO $range[1]]";
237             }
238             }
239             else {
240 0           return $qualifier . quote_term($term);
241             }
242             }
243              
244             #TODO: apply cql_mapping
245             elsif ($node->isa('CQL::ProxNode')) {
246 0           my $distance = 1;
247 0           my $qualifier = $node->left->getQualifier;
248 0           my $term = escape_term(
249             join(' ', $node->left->getTerm, $node->right->getTerm));
250              
251 0 0         if (my ($n) = $node->op =~ $distance_modifier) {
252 0 0         $distance = $n if $n > 1;
253             }
254 0 0         if ($qualifier =~ $any_field) {
255 0           return qq("$term"~$distance);
256             }
257             else {
258 0           return qq($qualifier:"$term"~$distance);
259             }
260             }
261              
262             elsif ($node->isa('CQL::BooleanNode')) {
263 0           my $lft = $node->left;
264 0           my $rgt = $node->right;
265 0           my $lft_q = $self->visit($lft);
266 0           my $rgt_q = $self->visit($rgt);
267 0 0 0       $lft_q = "( $lft_q)"
268             unless $lft->isa('CQL::TermNode') || $lft->isa('CQL::ProxNode');
269 0 0 0       $rgt_q = "( $rgt_q)"
270             unless $rgt->isa('CQL::TermNode') || $rgt->isa('CQL::ProxNode');
271              
272 0           return join ' ', $lft_q, uc $node->op, $rgt_q;
273             }
274             }
275              
276             1;
277              
278             =head1 NAME
279              
280             Catmandu::Store::Solr::CQL - Converts a CQL query string to a Solr query string
281              
282             =head1 SYNOPSIS
283              
284             $solr_query_string = Catmandu::Store::Solr::CQL->parse($cql_query_string);
285              
286             =head1 DESCRIPTION
287              
288             This package currently parses most of CQL 1.1:
289              
290             and
291             or
292             not
293             prox
294             prox/distance<$n
295             srw.allRecords
296             srw.serverChoice
297             srw.anywhere
298             cql.allRecords
299             cql.serverChoice
300             cql.anywhere
301             =
302             scr
303             =/fuzzy
304             scr/fuzzy
305             <
306             >
307             <=
308             >=
309             <>
310             exact
311             all
312             any
313             within
314              
315             =head1 METHODS
316              
317             =head2 parse
318              
319             Parses the given CQL query string with L<CQL::Parser> and converts it to a Solr query string.
320              
321             =head2 visit
322              
323             Converts the given L<CQL::Node> to a Solr query string.
324              
325             =head1 TODO
326              
327             support cql 1.2, more modifiers (esp. masked), sortBy, encloses
328              
329             =head1 SEE ALSO
330              
331             L<CQL::Parser>.
332              
333             =cut