File Coverage

blib/lib/Catmandu/Store/ElasticSearch/CQL.pm
Criterion Covered Total %
statement 15 183 8.2
branch 0 122 0.0
condition 0 18 0.0
subroutine 5 12 41.6
pod 2 2 100.0
total 22 337 6.5


line stmt bran cond sub pod time code
1             package Catmandu::Store::ElasticSearch::CQL;
2              
3 1     1   6 use Catmandu::Sane;
  1         1  
  1         7  
4 1     1   389 use Catmandu::Util qw(require_package trim);
  1         2  
  1         71  
5 1     1   5 use Carp qw(confess);
  1         2  
  1         44  
6 1     1   837 use CQL::Parser;
  1         46233  
  1         38  
7 1     1   12 use Moo;
  1         2  
  1         11  
8              
9             has parser => (is => 'ro', lazy => 1, builder => '_build_parser');
10             has mapping => (is => 'ro');
11              
12             my $RE_ANY_FIELD = qr'^(srw|cql)\.(serverChoice|anywhere)$'i;
13             my $RE_MATCH_ALL = qr'^(srw|cql)\.allRecords$'i;
14             my $RE_DISTANCE_MODIFIER = qr'\s*\/\s*distance\s*<\s*(\d+)'i;
15              
16             sub _build_parser {
17 0     0     CQL::Parser->new;
18             }
19              
20             sub parse {
21 0     0 1   my ($self, $query) = @_;
22             my $node = eval {
23 0           $self->parser->parse($query);
24 0 0         } or do {
25 0           my $error = $@;
26 0           die "cql error: $error";
27             };
28 0           $self->parse_node($node);
29             }
30              
31             sub parse_node {
32 0     0 1   my ($self, $node) = @_;
33              
34 0           my $query = {};
35              
36 0 0         unless ($node->isa('CQL::BooleanNode')) {
37 0 0         $node->isa('CQL::TermNode')
38             ? $self->_parse_term_node($node, $query)
39             : $self->_parse_prox_node($node, $query);
40 0           return $query;
41             }
42              
43 0           my @stack = ($node);
44 0           my @query_stack = (my $q = $query);
45              
46 0           while (@stack) {
47 0           $node = shift @stack;
48 0           $q = shift @query_stack;
49              
50 0 0         if ($node->isa('CQL::BooleanNode')) {
    0          
51 0           push @stack, $node->left, $node->right;
52 0           push @query_stack, my $left = {}, my $right = {};
53 0 0         if ($node->op eq 'and') {
    0          
54 0           $q->{bool} = { must => [$left, $right] };
55             } elsif ($node->op eq 'or') {
56 0           $q->{bool} = { should => [$left, $right] };
57             } else {
58 0           $q->{bool} = { must => [ $left, { bool => { must_not => [$right] } } ] };
59             }
60             } elsif ($node->isa('CQL::TermNode')) {
61 0           $self->_parse_term_node($node, $q);
62             } else {
63 0           $self->_parse_prox_node($node, $q);
64             }
65             }
66              
67 0           $query;
68             }
69              
70             sub _parse_term_node {
71 0     0     my ($self, $node, $query) = @_;
72              
73 0           my $term = $node->getTerm;
74              
75 0 0         if ($term =~ $RE_MATCH_ALL) {
76 0           return { match_all => {} };
77             }
78              
79 0           my $qualifier = $node->getQualifier;
80 0           my $relation = $node->getRelation;
81 0           my @modifiers = $relation->getModifiers;
82 0           my $base = lc $relation->getBase;
83              
84 0 0         if ($base eq 'scr') {
85 0 0 0       if ($self->mapping and my $rel = $self->mapping->{default_relation}) {
86 0           $base = $rel;
87             } else {
88 0           $base = '=';
89             }
90             }
91              
92 0 0         if ($qualifier =~ $RE_ANY_FIELD) {
93 0 0 0       if ($self->mapping and my $idx = $self->mapping->{default_index}) {
94 0           $qualifier = $idx;
95             } else {
96 0           $qualifier = '_all';
97             }
98             }
99              
100 0           my $nested;
101              
102 0 0 0       if ($self->mapping and my $indexes = $self->mapping->{indexes}) {
103 0           $qualifier = lc $qualifier;
104 0 0         $qualifier =~ s/(?<=[^_])_(?=[^_])//g if $self->mapping->{strip_separating_underscores};
105 0 0         my $mapping = $indexes->{$qualifier} or confess "cql error: unknown index $qualifier";
106 0 0         $mapping->{op}{$base} or confess "cql error: relation $base not allowed";
107 0           my $op = $mapping->{op}{$base};
108 0 0 0       if (ref $op && $op->{field}) {
    0          
109 0           $qualifier = $op->{field};
110             } elsif ($mapping->{field}) {
111 0           $qualifier = $mapping->{field};
112             }
113              
114 0           my $filters;
115 0 0 0       if (ref $op && $op->{filter}) {
    0          
116 0           $filters = $op->{filter};
117             } elsif ($mapping->{filter}) {
118 0           $filters = $mapping->{filter};
119             }
120 0 0         if ($filters) {
121 0           for my $filter (@$filters) {
122 0 0         if ($filter eq 'lowercase') { $term = lc $term; }
  0            
123             }
124             }
125 0 0 0       if (ref $op && $op->{cb}) {
    0          
126 0           my ($pkg, $sub) = @{$op->{cb}};
  0            
127 0           $term = require_package($pkg)->$sub($term);
128             } elsif ($mapping->{cb}) {
129 0           my ($pkg, $sub) = @{$mapping->{cb}};
  0            
130 0           $term = require_package($pkg)->$sub($term);
131             }
132              
133 0           $nested = $mapping->{nested};
134             }
135              
136             # TODO just pass query around
137 0           my $es_node = _term_node($base, $qualifier, $term, @modifiers);
138              
139 0 0         if ($nested) {
140 0 0         if ($nested->{query}) {
141 0           $es_node = { bool => { must => [
142             $nested->{query},
143             $es_node,
144             ] } };
145             }
146 0           $es_node = { nested => {
147             path => $nested->{path},
148             query => $es_node,
149             } };
150             }
151              
152 0           for my $key (keys %$es_node) {
153 0           $query->{$key} = $es_node->{$key};
154             }
155              
156 0           $query;
157             }
158              
159             sub _parse_prox_node {
160 0     0     my ($self, $node, $query) = @_;
161              
162 0           my $slop = 0;
163 0           my $qualifier = $node->left->getQualifier;
164 0           my $term = join ' ', $node->left->getTerm, $node->right->getTerm;
165 0 0         if (my ($n) = $node->op =~ $RE_DISTANCE_MODIFIER) {
166 0 0         $slop = $n - 1 if $n > 1;
167             }
168 0 0         if ($qualifier =~ $RE_ANY_FIELD) {
169 0           $qualifier = '_all';
170             }
171              
172 0           $query->{text_phrase} = { $qualifier => { query => $term, slop => $slop } };
173             }
174              
175             sub _term_node {
176 0     0     my ($base, $qualifier, $term, @modifiers) = @_;
177 0           my $q;
178 0 0         if ($base eq '=') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
179 0 0         if (ref $qualifier) {
180             return { bool => { should => [ map {
181 0 0         if ($_ eq '_id') {
  0            
182 0           { ids => { values => [$term] } };
183             } else {
184 0           _text_node($_, $term, @modifiers);
185             }
186             } @$qualifier ] } };
187             } else {
188 0 0         if ($qualifier eq '_id') {
189 0           return { ids => { values => [$term] } };
190             }
191 0           return _text_node($qualifier, $term, @modifiers);
192             }
193             } elsif ($base eq '<') {
194 0 0         if (ref $qualifier) {
195 0           return { bool => { should => [ map { { range => { $_ => { lt => $term } } } } @$qualifier ] } };
  0            
196             } else {
197 0           return { range => { $qualifier => { lt => $term } } };
198             }
199             } elsif ($base eq '>') {
200 0 0         if (ref $qualifier) {
201 0           return { bool => { should => [ map { { range => { $_ => { gt => $term } } } } @$qualifier ] } };
  0            
202             } else {
203 0           return { range => { $qualifier => { gt => $term } } };
204             }
205             } elsif ($base eq '<=') {
206 0 0         if (ref $qualifier) {
207 0           return { bool => { should => [ map { { range => { $_ => { lte => $term } } } } @$qualifier ] } };
  0            
208             } else {
209 0           return { range => { $qualifier => { lte => $term } } };
210             }
211             } elsif ($base eq '>=') {
212 0 0         if (ref $qualifier) {
213 0           return { bool => { should => [ map { { range => { $_ => { gte => $term } } } } @$qualifier ] } };
  0            
214             } else {
215 0           return { range => { $qualifier => { gte => $term } } };
216             }
217             } elsif ($base eq '<>') {
218 0 0         if (ref $qualifier) {
219             return { bool => { must_not => [ map {
220 0 0         if ($_ eq '_id') {
  0            
221 0           { ids => { values => [$term] } };
222             } else {
223 0           { text_phrase => { $_ => { query => $term } } };
224             }
225             } @$qualifier ] } };
226             } else {
227 0 0         if ($qualifier eq '_id') {
228 0           return { bool => { must_not => [ { ids => { values => [$term] } } ] } };
229             }
230 0           return { bool => { must_not => [ { text_phrase => { $qualifier => { query => $term } } } ] } };
231             }
232             } elsif ($base eq 'exact') {
233 0 0         if (ref $qualifier) {
234             return { bool => { should => [ map {
235 0 0         if ($_ eq '_id') {
  0            
236 0           { ids => { values => [$term] } };
237             } else {
238 0           { text_phrase => { $_ => { query => $term } } };
239             }
240             } @$qualifier ] } };
241             } else {
242 0 0         if ($qualifier eq '_id') {
243 0           return { ids => { values => [$term] } };
244             }
245 0           return { text_phrase => { $qualifier => { query => $term } } };
246             }
247             } elsif ($base eq 'any') {
248 0           $term = [split /\s+/, trim($term)];
249 0 0         if (ref $qualifier) {
250 0           return { bool => { should => [ map {
251 0           $q = $_;
252 0           map { _text_node($q, $_) } @$term;
  0            
253             } @$qualifier ] } };
254             } else {
255 0           return { bool => { should => [map { _text_node($qualifier, $_) } @$term] } };
  0            
256             }
257             } elsif ($base eq 'all') {
258 0           $term = [split /\s+/, trim($term)];
259 0 0         if (ref $qualifier) {
260 0           return { bool => { should => [ map {
261 0           $q = $_;
262 0           { bool => { must => [map { _text_node($q, $_) } @$term] } };
  0            
263             } @$qualifier ] } };
264             } else {
265 0           return { bool => { must => [map { _text_node($qualifier, $_) } @$term] } };
  0            
266             }
267             } elsif ($base eq 'within') {
268 0           my @range = split /\s+/, $term;
269 0 0         if (@range == 1) {
270 0 0         if (ref $qualifier) {
271 0           return { bool => { should => [ map { { text => { $_ => { query => $term } } } } @$qualifier ] } };
  0            
272             } else {
273 0           return { text => { $qualifier => { query => $term } } };
274             }
275             }
276 0 0         if (ref $qualifier) {
277 0           return { bool => { should => [ map { { range => { $_ => { lte => $range[0], gte => $range[1] } } } } @$qualifier ] } };
  0            
278             } else {
279 0           return { range => { $qualifier => { lte => $range[0], gte => $range[1] } } };
280             }
281             }
282              
283 0 0         if (ref $qualifier) {
284 0           return { bool => { should => [ map {
285 0           _text_node($_, $term, @modifiers);
286             } @$qualifier ] } };
287             } else {
288 0           return _text_node($qualifier, $term, @modifiers);
289             }
290             }
291              
292             sub _text_node {
293 0     0     my ($qualifier, $term, @modifiers) = @_;
294 0 0         if ($term =~ /[^\\][\*\?]/) { # TODO only works for single terms, mapping
295 0           return { wildcard => { $qualifier => { value => $term } } };
296             }
297 0           for my $m (@modifiers) {
298 0 0         if ($m->[1] eq 'fuzzy') { # TODO only works for single terms, mapping fuzzy_factor
299 0           return { fuzzy => { $qualifier => { value => $term, max_expansions => 10, min_similarity => 0.75 } } };
300             }
301             }
302 0 0         if ($term =~ /\s/) {
303 0           return { text_phrase => { $qualifier => { query => $term } } };
304             }
305 0           { text => { $qualifier => { query => $term } } };
306             }
307              
308             1;
309              
310             =head1 NAME
311              
312             Catmandu::Store::ElasticSearch::CQL - Converts a CQL query string to a Elasticsearch query hashref
313              
314             =head1 SYNOPSIS
315              
316             $es_query = Catmandu::Store::ElasticSearch::CQL
317             ->new(mapping => $cql_mapping)
318             ->parse($cql_query_string);
319              
320             =head1 DESCRIPTION
321              
322             This package currently parses most of CQL 1.1:
323              
324             and
325             or
326             not
327             prox
328             prox/distance<$n
329             srw.allRecords
330             srw.serverChoice
331             srw.anywhere
332             cql.allRecords
333             cql.serverChoice
334             cql.anywhere
335             =
336             scr
337             =/fuzzy
338             scr/fuzzy
339             <
340             >
341             <=
342             >=
343             <>
344             exact
345             all
346             any
347             within
348              
349             =head1 METHODS
350              
351             =head2 parse
352              
353             Parses the given CQL query string with L and converts it to a Elasticsearch query hashref.
354              
355             =head2 parse_node
356              
357             Converts the given L to a Elasticsearch query hashref.
358              
359             =head1 TODO
360              
361             support cql 1.2, more modifiers (esp. all of masked), sortBy, encloses
362              
363             =head1 SEE ALSO
364              
365             L.
366              
367             =cut
368