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

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

20 May 2026 10:28AM UTC coverage: 98.466% (-0.09%) from 98.554%
26158351968

Pull #128

github

wol-soft
Fix if/then/else branch conflict detection for null-typed and per-branch checks

IfValidatorFactory previously required both then and else branches to conflict
with the parent before throwing, and stripped null from parent type names — so a
null-typed branch against a non-null parent was silently accepted. Now each branch
is checked independently and null from isNullable() is included in the parent set.
getBranchTypeNames() distinguishes null-typed branches ({type: null}) from truly
untyped branches via getTypeHint(), so null-typed branches correctly intersect (or
conflict) with the parent type. CLAUDE.md extended with review-learning policy and
line-number-in-docblock rule. New test coverage for all affected edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #128: Filter composition ordering

772 of 792 new or added lines in 22 files covered. (97.47%)

5 existing lines in 3 files now uncovered.

5455 of 5540 relevant lines covered (98.47%)

592.57 hits per line

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

99.42
/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.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\Factory\AbstractValidatorFactory;
17
use PHPModelGenerator\Model\Validator\InstanceOfValidator;
18
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
19
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator;
20
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
21
use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker;
22
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
23
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
24
use PHPModelGenerator\Utils\TypeIntersection;
25

26
abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory
27
{
28
    /**
29
     * Emit a warning when the composition array for the current keyword is empty.
30
     */
31
    protected function warnIfEmpty(
651✔
32
        SchemaProcessor $schemaProcessor,
33
        PropertyInterface $property,
34
        JsonSchema $propertySchema,
35
    ): void {
36
        if (
37
            empty($propertySchema->getJson()[$this->key]) &&
651✔
38
            $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()
651✔
39
        ) {
40
            // @codeCoverageIgnoreStart
41
            echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n";
42
            // @codeCoverageIgnoreEnd
43
        }
44
    }
45

46
    /**
47
     * Returns true when composition processing should be skipped for this property.
48
     *
49
     * For non-root object-typed properties, composition keywords are processed inside
50
     * the nested schema by processSchema (with the type=base path). Adding a composition
51
     * validator at the parent level would duplicate validation and inject a _Merged_ type
52
     * hint that overrides the correct nested-class type.
53
     */
54
    protected function shouldSkip(PropertyInterface $property, JsonSchema $propertySchema): bool
810✔
55
    {
56
        return !($property instanceof BaseProperty)
810✔
57
            && ($propertySchema->getJson()['type'] ?? '') === 'object';
810✔
58
    }
59

60
    /**
61
     * Check the (post-type-inheritance) composition branches for filter keywords.
62
     *
63
     * Must be called AFTER inheritPropertyType() in each modify() method. A branch that
64
     * inherits "object" from the parent is genuinely object-typed: PropertyFactory routes
65
     * it through processSchema, producing a nested class whose properties are processed
66
     * independently and are not subject to ComposedItem $value reset. branchContainsFilter()
67
     * correctly skips the properties scan for such branches.
68
     *
69
     * For "not", the value is a single branch schema (not an array); all other keywords
70
     * use an array of branches.
71
     *
72
     * TODO: filters inside composition branches cannot be correctly applied
73
     * (ComposedItem.phptpl resets $value to $originalModelData after each branch).
74
     * Proper per-branch filter chaining is deferred to a follow-up topic.
75
     *
76
     * @throws SchemaException
77
     */
78
    protected function checkForFilterInBranches(
749✔
79
        PropertyInterface $property,
80
        JsonSchema $propertySchema,
81
    ): void {
82
        $json = $propertySchema->getJson();
749✔
83

84
        if ($this->key === 'not') {
749✔
85
            $branch = $json['not'] ?? null;
102✔
86
            if (
87
                is_array($branch)
102✔
88
                && CompositionCompatibilityChecker::branchContainsFilter($branch)
102✔
89
            ) {
90
                throw new SchemaException(sprintf(
1✔
91
                    'A filter keyword inside a not composition branch is not supported'
1✔
92
                        . ' for property %s in file %s.',
1✔
93
                    $property->getName(),
1✔
94
                    $property->getJsonSchema()->getFile(),
1✔
95
                ));
1✔
96
            }
97
            return;
101✔
98
        }
99

100
        foreach ($json[$this->key] ?? [] as $index => $compositionElement) {
651✔
101
            if (
102
                is_array($compositionElement)
632✔
103
                && CompositionCompatibilityChecker::branchContainsFilter($compositionElement)
632✔
104
            ) {
105
                throw new SchemaException(sprintf(
6✔
106
                    'A filter keyword inside a %s composition branch is not supported'
6✔
107
                        . ' for property %s in file %s (branch #%d).',
6✔
108
                    $this->key,
6✔
109
                    $property->getName(),
6✔
110
                    $property->getJsonSchema()->getFile(),
6✔
111
                    $index,
6✔
112
                ));
6✔
113
            }
114
        }
115
    }
116

117
    /**
118
     * Build composition sub-properties for the current keyword's branches.
119
     *
120
     * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches.
121
     *
122
     * @return CompositionPropertyDecorator[]
123
     *
124
     * @throws SchemaException
125
     */
126
    protected function getCompositionProperties(
742✔
127
        SchemaProcessor $schemaProcessor,
128
        Schema $schema,
129
        PropertyInterface $property,
130
        JsonSchema $propertySchema,
131
        bool $merged,
132
    ): array {
133
        $propertyFactory = new PropertyFactory();
742✔
134
        $compositionProperties = [];
742✔
135
        $json = $propertySchema->getJson()['propertySchema']->getJson();
742✔
136

137
        $property->addTypeHintDecorator(new ClearTypeHintDecorator());
742✔
138

139
        foreach ($json[$this->key] as $index => $compositionElement) {
742✔
140
            $compositionSchema = $propertySchema->getJson()['propertySchema']->navigate("$this->key/$index");
723✔
141

142
            $compositionProperty = new CompositionPropertyDecorator(
723✔
143
                $property->getName(),
723✔
144
                $compositionSchema,
723✔
145
                $propertyFactory->create(
723✔
146
                    $schemaProcessor,
723✔
147
                    $schema,
723✔
148
                    $property->getName(),
723✔
149
                    $compositionSchema,
723✔
150
                    $property->isRequired(),
723✔
151
                ),
723✔
152
            );
723✔
153

154
            $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void {
723✔
155
                $nestedSchema = $compositionProperty->getNestedSchema();
723✔
156

157
                $compositionProperty->filterValidators(
723✔
158
                    static function (Validator $validator) use ($nestedSchema): bool {
723✔
159
                        if (is_a($validator->getValidator(), RequiredPropertyValidator::class)) {
718✔
160
                            return false;
461✔
161
                        }
162
                        if (is_a($validator->getValidator(), ComposedPropertyValidator::class)) {
712✔
163
                            return false;
1✔
164
                        }
165
                        // An empty object schema ({type: object} with no declared properties)
166
                        // must accept any PHP object in composition context. The generated
167
                        // placeholder class carries no semantic constraints, so the strict
168
                        // instanceof check against it would incorrectly reject valid objects
169
                        // (e.g. a DateTime produced by a transforming filter) that are perfectly
170
                        // acceptable under the schema's actual semantics.
171
                        if (
172
                            is_a($validator->getValidator(), InstanceOfValidator::class)
712✔
173
                            && $nestedSchema !== null
712✔
174
                            && empty($nestedSchema->getProperties())
712✔
175
                        ) {
176
                            return false;
7✔
177
                        }
178
                        return true;
712✔
179
                    },
723✔
180
                );
723✔
181

182
                if (!($merged && $compositionProperty->getNestedSchema())) {
723✔
183
                    $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty));
427✔
184
                }
185
            });
