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

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

03 Jun 2026 08:37PM UTC coverage: 98.72% (+0.006%) from 98.714%
26912113495

push

github

wol-soft
Merge remote-tracking branch 'origin/master'

157 of 168 new or added lines in 9 files covered. (93.45%)

42 existing lines in 5 files now uncovered.

6172 of 6252 relevant lines covered (98.72%)

574.49 hits per line

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

98.77
/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, int> Number of branches that conflicted with the root type, keyed by property name */
28
    private array $rootConflictCounts = [];
29

30
    public function __construct(private ?GeneratorConfiguration $generatorConfiguration = null)
2,575✔
31
    {}
2,575✔
32

33
    /**
34
     * Merge $incoming into the $existing slot already registered on the schema.
35
     *
36
     * $isAllOf must be true when the merge originates from an allOf composition, false for anyOf,
37
     * oneOf, if/then/else, or any non-composition context (null compositionProcessor in Schema).
38
     *
39
     * $isRootRegistered must be true when the property was first registered from the root
40
     * (compositionProcessor === null in Schema::addProperty), which gives the root definition
41
     * precedence over anyOf/oneOf branch definitions.
42
     *
43
     * Returns early (no-op) when:
44
     * - Either property has a nested schema (object merging is handled elsewhere)
45
     * - Root-precedence guard blocks a non-allOf composition branch
46
     *
47
     * @throws SchemaException when allOf branches define conflicting types
48
     */
49
    public function merge(
190✔
50
        PropertyInterface $existing,
51
        PropertyInterface $incoming,
52
        bool $isAllOf,
53
        bool $isRootRegistered = false,
54
    ): void {
55
        // Nested-object merging is owned by the merged-property system; don't interfere.
56
        if (
57
            $existing->getNestedSchema() !== null
190✔
58
            || $incoming->getNestedSchema() !== null
185✔
59
            || $this->guardRootPrecedence($existing, $incoming, $isAllOf, $isRootRegistered)
190✔
60
        ) {
61
            return;
87✔
62
        }
63

64
        // Use getType(true) for the stored output type.
65
        // getType(false) returns a synthesised union type and cannot be decomposed into its parts.
66
        //
67
        // For allOf: a truly-untyped incoming branch (no type keyword, not an explicit null-type
68
        // branch) adds no type constraint — all allOf branches apply simultaneously, so the
69
        // existing type is unaffected. Skip mergeNullableBranch in that case to avoid wrongly
70
        // wiping the existing type.
71
        if (
72
            $isAllOf
122✔
73
            && $incoming->getType(true) === null
122✔
74
            && !str_contains($incoming->getTypeHint(), 'null')
122✔
75
        ) {
76
            return;
23✔
77
        }
78

79
        if ($this->mergeNullableBranch($existing, $incoming) || $this->mergeIntoExistingNull($existing, $incoming)) {
113✔
80
            return;
6✔
81
        }
82

83
        if ($isAllOf) {
107✔
84
            $this->narrowToIntersection(
21✔
85
                $existing,
21✔
86
                $incoming,
21✔
87
                sprintf(
21✔
88
                    "Property '%s' is defined with conflicting types across allOf branches. " .
21✔
89
                    "allOf requires all constraints to hold simultaneously, making this schema unsatisfiable.",
21✔
90
                    $incoming->getName(),
21✔
91
                ),
21✔
92
            );
21✔
93
            return;
18✔
94
        }
95

96
        $this->applyAnyOfOneOfUnion($existing, $incoming);
86✔
97
    }
98

99
    /**
100
     * Guard: anyOf/oneOf branches must not widen a property already registered by the root.
101
     * Emits an optional warning when the branch declares a type that differs from the root type,
102
     * and tracks the conflict count so checkForTotalConflict can detect unsatisfiable schemas.
103
     *
104
     * Returns true when the caller should return early (the guard handled this call).
105
     */
