File Coverage

blib/lib/SQL/Abstract/FromQuery.pm
Criterion Covered Total %
statement 178 186 95.7
branch 44 58 75.8
condition 15 23 65.2
subroutine 34 35 97.1
pod 2 12 16.6
total 273 314 86.9


line stmt bran cond sub pod time code
1             package SQL::Abstract::FromQuery;
2            
3 10     10   41543 use strict;
  10         12  
  10         225  
4 7     7   27 use warnings;
  7         9  
  7         193  
5 7     7   24 use Scalar::Util qw/refaddr reftype blessed/;
  7         10  
  7         619  
6 7     7   3025 use List::MoreUtils qw/mesh/;
  7         54749  
  7         44  
7 7     7   4331 use Module::Load qw/load/;
  7         2034  
  7         37  
8 7         591 use Params::Validate qw/validate SCALAR SCALARREF CODEREF ARRAYREF HASHREF
9 7     7   3382 UNDEF BOOLEAN/;
  7         43799  
10 7     7   2852 use UNIVERSAL::DOES qw/does/;
  7         2619  
  7         351  
11 7     7   81 use Digest::MD5 qw/md5_base64/;
  7         8  
  7         288  
12 7     7   2919 use mro 'c3';
  7         3026  
  7         28  
13            
14 7     7   3078 use namespace::clean;
  7         48398  
  7         35  
