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

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

23 Mar 2026 08:33PM UTC coverage: 98.605% (-0.09%) from 98.693%
23458742876

push

github

web-flow
Merge pull request #115 from wol-soft/fix/issue-110

Type system (Type widening for compositions, union types). Fixes #110 and #114.

544 of 554 new or added lines in 51 files covered. (98.19%)

3887 of 3942 relevant lines covered (98.6%)

547.23 hits per line

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

98.21
/src/Utils/PropertyMerger.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Utils;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Model\GeneratorConfiguration;
9
use PHPModelGenerator\Model\Property\PropertyInterface;
10
use PHPModelGenerator\Model\Property\PropertyType;
11
use PHPModelGenerator\Model\Validator;
12
use PHPModelGenerator\Model\Validator\TypeCheckInterface;
13
use PHPModelGenerator\PropertyProcessor\Decorator\Property\IntToFloatCastDecorator;
14

15
/**
16
 * Merges an incoming property into an already-registered slot on a Schema.
17
 *
18
 * Handles the four distinct merge paths:
19
 *  - Root-precedence guard (anyOf/oneOf must not widen a root-registered property)
20
 *  - Nullable-branch merge (incoming has no scalar type)
21
 *  - Existing-null promotion (existing slot is a null/untyped placeholder)
22
 *  - allOf intersection (narrow to the common type set)
23
 *  - anyOf/oneOf union (widen to the combined type set)
24
 */
