Skip to content

Commit b874081

Browse files
committed
optimize schema
1 parent 31b4505 commit b874081

8 files changed

Lines changed: 403 additions & 61 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ $user = $userSchema->parse([
7979
| `array()` | Arrays with item validation | [ArraySchema](doc/Schema/ArraySchema.md) |
8080
| `assoc()` | Associative arrays with field schemas | [AssocSchema](doc/Schema/AssocSchema.md) |
8181
| `object()` | Objects/DTOs with field schemas | [ObjectSchema](doc/Schema/ObjectSchema.md) |
82+
| `objectConstructor()` | Objects/DTOs with field schemas | [ObjectSchemaConstructor](doc/Schema/ObjectSchemaConstructor.md) |
8283
| `tuple()` | Fixed-length arrays with positional types | [TupleSchema](doc/Schema/TupleSchema.md) |
8384
| `record()` | Key-value maps with uniform value types | [RecordSchema](doc/Schema/RecordSchema.md) |
8485

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
}
1818
],
1919
"require": {
20-
"php": "^8.3"
20+
"php": "^8.3",
21+
"phpstan/phpdoc-parser": "^2.3",
22+
"symfony/type-info": "^8.0"
2123
},
2224
"require-dev": {
2325
"chubbyphp/chubbyphp-dev-helper": "dev-master",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# ObjectConstructorSchema
2+
3+
The `ObjectConstructorSchema` parses an associative array (or compatible input) and constructs an instance of a given class by passing validated fields to its constructor.
4+
5+
## Basic Usage
6+
7+
```php
8+
<?php
9+
10+
use Chubbyphp\Parsing\Parser;
11+
12+
class User
13+
{
14+
public function __construct(
15+
public readonly string $name,
16+
public readonly int $age
17+
) {}
18+
}
19+
20+
$p = new Parser();
21+
22+
$schema = $p->objectConstructor([
23+
'name' => $p->string(),
24+
'age' => $p->int(),
25+
], User::class);
26+
27+
$user = $schema->parse(['name' => 'John', 'age' => 30]);
28+
// Returns: User instance with populated properties
29+
```
30+
31+
## Constructor Validation
32+
33+
The schema validates that:
34+
1. All non-optional constructor parameters have a corresponding schema definition.
35+
2. No extra schemas are provided that don't match a constructor parameter.
36+
3. The types of the parsed values match the constructor parameter types (handling `TypeError` automatically).
37+
38+
## Supported Input Types
39+
40+
The `ObjectConstructorSchema` accepts multiple input formats, which are converted to an associative array before processing:
41+
42+
- **Arrays** - Standard associative arrays
43+
- **stdClass** - Anonymous objects
44+
- **Traversable** - Objects implementing `\Traversable`
45+
- **JsonSerializable** - Objects implementing `\JsonSerializable`
46+
47+
## Validations
48+
49+
### Strict Mode
50+
51+
By default, unknown fields in the input are silently ignored. Use `strict()` to reject unknown fields:
52+
53+
```php
54+
$schema = $p->objectConstructor([
55+
'name' => $p->string()
56+
], User::class)->strict();
57+
58+
$schema->parse(['name' => 'John']); // OK
59+
$schema->parse(['name' => 'John', 'extra' => 1]); // Throws error: object.unknownField
60+
```
61+
62+
### Strict with Exceptions
63+
64+
Allow specific unknown fields to be ignored while rejecting others:
65+
66+
```php
67+
$schema = $p->objectConstructor([
68+
'name' => $p->string()
69+
], User::class)->strict(['_id', '_rev']);
70+
71+
$schema->parse(['name' => 'John', '_id' => '123']); // OK, _id ignored
72+
$schema->parse(['name' => 'John', 'unknown' => 'val']); // Throws error: object.unknownField
73+
```
74+
75+
### Optional Fields
76+
77+
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).
78+
79+
```php
80+
class User
81+
{
82+
public function __construct(
83+
public readonly string $name,
84+
public readonly string $nickname = ''
85+
) {}
86+
}
87+
88+
$schema = $p->objectConstructor([
89+
'name' => $p->string(),
90+
'nickname' => $p->string(),
91+
], User::class)->optional(['nickname']);
92+
93+
$schema->parse(['name' => 'John']);
94+
// Returns: User(name: 'John', nickname: '') - using default value from constructor
95+
```
96+
97+
## Schema Utilities
98+
99+
### Get Field Schema
100+
101+
Retrieve the schema for a specific field:
102+
103+
```php
104+
$nameSchema = $schema->getFieldSchema('name'); // Returns StringSchema
105+
```
106+
107+
### Extend Schema
108+
109+
Get all field schemas to extend or compose:
110+
111+
```php
112+
$baseSchema = $p->objectConstructor([
113+
'id' => $p->int(),
114+
], BaseEntity::class);
115+
116+
$userSchema = $p->objectConstructor([
117+
...$baseSchema->getFieldToSchema(),
118+
'name' => $p->string(),
119+
], User::class);
120+
```
121+
122+
## Error Codes
123+
124+
| Code | Description |
125+
|------|-------------|
126+
| `object.type` | Value is not a valid object type (array, stdClass, Traversable) |
127+
| `object.unknownField` | Unknown field found in strict mode |
128+
| `object.parameterType` | Constructor parameter type mismatch (e.g., int passed to string parameter) |
129+
130+
Field-level errors (like validation failures within `name` or `age` schemas) include the field name in the error path.

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
parameters:
2+
ignoreErrors:
3+
-
4+
message: '#Dead catch - ReflectionException is never thrown in the try block#'

