Skip to content

Commit f751ec6

Browse files
authored
Merge pull request #121 from wol-soft/feat/composition-required-promotion
Add composition required promotion and fix allOf untyped-branch type erasure
2 parents de7dc05 + 10bf4ee commit f751ec6

32 files changed

Lines changed: 1384 additions & 24 deletions

.claude/settings.local.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,20 @@
1818
"Bash(cat:*)",
1919
"Bash(where gh:*)",
2020
"Bash(composer update:*)",
21-
"Bash(./vendor/bin/rector process:*)",
21+
"Bash(./vendor/bin/rector:*)",
2222
"Bash(git checkout:*)",
2323
"Bash(git add:*)",
24-
"Bash(./vendor/bin/rector --version 2>&1 | grep Rector)",
25-
"Bash(./vendor/bin/phpcs --standard=PSR12 --report=summary src/ 2>&1)",
26-
"Bash(./vendor/bin/phpcs --standard=PSR12 --report=source src/)",
27-
"Bash(./vendor/bin/phpcs --standard=PSR12 --report=json src/)",
2824
"Bash(python3 -c \":*)",
29-
"Bash(./vendor/bin/phpcbf --standard=phpcs.xml src/)",
30-
"Bash(./vendor/bin/phpcs --standard=phpcs.xml --report=full src/)",
31-
"Bash(./vendor/bin/phpcs --standard=phpcs.xml src/)",
32-
"Bash(./vendor/bin/phpcs --standard=phpcs.xml --report=summary src/)",
25+
"Bash(./vendor/bin/phpcbf:*)",
26+
"Bash(./vendor/bin/phpcs:*)",
3327
"Bash(git merge:*)",
34-
"Bash(gh api:*)"
28+
"Bash(gh api:*)",
29+
"Bash(git restore:*)",
30+
"Bash(git stash:*)",
31+
"Bash(composer show:*)",
32+
"Bash(php -r \":*)",
33+
"Bash(gh pr:*)",
34+
"Bash(xargs wc:*)"
3535
]
3636
}
3737
}

docs/source/combinedSchemas/allOf.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,10 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\All
8181
the intersection of all declared types across branches. See
8282
`Cross-typed compositions <crossTypedComposition.html>`__ for the full explanation and a contrast
8383
with ``anyOf``/``oneOf`` union widening.
84+
85+
.. note::
86+
87+
For object-level ``allOf`` compositions, when a property appears in the ``required`` array of
88+
**any** branch, the generator promotes that property to non-nullable in the generated class. All
89+
``allOf`` branches must hold simultaneously, so any branch's ``required`` constraint is effectively
90+
global. See `Cross-typed compositions <crossTypedComposition.html>`__ for the full promotion rules.

docs/source/combinedSchemas/anyOf.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,11 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any
7070
widens the property to a union type. See
7171
`Cross-typed compositions <crossTypedComposition.html>`__ for the full explanation including
7272
nullability rules and the ``allOf`` contrast.
73+
74+
.. note::
75+
76+
For object-level ``anyOf`` compositions, when a property appears in the ``required`` array of
77+
**every** branch, the generator promotes that property to non-nullable in the generated class.
78+
Because at least one branch must apply and all branches guarantee the property's presence, the
79+
getter can safely be non-nullable. See `Cross-typed compositions <crossTypedComposition.html>`__
80+
for the full promotion rules.

docs/source/combinedSchemas/crossTypedComposition.rst

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,72 @@ The ``age`` property appears in both branches with different types, so the gener
4949
object might satisfy the ``anyOf`` constraint via a branch that carries a different property
5050
entirely, leaving ``age`` absent.
5151

52-
Nullability
53-
-----------
52+
Nullability and required promotion
53+
------------------------------------
5454

5555
A property that is present in some branches but not others is always nullable in the generated
5656
class. The matching branch at runtime may not define the property at all, so the getter must be
5757
able to return ``null``.
5858

