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

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

23 Mar 2026 01:55PM UTC coverage: 98.584% (-0.001%) from 98.585%
23440965067

Pull #118

github

Enno Woortmann
Merge fix/issue-110 into chore/phpunit-13-upgrade

Brings in patternProperties type intersection, PSR-12 code style
enforcement, PropertyMerger API refactor, and all related tests and
documentation. Resolved conflicts by keeping readonly constructors and
PHP 8.4+ style from this branch, applying phpcbf to align opening
braces with the PSR-12 rule. Added squizlabs/php_codesniffer to
require-dev alongside phpunit ^13.0.
Pull Request #118: Upgrade to PHP 8.4 minimum and PHPUnit 13

508 of 517 new or added lines in 54 files covered. (98.26%)

23 existing lines in 9 files now uncovered.

3828 of 3883 relevant lines covered (98.58%)

548.12 hits per line

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

98.05
/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
    public function __construct(private readonly ?GeneratorConfiguration $generatorConfiguration = null)
2,069✔
31
    {}
2,069✔
32

33
    /**
34
     * Record a property name as having been registered from the root (compositionProcessor=null).
35
     * Called by Schema::addProperty on first registration when compositionProcessor is null.
36
     */
37
    public function markRootRegistered(string $propertyName): void
1,969✔
38
    {
39
        $this->rootRegisteredProperties[$propertyName] = true;
1,969✔
40
    }
41

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

64
        if ($this->guardRootPrecedence($existing, $incoming, $isAllOf)) {
94✔
65
            return;
38✔
66
        }
67

68
        // Use getType(true) for the stored output type.
69
        // getType(false) post-Phase-5 returns a synthesised union and cannot be decomposed.
70
        $existingOutput = $existing->getType(true);
72✔
71
        $incomingOutput = $incoming->getType(true);
72✔
72

73
        if ($this->mergeNullableBranch($existing, $incoming, $existingOutput, $incomingOutput)) {
72✔
74
            return;
16✔
75
        }
76

77
        if ($this->mergeIntoExistingNull($existing, $existingOutput, $incomingOutput)) {
70✔
78
            return;
2✔
79
        }
80

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

94
        $this->applyAnyOfOneOfUnion($existing, $existingOutput, $incomingOutput);
56✔
95
    }
96

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

112
        $existingOutput = $existing->getType(true);
38✔
113
        $incomingOutput = $incoming->getType(true);
38✔
114

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

128
        return true;
38✔
129
    }
130

131
    /**
132
     * Handle the case where the incoming branch carries no scalar type (null-type or truly untyped).
133
     *
134
     * - Explicit null-type branch (`{"type":"null"}`): adds nullable=true to the existing type.
135
     * - Truly untyped branch (`{}`): the combined type is unbounded — remove the type hint entirely.
136
     *
137
     * Returns true when the caller should return early (this method fully handled the merge).
138
     */
139
    private function mergeNullableBranch(
72✔
140
        PropertyInterface $existing,
141
        PropertyInterface $incoming,
142
        ?PropertyType $existingOutput,
143
        ?PropertyType $incomingOutput,
144
    ): bool {
145
        if ($incomingOutput !== null) {
72✔
146
            return false;
70✔
147
        }
148

149
        if (str_contains($incoming->getTypeHint(), 'null')) {
16✔
150
            // Explicit null-type branch: treat as nullable=true with no added scalar names.
151
            if ($existingOutput) {
1✔
152
                $existing->setType(
1✔
153
                    new PropertyType($existingOutput->getNames(), true),
1✔
154
                    new PropertyType($existingOutput->getNames(), true),
1✔
155
                );
1✔
156
                $existing->filterValidators(
1✔
157
                    static fn(Validator $validator): bool =>
1✔
158
                        !($validator->getValidator() instanceof TypeCheckInterface),
1✔
159
                );
1✔
160
            }
161
        } else {
162
            // Truly untyped branch: combined type is unbounded — remove the type hint.
163
            $existing->setType(null, null);
15✔
164
        }
165

166
        return true;
16✔
167
    }
168

169
    /**
170
     * Handle the case where the existing slot was claimed first by a null-type or truly-untyped
171
     * branch, and a concrete-typed branch arrives second.
172
     *
173
     * - If the existing slot carries a 'null' type hint (explicit null-type branch): promote to
174
     *   nullable scalar using the incoming type names.
175
     * - If the existing slot is truly untyped: keep as-is (no type hint — it was already widened
176
     *   to unbounded by the untyped branch).
177
     *
178
     * Returns true when the caller should return early (this method fully handled the merge).
179
     */
