Skip to content

Commit c406e0e

Browse files
authored
Merge pull request #147 from wol-soft/fix/composed-schema-branch-defaults
Add branch default handling for composed schemas (#140)
2 parents 6ca6622 + d71a188 commit c406e0e

47 files changed

Lines changed: 2289 additions & 86 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/source/combinedSchemas/allOf.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,13 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\All
9898
**any** branch, the generator promotes that property to non-nullable in the generated class. All
9999
``allOf`` branches must hold simultaneously, so any branch's ``required`` constraint is effectively
100100
global. See `Cross-typed compositions <crossTypedComposition.html>`__ for the full promotion rules.
101+
102+
.. note::
103+
104+
Properties in object-level ``allOf`` branches may carry a ``"default"`` value. Because all
105+
branches apply simultaneously, defaults from every branch are combined. When multiple branches
106+
define a default for the same property, those defaults must agree; the generator throws a
107+
``SchemaException`` at generation time if they differ.
108+
109+
See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full
110+
explanation.

docs/source/combinedSchemas/anyOf.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,15 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any
8888
Because at least one branch must apply and all branches guarantee the property's presence, the
8989
getter can safely be non-nullable. See `Cross-typed compositions <crossTypedComposition.html>`__
9090
for the full promotion rules.
91+
92+
.. note::
93+
94+
Properties in object-level ``anyOf`` branches may carry a ``"default"`` value. The generator
95+
applies the branch default only when that branch is among the active ones — determined at
96+
construction time by which branches the provided data satisfies. When multiple matching branches
97+
define a default for the same property, those defaults must agree; the generator throws a
98+
``SchemaException`` at generation time if they differ. Branch defaults are **not** included in
99+
``getRawModelDataInput()``.
100+
101+
See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full
102+
explanation.

docs/source/combinedSchemas/if.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,18 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at
194194
property's presence. If there is no ``else`` block, the property is never promoted — the schema
195195
is silent when the condition fails, so the property may be absent. See
196196
`Cross-typed compositions <crossTypedComposition.html>`__ for the full promotion rules.
197+
198+
.. note::
199+
200+
Properties in object-level ``then`` or ``else`` branches may carry a ``"default"`` value. The
201+
generator applies the branch default only when the relevant branch is active — the ``then``
202+
default applies when the ``if`` condition is satisfied, and the ``else`` default applies when it
203+
is not. A user-supplied value always overrides the branch default. Branch defaults are **not**
204+
included in ``getRawModelDataInput()``.
205+
206+
When a ``then`` or ``else`` branch default conflicts with a root ``properties`` default or a
207+
``patternProperties`` default for the same property, the generator throws a ``SchemaException``
208+
at generation time.
209+
210+
See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full
211+
explanation.

docs/source/combinedSchemas/oneOf.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,17 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One
9898
Exactly one branch applies at runtime; because all branches guarantee the property's presence,
9999
the getter can safely be non-nullable. See `Cross-typed compositions <crossTypedComposition.html>`__
100100
for the full promotion rules.
101+
102+
.. note::
103+
104+
Properties in object-level ``oneOf`` branches may carry a ``"default"`` value. The generator
105+
applies the branch default only when that branch is the active one — determined at construction
106+
time by which branch the provided data satisfies. A user-supplied value always overrides the
107+
branch default. Branch defaults are **not** included in ``getRawModelDataInput()``.
108+
109+
When two ``oneOf`` branches define a default for the same property, or when a branch default
110+
conflicts with a root ``properties`` default or a ``patternProperties`` default, the generator
111+
throws a ``SchemaException`` at generation time.
112+
113+
See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full
114+
explanation and examples.

docs/source/generic/default.rst

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,125 @@ Behaviour with different inputs:
3737
If no value for a property with a default value is defined the default value will be validated against all rules defined in the schema. Consequently you may get a validation error if the default value doesn't match your constraints.
3838

3939
If you use a `filter <../nonStandardExtensions/filter.html>`__ on a property with a default value the default value will be filtered if no value is provided for the property. If the filter is a `transforming filter <../nonStandardExtensions/filter.html#transforming-filter>`__ the default value will be transformed.
40+
41+
Branch defaults in compositions
42+
--------------------------------
43+
44+
Properties declared inside ``oneOf``, ``anyOf``, ``allOf``, ``if``/``then``/``else`` branches may
45+
also carry a ``"default"`` value. The generator supports branch-level defaults and applies them
46+
conditionally depending on the composition keyword.
47+
48+
**oneOf / anyOf / if–then–else**
49+
50+
For compositions where a single branch (or conditional branch) is active at a time, the branch
51+
default is applied only when that branch is the active one. A user-supplied value always takes
52+
precedence over the branch default.
53+
54+
.. code-block:: json
55+
56+
{
57+
"$id": "example",
58+
"type": "object",
59+
"oneOf": [
60+
{
61+
"properties": {
62+
"kind": {
63+
"type": "string",
64+
"enum": ["A"]
65+
}
66+
},
67+
"required": ["kind"]
68+
},
69+
{
70+
"properties": {
71+
"kind": {
72+
"type": "string",
73+
"enum": ["B"]
74+
},
75+
"timeout": {
76+
"type": "integer",
77+
"default": 30
78+
}
79+
},
80+
"required": ["kind"]
81+
}
82+
]
83+
}
84+
85+
.. code-block:: php
86+
87+
// Branch B is active — timeout defaults to 30
88+
$example = new Example(['kind' => 'B']);
89+
$example->getTimeout(); // returns 30
90+
91+
// Branch A is active — timeout has no default; returns null
92+
$example = new Example(['kind' => 'A']);
93+
$example->getTimeout(); // returns null
94+
95+
// User-supplied value overrides the branch default
96+
$example = new Example(['kind' => 'B', 'timeout' => 60]);
97+
$example->getTimeout(); // returns 60
98+
99+
Branch defaults are **not** included in ``getRawModelDataInput()``. Only values explicitly
100+
supplied by the caller appear in the raw input:
101+
102+
.. code-block:: php
103+
104+
$example = new Example(['kind' => 'B']);
105+
$example->getRawModelDataInput(); // returns ['kind' => 'B']
106+
107+
**allOf**
108+
109+
For ``allOf`` compositions, all branches apply simultaneously. When multiple branches define a
110+
default for the same property, the defaults must agree; a generation-time ``SchemaException`` is
111+
thrown if they differ.
112+
113+
**Conflict detection**
114+
115+
The generator detects conflicting defaults at schema-processing time and throws a
116+
``SchemaException`` when:
117+
118+
- Two ``oneOf`` or ``anyOf`` branches define the same property with different default values.
119+
- A branch default disagrees with a default on the matching root ``properties`` entry.
120+
- A ``patternProperties`` default disagrees with a branch or root default for the same property.
121+
- Two ``patternProperties`` patterns both match the same named property and specify different
122+
defaults.
123+
124+
**patternProperties defaults**
125+
126+
A ``"default"`` value on a ``patternProperties`` entry propagates to every named property whose
127+
key matches the pattern:
128+
129+
- If the named property is declared in the root ``properties`` section, it receives the default
130+
unconditionally — equivalent to placing the default directly on the property.
131+
- If the named property exists only inside a composition branch, it receives the default
132+
conditionally — the same branch-default mechanism applies.
133+
134+
.. code-block:: json
135+
136+
{
137+
"$id": "example",
138+
"type": "object",
139+
"properties": {
140+
"retry_count": {
141+
"type": "integer"
142+
}
143+
},
144+
"patternProperties": {
145+
"^retry_": {
146+
"type": "integer",
147+
"minimum": 1,
148+
"default": 3
149+
}
150+
}
151+
}
152+
153+
.. code-block:: php
154+
155+
// retry_count matches the pattern; default 3 is propagated
156+
$example = new Example([]);
157+
$example->getRetryCount(); // returns 3
158+
159+
// User-supplied value overrides the pattern default
160+
$example = new Example(['retry_count' => 5]);
161+
$example->getRetryCount(); // returns 5

src/Draft/Modifier/DefaultValueModifier.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ public function modify(
2525
return;
2626
}
2727

28+
// Scalar branch defaults are unreachable: the property value itself is what the
29+
// composition branch discriminates on, so a default can only fire when no value is
30+
// provided — but without a value there is no signal to select the branch in the
31+
// first place. Warn and drop rather than applying the default unconditionally.
32+
if ($this->isScalarInsideCompositionBranch($propertySchema)) {
33+
if ($schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()) {
34+
echo sprintf(
35+
"Warning: property '%s' declares a default value inside a composition branch"
36+
. " in file '%s'. Scalar branch defaults are unreachable and will be ignored.\n",
37+
$property->getName(),
38+
$propertySchema->getFile(),
39+
);
40+
}
41+
42+
return;
43+
}
44+
2845
$default = $json['default'];
2946
$types = isset($json['type']) ? (array) $json['type'] : [];
3047

@@ -56,4 +73,46 @@ public function modify(
5673
),
5774
);
5875
}
76+
77+
/**
78+
* Returns true when the given property schema is a scalar branch schema (not an object
79+
* with declared sub-properties) that sits directly at a composition branch level.
80+
*
81+
* Two cases must be distinguished:
82+
*
83+
* - A *named property inside an object-typed branch* (e.g. `sandbox` in
84+
* `oneOf/1/properties/sandbox`) — its pointer ends with `/properties/<name>`.
85+
* This is the object-level branch default handled by Phase 2; it must not be dropped.
86+
*
87+
* - A *branch schema that is itself scalar* (e.g. `oneOf/0` → `{type:string, default:"x"}`
88+
* where the property value IS the discriminant) — its pointer ends with a branch-index
89+
* segment, not `/properties/<name>`. This default is unreachable and must be dropped.
90+
*
91+
* A property whose pointer ends with `/properties/<name>` is always in the first category.
92+
* For the remaining pointers, stripping all `/properties/<name>` segments removes noise
93+
* from intermediate object-nesting, and the regex tests whether a composition keyword
94+
* segment is present in the structural path.
95+
*/
96+
private function isScalarInsideCompositionBranch(JsonSchema $propertySchema): bool
97+
{
98+
if (isset($propertySchema->getJson()['properties'])) {
99+
return false;
100+
}
101+
102+
$pointer = $propertySchema->getPointer();
103+
104+
// A pointer ending in /properties/<name> means this is a named property inside an
105+
// object-typed branch — handled by per-branch runtime application, not warned about.
106+
if (preg_match('#/properties/[^/]+$#', $pointer)) {
107+
return false;
108+
}
109+
110+
// Strip intermediate /properties/<name> segments to prevent false positives from
111+
// root properties coincidentally named after a composition keyword, then append '/'
112+
// so end-of-string branch segments are catchable by the regex.
113+
$structuralPointer = preg_replace('#/properties/[^/]+#', '', $pointer) . '/';
114+
115+
return preg_match('#/(allOf|anyOf|oneOf)/\d+/#', $structuralPointer)
116+
|| preg_match('#/(if|then|else)/#', $structuralPointer);
117+
}
59118
}

