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

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

20 Mar 2026 09:38PM UTC coverage: 98.584% (-0.1%) from 98.693%
23363658068

Pull #115

github

Enno Woortmann
Add PSR-12 code style enforcement via PHP CodeSniffer

- Add squizlabs/php_codesniffer ^4.0 as dev dependency
- Add phpcs.xml with PSR-12 baseline, disabling 4 rules:
  ScopeClosingBrace (keep compact empty bodies), ClassDeclaration
  ExtendsLine/ImplementsLine (allow extends/implements on own lines),
  and CamelCapsMethodName (allow snake_case internal utility methods)
- Apply phpcbf auto-fixes across all 108 affected source files
- Manually wrap 9 lines exceeding 120 characters
- Document phpcs check step and qlty.sh PR review URLs in CLAUDE.md
Pull Request #115: Type system (Type widening for compositions, union types)

479 of 489 new or added lines in 51 files covered. (97.96%)

1 existing line in 1 file now uncovered.

3829 of 3884 relevant lines covered (98.58%)

549.49 hits per line

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

97.96
/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)
2,071✔
32
    {}
2,071✔
33

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

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

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

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

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

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

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

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

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

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

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

124
        return true;
38✔
125
    }
126

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

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

162
        return true;
16✔
163
    }
164

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

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

197
        return true;
2✔
198
    }
199

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

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

261
        if (!$declaredIntersection) {
38✔
262
            throw new SchemaException($conflictMessage);
5✔
263
        }
264

265
        $existingEffective = $this->buildEffectiveTypeSet(
33✔
266
            $existingOutput,
33✔
267
            $existingProperty ?? $existing,
33✔
268
            $implicitNull,
33✔
269
        );
33✔
270
        $incomingEffective = $this->buildEffectiveTypeSet(
33✔
271
            $incomingType,
33✔
272
            $incomingProperty ?? $existing,
33✔
273
            $implicitNull,
33✔
274
        );
33✔
275

276
        $intersection = $this->computeDeclaredIntersection($existingEffective, $incomingEffective);
33✔
277

278
        // No-op when the intersection already equals the existing effective set.
279
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
33✔
280
            return;
26✔
281
        }
282

283
        $hasNull = in_array('null', $intersection, true);
8✔
284

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

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

295
        if (!$nonNull) {
8✔
296
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
NEW
297
            return;
×
298
        }
299

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

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

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

332
        $hasNull = in_array('null', $allNames, true)
56✔
333
            || $existingOutput->isNullable() === true
56✔
334
            || $incomingOutput->isNullable() === true;
56✔
335
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
56✔
336

337
        if (!$nonNullNames) {
56✔
NEW
338
            return;
×
339
        }
340

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

343
        if (
344
            $mergedType->getNames() === $existingOutput->getNames()
56✔
345
            && $mergedType->isNullable() === $existingOutput->isNullable()
56✔
346
        ) {
347
            return;
34✔
348
        }
349

350
        $existing->setType($mergedType, $mergedType);
22✔
351
        $existing->filterValidators(
22✔
352
            static fn(Validator $validator): bool =>
22✔
353
                !($validator->getValidator() instanceof TypeCheckInterface),
22✔
354
        );
22✔
355
    }
356

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

374
        if (
375
            $type->isNullable() === true
33✔
376
            || ($type->isNullable() === null && $implicitNull && !$property->isRequired())
33✔
377
        ) {
378
            $names[] = 'null';
29✔
379
        }
380

381
        return $names;
33✔
382
    }
383

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

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

409
        return array_values(array_unique($intersection));
38✔
410
    }
411
}
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