180
    private function mergeIntoExistingNull(
70✔
181
        PropertyInterface $existing,
182
        ?PropertyType $existingOutput,
183
        ?PropertyType $incomingOutput,
184
    ): bool {
185
        if ($existingOutput !== null) {
70✔
186
            return false;
68✔
187
        }
188

189
        if (str_contains($existing->getTypeHint(), 'null')) {
2✔
190
            $existing->setType(
1✔
191
                new PropertyType($incomingOutput->getNames(), true),
1✔
192
                new PropertyType($incomingOutput->getNames(), true),
1✔
193
            );
1✔
194
            $existing->filterValidators(
1✔
195
                static fn(Validator $validator): bool =>
1✔
196
                    !($validator->getValidator() instanceof TypeCheckInterface),
1✔
197
            );
1✔
198
        }
199
        // For a truly untyped existing slot: keep as-is (already unbounded, no type hint).
200

201
        return true;
2✔
202
    }
203

204
    /**
205
     * Narrow $existing's type to its intersection with $incoming's type.
206
     *
207
     * Intended for allOf branch merging: both properties' required flags and explicit nullable
208
     * flags are respected, and an explicitly nullable existing type is preserved unless the
209
     * incoming type explicitly denies nullability.
210
     *
211
     * Throws SchemaException with $conflictMessage when the declared intersection is empty
212
     * (the schema is unsatisfiable).
213
     *
214
     * @throws SchemaException
215
     */
216
    public function narrowToIntersection(
12✔
217
        PropertyInterface $existing,
218
        PropertyInterface $incoming,
219
        string $conflictMessage,
220
    ): void {
221
        $existingOutput = $existing->getType(true);
12✔
222
        $incomingOutput = $incoming->getType(true);
12✔
223

224
        $intersection = $this->resolveEffectiveIntersection(
12✔
225
            $existingOutput,
12✔
226
            $existing->isRequired(),
12✔
227
            $incomingOutput,
12✔
228
            $incoming->isRequired(),
12✔
229
            $conflictMessage,
12✔
230
        );
12✔
231

232
        if ($intersection === null) {
9✔
233
            return;
4✔
234
        }
235

236
        $hasNull = in_array('null', $intersection, true);
6✔
237

238
        // If the existing type is explicitly nullable (nullable=true) and the incoming type does
239
        // not explicitly deny nullability (nullable=false), preserve the explicit nullable.
240
        if (!$hasNull && $existingOutput->isNullable() === true && $incomingOutput->isNullable() !== false) {
6✔
241
            $hasNull = true;
2✔
242
        }
243

244
        $this->applyNarrowedType($existing, $existingOutput, $intersection, $hasNull);
6✔
245
    }
246

247
    /**
248
     * Constrain $existing's type to its intersection with $constraintType.
249
     *
250
     * Intended for patternProperties enforcement: uses strict intersection semantics —
251
     * nullability is determined solely by what survives the intersection, without
252
     * preserving any existing explicit nullable flag.
253
     *
254
     * Throws SchemaException with $conflictMessage when the declared intersection is empty
255
     * (the schema is unsatisfiable).
256
     *
257
     * @throws SchemaException
258
     */
259
    public function applyTypeConstraint(
26✔
260
        PropertyInterface $existing,
261
        PropertyType $constraintType,
262
        string $conflictMessage,
263
    ): void {
264
        $existingOutput = $existing->getType(true);
26✔
265

266
        $intersection = $this->resolveEffectiveIntersection(
26✔
267
            $existingOutput,
26✔
268
            $existing->isRequired(),
26✔
269
            $constraintType,
26✔
270
            $existing->isRequired(),
26✔
271
            $conflictMessage,
26✔
272
        );
26✔
273

274
        if ($intersection === null) {
24✔
275
            return;
22✔
276
        }
277

278
        $this->applyNarrowedType($existing, $existingOutput, $intersection, in_array('null', $intersection, true));
2✔
279
    }
280

281
    /**
282
     * Compute the effective type-name intersection of two sides, throwing when the declared
283
     * (scalar-only) intersection is empty.
284
     *
285
     * Returns null when the effective intersection equals the existing effective set (no-op).
286
     *
287
     * @return string[]|null null = no change needed; array = effective intersection to apply
288
     * @throws SchemaException
289
     */
290
    private function resolveEffectiveIntersection(
38✔
291
        PropertyType $existingType,
292
        bool $existingIsRequired,
293
        PropertyType $incomingType,
294
        bool $incomingIsRequired,
295
        string $conflictMessage,
296
    ): ?array {
297
        $implicitNull = $this->generatorConfiguration?->isImplicitNullAllowed() ?? false;
38✔
298

299
        if (!$this->computeDeclaredIntersection($existingType->getNames(), $incomingType->getNames())) {
38✔
300
            throw new SchemaException($conflictMessage);
5✔
301
        }
302

303
        $existingEffective = $this->buildEffectiveTypeSet($existingType, $existingIsRequired, $implicitNull);
33✔
304
        $incomingEffective = $this->buildEffectiveTypeSet($incomingType, $incomingIsRequired, $implicitNull);
33✔
305

306
        $intersection = $this->computeDeclaredIntersection($existingEffective, $incomingEffective);
33✔
307

308
        // No-op when the intersection already equals the existing effective set.
309
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
33✔
310
            return null;
26✔
311
        }
312

313
        return $intersection;
8✔
314
    }
315

