File Coverage

blib/lib/Catmandu/Store/ElasticSearch/CQL.pm
Criterion Covered Total %
statement 15 185 8.1
branch 0 124 0.0
condition 0 18 0.0
subroutine 5 12 41.6
pod 2 2 100.0
total 22 341 6.4


line stmt bran cond sub pod time code
1             package Catmandu::Store::ElasticSearch::CQL;
2              
3 1     1   4 use Catmandu::Sane;
  1         2  
  1         6  
4 1     1   249 use Catmandu::Util qw(require_package trim);
  1         1  
  1         70  
5 1     1   5 use Carp qw(confess);
  1         1  
  1         39  
6 1     1   756 use CQL::Parser;
  1         22946  
  1         121  
7 1     1   9 use Moo;
  1         1  
  1         7  
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->{match_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           { match_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 => [ { match_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           { match_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 {match_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 0         if ($qualifier eq '_id') {
256 0           return { ids => { values => $term } };
257             }
258 0           return { bool => { should => [map { _text_node($qualifier, $_) } @$term] } };
  0            
259             }
260             } elsif ($base eq 'all') {
261 0           $term = [split /\s+/, trim($term)];
262 0 0         if (ref $qualifier) {
263 0           return { bool => { should => [ map {
264 0           $q = $_;
265 0           { bool => { must => [map { _text_node($q, $_) } @$term] } };
  0            
266             } @$qualifier ] } };
267             } else {
268 0           return { bool => { must => [map { _text_node($qualifier, $_) } @$term] } };
  0            
269             }
270             } elsif ($base eq 'within') {
271 0           my @range = split /\s+/, $term;
272 0 0         if (@range == 1) {
273 0 0         if (ref $qualifier) {
274 0           return { bool => { should => [ map { { text => { $_ => { query => $term } } } } @$qualifier ] } };
  0            
275             } else {
276 0           return { match => { $qualifier => { query => $term } } };
277             }
278             }
279 0 0         if (ref $qualifier) {
280 0           return { bool => { should => [ map { { range => { $_ => { lte => $range[0], gte => $range[1] } } } } @$qualifier ] } };
  0            
281             } else {
282 0           return { range => { $qualifier => { lte => $range[0], gte => $range[1] } } };
283             }
284             }
285              
286 0 0         if (ref $qualifier) {
287 0           return { bool => { should => [ map {
288 0           _text_node($_, $term, @modifiers);
289             } @$qualifier ] } };
290             } else {
291 0           return _text_node($qualifier, $term, @modifiers);
292             }
293             }
294              
295             sub _text_node {
296 0     0     my ($qualifier, $term, @modifiers) = @_;
297 0 0         if ($term =~ /[^\\][\*\?]/) { # TODO only works for single terms, mapping
298 0           return { wildcard => { $qualifier => { value => $term } } };
299             }
300 0           for my $m (@modifiers) {
301 0 0         if ($m->[1] eq 'fuzzy') { # TODO only works for single terms, mapping fuzzy_factor
302 0           return { fuzzy => { $qualifier => { value => $term, max_expansions => 10 } } };
303             }
304             }
305 0 0         if ($term =~ /\s/) {
306 0           return { match_phrase => { $qualifier => { query => $term } } };
307             }
308 0           { match => { $qualifier => { query => $term } } };
309             }
310              
311             1;
312              
313             =head1 NAME
314              
315             Catmandu::Store::ElasticSearch::CQL - Converts a CQL query string to a Elasticsearch query hashref
316              
317             =head1 SYNOPSIS
318              
319             $es_query = Catmandu::Store::ElasticSearch::CQL
320             ->new(mapping => $cql_mapping)
321             ->parse($cql_query_string);
322              
323             =head1 DESCRIPTION
324              
325             This package currently parses most of CQL 1.1:
326              
327             and
328             or
329             not
330             prox
331             prox/distance<$n
332             srw.allRecords
333             srw.serverChoice
334             srw.anywhere
335             cql.allRecords
336             cql.serverChoice
337             cql.anywhere
338             =
339             scr
340             =/fuzzy
341             scr/fuzzy
342             <
343             >
344             <=
345             >=
346             <>
347             exact
348             all
349             any
350             within
351              
352             =head1 METHODS
353              
354             =head2 parse
355              
356             Parses the given CQL query string with L and converts it to a Elasticsearch query hashref.
357              
358             =head2 parse_node
359              
360             Converts the given L to a Elasticsearch query hashref.
361              
362             =head1 TODO
363              
364             support cql 1.2, more modifiers (esp. all of masked), sortBy, encloses
365              
366             =head1 SEE ALSO
367              
368             L.
369              
370             =cut
371