Skip to content

Commit 48817e5

Browse files
eraydbighappyface
authored andcommitted
Add option to apply default values from the schema (#349)
* Add option to apply default values from the schema * Clone default objects instead of passing by reference Objects should always be assigned via clone, to prevent modifications to the input object from also modifying the underlying schema. * Run php-cs-fixer * Remove two duplicate test cases
1 parent 42c1043 commit 48817e5

File tree

8 files changed

+268
-2
lines changed

8 files changed

+268
-2
lines changed

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ $validator->coerce($request, $schema);
8888
// equivalent to $validator->validate($data, $schema, Constraint::CHECK_MODE_COERCE_TYPES);
8989
```
9090

91+
### Default values
92+
93+
If your schema contains default values, you can have these automatically applied during validation:
94+
95+
```php
96+
<?php
97+
98+
use JsonSchema\Validator;
99+
use JsonSchema\Constraints\Constraint;
100+
101+
$request = (object)[
102+
'refundAmount'=>17
103+
];
104+
105+
$validator = new Validator();
106+
107+
$validator->validate(
108+
$request,
109+
(object)[
110+
"type"=>"object",
111+
"properties"=>(object)[
112+
"processRefund"=>(object)[
113+
"type"=>"boolean",
114+
"default"=>true
115+
]
116+
]
117+
],
118+
Constraint::CHECK_MODE_APPLY_DEFAULTS
119+
); //validates, and sets defaults for missing properties
120+
121+
is_bool($request->processRefund); // true
122+
$request->processRefund; // true
123+
```
124+
91125
### With inline references
92126

93127
```php
@@ -152,9 +186,11 @@ third argument to `Validator::validate()`, or can be provided as the third argum
152186
| `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default |
153187
| `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects |
154188
| `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible |
189+
| `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set |
155190
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |
156191

157-
Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` will modify your original data.
192+
Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` or `Constraint::CHECK_MODE_APPLY_DEFAULTS`
193+
will modify your original data.
158194

159195
## Running the tests
160196

src/JsonSchema/Constraints/Factory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace JsonSchema\Constraints;
1111

12+
use JsonSchema\Constraints\Constraint;
1213
use JsonSchema\Exception\InvalidArgumentException;
1314
use JsonSchema\Exception\InvalidConfigException;
1415
use JsonSchema\SchemaStorage;

src/JsonSchema/Constraints/TypeCheck/LooseTypeCheck.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ public static function propertyGet($value, $property)
2727
return $value[$property];
2828
}
2929