723✔
186

187
            $compositionProperties[] = $compositionProperty;
723✔
188
        }
189

190
        return $compositionProperties;
742✔
191
    }
192

193
    /**
194
     * Inherit a parent-level type into composition branches that declare no type.
195
     */
196
    protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema
809✔
197
    {
198
        $json = $propertySchema->getJson();
809✔
199

200
        if (!isset($json['type'])) {
809✔
201
            return $propertySchema;
359✔
202
        }
203

204
        switch ($this->key) {
471✔
205
            case 'not':
471✔
206
                if (!isset($json[$this->key]['type'])) {
28✔
207
                    $json[$this->key]['type'] = $json['type'];
28✔
208
                }
209
                break;
28✔
210
            case 'if':
443✔
211
                return $this->inheritIfPropertyType($propertySchema->withJson($json));
73✔
212
            default:
213
                foreach ($json[$this->key] as &$composedElement) {
388✔
214
                    if (!isset($composedElement['type'])) {
387✔
215
                        $composedElement['type'] = $json['type'];
176✔
216
                    }
217
                }
218
        }
219

220
        return $propertySchema->withJson($json);
416✔
221
    }
222

223
    /**
224
     * Inherit the parent type into all branches of an if/then/else composition.
225
     */
226
    protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema
