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

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

28 Jul 2026 06:05AM UTC coverage: 98.735% (+0.001%) from 98.734%
30333648898

Pull #180

github

claude
Exclude internal bookkeeping properties from composition branch default helpers

setupBranchDefaultHelpers() iterated a composition branch's nested schema
properties without excluding internal ones, so an internal bookkeeping
property with a non-null default (eg. _skipNotProvidedPropertiesMap, added
whenever serialization is enabled) was misread as real branch data and
leaked into componentDefaultValueMap and propertyAccessors of the
generated _getModifiedValues_* helper, even though no getter is ever
generated for it.

Fixes #179.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL8i9sBrFNCQv4WdGwxfup
Pull Request #180: Exclude internal bookkeeping properties from composition branch default helpers

2 of 2 new or added lines in 1 file covered. (100.0%)

6 existing lines in 3 files now uncovered.

7178 of 7270 relevant lines covered (98.73%)

573.98 hits per line

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

99.62
/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\Generic\DeniedPropertyException;
8
use PHPModelGenerator\Exception\SchemaException;
9
use PHPModelGenerator\Model\Property\BaseProperty;
10
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
11
use PHPModelGenerator\Model\Property\PropertyInterface;
12
use PHPModelGenerator\Model\Property\PropertyType;
13
use PHPModelGenerator\Model\Schema;
14
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
15
use PHPModelGenerator\Model\Validator;
16
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
17
use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory;
18
use PHPModelGenerator\Model\Validator\InstanceOfValidator;
19
use PHPModelGenerator\Model\Validator\PropertyValidator;
20
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
21
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator;
22
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
23
use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker;
24
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
25
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
26
use PHPModelGenerator\Utils\TypeIntersection;
27

