Skip to content

Commit 2fa3b72

Browse files
new option $STRINGY_NUMBERS
1 parent 9fa9e17 commit 2fa3b72

File tree

3 files changed

+237
-11
lines changed

3 files changed

+237
-11
lines changed

Changes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Revision history for JSON-Schema-Tiny
22

33
{{$NEXT}}
4+
- new $STRINGY_NUMBERS option, for validating numbers more loosely
45

56
0.021 2023-04-22 17:25:12Z
67
- fix bad handling of empty patterns in "pattern",

lib/JSON/Schema/Tiny.pm

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use JSON::MaybeXS 1.004001 'is_bool';
2222
use Feature::Compat::Try;
2323
use JSON::PP ();
2424
use List::Util 1.33 qw(any none);
25-
use Scalar::Util 'blessed';
25+
use Scalar::Util qw(blessed looks_like_number);
2626
use if "$]" >= 5.022, POSIX => 'isinf';
2727
use Math::BigFloat;
2828
use namespace::clean;
@@ -35,6 +35,7 @@ our $SHORT_CIRCUIT = 0;
3535
our $MAX_TRAVERSAL_DEPTH = 50;
3636
our $MOJO_BOOLEANS; # deprecated; renamed to $SCALARREF_BOOLEANS
3737
our $SCALARREF_BOOLEANS;
38+
our $STRINGY_NUMBERS;
3839
our $SPECIFICATION_VERSION;
3940