73✔
227
    {
228
        $json = $propertySchema->getJson();
73✔
229

230
        foreach (['if', 'then', 'else'] as $keyword) {
73✔
231
            if (!isset($json[$keyword])) {
73✔
232
                continue;
17✔
233
            }
234

235
            if (!isset($json[$keyword]['type'])) {
73✔
236
                $json[$keyword]['type'] = $json['type'];
73✔
237
            }
238
        }
239

240
        return $propertySchema->withJson($json);
73✔
241
    }
242

243
    /**
244
     * After all composition branches resolve, derive the parent property's type from the
245
     * branch types and apply it. Skips when any branch has a nested schema (object merging
246
     * is handled elsewhere).
247
     *
248
     * allOf: intersect all typed branch types — only values satisfying every branch simultaneously
249
     * are valid, so the PHP type is the intersection. Branches with no declared type impose no
250
     * constraint and are excluded from the intersection. An empty intersection (contradictory
251
     * branch types) throws SchemaException because no value can ever be valid.
252
     *
253
     * anyOf / oneOf: union of all typed branch types — at least one branch must pass, so the PHP
254
     * type is the union. An untyped branch accepts every value, making the composition satisfied
255
     * by any input; the property's type hint is removed (remains mixed) in that case.
256
     *
257
     * @param bool $isAllOf true for allOf, false for anyOf/oneOf.
258
     * @param CompositionPropertyDecorator[] $compositionProperties
259
     *
260
     * @throws SchemaException when allOf branches declare contradictory types.
261
     */
262
    protected function transferPropertyType(
628✔
263
        PropertyInterface $property,
264
        array $compositionProperties,
265
        bool $isAllOf,
266
    ): void {
267
        foreach ($compositionProperties as $compositionProperty) {
628✔
268
            if ($compositionProperty->getNestedSchema() !== null) {
628✔
269
                return;
468✔
270
            }
271
        }
272

273
        $hasBranchWithRequiredProperty = array_filter(
161✔
274
            $compositionProperties,
161✔
275
            static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(),
161✔
276
        ) !== [];
161✔
277

278
        $hasBranchWithOptionalProperty = $isAllOf
161✔
279
            ? !$hasBranchWithRequiredProperty
42✔
280
            : array_filter(
119✔
281
                $compositionProperties,
119✔
282
                static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(),
119✔
283
            ) !== [];
119✔
284

285
        if ($isAllOf) {
161✔
286
            $this->transferAllOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
42✔
287
            return;
40✔
288
        }
289

290
        $this->transferAnyOfOneOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
119✔
291
    }
292

293
    /**
294
     * Derive and apply the parent property's type using allOf intersection semantics.
295
     *
296
     * Only typed branches (those that declare a type keyword) constrain the intersection.
297
     * Untyped branches impose no type restriction and are excluded. Null is valid only when
298
     * ALL typed branches allow it. An empty non-null intersection (contradictory types) throws
299
     * SchemaException — no value can satisfy all branch type constraints simultaneously.
300
     *
301
     * @param CompositionPropertyDecorator[] $compositionProperties
302
     *
303
     * @throws SchemaException
304
     */