28
abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory
29
{
30
    /**
31
     * Emit a generation-time warning for always-unsatisfiable composition schemas.
32
     */
33
    protected function warnIfAlwaysFalse(
32✔
34
        SchemaProcessor $schemaProcessor,
35
        PropertyInterface $property,
36
        string $reason,
37
    ): void {
38
        $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning(
32✔
39
            "Always-unsatisfiable schema for property '{property}': {reason}",
32✔
40
            ['property' => $property->getName(), 'reason' => $reason],
32✔
41
        );
32✔
42
    }
43

44
    /**
45
     * Emit a warning when the composition array for the current keyword is empty.
46
     */
47
    protected function warnIfEmpty(
764✔
48
        SchemaProcessor $schemaProcessor,
49
        PropertyInterface $property,
50
        JsonSchema $propertySchema,
51
    ): void {
52
        if (empty($propertySchema->getJson()[$this->key])) {
764✔
53
            $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning(
23✔
54
                "Empty composition for '{property}' may lead to unexpected results",
23✔
55
                ['property' => $property->getName()],
23✔
56
            );
23✔
57
        }
58
    }
59

60
    /**
61
     * Returns true when composition processing should be skipped for this property.
62
     *
63
     * For non-root object-typed properties, composition keywords are processed inside
64
     * the nested schema by processSchema (with the type=base path). Adding a composition
65
     * validator at the parent level would duplicate validation and inject a _Merged_ type
66
     * hint that overrides the correct nested-class type.
67
     */
68
    protected function shouldSkip(PropertyInterface $property, JsonSchema $propertySchema): bool
968✔
69
    {
70
        return !($property instanceof BaseProperty)
968✔
71
            && ($propertySchema->getJson()['type'] ?? '') === 'object';
968✔
72
    }
73

74
    /**
75
     * Check the (post-type-inheritance) composition branches for filter keywords.
76
     *
77
     * Must be called AFTER inheritPropertyType() in each modify() method. A branch that
78
     * inherits "object" from the parent is genuinely object-typed: PropertyFactory routes
79
     * it through processSchema, producing a nested class whose properties are processed
80
     * independently and are not subject to ComposedItem $value reset. branchContainsFilter()
81
     * correctly skips the properties scan for such branches.
82
     *
83
     * For "not", the value is a single branch schema (not an array); all other keywords
84
     * use an array of branches.
85
     *
86
     * TODO: filters inside composition branches cannot be correctly applied
87
     * (ComposedItem.phptpl resets $value to $originalModelData after each branch).
88
     * Proper per-branch filter chaining is deferred to a follow-up topic.
89
     *
90
     * @throws SchemaException
91
     */
92
    protected function checkForFilterInBranches(
863✔
93
        PropertyInterface $property,
94
        JsonSchema $propertySchema,
95
    ): void {
96
        $json = $propertySchema->getJson();
863✔
97

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

117
        foreach ($json[$this->key] ?? [] as $index => $compositionElement) {
764✔
118
            if (
119
                is_array($compositionElement)
745✔
120
                && CompositionCompatibilityChecker::branchContainsFilter($compositionElement)
745✔
121
            ) {
122
                throw new SchemaException(
8✔
123
                    sprintf(
8✔
124
                        'A filter keyword inside a %s composition branch is not supported'
8✔
125
                            . ' for property %s in file %s (branch #%d).',
8✔
126
                        $this->key,
8✔
127
                        $property->getName(),
8✔
128
                        $property->getJsonSchema()->getFile(),
8✔
129
                        $index,
8✔
130
                    ),
8✔
131
                    $property->getJsonSchema(),
8✔
132
                );
8✔
133
            }
134
        }
135
    }
136

137
    /**
138
     * Build composition sub-properties for the current keyword's branches.
139
     *
140
     * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches.
141
     *
142
     * @return CompositionPropertyDecorator[]
143
     *
144
     * @throws SchemaException
145
     */
146
    protected function getCompositionProperties(
854✔
147
        SchemaProcessor $schemaProcessor,
148
        Schema $schema,
149
        PropertyInterface $property,
150
        JsonSchema $propertySchema,
151
        bool $merged,
152
    ): array {
153
        $propertyFactory = new PropertyFactory();
854✔
154
        $compositionProperties = [];
854✔
155
        $json = $propertySchema->getJson()['propertySchema']->getJson();
854✔
156

157
        $property->addTypeHintDecorator(new ClearTypeHintDecorator());
854✔
158

159
        foreach ($json[$this->key] as $index => $compositionElement) {
854✔
160
            if ($compositionElement === false) {
835✔
161
                $compositionProperties[] = $this->createAlwaysFalseBranchProperty(
25✔
162
                    $schemaProcessor,
25✔
163
                    $schema,
25✔
164
                    $property,
25✔
165
                    $propertySchema->getJson()['propertySchema'],
25✔
166
                );
25✔
167
                continue;
25✔
168
            }
169

170
            if ($compositionElement === true) {
825✔
171
                $compositionProperties[] = $this->createAlwaysTrueBranchProperty(
13✔
172
                    $schemaProcessor,
13✔
173
                    $schema,
13✔
174
                    $property,
13✔
175
                    $propertySchema->getJson()['propertySchema'],
13✔
176
                );
13✔
177
                continue;
13✔
178
            }
179

180
            $compositionSchema = $propertySchema->getJson()['propertySchema']->navigate("$this->key/$index");
824✔
181

182
            $compositionProperty = new CompositionPropertyDecorator(
824✔
183
                $property->getName(),
824✔
184
                $compositionSchema,
824✔
185
                $propertyFactory->create(
824✔
186
                    $schemaProcessor,
824✔
187
                    $schema,
824✔
188
                    $property->getName(),
824✔
189
                    $compositionSchema,
824✔
190
                    $property->isRequired(),
824✔
191
                ),
824✔
192
            );
824✔
193

194
            $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void {
824✔
195
                $nestedSchema = $compositionProperty->getNestedSchema();
824✔
196

197
                $compositionProperty->filterValidators(
824✔
198
                    static function (Validator $validator) use ($nestedSchema): bool {
824✔
199
                        if (is_a($validator->getValidator(), RequiredPropertyValidator::class)) {
818✔
200
                            return false;
527✔
201
                        }
202
                        // A branch's own nested composition/conditional validator (allOf, anyOf,
203
                        // oneOf, not, if/then/else — see AbstractComposedPropertyValidator) is
204
                        // never stripped here: $nestedSchema is only ever set for a branch whose
205
                        // OWN schema declares "type": "object" (PropertyFactory::
206
                        // createObjectProperty()), and for any such branch shouldSkip() already
207
                        // blocks every composition-keyword factory from attaching a validator to
208
                        // that same branch property in the first place — the object's own
209
                        // composition is instead processed entirely inside its generated nested
210
                        // class. So a branch here never simultaneously has a nested schema and its
211
                        // own composed/conditional validator; the validator, when present, always
212
                        // belongs to a bare (untyped) branch and must be kept and rendered, or the
213
                        // nested composition would silently accept every value (issue #167).
214
                        // An empty object schema ({type: object} with no declared properties)
215
                        // must accept any PHP object in composition context. The generated
216
                        // placeholder class carries no semantic constraints, so the strict
217
                        // instanceof check against it would incorrectly reject valid objects
218
                        // (e.g. a DateTime produced by a transforming filter) that are perfectly
219
                        // acceptable under the schema's actual semantics.
220
                        if (
221
                            is_a($validator->getValidator(), InstanceOfValidator::class)
812✔
222
                            && $nestedSchema !== null
812✔
223
                            && empty($nestedSchema->getProperties())
812✔
224
                        ) {
225
                            return false;
8✔
226
                        }
227
                        return true;
812✔
228
                    },
824✔
229
                );
824✔
230

231
                if (!($merged && $compositionProperty->getNestedSchema())) {
824✔
232
                    $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty));
508✔
233
                }
234
            });
