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 new file mode 100644 index 0000000..4d8d15d --- /dev/null +++ b/src/Schema/ObjectConstructorSchema.php @@ -0,0 +1,124 @@ + + */ + private readonly array $fieldToType; + + /** + * @param array $fieldToSchema + * @param class-string $classname + */ + public function __construct(array $fieldToSchema, private string $classname, private TypeResolver $typeResolver) + { + 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'); + } + + /** @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])) { + $sortedFieldToSchema[$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->fieldToType = $fieldToType; + + parent::__construct($sortedFieldToSchema); + } + + /** + * @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; + } + + $fieldValue = $fieldSchema->parse($input[$fieldName] ?? null); + + $fieldType = $this->fieldToType[$fieldName]; + + if (!$fieldType->accepts($fieldValue)) { + throw new ErrorsException(new Error( + self::ERROR_PARAMETER_TYPE_CODE, + self::ERROR_PARAMETER_TYPE_TEMPLATE, + [ + 'name' => $fieldName, + 'type' => (string) $fieldType, + 'given' => $this->getDataType($fieldValue), + ] + )); + } + + $parameters[$fieldName] = $fieldValue; + } catch (ErrorsException $e) { + $childrenErrors->add($e->errors, $fieldName); + } + } + + if (!$childrenErrors->has()) { + return new ($this->classname)(...$parameters); + } + + return null; + } +} diff --git a/tests/Unit/Schema/ObjectConstructorSchemaTest.php b/tests/Unit/Schema/ObjectConstructorSchemaTest.php new file mode 100644 index 0000000..906e6bc --- /dev/null +++ b/tests/Unit/Schema/ObjectConstructorSchemaTest.php @@ -0,0 +1,445 @@ + $this->field1, + 'field2' => $this->field2, + 'field3' => $this->field3, + 'field4' => $this->field4, + ]; + } +} + +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())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()); + + 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', TypeResolver::create()); + + 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, TypeResolver::create()); + + 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, TypeResolver::create()); + + 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())->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: "field5"', + $invalidArgumentException->getMessage() + ); + } + } + + public function testSuccessWithAllParameters(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field3' => 3.14159, 'field4' => 1.61803]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + '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, $object->jsonSerialize()); + } + + public function testSuccessWithAllParametersOptionalConsidered(): void + { + $input = ['field1' => 'test', 'field2' => 5, 'field4' => 1.61803]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + '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([ + 'field1' => 'test', + 'field2' => 5, + 'field3' => null, + 'field4' => 1.61803, + ], $object->jsonSerialize()); + } + + public function testSuccessWithRequiredParameters(): void + { + $input = ['field1' => 'test', 'field2' => 5]; + + $schema = (new ObjectConstructorSchema([ + 'field1' => new StringSchema(), + 'field2' => new IntSchema(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); + + /** @var ObjectConstructorDemo $object */ + $object = $schema->parse($input); + + self::assertInstanceOf(ObjectConstructorDemo::class, $object); + + self::assertSame([...$input, 'field3' => null, 'field4' => 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())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); + + 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())->nullable(), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (ErrorsException $errorsException) { + self::assertSame([ + [ + 'path' => 'field3', + 'error' => [ + 'code' => 'object.parameterType', + 'template' => 'Parameter {{name}} should be of {{type}}, {{given}} given', + 'variables' => [ + 'name' => 'field3', + 'type' => 'float|null', + '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())->nullable()->postParse(static fn () => throw $exception), + 'field4' => (new FloatSchema())->nullable(), + ], ObjectConstructorDemo::class, TypeResolver::create()))->optional(['field3', 'field4']); + + 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, TypeResolver::create()); + + try { + $schema->parse($input); + + throw new \Exception('code should not be reached'); + } catch (\TypeError $e) { + 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')); + } +}