File Coverage

blib/lib/Kwalify.pm
Criterion Covered Total %
statement 178 237 75.1
branch 96 132 72.7
condition 13 15 86.6
subroutine 25 34 73.5
pod 1 1 100.0
total 313 419 74.7


line stmt bran cond sub pod time code
1             # -*- mode: cperl -*-
2              
3             #
4             # Author: Slaven Rezic
5             #
6             # Copyright (C) 2006,2007,2008,2009,2010 Slaven Rezic. All rights reserved.
7             # This package is free software; you can redistribute it and/or
8             # modify it under the same terms as Perl itself.
9             #
10             # Mail: srezic@cpan.org
11             # WWW: http://www.rezic.de/eserte/
12             #
13              
14             package Kwalify;
15              
16 2     2   45233 use strict;
  2         5  
  2         102  
17              
18 2     2   13 use base qw(Exporter);
  2         3  
  2         242  
19 2     2   11 use vars qw(@EXPORT_OK $VERSION);
  2         18  
  2         318  
20             @EXPORT_OK = qw(validate);
21              
22             $VERSION = '1.22';
23              
24             BEGIN {
25 2 50   2   346 if ($] < 5.006) {
26 0         0 $INC{"warnings.pm"} = 1;
27 0         0 *warnings::import = sub { };
  0         0  
28 0         0 *warnings::unimport = sub { };
  0         0  
29             }
30             }
31              
32             sub validate ($$) {
33 40     40 1 38129 my($schema, $data) = @_;
34 40         317 my $self = Kwalify::Validator->new;
35 40         99 $self->validate($schema, $data, "/");
36 32 100       44 if (@{$self->{errors}}) {
  32         179  
37 16         21 die join("\n", map { " - $_" } @{$self->{errors}}) . "\n";
  18         165  
  16         35  
38             } else {
39 16         123 1;
40             }
41             }
42              
43             package Kwalify::Validator;
44              
45 2     2   6601 use overload ();
  2         2389  
  2         507  