824✔
235

236
            $compositionProperties[] = $compositionProperty;
824✔
237
        }
238

239
        return $compositionProperties;
854✔
240
    }
241

242
    /**
243
     * Create a composition branch for a boolean `false` schema element.
244
     *
245
     * The branch always fails when the property key is present in $modelData, so absent optional
246
     * properties are not denied. Used for false branches in allOf/anyOf/oneOf compositions.
247
     */
248
    protected function createAlwaysFalseBranchProperty(
25✔
249
        SchemaProcessor $schemaProcessor,
250
        Schema $schema,
251
        PropertyInterface $property,
252
        JsonSchema $parentSchema,
253
    ): CompositionPropertyDecorator {
254
        $propertyFactory = new PropertyFactory();
25✔
255
        $branchSchema = $parentSchema->withJson([]);
25✔
256

257
        $branchProperty = new CompositionPropertyDecorator(
25✔
258
            $property->getName(),
25✔
259
            $branchSchema,
25✔
260
            $propertyFactory->create(
25✔
261
                $schemaProcessor,
25✔
262
                $schema,
25✔
263
                $property->getName(),
25✔
264
                $branchSchema,
25✔
265
                $property->isRequired(),
25✔
266
            ),
25✔
267
        );
25✔
268

269
        $presenceCheck = "array_key_exists('" . addslashes($property->getName()) . "', \$modelData)";
25✔
270

271
        $branchProperty->onResolve(
25✔
272
            function () use ($branchProperty, $presenceCheck): void {
25✔
273
                $branchProperty->filterValidators(
25✔
274
                    static fn(Validator $validator): bool =>
25✔
275
                        !is_a($validator->getValidator(), RequiredPropertyValidator::class) &&
25✔
276
                        !is_a($validator->getValidator(), ComposedPropertyValidator::class),
25✔
277
                );
25✔
278
                $branchProperty->addValidator(
25✔
279
                    new PropertyValidator(
25✔
280
                        $branchProperty,
25✔
281
                        $presenceCheck,
25✔
282
                        DeniedPropertyException::class,
25✔
283
                    ),
25✔
284
                );
25✔
285
            },
25✔
286
        );
25✔
287

288
        return $branchProperty;
25✔
289
    }
290

291
    /**
292
     * Create a composition branch for a boolean `true` schema element.
293
     *
294
     * The branch always succeeds (no validators) and is marked as an always-true branch so that
295
     * type inference excludes it from type narrowing. Used for true branches in allOf/anyOf/oneOf.
296
     */
