From cd378b9832060bd686bb9099827271802f23291a Mon Sep 17 00:00:00 2001 From: Ori Hoch Date: Thu, 6 Jul 2017 16:59:29 +0300 Subject: [PATCH] [WIP] add field types (#24) * added field types: time, year, yearmonth, added formats for string * code style fixes * misc. improvements to time format handling * added all field types + tests for cast and for require and enum constraints * style fix --- composer.json | 4 +- src/Fields/AnyField.php | 21 ++ src/Fields/ArrayField.php | 31 ++ src/Fields/BaseField.php | 65 +++-- src/Fields/BooleanField.php | 38 +++ src/Fields/DateField.php | 46 +++ src/Fields/DatetimeField.php | 51 ++++ src/Fields/DurationField.php | 46 +++ src/Fields/FieldsFactory.php | 21 +- src/Fields/GeojsonField.php | 35 +++ src/Fields/GeopointField.php | 65 +++++ src/Fields/IntegerField.php | 8 +- src/Fields/NumberField.php | 37 ++- src/Fields/ObjectField.php | 35 +++ src/Fields/StringField.php | 33 ++- src/Fields/TimeField.php | 58 ++++ src/Fields/YearField.php | 25 ++ src/Fields/YearMonthField.php | 43 +++ src/SchemaValidationError.php | 2 +- tests/FieldTest.php | 26 +- tests/FieldTypesTest.php | 523 ++++++++++++++++++++++++++++++++++ tests/SchemaTest.php | 6 +- tests/TableTest.php | 4 +- 23 files changed, 1156 insertions(+), 67 deletions(-) create mode 100644 src/Fields/AnyField.php create mode 100644 src/Fields/ArrayField.php create mode 100644 src/Fields/BooleanField.php create mode 100644 src/Fields/DateField.php create mode 100644 src/Fields/DatetimeField.php create mode 100644 src/Fields/DurationField.php create mode 100644 src/Fields/GeojsonField.php create mode 100644 src/Fields/GeopointField.php create mode 100644 src/Fields/ObjectField.php create mode 100644 src/Fields/TimeField.php create mode 100644 src/Fields/YearField.php create mode 100644 src/Fields/YearMonthField.php create mode 100644 tests/FieldTypesTest.php diff --git a/composer.json b/composer.json index d95f857..d4effd4 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,9 @@ "license": "MIT", "require": { "php": ">=5.4", - "justinrainbow/json-schema": "^5.2" + "justinrainbow/json-schema": "^5.2", + "nesbot/carbon": "^1.22", + "jmikola/geojson": "^1.0" }, "require-dev": { "phpunit/phpunit": "^4.8.35", diff --git a/src/Fields/AnyField.php b/src/Fields/AnyField.php new file mode 100644 index 0000000..c66e7e3 --- /dev/null +++ b/src/Fields/AnyField.php @@ -0,0 +1,21 @@ +getValidationException($e->getMessage(), $val); + } + if (!is_array($val)) { + throw $this->getValidationException('json string must decode to array', $val); + } else { + return $val; + } + } else { + throw $this->getValidationException('must be array or string', $val); + } + } + + public static function type() + { + return 'array'; + } +} diff --git a/src/Fields/BaseField.php b/src/Fields/BaseField.php index e229c94..3be621e 100644 --- a/src/Fields/BaseField.php +++ b/src/Fields/BaseField.php @@ -134,7 +134,15 @@ final public function castValue($val) return null; } else { - return $this->validateCastValue($val); + $val = $this->validateCastValue($val); + if (!$this->constraintsDisabled) { + $validationErrors = $this->checkConstraints($val); + if (count($validationErrors) > 0) { + throw new FieldValidationException($validationErrors); + } + } + + return $val; } } @@ -176,13 +184,13 @@ public static function type() protected $descriptor; protected $constraintsDisabled = false; - protected function getValidationException($errorMsg, $val = null) + protected function getValidationException($errorMsg = null, $val = null) { return new FieldValidationException([ new SchemaValidationError(SchemaValidationError::FIELD_VALIDATION, [ - 'field' => $this->name(), + 'field' => isset($this->descriptor()->name) ? $this->name() : 'unknown', 'value' => $val, - 'error' => $errorMsg, + 'error' => is_null($errorMsg) ? 'invalid value' : $errorMsg, ]), ]); } @@ -199,28 +207,18 @@ protected function isEmptyValue($val) * * @throws \frictionlessdata\tableschema\Exceptions\FieldValidationException; */ - protected function validateCastValue($val) - { - // extending classes should extend this method - // value is guaranteed not to be an empty value, that is handled elsewhere - // should raise FieldValidationException on any validation errors - // can use getValidationException function to get a simple exception with single validation error message - // you can also throw an exception with multiple validation errors manually - if (!$this->constraintsDisabled) { - $validationErrors = $this->checkConstraints($val); - if (count($validationErrors) > 0) { - throw new FieldValidationException($validationErrors); - } - } - - return $val; - } + // extending classes should extend this method + // value is guaranteed not to be an empty value, that is handled elsewhere + // should raise FieldValidationException on any validation errors + // can use getValidationException function to get a simple exception with single validation error message + // you can also throw an exception with multiple validation errors manually + abstract protected function validateCastValue($val); protected function checkConstraints($val) { $validationErrors = []; $allowedValues = $this->getAllowedValues(); - if (!empty($allowedValues) && !in_array($val, $allowedValues)) { + if (!empty($allowedValues) && !$this->checkAllowedValues($allowedValues, $val)) { $validationErrors[] = new SchemaValidationError(SchemaValidationError::FIELD_VALIDATION, [ 'field' => $this->name(), 'value' => $val, @@ -296,12 +294,28 @@ protected function checkMaximumConstraint($val, $maxConstraint) protected function checkMinLengthConstraint($val, $minLength) { - return strlen($val) >= $minLength; + if (is_string($val)) { + return strlen($val) >= $minLength; + } elseif (is_array($val)) { + return count($val) >= $minLength; + } elseif (is_object($val)) { + return count($val) >= $minLength; + } else { + throw $this->getValidationException('invalid value for minLength constraint', $val); + } } protected function checkMaxLengthConstraint($val, $maxLength) { - return strlen($val) <= $maxLength; + if (is_string($val)) { + return strlen($val) <= $maxLength; + } elseif (is_array($val)) { + return count($val) <= $maxLength; + } elseif (is_object($val)) { + return count($val) <= $maxLength; + } else { + throw $this->getValidationException('invalid value for maxLength constraint', $val); + } } protected function getAllowedValues() @@ -314,6 +328,11 @@ protected function getAllowedValues() return $allowedValues; } + protected function checkAllowedValues($allowedValues, $val) + { + return in_array($val, $allowedValues, !is_object($val)); + } + protected function castValueNoConstraints($val) { $this->disableConstraints(); diff --git a/src/Fields/BooleanField.php b/src/Fields/BooleanField.php new file mode 100644 index 0000000..899cde9 --- /dev/null +++ b/src/Fields/BooleanField.php @@ -0,0 +1,38 @@ +descriptor()->trueValues)) { + $trueValues = $this->descriptor()->trueValues; + } else { + $trueValues = ['true', 'True', 'TRUE', '1']; + } + if (isset($this->descriptor()->falseValues)) { + $falseValues = $this->descriptor()->falseValues; + } else { + $falseValues = ['false', 'False', 'FALSE', '0']; + } + if (is_bool($val)) { + return $val; + } elseif (is_string($val)) { + if (in_array($val, $trueValues)) { + return true; + } elseif (in_array($val, $falseValues)) { + return false; + } else { + throw $this->getValidationException('invalid bool value', $val); + } + } else { + throw $this->getValidationException('value must be a bool or string', $val); + } + } + + public static function type() + { + return 'boolean'; + } +} diff --git a/src/Fields/DateField.php b/src/Fields/DateField.php new file mode 100644 index 0000000..9f56e01 --- /dev/null +++ b/src/Fields/DateField.php @@ -0,0 +1,46 @@ +format()) { + case 'default': + try { + list($year, $month, $day) = explode('-', $val); + + return Carbon::create($year, $month, $day, 0, 0, 0, 'UTC'); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + case 'any': + try { + $date = new Carbon($val); + $date->setTime(0, 0, 0); + + return $date; + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + default: + $date = strptime($val, $this->format()); + if ($date === false || $date['unparsed'] != '') { + throw $this->getValidationException("couldn't parse date/time according to given strptime format '{$this->format()}''", $val); + } else { + return Carbon::create( + (int) $date['tm_year'] + 1900, (int) $date['tm_mon'] + 1, (int) $date['tm_mday'], + 0, 0, 0 + ); + } + } + } + + public static function type() + { + return 'date'; + } +} diff --git a/src/Fields/DatetimeField.php b/src/Fields/DatetimeField.php new file mode 100644 index 0000000..975d856 --- /dev/null +++ b/src/Fields/DatetimeField.php @@ -0,0 +1,51 @@ +format()) { + case 'default': + if (substr($val, -1) != 'Z') { + throw $this->getValidationException('must have trailing Z', $val); + } else { + try { + $val = rtrim($val, 'Z'); + $val = explode('T', $val); + list($year, $month, $day) = explode('-', $val[0]); + list($hour, $minute, $second) = explode(':', $val[1]); + + return Carbon::create($year, $month, $day, $hour, $minute, $second, 'UTC'); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + } + case 'any': + try { + return new Carbon($val); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + default: + $date = strptime($val, $this->format()); + if ($date === false || $date['unparsed'] != '') { + throw $this->getValidationException("couldn't parse date/time according to given strptime format '{$this->format()}''", $val); + } else { + return Carbon::create( + (int) $date['tm_year'] + 1900, (int) $date['tm_mon'] + 1, (int) $date['tm_mday'], + (int) $date['tm_hour'], (int) $date['tm_min'], (int) $date['tm_sec'] + ); + } + } + } + + public static function type() + { + return 'datetime'; + } +} diff --git a/src/Fields/DurationField.php b/src/Fields/DurationField.php new file mode 100644 index 0000000..6d0743f --- /dev/null +++ b/src/Fields/DurationField.php @@ -0,0 +1,46 @@ +getValidationException('must be string', $val); + } else { + $val = trim($val); + try { + // we create a dateInterval first, because it's more restrictive + return CarbonInterval::instance(new \DateInterval($val)); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + } + } + + public static function type() + { + return 'duration'; + } + + protected function checkAllowedValues($allowedValues, $val) + { + foreach ($allowedValues as $allowedValue) { + if ( + $val->years == $allowedValue->years + && $val->months == $allowedValue->months + && $val->days == $allowedValue->days + && $val->hours == $allowedValue->hours + && $val->minutes == $allowedValue->minutes + && $val->seconds == $allowedValue->seconds + ) { + return true; + } + } + + return false; + } +} diff --git a/src/Fields/FieldsFactory.php b/src/Fields/FieldsFactory.php index 50e7eac..75ee73d 100644 --- a/src/Fields/FieldsFactory.php +++ b/src/Fields/FieldsFactory.php @@ -8,13 +8,30 @@ class FieldsFactory { /** - * list of all the available field classes - * ordered in infer order - the most strict field first. + * list of all the available field classes. + * + * this list is used when inferring field type from a value + * infer works by trying to case the value to the field, in the fieldClasses order + * first field that doesn't raise exception on infer wins */ public static $fieldClasses = [ '\\frictionlessdata\\tableschema\\Fields\\IntegerField', '\\frictionlessdata\\tableschema\\Fields\\NumberField', '\\frictionlessdata\\tableschema\\Fields\\StringField', + + // these fields will not be inferred - StringField will catch all values before it reaches these + '\\frictionlessdata\\tableschema\\Fields\\YearMonthField', + '\\frictionlessdata\\tableschema\\Fields\\YearField', + '\\frictionlessdata\\tableschema\\Fields\\TimeField', + '\\frictionlessdata\\tableschema\\Fields\\ObjectField', + '\\frictionlessdata\\tableschema\\Fields\\GeopointField', + '\\frictionlessdata\\tableschema\\Fields\\GeojsonField', + '\\frictionlessdata\\tableschema\\Fields\\DurationField', + '\\frictionlessdata\\tableschema\\Fields\\DatetimeField', + '\\frictionlessdata\\tableschema\\Fields\\DateField', + '\\frictionlessdata\\tableschema\\Fields\\BooleanField', + '\\frictionlessdata\\tableschema\\Fields\\ArrayField', + '\\frictionlessdata\\tableschema\\Fields\\AnyField', ]; /** diff --git a/src/Fields/GeojsonField.php b/src/Fields/GeojsonField.php new file mode 100644 index 0000000..8365123 --- /dev/null +++ b/src/Fields/GeojsonField.php @@ -0,0 +1,35 @@ +getValidationException($e->getMessage(), $val); + } + } + if (!is_object($val)) { + throw $this->getValidationException('must be an object', $val); + } + if ($this->format() == 'default') { + try { + // this validates the geojson + \GeoJson\GeoJson::jsonUnserialize($val); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + } + + return $val; + } + + public static function type() + { + return 'geojson'; + } +} diff --git a/src/Fields/GeopointField.php b/src/Fields/GeopointField.php new file mode 100644 index 0000000..d9d8812 --- /dev/null +++ b/src/Fields/GeopointField.php @@ -0,0 +1,65 @@ +format(), ['array', 'object']) && is_string($val)) { + try { + $val = json_decode($val); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + } + switch ($this->format()) { + case 'default': + if (!is_string($val)) { + throw $this->getValidationException('value must be a string', $val); + } else { + return $this->getNativeGeopoint(explode(',', $val)); + } + case 'array': + if (!is_array($val)) { + throw $this->getValidationException('value must be an array', $val); + } else { + return $this->getNativeGeopoint($val); + } + case 'object': + if (!is_object($val)) { + throw $this->getValidationException('value must be an object', $val); + } elseif (!isset($val->lat) || !isset($val->lon)) { + throw $this->getValidationException('object must contain lon and lat attributes', $val); + } else { + return $this->getNativeGeopoint([$val->lon, $val->lat]); + } + default: + throw $this->getValidationException('invalid format', $val); + } + } + + public static function type() + { + return 'geopoint'; + } + + protected function getNativeGeopoint($arr) + { + if (count($arr) != 2) { + throw $this->getValidationException('lon,lat array must contain only lon,lat', json_encode($arr)); + } else { + list($lon, $lat) = $arr; + $lon = (int) $lon; + $lat = (int) $lat; + if ( + $lon > 180 || $lon < -180 + || $lat > 90 or $lat < -90 + ) { + throw $this->getValidationException('invalid lon,lat values', json_encode($arr)); + } else { + return [$lon, $lat]; + } + } + } +} diff --git a/src/Fields/IntegerField.php b/src/Fields/IntegerField.php index 44821c7..05a3810 100644 --- a/src/Fields/IntegerField.php +++ b/src/Fields/IntegerField.php @@ -11,9 +11,8 @@ class IntegerField extends BaseField * * @throws \frictionlessdata\tableschema\Exceptions\FieldValidationException; */ - public function validateCastValue($val) + protected function validateCastValue($val) { - $val = parent::validateCastValue($val); if (!is_numeric($val)) { throw $this->getValidationException('value must be numeric', $val); } else { @@ -30,9 +29,4 @@ public static function type() { return 'integer'; } - - protected function isEmptyValue($val) - { - return !is_numeric($val) && empty($val); - } } diff --git a/src/Fields/NumberField.php b/src/Fields/NumberField.php index f4f3d84..f78c1e4 100644 --- a/src/Fields/NumberField.php +++ b/src/Fields/NumberField.php @@ -11,13 +11,39 @@ class NumberField extends BaseField * * @throws \frictionlessdata\tableschema\Exceptions\FieldValidationException; */ - public function validateCastValue($val) + protected function validateCastValue($val) { - $val = parent::validateCastValue($val); + $isPercent = false; + if (is_string($val)) { + if (substr($val, -1) == '%') { + $val = rtrim($val, '%'); + $isPercent = true; + } + if (isset($this->descriptor()->groupChar)) { + $val = str_replace($this->descriptor()->groupChar, '', $val); + } + if (isset($this->descriptor()->decimalChar) && $this->descriptor()->decimalChar != '.') { + $val = str_replace($this->descriptor()->decimalChar, '.', $val); + } + if (isset($this->descriptor()->currency) && $this->descriptor()->currency) { + $newval = ''; + foreach (str_split($val) as $chr) { + if (is_numeric($chr) || $chr == '.' || $chr == '+' || $chr == '-') { + $newval .= $chr; + } + } + $val = $newval; + } + } if (!is_numeric($val)) { throw $this->getValidationException('value must be numeric', $val); } else { - return (float) $val; + $val = (float) $val; + if ($isPercent) { + $val = $val / 100; + } + + return $val; } } @@ -25,9 +51,4 @@ public static function type() { return 'number'; } - - protected function isEmptyValue($val) - { - return !is_numeric($val) && empty($val); - } } diff --git a/src/Fields/ObjectField.php b/src/Fields/ObjectField.php new file mode 100644 index 0000000..b8a1d4e --- /dev/null +++ b/src/Fields/ObjectField.php @@ -0,0 +1,35 @@ +getValidationException($e->getMessage(), $val); + } + if (is_object($object)) { + return $object; + } else { + throw $this->getValidationException(null, $val); + } + } + + public static function type() + { + return 'object'; + } +} diff --git a/src/Fields/StringField.php b/src/Fields/StringField.php index 8414282..648847b 100644 --- a/src/Fields/StringField.php +++ b/src/Fields/StringField.php @@ -8,7 +8,7 @@ public function inferProperties($val, $lenient = false) { parent::inferProperties($val, $lenient); if (!$lenient) { - if (strpos($val, '@') !== false) { + if (is_string($val) && strpos($val, '@') !== false) { $this->descriptor->format = 'email'; } } @@ -21,14 +21,33 @@ public function inferProperties($val, $lenient = false) * * @throws \frictionlessdata\tableschema\Exceptions\FieldValidationException; */ - public function validateCastValue($val) + protected function validateCastValue($val) { - $val = parent::validateCastValue($val); - if ($this->format() == 'email' && strpos($val, '@') === false) { - throw $this->getValidationException('value is not a valid email', $val); - } else { - return $val; + try { + $val = (string) $val; + } catch (\Exception $e) { + $val = json_encode($val); } + switch ($this->format()) { + case 'email': + if (strpos($val, '@') === false) { + throw $this->getValidationException('value is not a valid email', $val); + } + break; + case 'uri': + if (filter_var($val, FILTER_VALIDATE_URL) === false) { + throw $this->getValidationException(null, $val); + } + break; + case 'binary': + $decoded = base64_decode($val, true); + if ($decoded === false) { + throw $this->getValidationException(null, $val); + } + break; + } + + return $val; } public static function type() diff --git a/src/Fields/TimeField.php b/src/Fields/TimeField.php new file mode 100644 index 0000000..8375fc0 --- /dev/null +++ b/src/Fields/TimeField.php @@ -0,0 +1,58 @@ +format()) { + case 'default': + $time = explode(':', $val); + if (count($time) != 3) { + throw $this->getValidationException(null, $val); + } else { + list($hour, $minute, $second) = $time; + + return $this->getNativeTime($hour, $minute, $second); + } + break; + case 'any': + try { + $dt = Carbon::parse($val); + } catch (\Exception $e) { + throw $this->getValidationException($e->getMessage(), $val); + } + + return $this->getNativeTime($dt->hour, $dt->minute, $dt->second); + default: + $date = strptime($val, $this->format()); + if ($date === false || $date['unparsed'] != '') { + throw $this->getValidationException(null, $val); + } else { + return $this->getNativeTime($date['tm_hour'], $date['tm_min'], $date['tm_sec']); + } + } + } + + public static function type() + { + return 'time'; + } + + protected function getNativeTime($hour, $minute, $second) + { + $parts = [$hour, $minute, $second]; + foreach ($parts as &$part) { + $part = (int) $part; + } + + return $parts; + } +} diff --git a/src/Fields/YearField.php b/src/Fields/YearField.php new file mode 100644 index 0000000..2bb3791 --- /dev/null +++ b/src/Fields/YearField.php @@ -0,0 +1,25 @@ +getValidationException(null, $val); + } else { + return $year; + } + } + + public static function type() + { + return 'year'; + } +} diff --git a/src/Fields/YearMonthField.php b/src/Fields/YearMonthField.php new file mode 100644 index 0000000..56b8889 --- /dev/null +++ b/src/Fields/YearMonthField.php @@ -0,0 +1,43 @@ +getValidationException(null, $val); + } else { + list($year, $month) = $val; + if ($year == '' || $month == '') { + throw $this->getValidationException(null, $val); + } else { + $year = (int) $year; + $month = (int) $month; + if ($month < 1 || $month > 12) { + throw $this->getValidationException(null, $val); + } else { + return $this->getNativeYearMonth($year, $month); + } + } + } + } + + public static function type() + { + return 'yearmonth'; + } + + protected function getNativeYearMonth($year, $month) + { + return [$year, $month]; + } +} diff --git a/src/SchemaValidationError.php b/src/SchemaValidationError.php index 7b684c6..70787a8 100644 --- a/src/SchemaValidationError.php +++ b/src/SchemaValidationError.php @@ -40,7 +40,7 @@ public function getMessage() case self::FIELD_VALIDATION: $field = $this->extraDetails['field']; $error = $this->extraDetails['error']; - $value = $this->extraDetails['value']; + $value = json_encode($this->extraDetails['value']); return "{$field}: {$error} ({$value})"; case self::ROW_FIELD_VALIDATION: diff --git a/tests/FieldTest.php b/tests/FieldTest.php index 11cfd80..2e5690a 100644 --- a/tests/FieldTest.php +++ b/tests/FieldTest.php @@ -89,10 +89,10 @@ public function testCastValue() public function testCastValueConstraintError() { try { - FieldsFactory::field($this->DESCRIPTOR_MAX)->castValue(''); + FieldsFactory::field($this->DESCRIPTOR_MAX)->castValue(null); $this->fail(); } catch (FieldValidationException $e) { - $this->assertEquals('id: field is required ()', $e->getMessage()); + $this->assertEquals('id: field is required (null)', $e->getMessage()); } } @@ -104,7 +104,7 @@ public function testDisableConstraints() ); $this->assertEquals( null, - FieldsFactory::field($this->DESCRIPTOR_MAX)->disableConstraints()->castValue('') + FieldsFactory::field($this->DESCRIPTOR_MAX)->disableConstraints()->castValue(null) ); } @@ -123,14 +123,14 @@ public function testCastValueNullMissingValues() public function testValidateValue() { $this->assertFieldValidateValue('', $this->DESCRIPTOR_MAX, '1'); - $this->assertFieldValidateValue('id: value must be numeric (string)', $this->DESCRIPTOR_MAX, 'string'); - $this->assertFieldValidateValue('id: field is required ()', $this->DESCRIPTOR_MAX, ''); + $this->assertFieldValidateValue('id: value must be numeric ("string")', $this->DESCRIPTOR_MAX, 'string'); + $this->assertFieldValidateValue('id: field is required (null)', $this->DESCRIPTOR_MAX, null); } public function testValidateValueDisableConstraints() { $this->assertEquals([], FieldsFactory::field($this->DESCRIPTOR_MIN)->disableConstraints()->validateValue('')); - $this->assertEquals([], FieldsFactory::field($this->DESCRIPTOR_MAX)->disableConstraints()->validateValue('')); + $this->assertEquals([], FieldsFactory::field($this->DESCRIPTOR_MAX)->disableConstraints()->validateValue(null)); } public function testStringMissingValues() @@ -160,11 +160,11 @@ public function testValidateValueRequired() $this->assertSchemaValidateValue('', $schema, 'none'); $this->assertSchemaValidateValue('', $schema, 'nil'); $this->assertSchemaValidateValue('', $schema, 'nan'); - $this->assertSchemaValidateValue('name: field is required ()', $schema, 'NA'); - $this->assertSchemaValidateValue('name: field is required ()', $schema, 'N/A'); + $this->assertSchemaValidateValue('name: field is required (null)', $schema, 'NA'); + $this->assertSchemaValidateValue('name: field is required (null)', $schema, 'N/A'); $this->assertSchemaValidateValue('', $schema, '-'); - $this->assertSchemaValidateValue('name: field is required ()', $schema, ''); - $this->assertSchemaValidateValue('name: field is required ()', $schema, null); + $this->assertSchemaValidateValue('name: field is required (null)', $schema, ''); + $this->assertSchemaValidateValue('name: field is required (null)', $schema, null); } public function testValidateValuePattern() @@ -176,7 +176,7 @@ public function testValidateValuePattern() ]; $this->assertFieldValidateValue('', $descriptor, '3'); $this->assertFieldValidateValue('', $descriptor, '321'); - $this->assertFieldValidateValue('name: value does not match pattern (123)', $descriptor, '123'); + $this->assertFieldValidateValue('name: value does not match pattern ("123")', $descriptor, '123'); } public function testValidateValueUnique() @@ -268,7 +268,7 @@ public function testValidateValueMinLength() $this->assertFieldValidateValue('', $descriptor, 'aaaa'); // null value passes (because field is not required) $this->assertFieldValidateValue('', $descriptor, null); - $this->assertFieldValidateValue('name: value is below minimum length (a)', $descriptor, 'a'); + $this->assertFieldValidateValue('name: value is below minimum length ("a")', $descriptor, 'a'); } public function testValidateValueMaxLength() @@ -282,7 +282,7 @@ public function testValidateValueMaxLength() $this->assertFieldValidateValue('', $descriptor, 'a'); $this->assertFieldValidateValue('', $descriptor, null); $this->assertFieldValidateValue('', $descriptor, ''); - $this->assertFieldValidateValue('name: value is above maximum length (aaa)', $descriptor, 'aaa'); + $this->assertFieldValidateValue('name: value is above maximum length ("aaa")', $descriptor, 'aaa'); } protected function assertFieldValidateValue($expectedErrors, $descriptor, $value) diff --git a/tests/FieldTypesTest.php b/tests/FieldTypesTest.php new file mode 100644 index 0000000..c163c70 --- /dev/null +++ b/tests/FieldTypesTest.php @@ -0,0 +1,523 @@ +assertFieldTestData('any', [ + ['default', 1, 1], + ['default', '1', '1'], + ['default', '3.14', '3.14'], + ['default', true, true], + ['default', '', ''], + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], + null, null, ], // any field has no empty value, so required without enum is meaningless + [(object) [ + 'format' => 'default', + 'constraints' => (object) ['required' => true, 'enum' => ['test', 1, false]], + ], null, self::ERROR], + [(object) [ + 'format' => 'default', + 'constraints' => (object) ['required' => true, 'enum' => ['test', 1, false]], + ], 'FOO', self::ERROR], + [(object) [ + 'format' => 'default', + 'constraints' => (object) ['required' => true, 'enum' => ['test', 1, false]], + ], false, false], + [(object) [ + 'format' => 'default', + 'constraints' => (object) ['required' => true, 'enum' => ['test', 1, null, false]], + ], null, null], + ]); + } + + public function testArray() + { + $this->assertFieldTestData('array', [ + ['default', [], []], + ['default', '[]', []], + ['default', ['key', 'value'], ['key', 'value']], + ['default', '["key", "value"]', ['key', 'value']], + ['default', (object) ['key' => 'value'], self::ERROR], + ['default', '{"key": "value"}', self::ERROR], + ['default', 'string', self::ERROR], + ['default', 1, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], + null, self::ERROR, ], + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], + [], [], ], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => [[1, 2], ['foo', 'bar']]], + ], [], self::ERROR], + [(object) [ + 'format' => 'default', 'constraints' => (object) [ + 'required' => true, + 'enum' => [[1, 2], ['foo', 'bar']], ], + ], '[1,2]', [1, 2]], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => [[1, 2], ['foo', 'bar']]], + ], [1, 2], [1, 2]], + // minLength / maxLength + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1]], [], self::ERROR], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1]], [1], [1]], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1]], [1, 2], [1, 2]], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1]], 'invalid', self::ERROR], + [(object) ['format' => 'default', 'constraints' => (object) ['maxLength' => 1]], [], []], + [(object) ['format' => 'default', 'constraints' => (object) ['maxLength' => 1]], [1], [1]], + [(object) ['format' => 'default', 'constraints' => (object) ['maxLength' => 1]], [1, 2], self::ERROR], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1, 'maxLength' => 1]], + [1, 2], self::ERROR, ], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1, 'maxLength' => 1]], + [1], [1], ], + [(object) ['format' => 'default', 'constraints' => (object) ['minLength' => 1, 'maxLength' => 1]], + [], self::ERROR, ], + ]); + } + + public function testBoolean() + { + $this->assertFieldTestData('boolean', [ + ['default', true, true], + [(object) ['format' => 'default', 'trueValues' => ['yes']], 'yes', true], + ['default', 'y', self::ERROR], + ['default', 'true', true], + ['default', 't', self::ERROR], + ['default', '1', true], + ['default', 'YES', self::ERROR], + ['default', 'Yes', self::ERROR], + ['default', false, false], + ['default', 'no', self::ERROR], + ['default', 'n', self::ERROR], + ['default', 'false', false], + [(object) ['format' => 'default', 'falseValues' => ['f']], 'f', false], + ['default', '0', false], + ['default', 'NO', self::ERROR], + ['default', 'No', self::ERROR], + ['default', 0, self::ERROR], + ['default', 1, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => [false]], + ], true, self::ERROR], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => [false]], + ], false, false], + ]); + } + + public function testDate() + { + $this->assertFieldTestData('date', [ + ['default', '2019-01-01', Carbon::create(2019, 1, 1, 0, 0, 0)], + ['default', '10th Jan 1969', self::ERROR], + ['default', 'invalid', self::ERROR], + ['default', true, self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + ['any', '2019-01-01', Carbon::create(2019, 1, 1, 0, 0, 0)], + ['any', '10th Jan 1969', Carbon::create(1969, 1, 10, 0, 0, 0)], + ['any', '10th Jan nineteen sixty nine', self::ERROR], + ['any', 'invalid', self::ERROR], + ['any', true, self::ERROR], + ['%d/%m/%y', '21/11/06', Carbon::create(2006, 11, 21, 0, 0, 0)], + ['%y/%m/%d', '21/11/06 16:30', self::ERROR], + ['%d/%m/%y', 'invalid', self::ERROR], + ['%d/%m/%y', true, self::ERROR], + ['%d/%m/%y', '', self::ERROR], // missingValues is handled at the schema level, + ['invalid', '21/11/06 16:30', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['2019-01-01']], + ], '2019-01-01', Carbon::create(2019, 1, 1, 0, 0, 0)], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['2019-01-01']], + ], '2019-01-02', self::ERROR], + ]); + } + + public function testDatetime() + { + $this->assertFieldTestData('datetime', [ + ['default', '2014-01-01T06:00:00Z', Carbon::create(2014, 1, 1, 6, 0, 0, 'UTC')], + ['default', 'Mon 1st Jan 2014 9 am', self::ERROR], + ['default', 'invalid', self::ERROR], + ['default', true, self::ERROR], + ['default', 'xxx-yy-zzTfo-ob:arZ', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + ['any', Carbon::create(2014, 1, 1, 6), Carbon::create(2014, 1, 1, 6, 0, 0, 'UTC')], + ['any', '10th Jan 1969 9 am', Carbon::create(1969, 1, 10, 9, 0, 0, 'UTC')], + ['any', 'invalid', self::ERROR], + ['any', true, self::ERROR], + ['%d/%m/%y %H:%M', '21/11/06 16:30', Carbon::create(2006, 11, 21, 16, 30, 0)], + ['%H:%M %d/%m/%y', '21/11/06 16:30', self::ERROR], + ['%d/%m/%y %H:%M', 'invalid', self::ERROR], + ['%d/%m/%y %H:%M', true, self::ERROR], + ['%d/%m/%y %H:%M', '', self::ERROR], // missingValues is handled at the schema level, + ['invalid', '21/11/06 16:30', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['2014-01-01T06:00:00Z']], + ], '2014-01-01T06:00:00Z', Carbon::create(2014, 1, 1, 6, 0, 0, 'UTC')], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['2014-01-01T06:00:00Z']], + ], '2014-01-01T06:01:00Z', self::ERROR], + ]); + } + + public function testDuration() + { + $this->assertFieldTestData('duration', [ + ['default', 'P1Y10M3DT5H11M7S', new CarbonInterval(1, 10, 0, 3, 5, 11, 7)], + ['default', 'P1Y', new CarbonInterval(1)], + ['default', 'P1M', new CarbonInterval(0, 1)], + ['default', 'P1M1Y', self::ERROR], + ['default', 'P-1Y', self::ERROR], + ['default', 'year', self::ERROR], + ['default', true, self::ERROR], + ['default', false, self::ERROR], + ['default', 1, self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + ['default', [], self::ERROR], + ['default', (object) [], self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['P1Y10M3DT5H11M7S']], + ], 'P1Y10M3DT5H11M7S', new CarbonInterval(1, 10, 0, 3, 5, 11, 7)], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['P1Y10M3DT5H11M7S']], + ], 'P1Y10M3DT5H11M8S', self::ERROR], + ]); + } + + public function testGeojson() + { + $this->assertFieldTestData('geojson', [ + [ + 'default', + (object) ['properties' => (object) ['Ã' => 'Ã'], 'type' => 'Feature', 'geometry' => null], + (object) ['properties' => (object) ['Ã' => 'Ã'], 'type' => 'Feature', 'geometry' => null], + ], + [ + 'default', + '{"geometry": null, "type": "Feature", "properties": {"\\u00c3": "\\u00c3"}}', + (object) ['properties' => (object) ['Ã' => 'Ã'], 'type' => 'Feature', 'geometry' => null], + ], + [ + 'default', + (object) ['coordinates' => [0, 0, 0], 'type' => 'Point'], + (object) ['coordinates' => [0, 0, 0], 'type' => 'Point'], + ], + ['default', 'string', self::ERROR], + ['default', 1, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + ['default', (object) [], self::ERROR], + ['default', '{}', self::ERROR], + [ + 'topojson', + (object) ['type' => 'LineString', 'arcs' => [42]], + (object) ['type' => 'LineString', 'arcs' => [42]], + ], + [ + 'topojson', + '{"type": "LineString", "arcs": [42]}', + (object) ['type' => 'LineString', 'arcs' => [42]], + ], + ['topojson', 'string', self::ERROR], + ['topojson', 1, self::ERROR], + ['topojson', '3.14', self::ERROR], + ['topojson', '', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['{"geometry": null, "type": "Feature", "properties": {"\\u00c3": "\\u00c3"}}']], + ], '{"geometry": null, "type": "Feature", "properties": {"\\u00c3": "\\u00c3"}}', (object) ['properties' => (object) ['Ã' => 'Ã'], 'type' => 'Feature', 'geometry' => null]], + [(object) [ + 'format' => 'default', 'constraints' => (object) ['enum' => ['{"geometry": null, "type": "Feature", "properties": {"\\u00c3": "\\u00c3"}}']], + ], '{"geometry": null, "type": "Feature", "properties": {"\\u00c3": "\\u00c4"}}', self::ERROR], + ]); + } + + public function testGeopoint() + { + $this->assertFieldTestData('geopoint', [ + ['default', [180, 90], self::ERROR], + ['default', [180, 90], self::ERROR], + ['default', '180,90', [180, 90]], + ['default', '180, -90', [180, -90]], + ['default', (object) ['lon' => 180, 'lat' => 90], self::ERROR], + ['default', '181,90', self::ERROR], + ['default', '0,91', self::ERROR], + ['default', 'string', self::ERROR], + ['default', 1, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + ['array', [180, 90], [180, 90]], + ['array', [180, 90], [180, 90]], + ['array', '[180, -90]', [180, -90]], + ['array', (object) ['lon' => 180, 'lat' => 90], self::ERROR], + ['array', [181, 90], self::ERROR], + ['array', [0, 91], self::ERROR], + ['array', '180,90', self::ERROR], + ['array', 'string', self::ERROR], + ['array', 1, self::ERROR], + ['array', '3.14', self::ERROR], + ['array', '', self::ERROR], // missingValues is handled at the schema level, + ['object', (object) ['lon' => 180, 'lat' => 90], [180, 90]], + ['object', '{"lon":180, "lat":90}', [180, 90]], + ['object', '{"lat":90, "lon":180, "foo": "bar"}', [180, 90]], + ['object', '[180, -90]', self::ERROR], + ['object', (object) ['lon' => 181, 'lat' => 90], self::ERROR], + ['object', (object) ['lon' => 180, 'lat' => -91], self::ERROR], + ['object', [180, -90], self::ERROR], + ['object', '180,90', self::ERROR], + ['object', 'string', self::ERROR], + ['object', 1, self::ERROR], + ['object', '3.14', self::ERROR], + ['object', '', self::ERROR], // missingValues is handled at the schema level, + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'array', 'constraints' => (object) ['enum' => ['[180, -90]']]], '[180, -90]', [180, -90]], + [(object) ['format' => 'array', 'constraints' => (object) ['enum' => ['[180, -90]']]], '[171, -90]', self::ERROR], + ]); + } + + public function testInteger() + { + $this->assertFieldTestData('integer', [ + ['default', 1, 1], + ['default', '1', 1], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [0, '1']]], '0', 0], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [0, '1']]], '2', self::ERROR], + ]); + } + + public function testNumber() + { + $this->assertFieldTestData('number', [ + [(object) ['format' => 'default'], 1, 1.0], + [(object) ['format' => 'default'], 1, 1.0], + [(object) ['format' => 'default'], 1.0, 1.0], + [(object) ['format' => 'default'], '1', 1.0], + [(object) ['format' => 'default'], '10.00', 10.0], + [(object) ['format' => 'default'], '10.50', 10.5], + [(object) ['format' => 'default'], '100%', 1.0], + [(object) ['format' => 'default'], '1000‰', self::ERROR], // spec only supports percent sign + [(object) ['format' => 'default'], '-1000', -1000.0], + [(object) ['format' => 'default', 'groupChar' => ','], '1,000', 1000.0], + [(object) ['format' => 'default', 'groupChar' => ','], '10,000.00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ','], '10,000,000.50', 10000000.5], + [(object) ['format' => 'default', 'groupChar' => '#'], '10#000.00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => '#'], '10#000#000.50', 10000000.5], + [(object) ['format' => 'default', 'groupChar' => '#'], '10.50', 10.5], + [(object) ['format' => 'default', 'groupChar' => '#'], '1#000', 1000.0], + [(object) ['format' => 'default', 'groupChar' => '#', 'decimalChar' => '@'], '10#000@00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => '#', 'decimalChar' => '@'], '10#000#000@50', 10000000.5], + [(object) ['format' => 'default', 'groupChar' => '#', 'decimalChar' => '@'], '10@50', 10.5], + [(object) ['format' => 'default', 'groupChar' => '#', 'decimalChar' => '@'], '1#000', 1000.0], + [(object) ['format' => 'default', 'groupChar' => ',', 'currency' => true], '10,000.00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ',', 'currency' => true], '10,000,000.00', 10000000.0], + [(object) ['format' => 'default', 'currency' => true], '$10000.00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ',', 'currency' => true], ' 10,000.00 €', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ' ', 'decimalChar' => ','], '10 000,00', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ' ', 'decimalChar' => ','], '10 000 000,00', 10000000.0], + [(object) ['format' => 'default', 'groupChar' => ' ', 'decimalChar' => ',', 'currency' => true], '10000,00 ₪', 10000.0], + [(object) ['format' => 'default', 'groupChar' => ' ', 'decimalChar' => ',', 'currency' => true], ' 10 000,00 £', 10000.0], + [(object) ['format' => 'default'], '10,000a.00', self::ERROR], + [(object) ['format' => 'default'], '10+000.00', self::ERROR], + [(object) ['format' => 'default'], '$10:000.00', self::ERROR], + [(object) ['format' => 'default'], 'string', self::ERROR], + [(object) ['format' => 'default'], '', self::ERROR], // missingValues is handled at the schema level, + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [0.5, '1.6']]], '1.6', 1.6], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [0.5, '1.6']]], '0.55', self::ERROR], + ]); + } + + public function testObject() + { + $this->assertFieldTestData('object', [ + ['default', (object) [], (object) []], + ['default', '{}', (object) []], + ['default', (object) ['key' => 'value'], (object) ['key' => 'value']], + ['default', '{"key": "value"}', (object) ['key' => 'value']], + ['default', '["key", "value"]', self::ERROR], + ['default', 'string', self::ERROR], + ['default', 1, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], // missingValues is handled at the schema level, + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['{"foo":"bar"}']]], '{"foo":"bar"}', (object) ['foo' => 'bar']], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['{"foo":"bar"}']]], '{"foox":"bar"}', self::ERROR], + ]); + } + + public function testString() + { + $this->assertFieldTestData('string', [ + // format , input value , expected cast value, (optional) expected infer type + ['default', 'string', 'string'], + ['default', '', ''], + ['default', 0, '0'], + ['uri', 'http://google.com', 'http://google.com'], + ['uri', 'string', self::ERROR], + ['uri', '', self::ERROR], + ['uri', 0, self::ERROR], + ['email', 'name@gmail.com', 'name@gmail.com'], + ['email', 'http://google.com', self::ERROR], + ['email', 'string', self::ERROR], + ['email', '', self::ERROR], + ['email', 0, self::ERROR], + ['binary', 'dGVzdA==', 'dGVzdA=='], + ['binary', '', ''], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], '', ''], + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['foobar']]], 'foobar', 'foobar'], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['foobar']]], 'foobarx', self::ERROR], + ]); + } + + public function testTime() + { + $this->assertFieldTestData('time', [ + // format , input value , expected cast value, (optional) expected infer type + ['default', '06:00:00', [6, 0, 0]], + ['default', '3 am', self::ERROR], + ['default', '3.00', self::ERROR], + ['default', 'invalid', self::ERROR], + ['default', true, self::ERROR], + ['default', '', self::ERROR], + ['any', '06:00:00', [6, 0, 0]], + ['any', '3:00 am', [3, 0, 0]], + ['any', 'some night', self::ERROR], + ['any', 'invalid', self::ERROR], + ['any', true, self::ERROR], + ['%H:%M', '06:00', [6, 0, 0]], + ['%H:%M', '3:00 am', self::ERROR], + ['%H:%M', 'some night', self::ERROR], + ['%H:%M', 'invalid', self::ERROR], + ['%H:%M', true, self::ERROR], + ['%H:%M', '', self::ERROR], + ['invalid', '', self::ERROR], + ['default', '06:35:21', [6, 35, 21]], + ['any', '06:35:21', [6, 35, 21]], + ['any', '06:35', [6, 35, 0]], + ['any', '6', self::ERROR], + ['any', '3 am', [3, 0, 0]], + ['%H:%M:%S', '06:35:21', [6, 35, 21]], + ['%H:%M', '06:35:21', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['06:00:00']]], '06:00:00', [6, 0, 0]], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => ['06:00:00']]], '06:01:00', self::ERROR], + ]); + } + + public function testYear() + { + $this->assertFieldTestData('year', [ + // format , input value , expected cast value, (optional) expected infer type + ['default', 2000, 2000], + ['default', '2000', 2000], + ['default', 20000, 20000], + ['default', '3.14', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [2000]]], '2000', 2000], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [2000]]], '2001', self::ERROR], + ]); + } + + public function testYearMonth() + { + $this->assertFieldTestData('yearmonth', [ + // format , input value , expected cast value, (optional) expected infer type + ['default', [2000, 10], [2000, 10]], + ['default', [2000, 10], [2000, 10], 'string'], + ['default', '2000-10', [2000, 10], 'string'], + ['default', [2000, 10, 20], self::ERROR], + ['default', '2000-13-20', self::ERROR], + ['default', '2000-13', self::ERROR], + ['default', '2000-0', self::ERROR], + ['default', '13', self::ERROR], + ['default', -10, self::ERROR], + ['default', 20, self::ERROR], + ['default', '3.14', self::ERROR], + ['default', '', self::ERROR], + // required + [(object) ['format' => 'default', 'constraints' => (object) ['required' => true]], null, self::ERROR], + // enum + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [[2000, 10]]]], '2000-10', [2000, 10]], + [(object) ['format' => 'default', 'constraints' => (object) ['enum' => [[2000, 10]]]], '2000-11', self::ERROR], + ]); + } + + protected function assertFieldTestData($fieldType, $testData) + { + foreach ($testData as $testLine) { + if (!isset($testLine[3])) { + $testLine[3] = null; + } + list($format, $inputValue, $expectedCastValue, $expectedInferType) = $testLine; + if (is_object($format)) { + $descriptor = $format; + $descriptor->type = $fieldType; + } else { + $descriptor = (object) ['type' => $fieldType, 'format' => $format]; + } + $assertMessage = 'descriptor='.json_encode($descriptor).", input='".json_encode($inputValue)."', expected='".json_encode($expectedCastValue)."'"; + if (!isset($descriptor->name)) { + $descriptor->name = 'unknown'; + } + $field = FieldsFactory::field($descriptor); + if ($expectedCastValue === self::ERROR) { + $this->assertTrue(count($field->validateValue($inputValue)) > 0, $assertMessage); + } elseif (is_object($expectedCastValue)) { + $this->assertEquals($expectedCastValue, $field->castValue($inputValue), $assertMessage); + } else { + $this->assertSame($expectedCastValue, $field->castValue($inputValue), $assertMessage); + } + $inferredType = FieldsFactory::infer($inputValue)->type(); + if ($expectedInferType) { + $this->assertSame($expectedInferType, $inferredType, $assertMessage); + } + } + } +} diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index cff3a1c..46b9c34 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -221,7 +221,7 @@ public function testValidateRow() ], ]); $this->assertEquals( - 'id: value must be numeric (foobar), email: value is not a valid email (bad.email)', + 'id: value must be numeric ("foobar"), email: value is not a valid email ("bad.email")', SchemaValidationError::getErrorMessages( $schema->validateRow(['id' => 'foobar', 'email' => 'bad.email']) ) @@ -344,7 +344,7 @@ public function testCastRowTooLong() public function testCastRowWrongType() { $this->assertCastRowException( - 'height: value must be numeric (notdecimal)', + 'height: value must be numeric ("notdecimal")', $this->maxDescriptorJson, ['id' => 'string', 'height' => 'notdecimal', 'age' => '1', 'name' => 'string', 'additional' => 'string'] ); @@ -353,7 +353,7 @@ public function testCastRowWrongType() public function testCastRowWrongTypeMultipleErrors() { $this->assertCastRowException( - 'height: value must be numeric (notdecimal), age: value must be an integer (10.6)', + 'height: value must be numeric ("notdecimal"), age: value must be an integer ("10.6")', $this->maxDescriptorJson, ['id' => 'string', 'height' => 'notdecimal', 'age' => '10.6', 'name' => 'string', 'additional' => 'string'] ); diff --git a/tests/TableTest.php b/tests/TableTest.php index 5c5283b..4a8d1ef 100644 --- a/tests/TableTest.php +++ b/tests/TableTest.php @@ -94,7 +94,7 @@ public function testMismatchBetweenSchemaAndHeaders() public function testInferSchemaFailsAfterLock() { - $this->assertInferSchemaException('id: value must be an integer (3.5)', [ + $this->assertInferSchemaException('id: value must be an integer ("3.5")', [ ['id' => '1', 'email' => 'test1_example_com'], ['id' => '2', 'email' => 'test2@example.com'], ['id' => '3.5', 'email' => 'test3@example.com'], @@ -127,7 +127,7 @@ public function testInferSchemaEmailFormat() ]; $this->assertInferSchemaException( // lock after 1 row, so that locked schema will be string with email format - 'email: value is not a valid email (invalid_email)', $inputRows, 1 + 'email: value is not a valid email ("invalid_email")', $inputRows, 1 ); // try again with locking after 2nd row - no exception // (value is not cast, because it's still a string)