30+
public static function propertySet(&$value, $property, $data)
31+
{
32+
if (is_object($value)) {
33+
$value->{$property} = $data;
34+
} else {
35+
$value[$property] = $data;
36+
}
37+
}
38+
3039
public static function propertyExists($value, $property)
3140
{
3241
if (is_object($value)) {

src/JsonSchema/Constraints/TypeCheck/StrictTypeCheck.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ public static function propertyGet($value, $property)
1919
return $value->{$property};
2020
}
2121

22+
public static function propertySet(&$value, $property, $data)
23+
{
24+
$value->{$property} = $data;
25+
}
26+
2227
public static function propertyExists($value, $property)
2328
{
2429
return property_exists($value, $property);

src/JsonSchema/Constraints/TypeCheck/TypeCheckInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public static function isArray($value);
1010

1111
public static function propertyGet($value, $property);
1212

13+
public static function propertySet(&$value, $property, $data);
14+
1315
public static function propertyExists($value, $property);
1416

1517
public static function propertyCount($value);

src/JsonSchema/Constraints/UndefinedConstraint.php

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace JsonSchema\Constraints;
1111

12+
use JsonSchema\Constraints\TypeCheck\LooseTypeCheck;
1213
use JsonSchema\Entity\JsonPointer;
1314
use JsonSchema\Uri\UriResolver;
1415

@@ -57,7 +58,9 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n
5758
}
5859

5960
// check object
60-
if ($this->getTypeCheck()->isObject($value)) {
61+
if (LooseTypeCheck::isObject($value)) { // object processing should always be run on assoc arrays,
62+
// so use LooseTypeCheck here even if CHECK_MODE_TYPE_CAST
63+
// is not set (i.e. don't use $this->getTypeCheck() here).
6164
$this->checkObject(
6265
$value,
6366
isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema,
@@ -107,6 +110,49 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer
107110
}
108111
}
109112

113+
// Apply default values from schema
114+
if ($this->factory->getConfig(self::CHECK_MODE_APPLY_DEFAULTS)) {
115+
if ($this->getTypeCheck()->isObject($value) && isset($schema->properties)) {
116+
// $value is an object, so apply default properties if defined
117+
foreach ($schema->properties as $i => $propertyDefinition) {
118+
if (!$this->getTypeCheck()->propertyExists($value, $i) && isset($propertyDefinition->default)) {
119+
if (is_object($propertyDefinition->default)) {
120+
$this->getTypeCheck()->propertySet($value, $i, clone $propertyDefinition->default);
121+
} else {
122+
$this->getTypeCheck()->propertySet($value, $i, $propertyDefinition->default);
123+
}
124+
}
125+
}
126+
} elseif ($this->getTypeCheck()->isArray($value)) {
127+
if (isset($schema->properties)) {
128+
// $value is an array, but default properties are defined, so treat as assoc
129+
foreach ($schema->properties as $i => $propertyDefinition) {
130+
if (!isset($value[$i]) && isset($propertyDefinition->default)) {
131+
if (is_object($propertyDefinition->default)) {
132+
$value[$i] = clone $propertyDefinition->default;
133+
} else {
134+
$value[$i] = $propertyDefinition->default;
135+
}
136+
}
137+
}
138+
} elseif (isset($schema->items)) {
139+
// $value is an array, and default items are defined - treat as plain array
140+
foreach ($schema->items as $i => $itemDefinition) {
141+
if (!isset($value[$i]) && isset($itemDefinition->default)) {
142+
if (is_object($itemDefinition->default)) {
143+
$value[$i] = clone $itemDefinition->default;
144+
} else {
145+
$value[$i] = $itemDefinition->default;
146+
}
147+
}
148+
}
149+
}
150+
} elseif (($value instanceof self || $value === null) && isset($schema->default)) {
151+
// $value is a leaf, not a container - apply the default directly
152+
$value = is_object($schema->default) ? clone $schema->default : $schema->default;
153+
}
154+
}
155+
110156
// Verify required values
111157
if ($this->getTypeCheck()->isObject($value)) {
112158
if (!($value instanceof self) && isset($schema->required) && is_array($schema->required)) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the JsonSchema package.
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace JsonSchema\Tests\Constraints;
11+
12+
use JsonSchema\Constraints\Constraint;
13+
use JsonSchema\Constraints\Factory;
14+
use JsonSchema\SchemaStorage;
15+
use JsonSchema\Validator;
16+
17+
class DefaultPropertiesTest extends VeryBaseTestCase
18+
{
19+
public function getValidTests()
20+
{
21+
return array(
22+
array(// default value for entire object
23+
'',
24+
'{"default":"valueOne"}',
25+
'"valueOne"'
26+
),
27+
array(// default value in an empty object
28+
'{}',
29+
'{"properties":{"propertyOne":{"default":"valueOne"}}}',
30+
'{"propertyOne":"valueOne"}'
31+
),
32+
array(// default value for top-level property
33+
'{"propertyOne":"valueOne"}',
34+
'{"properties":{"propertyTwo":{"default":"valueTwo"}}}',
35+
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
36+
),
37+
array(// default value for sub-property
38+
'{"propertyOne":{}}',
39+
'{"properties":{"propertyOne":{"properties":{"propertyTwo":{"default":"valueTwo"}}}}}',
40+
'{"propertyOne":{"propertyTwo":"valueTwo"}}'
41+
),
42+
array(// default value for sub-property with sibling
43+
'{"propertyOne":{"propertyTwo":"valueTwo"}}',
44+
'{"properties":{"propertyOne":{"properties":{"propertyThree":{"default":"valueThree"}}}}}',
45+
'{"propertyOne":{"propertyTwo":"valueTwo","propertyThree":"valueThree"}}'
46+
),
47+
array(// default value for top-level property with type check
48+
'{"propertyOne":"valueOne"}',
49+
'{"properties":{"propertyTwo":{"default":"valueTwo","type":"string"}}}',
50+
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
51+
),
52+
array(// default value for top-level property with v3 required check
53+
'{"propertyOne":"valueOne"}',
54+
'{"properties":{"propertyTwo":{"default":"valueTwo","required":"true"}}}',
55+
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
56+
),
57+
array(// default value for top-level property with v4 required check
58+
'{"propertyOne":"valueOne"}',
59+
'{"properties":{"propertyTwo":{"default":"valueTwo"}},"required":["propertyTwo"]}',
60+
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
61+
),
62+
array(//default value for an already set property
63+
'{"propertyOne":"alreadySetValueOne"}',
64+
'{"properties":{"propertyOne":{"default":"valueOne"}}}',
65+
'{"propertyOne":"alreadySetValueOne"}'
66+
),
67+
array(//default item value for an array
68+
'["valueOne"]',
69+
'{"type":"array","items":[{},{"type":"string","default":"valueTwo"}]}',
70+
'["valueOne","valueTwo"]'
71+
),
72+
array(//default item value for an empty array
73+
'[]',
74+
'{"type":"array","items":[{"type":"string","default":"valueOne"}]}',
75+
'["valueOne"]'
76+
),
77+
array(//property without a default available
78+
'{"propertyOne":"alreadySetValueOne"}',
79+
'{"properties":{"propertyOne":{"type":"string"}}}',
80+
'{"propertyOne":"alreadySetValueOne"}'
81+
),
82+
array(// default property value is an object
83+
'{"propertyOne":"valueOne"}',
84+
'{"properties":{"propertyTwo":{"default":{}}}}',
85+
'{"propertyOne":"valueOne","propertyTwo":{}}'
86+
),
87+
array(// default item value is an object
88+
'[]',
89+
'{"type":"array","items":[{"default":{}}]}',
90+
'[{}]'
91+
)
92+
);
93+
}
94+
95+
/**
96+
* @dataProvider getValidTests
97+
*/
98+
public function testValidCases($input, $schema, $expectOutput = null, $validator = null)
99+
{
100+
if (is_string($input)) {
101+
$inputDecoded = json_decode($input);
102+
} else {
103+
$inputDecoded = $input;
104+
}
105+
106+
if ($validator === null) {
107+
$factory = new Factory(null, null, Constraint::CHECK_MODE_APPLY_DEFAULTS);
108+
$validator = new Validator($factory);
109+
}
110+
$validator->validate($inputDecoded, json_decode($schema));
111+
112+
$this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true));
113+
114+
if ($expectOutput !== null) {
115+
$this->assertEquals($expectOutput, json_encode($inputDecoded));
116+
}
117+
}
118+
119+
/**
120+
* @dataProvider getValidTests
121+
*/
122+
public function testValidCasesUsingAssoc($input, $schema, $expectOutput = null)
123+
{
124+
$input = json_decode($input, true);
125+
126+
$factory = new Factory(null, null, Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_APPLY_DEFAULTS);
127+
self::testValidCases($input, $schema, $expectOutput, new Validator($factory));
128+
}
129+
130+
/**
131+
* @dataProvider getValidTests
132+
*/
133+
public function testValidCasesUsingAssocWithoutTypeCast($input, $schema, $expectOutput = null)
134+
{
135+
$input = json_decode($input, true);
136+
$factory = new Factory(null, null, Constraint::CHECK_MODE_APPLY_DEFAULTS);
137+
self::testValidCases($input, $schema, $expectOutput, new Validator($factory));
138+
}
139+
140+
public function testNoModificationViaReferences()
141+
{
142+
$input = json_decode('');
143+
$schema = json_decode('{"default":{"propertyOne":"valueOne"}}');
144+
145+
$validator = new Validator();
146+
$validator->validate($input, $schema, Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_APPLY_DEFAULTS);
147+
148+
$this->assertEquals('{"propertyOne":"valueOne"}', json_encode($input));
149+
150+
$input->propertyOne = 'valueTwo';
151+
$this->assertEquals('valueOne', $schema->default->propertyOne);
152+
}
153+
}

tests/Constraints/TypeTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace JsonSchema\Tests\Constraints;
1111

12+
use JsonSchema\Constraints\TypeCheck\LooseTypeCheck;
1213
use JsonSchema\Constraints\TypeConstraint;
1314

1415
/**
@@ -51,6 +52,19 @@ public function testIndefiniteArticleForTypeInTypeCheckErrorMessage($type, $word
5152
$this->assertTypeConstraintError(ucwords($label) . " value found, but $wording is required", $constraint);
5253
}
5354

55+
/**
56+
* Test uncovered areas of the loose type checker
57+
*/
58+
public function testLooseTypeChecking()
59+
{
60+
$v = new \StdClass();
61+
$v->property = 'dataOne';
62+
LooseTypeCheck::propertySet($v, 'property', 'dataTwo');
63+
$this->assertEquals('dataTwo', $v->property);
64+
$this->assertEquals('dataTwo', LooseTypeCheck::propertyGet($v, 'property'));
65+
$this->assertEquals(1, LooseTypeCheck::propertyCount($v));
66+
}
67+
5468
/**
5569
* Helper to assert an error message
5670
*

0 commit comments

Comments
 (0)