Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions doc/Schema/ObjectConstructorSchema.md
Original file line number Diff line number Diff line change
@@ -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
<?php

use Chubbyphp\Parsing\Parser;

class User
{
public function __construct(
public readonly string $name,
public readonly int $age
) {}
}

$p = new Parser();

$schema = $p->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.
3 changes: 3 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
parameters:
ignoreErrors:
-
message: '#Dead catch - ReflectionException is never thrown in the try block#'
10 changes: 10 additions & 0 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,6 +107,15 @@ public function object(array $fieldNameToSchema, string $classname = \stdClass::
return new ObjectSchema($fieldNameToSchema, $classname);
}

/**
* @param array<string, SchemaInterface> $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);
Expand Down
12 changes: 10 additions & 2 deletions src/ParserInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,8 +24,9 @@
use Chubbyphp\Parsing\Schema\UnionSchema;

/**
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
* @method ConstSchema const(bool|float|int|string $const)
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
* @method ConstSchema const(bool|float|int|string $const)
* @method ObjectConstructorSchema objectConstructor(array<string, SchemaInterface> $fieldNameToSchema, class-string $classname)
*/
interface ParserInterface
{
Expand Down Expand Up @@ -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<string, SchemaInterface> $fieldNameToSchema
// * @param class-string $classname
// */
// public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema;

public function record(SchemaInterface $fieldSchema): RecordSchema;

public function string(): StringSchema;
Expand Down
124 changes: 124 additions & 0 deletions src/Schema/ObjectConstructorSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace Chubbyphp\Parsing\Schema;

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
{
public const string ERROR_TYPE_CODE = 'object.type';
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 {{name}} should be of {{type}}, {{given}} given';

/**
* @var array<string, Type>
*/
private readonly array $fieldToType;

/**
* @param array<mixed, mixed> $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<string> $missingFieldToSchema */
$missingFieldToSchema = [];

/** @var array<string, SchemaInterface> $sortedFieldToSchema */
$sortedFieldToSchema = [];

/** @var array<string, Type> $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<string, mixed> $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;
}
}
Loading
Loading