src/Model/Schema.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,11 @@ public function getProperties(): array
162162
return false;
163163
};
164164

165-
// order the properties to make sure properties with a SchemaDependencyValidator are validated at the beginning
166-
// of the validation process for correct exception order of the messages
167-
usort(
165+
// Order the properties to make sure properties with a SchemaDependencyValidator are validated at the beginning
166+
// of the validation process for correct exception order of the messages.
167+
// uasort preserves the string keys (property names) that getProperty() relies on;
168+
// usort would reindex to 0,1,2,... and break all subsequent name-based lookups.
169+
uasort(
168170
$this->properties,
169171
static function (
170172
PropertyInterface $property,

src/Model/Validator/AbstractComposedPropertyValidator.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace PHPModelGenerator\Model\Validator;
66

77
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
8+
use PHPModelGenerator\SchemaProcessor\PostProcessor\RenderedMethod;
89

910
/**
1011
* Class AbstractComposedPropertyValidator
@@ -17,6 +18,7 @@ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidato
1718
protected $compositionProcessor;
1819
/** @var CompositionPropertyDecorator[] */
1920
protected $composedProperties;
21+
protected string $modifiedValuesMethod = '';
2022

2123
public function getCompositionProcessor(): string
2224
{
@@ -30,4 +32,96 @@ public function getComposedProperties(): array
3032
{
3133
return $this->composedProperties;
3234
}
35+
36+
protected function initModifiedValuesMethod(): void
37+
{
38+
$this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5(spl_object_hash($this)), 0, 5);
39+
}
40+
41+
/**
42+
* Returns true when at least one composition branch has a nested schema with declared
43+
* properties, meaning the modified-values helper method may produce non-empty results.
44+
*/
45+
protected function hasNestedSchemaWithProperties(): bool
46+
{
47+
foreach ($this->composedProperties as $compositionProperty) {
48+
$nestedSchema = $compositionProperty->getNestedSchema();
49+
if ($nestedSchema !== null && !empty($nestedSchema->getProperties())) {
50+
return true;
51+
}
52+
}
53+
54+
return false;
55+
}
56+
57+
/**
58+
* Sets up the allBranchDefaultAttributeMap template variable and registers the
59+
* _getModifiedValues_* helper method on the schema scope. Properties that already carry
60+
* a root-level (unconditional) default in the parent schema are excluded from the map;
61+
* those defaults are applied via PHP field initializers and must not be reset by the
62+
* per-branch mechanism.
63+
*
64+
* Returns true when the helper method was registered (at least one branch has a nested
65+
* schema with properties), false otherwise.
66+
*/
67+
protected function setupBranchDefaultHelpers(): bool
68+
{
69+
$hasNestedSchemaWithProperties = $this->hasNestedSchemaWithProperties();
70+
71+
$this->templateValues['hasModifiedValuesMethod'] = $hasNestedSchemaWithProperties;
72+
73+
if (!$hasNestedSchemaWithProperties) {
74+
$this->templateValues['allBranchDefaultAttributeMap'] = var_export([], true);
75+
76+
return false;
77+
}
78+
79+
$allBranchDefaultAttributeMap = [];
80+
$componentDefaultValueMap = [];
81+
$propertyAccessors = [];
82+
83+
foreach ($this->composedProperties as $branchIndex => $compositionProperty) {
84+
if (!$compositionProperty->getNestedSchema()) {
85+
continue;
86+
}
87+
88+
foreach ($compositionProperty->getNestedSchema()->getProperties() as $branchProperty) {
89+
$propertyAccessors[$branchProperty->getName()] = 'get' . ucfirst($branchProperty->getAttribute());
90+
91+
if ($branchProperty->getDefaultValue() === null) {
92+
continue;
93+
}
94+
95+
$componentDefaultValueMap[$branchIndex][] = $branchProperty->getName();
96+
97+
// Do not include properties that already have a root-level default on the
98+
// parent schema — root defaults are applied unconditionally via PHP field
99+
// initializers and must not be overwritten or reset by the branch mechanism.
100+
if ($this->scope?->getProperty($branchProperty->getName())?->getDefaultValue() !== null) {
101+
continue;
102+
}
103+
104+
$allBranchDefaultAttributeMap[$branchProperty->getName()] = $branchProperty->getAttribute();
105+
}
106+
}
107+
108+
$this->templateValues['allBranchDefaultAttributeMap'] = var_export($allBranchDefaultAttributeMap, true);
109+
$this->templateValues['modifiedValuesMethod'] = $this->modifiedValuesMethod;
110+
111+
$this->scope->addMethod(
112+
$this->modifiedValuesMethod,
113+
new RenderedMethod(
114+
$this->scope,
115+
$this->generatorConfiguration,
116+
'GetModifiedValues.phptpl',
117+
[
118+
'modifiedValuesMethod' => $this->modifiedValuesMethod,
119+
'componentDefaultValueMap' => var_export($componentDefaultValueMap, true),
120+
'propertyAccessors' => var_export($propertyAccessors, true),
121+
],
122+
),
123+
);
124+
125+
return true;
126+
}
33127
}

0 commit comments

Comments
 (0)