Skip to content
Snippets Groups Projects
Commit 86eb7306 authored by Rich Lott / Artful Robot's avatar Rich Lott / Artful Robot
Browse files

Add coersion to ArraySchema

parent 0d47faa4
Branches
Tags
No related merge requests found
......@@ -65,18 +65,22 @@ class ArraySchema {
public $schema;
/**
* If true, AND if a type check includes integerString *after* integer,
* then cast the value to an int.
* If set true, then attempt to coerce values before validating them.
*
* @param bool
*/
protected $coerceToInt = FALSE;
protected $coerce = FALSE;
/**
* @param bool
*/
protected $removeUnexpectedKeys = FALSE;
/**
* @param bool
*/
protected $removeInvalid = FALSE;
public static function getOwnSchema(): array {
return [
'//' => [ 'MUST', 'array',
......@@ -117,7 +121,7 @@ class ArraySchema {
* - array list of input keys describing the path to the invalid item.
* - the value
*/
public function getErrors(array $data) {
public function getErrors(array &$data) {
return $this->matches($data, [], $this->schema);
}
......@@ -132,8 +136,22 @@ class ArraySchema {
/**
* @return static
*/
public function setCoerceToInt(bool $y = TRUE) {
$this->coerceToInt = $y;
public function setRemoveInvalid(bool $remove = TRUE) {
$this->removeInvalid = $remove;
return $this;
}
/**
* @return static
*/
public function setCoerce(bool $coerce = TRUE, ?bool $removeUnexpectedKeys = NULL, ?bool $removeInvalid = NULL) {
$this->coerce = $coerce;
if (is_bool($removeUnexpectedKeys)) {
$this->setRemoveUnexpectedKeys($removeUnexpectedKeys);
}
if (is_bool($removeInvalid)) {
$this->setRemoveInvalid($removeInvalid);
}
return $this;
}
......@@ -190,61 +208,58 @@ class ArraySchema {
return $allErrors;
}
protected function valueMatch(string $keyMatch, array $schema, string $key, &$value, array $ancestry) {
$typeOK = FALSE;
/**
* @return array
* Each entry is an error, which itself is an array [string message, array ancestry, value]
* An empty return value means no errors.
*/
protected function matchesExpectedType(array $expectedTypes, &$value, array $schema, array $ancestry): array {
$actualType = gettype($value);
$expectedTypes = $schema[1];
if ($expectedTypes) {
foreach (explode('|', $expectedTypes) as $type) {
if (in_array($actualType, ['boolean', 'integer', 'double', 'string', 'array'])
&& $actualType === $type) {
$typeOK = TRUE;
break;
}
if ($actualType === 'NULL' && $type === 'NULL') {
// all ok, return early.
return [];
}
if ($type === 'integerString' && $actualType === 'string' && strval(intval($value)) === $value) {
$typeOK = TRUE;
if ($this->coerceToInt && preg_match('/integer[|].*integerString/', $expectedTypes)) {
$value = intval($value);
}
break;
}
if ($type === 'numeric' && is_numeric($value)) {
$typeOK = TRUE;
break;
}
if ($type === 'empty' && empty($value)) {
$typeOK = TRUE;
break;
}
$typeIsOK = FALSE;
foreach ($expectedTypes as $type) {
if ($actualType === 'NULL' && $type === 'NULL') {
return []; // special case, we don't do any other assertions if we have NULL and we're allowed NULL
}
if ($actualType === $type
&& in_array($actualType, ['boolean', 'integer', 'double', 'string', 'array'])
) {
$typeIsOK = TRUE;
break;
}
if (!$typeOK) {
return [["Expected $expectedTypes but got $actualType", $ancestry, $value]];
if ($type === 'numeric' && is_numeric($value)) {
$typeIsOK = TRUE;
break;
}
if ($type === 'empty' && empty($value)) {
$typeIsOK = TRUE;
break;
}
}
$actualTypeIsArray = $actualType === 'array';
if ($actualTypeIsArray && is_array($schema['schema'] ?? NULL)) {
// Recursive.
return $this->matches($value, $ancestry, $schema['schema']);
if (!$typeIsOK) {
return [["Expected " . implode("|", $expectedTypes) . " but got $actualType", $ancestry, $value]];
}
if ($actualTypeIsArray && is_array($schema['recurse'] ?? NULL)) {
// Recursive schema.
$arraySchema = $this->schema;
foreach ($schema['recurse'] as $key) {
$arraySchema = $arraySchema[$key] ?? NULL;
// By this point we know we have a valid, non-null type.
if ($actualType === 'array') {
// Arrays may have further schema to pass...
if (is_array($schema['schema'] ?? NULL)) {
// Recursive.
return $this->matches($value, $ancestry, $schema['schema']);
}
if (!is_array($arraySchema)) {
throw new \RuntimeException("Invalid recurse expression in schema: " . json_encode($schema['recurse']));
if (is_array($schema['recurse'] ?? NULL)) {
// Recursive schema.
$arraySchema = $this->schema;
foreach ($schema['recurse'] as $key) {
$arraySchema = $arraySchema[$key] ?? NULL;
}
if (!is_array($arraySchema)) {
throw new \RuntimeException("Invalid recurse expression in schema: " . json_encode($schema['recurse']));
}
return $this->matches($value, $ancestry, $arraySchema);
}
$schemaRequiresNotArray = array_keys(array_intersect_key($schema, array_flip(['oneOf', 'oneOfStrictly', 'regex', 'gt', 'gte', 'lt', 'lte'])));
if (count($schemaRequiresNotArray)) {
return [["Got array which can't be compared to schema " . implode(', ', $schemaRequiresNotArray), $ancestry, $value]];
}
return $this->matches($value, $ancestry, $arraySchema);
}
$schemaRequiresNotArray = array_keys(array_intersect_key($schema, array_flip(['oneOf', 'oneOfStrictly', 'regex', 'gt', 'gte', 'lt', 'lte'])));
if (count($schemaRequiresNotArray) && $actualTypeIsArray) {
return [["Got array which can't be compared to schema " . implode(', ', $schemaRequiresNotArray), $ancestry, $value]];
}
$enum = $schema['oneOf'] ?? $schema['oneOfStrictly'] ?? NULL;
......@@ -264,20 +279,84 @@ class ArraySchema {
}
if (!empty($schema['gt']) && !($value > $schema['gt'])) {
return [["Expected gt $schema[gt]", $ancestry, $value]];
return [["Expected gt $schema[gt]", $ancestry, $value]];
}
if (!empty($schema['gte']) && !($value >= $schema['gte'])) {
return [["Expected gte $schema[gte]", $ancestry, $value]];
return [["Expected gte $schema[gte]", $ancestry, $value]];
}
if (!empty($schema['lt']) && !($value < $schema['lt'])) {
return [["Expected lt $schema[lt]", $ancestry, $value]];
return [["Expected lt $schema[lt]", $ancestry, $value]];
}
if (!empty($schema['lte']) && !($value <= $schema['lte'])) {
return [["Expected lte $schema[lte]", $ancestry, $value]];
return [["Expected lte $schema[lte]", $ancestry, $value]];
}
return [];
}
protected function valueMatch(string $keyMatch, array $schema, string $key, &$value, array $ancestry) {
$expectedTypes = $schema[1];
if ($expectedTypes === NULL) {
// e.g. schema says a key must/may exist, but doesn't specify anything about the contents.
return [];
}
$expectedTypesArray = explode('|', $expectedTypes);
$errors = $this->matchesExpectedType($expectedTypesArray, $value, $schema, $ancestry);
if ($errors && $this->coerce && $this->coerceValue($expectedTypesArray, $keyMatch, $schema, $key, $value, $ancestry)) {
// We fixed the type, but check again for other conditions (gt, lte, oneOf etc.)
$errors = $this->matchesExpectedType($expectedTypesArray, $value, $schema, $ancestry);
}
if ($errors && $this->removeInvalid) {
// @todo
}
return $errors;
}
/**
* @return bool
* TRUE means we coerced the value
*/
protected function coerceValue(array $expectedTypesArray, string $keyMatch, array $schema, string $key, &$value, array $ancestry): bool {
$actualType = gettype($value);
if (in_array($actualType, ['array', 'object'])) {
// These types cannot be coerced into other types.
return FALSE;
}
// Find the first type we can cast to.
foreach ($expectedTypesArray as $acceptableType) {
if ($acceptableType === 'boolean') {
if (in_array($actualType, ['integer', 'string', 'double', 'NULL'])) {
$value = (bool) $value;
return TRUE;
}
}
elseif ($acceptableType === 'integer') {
if (in_array($actualType, ['bool', 'double', 'NULL'])
|| ($actualType === 'string' && strval(intval($value)) === rtrim($value, '.0'))
) {
$value = (int) $value;
return TRUE;
}
}
elseif ($acceptableType === 'double') {
if (in_array($actualType, ['bool', 'integer', 'NULL'])
|| ($actualType === 'string' && is_numeric($value))
) {
$value = (double) $value;
return TRUE;
}
}
elseif ($acceptableType === 'string') {
$value = (string) $value;
return TRUE;
}
elseif ($acceptableType === 'NULL') {
$value = NULL;
return TRUE;
}
}
return FALSE;
}
public function formatErrorsAsString(array $errors): string {
$messages = [];
foreach ($errors as $error) {
......
......@@ -38,9 +38,20 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
protected function runTests(string $setDescr, ArraySchema $a, array $casesAndErrorCounts) {
// print "\n## $setDescr ##\n\n";
foreach ($casesAndErrorCounts as $i => [$input, $errorCount]) {
$errors = $a->getErrors($input);
$this->assertCount($errorCount, $errors, "$setDescr #$i Failed with input " . json_encode($input, JSON_UNESCAPED_SLASHES));
foreach ($casesAndErrorCounts as $i => $c) {
if ("$setDescr$i" === 'coerce to string3') {
$x=1;
}
if (count($c) < 3) {
$c[] = NULL;
}
[$input, $errorCount, $result] = $c;
$x = $input;
$errors = $a->getErrors($x);
$this->assertCount($errorCount, $errors, "$setDescr #$i Failed with input " . json_encode($input, JSON_UNESCAPED_SLASHES) . "\n" . json_encode($errors));
if (!$errors && $result !== NULL) {
$this->assertSame($result, $x, "$setDescr #$i Failed with input " . json_encode($input, JSON_UNESCAPED_SLASHES) . "\n" . json_encode($errors));
}
}
}
......@@ -52,7 +63,6 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
'z' => ['MAY', 'string|NULL'],
'e' => ['MAY', 'string', 'oneOf' => ['aye', 'bee']],
'b' => ['MAY', 'integer', 'gt' => 2, 'lte' => '5'],
'n1' => ['MAY', 'integerString'],
'n2' => ['MAY', 'numeric'],
]),
[
......@@ -69,10 +79,6 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
[ ['x' => 123, 'b' => 3], 0 ],
[ ['x' => 123, 'b' => 5], 0 ],
[ ['x' => 123, 'b' => 6], 1 ],
[ ['x' => 123, 'n1' => '123'], 0],
[ ['x' => 123, 'n1' => '1.23'], 1],
[ ['x' => 123, 'n1' => 'not a number'], 1],
[ ['x' => 123, 'n1' => 123], 1],
[ ['x' => 123, 'n2' => 123], 0],
[ ['x' => 123, 'n2' => '123'], 0],
[ ['x' => 123, 'n2' => '123.23'], 0],
......@@ -104,6 +110,84 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
);
}
public function testCoerce() {
$wanted = ['i' => '123' ];
$this->runTests('coerce to string',
(new ArraySchema(['i' => ['MUST', 'string']]))->setCoerce(),
[
[ $wanted, 0 ],
[ ['i' => 123 ], 0, $wanted ],
[ ['i' => (double) 123.0 ], 0, $wanted ],
[ ['i' => (double) 123.1 ], 0, ['i' => '123.1'] ],
[ ['i' => '123.0' ], 0, ['i' => '123.0'] ], // left alone
[ ['i' => false ], 0, ['i' => ''] ],
[ ['i' => true ], 0, ['i' => '1'] ],
[ ['i' => NULL ], 0, ['i' => ''] ],
[ ['i' => [] ], 1],
],
);
$wanted = ['i' => 123 ];
$this->runTests('coerce',
(new ArraySchema(['i' => ['MUST', 'integer']]))->setCoerce(),
[
[ $wanted, 0 ],
[ ['i' => '123' ], 0, $wanted ],
[ ['i' => (double) 123.0 ], 0, $wanted ],
[ ['i' => (double) 123.1 ], 0, $wanted ],
[ ['i' => '123.0' ], 0, $wanted ], // Put up with .000
[ ['i' => '123.1' ], 1, $wanted ], // If we want an int, we'll put up with it in a string, but we're not having decimals.
[ ['i' => '234.1' ], 1, $wanted ], // wrong number completely.
[ ['i' => [234] ], 1], // can't cast arrays.
[ ['i' => NULL ], 0, ['i' => 0]], // null gets cast to zero
],
);
$wanted = ['i' => 123.1 ];
$this->runTests('coerce to double',
(new ArraySchema(['i' => ['MUST', 'double']]))->setCoerce(),
[
[ $wanted, 0, $wanted ],
[ ['i' => '123.1' ], 0, $wanted ],
[ ['i' => false ], 1],
],
);
$this->runTests('coerce to boolean',
(new ArraySchema(['i' => ['MUST', 'boolean']]))->setCoerce(),
[
[ ['i' => false], 0, ['i' => false]],
[ ['i' => null], 0, ['i' => false]],
[ ['i' => ''], 0, ['i' => false]],
[ ['i' => 0], 0, ['i' => false]],
[ ['i' => true], 0, ['i' => true]],
[ ['i' => 1], 0, ['i' => true]],
[ ['i' => 100.2], 0, ['i' => true]],
[ ['i' => 'hello'], 0, ['i' => true]],
[ ['i' => '1'], 0, ['i' => true]],
[ ['i' => ['1']], 1],
],
);
$this->runTests('trial and error coerceions',
(new ArraySchema(['i' => ['MUST', 'boolean|integer|string']]))->setCoerce(),
[
[ ['i' => false], 0, ['i' => false]],
[ ['i' => '123'], 0, ['i' => '123']], // '123' should not be coerced since strings are valid
[ ['i' => 1.23], 0, ['i' => true]], // 1.23 is not valid, but can be cast to boolean
],
);
$this->runTests('coerce to with NULL first',
(new ArraySchema(['i' => ['MUST', 'NULL|integer']]))->setCoerce(),
[
[ ['i' => false], 0, ['i' => NULL]], // bools are not null/int, so cast to NULL
[ ['i' => 'hello'], 0, ['i' => NULL]], // strings are not null/int, so cast to NULL
],
);
}
// @todo test coersion and removing unrecognised keys
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment