• 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

99.04
/src/PropertyProcessor/Property/BaseProcessor.php
1
<?php
2

3
declare(strict_types = 1);
4

5
namespace PHPModelGenerator\PropertyProcessor\Property;
6

7
use PHPMicroTemplate\Exception\FileSystemException;
8
use PHPMicroTemplate\Exception\SyntaxErrorException;
9
use PHPMicroTemplate\Exception\UndefinedSymbolException;
10
use PHPModelGenerator\Exception\Object\MaxPropertiesException;
11
use PHPModelGenerator\Exception\Object\MinPropertiesException;
12
use PHPModelGenerator\Exception\SchemaException;
13
use PHPModelGenerator\Model\Property\BaseProperty;
14
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
15
use PHPModelGenerator\Model\Property\Property;
16
use PHPModelGenerator\Model\Property\PropertyInterface;
17
use PHPModelGenerator\Model\Property\PropertyType;
18
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
19
use PHPModelGenerator\Model\Validator;
20
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
21
use PHPModelGenerator\Exception\Generic\DeniedPropertyException;
22
use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator;
23
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
24
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
25
use PHPModelGenerator\Model\Validator\NoAdditionalPropertiesValidator;
26
use PHPModelGenerator\Model\Validator\PatternPropertiesValidator;
27
use PHPModelGenerator\Model\Validator\PropertyNamesValidator;
28
use PHPModelGenerator\Model\Validator\PropertyTemplateValidator;
29
use PHPModelGenerator\Model\Validator\PropertyValidator;
30
use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor;
31
use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface;
32
use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection;
33
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
34
use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory;
35

36
/**
37
 * Class BaseObjectProcessor
38
 *
39
 * @package PHPModelGenerator\PropertyProcessor\Property
40
 */
41
class BaseProcessor extends AbstractPropertyProcessor
42
{
43
    protected const TYPE = 'object';
44

45
    private const COUNT_PROPERTIES =
46
        'count(
47
            array_unique(
48
                array_merge(
49
                    array_keys($this->_rawModelDataInput),
50
                    array_keys($modelData),
51
                )
52
            ),
53
        )';
54

55
    /**
56
     * @inheritdoc
57
     *
58
     * @throws FileSystemException
59
     * @throws SchemaException
60
     * @throws SyntaxErrorException
61
     * @throws UndefinedSymbolException
62
     */
63
    public function process(string $propertyName, JsonSchema $propertySchema): PropertyInterface
2,049✔
64
    {
65
        $this->schema
2,049✔
66
            ->getSchemaDictionary()
2,049✔
67
            ->setUpDefinitionDictionary($this->schemaProcessor, $this->schema);
2,049✔
68

69
        // create a property which is used to gather composed properties validators.
70
        $property = new BaseProperty($propertyName, new PropertyType(static::TYPE), $propertySchema);
2,049✔
71
        $this->generateValidators($property, $propertySchema);
2,049✔
72

73
        $this->addPropertiesToSchema($propertySchema);
2,048✔
74
        $this->transferComposedPropertiesToSchema($property);
1,990✔
75

76
        $this->addPropertyNamesValidator($propertySchema);
1,989✔
77
        $this->addPatternPropertiesValidator($propertySchema);
1,988✔
78
        $this->addAdditionalPropertiesValidator($propertySchema);
1,987✔
79

80
        $this->addMinPropertiesValidator($propertyName, $propertySchema);
1,987✔
81
        $this->addMaxPropertiesValidator($propertyName, $propertySchema);
1,987✔
82

83
        return $property;
1,987✔
84
    }
85

86
    /**
87
     * Add a validator to check all provided property names
88
     *
89
     * @throws SchemaException
90
     * @throws FileSystemException
91
     * @throws SyntaxErrorException
92
     * @throws UndefinedSymbolException
93
     */
94
    protected function addPropertyNamesValidator(JsonSchema $propertySchema): void
1,989✔
95
    {
96
        if (!isset($propertySchema->getJson()['propertyNames'])) {
1,989✔
97
            return;
1,945✔
98
        }
99

100
        $this->schema->addBaseValidator(
44✔
101
            new PropertyNamesValidator(
44✔
102
                $this->schemaProcessor,
44✔
103
                $this->schema,
44✔
104
                $propertySchema->withJson($propertySchema->getJson()['propertyNames']),
44✔
105
            )
44✔
106
        );
44✔
107
    }
108

109
    /**
110
     * Add an object validator to specify constraints for properties which are not defined in the schema
111
     *
112
     * @throws FileSystemException
113
     * @throws SchemaException
114
     * @throws SyntaxErrorException
115
     * @throws UndefinedSymbolException
116
     */
