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

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

20 Mar 2026 01:53AM UTC coverage: 98.567% (-0.1%) from 98.693%
23325740070

Pull #115

github

Enno Woortmann
Document patternProperties type intersection behaviour in object.rst

Add a note to the Pattern Properties section explaining that when a
declared property name matches a pattern, both constraints apply
simultaneously (allOf semantics). Compatible types are narrowed to the
intersection; contradictory types throw SchemaException at generation time.
Pull Request #115: Type system (Type widening for compositions, union types)

360 of 370 new or added lines in 23 files covered. (97.3%)

5 existing lines in 5 files now uncovered.

3783 of 3838 relevant lines covered (98.57%)

546.14 hits per line

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

97.93
/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\ComposedValue\AllOfProcessor;
14
use PHPModelGenerator\PropertyProcessor\Decorator\Property\IntToFloatCastDecorator;
15

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

31
    public function __construct(private ?GeneratorConfiguration $generatorConfiguration = null) {}
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,971✔
38
    {
39
        $this->rootRegisteredProperties[$propertyName] = true;
1,971✔
40
    }
41

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

61
        if ($this->guardRootPrecedence($existing, $incoming, $compositionProcessor)) {
94✔
62
            return;
38✔
63
        }
64

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

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

74
        if ($this->mergeIntoExistingNull($existing, $existingOutput, $incomingOutput)) {
70✔
75
            return;
2✔
76
        }
77

78
        if ($compositionProcessor !== null
68✔
79
            && is_a($compositionProcessor, AllOfProcessor::class, true)
68✔
80
        ) {
81
            $this->applyAllOfIntersection($existing, $incoming, $existingOutput, $incomingOutput);
12✔
82
            return;
9✔
83
        }
84

85
        $this->applyAnyOfOneOfUnion($existing, $existingOutput, $incomingOutput);
56✔
86
    }
87

88
    /**
89
     * Guard: anyOf/oneOf branches must not widen a property already registered by the root.
90
     * Emits an optional warning when the branch declares a type that differs from the root type.
91
     *
92
     * Returns true when the caller should return early (the guard handled this call).
93
     */
94
    private function guardRootPrecedence(
94✔
95
        PropertyInterface $existing,
96
        PropertyInterface $incoming,
97
        ?string $compositionProcessor,
98
    ): bool {
99
        if (!isset($this->rootRegisteredProperties[$incoming->getName()])
94✔
100
            || is_a($compositionProcessor, AllOfProcessor::class, true)
94✔
101
        ) {
102
            return false;
72✔
103
        }
104

105
        $existingOutput = $existing->getType(true);
38✔
106
        $incomingOutput = $incoming->getType(true);
38✔
107

108
        if ($incomingOutput
38✔
109
            && $existingOutput
110
            && array_diff($incomingOutput->getNames(), $existingOutput->getNames())
38✔
111
            && $this->generatorConfiguration?->isOutputEnabled()
38✔
112
        ) {
113
            echo "Warning: composition branch defines property '{$incoming->getName()}' with type "
1✔
114
                . implode('|', $incomingOutput->getNames())
1✔
115
                . " which differs from root type "
1✔
116
                . implode('|', $existingOutput->getNames())
1✔
117
                . " — root definition takes precedence.\n";
1✔
118
        }
119

120
        return true;
38✔
121
    }
122

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

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

158
        return true;
16✔
159
    }
160

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

