Skip to content

Commit 1a0bf5f

Browse files
committed
Improvements to type coercion (jsonrainbow#384)
* Improve performance - don't loop over everything if already valid * Don't coerce already-valid types (fixes jsonrainbow#379) * Add remaining coercion cases & rewrite tests * Add all remaining coercion cases from ajv matrix * Rewrite the coercion tests to tidy things up a bit * Add CHECK_MODE_EARLY_COERCE If set, falls back to the old behavior of coercing to the first compatible type, regardless of whether another already-valid type might be available. * Add multiple-type test that requires coercion * \JSON_PRETTY_PRINT doesn't exist in PHP-5.3, so work around this * Various PR cleanup stuff * Fix whitespace * Turn $early into $extraFlags * Change "string" to "ABC" in string test * Update README.md description of CHECK_MODE_EARLY_COERCE * Move loop after complex tests definition * Move test jsonrainbow#39 to grid jsonrainbow#15
1 parent b5bc719 commit 1a0bf5f

File tree

5 files changed

+286
-264
lines changed

5 files changed

+286
-264
lines changed

README.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,19 @@ third argument to `Validator::validate()`, or can be provided as the third argum
186186
| `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default |
187187
| `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects |
188188
| `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible |
189+
| `Constraint::CHECK_MODE_EARLY_COERCE` | Apply type coercion as soon as possible |
189190
| `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set |
190191
| `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required |
191192
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |
192193
| `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints |
193194
| `Constraint::CHECK_MODE_VALIDATE_SCHEMA` | Validate the schema as well as the provided document |
194195

195-
Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` or `Constraint::CHECK_MODE_APPLY_DEFAULTS`
196-
will modify your original data.
196+
Please note that using `CHECK_MODE_COERCE_TYPES` or `CHECK_MODE_APPLY_DEFAULTS` will modify your
197+
original data.
198+
199+
`CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If
200+
enabled, the validator will use (and coerce) the first compatible type it encounters, even if the
201+
schema defines another type that matches directly and does not require coercion.
197202

198203
## Running the tests
199204

src/JsonSchema/Constraints/Constraint.php

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface
3131
const CHECK_MODE_APPLY_DEFAULTS = 0x00000008;
3232
const CHECK_MODE_EXCEPTIONS = 0x00000010;
3333
const CHECK_MODE_DISABLE_FORMAT = 0x00000020;
34+
const CHECK_MODE_EARLY_COERCE = 0x00000040;
3435
const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080;
3536
const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100;
3637

src/JsonSchema/Constraints/TypeConstraint.php

+115-15
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,24 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null,
4444
{
4545
$type = isset($schema->type) ? $schema->type : null;
4646
$isValid = false;
47+
$coerce = $this->factory->getConfig(self::CHECK_MODE_COERCE_TYPES);
48+
$earlyCoerce = $this->factory->getConfig(self::CHECK_MODE_EARLY_COERCE);
4749
$wording = array();
4850

4951
if (is_array($type)) {
50-
$this->validateTypesArray($value, $type, $wording, $isValid, $path);
52+
$this->validateTypesArray($value, $type, $wording, $isValid, $path, $coerce && $earlyCoerce);
53+
if (!$isValid && $coerce && !$earlyCoerce) {
54+
$this->validateTypesArray($value, $type, $wording, $isValid, $path, true);
55+
}
5156
} elseif (is_object($type)) {
5257
$this->checkUndefined($value, $type, $path);
5358

5459
return;
5560
} else {
56-
$isValid = $this->validateType($value, $type);
61+
$isValid = $this->validateType($value, $type, $coerce && $earlyCoerce);
62+
if (!$isValid && $coerce && !$earlyCoerce) {
63+
$isValid = $this->validateType($value, $type, true);
64+
}
5765
}
5866

5967
if ($isValid === false) {
@@ -62,8 +70,8 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null,
6270
$wording[] = self::$wording[$type];
6371
}
6472
$this->addError(ConstraintError::TYPE(), $path, array(
65-
'expected' => gettype($value),
66-
'found' => $this->implodeWith($wording, ', ', 'or')
73+
'found' => gettype($value),
74+
'expected' => $this->implodeWith($wording, ', ', 'or')
6775
));
6876
}
6977
}
@@ -79,9 +87,14 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null,
7987
* @param bool $isValid The current validation value
8088
* @param $path
8189
*/
82-
protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path)
90+
protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path, $coerce = false)
8391
{
8492
foreach ($type as $tp) {
93+
// already valid, so no need to waste cycles looping over everything
94+
if ($isValid) {
95+
return;
96+
}
97+
8598
// $tp can be an object, if it's a schema instead of a simple type, validate it
8699
// with a new type constraint
87100
if (is_object($tp)) {
@@ -98,7 +111,7 @@ protected function validateTypesArray(&$value, array $type, &$validTypesWording,
98111
$this->validateTypeNameWording($tp);
99112
$validTypesWording[] = self::$wording[$tp];
100113
if (!$isValid) {
101-
$isValid = $this->validateType($value, $tp);
114+
$isValid = $this->validateType($value, $tp, $coerce);
102115
}
103116
}
104117
}
@@ -157,7 +170,7 @@ protected function validateTypeNameWording($type)
157170
*
158171
* @return bool
159172
*/
160-
protected function validateType(&$value, $type)
173+
protected function validateType(&$value, $type, $coerce = false)
161174
{
162175
//mostly the case for inline schema
163176
if (!$type) {
@@ -173,11 +186,13 @@ protected function validateType(&$value, $type)
173186
}
174187

175188
if ('array' === $type) {
189+
if ($coerce) {
190+
$value = $this->toArray($value);
191+
}
192+
176193
return $this->getTypeCheck()->isArray($value);
177194
}
178195

179-
$coerce = $this->factory->getConfig(Constraint::CHECK_MODE_COERCE_TYPES);
180-
181196
if ('integer' === $type) {
182197
if ($coerce) {
183198
$value = $this->toInteger($value);
@@ -203,10 +218,18 @@ protected function validateType(&$value, $type)
203218
}
204219

205220
if ('string' === $type) {
221+
if ($coerce) {
222+
$value = $this->toString($value);
223+
}
224+
206225
return is_string($value);
207226
}
208227

209228
if ('null' === $type) {
229+
if ($coerce) {
230+
$value = $this->toNull($value);
231+
}
232+
210233
return is_null($value);
211234
}
212235

@@ -222,19 +245,21 @@ protected function validateType(&$value, $type)
222245
*/
223246
protected function toBoolean($value)
224247
{
225-
if ($value === 'true') {
248+
if ($value === 1 || $value === 'true') {
226249
return true;
227250
}
228-
229-
if ($value === 'false') {
251+
if (is_null($value) || $value === 0 || $value === 'false') {
230252
return false;
231253
}
254+
if ($this->getTypeCheck()->isArray($value) && count($value) === 1) {
255+
return $this->toBoolean(reset($value));
256+
}
232257

233258
return $value;
234259
}
235260

236261
/**
237-
* Converts a numeric string to a number. For example, "4" becomes 4.
262+
* Converts a value to a number. For example, "4.5" becomes 4.5.
238263
*
239264
* @param mixed $value the value to convert to a number
240265
*
@@ -245,14 +270,89 @@ protected function toNumber($value)
245270
if (is_numeric($value)) {
246271
return $value + 0; // cast to number
247272
}
273+
if (is_bool($value) || is_null($value)) {
274+
return (int) $value;
275+
}
276+
if ($this->getTypeCheck()->isArray($value) && count($value) === 1) {
277+
return $this->toNumber(reset($value));
278+
}
248279

249280
return $value;
250281
}
251282

283+
/**
284+
* Converts a value to an integer. For example, "4" becomes 4.
285+
*
286+
* @param mixed $value
287+
*
288+
* @return int|mixed
289+
*/
252290
protected function toInteger($value)
253291
{
254-
if (is_numeric($value) && (int) $value == $value) {
255-
return (int) $value; // cast to number
292+
$numberValue = $this->toNumber($value);
293+
if (is_numeric($numberValue) && (int) $numberValue == $numberValue) {
294+
return (int) $numberValue; // cast to number
295+
}
296+
297+
return $value;
298+
}
299+
300+
/**
301+
* Converts a value to an array containing that value. For example, [4] becomes 4.
302+
*
303+
* @param mixed $value
304+
*
305+
* @return array|mixed
306+
*/
307+
protected function toArray($value)
308+
{
309+
if (is_scalar($value) || is_null($value)) {
310+
return array($value);
311+
}
312+
313+
return $value;
314+
}
315+
316+
/**
317+
* Convert a value to a string representation of that value. For example, null becomes "".
318+
*
319+
* @param mixed $value
320+
*
321+
* @return string|mixed
322+
*/
323+
protected function toString($value)
324+
{
325+
if (is_numeric($value)) {
326+
return "$value";
327+
}
328+
if ($value === true) {
329+
return 'true';
330+
}
331+
if ($value === false) {
332+
return 'false';
333+
}
334+
if (is_null($value)) {
335+
return '';
336+
}
337+
if ($this->getTypeCheck()->isArray($value) && count($value) === 1) {
338+
return $this->toString(reset($value));
339+
}
340+
}
341+
342+
/**
343+
* Convert a value to a null. For example, 0 becomes null.
344+
*
345+
* @param mixed $value
346+
*
347+
* @return null|mixed
348+
*/
349+
protected function toNull($value)
350+
{
351+
if ($value === 0 || $value === false || $value === '') {
352+
return null;
353+
}
354+
if ($this->getTypeCheck()->isArray($value) && count($value) === 1) {
355+
return $this->toNull(reset($value));
256356
}
257357

258358
return $value;

0 commit comments

Comments
 (0)