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 3a7842608f9a3901ee67b16f19db7747bf8dd352 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 --- README.md | 1 + composer.json | 4 +- doc/Schema/ObjectConstructorSchema.md | 130 ++++++++++ phpstan.neon | 3 + src/Parser.php | 10 + src/ParserInterface.php | 12 +- src/Schema/ObjectConstructorSchema.php | 73 ++++-- .../Schema/ObjectConstructorSchemaTest.php | 240 +++++++++++++++--- 8 files changed, 407 insertions(+), 66 deletions(-) create mode 100644 doc/Schema/ObjectConstructorSchema.md diff --git a/README.md b/README.md index dcbba39..9d14542 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ $user = $userSchema->parse([ | `array()` | Arrays with item validation | [ArraySchema](doc/Schema/ArraySchema.md) | | `assoc()` | Associative arrays with field schemas | [AssocSchema](doc/Schema/AssocSchema.md) | | `object()` | Objects/DTOs with field schemas | [ObjectSchema](doc/Schema/ObjectSchema.md) | +| `objectConstructor()` | Objects/DTOs with field schemas | [ObjectSchemaConstructor](doc/Schema/ObjectSchemaConstructor.md) | | `tuple()` | Fixed-length arrays with positional types | [TupleSchema](doc/Schema/TupleSchema.md) | | `record()` | Key-value maps with uniform value types | [RecordSchema](doc/Schema/RecordSchema.md) | diff --git a/composer.json b/composer.json index 4d84ce3..907efc2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,9 @@ } ], "require": { - "php": "^8.3" + "php": "^8.3", + "phpstan/phpdoc-parser": "^2.3", + "symfony/type-info": "^8.0" }, "require-dev": { "chubbyphp/chubbyphp-dev-helper": "dev-master", diff --git a/doc/Schema/ObjectConstructorSchema.md b/doc/Schema/ObjectConstructorSchema.md new file mode 100644 index 0000000..423972b --- /dev/null +++ b/doc/Schema/ObjectConstructorSchema.md @@ -0,0 +1,130 @@ +# ObjectConstructorSchema + +The `ObjectConstructorSchema` parses an associative array (or compatible input) and constructs an instance of a given class by passing validated fields to its constructor. + +## Basic Usage + +```php +objectConstructor([ + 'name' => $p->string(), + 'age' => $p->int(), +], User::class); + +$user = $schema->parse(['name' => 'John', 'age' => 30]); +// Returns: User instance with populated properties +``` + +## Constructor Validation + +The schema validates that: +1. All non-optional constructor parameters have a corresponding schema definition. +2. No extra schemas are provided that don't match a constructor parameter. +3. The types of the parsed values match the constructor parameter types (handling `TypeError` automatically). + +## Supported Input Types + +The `ObjectConstructorSchema` accepts multiple input formats, which are converted to an associative array before processing: + +- **Arrays** - Standard associative arrays +- **stdClass** - Anonymous objects +- **Traversable** - Objects implementing `\Traversable` +- **JsonSerializable** - Objects implementing `\JsonSerializable` + +## Validations + +### Strict Mode + +By default, unknown fields in the input are silently ignored. Use `strict()` to reject unknown fields: + +```php +$schema = $p->objectConstructor([ + 'name' => $p->string() +], User::class)->strict(); + +$schema->parse(['name' => 'John']); // OK +$schema->parse(['name' => 'John', 'extra' => 1]); // Throws error: object.unknownField +``` + +### Strict with Exceptions + +Allow specific unknown fields to be ignored while rejecting others: + +```php +$schema = $p->objectConstructor([ + 'name' => $p->string() +], User::class)->strict(['_id', '_rev']); + +$schema->parse(['name' => 'John', '_id' => '123']); // OK, _id ignored +$schema->parse(['name' => 'John', 'unknown' => 'val']); // Throws error: object.unknownField +``` + +### Optional Fields + +Make certain fields optional in the input. If an optional field is missing from the input, it won't be passed to the constructor (so the constructor parameter must be optional). + +```php +class User +{ + public function __construct( + public readonly string $name, + public readonly string $nickname = '' + ) {} +} + +$schema = $p->objectConstructor([ + 'name' => $p->string(), + 'nickname' => $p->string(), +], User::class)->optional(['nickname']); + +$schema->parse(['name' => 'John']); +// Returns: User(name: 'John', nickname: '') - using default value from constructor +``` + +## Schema Utilities + +### Get Field Schema + +Retrieve the schema for a specific field: + +```php +$nameSchema = $schema->getFieldSchema('name'); // Returns StringSchema +``` + +### Extend Schema + +Get all field schemas to extend or compose: + +```php +$baseSchema = $p->objectConstructor([ + 'id' => $p->int(), +], BaseEntity::class); + +$userSchema = $p->objectConstructor([ + ...$baseSchema->getFieldToSchema(), + 'name' => $p->string(), +], User::class); +``` + +## Error Codes + +| Code | Description | +|------|-------------| +| `object.type` | Value is not a valid object type (array, stdClass, Traversable) | +| `object.unknownField` | Unknown field found in strict mode | +| `object.parameterType` | Constructor parameter type mismatch (e.g., int passed to string parameter) | + +Field-level errors (like validation failures within `name` or `age` schemas) include the field name in the error path. diff --git a/phpstan.neon b/phpstan.neon index 9631d44..59ce3ea 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1 +1,4 @@ parameters: + ignoreErrors: + - + message: '#Dead catch - ReflectionException is never thrown in the try block#' diff --git a/src/Parser.php b/src/Parser.php index 839db6b..db759e5 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -15,6 +15,7 @@ use Chubbyphp\Parsing\Schema\IntSchema; use Chubbyphp\Parsing\Schema\LazySchema; use Chubbyphp\Parsing\Schema\LiteralSchema; +use Chubbyphp\Parsing\Schema\ObjectConstructorSchema; use Chubbyphp\Parsing\Schema\ObjectSchema; use Chubbyphp\Parsing\Schema\ObjectSchemaInterface; use Chubbyphp\Parsing\Schema\RecordSchema; @@ -106,6 +107,15 @@ public function object(array $fieldNameToSchema, string $classname = \stdClass:: return new ObjectSchema($fieldNameToSchema, $classname); } + /** + * @param array $fieldNameToSchema + * @param class-string $classname + */ + public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema + { + return new ObjectConstructorSchema($fieldNameToSchema, $classname); + } + public function record(SchemaInterface $fieldSchema): RecordSchema { return new RecordSchema($fieldSchema); diff --git a/src/ParserInterface.php b/src/ParserInterface.php index 6231da5..94d144f 100644 --- a/src/ParserInterface.php +++ b/src/ParserInterface.php @@ -14,6 +14,7 @@ use Chubbyphp\Parsing\Schema\FloatSchema; use Chubbyphp\Parsing\Schema\IntSchema; use Chubbyphp\Parsing\Schema\LiteralSchema; +use Chubbyphp\Parsing\Schema\ObjectConstructorSchema; use Chubbyphp\Parsing\Schema\ObjectSchema; use Chubbyphp\Parsing\Schema\ObjectSchemaInterface; use Chubbyphp\Parsing\Schema\RecordSchema; @@ -23,8 +24,9 @@ use Chubbyphp\Parsing\Schema\UnionSchema; /** - * @method AssocSchema assoc(array $fieldNameToSchema) - * @method ConstSchema const(bool|float|int|string $const) + * @method AssocSchema assoc(array $fieldNameToSchema) + * @method ConstSchema const(bool|float|int|string $const) + * @method ObjectConstructorSchema objectConstructor(array $fieldNameToSchema, class-string $classname) */ interface ParserInterface { @@ -71,6 +73,12 @@ public function literal(bool|float|int|string $literal): LiteralSchema; */ public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema; + // /** + // * @param array $fieldNameToSchema + // * @param class-string $classname + // */ + // public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema; + public function record(SchemaInterface $fieldSchema): RecordSchema; public function string(): StringSchema; diff --git a/src/Schema/ObjectConstructorSchema.php b/src/Schema/ObjectConstructorSchema.php index 542c757..4d8d15d 100644 --- a/src/Schema/ObjectConstructorSchema.php +++ b/src/Schema/ObjectConstructorSchema.php @@ -7,6 +7,8 @@ use Chubbyphp\Parsing\Error; use Chubbyphp\Parsing\Errors; use Chubbyphp\Parsing\ErrorsException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; final class ObjectConstructorSchema extends AbstractObjectSchema implements ObjectSchemaInterface { @@ -14,15 +16,18 @@ final class ObjectConstructorSchema extends AbstractObjectSchema implements Obje public const string ERROR_UNKNOWN_FIELD_CODE = 'object.unknownField'; public const string ERROR_PARAMETER_TYPE_CODE = 'object.parameterType'; - public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given'; + public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{name}} should be of {{type}}, {{given}} given'; - private readonly string $typeErrorPattern; + /** + * @var array + */ + private readonly array $fieldToType; /** * @param array $fieldToSchema * @param class-string $classname */ - public function __construct(array $fieldToSchema, private string $classname) + public function __construct(array $fieldToSchema, private string $classname, private TypeResolver $typeResolver) { try { $reflectionClass = new \ReflectionClass($this->classname); @@ -36,16 +41,20 @@ public function __construct(array $fieldToSchema, private string $classname) throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method'); } - $parameterFieldToSchema = []; - /** @var list $missingFieldToSchema */ $missingFieldToSchema = []; + + /** @var array $sortedFieldToSchema */ + $sortedFieldToSchema = []; + + /** @var array $fieldToType */ + $fieldToType = []; foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) { $name = $parameterReflection->getName(); + $fieldToType[$name] = $typeResolver->resolve($parameterReflection); if (isset($fieldToSchema[$name])) { - $parameterFieldToSchema[$name] = $fieldToSchema[$name]; - + $sortedFieldToSchema[$name] = $fieldToSchema[$name]; unset($fieldToSchema[$name]); } elseif (!$parameterReflection->isOptional()) { $missingFieldToSchema[] = $name; @@ -53,16 +62,22 @@ public function __construct(array $fieldToSchema, private string $classname) } if ([] !== $missingFieldToSchema) { - throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $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)).'"'); + 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, '/')); + $this->fieldToType = $fieldToType; - parent::__construct($parameterFieldToSchema); + parent::__construct($sortedFieldToSchema); } /** @@ -78,28 +93,32 @@ protected function parseFields(array $input, Errors $childrenErrors): ?object continue; } - $parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null); - } catch (ErrorsException $e) { - $childrenErrors->add($e->errors, $fieldName); - } - } + $fieldValue = $fieldSchema->parse($input[$fieldName] ?? null); - try { - return new ($this->classname)(...$parameters); - } catch (\TypeError $e) { - $matches = []; + $fieldType = $this->fieldToType[$fieldName]; - if (1 === preg_match($this->typeErrorPattern, $e->getMessage(), $matches)) { - throw new ErrorsException( - new Error( + if (!$fieldType->accepts($fieldValue)) { + 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]] - ) - ); + [ + 'name' => $fieldName, + 'type' => (string) $fieldType, + 'given' => $this->getDataType($fieldValue), + ] + )); + } + + $parameters[$fieldName] = $fieldValue; + } catch (ErrorsException $e) { + $childrenErrors->add($e->errors, $fieldName); } + } - throw $e; + if (!$childrenErrors->has()) { + return new ($this->classname)(...$parameters); } + + return null; } } diff --git a/tests/Unit/Schema/ObjectConstructorSchemaTest.php b/tests/Unit/Schema/ObjectConstructorSchemaTest.php index d3b6534..906e6bc 100644 --- a/tests/Unit/Schema/ObjectConstructorSchemaTest.php +++ b/tests/Unit/Schema/ObjectConstructorSchemaTest.php @@ -5,11 +5,13 @@ namespace Chubbyphp\Tests\Parsing\Unit\Schema; use Chubbyphp\Parsing\ErrorsException; +use Chubbyphp\Parsing\Schema\BoolSchema; use Chubbyphp\Parsing\Schema\FloatSchema; use Chubbyphp\Parsing\Schema\IntSchema; use Chubbyphp\Parsing\Schema\ObjectConstructorSchema; use Chubbyphp\Parsing\Schema\StringSchema; use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; final class ObjectConstructorDemo implements \JsonSerializable { @@ -17,6 +19,7 @@ public function __construct( public readonly string $field1, public readonly int $field2, public readonly ?float $field3 = null, + public readonly ?float $field4 = null, ) {} public function jsonSerialize(): array @@ -25,6 +28,7 @@ public function jsonSerialize(): array 'field1' => $this->field1, 'field2' => $this->field2, 'field3' => $this->field3, + 'field4' => $this->field4, ]; } } @@ -47,7 +51,12 @@ final class ObjectConstructorSchemaTest extends TestCase { public function testImmutability(): void { - $schema = new ObjectConstructorSchema(['field1' => new StringSchema(), 'field2' => new IntSchema(), 'field3' => new FloatSchema()], ObjectConstructorDemo::class); + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new FloatSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()); self::assertNotSame($schema, $schema->nullable()); self::assertNotSame($schema, $schema->nullable(false)); @@ -63,7 +72,7 @@ public function testImmutability(): void public function testConstructWithClassname(): void { try { - new ObjectConstructorSchema([], 'UnknownClass'); + new ObjectConstructorSchema([], 'UnknownClass', TypeResolver::create()); throw new \Exception('code should not be reached'); } catch (\InvalidArgumentException $invalidArgumentException) { @@ -77,7 +86,7 @@ public function testConstructWithClassname(): void public function testConstructWithClassNotHavingAConstructor(): void { try { - new ObjectConstructorSchema([], \stdClass::class); + new ObjectConstructorSchema([], \stdClass::class, TypeResolver::create()); throw new \Exception('code should not be reached'); } catch (\InvalidArgumentException $invalidArgumentException) { @@ -91,7 +100,7 @@ public function testConstructWithClassNotHavingAConstructor(): void public function testConstructWithMissingFieldSchema(): void { try { - new ObjectConstructorSchema([], ObjectConstructorDemo::class); + new ObjectConstructorSchema([], ObjectConstructorDemo::class, TypeResolver::create()); throw new \Exception('code should not be reached'); } catch (\InvalidArgumentException $invalidArgumentException) { @@ -105,17 +114,18 @@ public function testConstructWithMissingFieldSchema(): void public function testConstructWithAdditionalFieldSchema(): void { try { - new ObjectConstructorSchema([ + (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - 'field4' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new FloatSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + 'field5' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); throw new \Exception('code should not be reached'); } catch (\InvalidArgumentException $invalidArgumentException) { self::assertSame( - 'Additional fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field4"', + 'Additional fieldToSchema for "'.ObjectConstructorDemo::class.'" __construct parameters: "field5"', $invalidArgumentException->getMessage() ); } @@ -123,13 +133,14 @@ public function testConstructWithAdditionalFieldSchema(): void public function testSuccessWithAllParameters(): void { - $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159, 'field4' => 1.61803]; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new FloatSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); /** @var ObjectConstructorDemo $object */ $object = $schema->parse($input); @@ -141,48 +152,55 @@ public function testSuccessWithAllParameters(): void public function testSuccessWithAllParametersOptionalConsidered(): void { - $input = ['field1' => 'test', 'field2' => 5]; + $input = ['field1' => 'test', 'field2' => 5, 'field4' => 1.61803]; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new FloatSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); /** @var ObjectConstructorDemo $object */ $object = $schema->parse($input); self::assertInstanceOf(ObjectConstructorDemo::class, $object); - self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); + self::assertSame([ + 'field1' => 'test', + 'field2' => 5, + 'field3' => null, + 'field4' => 1.61803, + ], $object->jsonSerialize()); } public function testSuccessWithRequiredParameters(): void { $input = ['field1' => 'test', 'field2' => 5]; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); /** @var ObjectConstructorDemo $object */ $object = $schema->parse($input); self::assertInstanceOf(ObjectConstructorDemo::class, $object); - self::assertSame([...$input, 'field3' => null], $object->jsonSerialize()); + self::assertSame([...$input, 'field3' => null, 'field4' => null], $object->jsonSerialize()); } public function testFailedWithInvalidValue(): void { $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new FloatSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new FloatSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); try { $schema->parse($input); @@ -208,11 +226,12 @@ public function testFailedWithInvalidValueNotCatchedByFieldSchema(): void { $input = ['field1' => 'test', 'field2' => 5, 'field3' => 'test']; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new StringSchema(), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); try { $schema->parse($input); @@ -221,14 +240,13 @@ public function testFailedWithInvalidValueNotCatchedByFieldSchema(): void } catch (ErrorsException $errorsException) { self::assertSame([ [ - 'path' => '', + 'path' => 'field3', 'error' => [ 'code' => 'object.parameterType', - 'template' => 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given', + 'template' => 'Parameter {{name}} should be of {{type}}, {{given}} given', 'variables' => [ - 'index' => '3', - 'name' => '$field3', - 'type' => '?float', + 'name' => 'field3', + 'type' => 'float|null', 'given' => 'string', ], ], @@ -243,11 +261,12 @@ public function testFailedWithUnknownException(): void $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159]; - $schema = new ObjectConstructorSchema([ + $schema = (new ObjectConstructorSchema([ 'field1' => new StringSchema(), 'field2' => new IntSchema(), - 'field3' => new FloatSchema()->postParse(static fn () => throw $exception), - ], ObjectConstructorDemo::class)->optional(['field3']); + 'field3' => (new FloatSchema())->nullable()->postParse(static fn () => throw $exception), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); try { $schema->parse($input); @@ -264,7 +283,7 @@ public function testFailedWithTypeErrorNotMatchingPattern(): void $schema = new ObjectConstructorSchema([ 'field1' => new StringSchema(), - ], ObjectConstructorThrowingTypeErrorDemo::class); + ], ObjectConstructorThrowingTypeErrorDemo::class, TypeResolver::create()); try { $schema->parse($input); @@ -274,4 +293,153 @@ public function testFailedWithTypeErrorNotMatchingPattern(): void self::assertSame('some unrelated type error', $e->getMessage()); } } + + public function testParseSuccessWithPreParse(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field3' => null, 'field4' => null]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create())) + ->optional(['field3', 'field4']) + ->preParse(static fn () => $input) + ; + + self::assertSame($input, $schema->parse(null)->jsonSerialize()); + } + + public function testParseSuccessWithPostParse(): void + { + $input = ['field1' => 'test', 'field2' => 5]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create())) + ->optional(['field3', 'field4']) + ->postParse(static fn (ObjectConstructorDemo $output) => new ObjectConstructorDemo(...[...$output->jsonSerialize(), 'field2' => 10])) + ; + + self::assertSame( + [...$input, 'field2' => 10, 'field3' => null, 'field4' => null], + (array) $schema->parse($input) + ); + } + + public function testParseFailedWithCatch(): void + { + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create())) + ->optional(['field3', 'field4']) + ->catch(static function (mixed $input, ErrorsException $errorsException) { + self::assertNull($input); + self::assertSame([ + [ + 'path' => '', + 'error' => [ + 'code' => 'object.type', + 'template' => 'Type should be "array|\stdClass|\Traversable", {{given}} given', + 'variables' => [ + 'given' => 'NULL', + ], + ], + ], + ], $errorsException->errors->jsonSerialize()); + + return 'catched'; + }) + ; + + self::assertSame('catched', $schema->parse(null)); + } + + public function testSafeParseSuccess(): void + { + $input = ['field1' => 'test', 'field2' => 1]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create())) + ->optional(['field3', 'field4']) + ; + + self::assertSame( + [...$input, 'field3' => null, 'field4' => null], + $schema->safeParse($input)->data->jsonSerialize() + ); + } + + public function testSafeParseFailed(): void + { + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + 'field3' => (new StringSchema())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create())) + ->optional(['field3', 'field4']) + ; + + self::assertSame([ + [ + 'path' => '', + 'error' => [ + 'code' => 'object.type', + 'template' => 'Type should be "array|\stdClass|\Traversable", {{given}} given', + 'variables' => [ + 'given' => 'NULL', + ], + ], + ], + ], $schema->safeParse(null)->exception->errors->jsonSerialize()); + } + + public function testGetFieldToSchema(): void + { + $fieldToSchema = ['field1' => new StringSchema(), 'field2' => new IntSchema()]; + + $schema = new ObjectConstructorSchema($fieldToSchema, ObjectConstructorDemo::class, TypeResolver::create()); + + self::assertSame($fieldToSchema, $schema->getFieldToSchema()); + + $fieldToSchema2 = [...$schema->getFieldToSchema(), 'field3' => new BoolSchema()]; + + $schema2 = new ObjectConstructorSchema($fieldToSchema2, ObjectConstructorDemo::class, TypeResolver::create()); + + self::assertSame($fieldToSchema2, $schema2->getFieldToSchema()); + } + + public function testGetFieldSchemaSuccess(): void + { + $field2Schema = new IntSchema(); + + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => $field2Schema, + ], ObjectConstructorDemo::class, TypeResolver::create()); + + self::assertSame($field2Schema, $schema->getFieldSchema('field2')); + } + + public function testGetFieldSchemaFailed(): void + { + $schema = new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + ], ObjectConstructorDemo::class, TypeResolver::create()); + + self::assertNull($schema->getFieldSchema('field3')); + } }