106
    private function guardRootPrecedence(
185✔
107
        PropertyInterface $existing,
108
        PropertyInterface $incoming,
109
        bool $isAllOf,
110
        bool $isRootRegistered,
111
    ): bool {
112
        if (!$isRootRegistered || $isAllOf) {
185✔
113
            return false;
122✔
114
        }
115

116
        $existingOutput = $existing->getType(true);
82✔
117
        $incomingOutput = $incoming->getType(true);
82✔
118

119
        $intersection = $incomingOutput && $existingOutput
82✔
120
            ? TypeIntersection::compute($incomingOutput->getNames(), $existingOutput->getNames())
15✔
121
            : null;
71✔
122

123
        if ($intersection === []) {
82✔
124
            $this->rootConflictCounts[$incoming->getName()] =
6✔
125
                ($this->rootConflictCounts[$incoming->getName()] ?? 0) + 1;
6✔
126

127
            if ($this->generatorConfiguration?->isOutputEnabled()) {
6✔
128
                echo "Warning: composition branch defines property '{$incoming->getName()}' with type "
1✔
129
                    . implode('|', $incomingOutput->getNames())
1✔
130
                    . " which differs from root type "
1✔
131
                    . implode('|', $existingOutput->getNames())
1✔
132
                    . " — root definition takes precedence.\n";
1✔
133
            }
134
        }
135

136
        return true;
82✔
137
    }
138

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

159
    /**
160
     * Handle the case where the incoming branch carries no scalar type (null-type or truly untyped).
161
     *
162
     * - Explicit null-type branch (`{"type":"null"}`): adds nullable=true to the existing type.
163
     * - Truly untyped branch (`{}`): the combined type is unbounded — remove the type hint entirely.
164
     *
165
     * Returns true when the caller should return early (this method fully handled the merge).
166
     */
167
    private function mergeNullableBranch(
113✔
168
        PropertyInterface $existing,
169
        PropertyInterface $incoming,
170
    ): bool {
171
        $incomingOutput = $incoming->getType(true);
113✔
172

173
        if ($incomingOutput !== null) {
113✔
174
            return false;
109✔
175
        }
176

177
        $existingOutput = $existing->getType(true);
4✔
178

179
        if (str_contains($incoming->getTypeHint(), 'null')) {
4✔
180
            // Explicit null-type branch: treat as nullable=true with no added scalar names.
181
            if ($existingOutput) {
1✔
182
                $existing->setType(
1✔
183
                    new PropertyType($existingOutput->getNames(), true),
1✔
184
                    new PropertyType($existingOutput->getNames(), true),
1✔
185
                );
1✔
186
                $existing->filterValidators(
1✔
187
                    static fn(Validator $validator): bool =>
1✔
188
                        !($validator->getValidator() instanceof TypeCheckInterface),
1✔
189
                );
1✔
190
            }
191
        } else {
192
            // Truly untyped branch: combined type is unbounded — remove the type hint.
193
            $existing->setType(null, null);
3✔
194
        }
195

196
        return true;
4✔
197
    }
198

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

216
        if ($existingOutput !== null) {
109✔
217
            return false;
107✔
218
        }
219

220
        $incomingOutput = $incoming->getType(true);
2✔
221

222
        if (str_contains($existing->getTypeHint(), 'null')) {
2✔
223
            $existing->setType(
1✔
224
                new PropertyType($incomingOutput->getNames(), true),
1✔
225
                new PropertyType($incomingOutput->getNames(), true),
1✔
226
            );
1✔
227
            $existing->filterValidators(
1✔
228
                static fn(Validator $validator): bool =>
1✔
229
                    !($validator->getValidator() instanceof TypeCheckInterface),
1✔
230
            );
1✔
231
        }
232
        // For a truly untyped existing slot: keep as-is (already unbounded, no type hint).
233

234
        return true;
2✔
235
    }
236

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

257
        $intersection = $this->resolveEffectiveIntersection(
21✔
258
            $existingOutput,
21✔
259
            $existing->isRequired(),
21✔
260
            $incomingOutput,
21✔
261
            $incoming->isRequired(),
21✔
262
            $conflictMessage,
21✔
263
        );
21✔
264

