diff --git a/README.md b/README.md index 8df14db7..aabb2eb6 100644 --- a/README.md +++ b/README.md @@ -186,14 +186,19 @@ third argument to `Validator::validate()`, or can be provided as the third argum | `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default | | `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects | | `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible | +| `Constraint::CHECK_MODE_EARLY_COERCE` | Apply type coercion as soon as possible | | `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set | | `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required | | `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails | | `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints | | `Constraint::CHECK_MODE_VALIDATE_SCHEMA` | Validate the schema as well as the provided document | -Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` or `Constraint::CHECK_MODE_APPLY_DEFAULTS` -will modify your original data. +Please note that using `CHECK_MODE_COERCE_TYPES` or `CHECK_MODE_APPLY_DEFAULTS` will modify your +original data. + +`CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If +enabled, the validator will use (and coerce) the first compatible type it encounters, even if the +schema defines another type that matches directly and does not require coercion. ## Running the tests diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index b7f3bb42..05e3efa2 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -31,6 +31,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface const CHECK_MODE_APPLY_DEFAULTS = 0x00000008; const CHECK_MODE_EXCEPTIONS = 0x00000010; const CHECK_MODE_DISABLE_FORMAT = 0x00000020; + const CHECK_MODE_EARLY_COERCE = 0x00000040; const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080; const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100; diff --git a/src/JsonSchema/Constraints/TypeConstraint.php b/src/JsonSchema/Constraints/TypeConstraint.php index 096f5485..5bfe08a9 100644 --- a/src/JsonSchema/Constraints/TypeConstraint.php +++ b/src/JsonSchema/Constraints/TypeConstraint.php @@ -44,16 +44,24 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, { $type = isset($schema->type) ? $schema->type : null; $isValid = false; + $coerce = $this->factory->getConfig(self::CHECK_MODE_COERCE_TYPES); + $earlyCoerce = $this->factory->getConfig(self::CHECK_MODE_EARLY_COERCE); $wording = array(); if (is_array($type)) { - $this->validateTypesArray($value, $type, $wording, $isValid, $path); + $this->validateTypesArray($value, $type, $wording, $isValid, $path, $coerce && $earlyCoerce); + if (!$isValid && $coerce && !$earlyCoerce) { + $this->validateTypesArray($value, $type, $wording, $isValid, $path, true); + } } elseif (is_object($type)) { $this->checkUndefined($value, $type, $path); return; } else { - $isValid = $this->validateType($value, $type); + $isValid = $this->validateType($value, $type, $coerce && $earlyCoerce); + if (!$isValid && $coerce && !$earlyCoerce) { + $isValid = $this->validateType($value, $type, true); + } } if ($isValid === false) { @@ -62,8 +70,8 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, $wording[] = self::$wording[$type]; } $this->addError(ConstraintError::TYPE(), $path, array( - 'expected' => gettype($value), - 'found' => $this->implodeWith($wording, ', ', 'or') + 'found' => gettype($value), + 'expected' => $this->implodeWith($wording, ', ', 'or') )); } } @@ -79,9 +87,14 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, * @param bool $isValid The current validation value * @param $path */ - protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path) + protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path, $coerce = false) { foreach ($type as $tp) { + // already valid, so no need to waste cycles looping over everything + if ($isValid) { + return; + } + // $tp can be an object, if it's a schema instead of a simple type, validate it // with a new type constraint if (is_object($tp)) { @@ -98,7 +111,7 @@ protected function validateTypesArray(&$value, array $type, &$validTypesWording, $this->validateTypeNameWording($tp); $validTypesWording[] = self::$wording[$tp]; if (!$isValid) { - $isValid = $this->validateType($value, $tp); + $isValid = $this->validateType($value, $tp, $coerce); } } } @@ -157,7 +170,7 @@ protected function validateTypeNameWording($type) * * @return bool */ - protected function validateType(&$value, $type) + protected function validateType(&$value, $type, $coerce = false) { //mostly the case for inline schema if (!$type) { @@ -173,11 +186,13 @@ protected function validateType(&$value, $type) } if ('array' === $type) { + if ($coerce) { + $value = $this->toArray($value); + } + return $this->getTypeCheck()->isArray($value); } - $coerce = $this->factory->getConfig(Constraint::CHECK_MODE_COERCE_TYPES); - if ('integer' === $type) { if ($coerce) { $value = $this->toInteger($value); @@ -203,10 +218,18 @@ protected function validateType(&$value, $type) } if ('string' === $type) { + if ($coerce) { + $value = $this->toString($value); + } + return is_string($value); } if ('null' === $type) { + if ($coerce) { + $value = $this->toNull($value); + } + return is_null($value); } @@ -222,19 +245,21 @@ protected function validateType(&$value, $type) */ protected function toBoolean($value) { - if ($value === 'true') { + if ($value === 1 || $value === 'true') { return true; } - - if ($value === 'false') { + if (is_null($value) || $value === 0 || $value === 'false') { return false; } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toBoolean(reset($value)); + } return $value; } /** - * Converts a numeric string to a number. For example, "4" becomes 4. + * Converts a value to a number. For example, "4.5" becomes 4.5. * * @param mixed $value the value to convert to a number * @@ -245,14 +270,89 @@ protected function toNumber($value) if (is_numeric($value)) { return $value + 0; // cast to number } + if (is_bool($value) || is_null($value)) { + return (int) $value; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toNumber(reset($value)); + } return $value; } + /** + * Converts a value to an integer. For example, "4" becomes 4. + * + * @param mixed $value + * + * @return int|mixed + */ protected function toInteger($value) { - if (is_numeric($value) && (int) $value == $value) { - return (int) $value; // cast to number + $numberValue = $this->toNumber($value); + if (is_numeric($numberValue) && (int) $numberValue == $numberValue) { + return (int) $numberValue; // cast to number + } + + return $value; + } + + /** + * Converts a value to an array containing that value. For example, [4] becomes 4. + * + * @param mixed $value + * + * @return array|mixed + */ + protected function toArray($value) + { + if (is_scalar($value) || is_null($value)) { + return array($value); + } + + return $value; + } + + /** + * Convert a value to a string representation of that value. For example, null becomes "". + * + * @param mixed $value + * + * @return string|mixed + */ + protected function toString($value) + { + if (is_numeric($value)) { + return "$value"; + } + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if (is_null($value)) { + return ''; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toString(reset($value)); + } + } + + /** + * Convert a value to a null. For example, 0 becomes null. + * + * @param mixed $value + * + * @return null|mixed + */ + protected function toNull($value) + { + if ($value === 0 || $value === false || $value === '') { + return null; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toNull(reset($value)); } return $value; diff --git a/tests/Constraints/CoerciveTest.php b/tests/Constraints/CoerciveTest.php index e4dd173d..9a910c8a 100644 --- a/tests/Constraints/CoerciveTest.php +++ b/tests/Constraints/CoerciveTest.php @@ -11,112 +11,191 @@ use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; -use JsonSchema\SchemaStorage; -use JsonSchema\Uri\UriResolver; +use JsonSchema\Constraints\TypeCheck\LooseTypeCheck; use JsonSchema\Validator; -class CoerciveTest extends BasicTypesTest +class CoerciveTest extends VeryBaseTestCase { - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; - protected $validateSchema = true; + protected $factory = null; - /** - * @dataProvider getValidCoerceTests - */ - public function testValidCoerceCasesUsingAssoc($input, $schema) + public function setUp() { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - - $value = json_decode($input, true); - - $validator->validate($value, $schema, $checkMode); - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + $this->factory = new Factory(); + $this->factory->setConfig(Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES); } - /** - * @dataProvider getValidCoerceTests - */ - public function testValidCoerceCases($input, $schema, $errors = array()) + public function dataCoerceCases() { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; + // check type conversions + $types = array( + // toType + 'string' => array( + // fromType fromValue toValue valid Test Number + array('string', '"ABC"', 'ABC', true), // #0 + array('integer', '45', '45', true), // #1 + array('boolean', 'true', 'true', true), // #2 + array('boolean', 'false', 'false', true), // #3 + array('NULL', 'null', '', true), // #4 + array('array', '[45]', '45', true), // #5 + array('object', '{"a":"b"}', null, false), // #6 + array('array', '[{"a":"b"}]', null, false), // #7 + ), + 'integer' => array( + array('string', '"45"', 45, true), // #8 + array('integer', '45', 45, true), // #9 + array('boolean', 'true', 1, true), // #10 + array('boolean', 'false', 0, true), // #11 + array('NULL', 'null', 0, true), // #12 + array('array', '["-45"]', -45, true), // #13 + array('object', '{"a":"b"}', null, false), // #14 + array('array', '["ABC"]', null, false), // #15 + ), + 'boolean' => array( + array('string', '"true"', true, true), // #16 + array('integer', '1', true, true), // #17 + array('boolean', 'true', true, true), // #18 + array('NULL', 'null', false, true), // #19 + array('array', '["true"]', true, true), // #20 + array('object', '{"a":"b"}', null, false), // #21 + array('string', '""', null, false), // #22 + array('string', '"ABC"', null, false), // #23 + array('integer', '2', null, false), // #24 + ), + 'NULL' => array( + array('string', '""', null, true), // #25 + array('integer', '0', null, true), // #26 + array('boolean', 'false', null, true), // #27 + array('NULL', 'null', null, true), // #28 + array('array', '[0]', null, true), // #29 + array('object', '{"a":"b"}', null, false), // #30 + array('string', '"null"', null, false), // #31 + array('integer', '-1', null, false), // #32 + ), + 'array' => array( + array('string', '"ABC"', array('ABC'), true), // #33 + array('integer', '45', array(45), true), // #34 + array('boolean', 'true', array(true), true), // #35 + array('NULL', 'null', array(null), true), // #36 + array('array', '["ABC"]', array('ABC'), true), // #37 + array('object', '{"a":"b"}', null, false), // #38 + ), + ); - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + // #39 check post-coercion validation (to array) + $tests[] = array( + '{"properties":{"propertyOne":{"type":"array","items":[{"type":"number"}]}}}', + '{"propertyOne":"ABC"}', + 'string', null, null, false + ); - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input); + // #40 check multiple types (first valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "string"]}}}', + '{"propertyOne":42}', + 'integer', 'integer', 42, true + ); - $this->assertTrue(gettype($value->number) == 'string'); - $this->assertTrue(gettype($value->integer) == 'string'); - $this->assertTrue(gettype($value->boolean) == 'string'); + // #41 check multiple types (last valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "string"]}}}', + '{"propertyOne":"42"}', + 'string', 'string', '42', true + ); - $validator->validate($value, $schema, $checkMode); + // #42 check the meaning of life + $tests[] = array( + '{"properties":{"propertyOne":{"type":"any"}}}', + '{"propertyOne":"42"}', + 'string', 'string', '42', true + ); - $this->assertTrue(gettype($value->number) == 'double'); - $this->assertTrue(gettype($value->integer) == 'integer'); - $this->assertTrue(gettype($value->negativeInteger) == 'integer'); - $this->assertTrue(gettype($value->boolean) == 'boolean'); + // #43 check turple coercion + $tests[] = array( + '{"properties":{"propertyOne":{"type":"array","items":[{"type":"number"},{"type":"string"}]}}}', + '{"propertyOne":["42", 42]}', + 'array', 'array', array(42, '42'), true + ); - $this->assertTrue($value->number === 1.5); - $this->assertTrue($value->integer === 1); - $this->assertTrue($value->negativeInteger === -2); - $this->assertTrue($value->boolean === true); + // #44 check early coercion + $tests[] = array( + '{"properties":{"propertyOne":{"type":["object", "number", "string"]}}}', + '{"propertyOne":"42"}', + 'string', 'integer', 42, true, Constraint::CHECK_MODE_EARLY_COERCE + ); - $this->assertTrue(gettype($value->multitype1) == 'boolean'); - $this->assertTrue(gettype($value->multitype2) == 'double'); - $this->assertTrue(gettype($value->multitype3) == 'integer'); + // #45 check multiple types (none valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "boolean"]}}}', + '{"propertyOne":"42"}', + 'string', 'integer', 42, true + ); - $this->assertTrue($value->number === 1.5); - $this->assertTrue($value->integer === 1); - $this->assertTrue($value->negativeInteger === -2); - $this->assertTrue($value->boolean === true); + $tests = array(); + foreach ($types as $toType => $testCases) { + foreach ($testCases as $testCase) { + $tests[] = array( + sprintf('{"properties":{"propertyOne":{"type":"%s"}}}', strtolower($toType)), + sprintf('{"propertyOne":%s}', $testCase[1]), + $testCase[0], + $toType, + $testCase[2], + $testCase[3] + ); + } + } - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + return $tests; } - /** - * @dataProvider getInvalidCoerceTests - */ - public function testInvalidCoerceCases($input, $schema, $errors = array()) + /** @dataProvider dataCoerceCases **/ + public function testCoerceCases($schema, $data, $startType, $endType, $endValue, $valid, $extraFlags = 0, $assoc = false) { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; + $validator = new Validator($this->factory); - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + $schema = json_decode($schema); + $data = json_decode($data, $assoc); - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input); - $validator->validate($value, $schema, $checkMode); - - if (array() !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + // check initial type + $type = gettype(LooseTypeCheck::propertyGet($data, 'propertyOne')); + if ($assoc && $type == 'array' && $startType == 'object') { + $type = 'object'; + } + $this->assertEquals($startType, $type, "Incorrect type '$type': expected '$startType'"); + + $validator->validate($data, $schema, $this->factory->getConfig() | $extraFlags); + + // check validity + if ($valid) { + $prettyPrint = defined('\JSON_PRETTY_PRINT') ? constant('\JSON_PRETTY_PRINT') : 0; + $this->assertTrue( + $validator->isValid(), + 'Validation failed: ' . json_encode($validator->getErrors(), $prettyPrint) + ); + + // check end type + $type = gettype(LooseTypeCheck::propertyGet($data, 'propertyOne')); + $this->assertEquals($endType, $type, "Incorrect type '$type': expected '$endType'"); + + // check end value + $value = LooseTypeCheck::propertyGet($data, 'propertyOne'); + $this->assertTrue( + $value === $endValue, + sprintf( + "Incorrect value '%s': expected '%s'", + is_scalar($value) ? $value : gettype($value), + is_scalar($endValue) ? $endValue : gettype($endValue) + ) + ); + } else { + $this->assertFalse($validator->isValid(), 'Validation succeeded, but should have failed'); + $this->assertEquals(1, count($validator->getErrors())); } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); } - /** - * @dataProvider getInvalidCoerceTests - */ - public function testInvalidCoerceCasesUsingAssoc($input, $schema, $errors = array()) + /** @dataProvider dataCoerceCases **/ + public function testCoerceCasesUsingAssoc($schema, $data, $startType, $endType, $endValue, $valid, $early = false) { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input, true); - $validator->validate($value, $schema, $checkMode); - - if (array() !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); - } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); + $this->testCoerceCases($schema, $data, $startType, $endType, $endValue, $valid, $early, true); } public function testCoerceAPI() @@ -127,167 +206,4 @@ public function testCoerceAPI() $v->coerce($input, $schema); $this->assertEquals('{"propertyOne":10}', json_encode($input)); } - - public function getValidCoerceTests() - { - return array( - array( - '{ - "string":"string test", - "number":"1.5", - "integer":"1", - "negativeInteger":"-2", - "boolean":"true", - "object":{}, - "array":[], - "null":null, - "any": "string", - "allOf": "1", - "multitype1": "false", - "multitype2": "1.2", - "multitype3": "7", - "arrayOfIntegers":["-1","0","1"], - "tupleTyping":["1","2.2","true"], - "any1": 2.6, - "any2": 4, - "any3": false, - "any4": {}, - "any5": [], - "any6": null - }', - '{ - "type":"object", - "properties":{ - "string":{"type":"string"}, - "number":{"type":"number"}, - "integer":{"type":"integer"}, - "negativeInteger":{"type":"integer"}, - "boolean":{"type":"boolean"}, - "object":{"type":"object"}, - "array":{"type":"array"}, - "null":{"type":"null"}, - "any": {"type":"any"}, - "allOf" : {"allOf":[{ - "type" : "string" - },{ - "type" : "integer" - }]}, - "multitype1": {"type":["boolean","integer","number"]}, - "multitype2": {"type":["boolean","integer","number"]}, - "multitype3": {"type":["boolean","integer","number"]}, - "arrayOfIntegers":{ - "items":{ - "type":"integer" - } - }, - "tupleTyping":{ - "type":"array", - "items":[ - {"type":"integer"}, - {"type":"number"} - ], - "additionalItems":{"type":"boolean"} - }, - "any1": {"type":"any"}, - "any2": {"type":"any"}, - "any3": {"type":"any"}, - "any4": {"type":"any"}, - "any5": {"type":"any"}, - "any6": {"type":"any"} - }, - "additionalProperties":false - }', - ), - ); - } - - public function getInvalidCoerceTests() - { - return array( - array( - '{ - "string":null - }', - '{ - "type":"object", - "properties": { - "string":{"type":"string"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "number":"five" - }', - '{ - "type":"object", - "properties": { - "number":{"type":"number"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "integer":"5.2" - }', - '{ - "type":"object", - "properties": { - "integer":{"type":"integer"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "boolean":"0" - }', - '{ - "type":"object", - "properties": { - "boolean":{"type":"boolean"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "object":null - }', - '{ - "type":"object", - "properties": { - "object":{"type":"object"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "array":null - }', - '{ - "type":"object", - "properties": { - "array":{"type":"array"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "null":1 - }', - '{ - "type":"object", - "properties": { - "null":{"type":"null"} - }, - "additionalProperties":false - }', - ), - ); - } } diff --git a/tests/Constraints/OfPropertiesTest.php b/tests/Constraints/OfPropertiesTest.php index 192369c6..ff8bded3 100644 --- a/tests/Constraints/OfPropertiesTest.php +++ b/tests/Constraints/OfPropertiesTest.php @@ -83,8 +83,8 @@ public function getInvalidTests() 'constraint' => array( 'name' => 'type', 'params' => array( - 'expected' => 'array', - 'found' => 'a string' + 'expected' => 'a string', + 'found' => 'array' ) ), 'context' => Validator::ERROR_DOCUMENT_VALIDATION @@ -96,8 +96,8 @@ public function getInvalidTests() 'constraint' => array( 'name' => 'type', 'params' => array( - 'expected' => 'array', - 'found' => 'a number' + 'expected' => 'a number', + 'found' => 'array' ) ), 'context' => Validator::ERROR_DOCUMENT_VALIDATION