25
class PropertyMerger
26
{
27
    /** @var array<string, true> Property names registered from the root (compositionProcessor=null) */
28
    private array $rootRegisteredProperties = [];
29

30
    /** @var array<string, int> Number of branches that conflicted with the root type, keyed by property name */
31
    private array $rootConflictCounts = [];
32

33
    public function __construct(private ?GeneratorConfiguration $generatorConfiguration = null)
2,073✔
34
    {}
2,073✔
35

36
    /**
37
     * Record a property name as having been registered from the root (compositionProcessor=null).
38
     */
39
    public function markRootRegistered(string $propertyName): void
1,973✔
40
    {
41
        $this->rootRegisteredProperties[$propertyName] = true;
1,973✔
42
    }
43

44
    /**
45
     * Merge $incoming into the $existing slot already registered on the schema.
46
     *
47
     * $isAllOf must be true when the merge originates from an allOf composition, false for anyOf,
48
     * oneOf, if/then/else, or any non-composition context (null compositionProcessor in Schema).
49
     *
50
     * Returns early (no-op) when:
51
     * - Either property has a nested schema (object merging is handled elsewhere)
52
     * - Root-precedence guard blocks a non-allOf composition branch
53
     *
54
     * @throws SchemaException when allOf branches define conflicting types
55
     */
56
    public function merge(
98✔
57
        PropertyInterface $existing,
58
        PropertyInterface $incoming,
59
        bool $isAllOf,
60
    ): void {
61
        // Nested-object merging is owned by the merged-property system; don't interfere.
62
        if (
63
            $existing->getNestedSchema() !== null
98✔
64
            || $incoming->getNestedSchema() !== null
97✔
65
            || $this->guardRootPrecedence($existing, $incoming, $isAllOf)
98✔
66
        ) {
67
            return;
42✔
68
        }
69

70
        // Use getType(true) for the stored output type.
71
        // getType(false) post-Phase-5 returns a synthesised union and cannot be decomposed.
72
        if ($this->mergeNullableBranch($existing, $incoming) || $this->mergeIntoExistingNull($existing, $incoming)) {
72✔
73
            return;
18✔
74
        }
75

76
        if ($isAllOf) {
68✔
77
            $this->narrowToIntersection(
12✔
78
                $existing,
12✔
79
                $incoming,
12✔
80
                sprintf(
12✔
81
                    "Property '%s' is defined with conflicting types across allOf branches. " .
12✔
82
                    "allOf requires all constraints to hold simultaneously, making this schema unsatisfiable.",
12✔
83
                    $incoming->getName(),
12✔
84
                ),
12✔
85
            );
12✔
86
            return;
9✔
87
        }
88

89
        $this->applyAnyOfOneOfUnion($existing, $incoming);
56✔
90
    }
91

92
    /**
93
     * Guard: anyOf/oneOf branches must not widen a property already registered by the root.
94
     * Emits an optional warning when the branch declares a type that differs from the root type,
95
     * and tracks the conflict count so checkForTotalConflict can detect unsatisfiable schemas.
96
     *
97
     * Returns true when the caller should return early (the guard handled this call).
98
     */
99
    private function guardRootPrecedence(
97✔
100
        PropertyInterface $existing,
101
        PropertyInterface $incoming,
102
        bool $isAllOf,
103
    ): bool {
104
        if (!isset($this->rootRegisteredProperties[$incoming->getName()]) || $isAllOf) {
97✔
105
            return false;
72✔
106
        }
107

108
        $existingOutput = $existing->getType(true);
41✔
109
        $incomingOutput = $incoming->getType(true);
41✔
110

111
        $intersection = $incomingOutput && $existingOutput
41✔
112
            ? $this->computeDeclaredIntersection($incomingOutput->getNames(), $existingOutput->getNames())
9✔
113
            : null;
36✔
114

115
        if ($intersection === []) {
41✔
116
            $this->rootConflictCounts[$incoming->getName()] =
6✔
117
                ($this->rootConflictCounts[$incoming->getName()] ?? 0) + 1;
6✔
118

119
            if ($this->generatorConfiguration?->isOutputEnabled()) {
6✔
120
                echo "Warning: composition branch defines property '{$incoming->getName()}' with type "
1✔
121
                    . implode('|', $incomingOutput->getNames())
1✔
122
                    . " which differs from root type "
1✔
123
                    . implode('|', $existingOutput->getNames())
1✔
124
                    . " — root definition takes precedence.\n";
1✔
125
            }
126
        }
127

128
        return true;
41✔
129
    }
130

131
    /**
132
     * Throw a SchemaException if all $branchCount branches that define $propertyName conflicted
133
     * with the root type. When every branch is incompatible the schema is unsatisfiable.
134
     *
135
     * @throws SchemaException
136
     */
137
    public function checkForTotalConflict(string $propertyName, int $branchCount): void
262✔
138
    {
139
        if (
140
            isset($this->rootConflictCounts[$propertyName]) &&
262✔
141
            $this->rootConflictCounts[$propertyName] >= $branchCount
262✔
142
        ) {
143
            throw new SchemaException(sprintf(
3✔
144
                "Property '%s' is defined in root with a type that conflicts with all composition branches, " .
3✔
145
                "making this schema unsatisfiable.",
3✔
146
                $propertyName,
3✔
147
            ));
3✔
148
        }
149
    }
150

151
    /**
152
     * Handle the case where the incoming branch carries no scalar type (null-type or truly untyped).
153
     *
154
     * - Explicit null-type branch (`{"type":"null"}`): adds nullable=true to the existing type.
155
     * - Truly untyped branch (`{}`): the combined type is unbounded — remove the type hint entirely.
156
     *
157
     * Returns true when the caller should return early (this method fully handled the merge).
158
     */
159
    private function mergeNullableBranch(
72✔
160
        PropertyInterface $existing,
161
        PropertyInterface $incoming,
162
    ): bool {
163
        $incomingOutput = $incoming->getType(true);
72✔
164

165
        if ($incomingOutput !== null) {
72✔
166
            return false;
70✔
167
        }
168

169
        $existingOutput = $existing->getType(true);
16✔
170

171
        if (str_contains($incoming->getTypeHint(), 'null')) {
16✔
172
            // Explicit null-type branch: treat as nullable=true with no added scalar names.
173
            if ($existingOutput) {
1✔
174
                $existing->setType(
1✔
175
                    new PropertyType($existingOutput->getNames(), true),
1✔
176
                    new PropertyType($existingOutput->getNames(), true),
1✔
177
                );
1✔
178
                $existing->filterValidators(
1✔
179
                    static fn(Validator $validator): bool =>
1✔
180
                        !($validator->getValidator() instanceof TypeCheckInterface),
1✔
181
                );
1✔
182
            }
183
        } else {
184
            // Truly untyped branch: combined type is unbounded — remove the type hint.
185
            $existing->setType(null, null);
15✔
186
        }
187

188
        return true;
16✔
189
    }
190

191
    /**
192
     * Handle the case where the existing slot was claimed first by a null-type or truly-untyped
193
     * branch, and a concrete-typed branch arrives second.
194
     *
195
     * - If the existing slot carries a 'null' type hint (explicit null-type branch): promote to
196
     *   nullable scalar using the incoming type names.
197
     * - If the existing slot is truly untyped: keep as-is (no type hint — it was already widened
198
     *   to unbounded by the untyped branch).
199
     *
200
     * Returns true when the caller should return early (this method fully handled the merge).
201
     */
202
    private function mergeIntoExistingNull(
70✔
203
        PropertyInterface $existing,
204
        PropertyInterface $incoming,
205
    ): bool {
206
        $existingOutput = $existing->getType(true);
70✔
207

208
        if ($existingOutput !== null) {
70✔
209
            return false;
68✔
210
        }
211

212
        $incomingOutput = $incoming->getType(true);
2✔
213

214
        if (str_contains($existing->getTypeHint(), 'null')) {
2✔
215
            $existing->setType(
1✔
216
                new PropertyType($incomingOutput->getNames(), true),
1✔
217
                new PropertyType($incomingOutput->getNames(), true),
1✔
218
            );
1✔
219
            $existing->filterValidators(
1✔
220
                static fn(Validator $validator): bool =>
1✔
221
                    !($validator->getValidator() instanceof TypeCheckInterface),
1✔
222
            );
1✔
223
        }
224
        // For a truly untyped existing slot: keep as-is (already unbounded, no type hint).
225

226
        return true;
2✔
227
    }
228

229
    /**
230
     * Narrow $existing's type to its intersection with $incoming's type.
231
     *
232
     * Intended for allOf branch merging: both properties' required flags and explicit nullable
233
     * flags are respected, and an explicitly nullable existing type is preserved unless the
234
     * incoming type explicitly denies nullability.
235
     *
236
     * Throws SchemaException with $conflictMessage when the declared intersection is empty
237
     * (the schema is unsatisfiable).
238
     *
239
     * @throws SchemaException
240
     */
241
    public function narrowToIntersection(
12✔
242
        PropertyInterface $existing,
243
        PropertyInterface $incoming,
244
        string $conflictMessage,
245
    ): void {
246
        $existingOutput = $existing->getType(true);
12✔
247
        $incomingOutput = $incoming->getType(true);
12✔
248

249
        $intersection = $this->resolveEffectiveIntersection(
12✔
250
            $existingOutput,
12✔
251
            $existing->isRequired(),
12✔
252
            $incomingOutput,
12✔
253
            $incoming->isRequired(),
12✔
254
            $conflictMessage,
12✔
255
        );
12✔
256

257
        if ($intersection === null) {
9✔
258
            return;
4✔
259
        }
260

261
        $hasNull = in_array('null', $intersection, true);
6✔
262

263
        // If the existing type is explicitly nullable (nullable=true) and the incoming type does
264
        // not explicitly deny nullability (nullable=false), preserve the explicit nullable.
265
        if (!$hasNull && $existingOutput->isNullable() === true && $incomingOutput->isNullable() !== false) {
6✔
266
            $hasNull = true;
2✔
267
        }
268

269
        $this->applyNarrowedType($existing, $existingOutput, $intersection, $hasNull);
6✔
270
    }
271

272
    /**
273
     * Constrain $existing's type to its intersection with $constraintType.
274
     *
275
     * Intended for patternProperties enforcement: uses strict intersection semantics —
276
     * nullability is determined solely by what survives the intersection, without
277
     * preserving any existing explicit nullable flag.
278
     *
279
     * Throws SchemaException with $conflictMessage when the declared intersection is empty
280
     * (the schema is unsatisfiable).
281
     *
282
     * @throws SchemaException
283
     */
284
    public function applyTypeConstraint(
26✔
285
        PropertyInterface $existing,
286
        PropertyType $constraintType,
287
        string $conflictMessage,
288
    ): void {
289
        $existingOutput = $existing->getType(true);
26✔
290

291
        $intersection = $this->resolveEffectiveIntersection(
26✔
292
            $existingOutput,
26✔
293
            $existing->isRequired(),
26✔
294
            $constraintType,
26✔
295
            $existing->isRequired(),
26✔
296
            $conflictMessage,
26✔
297
        );
26✔
298

299
        if ($intersection === null) {
24✔
300
            return;
22✔
301
        }
302

303
        $this->applyNarrowedType($existing, $existingOutput, $intersection, in_array('null', $intersection, true));
2✔
304
    }
305

306
    /**
307
     * Compute the effective type-name intersection of two sides, throwing when the declared
308
     * (scalar-only) intersection is empty.
309
     *
310
     * Returns null when the effective intersection equals the existing effective set (no-op).
311
     *
312
     * @return string[]|null null = no change needed; array = effective intersection to apply
313
     * @throws SchemaException
314
     */
315
    private function resolveEffectiveIntersection(
38✔
316
        PropertyType $existingType,
317
        bool $existingIsRequired,
318
        PropertyType $incomingType,
319
        bool $incomingIsRequired,
320
        string $conflictMessage,
321
    ): ?array {
322
        $implicitNull = $this->generatorConfiguration?->isImplicitNullAllowed() ?? false;
38✔
323

324
        if (!$this->computeDeclaredIntersection($existingType->getNames(), $incomingType->getNames())) {
38✔
325
            throw new SchemaException($conflictMessage);
5✔
326
        }
327

328
        $existingEffective = $this->buildEffectiveTypeSet($existingType, $existingIsRequired, $implicitNull);
33✔
329
        $incomingEffective = $this->buildEffectiveTypeSet($incomingType, $incomingIsRequired, $implicitNull);
33✔
330

331
        $intersection = $this->computeDeclaredIntersection($existingEffective, $incomingEffective);
33✔
332

333
        // No-op when the intersection already equals the existing effective set.
334
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
33✔
335
            return null;
26✔
336
        }
337

338
        return $intersection;
8✔
339
    }
