File Coverage

blib/lib/Data/DynamicValidator.pm
Criterion Covered Total %
statement 202 202 100.0
branch 43 48 89.5
condition 27 38 71.0
subroutine 35 35 100.0
pod 7 8 87.5
total 314 331 94.8


line stmt bran cond sub pod time code
1             package Data::DynamicValidator;
2             {
3             $Data::DynamicValidator::VERSION = '0.03';
4             }
5             #ABSTRACT: JPointer-like and Perl union for flexible perl data structures validation
6              
7 8     8   311440 use strict;
  8         22  
  8         314  
8 8     8   179 use warnings;
  8         17  
  8         266  
9              
10 8     8   59 use Carp;
  8         15  
  8         804  
11 8     8   9161 use Devel::LexAlias qw(lexalias);
  8         75674  
  8         584  
12 8     8   107 use PadWalker qw(peek_sub);
  8         27  
  8         455  
13 8     8   48 use Scalar::Util qw/looks_like_number/;
  8         16  
  8         880  
14 8     8   236005 use Storable qw(dclone);
  8         78924  
  8         896  
15              
16 8     8   7123 use aliased qw/Data::DynamicValidator::Error/;
  8         6235  
  8         63  
17 8     8   741 use aliased qw/Data::DynamicValidator::Filter/;
  8         17  
  8         45  
18 8     8   1256 use aliased qw/Data::DynamicValidator::Label/;
  8         15  
  8         50  
19 8     8   13012 use aliased qw/Data::DynamicValidator::Path/;
  8         22  
  8         51  
20              
21             use overload
22             fallback => 1,
23             '&{}' => sub {
24 36     36   246 my $self = shift;
25 36     36   129 return sub { $self->validate(@_) }
26 8     8   1021 };
  8         17  
  8         57  
  36         172  
27              
28 8     8   527 use parent qw/Exporter/;
  8         18  
  8         58  
29             our @EXPORT_OK = qw/validator/;
30              
31 8   50 8   694 use constant DEBUG => $ENV{DATA_DYNAMICVALIDATOR_DEBUG} || 0;
  8         18  
  8         19660  
32              
33              
34              
35              
36              
37              
38              
39              
40             sub validator {
41 48     48 1 54237 return Data::DynamicValidator->new(@_);
42             }
43              
44             sub new {
45 48     48 0 99 my ($class, $data) = @_;
46 48         203 my $self = {
47             _data => $data,
48             _errors => [],
49             _bases => [],
50             };
51 48         1610 return bless $self => $class;
52             }
53              
54              
55             sub validate {
56 40     40 1 166 my ($self, %args) = @_;
57              
58 40         66 my $on = $args{on };
59 40         58 my $should = $args{should };
60 40         59 my $because = $args{because};
61 40         51 my $each = $args{each };
62              
63 40 50 33     218 croak("Wrong arguments: 'on', 'should', 'because' should be specified")
      33        
64             if(!$on || !$should || !$because);
65              
66 40         41 warn "-- validating : $on \n" if DEBUG;
67              
68 40         82 my $errors = $self->{_errors};
69 40         49 my $selection_results;
70 40 50       85 if ( !@$errors ) {
71 40         648 my $success;
72 40         99 my $current_base = $self->current_base;
73 40         101 my $selector = $self->_rebase_selector($on);
74 40         108 ($success, $selection_results) = $self->_apply($selector, $should);
75 40 100       135 $self->report_error($because, $selector)
76             unless $success;
77             }
78             # OK, now going to child rules if there is no errors
79 40 100 100     194 if ( !@$errors && $each ) {
80 11         20 warn "-- no errors, will check children\n" if DEBUG;
81 11         43 $self->_validate_children($selection_results, $each);
82             }
83              
84 40         287 return $self;
85             }
86              
87              
88             sub report_error {
89 6     6 1 17 my ($self, $reason, $path) = @_;
90 6   66     18 $path //= $self->{_current_path};
91 6 50       13 croak "Can't report error unless path is undefined"
92             unless defined $path;
93 6         6 push @{ $self->{_errors} }, Error->new($reason, $path);
  6         37  
94             }
95              
96              
97 9     9 1 318 sub is_valid { @{ $_[0]->{_errors} } == 0; }
  9         58  