297
    protected function createAlwaysTrueBranchProperty(
13✔
298
        SchemaProcessor $schemaProcessor,
299
        Schema $schema,
300
        PropertyInterface $property,
301
        JsonSchema $parentSchema,
302
    ): CompositionPropertyDecorator {
303
        $propertyFactory = new PropertyFactory();
13✔
304
        $branchSchema = $parentSchema->withJson([]);
13✔
305

306
        $branchProperty = new CompositionPropertyDecorator(
13✔
307
            $property->getName(),
13✔
308
            $branchSchema,
13✔
309
            $propertyFactory->create(
13✔
310
                $schemaProcessor,
13✔
311
                $schema,
13✔
312
                $property->getName(),
13✔
313
                $branchSchema,
13✔
314
                $property->isRequired(),
13✔
315
            ),
13✔
316
        );
13✔
317

318
        $branchProperty->markAsAlwaysTrueBranch();
13✔
319

320
        $branchProperty->onResolve(function () use ($branchProperty): void {
13✔
321
            $branchProperty->filterValidators(
13✔
322
                static fn(Validator $validator): bool =>
13✔
323
                    !is_a($validator->getValidator(), RequiredPropertyValidator::class) &&
13✔
324
                    !is_a($validator->getValidator(), ComposedPropertyValidator::class),
13✔
325
            );
13✔
326
            // No validator added — true schema always succeeds.
327
            // No type hint decorator — true schema contributes no type constraint.
328
        });
13✔
329

330
        return $branchProperty;
13✔
331
    }
332

333
    /**
334
     * Inherit a parent-level type into composition branches that declare no type.
335
     */
336
    protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema
954✔
337
    {
338
        $json = $propertySchema->getJson();
954✔
339

340
        if (!isset($json['type'])) {
954✔
341
            return $propertySchema;
457✔
342
        }
343

344
        switch ($this->key) {
520✔
345
            case 'not':
520✔
346
                if (!isset($json[$this->key]['type'])) {
29✔
347
                    $json[$this->key]['type'] = $json['type'];
29✔
348
                }
349
                break;
29✔
350
            case 'if':
491✔
351
                return $this->inheritIfPropertyType($propertySchema->withJson($json));
83✔
352
            default:
353
                foreach ($json[$this->key] as &$composedElement) {
426✔
354
                    if (!is_bool($composedElement) && !isset($composedElement['type'])) {
425✔
355
                        $composedElement['type'] = $json['type'];
206✔
356
                    }
357
                }
358
        }
359

360
        return $propertySchema->withJson($json);
455✔
361
    }
362

363
    /**
364
     * Inherit the parent type into all branches of an if/then/else composition.
365
     */
366
    protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema
83✔
367
    {
368
        $json = $propertySchema->getJson();
83✔
369

370
        foreach (['if', 'then', 'else'] as $keyword) {
83✔
371
            if (!isset($json[$keyword]) || is_bool($json[$keyword])) {
83✔
372
                continue;
23✔
373
            }
374

375
            if (!isset($json[$keyword]['type'])) {
83✔
376
                $json[$keyword]['type'] = $json['type'];
83✔
377
            }
378
        }
379

380
        return $propertySchema->withJson($json);
83✔
381
    }
382

383
    /**
384
     * After all composition branches resolve, derive the parent property's type from the
385
     * branch types and apply it. Skips when any branch has a nested schema (object merging
386
     * is handled elsewhere).
387
     *
388
     * allOf: intersect all typed branch types — only values satisfying every branch simultaneously
389
     * are valid, so the PHP type is the intersection. Branches with no declared type impose no
390
     * constraint and are excluded from the intersection. An empty intersection (contradictory
391
     * branch types) throws SchemaException because no value can ever be valid.
392
     *
393
     * anyOf / oneOf: union of all typed branch types — at least one branch must pass, so the PHP
394
     * type is the union. An untyped branch accepts every value, making the composition satisfied
395
     * by any input; the property's type hint is removed (remains mixed) in that case.
396
     *
397
     * Also callable from outside the factory (e.g. EnumPostProcessor) after a post processor has
398
     * mutated branch types and needs the parent's native type recomputed from the updated branches.
399
     *
400
     * @param bool $isAllOf true for allOf, false for anyOf/oneOf.
401
     * @param CompositionPropertyDecorator[] $compositionProperties
402
     *
403
     * @throws SchemaException when allOf branches declare contradictory types.
404
     */