340

341
    /**
342
     * Apply the result of a type intersection to $existing: update its PropertyType, strip stale
343
     * type-check validators, and remove the IntToFloatCastDecorator when narrowing float → int.
344
     *
345
     * @param string[] $intersection effective type name set after intersection (may include 'null')
346
     */
347
    private function applyNarrowedType(
8✔
348
        PropertyInterface $existing,
349
        PropertyType $existingOutput,
350
        array $intersection,
351
        bool $hasNull,
352
    ): void {
353
        $nonNull = array_values(array_filter($intersection, fn(string $t): bool => $t !== 'null'));
8✔
354

355
        if (!$nonNull) {
8✔
356
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
NEW
357
            return;
×
358
        }
359

360
        $existing->setType(
8✔
361
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
362
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
363
        );
8✔
364
        $existing->filterValidators(
8✔
365
            static fn(Validator $validator): bool =>
8✔
366
                !($validator->getValidator() instanceof TypeCheckInterface),
8✔
367
        );
8✔
368

369
        // When narrowing from float to int (JSON: number → integer), the IntToFloatCastDecorator
370
        // is no longer appropriate — the property now holds a strict int value.
371
        if (in_array('float', $existingOutput->getNames(), true) && in_array('int', $nonNull, true)) {
8✔
372
            $existing->filterDecorators(
1✔
373
                static fn($decorator): bool => !($decorator instanceof IntToFloatCastDecorator),
1✔
374
            );
1✔
375
        }
376
    }