265
        if ($intersection === null) {
18✔
266
            return;
13✔
267
        }
268

269
        $hasNull = in_array('null', $intersection, true);
6✔
270

271
        // If the existing type is explicitly nullable (nullable=true) and the incoming type does
272
        // not explicitly deny nullability (nullable=false), preserve the explicit nullable.
273
        if (!$hasNull && $existingOutput->isNullable() === true && $incomingOutput->isNullable() !== false) {
6✔
274
            $hasNull = true;
2✔
275
        }
276

277
        $this->applyNarrowedType($existing, $existingOutput, $intersection, $hasNull);
6✔
278
    }
279

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

299
        $intersection = $this->resolveEffectiveIntersection(
26✔
300
            $existingOutput,
26✔
301
            $existing->isRequired(),
26✔
302
            $constraintType,
26✔
303
            $existing->isRequired(),
26✔
304
            $conflictMessage,
26✔
305
        );
26✔
306

307
        if ($intersection === null) {
24✔
308
            return;
22✔
309
        }
310

311
        $this->applyNarrowedType($existing, $existingOutput, $intersection, in_array('null', $intersection, true));
2✔
312
    }
313

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

332
        if (!TypeIntersection::compute($existingType->getNames(), $incomingType->getNames())) {
47✔
333
            throw new SchemaException($conflictMessage);
5✔
334
        }
335

336
        $existingEffective = $this->buildEffectiveTypeSet($existingType, $existingIsRequired, $implicitNull);
42✔
337
        $incomingEffective = $this->buildEffectiveTypeSet($incomingType, $incomingIsRequired, $implicitNull);
42✔
338

339
        $intersection = TypeIntersection::compute($existingEffective, $incomingEffective);
42✔
340

341
        // No-op when the intersection already equals the existing effective set.
342
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
42✔
343
            return null;
35✔
344
        }
345

346
        return $intersection;
8✔
347
    }
348

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

363
        if (!$nonNull) {
8✔
364
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
UNCOV
365
            return;
×
366
        }
367

368
        $existing->setType(
8✔
369
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
370
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
371
        );
8✔
372
        $existing->filterValidators(
8✔
373
            static fn(Validator $validator): bool =>
8✔
374
                !($validator->getValidator() instanceof TypeCheckInterface),
8✔
375
        );
8✔
376

377
        // When narrowing from float to int (JSON: number → integer), the IntToFloatCastDecorator
378
        // is no longer appropriate — the property now holds a strict int value.
379
        if (in_array('float', $existingOutput->getNames(), true) && in_array('int', $nonNull, true)) {
8✔
380
            $existing->filterDecorators(
1✔
381
                static fn($decorator): bool => !($decorator instanceof IntToFloatCastDecorator),
1✔
382
            );
1✔
383
        }
384
    }
385

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

400
        $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
86✔
401

402
        $hasNull = in_array('null', $allNames, true)
86✔
403
            || $existingOutput->isNullable() === true
86✔
404
            || $incomingOutput->isNullable() === true;
86✔
405
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
86✔
406

407
        if (!$nonNullNames) {
86✔
UNCOV
408
            return;
×
409
        }
410

411
        $mergedType = new PropertyType($nonNullNames, $hasNull ? true : null);
86✔
412

413
        if (
414
            $mergedType->getNames() === $existingOutput->getNames()
86✔
415
            && $mergedType->isNullable() === $existingOutput->isNullable()
86✔
416
        ) {
417
            return;
64✔
418
        }
419

420
        $existing->setType($mergedType, $mergedType);
22✔
421
        $existing->filterValidators(
22✔
422
            static fn(Validator $validator): bool =>
22✔
423
                !($validator->getValidator() instanceof TypeCheckInterface),
22✔
424
        );
22✔
425
    }
426

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

444
        if (
445
            $type->isNullable() === true
42✔
446
            || ($type->isNullable() === null && $implicitNull && !$isRequired)
42✔
447
        ) {
448
            $names[] = 'null';
34✔
449
        }
450

451
        return $names;
42✔
452
    }
453
}
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