117
    protected function addAdditionalPropertiesValidator(JsonSchema $propertySchema): void
1,987✔
118
    {
119
        $json = $propertySchema->getJson();
1,987✔
120

121
        if (!isset($json['additionalProperties']) &&
1,987✔
122
            $this->schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties()
1,987✔
123
        ) {
124
            $json['additionalProperties'] = false;
5✔
125
        }
126

127
        if (!isset($json['additionalProperties']) || $json['additionalProperties'] === true) {
1,987✔
128
            return;
1,909✔
129
        }
130

131
        if (!is_bool($json['additionalProperties'])) {
230✔
132
            $this->schema->addBaseValidator(
88✔
133
                new AdditionalPropertiesValidator(
88✔
134
                    $this->schemaProcessor,
88✔
135
                    $this->schema,
88✔
136
                    $propertySchema,
88✔
137
                )
88✔
138
            );
88✔
139

140
            return;
88✔
141
        }
142

143
        $this->schema->addBaseValidator(
142✔
144
            new NoAdditionalPropertiesValidator(
142✔
145
                new Property($this->schema->getClassName(), null, $propertySchema),
142✔
146
                $json,
142✔
147
            )
142✔
148
        );
142✔
149
    }
150

151
    /**
152
     * @throws SchemaException
153
     */
154
    protected function addPatternPropertiesValidator(JsonSchema $propertySchema): void
1,988✔
155
    {
156
        $json = $propertySchema->getJson();
1,988✔
157

158
        if (!isset($json['patternProperties'])) {
1,988✔
159
            return;
1,938✔
160
        }
161

162
        foreach ($json['patternProperties'] as $pattern => $schema) {
50✔
163
            $escapedPattern = addcslashes($pattern, '/');
50✔
164

165
            if (@preg_match("/$escapedPattern/", '') === false) {
50✔
166
                throw new SchemaException(
1✔
167
                    "Invalid pattern '$pattern' for pattern property in file {$propertySchema->getFile()}",
1✔
168
                );
1✔
169
            }
170

171
            $validator = new PatternPropertiesValidator(
49✔
172
                $this->schemaProcessor,
49✔
173
                $this->schema,
49✔
174
                $pattern,
49✔
175
                $propertySchema->withJson($schema),
49✔
176
            );
49✔
177

178
            $this->schema->addBaseValidator($validator);
49✔
179
        }
180
    }
181

182
    /**
183
     * Add an object validator to limit the amount of provided properties
184
     *
185
     * @throws SchemaException
186
     */
187
    protected function addMaxPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void
1,987✔
188
    {
189
        $json = $propertySchema->getJson();
1,987✔
190

191
        if (!isset($json['maxProperties'])) {
1,987✔
192
            return;
1,954✔
193
        }
194

195
        $this->schema->addBaseValidator(
41✔
196
            new PropertyValidator(
41✔
197
                new Property($propertyName, null, $propertySchema),
41✔
198
                sprintf(
41✔
199
                    '%s > %d',
41✔
200
                    self::COUNT_PROPERTIES,
41✔
201
                    $json['maxProperties'],
41✔
202
                ),
41✔
203
                MaxPropertiesException::class,
41✔
204
                [$json['maxProperties']],
41✔
205
            )
41✔
206
        );
41✔
207
    }
208

209
    /**
210
     * Add an object validator to force at least the defined amount of properties to be provided
211
     *
212
     * @throws SchemaException
213
     */
214
    protected function addMinPropertiesValidator(string $propertyName, JsonSchema $propertySchema): void
1,987✔
215
    {
216
        $json = $propertySchema->getJson();
1,987✔
217

218
        if (!isset($json['minProperties'])) {
1,987✔
219
            return;
1,972✔
220
        }
221

222
        $this->schema->addBaseValidator(
23✔
223
            new PropertyValidator(
23✔
224
                new Property($propertyName, null, $propertySchema),
23✔
225
                sprintf(
23✔
226
                    '%s < %d',
23✔
227
                    self::COUNT_PROPERTIES,
23✔
228
                    $json['minProperties'],
23✔
229
                ),
23✔
230
                MinPropertiesException::class,
23✔
231
                [$json['minProperties']],
23✔
232
            )
23✔
233
        );
23✔
234
    }
235

236
    /**
237
     * Add the properties defined in the JSON schema to the current schema model
238
     *
239
     * @throws SchemaException
240
     */