4041
my %version_uris = (
@@ -55,6 +56,7 @@ sub evaluate {
5556
local $SHORT_CIRCUIT = $_[0]->{short_circuit} // $SHORT_CIRCUIT,
5657
local $MAX_TRAVERSAL_DEPTH = $_[0]->{max_traversal_depth} // $MAX_TRAVERSAL_DEPTH,
5758
local $SCALARREF_BOOLEANS = $_[0]->{scalarref_booleans} // $SCALARREF_BOOLEANS // $_[0]->{mojo_booleans},
59+
local $STRINGY_NUMBERS = $_[0]->{stringy_numbers} // $STRINGY_NUMBERS,
5860
local $SPECIFICATION_VERSION = $_[0]->{specification_version} // $SPECIFICATION_VERSION,
5961
shift
6062
if blessed($_[0]) and blessed($_[0])->isa(__PACKAGE__);
@@ -415,6 +417,8 @@ sub _eval_keyword_type ($data, $schema, $state) {
415417
my $type = get_type($data);
416418
return 1 if any {
417419
$type eq $_ or ($_ eq 'number' and $type eq 'integer')
420+
or ($type eq 'string' and $STRINGY_NUMBERS and looks_like_number($data)
421+
and ($_ eq 'number' or ($_ eq 'integer' and $data == int($data))))
418422
or ($_ eq 'boolean' and $SCALARREF_BOOLEANS and $type eq 'reference to SCALAR')
419423
} $schema->{type}->@*;
420424
return E($state, 'got %s, not one of %s', $type, join(', ', $schema->{type}->@*));
@@ -426,6 +430,8 @@ sub _eval_keyword_type ($data, $schema, $state) {
426430

427431
my $type = get_type($data);
428432
return 1 if $type eq $schema->{type} or ($schema->{type} eq 'number' and $type eq 'integer')
433+
or ($type eq 'string' and $STRINGY_NUMBERS and looks_like_number($data)
434+
and ($schema->{type} eq 'number' or ($schema->{type} eq 'integer' and $data == int($data))))
429435
or ($schema->{type} eq 'boolean' and $SCALARREF_BOOLEANS and $type eq 'reference to SCALAR');
430436
return E($state, 'got %s, not %s', $type, $schema->{type});
431437
}
@@ -452,7 +458,9 @@ sub _eval_keyword_multipleOf ($data, $schema, $state) {
452458
assert_keyword_type($state, $schema, 'number');
453459
abort($state, 'multipleOf value is not a positive number') if $schema->{multipleOf} <= 0;
454460

455-
return 1 if not is_type('number', $data);
461+
return 1 if not is_type('number', $data)
462+
and not ($STRINGY_NUMBERS and is_type('string', $data) and looks_like_number($data)
463+
and do { $data = 0+$data; 1 });
456464

457465
# if either value is a float, use the bignum library for the calculation
458466
if (ref($data) =~ /^Math::Big(?:Int|Float)$/
@@ -476,29 +484,33 @@ sub _eval_keyword_multipleOf ($data, $schema, $state) {
476484

477485
sub _eval_keyword_maximum ($data, $schema, $state) {
478486
assert_keyword_type($state, $schema, 'number');
479-
return 1 if not is_type('number', $data);
480-
return 1 if $data <= $schema->{maximum};
487+
return 1 if not is_type('number', $data)
488+
and not ($STRINGY_NUMBERS and is_type('string', $data) and looks_like_number($data));
489+
return 1 if 0+$data <= $schema->{maximum};
481490
return E($state, 'value is larger than %s', sprintf_num($schema->{maximum}));
482491
}
483492

484493
sub _eval_keyword_exclusiveMaximum ($data, $schema, $state) {
485494
assert_keyword_type($state, $schema, 'number');
486-
return 1 if not is_type('number', $data);
487-
return 1 if $data < $schema->{exclusiveMaximum};
495+
return 1 if not is_type('number', $data)
496+
and not ($STRINGY_NUMBERS and is_type('string', $data) and looks_like_number($data));
497+
return 1 if 0+$data < $schema->{exclusiveMaximum};
488498
return E($state, 'value is equal to or larger than %s', sprintf_num($schema->{exclusiveMaximum}));
489499
}
490500

491501
sub _eval_keyword_minimum ($data, $schema, $state) {
492502
assert_keyword_type($state, $schema, 'number');
493-
return 1 if not is_type('number', $data);
494-
return 1 if $data >= $schema->{minimum};
503+
return 1 if not is_type('number', $data)
504+
and not ($STRINGY_NUMBERS and is_type('string', $data) and looks_like_number($data));
505+
return 1 if 0+$data >= $schema->{minimum};
495506
return E($state, 'value is smaller than %s', sprintf_num($schema->{minimum}));
496507
}
497508

498509
sub _eval_keyword_exclusiveMinimum ($data, $schema, $state) {
499510
assert_keyword_type($state, $schema, 'number');
500-
return 1 if not is_type('number', $data);
501-
return 1 if $data > $schema->{exclusiveMinimum};
511+
return 1 if not is_type('number', $data)
512+
and not ($STRINGY_NUMBERS and is_type('string', $data) and looks_like_number($data));
513+
return 1 if 0+$data > $schema->{exclusiveMinimum};
502514
return E($state, 'value is equal to or smaller than %s', sprintf_num($schema->{exclusiveMinimum}));
503515
}
504516

@@ -1366,10 +1378,36 @@ other, or badly-written schemas that could be optimized. Defaults to 50.
13661378
13671379
=head2 C<$SCALARREF_BOOLEANS>
13681380
1369-
When true, any type that is expected to be a boolean B<in the instance data> may also be expressed as
1381+
When true, any value that is expected to be a boolean B<in the instance data> may also be expressed as
13701382
the scalar references C<\0> or C<\1> (which are serialized as booleans by JSON backends).
13711383
Defaults to false.
13721384
1385+
=head2 C<$STRINGY_NUMBERS>
1386+
1387+
When true, any value that is expected to be a number or integer B<in the instance data> may also be
1388+
expressed as a string. This does B<not> apply to the C<const> or C<enum> keywords, but only
1389+
the following keywords:
1390+
1391+
=for :list
1392+
* C<type> (where both C<string> and C<number> (and possibly C<integer>) are considered types
1393+
* C<multipleOf>
1394+
* C<maximum>
1395+
* C<exclusiveMaximum>
1396+
* C<minimum>
1397+
* C<exclusiveMinimum>
1398+
1399+
This allows you to write a schema like this (which validates a string representing an integer):
1400+
1401+
type: string
1402+
pattern: ^[0-9]$
1403+
multipleOf: 4
1404+
minimum: 16
1405+
maximum: 256
1406+
1407+
Such keywords are only applied if the value looks like a number, and do not generate a failure
1408+
otherwise. Values are determined to be numbers via L<perlapi/looks_like_number>.
1409+
Defaults to false.
1410+
13731411
=head2 C<$SPECIFICATION_VERSION>
13741412
13751413
When set, the version of the draft specification is locked to one particular value, and use of

t/stringy-numbers.t

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
use strictures 2;
2+
use 5.020;
3+
use stable 0.031 'postderef';
4+
use experimental 'signatures';
5+
no if "$]" >= 5.031009, feature => 'indirect';
6+
no if "$]" >= 5.033001, feature => 'multidimensional';
7+
no if "$]" >= 5.033006, feature => 'bareword_filehandles';
8+
use open ':std', ':encoding(UTF-8)'; # force stdin, stdout, stderr into utf8
9+
10+
use Test::More 0.88;
11+
use if $ENV{AUTHOR_TESTING}, 'Test::Warnings';
12+
use Test::Deep;
13+
use JSON::Schema::Tiny 'evaluate';
14+
use lib 't/lib';
15+
use Helper;
16+
17+
foreach my $config (0, 1) {
18+
$JSON::Schema::Tiny::STRINGY_NUMBERS = $config;
19+
note '$STRINGY_NUMBERS = '.$config;
20+
21+
cmp_deeply(
22+
evaluate(1, { $_ => '1' }),
23+
{
24+
valid => false,
25+
errors => [
26+
{
27+
instanceLocation => '',
28+
keywordLocation => '/'.$_,
29+
error => $_.' value is not a number',
30+
}
31+
],
32+
},
33+
'strings cannot be used in place of numbers in schema for '.$_,
34+
) foreach qw(multipleOf maximum exclusiveMaximum minimum exclusiveMinimum);
35+
36+
my $schema = {
37+
allOf => [
38+
{ type => 'string' },
39+
{ type => 'number' },
40+
{ type => 'integer' },
41+
{ type => [ 'object', 'number' ] },
42+
{ type => [ 'object', 'integer' ] },
43+
],
44+
};
45+
46+
cmp_deeply(
47+
evaluate('1.1', $schema),
48+
{
49+
valid => false,
50+
errors => [
51+
{
52+
instanceLocation => '',
53+
keywordLocation => '/allOf/1/type',
54+
error => 'got string, not number',
55+
},
56+
{
57+
instanceLocation => '',
58+
keywordLocation => '/allOf/2/type',
59+
error => 'got string, not integer',
60+
},
61+
{
62+
instanceLocation => '',
63+
keywordLocation => '/allOf/3/type',
64+
error => 'got string, not one of object, number',
65+
},
66+
{
67+
instanceLocation => '',
68+
keywordLocation => '/allOf/4/type',
69+
error => 'got string, not one of object, integer',
70+
},
71+
{
72+
instanceLocation => '',
73+
keywordLocation => '/allOf',
74+
error => 'subschemas 1, 2, 3, 4 are not valid',
75+
},
76+
],
77+
},
78+
'by default "type": "string" does not accept numbers',
79+
) if not $config;
80+
81+
cmp_deeply(
82+
evaluate('1.1', $schema),
83+
{
84+
valid => false,
85+
errors => [
86+
{
87+
instanceLocation => '',
88+
keywordLocation => '/allOf/2/type',
89+
error => 'got string, not integer',
90+
},
91+
{
92+
instanceLocation => '',
93+
keywordLocation => '/allOf/4/type',
94+
error => 'got string, not one of object, integer',
95+
},
96+
{
97+
instanceLocation => '',
98+
keywordLocation => '/allOf',
99+
error => 'subschemas 2, 4 are not valid',
100+
},
101+
],
102+
},
103+
'using stringy numbers, numeric strings are treated as numbers but are still not always integers',
104+
) if $config;
105+
106+
107+
$schema = {
108+
maximum => 5,
109+
exclusiveMaximum => 5,
110+
minimum => 15,
111+
exclusiveMinimum => 15,
112+
allOf => [
113+
{ multipleOf => 2 },
114+
{ multipleOf => 0.3 },
115+
],
116+
};
117+
118+
my $errors = [
119+
{
120+
instanceLocation => '',
121+
keywordLocation => '/allOf/0/multipleOf',
122+
error => 'value is not a multiple of 2',
123+
},
124+
{
125+
instanceLocation => '',
126+
keywordLocation => '/allOf/1/multipleOf',
127+
error => 'value is not a multiple of 0.3',
128+
},
129+
{
130+
instanceLocation => '',
131+
keywordLocation => '/allOf',
132+
error => 'subschemas 0, 1 are not valid',
133+
},
134+
{
135+
instanceLocation => '',
136+
keywordLocation => '/maximum',
137+
error => 'value is larger than 5',
138+
},
139+
{
140+
instanceLocation => '',
141+
keywordLocation => '/exclusiveMaximum',
142+
error => 'value is equal to or larger than 5',
143+
},
144+
{
145+
instanceLocation => '',
146+
keywordLocation => '/minimum',
147+
error => 'value is smaller than 15',
148+
},
149+
{
150+
instanceLocation => '',
151+
keywordLocation => '/exclusiveMinimum',
152+
error => 'value is equal to or smaller than 15',
153+
},
154+
];
155+
156+
my $data = 11e0;
157+
158+
cmp_deeply(
159+
evaluate($data, $schema),
160+
{
161+
valid => false,
162+
errors => $errors,
163+
},
164+
'real numbers are always evaluated',
165+
);
166+
167+
$data = '11e0';
168+
169+
cmp_deeply(
170+
evaluate($data, $schema),
171+
{ valid => true },
172+
'by default, stringy numbers are not evaluated by numeric keywords',
173+
) if $config == 0;
174+
175+
cmp_deeply(
176+
evaluate($data, $schema),
177+
{
178+
valid => false,
179+
errors => $errors,
180+
},
181+
'with the config enabled, stringy numbers are treated as numbers by numeric keywords',
182+
) if $config == 1;
183+
184+
is(JSON::Schema::Tiny::get_type($data), 'string', 'data was not mutated');
185+
}
186+
187+
done_testing;

0 commit comments

Comments
 (0)