405
    public static function transferPropertyType(
739✔
406
        PropertyInterface $property,
407
        array $compositionProperties,
408
        bool $isAllOf,
409
    ): void {
410
        foreach ($compositionProperties as $compositionProperty) {
739✔
411
            if ($compositionProperty->getNestedSchema() !== null) {
739✔
412
                return;
518✔
413
            }
414
        }
415

416
        // For anyOf/oneOf: a true branch always satisfies the composition for any value,
417
        // so the property type cannot be narrowed — leave it untyped.
418
        // For allOf: exclude true branches from type computation; they contribute no constraint.
419
        $activeBranches = array_values(array_filter(
222✔
420
            $compositionProperties,
222✔
421
            static fn(CompositionPropertyDecorator $compositionProperty): bool =>
222✔
422
                !$compositionProperty->isAlwaysTrueBranch(),
222✔
423
        ));
222✔
424

425
        if (!$isAllOf && count($activeBranches) < count($compositionProperties)) {
222✔
426
            return;
8✔
427
        }
428

429

430
        $hasBranchWithRequiredProperty = array_filter(
214✔
431
            $activeBranches,
214✔
432
            static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(),
214✔
433
        ) !== [];
214✔
434

435
        $hasBranchWithOptionalProperty = $isAllOf
214✔
436
            ? !$hasBranchWithRequiredProperty
67✔
437
            : array_filter(
151✔
438
                $activeBranches,
151✔
439
                static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(),
151✔
440
            ) !== [];
151✔
441

442
        if ($isAllOf) {
214✔
443
            self::transferAllOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
67✔
444
            return;
65✔
445
        }
446

447
        self::transferAnyOfOneOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
151✔
448
    }
449

450
    /**
451
     * Derive and apply the parent property's type using allOf intersection semantics.
452
     *
453
     * Only typed branches (those that declare a type keyword) constrain the intersection.
454
     * Untyped branches impose no type restriction and are excluded. Null is valid only when
455
     * ALL typed branches allow it. An empty non-null intersection (contradictory types) throws
456
     * SchemaException — no value can satisfy all branch type constraints simultaneously.
457
     *
458
     * @param CompositionPropertyDecorator[] $compositionProperties
459
     *
460
     * @throws SchemaException
461
     */
462
    private static function transferAllOfType(
67✔
463
        PropertyInterface $property,
464
        array $compositionProperties,
465
        bool $hasBranchWithOptionalProperty,
466
    ): void {
467
        $constrainingBranches = array_values(array_filter(
67✔
468
            $compositionProperties,
67✔
469
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
67✔
470
        ));
67✔
471

472
        if (empty($constrainingBranches)) {
67✔
473
            // No typed branches — no type constraint to apply.
474
            return;
4✔
475
        }
476

477
        // Intersection of non-null type names across all typed branches.
478
        // TypeIntersection::compute handles int ⊂ float (integer is a subtype of number in JSON Schema).
479
        $nonNullSets = array_map(
63✔
480
            static fn(CompositionPropertyDecorator $p): array => array_values(array_filter(
63✔
481
                $p->getType()->getNames(),
63✔
482
                static fn(string $typeName): bool => $typeName !== 'null',
63✔
483
            )),
63✔
484
            $constrainingBranches,
63✔
485
        );
63✔
486
        $nonNullNames = array_shift($nonNullSets);
63✔
487
        foreach ($nonNullSets as $typeSet) {
63✔
488
            $nonNullNames = TypeIntersection::compute($nonNullNames, $typeSet);
39✔
489
        }
490

491
        // Null is valid in allOf only when ALL typed branches allow it.
492
        $allBranchesAllowNull = count(array_filter(
63✔
493
            $constrainingBranches,
63✔
494
            static fn(CompositionPropertyDecorator $p): bool =>
63✔
495
                in_array('null', $p->getType()->getNames(), true)
63✔
496
                || $p->getType()->isNullable() === true,
63✔
497
        )) === count($constrainingBranches);
63✔
498

499
        if (empty($nonNullNames) && !$allBranchesAllowNull) {
63✔
500
            throw new SchemaException(
2✔
501
                sprintf(
2✔
502
                    "Property '%s' is defined with conflicting types in allOf composition branches"
2✔
503
                        . ' (file %s). allOf requires all constraints to hold simultaneously,'
2✔
504
                        . ' making this schema unsatisfiable.',
2✔
505
                    $property->getName(),
2✔
506
                    $property->getJsonSchema()->getFile(),
2✔
507
                ),
2✔
508
                $property->getJsonSchema(),
2✔
509
            );
2✔
510
        }
511

512
        if (empty($nonNullNames)) {
61✔
513
            // Only null survives the intersection; the null-processor path handles pure-null types.
514
            return;
1✔
515
        }
516

517
        $nullable = ($allBranchesAllowNull || $hasBranchWithOptionalProperty) ? true : null;
60✔
518
        $property->setType(new PropertyType($nonNullNames, $nullable));
60✔
519
    }