181
        if (str_contains($existing->getTypeHint(), 'null')) {
2✔
182
            $existing->setType(
1✔
183
                new PropertyType($incomingOutput->getNames(), true),
1✔
184
                new PropertyType($incomingOutput->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
        // For a truly untyped existing slot: keep as-is (already unbounded, no type hint).
192

193
        return true;
2✔
194
    }
195

196
    /**
197
     * allOf: every branch must hold simultaneously, so narrow the existing type to the intersection.
198
     *
199
     * Conflict detection uses the declared scalar names only (implicit-null is a rendering concern
200
     * and does not affect whether two types are mutually satisfiable). If the declared intersection
201
     * is empty the schema is unsatisfiable and a SchemaException is thrown.
202
     *
203
     * Type narrowing then uses the effective type set, which expands nullable=null to include 'null'
204
     * when implicitNull is enabled and the property is optional.
205
     *
206
     * @throws SchemaException
207
     */
208
    private function applyAllOfIntersection(
12✔
209
        PropertyInterface $existing,
210
        PropertyInterface $incoming,
211
        PropertyType $existingOutput,
212
        PropertyType $incomingOutput,
213
    ): void {
214
        $this->narrowToIntersection(
12✔
215
            $existing,
12✔
216
            $existingOutput,
12✔
217
            $incomingOutput,
12✔
218
            sprintf(
12✔
219
                "Property '%s' is defined with conflicting types across allOf branches. " .
12✔
220
                "allOf requires all constraints to hold simultaneously, making this schema unsatisfiable.",
12✔
221
                $incoming->getName(),
12✔
222
            ),
12✔
223
            $this->generatorConfiguration?->isImplicitNullAllowed() ?? false,
12✔
224
            $existing,
12✔
225
            $incoming,
12✔
226
        );
12✔
227
    }
228

229
    /**
230
     * Narrow $existing to the intersection of $existingOutput and $incomingType.
231
     *
232
     * Throws SchemaException with $conflictMessage when the declared intersection is empty.
233
     *
234
     * Nullability: when $preserveNullable is true, an explicitly nullable existing type retains
235
     * its nullable flag even if the incoming type does not carry 'null' — unless the incoming
236
     * type explicitly denies nullability (nullable=false).
237
     * When $preserveNullable is false, nullability is determined purely by whether 'null' survives
238
     * the effective-type intersection (strict intersection semantics).
239
     *
240
     * @throws SchemaException
241
     */
242
    public function narrowToIntersection(
38✔
243
        PropertyInterface $existing,
244
        PropertyType $existingOutput,
245
        PropertyType $incomingType,
246
        string $conflictMessage,
247
        bool $implicitNull = false,
248
        ?PropertyInterface $existingProperty = null,
249
        ?PropertyInterface $incomingProperty = null,
250
        bool $preserveNullable = true,
251
    ): void {
252
        $declaredIntersection = $this->computeDeclaredIntersection(
38✔
253
            $existingOutput->getNames(),
38✔
254
            $incomingType->getNames(),
38✔
255
        );
38✔
256

257
        if (!$declaredIntersection) {
38✔
258
            throw new SchemaException($conflictMessage);
5✔
259
        }
260

261
        $existingEffective = $this->buildEffectiveTypeSet(
33✔
262
            $existingOutput,
33✔
263
            $existingProperty ?? $existing,
33✔
264
            $implicitNull,
33✔
265
        );
33✔
266
        $incomingEffective = $this->buildEffectiveTypeSet(
33✔
267
            $incomingType,
33✔
268
            $incomingProperty ?? $existing,
33✔
269
            $implicitNull,
33✔
270
        );
33✔
271

272
        $intersection = $this->computeDeclaredIntersection($existingEffective, $incomingEffective);
33✔
273

274
        // No-op when the intersection already equals the existing effective set.
275
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
33✔
276
            return;
26✔
277
        }
278

279
        $hasNull = in_array('null', $intersection, true);
8✔
280

281
        if ($preserveNullable) {
8✔
282
            // If the existing type is explicitly nullable (nullable=true) and the incoming type does
283
            // not explicitly deny nullability (nullable=false), preserve the explicit nullable.
284
            if (!$hasNull && $existingOutput->isNullable() === true && $incomingType->isNullable() !== false) {
6✔
285
                $hasNull = true;
2✔
286
            }
287
        }
288

289
        $nonNull = array_values(array_filter($intersection, fn(string $t): bool => $t !== 'null'));
8✔
290

291
        if (!$nonNull) {
8✔
292
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
NEW
293
            return;
×
294
        }
295

296
        $existing->setType(
8✔
297
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
298
            new PropertyType($nonNull, $hasNull ? true : null),
8✔
299
        );
8✔
300
        $existing->filterValidators(
8✔
301
            static fn(Validator $validator): bool =>
8✔
302
                !($validator->getValidator() instanceof TypeCheckInterface),
8✔
303
        );
8✔
304

305
        // When narrowing from float to int (JSON: number → integer), the IntToFloatCastDecorator
306
        // is no longer appropriate — the property now holds a strict int value.
307
        if (in_array('float', $existingOutput->getNames(), true) && in_array('int', $nonNull, true)) {
8✔
308
            $existing->filterDecorators(
1✔
309
                static fn($decorator): bool => !($decorator instanceof IntToFloatCastDecorator),
1✔
310
            );
1✔
311
        }
312
    }
313

314
    /**
315
     * anyOf / oneOf: widen the existing type to the union of all observed branch types.
316
     *
317
     * 'null' in the name list is promoted to nullable=true rather than kept as a type name,
318
     * so the render pipeline does not emit "string|null|null". Any nullable=true already set
319
     * on either side (e.g. from cloneTransferredProperty) is propagated.
320
     */
321
    private function applyAnyOfOneOfUnion(
56✔
322
        PropertyInterface $existing,
323
        PropertyType $existingOutput,
324
        PropertyType $incomingOutput,
325
    ): void {
326
        $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
56✔
327

328
        $hasNull = in_array('null', $allNames, true)
56✔
329
            || $existingOutput->isNullable() === true
56✔
330
            || $incomingOutput->isNullable() === true;
56✔
331
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
56✔
332

333
        if (!$nonNullNames) {
56✔
NEW
334
            return;
×
335
        }
336

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

339
        if ($mergedType->getNames() === $existingOutput->getNames()
56✔
340
            && $mergedType->isNullable() === $existingOutput->isNullable()
56✔
341
        ) {
342
            return;
34✔
343
        }
344

345
        $existing->setType($mergedType, $mergedType);
22✔
346
        $existing->filterValidators(
22✔
347
            static fn(Validator $validator): bool =>
22✔
348
                !($validator->getValidator() instanceof TypeCheckInterface),
22✔
349
        );
22✔
350
    }
351

352
    /**
353
     * Build the effective type name set for one side of an allOf intersection.
354
     *
355
     * The effective set is the declared names plus 'null' when:
356
     * - the PropertyType explicitly marks itself as nullable (nullable=true), or
357
     * - the PropertyType has undecided nullability (nullable=null), implicitNull is enabled,
358
     *   and the property is not required (so the render layer would add null anyway).
359
     *
360
     * @return string[]
361
     */
362
    private function buildEffectiveTypeSet(
33✔
363
        PropertyType $type,
364
        PropertyInterface $property,
365
        bool $implicitNull,
366
    ): array {
367
        $names = $type->getNames();
33✔
368

369
        if ($type->isNullable() === true
33✔
370
            || ($type->isNullable() === null && $implicitNull && !$property->isRequired())
33✔
371
        ) {
372
            $names[] = 'null';
29✔
373
        }
374

375
        return $names;
33✔
376
    }
377

378
    /**
379
     * Compute the intersection of two type-name sets, treating 'int' as a subtype of 'float'
380
     * (JSON Schema: integer is a subset of number).
381
     *
382
     * When one side contains 'float' and the other contains 'int' (but not 'float'), the
383
     * intersection resolves to 'int' — the narrower concrete type — rather than empty.
384
     *
385
     * @param string[] $a
386
     * @param string[] $b
387
     * @return string[]
388
     */
389
    private function computeDeclaredIntersection(array $a, array $b): array
38✔
390
    {
391
        $intersection = array_values(array_intersect($a, $b));
38✔
392

393
        // int ⊂ float (JSON Schema: integer is a subtype of number).
394
        // When one side has float and the other has int (without float), resolve to int.
395
        if (!in_array('float', $intersection, true)) {
38✔
396
            if (in_array('float', $a, true) && in_array('int', $b, true)) {
38✔
397
                $intersection[] = 'int';
1✔
398
            } elseif (in_array('int', $a, true) && in_array('float', $b, true)) {
37✔
NEW
399
                $intersection[] = 'int';
×
400
            }
401
        }
402

403
        return array_values(array_unique($intersection));
38✔
404
    }
405
}
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