241
    protected function addPropertiesToSchema(JsonSchema $propertySchema): void
2,048✔
242
    {
243
        $json = $propertySchema->getJson();
2,048✔
244

245
        $propertyFactory = new PropertyFactory(new PropertyProcessorFactory());
2,048✔
246
        $propertyMetaDataCollection = new PropertyMetaDataCollection(
2,048✔
247
            $json['required'] ?? [],
2,048✔
248
            $json['dependencies'] ?? [],
2,048✔
249
        );
2,048✔
250

251
        $json['properties'] ??= [];
2,048✔
252
        // setup empty properties for required properties which aren't defined in the properties section of the schema
253
        $json['properties'] += array_fill_keys(
2,048✔
254
            array_diff($json['required'] ?? [], array_keys($json['properties'])),
2,048✔
255
            [],
2,048✔
256
        );
2,048✔
257

258
        foreach ($json['properties'] as $propertyName => $propertyStructure) {
2,048✔
259
            if ($propertyStructure === false) {
1,981✔
260
                if (in_array($propertyName, $json['required'] ?? [], true)) {
13✔
261
                    throw new SchemaException(
1✔
262
                        sprintf(
1✔
263
                            "Property '%s' is denied (schema false) but also listed as required in file %s",
1✔
264
                            $propertyName,
1✔
265
                            $propertySchema->getFile(),
1✔
266
                        ),
1✔
267
                    );
1✔
268
                }
269

270
                $this->schema->addBaseValidator(
12✔
271
                    new PropertyValidator(
12✔
272
                        new Property($propertyName, null, $propertySchema->withJson([])),
12✔
273
                        "array_key_exists('" . addslashes($propertyName) . "', \$modelData)",
12✔
274
                        DeniedPropertyException::class,
12✔
275
                    )
12✔
276
                );
12✔
277
                continue;
12✔
278
            }
279

280
            $this->schema->addProperty(
1,980✔
281
                $propertyFactory->create(
1,980✔
282
                    $propertyMetaDataCollection,
1,980✔
283
                    $this->schemaProcessor,
1,980✔
284
                    $this->schema,
1,980✔
285
                    (string) $propertyName,
1,980✔
286
                    $propertySchema->withJson($propertyStructure),
1,980✔
287
                )
1,980✔
288
            );
1,980✔
289
        }
290
    }
291

292
    /**
293
     * Transfer properties of composed properties to the current schema to offer a complete model including all
294
     * composed properties.
295
     *
296
     * @throws SchemaException
297
     */
298
    protected function transferComposedPropertiesToSchema(PropertyInterface $property): void
1,990✔
299
    {
300
        foreach ($property->getValidators() as $validator) {
1,990✔
301
            $validator = $validator->getValidator();
1,990✔
302

303
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
1,990✔
304
                continue;
1,990✔
305
            }
306

307
            // If the transferred validator of the composed property is also a composed property strip the nested
308
            // composition validations from the added validator. The nested composition will be validated in the object
309
            // generated for the nested composition which will be executed via an instantiation. Consequently, the
310
            // validation must not be executed in the outer composition.
311
            $this->schema->addBaseValidator(
257✔
312
                ($validator instanceof ComposedPropertyValidator)
257✔
313
                    ? $validator->withoutNestedCompositionValidation()
234✔
314
                    : $validator,
257✔
315
            );
257✔
316

317
            if (!is_a($validator->getCompositionProcessor(), ComposedPropertiesInterface::class, true)) {
257✔
UNCOV
318
                continue;
×
319
            }
320

321
            foreach ($validator->getComposedProperties() as $composedProperty) {
257✔
322
                $composedProperty->onResolve(function () use ($composedProperty, $property, $validator): void {
257✔
323
                    if (!$composedProperty->getNestedSchema()) {
257✔
324
                        throw new SchemaException(
1✔
325
                            sprintf(
1✔
326
                                "No nested schema for composed property %s in file %s found",
1✔
327
                                $property->getName(),
1✔
328
                                $property->getJsonSchema()->getFile(),
1✔
329
                            )
1✔
330
                        );
1✔
331
                    }
332

333
                    $composedProperty->getNestedSchema()->onAllPropertiesResolved(
256✔
334
                        function () use ($composedProperty, $validator): void {
256✔
335
                            foreach ($composedProperty->getNestedSchema()->getProperties() as $property) {
256✔
336
                                $this->schema->addProperty(
256✔
337
                                    $this->cloneTransferredProperty(
256✔
338
                                        $property,
256✔
339
                                        $validator->getCompositionProcessor(),
256✔
340
                                        $composedProperty,
256✔
341
                                        $validator,
256✔
342
                                    ),
256✔
343
                                    $validator->getCompositionProcessor(),
256✔
344
                                );
256✔
345

346
                                $composedProperty->appendAffectedObjectProperty($property);
254✔
347
                            }
348
                        },
256✔
349
                    );
256✔
350
                });
257✔
351
            }
352
        }