377

378
    /**
379
     * anyOf / oneOf: widen the existing type to the union of all observed branch types.
380
     *
381
     * 'null' in the name list is promoted to nullable=true rather than kept as a type name,
382
     * so the render pipeline does not emit "string|null|null". Any nullable=true already set
383
     * on either side (e.g. from cloneTransferredProperty) is propagated.
384
     */
385
    private function applyAnyOfOneOfUnion(
56✔
386
        PropertyInterface $existing,
387
        PropertyInterface $incoming,
388
    ): void {
389
        $existingOutput = $existing->getType(true);
56✔
390
        $incomingOutput = $incoming->getType(true);
56✔
391

392
        $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
56✔
393

394
        $hasNull = in_array('null', $allNames, true)
56✔
395
            || $existingOutput->isNullable() === true
56✔
396
            || $incomingOutput->isNullable() === true;
56✔
397
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
56✔
398

399
        if (!$nonNullNames) {
56✔
NEW
400
            return;
×
401
        }
402

403
        $mergedType = new PropertyType($nonNullNames, $hasNull ? true : null);
56✔
404

405
        if (
406
            $mergedType->getNames() === $existingOutput->getNames()
56✔
407
            && $mergedType->isNullable() === $existingOutput->isNullable()
56✔
408
        ) {
409
            return;
34✔
410
        }
411

412
        $existing->setType($mergedType, $mergedType);
22✔
413
        $existing->filterValidators(
22✔
414
            static fn(Validator $validator): bool =>
22✔
415
                !($validator->getValidator() instanceof TypeCheckInterface),
22✔
416
        );
22✔
417
    }