520

521
    /**
522
     * Derive and apply the parent property's type using anyOf/oneOf union semantics.
523
     *
524
     * Branches are partitioned into three categories:
525
     * - Typed (getType() !== null): contribute their names to the union.
526
     * - Explicit null-type ({type:null}): getType() is null but typeHint contains 'null';
527
     *   contributes nullable=true to the result.
528
     * - Truly untyped ({}): getType() is null and typeHint does not contain 'null'; the branch
529
     *   accepts every value, so the composition is always satisfiable and no type hint applies.
530
     *
531
     * A truly untyped branch causes early return without setting a type (property remains mixed),
532
     * matching the behaviour of PropertyMerger::mergeNullableBranch for object-level compositions.
533
     * An explicit null-type branch ({type:null}) is NOT treated as untyped — it adds nullable=true
534
     * to the typed union rather than removing the type hint.
535
     *
536
     * @param CompositionPropertyDecorator[] $compositionProperties
537
     */
538
    private static function transferAnyOfOneOfType(
151✔
539
        PropertyInterface $property,
540
        array $compositionProperties,
541
        bool $hasBranchWithOptionalProperty,
542
    ): void {
543
        $hasExplicitNullBranch = false;
151✔
544

545
        foreach ($compositionProperties as $compositionProperty) {
151✔
546
            if ($compositionProperty->getType() !== null) {
151✔
547
                continue;
127✔
548
            }
549

550
            if (str_contains($compositionProperty->getTypeHint(), 'null')) {
28✔
551
                // Explicit null-type branch ({type: null}): contributes nullable=true.
552
                $hasExplicitNullBranch = true;
2✔
553
            } else {
554
                // Truly untyped branch ({}): any value is valid, so the composition is
555
                // always satisfiable — no type hint is appropriate for this property.
556
                return;
26✔
557
            }
558
        }
559

560
        $typedBranches = array_values(array_filter(
125✔
561
            $compositionProperties,
125✔
562
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
125✔
563
        ));
125✔
564

565
        if (empty($typedBranches)) {
125✔
566
            // Only explicit null branches; no scalar type to build a union from.
567
            return;
1✔
568
        }
569

570
        $allNames = array_merge(...array_map(
124✔
571
            static fn(CompositionPropertyDecorator $p): array => $p->getType()->getNames(),
124✔
572
            $typedBranches,
124✔
573
        ));
124✔
574

575
        $hasNull = $hasExplicitNullBranch || in_array('null', $allNames, true);
124✔
576
        $nonNullNames = array_values(array_filter(
124✔
577
            array_unique($allNames),
124✔
578
            static fn(string $typeName): bool => $typeName !== 'null',
124✔
579
        ));
124✔
580

581
        if (!$nonNullNames) {
124✔
UNCOV
582
            return;
×
583
        }
584

585
        $nullable = ($hasNull || $hasBranchWithOptionalProperty) ? true : null;
124✔
586
        $property->setType(new PropertyType($nonNullNames, $nullable));
124✔
587
    }
588
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc