Skip to content

Commit

Permalink
[WIP] add field types (frictionlessdata#24)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
OriHoch committed Jul 6, 2017
1 parent fbe0491 commit cd378b9
Show file tree
Hide file tree
Showing 23 changed files with 1,156 additions and 67 deletions.
4 changes: 3 additions & 1 deletion composer.json
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/Fields/AnyField.php
@@ -0,0 +1,21 @@
<?php

namespace frictionlessdata\tableschema\Fields;

class AnyField extends BaseField
{
public static function type()
{
return 'any';
}

protected function validateCastValue($val)
{
return $val;
}

protected function isEmptyValue($val)
{
return false;
}
}
31 changes: 31 additions & 0 deletions src/Fields/ArrayField.php
@@ -0,0 +1,31 @@
<?php

namespace frictionlessdata\tableschema\Fields;

class ArrayField extends BaseField
{
protected function validateCastValue($val)
{
if (is_array($val)) {
return $val;
} elseif (is_string($val)) {
try {
$val = json_decode($val);
} catch (\Exception $e) {
throw $this->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';
}
}
65 changes: 42 additions & 23 deletions src/Fields/BaseField.php
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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,
]),
]);
}
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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();
Expand Down
38 changes: 38 additions & 0 deletions src/Fields/BooleanField.php
@@ -0,0 +1,38 @@
<?php

namespace frictionlessdata\tableschema\Fields;

class BooleanField extends BaseField
{
protected function validateCastValue($val)
{
if (isset($this->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';
}
}
46 changes: 46 additions & 0 deletions src/Fields/DateField.php
@@ -0,0 +1,46 @@
<?php

namespace frictionlessdata\tableschema\Fields;

use Carbon\Carbon;

class DateField extends BaseField
{
protected function validateCastValue($val)
{
switch ($this->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';
}
}
51 changes: 51 additions & 0 deletions src/Fields/DatetimeField.php
@@ -0,0 +1,51 @@
<?php

namespace frictionlessdata\tableschema\Fields;

use Carbon\Carbon;

class DatetimeField extends BaseField
{
protected function validateCastValue($val)
{
$val = trim($val);
switch ($this->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';
}
}
46 changes: 46 additions & 0 deletions src/Fields/DurationField.php
@@ -0,0 +1,46 @@
<?php

namespace frictionlessdata\tableschema\Fields;

use Carbon\CarbonInterval;

class DurationField extends BaseField
{
protected function validateCastValue($val)
{
if (!is_string($val)) {
throw $this->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;
}
}
21 changes: 19 additions & 2 deletions src/Fields/FieldsFactory.php
Expand Up @@ -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',
];

/**
Expand Down

0 comments on commit cd378b9

Please sign in to comment.