src/Parser.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Chubbyphp\Parsing\Schema\IntSchema;
1616
use Chubbyphp\Parsing\Schema\LazySchema;
1717
use Chubbyphp\Parsing\Schema\LiteralSchema;
18+
use Chubbyphp\Parsing\Schema\ObjectConstructorSchema;
1819
use Chubbyphp\Parsing\Schema\ObjectSchema;
1920
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
2021
use Chubbyphp\Parsing\Schema\RecordSchema;
@@ -106,6 +107,15 @@ public function object(array $fieldNameToSchema, string $classname = \stdClass::
106107
return new ObjectSchema($fieldNameToSchema, $classname);
107108
}
108109

110+
/**
111+
* @param array<string, SchemaInterface> $fieldNameToSchema
112+
* @param class-string $classname
113+
*/
114+
public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema
115+
{
116+
return new ObjectConstructorSchema($fieldNameToSchema, $classname);
117+
}
118+
109119
public function record(SchemaInterface $fieldSchema): RecordSchema
110120
{
111121
return new RecordSchema($fieldSchema);

src/ParserInterface.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Chubbyphp\Parsing\Schema\FloatSchema;
1515
use Chubbyphp\Parsing\Schema\IntSchema;
1616
use Chubbyphp\Parsing\Schema\LiteralSchema;
17+
use Chubbyphp\Parsing\Schema\ObjectConstructorSchema;
1718
use Chubbyphp\Parsing\Schema\ObjectSchema;
1819
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
1920
use Chubbyphp\Parsing\Schema\RecordSchema;
@@ -23,8 +24,9 @@
2324
use Chubbyphp\Parsing\Schema\UnionSchema;
2425

2526
/**
26-
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
27-
* @method ConstSchema const(bool|float|int|string $const)
27+
* @method AssocSchema assoc(array<string, SchemaInterface> $fieldNameToSchema)
28+
* @method ConstSchema const(bool|float|int|string $const)
29+
* @method ObjectConstructorSchema objectConstructor(array<string, SchemaInterface> $fieldNameToSchema, class-string $classname)
2830
*/
2931
interface ParserInterface
3032
{
@@ -71,6 +73,12 @@ public function literal(bool|float|int|string $literal): LiteralSchema;
7173
*/
7274
public function object(array $fieldNameToSchema, string $classname = \stdClass::class): ObjectSchema;
7375

76+
// /**
77+
// * @param array<string, SchemaInterface> $fieldNameToSchema
78+
// * @param class-string $classname
79+
// */
80+
// public function objectConstructor(array $fieldNameToSchema, string $classname): ObjectConstructorSchema;
81+
7482
public function record(SchemaInterface $fieldSchema): RecordSchema;
7583

7684
public function string(): StringSchema;

src/Schema/ObjectConstructorSchema.php

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@
77
use Chubbyphp\Parsing\Error;
88
use Chubbyphp\Parsing\Errors;
99
use Chubbyphp\Parsing\ErrorsException;
10+
use Symfony\Component\TypeInfo\Type;
11+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
1012

1113
final class ObjectConstructorSchema extends AbstractObjectSchema implements ObjectSchemaInterface
1214
{
1315
public const string ERROR_TYPE_CODE = 'object.type';
1416
public const string ERROR_UNKNOWN_FIELD_CODE = 'object.unknownField';
1517

1618
public const string ERROR_PARAMETER_TYPE_CODE = 'object.parameterType';
17-
public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{index}} {{name}} should be of {{type}}, {{given}} given';
19+
public const string ERROR_PARAMETER_TYPE_TEMPLATE = 'Parameter {{name}} should be of {{type}}, {{given}} given';
1820

19-
private readonly string $typeErrorPattern;
21+
/**
22+
* @var array<string, Type>
23+
*/
24+
private readonly array $fieldToType;
2025

2126
/**
2227
* @param array<mixed, mixed> $fieldToSchema
@@ -36,33 +41,45 @@ public function __construct(array $fieldToSchema, private string $classname)
3641
throw new \InvalidArgumentException('Class "'.$classname.'" does not have a __construct method');
3742
}
3843

39-
$parameterFieldToSchema = [];
40-
4144
/** @var list<string> $missingFieldToSchema */
4245
$missingFieldToSchema = [];
46+
47+
/** @var array<string, SchemaInterface> $sortedFieldToSchema */
48+
$sortedFieldToSchema = [];
49+
50+
$typeResolver = TypeResolver::create();
51+
52+
/** @var array<string, Type> $fieldToType */
53+
$fieldToType = [];
4354
foreach ($constructorReflectionMethod->getParameters() as $parameterReflection) {
4455
$name = $parameterReflection->getName();
56+
$fieldToType[$name] = $typeResolver->resolve($parameterReflection);
4557

4658
if (isset($fieldToSchema[$name])) {
47-
$parameterFieldToSchema[$name] = $fieldToSchema[$name];
48-
59+
$sortedFieldToSchema[$name] = $fieldToSchema[$name];
4960
unset($fieldToSchema[$name]);
5061
} elseif (!$parameterReflection->isOptional()) {
5162
$missingFieldToSchema[] = $name;
5263
}
5364
}
5465

5566
if ([] !== $missingFieldToSchema) {
56-
throw new \InvalidArgumentException('Missing fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', $missingFieldToSchema).'"');
67+
throw new \InvalidArgumentException(
68+
'Missing fieldToSchema for "'.$classname.'" __construct parameters: "'
69+
.implode('", "', $missingFieldToSchema).'"'
70+
);
5771
}
5872

5973
if ([] !== $fieldToSchema) {
60-
throw new \InvalidArgumentException('Additional fieldToSchema for "'.$classname.'" __construct parameters: "'.implode('", "', array_keys($fieldToSchema)).'"');
74+
throw new \InvalidArgumentException(
75+
'Additional fieldToSchema for "'.$classname.'" __construct parameters: "'
76+
.implode('", "', array_keys($fieldToSchema)).'"'
77+
);
6178
}
6279

63-
$this->typeErrorPattern = \sprintf('/%s::__construct\(\): Argument #(\d+) \(([^)]+)\) must be of type ([^ ]+), ([^ ]+) given/', preg_quote($this->classname, '/'));
80+
$this->fieldToType = $fieldToType;
6481

65-
parent::__construct($parameterFieldToSchema);
82+
parent::__construct($sortedFieldToSchema);
6683
}
6784

