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

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

06 Mar 2026 08:15PM UTC coverage: 98.497% (-0.2%) from 98.693%
22780353222

Pull #115

github

Enno Woortmann
Transfer if/then/else branch properties to parent schema with correct union types

- if/then/else composition now transfers all branch properties (if, then, else)
  to the parent schema, making them accessible as typed properties on the model
- ConditionalPropertyValidator distinguishes composed properties (all branches,
  for transfer) from data branches (then/else only, for widening checks), so
  the if condition block is never treated as a competing data branch when
  computing type-widening eligibility
- Cross-typed then/else branches produce union type hints (int | string | null),
  consistent with anyOf/oneOf behaviour
- then-only branches produce nullable hints (?int) since the branch may not apply
- Fix PropertyMerger readonly constructor promotion (PHP 8.1 only) to plain
  private for PHP 8.0 compatibility
- Add PHP version compatibility rule to CLAUDE.md
- Document cross-typed if/then/else union type behaviour in docs/source/combinedSchemas/if.rst
Pull Request #115: Type system (Type widening for compositions, union types)

248 of 259 new or added lines in 22 files covered. (95.75%)

15 existing lines in 6 files now uncovered.

3671 of 3727 relevant lines covered (98.5%)

553.38 hits per line

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

93.1
/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

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 ?GeneratorConfiguration $generatorConfiguration = null) {}
31

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

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

60
        if ($this->guardRootPrecedence($existing, $incoming, $compositionProcessor)) {
89✔
61
            return;
37✔
62
        }
63

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

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

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

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

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

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

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

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

119
        return true;
37✔
120
    }
121

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

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

157
        return true;
16✔
158
    }
159

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

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

192
        return true;
2✔
193
    }
194

195
    /**
196
     * allOf: every branch must hold simultaneously, so narrow the existing type to the intersection.
197
     *
198
     * Conflict detection uses the declared scalar names only (implicit-null is a rendering concern
199
     * and does not affect whether two types are mutually satisfiable). If the declared intersection
200
     * is empty the schema is unsatisfiable and a SchemaException is thrown.
201
     *
202
     * Type narrowing then uses the effective type set, which expands nullable=null to include 'null'
203
     * when implicitNull is enabled and the property is optional.
204
     *
205
     * @throws SchemaException
206
     */
207
    private function applyAllOfIntersection(
10✔
208
        PropertyInterface $existing,
209
        PropertyInterface $incoming,
210
        PropertyType $existingOutput,
211
        PropertyType $incomingOutput,
212
    ): void {
213
        $declaredIntersection = array_values(
10✔
214
            array_intersect($existingOutput->getNames(), $incomingOutput->getNames()),
10✔
215
        );
10✔
216

217
        if (!$declaredIntersection) {
10✔
218
            throw new SchemaException(
3✔
219
                sprintf(
3✔
220
                    "Property '%s' is defined with conflicting types across allOf branches. " .
3✔
221
                    "allOf requires all constraints to hold simultaneously, making this schema unsatisfiable.",
3✔
222
                    $incoming->getName(),
3✔
223
                ),
3✔
224
            );
3✔
225
        }
226

227
        $implicitNull = $this->generatorConfiguration?->isImplicitNullAllowed() ?? false;
7✔
228

229
        $existingEffective = $this->buildEffectiveTypeSet($existingOutput, $existing, $implicitNull);
7✔
230
        $incomingEffective = $this->buildEffectiveTypeSet($incomingOutput, $incoming, $implicitNull);
7✔
231

232
        $intersection = array_values(array_intersect($existingEffective, $incomingEffective));
7✔
233

234
        // No-op when the intersection already equals the existing effective set.
235
        if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) {
7✔
236
            return;
4✔
237
        }
238

239
        $hasNull = in_array('null', $intersection, true);
4✔
240

241
        // If the existing type is explicitly nullable (nullable=true) and the incoming branch does
242
        // not explicitly deny nullability (nullable=false), preserve the explicit nullable.
243
        // An allOf branch that says {"type":"integer"} does not say "must not be null" — it only
244
        // constrains the non-null value. Only an explicit nullable=false would override this.
245
        if (!$hasNull && $existingOutput->isNullable() === true && $incomingOutput->isNullable() !== false) {
4✔
246
            $hasNull = true;
1✔
247
        }
248

249
        $nonNull = array_values(array_filter($intersection, fn(string $t): bool => $t !== 'null'));
4✔
250

251
        if (!$nonNull) {
4✔
252
            // Only null survives — keep as-is; the null-processor path handles pure-null types.
NEW
253
            return;
×
254
        }
255

256
        $existing->setType(
4✔
257
            new PropertyType($nonNull, $hasNull ? true : null),
4✔
258
            new PropertyType($nonNull, $hasNull ? true : null),
4✔
259
        );
4✔
260
        $existing->filterValidators(
4✔
261
            static fn(Validator $validator): bool =>
4✔
262
                !($validator->getValidator() instanceof TypeCheckInterface),
4✔
263
        );
4✔
264
    }
265

266
    /**
267
     * anyOf / oneOf: widen the existing type to the union of all observed branch types.
268
     *
269
     * 'null' in the name list is promoted to nullable=true rather than kept as a type name,
270
     * so the render pipeline does not emit "string|null|null". Any nullable=true already set
271
     * on either side (e.g. from cloneTransferredProperty) is propagated.
272
     */
273
    private function applyAnyOfOneOfUnion(
54✔
274
        PropertyInterface $existing,
275
        PropertyType $existingOutput,
276
        PropertyType $incomingOutput,
277
    ): void {
278
        $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
54✔
279

280
        $hasNull = in_array('null', $allNames, true)
54✔
281
            || $existingOutput->isNullable() === true
54✔
282
            || $incomingOutput->isNullable() === true;
54✔
283
        $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
54✔
284

285
        if (!$nonNullNames) {
54✔
NEW
286
            return;
×
287
        }
288

289
        $mergedType = new PropertyType($nonNullNames, $hasNull ? true : null);
54✔
290

291
        if ($mergedType->getNames() === $existingOutput->getNames()
54✔
292
            && $mergedType->isNullable() === $existingOutput->isNullable()
54✔
293
        ) {
294
            return;
34✔
295
        }
296

297
        $existing->setType($mergedType, $mergedType);
20✔
298
        $existing->filterValidators(
20✔
299
            static fn(Validator $validator): bool =>
20✔
300
                !($validator->getValidator() instanceof TypeCheckInterface),
20✔
301
        );
20✔
302
    }
303

304
    /**
305
     * Build the effective type name set for one side of an allOf intersection.
306
     *
307
     * The effective set is the declared names plus 'null' when:
308
     * - the PropertyType explicitly marks itself as nullable (nullable=true), or
309
     * - the PropertyType has undecided nullability (nullable=null), implicitNull is enabled,
310
     *   and the property is not required (so the render layer would add null anyway).
311
     *
312
     * @return string[]
313
     */
314
    private function buildEffectiveTypeSet(
7✔
315
        PropertyType $type,
316
        PropertyInterface $property,
317
        bool $implicitNull,
318
    ): array {
319
        $names = $type->getNames();
7✔
320

321
        if ($type->isNullable() === true
7✔
322
            || ($type->isNullable() === null && $implicitNull && !$property->isRequired())
7✔
323
        ) {
324
            $names[] = 'null';
6✔
325
        }
326

327
        return $names;
7✔
328
    }
329
}
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