File Coverage

blib/lib/Catmandu/Store/MongoDB/CQL.pm
Criterion Covered Total %
statement 127 138 92.0
branch 75 96 78.1
condition 21 30 70.0
subroutine 12 12 100.0
pod 2 2 100.0
total 237 278 85.2


line stmt bran cond sub pod time code
1             package Catmandu::Store::MongoDB::CQL;
2              
3 4     4   1181 use Catmandu::Sane;
  4         156501  
  4         29  
4 4     4   2954 use CQL::Parser;
  4         111488  
  4         154  
5 4     4   38 use Carp qw(confess);
  4         9  
  4         216  
6 4     4   25 use Catmandu::Util qw(:is array_includes require_package);
  4         9  
  4         1262  
7 4     4   31 use Data::Dumper;
  4         7  
  4         230  
8 4     4   51 use Moo;
  4         9  
  4         33  
9              
10             with 'Catmandu::Logger';
11              
12             has parser => (is => 'ro', lazy => 1, builder => '_build_parser');
13             has mapping => (is => 'ro');
14              
15             my $any_field = qr'^(srw|cql)\.(serverChoice|anywhere)$'i;
16             my $match_all = qr'^(srw|cql)\.allRecords$'i;
17              
18             sub _build_parser {
19 1     1   12 CQL::Parser->new;
20             }
21              
22             sub parse {
23 29     29 1 3626 my ($self, $query) = @_;
24              
25 29 50       46 my $node = eval {$self->parser->parse($query)} or do {
  29         590  
26 0         0 my $error = $@;
27 0         0 die "cql error: $error";
28             };
29              
30 29         21434 my $mongo_query = $self->visit($node);
31              
32 27 50       565 if ($self->log->is_debug()) {
33              
34 0         0 $self->log->debug("CQL query: $query, translated into mongo query: "
35             . Dumper($mongo_query));
36              
37             }
38              
39 27         2626 $mongo_query;
40             }
41              
42             sub visit {
43 45     45 1 79 my ($self, $node) = @_;
44              
45 45 50       125 my $indexes = $self->mapping ? $self->mapping->{indexes} : undef;
46              
47 45 50       75 confess "no cql_mapping.indexes defined" unless $indexes;
48              
49 45 100       190 if ($node->isa('CQL::TermNode')) {
    50          
    50          
50 37         67 my $term = $node->getTerm;
51              
52 37 50       187 if ($term =~ $match_all) {
53 0         0 return +{};
54             }
55              
56 37         63 my $qualifier = $node->getQualifier;
57 37         108 my $relation = $node->getRelation;
58 37         119 my @modifiers = $relation->getModifiers;
59 37         245 my $base = lc $relation->getBase;
60 37         163 my $search_field;
61             my $search_clause;
62              
63 37 100       75 if ($base eq 'scr') {
64 2 50 33     15 if ($self->mapping && $self->mapping->{default_relation}) {
65 2         6 $base = $self->mapping->{default_relation};
66             }
67             else {
68 0         0 $base = '=';
69             }
70             }
71              
72             #fields to search for
73 37 100       98 if ($qualifier =~ $any_field) {
74              
75             #set default field explicitely
76 2 50 33     14 if ($self->mapping && $self->mapping->{default_index}) {
77 2         5 $search_field = $self->mapping->{default_index};
78             }
79             else {
80 0         0 $search_field = '_all';
81             }
82             }
83             else {
84 35         54 $search_field = $qualifier;
85              
86             #change search field
87             $search_field =~ s/(?<=[^_])_(?=[^_])//g
88             if $self->mapping
89 35 50 33     132 && $self->mapping->{strip_separating_underscores};
90 35 100       86 my $q_mapping = $indexes->{$search_field}
91             or confess "cql error: unknown index $search_field";
92 34 100       81 $q_mapping->{op}->{$base}
93             or confess "cql error: relation $base not allowed";
94              
95 33         50 my $op = $q_mapping->{op}->{$base};
96              
97 33 100 100     92 if (ref $op && $op->{field}) {
    100          
98 1         2 $search_field = $op->{field};
99             }
100             elsif ($q_mapping->{field}) {
101 3         7 $search_field = $q_mapping->{field};
102             }
103              
104             #change term using filters
105 33         38 my $filters;
106 33 100 100     90 if (ref $op && $op->{filter}) {
    100          
107 1         2 $filters = $op->{filter};
108             }
109             elsif ($q_mapping->{filter}) {
110 1         3 $filters = $q_mapping->{filter};
111             }
112              
113 33 100       48 if ($filters) {
114 2         5 for my $filter (@$filters) {
115 2 50       6 if ($filter eq 'lowercase') {
116 2         4 $term = lc $term;
117             }
118             }
119             }
120              
121             #change term using callbacks
122 33 100 100     89 if (ref $op && $op->{cb}) {
    100          
123 1         2 my ($pkg, $sub) = @{$op->{cb}};
  1         3  
124 1         4 $term = require_package($pkg)->$sub($term);
125             }
126             elsif ($q_mapping->{cb}) {
127 1         2 my ($pkg, $sub) = @{$q_mapping->{cb}};
  1         4  
128 1         6 $term = require_package($pkg)->$sub($term);
129             }
130             }
131              
132             #field search
133             my $unmasked
134 35         123 = array_includes([map {$_->[1]} @modifiers], "cql.unmasked");
  2         9  
135              
136             # trick to force numeric values interpreted as integers
137 35 100       368 $term = $term + 0 if ($term =~ /^[1-9]\d*$/);
138              
139 35 100 66     151 if ($base eq '=' or $base eq 'scr') {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
140 12 50       16 unless ($unmasked) {
141 12 50       23 $term
142             = _is_wildcard($term) ? _wildcard_to_regex($term) : $term;
143             }
144              
145 12         30 $search_clause = +{$search_field => $term};
146             }
147             elsif ($base eq '<') {
148 1         4 $search_clause = +{$search_field => {'$lt' => $term}};
149             }
150             elsif ($base eq '>') {
151 1         4 $search_clause = +{$search_field => {'$gt' => $term}};
152             }
153             elsif ($base eq '<=') {
154 1         3 $search_clause = +{$search_field => {'$lte' => $term}};
155             }
156             elsif ($base eq '>=') {
157 1         3 $search_clause = +{$search_field => {'$gte' => $term}};
158             }
159             elsif ($base eq '<>') {
160 2         9 $search_clause = +{$search_field => {'$ne' => $term}};
161             }
162             elsif ($base eq 'exact') {
163 10         22 $search_clause = +{$search_field => $term};
164             }
165             elsif ($base eq 'all') {
166 3         13 my @terms = split /\s+/, $term;
167              
168             #query $all in mongo means exact matching, so we always need regular expressions here
169 3         9 for (my $i = 0; $i < scalar(@terms); $i++) {
170              
171 9         14 my $term = $terms[$i];
172              
173 9 100       19 if ($unmasked) {
    100          
174              
175 3         5 $term = _quote_wildcard($term);
176 3         22 $term = qr($term);
177              
178             }
179             elsif (_is_wildcard($term)) {
180              
181 2         4 $term = _wildcard_to_regex($term);
182              
183             }
184             else {
185              
186 4         28 $term = qr($term);
187              
188             }
189              
190 9         23 $terms[$i] = $term;
191              
192             }
193              
194 3         10 $search_clause = +{$search_field => {'$all' => \@terms}};
195             }
196             elsif ($base eq 'any') {
197 3         14 my @terms = split /\s+/, $term;
198              
199             #query $in in mongo means exact matching, so we always need regular expressions here
200 3         9 for (my $i = 0; $i < scalar(@terms); $i++) {
201              
202 9         16 my $term = $terms[$i];
203              
204 9 100       18 if ($unmasked) {
    100          
205              
206 3         7 $term = _quote_wildcard($term);
207 3         22 $term = qr($term);
208              
209             }
210             elsif (_is_wildcard($term)) {
211              
212 2         6 $term = _wildcard_to_regex($term);
213              
214             }
215             else {
216              
217 4         52 $term = qr($term);
218              
219             }
220              
221 9         28 $terms[$i] = $term;
222              
223             }
224              
225 3         8 $search_clause = +{$search_field => {'$in' => \@terms}};
226             }
227             elsif ($base eq 'within') {
228 1         5 my @range = split /\s+/, $term;
229              
230 1 50       4 if (@range == 1) {
231 0         0 $search_clause = +{$search_field => $term};
232             }
233             else {
234 1         5 $search_clause
235             = +{$search_field =>
236             {'$gte' => $range[0], '$lte' => $range[1]}
237             };
238             }
239             }
240              
241             #as $base is always set, this code should be removed?
242             else {
243 0 0       0 unless ($unmasked) {
244 0 0       0 $term
245             = _is_wildcard($term) ? _wildcard_to_regex($term) : $term;
246             }
247              
248 0         0 $search_clause = +{$search_field => $term};
249             }
250              
251 35         81 return $search_clause;
252             }
253             elsif ($node->isa('CQL::ProxNode')) {
254              
255             # TODO: apply cql_mapping
256 0         0 confess "not supported";
257             }
258             elsif ($node->isa('CQL::BooleanNode')) {
259 8         36 my $lft = $node->left;
260 8         35 my $rgt = $node->right;
261 8         36 my $lft_q = $self->visit($lft);
262 8         14 my $rgt_q = $self->visit($rgt);
263 8         22 my $op = '$' . lc($node->op);
264              
265 8 100 100     46 if ($op eq '$and' || $op eq '$or') {
    50          
266 4         13 return +{$op => [$lft_q, $rgt_q]};
267             }
268             elsif ($op eq '$not') {
269 4         12 my ($k, $v) = each(%$rgt_q);
270              
271 4 100       10 if ($k eq '$or') {
    100          
272 1         6 return +{%$lft_q, '$nor' => $v};
273             }
274             elsif ($k eq '$and') {
275              
276             #$nand not implemented yet (https://jira.mongodb.org/browse/SERVER-15577)
277 1         6 return +{%$lft_q, '$nor' => [{'$and' => $v}]};
278             }
279             else {
280 2         14 return +{%$lft_q, '$nor' => [{'$and' => [{$k => $v}]}]};
281             }
282             }
283             }
284             }
285              
286             sub _is_wildcard {
287 24     24   36 my $value = $_[0];
288              
289 24 50 66     134 (index($value, '^') == 0)
      66        
290             || (rindex($value, '^') == length($value) - 1)
291             || (index($value, '*') >= 0)
292             || (index($value, '?') >= 0);
293             }
294              
295             sub _wildcard_to_regex {
296 4     4   5 my $value = $_[0];
297 4         6 my $regex = $value;
298 4         8 $regex =~ s/\*/.*/go;
299 4         5 $regex =~ s/\?/.?/go;
300 4         11 $regex =~ s/\^$/\$/o;
301 4         36 qr/$regex/;
302             }
303              
304             sub _quote_wildcard {
305 6     6   8 my $value = $_[0];
306 6         10 $value =~ s/\*/\\*/go;
307 6         7 $value =~ s/\?/\\?/go;
308 6         15 $value =~ s/\^/\\^/go;
309 6         10 $value;
310             }
311              
312             1;
313              
314             __END__
315              
316             =head1 NAME
317              
318             Catmandu::Store::MongoDB::CQL - Converts a CQL query string to a MongoDB query string
319              
320             =head1 SYNOPSIS
321              
322             $mongo_query = Catmandu::Store::ElasticSearch::CQL
323             ->new(mapping => $cql_mapping)
324             ->parse($cql_query_string);
325              
326             =head1 DESCRIPTION
327              
328             This package currently parses most of CQL 1.1:
329              
330             and
331             or
332             not
333             srw.allRecords
334             srw.serverChoice
335             srw.anywhere
336             cql.allRecords
337             cql.serverChoice
338             cql.anywhere
339             =
340             scr
341             <
342             >
343             <=
344             >=
345             <>
346             exact
347             all
348             any
349             within
350              
351             See L<https://www.loc.gov/standards/sru/cql/spec.html> for
352             more information on the CQL query language.
353              
354             =head1 LIMITATIONS
355              
356             MongoDB is not a full-text search engine. All queries will try to find exact
357             matches in the database, except for the 'any' and 'all' relations which will
358             translate queries into wildcard queries (which are slow!):
359              
360             title any 'funny cats'
361              
362             will be treated internally as something like:
363              
364             title : { $regex : /funny/ } OR title : { $regex : /cats/ }
365              
366             And,
367              
368             title all 'funny cats'
369              
370             as
371              
372             title : { $regex : /funny/ } AND title : { $regex : /cats/ }
373              
374             This makes the 'any' and 'all' not as efficient (fast) as exact matches
375             '==','exact'.
376              
377             =head1 METHODS
378              
379             =head2 parse
380              
381             Parses the given CQL query string with L<CQL::Parser> and converts it to a Mongo query string.
382              
383             =head2 visit
384              
385             Converts the given L<CQL::Node> to a Mongo query string.
386              
387             =head1 REMARKS
388              
389             no support for fuzzy search, search modifiers, sortBy and encloses
390              
391             =head1 SEE ALSO
392              
393             L<CQL::Parser>.
394              
395             =cut