418

419
    /**
420
     * Build the effective type name set for one side of an allOf intersection.
421
     *
422
     * The effective set is the declared names plus 'null' when:
423
     * - the PropertyType explicitly marks itself as nullable (nullable=true), or
424
     * - the PropertyType has undecided nullability (nullable=null), implicitNull is enabled,
425
     *   and the property is not required (so the render layer would add null anyway).
426
     *
427
     * @return string[]
428
     */
429
    private function buildEffectiveTypeSet(
33✔
430
        PropertyType $type,
431
        bool $isRequired,
432
        bool $implicitNull,
433
    ): array {
434
        $names = $type->getNames();
33✔
435

436
        if (
437
            $type->isNullable() === true
33✔
438
            || ($type->isNullable() === null && $implicitNull && !$isRequired)
33✔
439
        ) {
440
            $names[] = 'null';
29✔
441
        }
442

443
        return $names;
33✔
444
    }
445

446
    /**
447
     * Compute the intersection of two type-name sets, treating 'int' as a subtype of 'float'
448
     * (JSON Schema: integer is a subset of number).
449
     *
450
     * When one side contains 'float' and the other contains 'int' (but not 'float'), the
451
     * intersection resolves to 'int' — the narrower concrete type — rather than empty.
452
     *
453
     * @param string[] $a
454
     * @param string[] $b
455
     * @return string[]
456
     */
457
    private function computeDeclaredIntersection(array $a, array $b): array
47✔
458
    {
459
        $intersection = array_values(array_intersect($a, $b));
47✔
460

461
        // int ⊂ float (JSON Schema: integer is a subtype of number).
462
        // When one side has float and the other has int (without float), resolve to int.
463
        if (!in_array('float', $intersection, true)) {
47✔
464
            if (in_array('float', $a, true) && in_array('int', $b, true)) {
47✔
465
                $intersection[] = 'int';
1✔
466
            } elseif (in_array('int', $a, true) && in_array('float', $b, true)) {
46✔
NEW
467
                $intersection[] = 'int';
×
468
            }
469
        }
470

471
        return array_values(array_unique($intersection));
47✔
472
    }
473
}
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