59-
If a property is marked as ``required`` in **every** branch that defines it, and at least one
60-
branch is guaranteed to apply, the property may be non-nullable.
59+
When the composition structure **guarantees** that a property will always be present, the generator
60+
promotes the property to non-nullable — regardless of whether ``implicitNull`` is enabled. The
61+
promotion rules depend on the keyword:
62+
63+
* **allOf** — property is promoted when it appears in ``required`` in **any** branch (because all
64+
branches must hold simultaneously, so any branch's ``required`` constraint applies globally).
65+
* **anyOf** / **oneOf** — property is promoted only when it appears in ``required`` in **every**
66+
branch (because only one branch applies at runtime; the property is present only if all branches
67+
guarantee it).
68+
* **if / then / else** — property is promoted only when it appears in ``required`` in **both**
69+
``then`` and ``else``. If only a ``then`` block exists (no ``else``), the property is never
70+
promoted because the ``else`` path may apply at runtime and the property would be absent.
71+
72+
For example, with a two-branch ``oneOf`` where ``age`` is required in both branches:
73+
74+
.. code-block:: json
75+
76+
{
77+
"$id": "example",
78+
"type": "object",
79+
"oneOf": [
80+
{
81+
"type": "object",
82+
"required": ["age"],
83+
"properties": {
84+
"age": {
85+
"type": "integer"
86+
}
87+
}
88+
},
89+
{
90+
"type": "object",
91+
"required": ["age"],
92+
"properties": {
93+
"age": {
94+
"type": "string"
95+
}
96+
}
97+
}
98+
]
99+
}
100+
101+
Generated interface:
102+
103+
.. code-block:: php
104+
105+
public function setAge(int | string $age): static;
106+
public function getAge(): int | string;
107+
108+
Because ``age`` is required in every branch, the generator removes the ``null`` from the union — a
109+
valid input always provides ``age``. If ``age`` were optional in even one branch, the getter would
110+
be ``int | string | null``.
111+
112+
.. note::
113+
114+
Promotion only affects the PHP type hint (nullability). It does **not** set ``isRequired()``
115+
to ``true`` on the property. The schema-level ``required`` constraint is still enforced by the
116+
composition validator, not by a separate required-value check. This means omitting a promoted
117+
property still raises a composition exception, not a ``RequiredValueException``.
61118

62119
Properties exclusive to a single branch
63120
----------------------------------------

docs/source/combinedSchemas/if.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,12 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at
167167
The union-widening and nullability rules for ``if``/``then``/``else`` follow the same logic as
168168
``anyOf``/``oneOf``. See `Cross-typed compositions <crossTypedComposition.html>`__ for the full
169169
explanation.
170+
171+
.. note::
172+
173+
For object-level ``if``/``then``/``else`` compositions, when a property appears in the
174+
``required`` array of **both** ``then`` and ``else``, the generator promotes that property to
175+
non-nullable. Exactly one of the two branches applies at runtime, and both guarantee the
176+
property's presence. If there is no ``else`` block, the property is never promoted — the schema
177+
is silent when the condition fails, so the property may be absent. See
178+
`Cross-typed compositions <crossTypedComposition.html>`__ for the full promotion rules.

docs/source/combinedSchemas/oneOf.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,11 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One
8080
widens the property to a union type. See
8181
`Cross-typed compositions <crossTypedComposition.html>`__ for the full explanation including
8282
nullability rules and the ``allOf`` contrast.
83+
84+
.. note::
85+
86+
For object-level ``oneOf`` compositions, when a property appears in the ``required`` array of
87+
**every** branch, the generator promotes that property to non-nullable in the generated class.
88+
Exactly one branch applies at runtime; because all branches guarantee the property's presence,
89+
the getter can safely be non-nullable. See `Cross-typed compositions <crossTypedComposition.html>`__
90+
for the full promotion rules.

src/ModelGenerator.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPModelGenerator\Model\GeneratorConfiguration;
1212
use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\ {
1313
AdditionalPropertiesPostProcessor,
14+
CompositionRequiredPromotionPostProcessor,
1415
CompositionValidationPostProcessor,
1516
ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor,
1617
PatternPropertiesPostProcessor,
@@ -45,7 +46,8 @@ public function __construct(protected GeneratorConfiguration $generatorConfigura
4546
->addPostProcessor(new CompositionValidationPostProcessor())
4647
->addPostProcessor(new AdditionalPropertiesPostProcessor())
4748
->addPostProcessor(new PatternPropertiesPostProcessor())
48-
->addPostProcessor(new ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor());
49+
->addPostProcessor(new ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor())
50+
->addPostProcessor(new CompositionRequiredPromotionPostProcessor());
4951
}
5052

5153
public function addPostProcessor(PostProcessor $postProcessor): self
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PHPModelGenerator\SchemaProcessor\PostProcessor\Internal;
6+
7+
use PHPModelGenerator\Model\GeneratorConfiguration;
8+
use PHPModelGenerator\Model\Property\PropertyInterface;
9+
use PHPModelGenerator\Model\Property\PropertyType;
10+
use PHPModelGenerator\Model\Schema;
11+
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
12+
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
13+
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
14+
use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor;
15+
use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor;
16+
17+
/**
18+
* Promotes properties transferred from composition branches to non-nullable when the composition
19+
* structure guarantees the property is always present in a valid object.
20+
*
21+
* Rules:
22+
* allOf — property is required in any branch (all branches apply simultaneously)
23+
* anyOf — property is required in every branch (at least one always matches)
24+
* oneOf — property is required in every branch (exactly one always matches)
25+
* if/then/else — property is required in both then and else (one always applies)
26+
*
27+
* The property's isRequired() flag is intentionally left false so the template short-circuit
28+
* (which exits early when the key is absent) continues to work correctly during construction.
29+
* Only the nullable flag on the PropertyType is changed to false.
30+
*/
31+
class CompositionRequiredPromotionPostProcessor extends PostProcessor
32+
{
33+
public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
34+
{
35+
foreach ($schema->getBaseValidators() as $validator) {
36+
if (!($validator instanceof AbstractComposedPropertyValidator)) {
37+
continue;
38+
}
39+
40+
foreach ($this->collectPromotablePropertyNames($validator) as $propertyName) {
41+
$this->promoteProperty($schema, $propertyName);
42+
}
43+
}
44+
}
45+
46+
/**
47+
* Returns the names of all properties that are guaranteed to be present by the given validator.
48+
*
49+
* @return string[]
50+
*/
51+
private function collectPromotablePropertyNames(AbstractComposedPropertyValidator $validator): array
52+
{
53+
if ($validator instanceof ConditionalPropertyValidator) {
54+
return $this->collectFromConditional($validator);
55+
}
56+
57+
return $this->collectFromComposed($validator);
58+
}
59+
60+
/**
61+
* For if/then/else: a property is guaranteed only when both then and else are present and
62+
* both require the property.
63+
*
64+
* @return string[]
65+
*/
66+
private function collectFromConditional(ConditionalPropertyValidator $validator): array
67+
{
68+
$branches = $validator->getConditionBranches();
69+
70+
if (count($branches) < 2) {
71+
return [];
72+
}
73+
74+
$requiredPerBranch = array_map(
75+
static fn($branch): array =>
76+
$branch->getNestedSchema()?->getJsonSchema()->getJson()['required'] ?? [],
77+
$branches,
78+
);
79+
80+
return array_values(array_intersect(...$requiredPerBranch));
81+
}
82+
83+
/**
84+
* For allOf: a property is guaranteed when it is required in any branch.
85+
* For anyOf/oneOf: a property is guaranteed when it is required in every branch.
86+
*
87+
* @return string[]
88+
*/
89+
private function collectFromComposed(ComposedPropertyValidator $validator): array
90+
{
91+
$branches = $validator->getComposedProperties();
92+
93+
if (empty($branches)) {
94+
return [];
95+
}
96+
97+
$requiredPerBranch = array_map(
98+
static fn($branch): array =>
99+
$branch->getNestedSchema()?->getJsonSchema()->getJson()['required'] ?? [],
100+
$branches,
101+
);
102+
103+
if (is_a($validator->getCompositionProcessor(), AllOfProcessor::class, true)) {
104+
return array_values(array_unique(array_merge(...$requiredPerBranch)));
105+
}
106+
107+
return array_values(array_intersect(...$requiredPerBranch));
108+
}
109+
110+
/**
111+
* Strips the nullable flag from the property's type if the property is not already required
112+
* at root level and has a type that can be promoted.
113+
*/
114+
private function promoteProperty(Schema $schema, string $propertyName): void
115+
{
116+
$property = array_find(
117+
$schema->getProperties(),
118+
static fn (PropertyInterface $property): bool => $property->getName() === $propertyName,
119+
);
120+
121+
if (!$property || $property->isRequired()) {
122+
return;
123+
}
124+
125+
$type = $property->getType();
126+
$outputType = $property->getType(true);
127+
128+
if ($type === null) {
129+
return;
130+
}
131+
132+
$property->setType(
133+
new PropertyType($type->getNames(), false),
134+
new PropertyType($outputType->getNames(), false),
135+
);
136+
}
137+
}

src/Utils/PropertyMerger.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ public function merge(
6969

7070
// Use getType(true) for the stored output type.
7171
// getType(false) post-Phase-5 returns a synthesised union and cannot be decomposed.
72+
//
73+
// For allOf: a truly-untyped incoming branch (no type keyword, not an explicit null-type
74+
// branch) adds no type constraint — all allOf branches apply simultaneously, so the
75+
// existing type is unaffected. Skip mergeNullableBranch in that case to avoid wrongly
76+
// wiping the existing type.
77+
if (
78+
$isAllOf
79+
&& $incoming->getType(true) === null
80+
&& !str_contains($incoming->getTypeHint(), 'null')
81+
) {
82+
return;
83+
}
84+
7285
if ($this->mergeNullableBranch($existing, $incoming) || $this->mergeIntoExistingNull($existing, $incoming)) {
7386
return;
7487
}

0 commit comments

Comments
 (0)