15            
16             our $VERSION = '0.09';
17            
18             # root grammar (will be inherited by subclasses)
19             my $root_grammar = do {
20 7     7   8962 use Regexp::Grammars 1.038;
  7         132814  
  7         51  
21             qr{
22             #
23            
24            
25            
26            
27             \A (?:
28            
29             |
30             |
31             |
32             )
33             (?: \Z | )
34            
35            
36             (*COMMIT) (?: | )
37            
38            
39             (*COMMIT) (?: | )
40            
41            
42             <[value]>+ % ,
43            
44            
45             BETWEEN (*COMMIT) (?: AND | )
46            
47            
48             \<= | \< | \>= | \>
49            
50            
51             \<\> | -(?!\d) | != | !
52            
53            
54            
55             |
56             |
57             |
58             # | # removed from "standard" value because it might
59             # interfere with other codes like gender M/F
60            
61            
62             NULL
63            
64            
65             \.\.
66             | --
67            
68            
69             :(?::)?
70            
71            
72             Y(?:ES)? (?{ $MATCH = 1 })
73             | T(?:RUE)? (?{ $MATCH = 1 })
74             | N(?:O)? (?{ $MATCH = 0 })
75             | F(?:ALSE)? (?{ $MATCH = 0 })
76            
77            
78            
79             |
80            
81            
82             '(.*?)' (*COMMIT) (?{ $MATCH = $CAPTURE })
83             | "(.*?)" (*COMMIT) (?{ $MATCH = $CAPTURE })
84            
85            
86             [^\s,]+(?:\s+[^\s,]+)*?
87            
88             }xms;
89             };
90            
91            
92            
93             #======================================================================
94             # CLASS METHODS
95             #======================================================================
96             sub sub_grammar {
97 2     2 0 3 my $class = shift;
98 2         5 return; # should redefine method in subclasses that refine the root grammar
99             }
100            
101             my %params_for_new = (
102             -components => {type => ARRAYREF, optional => 1 },
103             -fields => {type => HASHREF, default => {} },
104             -multicols_sep => {type => SCALAR, optional => 1 },
105             );
106            
107             sub new {
108 8     8 1 1313 my $class = shift;
109 8         12 my $self = {};
110 8         263 my %args = validate(@_, \%params_for_new);
111            
112             # load optional components
113 8 100       55 if ($args{-components}) {
114             # deactivate strict refs because we'll be playing with symbol tables
115 7     7   63 no strict 'refs';
  7         9  
  7         8963  
116            
117 5         5 my @components;
118 5         7 foreach my $component (@{$args{-components}}) {
  5         16  
119 6 50       87 $component =~ s/^\+//
120             or $component = __PACKAGE__ . "::$component";
121 6         22 load $component;
122 6         88 push @components, $component;
123 6         43 my @sub_grammar = $component->sub_grammar;
124 6 100       33 push @{$self->{grammar_ISA}}, @sub_grammar if @sub_grammar;
  4         18  
125             }
126            
127             # a new anonymous class will inherit from all components
128 5         43 $class .= "::_ANON_::" . md5_base64(join ",", @components);
129 5 50       8 unless (@{$class . "::ISA"}) {
  5         52  
130             # dynamically create that class and use 'c3' inheritance in it
131 5         8 push @{$class . "::ISA"}, @components;
  5         47  
132 5         26 mro::set_mro($class, 'c3');
133             }
134             }
135            
136             # use root grammar if no derived grammar was installed by components
137 8   100     41 $self->{grammar_ISA} ||= [ 'SQL::Abstract::FromQuery' ];
138            
139             # setup fields info
140 8         12 foreach my $type (keys %{$args{-fields}}) {
  8         35  
141 4 100       12 if ($type eq 'IGNORE') {
142 1 50       5 ref $args{-fields}{IGNORE} eq 'Regexp'
143             or die "IGNORE should be associated with a qr/.../ regular expression";
144 1         3 $self->{IGNORE} = $args{-fields}{IGNORE};
145             }
146             else {
147 3         7 my $fields_aref = $args{-fields}{$type};
148 3 50       14 does($fields_aref, 'ARRAY')
149             or die "list of fields for type $type should be an arrayref";
150 3         67 $self->{field}{$_} = $type foreach @$fields_aref;
151             }
152             }
153            
154             # other args are just copied into $self (at the moment, only one such arg)
155 8         72 $self->{$_} = $args{-$_} for qw/multicols_sep/;
156            
157             # return the blessed object
158 8         38 bless $self, $class;
159             }
160            
161             sub _error_handler {
162 33     33   33 my $class = shift;
163             return 'INCORRECT INPUT', sub {
164 44     44   154 my ($error, $rule, $context) = @_;
165 44         96 my $msg = {
166             negated_values => 'Expected a value after negation',
167             op_and_value => 'Expected a value after comparison operator',
168             between => 'Expected min and max after "BETWEEN"',
169             standard => 'Unexpected input after initial value',
170             }->{$rule};
171 44   33     75 $msg //= "Could not parse rule '$rule'";
172 44 100       77 $msg .= " ('$context')" if $context;
173 44         84 return $msg;
174 33         107 };
175             }
176            
177            
178             #======================================================================
179             # INSTANCE METHODS
180             #======================================================================
181            
182            
183             sub _grammar {
184 10     10   15 my ($self, $rule) = @_;
185            
186 10         16 my $extends = join "", map {"\n"} @{$self->{grammar_ISA}};
  10         40  
  10         22  
187 10         26 my $grammar = "<$rule>\n$extends";
188            
189             # compile this grammar. NOTE : since Regexp::Grammars uses a very
190             # special form of operator overloading, we must go through an eval
191             # so that qr/../ receives a string without variable interpolation;
192             # do {use Regexp::Grammars; qr{$grammar}x;} would seem logical but won't work.
193 10         12 local $@;
194 7 50   7   36 my $compiled_grammar = eval "use Regexp::Grammars; qr{$grammar}x"
  7         8  
  7         32  
  10         621  
195             or die "INVALID GRAMMAR: $@";
196            
197 10         57 return $compiled_grammar;
198             }
199            
200            
201            
202            
203             sub parse {
204 36     36 1 13429 my ($self, $data) = @_;
205 36         46 my $class = ref $self;
206            
207             # if $data is an object with ->param() method, transform into plain hashref
208 36 50 33     123 $data = $self->_flatten_into_hashref($data) if blessed $data
209             && $data->can('param');
210            
211             # set error translator for grammars
212 36         78 my ($err_msg, $err_translator) = $self->_error_handler;
213 36         94 my $tmp = Regexp::Grammars::set_error_translator($err_translator);
214            
215             # parse each field within $data
216 36         387 my %result;
217             my %errors;
218             FIELD:
219 36         75 foreach my $field (keys %$data) {
220             # ignore fields in exclusion list or fields without any data
221 51 100 66     238 !$self->{IGNORE} or $field !~ $self->{IGNORE} or next FIELD;
222 50 50       101 my $val = $data->{$field} or next FIELD;
223            
224             # decide which grammar to apply
225 50   100     157 my $rule = $self->{field}{$field} || 'standard';
226 50   66     161 my $grammar = $self->{grammar}{$rule} ||= $self->_grammar($rule);
227            
228             # invoke grammar on field content
229 50 100       119 if ($val =~ $grammar->with_actions($self)) {
230 41         137 $result{$field} = $/{$rule};
231             }
232             else {
233 9         231 $errors{$field} = [@!];
234             }
235             }
236            
237             # report errors, if any
238 36 100       101 SQL::Abstract::FromQuery::_Exception->throw($err_msg, %errors) if %errors;
239            
240             # otherwise fix multicolumns and then return result
241 31 100       60 $self->distribute_multicols_criteria(\%result) if $self->{multicols_sep};
242 31         106 return \%result;
243             }
244            
245            
246             sub _flatten_into_hashref {
247 0     0   0 my ($self, $data) = @_;
248 0         0 my %h;
249 0         0 foreach my $field ($data->param()) {
250 0         0 my @vals = $data->param($field);
251 0         0 my $val = join ",", @vals; # TOO simple-minded - should make it more abstract
252 0         0 $h{$field} = $val;
253             }
254 0         0 return \%h;
255             }
256            
257            
258            
259            
260             sub distribute_multicols_criteria {
261 2     2 0 3 my ($self, $criteria) = @_;
262            
263 2         14 my $sep = qr[$self->{multicols_sep}];
264 2         1 my @and_conditions;
265            
266             # loop over keys that contain the multicol separator character
267 2         16 foreach my $multi_cols_key (grep /$sep/, sort keys %$criteria) {
268            
269             # separate individual columns
270 2         7 my @cols = split $sep, $multi_cols_key;
271            
272             # remove the entry from the hash and keep the multi-value
273 2         3 my $vals = delete $criteria->{$multi_cols_key};
274            
275             # extract the distributed conditions
276 2         6 my $conditions = $self->_build_conditions(\@cols, $vals);
277            
278             # add that to the list of conditions
279 2 100       6 my $new_cond = @$conditions > 1 ? {-or => $conditions} : $conditions->[0];
280 2         3 push @and_conditions, $new_cond;
281             }
282            
283             # assemble conditions and put them back into the criteria hash
284 2         4 my $previous_and = $criteria->{-and};
285 2 50       4 push @and_conditions, $previous_and if $previous_and;
286 2 100       9 $criteria->{-and} = \@and_conditions if @and_conditions;
287             }
288            
289            
290             sub _build_conditions {
291 2     2   3 my ($self, $cols, $val) = @_;
292            
293             # is this a SQL::Abstract '-in' clause ?
294 2   100     8 my $ref = ref $val || '';
295 2   66     9 my $is_in = $ref eq 'HASH' && join('', keys %$val) eq '-in';
296            
297             # for easyness of the algorithm, an '-in' clause or a plain scalar
298             # are both treated as a list
299 2 50       4 my @vals = $is_in ? @{$val->{-in}}
  1 100       3  
300             : $ref ? die "unexpected ref value for multi_cols_key"
301             : ($val);
302            
303             # for each multi-columns value, we build a "condition" (hashref col=>val)
304 2         3 my @conditions;
305 2         8 my $sep = qr[$self->{multicols_sep}];
306 2         3 foreach my $val (@vals) {
307 3         8 my @single_vals = split $sep, $val;
308 3 50       6 @$cols == @single_vals
309             or die "inconsistent number of values for multi_cols_key";
310 3         20 my %condition = mesh @$cols, @single_vals;
311 3         6 push @conditions, \%condition;
312             }
313            
314             # the result is a list of conditions that will be 'OR-ed' by the caller
315 2         5 return \@conditions;
316             }
317            
318            
319            
320             #======================================================================
321             # ACTIONS HOOKED TO THE GRAMMAR
322             #======================================================================
323            
324             sub negated_values {
325 5     5 0 91 my ($self, $h) = @_;
326 5         7 my $vals = $h->{values};
327 5 100       10 if (ref $vals) {
328 3 50       9 ref $vals eq 'HASH' or die 'unexpected reference in negation';
329 3         7 my ($op, $val, @others) = %$vals;
330 3 50       7 not @others or die 'unexpected hash size in negation';
331 3 100       10 if ($op eq '-in') {return {-not_in => $val} }
  1 50       4  
  2         7  
332 0         0 elsif ($op eq '=' ) {return {'<>' => $val} }
333             else {die "unexpected operator '$op' in negation"}
334             }
335             else {
336 2         5 return {'<>' => $vals};
337             }
338             }
339            
340            
341             sub null {
342 5     5 0 76 my ($self, $h) = @_;
343 5         13 return {'=' => undef};
344             # Note: unfortunately, we can't return just undef at this stage,
345             # because Regex::Grammars would interpret it as a parse failure.
346             }
347            
348            
349             sub op_and_value {
350 2     2 0 59 my ($self, $h) = @_;
351 2         5 return {$h->{compare} => $h->{value}};
352             }
353            
354            
355             sub between {
356 7     7 0 106 my ($self, $h) = @_;
357 7         28 return {-between => [$h->{min}, $h->{max}]};
358             }
359            
360            
361            
362             sub values {
363 34     34 0 613 my ($self, $h) = @_;
364 34         31 my $n_values = @{$h->{value}};
  34         47  
365 34 100       104 return $n_values > 1 ? {-in => $h->{value}}
366             : $h->{value}[0];
367             }
368            
369            
370             sub date {
371 4     4 0 69 my ($self, $h) = @_;
372 4 50       18 $h->{year} += 2000 if length($h->{year}) < 3;
373 4         8 return sprintf "%04d-%02d-%02d", @{$h}{qw/year month day/};
  4         24  
374             }
375            
376            
377             sub time {
378 2     2 0 31 my ($self, $h) = @_;
379 2   50     13 $h->{seconds} ||= 0;
380 2         4 return sprintf "%02d:%02d:%02d", @{$h}{qw/hour minutes seconds/};
  2         12  
381             }
382            
383            
384             sub string {
385 57     57 0 773 my ($self, $s) = @_;
386            
387             # if any '*', substitute by '%' and make it a "-like" operator
388 57         66 my $is_pattern = $s =~ tr/*/%/;
389             # NOTE : a reentrant =~ s/../../ would core dump, but tr/../../ is OK
390            
391 57 100       128 return $is_pattern ? {-like => $s} : $s;
392             }
393            
394            
395             #======================================================================
396             # PRIVATE CLASS FOR REPORTING PARSE EXCEPTIONS
397             #======================================================================
398            
399             package
400             SQL::Abstract::FromQuery::_Exception;
401 7     7   32 use strict;
  7         11  
  7         200  
402 7     7   26 use warnings;
  7         10  
  7         1381  
403            
404             use overload
405             '""' => sub {
406 8     8   633 my $self = shift;
407 8         14 my $msg = $self->{err_msg};
408 8         9 for my $field (sort keys %{$self->{errors}}) {
  8         28  
409 12         15 my $field_errors = $self->{errors}{$field};
410 12         36 $msg .= "\n$field : " . join ", ", @$field_errors;
411             }
412            
413 8         65 return $msg;
414             },
415 7         81 fallback => 1,
416 7     7   728 ;
  7         7  
417            
418            
419             sub throw {
420 5     5   12 my ($class, $err_msg, %errors) = @_;
421 5         19 my $self = bless {err_msg => $err_msg, errors => \%errors}, $class;
422 5         28 die $self;
423             }
424            
425            
426             #======================================================================
427             1; # End of SQL::Abstract::FromQuery
428             #======================================================================
429            
430             __END__