46              
47             sub new {
48 40     40   52 my($class) = @_;
49 40         164 bless { errors => [] }, $class;
50             }
51              
52             sub validate {
53 40     40   72 my($self, $schema, $data, $path, $args) = @_;
54 40         99 $self->{done} = {};
55 40         95 $self->_validate($schema, $data, $path, $args);
56             }
57              
58             sub _validate {
59 126     126   225 my($self, $schema, $data, $path, $args) = @_;
60 126         199 $self->{path} = $path;
61              
62 126 100       386 if (!UNIVERSAL::isa($schema, "HASH")) {
63 1         5 $self->_die("Schema structure must be a hash reference");
64             }
65              
66 125         193 my $type = $schema->{type};
67 125 50       232 if (!defined $type) {
68 0         0 $type = 'str'; # default type;
69             }
70 125         189 my $type_check_method = "validate_" . $type;
71 125 100       451 if (!$self->can($type_check_method)) {
72 1         6 $self->_die("Invalid or unimplemented type '$type'");
73             }
74              
75 124         345 $self->$type_check_method($schema, $data, $path, $args);
76             }
77              
78             sub _additional_rules {
79 77     77   117 my($self, $schema, $data, $path) = @_;
80 2     2   13 no warnings 'uninitialized'; # legal undef values may happen everywhere
  2         2  
  2         1160  
81 77         190 for my $schema_key (keys %$schema) {
82 154 50       325 if (defined $schema->{$schema_key}) {
83 154 100       1160 if ($schema_key eq 'pattern') {
    100          
    100          
    100          
    50          
    100          
84 4         18 (my $pattern = $schema->{pattern}) =~ s{^/(.*)/$}{$1};
85 4 100       90 if ($data !~ qr{$pattern}) {
86 1         7 $self->_error("Non-valid data '$data' does not match /$pattern/");
87             }
88             } elsif ($schema_key eq 'length') {
89 8 100       84 if (!UNIVERSAL::isa($schema->{'length'}, "HASH")) {
90 1         3 $self->_die("'length' must be a hash with keys max and/or min");
91             }
92 7         11 my $length = length($data);
93 7         8 for my $sub_schema_key (keys %{ $schema->{'length'} }) {
  7         25  
94 13 50       50 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
95 0         0 my $min = $schema->{'length'}->{min};
96 0 0       0 if ($length < $min) {
97 0         0 $self->_error("'$data' is too short (length $length < min $min)");
98             }
99             } elsif ($sub_schema_key eq 'min-ex') {
100 6         11 my $min = $schema->{'length'}->{'min-ex'};
101 6 100       25 if ($length <= $min) {
102 1         7 $self->_error("'$data' is too short (length $length <= min $min)");
103             }
104             } elsif ($sub_schema_key eq 'max') {
105 2         4 my $max = $schema->{'length'}->{max};
106 2 100       8 if ($length > $max) {
107 1         7 $self->_error("'$data' is too long (length $length > max $max)");
108             }
109             } elsif ($sub_schema_key eq 'max-ex') {
110 4         9 my $max = $schema->{'length'}->{'max-ex'};
111 4 100       13 if ($length >= $max) {
112 1         8 $self->_error("'$data' is too long (length $length >= max $max)");
113             }
114             } else {
115 1         6 $self->_die("Unexpected key '$sub_schema_key' in length specification, expected min, max, min-ex and/or max-ex");
116             }
117             }
118             } elsif ($schema_key eq 'enum') {
119 3 100       16 if (!UNIVERSAL::isa($schema->{enum}, 'ARRAY')) {
120 1         4 $self->_die("'enum' must be an array");
121             }
122 2         3 my %valid = map { ($_,1) } @{ $schema->{enum} };
  6         20  
  2         5  
123 2 50       13 if (!exists $valid{$data}) {
124 0         0 $self->_error("'$data': invalid " . _base_path($path) . " value");
125             }
126             } elsif ($schema_key eq 'range') {
127 8 100       24 if (!UNIVERSAL::isa($schema->{range}, "HASH")) {
128 1         5 $self->_die("'range' must be a hash with keys max and/or min");
129             }
130 7         9 my($lt, $le, $gt, $ge);
131             ## yes? no?
132             # if (eval { require Scalar::Util; defined &Scalar::Util::looks_like_number }) {
133             # if (Scalar::Util::looks_like_number($data)) {
134             # $lt = sub { $_[0] < $_[1] };
135             # $gt = sub { $_[0] > $_[1] };
136             # } else {
137             # $lt = sub { $_[0] lt $_[1] };
138             # $gt = sub { $_[0] gt $_[1] };
139             # }
140             # } else {
141             # warn "Cannot determine whether $data is a number, assume so..."; # XXX show only once
142 2     2   13 no warnings 'numeric';
  2         4  
  2         13485  
143 7     0   28 $lt = sub { $_[0] < $_[1] };
  0         0  
144 7     2   18 $gt = sub { $_[0] > $_[1] };
  2         10  
145 7     6   19 $le = sub { $_[0] <= $_[1] };
  6         139  
146 7     4   20 $ge = sub { $_[0] >= $_[1] };
  4         30  
147             # }
148              
149 7         9 for my $sub_schema_key (keys %{ $schema->{range} }) {
  7         60  
150 13 50       49 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
151 0         0 my $min = $schema->{range}->{min};
152 0 0       0 if ($lt->($data, $min)) {
153 0         0 $self->_error("'$data' is too small (< min $min)");
154             }
155             } elsif ($sub_schema_key eq 'min-ex') {
156 6         11 my $min = $schema->{range}->{'min-ex'};
157 6 100       14 if ($le->($data, $min)) {
158 1         6 $self->_error("'$data' is too small (<= min $min)");
159             }
160             } elsif ($sub_schema_key eq 'max') {
161 2         6 my $max = $schema->{range}->{max};
162 2 100       7 if ($gt->($data, $max)) {
163 1         7 $self->_error("'$data' is too large (> max $max)");
164             }
165             } elsif ($sub_schema_key eq 'max-ex') {
166 4         9 my $max = $schema->{range}->{'max-ex'};
167 4 100       7 if ($ge->($data, $max)) {
168 1         5 $self->_error("'$data' is too large (>= max $max)");
169             }
170             } else {
171 1         6 $self->_die("Unexpected key '$sub_schema_key' in range specification, expected min, max, min-ex and/or max-ex");
172             }
173             }
174             } elsif ($schema_key eq 'assert') {
175 0         0 $self->_die("'assert' is not yet implemented");
176             } elsif ($schema_key !~ m{^(type|required|unique|name|classname|class|desc)$}) {
177 1         4 $self->_die("Unexpected key '$schema_key' in type specification");
178             }
179             }
180             }
181             }
182              
183             sub validate_text {
184 16     16   30 my($self, $schema, $data, $path) = @_;
185 16 100 100     82 if (!defined $data || ref $data) {
186 3 100       66 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected text");
187             }
188 13         46 $self->_additional_rules($schema, $data, $path);
189             }
190              
191             sub validate_str {
192 53     53   104 my($self, $schema, $data, $path) = @_;
193 53 100 100     399 if (!defined $data || ref $data || $data =~ m{^\d+(\.\d+)?$}) {
      100        
194 3 100       18 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected a str");
195             }
196 50         113 $self->_additional_rules($schema, $data, $path);
197             }
198              
199             sub validate_int {
200 8     8   19 my($self, $schema, $data, $path) = @_;
201 8 100       37 if ($data !~ m{^[+-]?\d+$}) { # XXX what about scientific notation?
202 2         8 $self->_error("Non-valid data '" . $data . "', expected an int");
203             }
204 8         18 $self->_additional_rules($schema, $data, $path);
205             }
206              
207             sub validate_float {
208 0     0   0 my($self, $schema, $data, $path) = @_;
209 0 0       0 if ($data !~ m{^[+-]?\d+\.\d+$}) { # XXX other values?
210 0         0 $self->_error("Non-valid data '" . $data . "', expected a float");
211             }
212 0         0 $self->_additional_rules($schema, $data, $path);
213             }
214              
215             sub validate_number {
216 0     0   0 my($self, $schema, $data, $path) = @_;
217 0 0       0 if ($data !~ m{^[+-]?\d+(\.\d+)?$}) { # XXX combine int+float regexp!
218 0         0 $self->_error("Non-valid data '" . $data . "', expected a number");
219             }
220 0         0 $self->_additional_rules($schema, $data, $path);
221             }
222              
223             sub validate_bool {
224 0     0   0 my($self, $schema, $data, $path) = @_;
225 0 0       0 if ($data !~ m{^(yes|true|1|no|false|0)$}) { # XXX correct?
226 0         0 $self->_error("Non-valid data '" . $data . "', expected a boolean");
227             }
228 0         0 $self->_additional_rules($schema, $data, $path);
229             }
230              
231             # XXX is this correct?
232             sub validate_scalar {
233 0     0   0 shift->validate_text(@_);
234             }
235              
236             sub validate_date {
237 0     0   0 my($self, $schema, $data, $path) = @_;
238 0 0       0 if ($data !~ m{^\d{4}-\d{2}-\d{2}$}) {
239 0         0 $self->_error("Non-valid data '" . $data . "', expected a date (YYYY-MM-DD)");
240             }
241 0         0 $self->_additional_rules($schema, $data, $path);
242             }
243              
244             sub validate_time {
245 0     0   0 my($self, $schema, $data, $path) = @_;
246 0 0       0 if ($data !~ m{^\d{2}:\d{2}:\d{2}$}) {
247 0         0 $self->_error("Non-valid data '" . $data . "', expected a time (HH:MM:SS)");
248             }
249 0         0 $self->_additional_rules($schema, $data, $path);
250             }
251              
252             sub validate_timestamp {
253 0     0   0 my($self) = @_;
254 0         0 $self->_error("timestamp validation NYI"); # XXX
255             }
256              
257             sub validate_any {
258 6     6   10 my($self, $schema, $data, $path) = @_;
259 6         14 $self->_additional_rules($schema, $data, $path);
260             }
261              
262             sub validate_seq {
263 16     16   30 my($self, $schema, $data, $path) = @_;
264 16 50       40 if (!exists $schema->{sequence}) {
265 0         0 $self->_die("'sequence' missing with 'seq' type");
266             }
267 16         23 my $sequence = $schema->{sequence};
268 16 50       813 if (!UNIVERSAL::isa($sequence, 'ARRAY')) {
269 0         0 $self->_die("Expected array in 'sequence'");
270             }
271 16 50       82 if (@$sequence != 1) {
272 0         0 $self->_die("Expect exactly one element in sequence");
273             }
274 16 50       45 if (!UNIVERSAL::isa($data, 'ARRAY')) {
275 0         0 $self->_error("Non-valid data " . $data . ", expected sequence");
276 0         0 return;
277             }
278              
279 16 50       56 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
280 16         159 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
281              
282 16         120 my $subschema = $sequence->[0];
283 16         1579 my $unique = _get_boolean($subschema->{unique});
284 16         25 my %unique_val;
285             my %unique_mapping_val;
286 16         24 my $index = 0;
287 16         31 for my $elem (@$data) {
288 38         72 my $subpath = _append_path($path, $index);
289 38         187 $self->_validate($subschema, $elem, $subpath, { unique_mapping_val => \%unique_mapping_val});
290 38 100       125 if ($unique) {
291 26 100       51 if (exists $unique_val{$elem}) {
292 2         14 $self->_error("'$elem' is already used at '$unique_val{$elem}'");
293             } else {
294 24         100 $unique_val{$elem} = $subpath;
295             }
296             }
297 38         122 $index++;
298             }
299             }
300              
301             sub validate_map {
302 25     25   38 my($self, $schema, $data, $path, $args) = @_;
303 25         29 my $unique_mapping_val;
304 25 100 66     99 if ($args && $args->{unique_mapping_val}) {
305 12         22 $unique_mapping_val = $args->{unique_mapping_val};
306             }
307 25 50       658 if (!exists $schema->{mapping}) {
308 0         0 $self->_die("'mapping' missing with 'map' type");
309             }
310 25         34 my $mapping = $schema->{mapping};
311 25 50       71 if (!UNIVERSAL::isa($mapping, 'HASH')) {
312 0         0 $self->_die("Expected hash in 'mapping'");
313             }
314 25 50       48 if (!defined $data) {
315 0         0 $self->_error("Undefined data, expected mapping");
316 0         0 return;
317             }
318 25 100       61 if (!UNIVERSAL::isa($data, 'HASH')) {
319 1         6 $self->_error("Non-valid data " . $data . ", expected mapping");
320 1         3 return;
321             }
322              
323 24 50       83 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
324 24         316 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
325              
326 24         142 my %seen_key;
327             my $default_key_schema;
328              
329             ## Originally this was an each-loop, but this could lead into
330             ## endless recursions, because mapping may be reused in Kwalify,
331             ## thus the each iterator was shared between recursion levels.
332             # while(my($key,$subschema) = each %$mapping) {
333 24         71 for my $key (keys %$mapping) {
334 60         114 my $subschema = $mapping->{$key};
335 60 50       120 if ($key eq '=') { # the "default" key
336 0         0 $default_key_schema = $subschema;
337 0         0 next;
338             }
339 60         188 my $subpath = _append_path($path, $key);
340 60         180 $self->{path} = $subpath;
341 60 50       188 if (!UNIVERSAL::isa($subschema, 'HASH')) {
342 0         0 $self->_die("Expected subschema (a hash)");
343             }
344 60         162 my $required = _get_boolean($subschema->{required});
345 60 100       167 if (!exists $data->{$key}) {
346 12 50       23 if ($required) {
347 0         0 $self->{path} = $path;
348 0         0 $self->_error("Expected required key '$key'");
349 0         0 next;
350             } else {
351 12         44 next;
352             }
353             }
354 48         109 my $unique = _get_boolean($subschema->{unique});
355 48 100       106 if ($unique) {
356 12 100 66     266 if (defined $unique_mapping_val->{$data->{$key}}->{val}
357             && $unique_mapping_val->{$data->{$key}}->{val} eq $data->{$key}) {
358 2         13 $self->_error("'$data->{$key}' is already used at '$unique_mapping_val->{$data->{$key}}->{path}'");
359             } else {
360 10         44 $unique_mapping_val->{$data->{$key}} = { val => $data->{$key},
361             path => $subpath,
362             };
363             }
364             }
365              
366 48         129 $self->_validate($subschema, $data->{$key}, $subpath);
367 48         137 $seen_key{$key}++;
368             }
369              
370             # while(my($key,$val) = each %$data) {
371 24         78 for my $key (keys %$data) {
372 48         76 my $val = $data->{$key};
373 48         90 my $subpath = _append_path($path, $key);
374 48         79 $self->{path} = $subpath;
375 48 50       189 if (!$seen_key{$key}) {
376 0 0       0 if ($default_key_schema) {
377 0         0 $self->_validate($default_key_schema, $val, $subpath);
378             } else {
379 0         0 $self->_error("Unexpected key '$key'");
380             }
381             }
382             }
383             }
384              
385             sub _die {
386 8     8   17 my($self, $msg) = @_;
387 8         18 $msg = "[$self->{path}] $msg";
388 8         72 die $msg."\n";
389             }
390              
391             sub _error {
392 20     20   35 my($self, $msg) = @_;
393 20         48 $msg = "[$self->{path}] $msg";
394 20         21 push @{$self->{errors}}, $msg;
  20         49  
395 20         56 0;
396             }
397              
398             # Functions:
399             sub _append_path {
400 146     146   221 my($root, $leaf) = @_;
401 146 100       683 $root . ($root !~ m{/$} ? "/" : "") . $leaf;
402             }
403              
404             sub _base_path {
405 0     0   0 my($path) = @_;
406 0         0 my($base) = $path =~ m{([^/]+)$};
407 0         0 $base;
408             }
409              
410             sub _get_boolean {
411 124     124   218 my($val) = @_;
412 124 100       967 defined $val && $val =~ m{^(yes|true|1)$}; # XXX check for all boolean trues
413             }
414              
415             1;
416             __END__