316
    /**
317
     * Apply the result of a type intersection to $existing: update its PropertyType, strip stale
318
     * type-check validators, and remove the IntToFloatCastDecorator when narrowing float → int.
319
     *
320
     * @param string[] $intersection effective type name set after intersection (may include 'null')
321
     */
322
    private function applyNarrowedType(
8✔
323
        PropertyInterface $existing,
324
        PropertyType $existingOutput,
325
        array $intersection,
326
        bool $hasNull,
327
    ): void {
328
        $nonNull = array_values(array_filter($intersection, fn(string $t): bool => $t !== 'null'));
8✔
329

330
        if (!$nonNull) {
8✔
331
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
NEW
332
            return;
×
333
        }
334

335
        $existing->setType(
8✔
336
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
337
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
338
        );
8✔
339
        $existing->filterValidators(
8✔
340
            static fn(Validator $validator): bool =>
8✔
341
                !($validator->getValidator() instanceof TypeCheckInterface),
8✔
342
        );
8✔
343

344
        // When narrowing from float to int (JSON: number → integer), the IntToFloatCastDecorator
345
        // is no longer appropriate — the property now holds a strict int value.
346
        if (in_array('float', $existingOutput->getNames(), true) && in_array('int', $nonNull, true)) {
8✔
347
            $existing->filterDecorators(
1✔
348
                static fn($decorator): bool => !($decorator instanceof IntToFloatCastDecorator),
1✔
349
            );
1✔
350
        }
351
    }
352

353
    /**
354
     * anyOf / oneOf: widen the existing type to the union of all observed branch types.
355
     *
356
     * 'null' in the name list is promoted to nullable=true rather than kept as a type name,
357
     * so the render pipeline does not emit "string|null|null". Any nullable=true already set
358
     * on either side (e.g. from cloneTransferredProperty) is propagated.
359
     */
360
    private function applyAnyOfOneOfUnion(
56✔
361
        PropertyInterface $existing,
362
        PropertyType $existingOutput,
363
        PropertyType $incomingOutput,
364
    ): void {
365
        $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
56✔
366

367
        $hasNull = in_array('null', $allNames, true)
56✔
368
            || $existingOutput->isNullable() === true
56✔
369
            || $incomingOutput->isNullable() === true;
56✔
370
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
56✔
371

372
        if (!$nonNullNames) {
56✔
NEW
373
            return;
×
374
        }
375

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

378
        if (
379
            $mergedType->getNames() === $existingOutput->getNames()
56✔
380
            && $mergedType->isNullable() === $existingOutput->isNullable()
56✔
381
        ) {
382
            return;
34✔
383
        }
384

385
        $existing->setType($mergedType, $mergedType);
22✔
386
        $existing->filterValidators(
22✔
387
            static fn(Validator $validator): bool =>
22✔
388
                !($validator->getValidator() instanceof TypeCheckInterface),
22✔
389
        );
22✔
390
    }
391

392
    /**
393
     * Build the effective type name set for one side of an allOf intersection.
394
     *
395
     * The effective set is the declared names plus 'null' when:
396
     * - the PropertyType explicitly marks itself as nullable (nullable=true), or
397
     * - the PropertyType has undecided nullability (nullable=null), implicitNull is enabled,
398
     *   and the property is not required (so the render layer would add null anyway).
399
     *
400
     * @return string[]
401
     */
402
    private function buildEffectiveTypeSet(
33✔
403
        PropertyType $type,
404
        bool $isRequired,
405
        bool $implicitNull,
406
    ): array {
407
        $names = $type->getNames();
33✔
408

409
        if (
410
            $type->isNullable() === true
33✔
411
            || ($type->isNullable() === null && $implicitNull && !$isRequired)
33✔
412
        ) {
413
            $names[] = 'null';
29✔
414
        }
415

416
        return $names;
33✔
417
    }
418

419
    /**
420
     * Compute the intersection of two type-name sets, treating 'int' as a subtype of 'float'
421
     * (JSON Schema: integer is a subset of number).
422
     *
423
     * When one side contains 'float' and the other contains 'int' (but not 'float'), the
424
     * intersection resolves to 'int' — the narrower concrete type — rather than empty.
425
     *
426
     * @param string[] $a
427
     * @param string[] $b
428
     * @return string[]
429
     */
430
    private function computeDeclaredIntersection(array $a, array $b): array
38✔
431
    {
432
        $intersection = array_values(array_intersect($a, $b));
38✔
433

434
        // int ⊂ float (JSON Schema: integer is a subtype of number).
435
        // When one side has float and the other has int (without float), resolve to int.
436
        if (!in_array('float', $intersection, true)) {
38✔
437
            if (in_array('float', $a, true) && in_array('int', $b, true)) {
38✔
438
                $intersection[] = 'int';
1✔
439
            } elseif (in_array('int', $a, true) && in_array('float', $b, true)) {
37✔
NEW
440
                $intersection[] = 'int';
×
441
            }
442
        }
443

444
        return array_values(array_unique($intersection));
38✔
445
    }
446
}
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