98              
99              
100              
101 10     10 1 51 sub errors { $_[0]->{_errors} }
102              
103              
104             sub rebase {
105 5     5 1 24 my ($self, $expandable_route, $rule) = @_;
106 5         14 my $current_base = $self->current_base;
107 5         15 my $selector = $self->_rebase_selector($expandable_route);
108 5         18 my $scenario = $self->_select($selector);
109 5         50 my $number_of_routes = @{ $scenario->{routes} };
  5         13  
110 5 50       16 carp "The route '$expandable_route' is ambigious for rebasing (should be unique)"
111             if $number_of_routes > 1;
112              
113 5 100       17 return $self if $number_of_routes == 0;
114              
115 4         7 push @{ $self->{_bases} }, $scenario->{routes}->[0];
  4         13  
116 4         17 $rule->($self);
117 4         37 pop @{ $self->{_bases} };
  4         12  
118 4         31 return $self;
119             }
120              
121              
122             sub current_base {
123 92     92 1 231 my $bases = $_[0]->{_bases};
124 92 100       218 return undef unless @$bases;
125 56         116 return $bases->[-1];
126             }
127              
128             ### private/implementation methods
129              
130             sub _rebase_selector {
131 45     45   74 my ($self, $selector) = @_;
132 45         84 my $current_base = $self->current_base;
133 45   100     129 my $add_base = $current_base && $selector !~ /^\/{2,}/;
134 45 100       469 my $rebased = $add_base ? $current_base . $selector : $selector;
135 45         61 warn "-- Rebasing selector $selector to $rebased \n" if DEBUG;
136 45         111 return $rebased;
137             }
138              
139             sub _validate_children {
140 11     11   19 my ($self, $selection_results, $each) = @_;
141 11         16 my ($routes, $values) = @{$selection_results}{qw/routes values/};
  11         28  
142 11         26 my $errors = $self->{_errors};
143 11         21 my $data = $self->{_data};
144 11         34 for my $i (0 .. @$routes-1) {
145 23         70 my $route = $routes->[$i];
146 23         25 push @{ $self->{_bases} }, $route;
  23         48  
147 23         38 my $value = $values->[$i];
148 23         81 my $label_for = { map { $_ => 1 } ($route->labels) };
  23         179  
149             # prepare context
150 23         152 my $pad = peek_sub($each);
151 23         102 while (my ($var, $ref) = each %$pad) {
152 38         202 my $var_name = substr($var, 1); # chomp sigil
153 38 100       135 next unless exists $label_for->{$var_name};
154 23         104 my $label_obj = Label->new($var_name, $route, $data);
155 23         86 lexalias($each, $var, \$label_obj);
156             }
157             # call
158 23         114 $self->{_current_path} = $route;
159 23         104 $each->($self, local $_ = Label->new('_', $route, $data));
160 23         1004 pop @{ $self->{_bases} };
  23         63  
161 23 100       144 last if(@$errors);
162             }
163             }
164              
165              
166             # Takes path-like expandable expression and returns hashref of path with corresponding
167             # values from data, e.g.
168              
169             # validator({ a => [5,'z']})->_select('/a/*');
170             # # will return
171             # # {
172             # # routes => ['/a/0', '/a/1'],
173             # # values => [5, z],
174             # # }
175              
176             # Actualy routes are presented by Path objects.
177              
178             sub _select {
179 58     58   82 my ($self, $expession) = @_;
180 58         230 my $data = $self->{_data};
181 58         130 my $routes = $self->_expand_routes($expession);
182 58         138 my $values = [ map { $_->value($data) } @$routes ];
  75         249  
183             return {
184 58         245 routes => $routes,
185             values => $values,
186             };
187             }
188              
189              
190              
191             # Takes xpath-like expandable expression and sorted array of exapnded path e.g.
192             # validator({ a => [5,'z']})->_expand_routes('/a/*');
193             # # will return [ '/a/0', '/a/1' ]
194             # validator({ a => [5,'z']})->_expand_routes('/a');
195             # # will return [ '/a' ]
196             # validator({ a => { b => [5,'z'], c => ['y']} })->_expand_routes('/a/*/*');
197             # # will return [ '/a/b/0', '/a/b/1', '/a/c/0' ]
198              
199             sub _expand_routes {
200 78     78   128 my ($self, $expression) = @_;
201 78         86 warn "-- Expanding routes for $expression\n" if DEBUG;
202             # striping leading slashes
203 78         195 $expression =~ s/\/{2,}/\//;
204 78         346 my @routes = ( Path->new($expression) );
205 78         150 my $result = [];
206 78         195 while (@routes) {
207 182         276 my $route = shift(@routes);
208 182         490 my $current = $self->{_data};
209 182         539 my $elements = $route->components;
210 182         217 my $i;
211 182         229 my $can_be_accessed = 0;
212 182         481 for ($i = 0; $i < @$elements; $i++) {
213 587         602 $can_be_accessed = 0;
214 587         840 my $element = $elements->[$i];
215             # no futher examination if current value is undefined
216 587 100       1680 last unless defined($current);
217 581 100       1340 next if($element eq '');
218 399         387 my $filter;
219 399         683 ($element, $filter) = _filter($element);
220 399         752 my $type = ref($current);
221 399         412 my $generator;
222             my $advancer;
223 399 100 100     2649 if ($element eq '*') {
    100 66        
    100 66        
      66        
224 73 100       231 if ($type eq 'HASH') {
    50          
225 42         155 my @keys = keys %$current;
226 42         70 my $idx = 0;
227             $generator = sub {
228 93     93   226 while($idx < @keys) {
229 80         146 my $key = $keys[$idx++];
230 80         427 my $match = $filter->($current->{$key}, {key => $key});
231 80 100       489 return $key if($match);
232             }
233 42         122 return undef;
234 42         189 };
235             } elsif ($type eq 'ARRAY') {
236 31         43 my $idx = 0;
237             $generator = sub {
238 84     84   227 while($idx < @$current) {
239 65         95 my $index = $idx++;
240 65         371 my $match = $filter->($current->[$index], {index => $index});
241 65 100       346 return $index if($match);
242             }
243 31         91 return undef;
244 31         145 };
245             }
246             }elsif ($type eq 'HASH' && exists $current->{$element}) {
247 225     225   669 $advancer = sub { $current->{$element} };
  225         427  
248             }elsif ($type eq 'ARRAY' && looks_like_number($element)
249             && (
250             ($element >= 0 && $element < @$current)
251             || ($element < 0 && abs($element) <= @$current)
252             )
253             ){
254 92     92   271 $advancer = sub { $current->[$element] };
  92         181  
255             }
256 399 100       1001 if ($generator) {
257 73         176 while ( defined( my $new_element = $generator->()) ) {
258 104         6680 my $new_path = dclone($route);
259 104         596 $new_path->components->[$i] = $new_element;
260 104         432 push @routes, $new_path;
261             }
262 73         178 $current = undef;
263 73         412 last;
264             }
265 326 100       595 if ($advancer) {
266 317         660 $current = $advancer->();
267 317         397 $can_be_accessed = 1;
268 317         2086 next;
269             }
270             # the current element isn't hash nor array
271             # we can't traverse further, because there is more
272             # else current path
273 9         16 $current = undef;
274 9         33 $can_be_accessed = 0;
275             }
276 182   66     586 my $do_expansion = defined $current
277             || ($can_be_accessed && $i == @$elements);
278 182         156 warn "-- Expanded route : $route \n" if(DEBUG && $do_expansion);
279 182 100       858 push @$result, $route if($do_expansion);
280             }
281 78         368 return [ sort @$result ];
282             }
283              
284             sub _filter {
285 399     399   495 my $element = shift;
286 399         375 my $filter;
287 399         2015 my $condition_re = qr/(.+?)(\[(.+)\])/;
288 399         1601 my @parts = $element =~ /$condition_re/;
289 399 100 66     1349 if (@parts == 3 && defined($parts[2])) {
290 18         31 $element = $parts[0];
291 18         28 my $condition = $parts[2];
292 18         120 $filter = Filter->new($condition);
293             } else {
294 381     83   1181 $filter = sub { 1 }; # always true
  83         161  
295             }
296 399         1495 return ($element, $filter);
297             }
298              
299              
300             # Takes the expandable expression and validation closure, then
301             # expands it, and applies the closure for every data piese,
302             # obtainted from expansion.
303              
304             # Returns the list of success validation mark and the hash
305             # of details (obtained via _select).
306              
307             sub _apply {
308 52     52   83 my ($self, $on, $should) = @_;
309 52         121 my $selection_results = $self->_select($on);
310 52         97 my $values = $selection_results->{values};
311 52   100     346 my $result = $values && @$values && $should->( @$values );
312 52         1346 return ($result, $selection_results);
313             };
314              
315             1;
316              
317             __END__
318              
319             =pod
320              
321             =encoding UTF-8
322              
323             =head1 NAME
324              
325             Data::DynamicValidator - JPointer-like and Perl union for flexible perl data structures validation
326              
327             =head1 VERSION
328              
329             version 0.03
330              
331             =head1 SYNOPSIS
332              
333             my $my_complex_config = {
334             features => [
335             "a/f",
336             "application/feature1",
337             "application/feature2",
338             ],
339             service_points => {
340             localhost => {
341             "a/f" => { job_slots => 3, },
342             "application/feature1" => { job_slots => 5 },
343             "application/feature2" => { job_slots => 5 },
344             },
345             "127.0.0.1" => {
346             "application/feature2" => { job_slots => 5 },
347             },
348             },
349             mojolicious => {
350             hypnotoad => {
351             pid_file => '/tmp/hypnotoad-ng.pid',
352             listen => [ 'http://localhost:3000' ],
353             },
354             },
355             };
356              
357             use Data::DynamicValidator qw/validator/;
358             use Net::hostent;
359              
360             my $errors = validator($cfg)->(
361             on => '/features/*',
362             should => sub { @_ > 0 },
363             because => "at least one feature should be defined",
364             each => sub {
365             my $f = $_->();
366             shift->(
367             on => "//service_points/*/`$f`/job_slots",
368             should => sub { defined($_[0]) && $_[0] > 0 },
369             because => "at least 1 service point should be defined for feature '$f'",
370             )
371             }
372             )->(
373             on => '/service_points/sp:*',
374             should => sub { @_ > 0 },
375             because => "at least one service point should be defined",
376             each => sub {
377             my $sp;
378             shift->report_error("SP '$sp' isn't resolvable")
379             unless gethost($sp);
380             }
381             )->(
382             on => '/service_points/sp:*/f:*',
383             should => sub { @_ > 0 },
384             because => "at least one feature under service point should be defined",
385             each => sub {
386             my ($sp, $f);
387             shift->(
388             on => "//features/`*[value eq '$f']`",
389             should => sub { 1 },
390             because => "Feature '$f' of service point '$sp' should be decrlared in top-level features list",
391             )
392             },
393             })->rebase('/mojolicious/hypnotoad' => sub {
394             shift->(
395             on => '/pid_file',
396             should => sub { @_ == 1 },
397             because => "hypnotoad pid_file should be defined",
398             )->(
399             on => '/listen/*',
400             should => sub { @_ > 0 },
401             because => "hypnotoad listening interfaces defined",
402             );
403             })->errors;
404              
405             print "all OK\n"
406             unless(@$errors);
407              
408             =head2 RATIONALE
409              
410             There are complex data configurations, e.g. application configs. Not to
411             check them on applicaiton startup is B<wrong>, because of sudden
412             unexpected runtime errors can occur, which are not-so-pleasent to detect.
413             Write the code, that does full exhaustive checks, is B<boring>.
414              
415             This module tries to offer to use DLS, that makes data validation fun
416             for developer yet understandable for the person, which provides the data.
417              
418             =head1 DESCRIPTION
419              
420             First of all, you should create Validator instance:
421              
422             use Data::DynamicValidator qw/validator/;
423              
424             my $data = { ports => [2222] };
425             my $v = validator($data);
426              
427             Then, actually do validation:
428              
429             $v->(
430             on => '/ports/*',
431             should => sub { @_ > 0 },
432             because => 'At least one port should be defined at "ports" section',
433             );
434              
435             The C<on> parameter defines the data path, via JSON-pointer like expression;
436             the C<should> parameter provides the closure, which will check the values
437             gathered on via pointer. If the closure returns false, then the error will
438             be recorded, with description, provided by C<because> parameter.
439              
440             To get the results of validation, you can call:
441              
442             $v->is_valid; # returns true, if there is no validation errors
443             $v->errors; # returns array reference, consisting of the met Errors
444              
445             C<on>/C<should> parameters are convenient for validation of presense of
446             something, but they aren't so handy in checking of B<individual> values.
447             It should be mentioned, that C<should> closure, always takes an array of
448             the selected by C<on>, even if only one element has been selected.
449              
450             To handle B<individual> values in more convenient way the optional
451             C<each> parameter has been introduced.
452              
453             my $data = { ports => [2222, 3333] };
454             $v->(
455             on => '/ports/*',
456             should => sub { @_ > 0 },
457             because => 'At least one port should be defined at "ports" section',
458             each => sub {
459             my $port = $_->();
460             $v->report_error("All ports should be greater than 1000")
461             unless $port > 1000;
462             },
463             );
464              
465             So, C<report_error> could be used for custom errors reporting on current
466             path or current data value. The C<$_> is the an implicit alias or B<label>
467             to the last componenet of the current path, i.e. on our case the current
468             path in C<each> closure will be C</ports/0> and C</ports/1>, so the C<$_>
469             will be 0 and 1 respectively. To get the I<value> of the label, you should
470             "invoke" it, as showed previously. A label stringizes to the last data
471             path component, e.g. to "0" and "1" respectively.
472              
473             The C<each> closure single argrument is the validator instance itself. The
474             previous example could be rewriten with explicit label like:
475              
476             $v->(
477             on => '/ports/port:*',
478             should => sub { @_ > 0 },
479             because => 'At least one port should be defined at "ports" section',
480             each => sub {
481             my $port;
482             my $port_value = $port->();
483             shift->report_error("All ports should be greater than 1000")
484             unless $port_value > 1000;
485             },
486             );
487              
488             Providing aliases for array indices may be not so handy as for keys
489             of hashes. Please note, that the label C<port> was previously "declated"
490             in C<on> rule, and only then "injected" into C<$port> variable in
491             C<each> closure.
492              
493             Consider the following example:
494              
495             my $data = {
496             ports => [2000, 3000],
497             2000 => 'tcp',
498             3000 => 'udp',
499             };
500              
501             Let's validate it. The validation rule sounds as: there is 'ports' section,
502             where at least one port > 1000 should be declated, and then the same port
503             should appear at top-level, and it should be either 'tcp' or 'upd' type.
504              
505             use List::MoreUtils qw/any/;
506              
507             my $errors = validator($data)->(
508             on => '/ports/*[value > 1000 ]',
509             should => sub { @_ > 0 },
510             because => 'At least one port > 1000 should be defined in "ports" section',
511             each => sub {
512             my $port = $_->();
513             shift->(
514             on => "//*[key eq $port]",
515             should => sub { @_ == 1 && any { $_[0] eq $_ } (qw/tcp udp/) },
516             because => "The port $port should be declated at top-level as tcp or udp",
517             )
518             }
519             )->errors;
520              
521             As you probably noted, the the path expression contains two slashes at C<on> rule
522             inside C<each> rule. This is required to search data from the root, because
523             the current element is been set as B<base> before calling C<each>, so all expressions
524             inside C<each> are relative to the current element (aka base).
525              
526             You can change the base explicit way via C<rebase> method:
527              
528             my $data = {
529             mojolicious => {
530             hypnotoad => {
531             pid_file => '/tmp/hypnotoad-ng.pid',
532             listen => [ 'http://localhost:3000' ],
533             },
534             },
535             };
536              
537             $v->rebase('/mojolicious/hypnotoad' => sub {
538             shift->(
539             on => '/pid_file',
540             should => sub { @_ == 1 },
541             because => "hypnotoad pid_file should be defined",
542             )->(
543             on => '/listen/*',
544             should => sub { @_ > 0 },
545             because => "hypnotoad listening interfaces defined",
546             );
547             })->errors;
548              
549             =head2 DATA PATH EXPRESSIONS
550              
551             my $data = [qw/a b c d e/];
552             '/2' # selects the 'c' value in $data array
553             '/-1' # selects the 'e' value in $data array
554              
555             $data = { abc => 123 };
556             '/abc' # selects the '123' value in hashref under key 'abc'
557              
558             $data = {
559             mojolicious => {
560             hypnotoad => {
561             pid_file => '/tmp/hypnotoad-ng.pid',
562             }
563             }
564             };
565             '/mojolicious/hypnotoad/pid_file' # point to pid_file
566             '//mojolicious/hypnotoad/pid_file' # point to pid_file (independently of current base)
567              
568             # Escaping by back-quotes sample
569             $data => { "a/b" => { c => 5 } }
570             '/`a/b`/c' # selects 5
571              
572             $data = {abc => [qw/a b/]}; # 1
573             $data = {abc => { c => 'd'}}; # 2
574             $data = {abc => 7}; # 3
575             '/abc/*' # selects 'a' and 'b' in 1st case
576             # the 'd' in 2nd case
577             # the number 7 in 3rd case
578              
579             # Filtering capabilities samples:
580              
581             '/abc/*[size == 5]' # filter array/hash by size
582             '/abc/*[value eq "z"]' # filter array/hash by value equality
583             '/abc/*[index > 5]' # finter array by index
584             '/abc/*[key =~ /def/]' # finter hash by key
585              
586             =head2 DEBUGGING
587              
588             You can set the DATA_DYNAMICVALIDATOR_DEBUG environment variable
589             to get some advanced diagnostics information printed to "STDERR".
590              
591             DATA_DYNAMICVALIDATOR_DEBUG=1
592              
593             =head1 METHODS
594              
595             =head2 validate
596              
597             Performs validation based on C<on>, C<should>, C<because> and optional C<each>
598             parameters. Returns the validator itself (C<$self>), to allow further C<chain>
599             invocations. The validation will not be performed, if some errors already
600             have been detected.
601              
602             It is recommended to use overloaded function call, instead of this method
603             call. (e.g. C<$validator->(...)> instead of C<$validato->validate(...)> )
604              
605             =head2 report_error
606              
607             The method is used for custom errors reporing. It is mainly usable in C<each>
608             closure.
609              
610             validator({ ports => [1000, 2000, 3000] })->(
611             on => '/ports/port:*',
612             should => sub { @_ > 0 },
613             because => "At least one listening port should be defined",
614             each => sub {
615             my $port;
616             my $port_value = $port->();
617             shift->report_error("Port value $port_value isn't acceptable, because < 1000")
618             if($port_value < 1000);
619             }
620             );
621              
622             =head2 is_valid
623              
624             Checks, whether validator already has errors
625              
626             =head2 errors
627              
628             Returns internal array of errors
629              
630             =head2 rebase
631              
632             Temporaly sets the new base to the specified route, and invokes the closure
633             with the validator instance, i.e.
634              
635             $v->('/a' => $closure->($v))
636              
637             If the data can't be found at the specified route, the C<closure> is not
638             invoked.
639              
640             =head2 current_base
641              
642             Returns the current base, which is set only inside C<rebase> call or C<each> closure.
643             Returns undef is there is no current base.
644              
645             =head1 FUNCTIONS
646              
647             =head2 validator
648              
649             The enter point for DynamicValidator.
650              
651             my $errors = validator(...)->(
652             on => "...",
653             should => sub { ... },
654             because => "...",
655             )->errors;
656              
657             =head1 RESOURCES
658              
659             =over 4
660              
661             =item * Data::DPath
662              
663             L<https://metacpan.org/pod/Data::DPath>
664              
665             =back
666              
667             =head1 AUTHOR
668              
669             Ivan Baidakou <dmol@gmx.com>
670              
671             =head1 COPYRIGHT AND LICENSE
672              
673             This software is copyright (c) 2014 by Ivan Baidakou.
674              
675             This is free software; you can redistribute it and/or modify it under
676             the same terms as the Perl 5 programming language system itself.
677              
678             =cut