6885
/**
@@ -78,28 +95,32 @@ protected function parseFields(array $input, Errors $childrenErrors): ?object
7895
continue;
7996
}
8097

81-
$parameters[$fieldName] = $fieldSchema->parse($input[$fieldName] ?? null);
82-
} catch (ErrorsException $e) {
83-
$childrenErrors->add($e->errors, $fieldName);
84-
}
85-
}
98+
$fieldValue = $fieldSchema->parse($input[$fieldName] ?? null);
8699

87-
try {
88-
return new ($this->classname)(...$parameters);
89-
} catch (\TypeError $e) {
90-
$matches = [];
100+
$fieldType = $this->fieldToType[$fieldName];
91101

92-
if (1 === preg_match($this->typeErrorPattern, $e->getMessage(), $matches)) {
93-
throw new ErrorsException(
94-
new Error(
102+
if (!$fieldType->accepts($fieldValue)) {
103+
throw new ErrorsException(new Error(
95104
self::ERROR_PARAMETER_TYPE_CODE,
96105
self::ERROR_PARAMETER_TYPE_TEMPLATE,
97-
['index' => $matches[1], 'name' => $matches[2], 'type' => $matches[3], 'given' => $matches[4]]
98-
)
99-
);
106+
[
107+
'name' => $fieldName,
108+
'type' => (string) $fieldType,
109+
'given' => $this->getDataType($fieldValue),
110+
]
111+
));
112+
}
113+
114+
$parameters[$fieldName] = $fieldValue;
115+
} catch (ErrorsException $e) {
116+
$childrenErrors->add($e->errors, $fieldName);
100117
}
118+
}
101119

102-
throw $e;
120+
if (!$childrenErrors->has()) {
121+
return new ($this->classname)(...$parameters);
103122
}
123+
124+
return null;
104125
}
105126
}

0 commit comments

Comments
 (0)