305
    private function transferAllOfType(
42✔
306
        PropertyInterface $property,
307
        array $compositionProperties,
308
        bool $hasBranchWithOptionalProperty,
309
    ): void {
310
        $constrainingBranches = array_values(array_filter(
42✔
311
            $compositionProperties,
42✔
312
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
42✔
313
        ));
42✔
314

315
        if (empty($constrainingBranches)) {
42✔
316
            // No typed branches — no type constraint to apply.
317
            return;
3✔
318
        }
319

320
        // Intersection of non-null type names across all typed branches.
321
        // TypeIntersection::compute handles int ⊂ float (integer is a subtype of number in JSON Schema).
322
        $nonNullSets = array_map(
39✔
323
            static fn(CompositionPropertyDecorator $p): array => array_values(array_filter(
39✔
324
                $p->getType()->getNames(),
39✔
325
                static fn(string $typeName): bool => $typeName !== 'null',
39✔
326
            )),
39✔
327
            $constrainingBranches,
39✔
328
        );
39✔
329
        $nonNullNames = array_shift($nonNullSets);
39✔
330
        foreach ($nonNullSets as $typeSet) {
39✔
331
            $nonNullNames = TypeIntersection::compute($nonNullNames, $typeSet);
32✔
332
        }
333

334
        // Null is valid in allOf only when ALL typed branches allow it.
335
        $allBranchesAllowNull = count(array_filter(
39✔
336
            $constrainingBranches,
39✔
337
            static fn(CompositionPropertyDecorator $p): bool =>
39✔
338
                in_array('null', $p->getType()->getNames(), true)
39✔
339
                || $p->getType()->isNullable() === true,
39✔
340
        )) === count($constrainingBranches);
39✔
341

342
        if (empty($nonNullNames) && !$allBranchesAllowNull) {
39✔
343
            throw new SchemaException(sprintf(
2✔
344
                "Property '%s' is defined with conflicting types in allOf composition branches"
2✔
345
                    . ' (file %s). allOf requires all constraints to hold simultaneously,'
2✔
346
                    . ' making this schema unsatisfiable.',
2✔
347
                $property->getName(),
2✔
348
                $property->getJsonSchema()->getFile(),
2✔
349
            ));
2✔
350
        }
351

352
        if (empty($nonNullNames)) {
37✔
353
            // Only null survives the intersection; the null-processor path handles pure-null types.
354
            return;
1✔
355
        }
356

357
        $nullable = ($allBranchesAllowNull || $hasBranchWithOptionalProperty) ? true : null;
36✔
358
        $property->setType(new PropertyType($nonNullNames, $nullable));
36✔
359
    }
360

361
    /**
362
     * Derive and apply the parent property's type using anyOf/oneOf union semantics.
363
     *
364
     * Branches are partitioned into three categories:
365
     * - Typed (getType() !== null): contribute their names to the union.
366
     * - Explicit null-type ({type:null}): getType() is null but typeHint contains 'null';
367
     *   contributes nullable=true to the result.
368
     * - Truly untyped ({}): getType() is null and typeHint does not contain 'null'; the branch
369
     *   accepts every value, so the composition is always satisfiable and no type hint applies.
370
     *
371
     * A truly untyped branch causes early return without setting a type (property remains mixed),
372
     * matching the behaviour of PropertyMerger::mergeNullableBranch for object-level compositions.
373
     * An explicit null-type branch ({type:null}) is NOT treated as untyped — it adds nullable=true
374
     * to the typed union rather than removing the type hint.
375
     *
376
     * @param CompositionPropertyDecorator[] $compositionProperties
377
     */
378
    private function transferAnyOfOneOfType(
119✔
379
        PropertyInterface $property,
380
        array $compositionProperties,
381
        bool $hasBranchWithOptionalProperty,
382
    ): void {
383
        $hasExplicitNullBranch = false;
119✔
384

385
        foreach ($compositionProperties as $compositionProperty) {
119✔
386
            if ($compositionProperty->getType() !== null) {
119✔
387
                continue;
114✔
388
            }
389

390
            if (str_contains($compositionProperty->getTypeHint(), 'null')) {
8✔
391
                // Explicit null-type branch ({type: null}): contributes nullable=true.
392
                $hasExplicitNullBranch = true;
2✔
393
            } else {
394
                // Truly untyped branch ({}): any value is valid, so the composition is
395
                // always satisfiable — no type hint is appropriate for this property.
396
                return;
6✔
397
            }
398
        }
399

400
        $typedBranches = array_values(array_filter(
113✔
401
            $compositionProperties,
113✔
402
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
113✔
403
        ));
113✔
404

405
        if (empty($typedBranches)) {
113✔
406
            // Only explicit null branches; no scalar type to build a union from.
407
            return;
1✔
408
        }
409

410
        $allNames = array_merge(...array_map(
112✔
411
            static fn(CompositionPropertyDecorator $p): array => $p->getType()->getNames(),
112✔
412
            $typedBranches,
112✔
413
        ));
112✔
414

415
        $hasNull = $hasExplicitNullBranch || in_array('null', $allNames, true);
112✔
416
        $nonNullNames = array_values(array_filter(
112✔
417
            array_unique($allNames),
112✔
418
            static fn(string $typeName): bool => $typeName !== 'null',
112✔
419
        ));
112✔
420

421
        if (!$nonNullNames) {
112✔
UNCOV
422
            return;
×
423
        }
424

425
        $nullable = ($hasNull || $hasBranchWithOptionalProperty) ? true : null;
112✔
426
        $property->setType(new PropertyType($nonNullNames, $nullable));
112✔
427
    }
428
}
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