353
    }
354

355
    /**
356
     * Clone the provided property to transfer it to a schema. Sets the nullability and required flag based on the
357
     * composition processor used to set up the composition. Widens the type to mixed when the property is exclusive
358
     * to one anyOf/oneOf branch and at least one other branch allows additional properties, preventing TypeError when
359
     * raw input values of an arbitrary type are stored in the property slot.
360
     *
361
     * For ConditionalPropertyValidator (if/then/else), the widening check uses only the data branches
362
     * (then/else), not the if condition branch, since the if block is a filter and not a competing
363
     * data branch for type-widening purposes.
364
     */
365
    private function cloneTransferredProperty(
256✔
366
        PropertyInterface $property,
367
        string $compositionProcessor,
368
        CompositionPropertyDecorator $sourceBranch,
369
        AbstractComposedPropertyValidator $validator,
370
    ): PropertyInterface {
371
        $transferredProperty = (clone $property)
256✔
372
            ->filterValidators(static fn(Validator $validator): bool =>
256✔
373
                is_a($validator->getValidator(), PropertyTemplateValidator::class)
256✔
374
            );
256✔
375

376
        if (!is_a($compositionProcessor, AllOfProcessor::class, true)) {
256✔
377
            $transferredProperty->setRequired(false);
160✔
378

379
            if ($transferredProperty->getType()) {
160✔
380
                $transferredProperty->setType(
160✔
381
                    new PropertyType($transferredProperty->getType()->getNames(), true),
160✔
382
                    new PropertyType($transferredProperty->getType(true)->getNames(), true),
160✔
383
                );
160✔
384
            }
385

386
            $wideningBranches = $validator instanceof ConditionalPropertyValidator
160✔
387
                ? $validator->getDataBranches()
37✔
388
                : $validator->getComposedProperties();
123✔
389

390
            if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) {
160✔
391
                $transferredProperty->setType(null, null, reset: true);
129✔
392
            }
393
        }
394

395
        return $transferredProperty;
256✔
396
    }
397

398
    /**
399
     * Returns true when the property named $propertyName is exclusive to $sourceBranch and at least
400
     * one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare
401
     * additionalProperties: false). In that case the property slot can receive an arbitrarily-typed
402
     * raw input value from a non-matching branch, so the native type hint must be removed.
403
     *
404
     * Returns false when the property appears in another branch too (Phase 6 handles that via
405
     * Schema::addProperty merging) or when all other branches have additionalProperties: false
406
     * (making the property mutually exclusive with the other branches' properties).
407
     *
408
     * @param CompositionPropertyDecorator[] $allBranches
409
     */
410
    private function exclusiveBranchPropertyNeedsWidening(
160✔
411
        string $propertyName,
412
        CompositionPropertyDecorator $sourceBranch,
413
        array $allBranches,
414
    ): bool {
415
        // Pass 1: if any other branch defines the same property, Phase 6 handles the type
416
        // merging via Schema::addProperty — widening to mixed is not needed here.
417
        foreach ($allBranches as $branch) {
160✔
418
            if ($branch === $sourceBranch) {
160✔
419
                continue;
160✔
420
            }
421

422
            $branchPropertyNames = $branch->getNestedSchema()
160✔
423
                ? array_map(
160✔
424
                    static fn(PropertyInterface $p): string => $p->getName(),
160✔
425
                    $branch->getNestedSchema()->getProperties(),
160✔
426
                  )
160✔
NEW
427
                : [];
×
428

429
            if (in_array($propertyName, $branchPropertyNames, true)) {
160✔
430
                return false;
63✔
431
            }
432
        }
433

434
        // Pass 2: the property is exclusive to $sourceBranch. Widening is needed when at
435
        // least one other branch allows additional properties — an arbitrary input value can
436
        // then land in this slot when that branch is the one that matched.
437
        foreach ($allBranches as $branch) {
130✔
438
            if ($branch === $sourceBranch) {
130✔
439
                continue;
100✔
440
            }
441

442
            if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) {
130✔
443
                return true;
129✔
444
            }
445
        }
446

447
        // All other branches have additionalProperties:false — no arbitrary value can arrive.
448
        return false;
7✔
449
    }
450
}
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