• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wol-soft / php-json-schema-model-generator / 25945556987

15 May 2026 11:04PM UTC coverage: 97.998% (-0.6%) from 98.554%
25945556987

Pull #128

github

web-flow
Merge ce9326ed2 into fddd87e19
Pull Request #128: Filter composition ordering

750 of 796 new or added lines in 22 files covered. (94.22%)

9 existing lines in 3 files now uncovered.

5433 of 5544 relevant lines covered (98.0%)

588.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.48
/src/Model/Validator/Factory/Composition/IfValidatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Model\Validator\Factory\Composition;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Model\Property\BaseProperty;
9
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
10
use PHPModelGenerator\Model\Property\PropertyInterface;
11
use PHPModelGenerator\Model\Property\PropertyType;
12
use PHPModelGenerator\Model\Schema;
13
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
14
use PHPModelGenerator\Model\Validator;
15
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
16
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
17
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
18
use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker;
19
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
20
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
21
use PHPModelGenerator\Utils\RenderHelper;
22
use PHPModelGenerator\Utils\TypeIntersection;
23

24
class IfValidatorFactory
25
    extends AbstractCompositionValidatorFactory
26
    implements ComposedPropertiesValidatorFactoryInterface
27
{
28
    /**
29
     * @throws SchemaException
30
     */
31
    public function modify(
2,345✔
32
        SchemaProcessor $schemaProcessor,
33
        Schema $schema,
34
        PropertyInterface $property,
35
        JsonSchema $propertySchema,
36
    ): void {
37
        if (!isset($propertySchema->getJson()[$this->key]) || $this->shouldSkip($property, $propertySchema)) {
2,345✔
38
            return;
2,344✔
39
        }
40

41
        if (!isset($propertySchema->getJson()['then']) && !isset($propertySchema->getJson()['else'])) {
76✔
42
            throw new SchemaException(
1✔
43
                sprintf(
1✔
44
                    'Incomplete conditional composition for property %s in file %s',
1✔
45
                    $property->getName(),
1✔
46
                    $property->getJsonSchema()->getFile(),
1✔
47
                ),
1✔
48
            );
1✔
49
        }
50

51
        // Inherit the parent type into if/then/else sub-schemas before the filter check so
52
        // that sub-schemas that inherit 'object' are correctly recognised as object-typed.
53
        // Object-typed sub-schemas create nested schemas whose properties are processed
54
        // independently and are not subject to ComposedItem $value reset.
55
        $propertySchema = $this->inheritPropertyType($propertySchema);
75✔
56
        $json = $propertySchema->getJson();
75✔
57

58
        // Check for filter keywords in if/then/else sub-schemas after type inheritance.
59
        // TODO: filters inside if/then/else sub-schemas cannot be correctly applied
60
        // (ComposedItem.phptpl resets $value to $originalModelData after each branch).
61
        // Proper per-branch filter chaining is deferred to a follow-up topic.
62
        foreach (['if', 'then', 'else'] as $keyword) {
75✔
63
            if (
64
                isset($json[$keyword])
75✔
65
                && is_array($json[$keyword])
75✔
66
                && CompositionCompatibilityChecker::branchContainsFilter($json[$keyword])
75✔
67
            ) {
68
                throw new SchemaException(sprintf(
1✔
69
                    'A filter keyword inside an if/then/else composition branch is not supported'
1✔
70
                        . ' for property %s in file %s (%s sub-schema).',
1✔
71
                    $property->getName(),
1✔
72
                    $property->getJsonSchema()->getFile(),
1✔
73
                    $keyword,
1✔
74
                ));
1✔
75
            }
76
        }
77

78
        $propertyFactory = new PropertyFactory();
74✔
79

80
        $onlyForDefinedValues = !($property instanceof BaseProperty)
74✔
81
            && (!$property->isRequired()
74✔
82
                && $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed());
74✔
83

84
        /** @var array<string, CompositionPropertyDecorator|null> $properties */
85
        $properties = [];
74✔
86

87
        foreach (['if', 'then', 'else'] as $keyword) {
74✔
88
            if (!isset($json[$keyword])) {
74✔
89
                $properties[$keyword] = null;
19✔
90
                continue;
19✔
91
            }
92

93
            $compositionSchema = $propertySchema->navigate($keyword);
74✔
94

95
            $compositionProperty = new CompositionPropertyDecorator(
74✔
96
                $property->getName(),
74✔
97
                $compositionSchema,
74✔
98
                $propertyFactory->create(
74✔
99
                    $schemaProcessor,
74✔
100
                    $schema,
74✔
101
                    $property->getName(),
74✔
102
                    $compositionSchema,
74✔
103
                    $property->isRequired(),
74✔
104
                ),
74✔
105
            );
74✔
106

107
            $compositionProperty->onResolve(static function () use ($compositionProperty): void {
74✔
108
                $compositionProperty->filterValidators(
74✔
109
                    static fn(Validator $validator): bool =>
74✔
110
                        !is_a($validator->getValidator(), RequiredPropertyValidator::class) &&
74✔
111
                        !is_a($validator->getValidator(), ComposedPropertyValidator::class),
74✔
112
                );
74✔
113
            });
74✔
114

115
            $properties[$keyword] = $compositionProperty;
74✔
116
        }
117

118
        $this->applyIfThenElseTypeSemantics($property, $properties);
74✔
119

120
        $property->addValidator(
73✔
121
            new ConditionalPropertyValidator(
73✔
122
                $schemaProcessor->getGeneratorConfiguration(),
73✔
123
                $property,
73✔
124
                array_values(array_filter($properties)),
73✔
125
                array_values(array_filter([$properties['then'], $properties['else']])),
73✔
126
                [
73✔
127
                    'ifProperty' => $properties['if'],
73✔
128
                    'thenProperty' => $properties['then'],
73✔
129
                    'elseProperty' => $properties['else'],
73✔
130
                    'schema' => $schema,
73✔
131
                    'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(),
73✔
132
                    'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()),
73✔
133
                    'onlyForDefinedValues' => $onlyForDefinedValues,
73✔
134
                ],
73✔
135
            ),
73✔
136
            100,
73✔
137
        );
73✔
138
    }
139

140
    /**
141
     * Apply type widening and conflict detection for property-level if/then/else.
142
     *
143
     * When both then and else branches are present, a coordinated callback fires after both
144
     * resolve to:
145
     * - Detect unsatisfiable schemas: if the parent property has a declared type that is
146
     *   incompatible (empty intersection) with BOTH then and else types, throw SchemaException.
147
     * - Widen the property type: when the parent property has no declared type, compute the
148
     *   union of then and else types (anyOf-like semantics) and apply it to the parent property.
149
     *
150
     * If else is absent, the absent branch accepts any value when `if` evaluates to false, so
151
     * the composition cannot constrain the type further; widening is skipped (property stays
152
     * mixed). Conflict detection also requires both branches to be present — a single conflicting
153
     * branch does not make the schema unsatisfiable.
154
     *
155
     * The parent type is captured at call time (before branch resolution) to avoid reading a
156
     * value that may have been mutated by concurrent onResolve side-effects.
157
     *
158
     * @param array<string, CompositionPropertyDecorator|null> $properties
159
     *
160
     * @throws SchemaException
161
     */
162
    private function applyIfThenElseTypeSemantics(
74✔
163
        PropertyInterface $property,
164
        array $properties,
165
    ): void {
166
        $thenProperty = $properties['then'];
74✔
167
        $elseProperty = $properties['else'];
74✔
168

169
        if ($thenProperty === null || $elseProperty === null) {
74✔
170
            // Absent branch = untyped (accepts any value on that path) — no type widening or
171
            // conflict detection possible without both branches.
172
            return;
19✔
173
        }
174

175
        // Capture before any branch-resolution callback can mutate the parent type.
176
        $originalParentType = $property->getType(true);
55✔
177

178
        $resolvedCount = 0;
55✔
179
        $onBothResolved = function () use (
55✔
180
            &$resolvedCount,
55✔
181
            $property,
55✔
182
            $thenProperty,
55✔
183
            $elseProperty,
55✔
184
            $originalParentType,
55✔
185
        ): void {
55✔
186
            $resolvedCount++;
55✔
187
            if ($resolvedCount < 2) {
55✔
188
                return;
55✔
189
            }
190

191
            // Object-level if/then/else creates nested schemas in the branches; type merging for
192
            // that case is owned by PropertyMerger. Skip here to avoid false conflict detection
193
            // (the parent 'object' type and branch generated-class types appear disjoint to
194
            // TypeIntersection::compute even though they are semantically compatible).
195
            if ($thenProperty->getNestedSchema() !== null || $elseProperty->getNestedSchema() !== null) {
55✔
196
                return;
40✔
197
            }
198

199
            if ($originalParentType !== null) {
15✔
200
                $this->detectIfThenElseConflict(
13✔
201
                    $property,
13✔
202
                    $originalParentType,
13✔
203
                    $thenProperty,
13✔
204
                    $elseProperty,
13✔
205
                );
13✔
206
                // Parent type constrains the property independently; widening is not applied.
207
                return;
12✔
208
            }
209

210
            // No parent type: widen to the union of then and else types (anyOf-like semantics).
211
            $this->transferPropertyType($property, [$thenProperty, $elseProperty], false);
2✔
212
        };
55✔
213

214
        $thenProperty->onResolve($onBothResolved);
55✔
215
        $elseProperty->onResolve($onBothResolved);
55✔
216
    }
217

218
    /**
219
     * Throw a SchemaException when the parent property's declared type is incompatible with
220
     * BOTH the then and the else branch types (empty intersection on both sides).
221
     *
222
     * A schema is unsatisfiable under this condition: no value can simultaneously satisfy the
223
     * parent type constraint and whichever composition branch fires. The schema is NOT
224
     * unsatisfiable when only one branch conflicts (values that satisfy the compatible branch
225
     * are still valid).
226
     *
227
     * @throws SchemaException
228
     */
229
    private function detectIfThenElseConflict(
13✔
230
        PropertyInterface $property,
231
        PropertyType $originalParentType,
232
        CompositionPropertyDecorator $thenProperty,
233
        CompositionPropertyDecorator $elseProperty,
234
    ): void {
235
        $parentNames = array_filter(
13✔
236
            $originalParentType->getNames(),
13✔
237
            static fn(string $typeName): bool => $typeName !== 'null',
13✔
238
        );
13✔
239

240
        if (empty($parentNames)) {
13✔
NEW
UNCOV
241
            return;
×
242
        }
243

244
        $thenType = $thenProperty->getType();
13✔
245
        $elseType = $elseProperty->getType();
13✔
246

247
        if ($thenType === null || $elseType === null) {
13✔
248
            // At least one branch is untyped — it accepts any value, so no total conflict.
NEW
UNCOV
249
            return;
×
250
        }
251

252
        $thenNonNull = array_filter(
13✔
253
            $thenType->getNames(),
13✔
254
            static fn(string $typeName): bool => $typeName !== 'null',
13✔
255
        );
13✔
256
        $elseNonNull = array_filter(
13✔
257
            $elseType->getNames(),
13✔
258
            static fn(string $typeName): bool => $typeName !== 'null',
13✔
259
        );
13✔
260

261
        $thenConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($thenNonNull)));
13✔
262
        $elseConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($elseNonNull)));
13✔
263

264
        if ($thenConflicts && $elseConflicts) {
13✔
265
            throw new SchemaException(sprintf(
1✔
266
                "Property '%s' has a type that conflicts with all if/then/else composition branches"
1✔
267
                    . ' (file %s). No value can satisfy both the property type and the applicable'
1✔
268
                    . ' branch constraint, making this schema unsatisfiable.',
1✔
269
                $property->getName(),
1✔
270
                $property->getJsonSchema()->getFile(),
1✔
271
            ));
1✔
272
        }
273
    }
274
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc