From 7af942dcb7bc9341609256dccbaa919b67f571ee Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Sun, 22 Feb 2026 21:50:50 +0100 Subject: [PATCH 1/2] object-constructor-schema --- src/Schema/ObjectConstructorSchema.php | 105 +++++++ .../Schema/ObjectConstructorSchemaTest.php | 277 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/Schema/ObjectConstructorSchema.php create mode 100644 tests/Unit/Schema/ObjectConstructorSchemaTest.php diff --git a/src/Schema/ObjectConstructorSchema.php b/src/Schema/ObjectConstructorSchema.php new file mode 100644 index 0000000..542c757 --- /dev/null +++ b/src/Schema/ObjectConstructorSchema.php @@ -0,0 +1,105 @@ + $fieldToSchema + * @param class-string $classname + */ + public function __construct(array $fieldToSchema, private string $classname) + { + try { + $reflectionClass = new \ReflectionClass($this->classname); + } catch (\ReflectionException) { + throw new \InvalidArgumentException('Class "'.$classname.'" does not exist or cannot be used for reflection'); + } + + try { + $constructorReflectionMethod = $reflectionClass->getMethod('__construct'); + } catch (\ReflectionException) { + throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method'); + } + + $parameterFieldToSchema = []; + + /** @var list $missingFieldToSchema */ + $missingFieldToSchema = []; + foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) { + $name = $parameterReflection->getName(); + + if (isset($fieldToSchema[$name])) { + $parameterFieldToSchema[$name] = $fieldToSchema[$name]; + + unset($fieldToSchema[$name]); + } elseif (!$parameterReflection->isOptional()) { + $missingFieldToSchema[] = $name; + } + } + + if ([] !== $missingFieldToSchema) { + throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $missingFieldToSchema).'"'); + } + + if ([] !== $fieldToSchema) { + throw new \InvalidArgumentException('Additional fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', array_keys($fieldToSchema)).'"'); + } + + $this->typeErrorPattern = \sprintf('/%s::__construct\(\): Argument #(\d+) \(([^)]+)\) must be of type ([^ ]+), ([^ ]+) given/', preg_quote($this->classname, '/')); + + parent::__construct($parameterFieldToSchema); + } + + /** + * @param array $input + */ + protected function parseFields(array $input, Errors $childrenErrors): ?object + { + $parameters = []; + + foreach ($this->getFieldToSchema() as $fieldName => $fieldSchema) { + try { + if ($this->skip($input, $fieldName)) { + continue; + } + + $parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null); + } catch (ErrorsException $e) { + $childrenErrors->add($e->errors, $fieldName); + } + } + + try { + return new ($this->classname)(...$parameters); + } catch (\TypeError $e) { + $matches = []; + + if (1 === preg_match($this->typeErrorPattern, $e->getMessage(), $matches)) { + throw new ErrorsException( + new Error( + self::ERROR_PARAMETER_TYPE_CODE, + self::ERROR_PARAMETER_TYPE_TEMPLATE, + ['index' => $matches[1], 'name' => $matches[2], 'type' => $matches[3], 'given' => $matches[4]] + ) + ); + } + + throw $e; + } + } +} diff --git a/tests/Unit/Schema/ObjectConstructorSchemaTest.php b/tests/Unit/Schema/ObjectConstructorSchemaTest.php new file mode 100644 index 0000000..d3b6534 --- /dev/null +++ b/tests/Unit/Schema/ObjectConstructorSchemaTest.php @@ -0,0 +1,277 @@ + $this->field1, + 'field2' => $this->field2, + 'field3' => $this->field3, + ]; + } +} + +final class ObjectConstructorThrowingTypeErrorDemo +{ + public function __construct( + public readonly string $field1, + ) { + throw new \TypeError('some unrelated type error'); + } +} + +/** + * @covers \Chubbyphp\Parsing\Schema\ObjectConstructorSchema + * + * @internal + */ +final class ObjectConstructorSchemaTest extends TestCase +{ + public function testImmutability(): void + { + $schema = new ObjectConstructorSchema(['field1' => new StringSchema(), 'field2' => new IntSchema(), 'field3' => new FloatSchema()], ObjectConstructorDemo::class); + + self::assertNotSame($schema, $schema->nullable()); + self::assertNotSame($schema, $schema->nullable(false)); + self::assertNotSame($schema, $schema->default([])); + self::assertNotSame($schema, $schema->preParse(static fn (mixed $input) => $input)); + self::assertNotSame($schema, $schema->postParse(static fn (\stdClass $output) => $output)); + self::assertNotSame($schema, $schema->catch(static fn (\stdClass $output, ErrorsException $e) => $output)); + + self::assertNotSame($schema, $schema->strict()); + self::assertNotSame($schema, $schema->optional([])); + } + + public function testConstructWithClassname(): void + { + try { + new ObjectConstructorSchema([], 'UnknownClass'); + + throw new \Exception('code should not be reached'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame( + 'Class "UnknownClass" does not exist or cannot be used for reflection', + $invalidArgumentException->getMessage() + ); + } + } + + public function testConstructWithClassNotHavingAConstructor(): void + { + try { + new ObjectConstructorSchema([], \stdClass::class); + + throw new \Exception('code should not be reached'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame( + 'Class "'.\stdClass::class.'" does not have a __construct method', + $invalidArgumentException->getMessage() + ); + } + } + + public function testConstructWithMissingFieldSchema(): void + { + try { + new ObjectConstructorSchema([], ObjectConstructorDemo::class); + + throw new \Exception('code should not be reached'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame( + 'Missing fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field1", "field2"', + $invalidArgumentException->getMessage() + ); + } + } + + public function testConstructWithAdditionalFieldSchema(): void + { + try { + new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + 'field4' => new FloatSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + throw new \Exception('code should not be reached'); + } catch (\InvalidArgumentException $invalidArgumentException) { + self::assertSame( + 'Additional fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field4"', + $invalidArgumentException->getMessage() + ); + } + } + + public function testSuccessWithAllParameters(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + /** @var ObjectConstructorDemo $object */ + $object = $schema->parse($input); + + self::assertInstanceOf(ObjectConstructorDemo::class, $object); + + self::assertSame($input, $object->jsonSerialize()); + } + + public function testSuccessWithAllParametersOptionalConsidered(): void + { + $input = ['field1' => 'test', 'field2' => 5]; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + /** @var ObjectConstructorDemo $object */ + $object = $schema->parse($input); + + self::assertInstanceOf(ObjectConstructorDemo::class, $object); + + self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); + } + + public function testSuccessWithRequiredParameters(): void + { + $input = ['field1' => 'test', 'field2' => 5]; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + /** @var ObjectConstructorDemo $object */ + $object = $schema->parse($input); + + self::assertInstanceOf(ObjectConstructorDemo::class, $object); + + self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); + } + + public function testFailedWithInvalidValue(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (ErrorsException $errorsException) { + self::assertSame([ + [ + 'path' => 'field3', + 'error' => [ + 'code' => 'float.type', + 'template' => 'Type should be "float", {{given}} given', + 'variables' => [ + 'given' => 'string', + ], + ], + ], + ], $errorsException->errors->jsonSerialize()); + } + } + + public function testFailedWithInvalidValueNotCatchedByFieldSchema(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new StringSchema(), + ], ObjectConstructorDemo::class)->optional(['field3']); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (ErrorsException $errorsException) { + self::assertSame([ + [ + 'path' => '', + 'error' => [ + 'code' => 'object.parameterType', + 'template' => 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given', + 'variables' => [ + 'index' => '3', + 'name' => '$field3', + 'type' => '?float', + 'given' => 'string', + ], + ], + ], + ], $errorsException->errors->jsonSerialize()); + } + } + + public function testFailedWithUnknownException(): void + { + $exception = new \Exception('unknown'); + + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema()->postParse(static fn () => throw $exception), + ], ObjectConstructorDemo::class)->optional(['field3']); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (\Exception $e) { + self::assertSame($exception, $e); + } + } + + public function testFailedWithTypeErrorNotMatchingPattern(): void + { + $input = ['field1' => 'test']; + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + ], ObjectConstructorThrowingTypeErrorDemo::class); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (\TypeError $e) { + self::assertSame('some unrelated type error', $e->getMessage()); + } + } +} From 7d3800cf66a9640c446dcb8a247e4ca77c169dd1 Mon Sep 17 00:00:00 2001 From: Dominik Zogg Date: Mon, 23 Feb 2026 21:15:10 +0100 Subject: [PATCH 2/2] optimize schema --- src/Parser.php | 4 +- src/Schema/ObjectConstructorSchema.php | 105 ------- src/Schema/ObjectSchema.php | 19 +- tests/Unit/ParserTest.php | 64 +++- .../Schema/ObjectConstructorSchemaTest.php | 277 ------------------ tests/Unit/Schema/ObjectSchemaTest.php | 79 ++++- 6 files changed, 148 insertions(+), 400 deletions(-) delete mode 100644 src/Schema/ObjectConstructorSchema.php delete mode 100644 tests/Unit/Schema/ObjectConstructorSchemaTest.php diff --git a/src/Parser.php b/src/Parser.php index 839db6b..0a0c963 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -101,9 +101,9 @@ public function literal(bool|float|int|string $literal): LiteralSchema * @param array $fieldNameToSchema * @param class-string $classname */ - public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema + public function object(array $fieldNameToSchema, string $classname = \stdClass::class, bool $construct = false): ObjectSchema { - return new ObjectSchema($fieldNameToSchema, $classname); + return new ObjectSchema($fieldNameToSchema, $classname, $construct); } public function record(SchemaInterface $fieldSchema): RecordSchema diff --git a/src/Schema/ObjectConstructorSchema.php b/src/Schema/ObjectConstructorSchema.php deleted file mode 100644 index 542c757..0000000 --- a/src/Schema/ObjectConstructorSchema.php +++ /dev/null @@ -1,105 +0,0 @@ - $fieldToSchema - * @param class-string $classname - */ - public function __construct(array $fieldToSchema, private string $classname) - { - try { - $reflectionClass = new \ReflectionClass($this->classname); - } catch (\ReflectionException) { - throw new \InvalidArgumentException('Class "'.$classname.'" does not exist or cannot be used for reflection'); - } - - try { - $constructorReflectionMethod = $reflectionClass->getMethod('__construct'); - } catch (\ReflectionException) { - throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method'); - } - - $parameterFieldToSchema = []; - - /** @var list $missingFieldToSchema */ - $missingFieldToSchema = []; - foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) { - $name = $parameterReflection->getName(); - - if (isset($fieldToSchema[$name])) { - $parameterFieldToSchema[$name] = $fieldToSchema[$name]; - - unset($fieldToSchema[$name]); - } elseif (!$parameterReflection->isOptional()) { - $missingFieldToSchema[] = $name; - } - } - - if ([] !== $missingFieldToSchema) { - throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $missingFieldToSchema).'"'); - } - - if ([] !== $fieldToSchema) { - throw new \InvalidArgumentException('Additional fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', array_keys($fieldToSchema)).'"'); - } - - $this->typeErrorPattern = \sprintf('/%s::__construct\(\): Argument #(\d+) \(([^)]+)\) must be of type ([^ ]+), ([^ ]+) given/', preg_quote($this->classname, '/')); - - parent::__construct($parameterFieldToSchema); - } - - /** - * @param array $input - */ - protected function parseFields(array $input, Errors $childrenErrors): ?object - { - $parameters = []; - - foreach ($this->getFieldToSchema() as $fieldName => $fieldSchema) { - try { - if ($this->skip($input, $fieldName)) { - continue; - } - - $parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null); - } catch (ErrorsException $e) { - $childrenErrors->add($e->errors, $fieldName); - } - } - - try { - return new ($this->classname)(...$parameters); - } catch (\TypeError $e) { - $matches = []; - - if (1 === preg_match($this->typeErrorPattern, $e->getMessage(), $matches)) { - throw new ErrorsException( - new Error( - self::ERROR_PARAMETER_TYPE_CODE, - self::ERROR_PARAMETER_TYPE_TEMPLATE, - ['index' => $matches[1], 'name' => $matches[2], 'type' => $matches[3], 'given' => $matches[4]] - ) - ); - } - - throw $e; - } - } -} diff --git a/src/Schema/ObjectSchema.php b/src/Schema/ObjectSchema.php index 90fa4f0..c323ec0 100644 --- a/src/Schema/ObjectSchema.php +++ b/src/Schema/ObjectSchema.php @@ -16,7 +16,7 @@ final class ObjectSchema extends AbstractObjectSchema implements ObjectSchemaInt * @param array $fieldToSchema * @param class-string $classname */ - public function __construct(array $fieldToSchema, private string $classname = \stdClass::class) + public function __construct(array $fieldToSchema, private string $classname = \stdClass::class, private bool $construct = false) { parent::__construct($fieldToSchema); } @@ -26,20 +26,29 @@ public function __construct(array $fieldToSchema, private string $classname = \s */ protected function parseFields(array $input, Errors $childrenErrors): object { - $object = new ($this->classname); - + $fields = []; foreach ($this->getFieldToSchema() as $fieldName => $fieldSchema) { try { if ($this->skip($input, $fieldName)) { continue; } - $object->{$fieldName} = $fieldSchema->parse($input[$fieldName] ?? null); + $fields[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null); } catch (ErrorsException $e) { $childrenErrors->add($e->errors, $fieldName); } } - return $object; + if (!$this->construct) { + $object = new ($this->classname); + + foreach ($fields as $fieldName => $fieldValue) { + $object->{$fieldName} = $fieldValue; + } + + return $object; + } + + return new ($this->classname)(...$fields); } } diff --git a/tests/Unit/ParserTest.php b/tests/Unit/ParserTest.php index 352896b..1bcec53 100644 --- a/tests/Unit/ParserTest.php +++ b/tests/Unit/ParserTest.php @@ -33,6 +33,30 @@ enum BackedSuit: string case Spades = 'S'; } +final class ObjectDemo implements \JsonSerializable +{ + public string $field1; + + public function jsonSerialize(): array + { + return [ + 'field1' => $this->field1, + ]; + } +} + +final class ObjectConstructDemo implements \JsonSerializable +{ + public function __construct(public string $field1) {} + + public function jsonSerialize(): array + { + return [ + 'field1' => $this->field1, + ]; + } +} + /** * @covers \Chubbyphp\Parsing\Parser * @@ -159,7 +183,7 @@ public function testLiteral(): void self::assertInstanceOf(LiteralSchema::class, $literalSchema); } - public function testObject(): void + public function testObjectStdClass(): void { $p = new Parser(); @@ -170,6 +194,44 @@ public function testObject(): void self::assertInstanceOf(ObjectSchema::class, $objectSchema); } + public function testObjectWithObject(): void + { + $p = new Parser(); + + $objectSchema = $p->object([ + 'field' => $p->string(), + ], ObjectDemo::class, ); + + $classnameReflection = new \ReflectionProperty(ObjectSchema::class, 'classname'); + + self::assertSame(ObjectDemo::class, $classnameReflection->getValue($objectSchema)); + + $constructReflection = new \ReflectionProperty(ObjectSchema::class, 'construct'); + + self::assertFalse($constructReflection->getValue($objectSchema)); + + self::assertInstanceOf(ObjectSchema::class, $objectSchema); + } + + public function testObjectWithObjectConstruct(): void + { + $p = new Parser(); + + $objectSchema = $p->object([ + 'field' => $p->string(), + ], ObjectConstructDemo::class, true); + + $classnameReflection = new \ReflectionProperty(ObjectSchema::class, 'classname'); + + self::assertSame(ObjectConstructDemo::class, $classnameReflection->getValue($objectSchema)); + + $constructReflection = new \ReflectionProperty(ObjectSchema::class, 'construct'); + + self::assertTrue($constructReflection->getValue($objectSchema)); + + self::assertInstanceOf(ObjectSchema::class, $objectSchema); + } + public function testRecord(): void { $p = new Parser(); diff --git a/tests/Unit/Schema/ObjectConstructorSchemaTest.php b/tests/Unit/Schema/ObjectConstructorSchemaTest.php deleted file mode 100644 index d3b6534..0000000 --- a/tests/Unit/Schema/ObjectConstructorSchemaTest.php +++ /dev/null @@ -1,277 +0,0 @@ - $this->field1, - 'field2' => $this->field2, - 'field3' => $this->field3, - ]; - } -} - -final class ObjectConstructorThrowingTypeErrorDemo -{ - public function __construct( - public readonly string $field1, - ) { - throw new \TypeError('some unrelated type error'); - } -} - -/** - * @covers \Chubbyphp\Parsing\Schema\ObjectConstructorSchema - * - * @internal - */ -final class ObjectConstructorSchemaTest extends TestCase -{ - public function testImmutability(): void - { - $schema = new ObjectConstructorSchema(['field1' => new StringSchema(), 'field2' => new IntSchema(), 'field3' => new FloatSchema()], ObjectConstructorDemo::class); - - self::assertNotSame($schema, $schema->nullable()); - self::assertNotSame($schema, $schema->nullable(false)); - self::assertNotSame($schema, $schema->default([])); - self::assertNotSame($schema, $schema->preParse(static fn (mixed $input) => $input)); - self::assertNotSame($schema, $schema->postParse(static fn (\stdClass $output) => $output)); - self::assertNotSame($schema, $schema->catch(static fn (\stdClass $output, ErrorsException $e) => $output)); - - self::assertNotSame($schema, $schema->strict()); - self::assertNotSame($schema, $schema->optional([])); - } - - public function testConstructWithClassname(): void - { - try { - new ObjectConstructorSchema([], 'UnknownClass'); - - throw new \Exception('code should not be reached'); - } catch (\InvalidArgumentException $invalidArgumentException) { - self::assertSame( - 'Class "UnknownClass" does not exist or cannot be used for reflection', - $invalidArgumentException->getMessage() - ); - } - } - - public function testConstructWithClassNotHavingAConstructor(): void - { - try { - new ObjectConstructorSchema([], \stdClass::class); - - throw new \Exception('code should not be reached'); - } catch (\InvalidArgumentException $invalidArgumentException) { - self::assertSame( - 'Class "'.\stdClass::class.'" does not have a __construct method', - $invalidArgumentException->getMessage() - ); - } - } - - public function testConstructWithMissingFieldSchema(): void - { - try { - new ObjectConstructorSchema([], ObjectConstructorDemo::class); - - throw new \Exception('code should not be reached'); - } catch (\InvalidArgumentException $invalidArgumentException) { - self::assertSame( - 'Missing fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field1", "field2"', - $invalidArgumentException->getMessage() - ); - } - } - - public function testConstructWithAdditionalFieldSchema(): void - { - try { - new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - 'field4' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - throw new \Exception('code should not be reached'); - } catch (\InvalidArgumentException $invalidArgumentException) { - self::assertSame( - 'Additional fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field4"', - $invalidArgumentException->getMessage() - ); - } - } - - public function testSuccessWithAllParameters(): void - { - $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - /** @var ObjectConstructorDemo $object */ - $object = $schema->parse($input); - - self::assertInstanceOf(ObjectConstructorDemo::class, $object); - - self::assertSame($input, $object->jsonSerialize()); - } - - public function testSuccessWithAllParametersOptionalConsidered(): void - { - $input = ['field1' => 'test', 'field2' => 5]; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - /** @var ObjectConstructorDemo $object */ - $object = $schema->parse($input); - - self::assertInstanceOf(ObjectConstructorDemo::class, $object); - - self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); - } - - public function testSuccessWithRequiredParameters(): void - { - $input = ['field1' => 'test', 'field2' => 5]; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - /** @var ObjectConstructorDemo $object */ - $object = $schema->parse($input); - - self::assertInstanceOf(ObjectConstructorDemo::class, $object); - - self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); - } - - public function testFailedWithInvalidValue(): void - { - $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - try { - $schema->parse($input); - - throw new \Exception('code should not be reached'); - } catch (ErrorsException $errorsException) { - self::assertSame([ - [ - 'path' => 'field3', - 'error' => [ - 'code' => 'float.type', - 'template' => 'Type should be "float", {{given}} given', - 'variables' => [ - 'given' => 'string', - ], - ], - ], - ], $errorsException->errors->jsonSerialize()); - } - } - - public function testFailedWithInvalidValueNotCatchedByFieldSchema(): void - { - $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new StringSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); - - try { - $schema->parse($input); - - throw new \Exception('code should not be reached'); - } catch (ErrorsException $errorsException) { - self::assertSame([ - [ - 'path' => '', - 'error' => [ - 'code' => 'object.parameterType', - 'template' => 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given', - 'variables' => [ - 'index' => '3', - 'name' => '$field3', - 'type' => '?float', - 'given' => 'string', - ], - ], - ], - ], $errorsException->errors->jsonSerialize()); - } - } - - public function testFailedWithUnknownException(): void - { - $exception = new \Exception('unknown'); - - $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - 'field2' => new IntSchema(), - 'field3' => new FloatSchema()->postParse(static fn () => throw $exception), - ], ObjectConstructorDemo::class)->optional(['field3']); - - try { - $schema->parse($input); - - throw new \Exception('code should not be reached'); - } catch (\Exception $e) { - self::assertSame($exception, $e); - } - } - - public function testFailedWithTypeErrorNotMatchingPattern(): void - { - $input = ['field1' => 'test']; - - $schema = new ObjectConstructorSchema([ - 'field1' => new StringSchema(), - ], ObjectConstructorThrowingTypeErrorDemo::class); - - try { - $schema->parse($input); - - throw new \Exception('code should not be reached'); - } catch (\TypeError $e) { - self::assertSame('some unrelated type error', $e->getMessage()); - } - } -} diff --git a/tests/Unit/Schema/ObjectSchemaTest.php b/tests/Unit/Schema/ObjectSchemaTest.php index f0ccfd2..4f18ea2 100644 --- a/tests/Unit/Schema/ObjectSchemaTest.php +++ b/tests/Unit/Schema/ObjectSchemaTest.php @@ -16,12 +16,28 @@ final class ObjectDemo implements \JsonSerializable { public string $field1; public int $field2; + public ?float $field3; public function jsonSerialize(): array { return [ 'field1' => $this->field1, 'field2' => $this->field2, + 'field3' => $this->field3, + ]; + } +} + +final class ObjectConstructDemo implements \JsonSerializable +{ + public function __construct(public string $field1, public int $field2, public ?float $field3) {} + + public function jsonSerialize(): array + { + return [ + 'field1' => $this->field1, + 'field2' => $this->field2, + 'field3' => $this->field3, ]; } } @@ -35,7 +51,11 @@ final class ObjectSchemaTest extends TestCase { public function testImmutability(): void { - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()]); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ]); self::assertNotSame($schema, $schema->nullable()); self::assertNotSame($schema, $schema->nullable(false)); @@ -78,11 +98,15 @@ public function testConstructWithoutFieldSchema(): void public function testParseSuccess(): void { - $input = ['field1' => 'test', 'field2' => 1]; + $input = ['field1' => 'test', 'field2' => 1, 'field3' => 3.14159]; - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()]); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ]); - $output = $schema->parse([...$input, 'field3' => 1.5]); + $output = $schema->parse($input); self::assertInstanceOf(\stdClass::class, $output); @@ -91,9 +115,13 @@ public function testParseSuccess(): void public function testParseSuccessWithClass(): void { - $input = ['field1' => 'test', 'field2' => 1]; + $input = ['field1' => 'test', 'field2' => 1, 'field3' => 3.14159]; - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()], ObjectDemo::class); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ], ObjectDemo::class); $output = $schema->parse($input); @@ -102,13 +130,35 @@ public function testParseSuccessWithClass(): void self::assertSame($input, (array) $output); } + public function testParseSuccessWithConstructClass(): void + { + $input = ['field1' => 'test', 'field2' => 1, 'field3' => 3.14159]; + + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ], ObjectConstructDemo::class, true); + + $output = $schema->parse($input); + + self::assertInstanceOf(ObjectConstructDemo::class, $output); + + self::assertSame($input, (array) $output); + } + public function testParseSuccessWithStdClassInput(): void { $input = new \stdClass(); $input->field1 = 'test'; $input->field2 = 1; + $input->field3 = 3.14159; - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()]); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ]); $output = $schema->parse($input); @@ -119,9 +169,13 @@ public function testParseSuccessWithStdClassInput(): void public function testParseSuccessWithIteratorInput(): void { - $input = new \ArrayIterator(['field1' => 'test', 'field2' => 1]); + $input = new \ArrayIterator(['field1' => 'test', 'field2' => 1, 'field3' => 3.14159]); - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()]); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ]); $output = $schema->parse($input); @@ -135,8 +189,13 @@ public function testParseSuccessWithJsonSerialzableObject(): void $input = new ObjectDemo(); $input->field1 = 'test'; $input->field2 = 1; + $input->field3 = 3.14159; - $schema = new ObjectSchema(['field1' => new StringSchema(), 'field2' => new IntSchema()]); + $schema = new ObjectSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => new FloatSchema(), + ]); $output = $schema->parse($input);