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

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

05 Mar 2026 12:03AM UTC coverage: 98.631% (-0.06%) from 98.693%
22695477925

Pull #115

github

Enno Woortmann
cleanup
Pull Request #115: Type system (Type widening for compositions, union types)

178 of 183 new or added lines in 18 files covered. (97.27%)

6 existing lines in 2 files now uncovered.

3601 of 3651 relevant lines covered (98.63%)

555.14 hits per line

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

99.02
/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\NoAdditionalPropertiesValidator;
25
use PHPModelGenerator\Model\Validator\PatternPropertiesValidator;
26
use PHPModelGenerator\Model\Validator\PropertyNamesValidator;
27
use PHPModelGenerator\Model\Validator\PropertyTemplateValidator;
28
use PHPModelGenerator\Model\Validator\PropertyValidator;
29
use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor;
30
use PHPModelGenerator\PropertyProcessor\ComposedValue\ComposedPropertiesInterface;
31
use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection;
32
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
33
use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory;
34

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

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

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

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

72
        $this->addPropertiesToSchema($propertySchema);
2,031✔
73
        $this->transferComposedPropertiesToSchema($property);
1,973✔
74

75
        $this->addPropertyNamesValidator($propertySchema);
1,972✔
76
        $this->addPatternPropertiesValidator($propertySchema);
1,971✔
77
        $this->addAdditionalPropertiesValidator($propertySchema);
1,970✔
78

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

82
        return $property;
1,970✔
83
    }
84

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

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

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

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

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

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

139
            return;
88✔
140
        }
141

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

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

157
        if (!isset($json['patternProperties'])) {
1,971✔
158
            return;
1,921✔
159
        }
160

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

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

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

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

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

190
        if (!isset($json['maxProperties'])) {
1,970✔
191
            return;
1,937✔
192
        }
193

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

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

217
        if (!isset($json['minProperties'])) {
1,970✔
218
            return;
1,955✔
219
        }
220

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

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

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

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

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

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

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

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

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

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

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

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

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

345
                                $composedProperty->appendAffectedObjectProperty($property);
239✔
346
                            }
347
                        },
239✔
348
                    );
239✔
349
                });
240✔
350
            }
351
        }
352
    }
353

354
    /**
355
     * Clone the provided property to transfer it to a schema. Sets the nullability and required flag based on the
356
     * composition processor used to set up the composition. Widens the type to mixed when the property is exclusive
357
     * to one anyOf/oneOf branch and at least one other branch allows additional properties, preventing TypeError when
358
     * raw input values of an arbitrary type are stored in the property slot.
359
     *
360
     * @param CompositionPropertyDecorator[] $allBranches
361
     */
362
    private function cloneTransferredProperty(
239✔
363
        PropertyInterface $property,
364
        string $compositionProcessor,
365
        CompositionPropertyDecorator $sourceBranch,
366
        array $allBranches,
367
    ): PropertyInterface {
368
        $transferredProperty = (clone $property)
239✔
369
            ->filterValidators(static fn(Validator $validator): bool =>
239✔
370
                is_a($validator->getValidator(), PropertyTemplateValidator::class)
239✔
371
            );
239✔
372

373
        if (!is_a($compositionProcessor, AllOfProcessor::class, true)) {
239✔
374
            $transferredProperty->setRequired(false);
150✔
375

376
            if ($transferredProperty->getType()) {
150✔
377
                $transferredProperty->setType(
150✔
378
                    new PropertyType($transferredProperty->getType()->getNames(), true),
150✔
379
                    new PropertyType($transferredProperty->getType(true)->getNames(), true),
150✔
380
                );
150✔
381
            }
382

383
            if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $allBranches)) {
150✔
384
                $transferredProperty->setType(null, null, reset: true);
122✔
385
            }
386
        }
387

388
        return $transferredProperty;
239✔
389
    }
390

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

415
            $branchPropertyNames = $branch->getNestedSchema()
150✔
416
                ? array_map(
150✔
417
                    static fn(PropertyInterface $p): string => $p->getName(),
150✔
418
                    $branch->getNestedSchema()->getProperties(),
150✔
419
                  )
150✔
NEW
420
                : [];
×
421

422
            if (in_array($propertyName, $branchPropertyNames, true)) {
150✔
423
                return false;
57✔
424
            }
425
        }
426

427
        // Pass 2: the property is exclusive to $sourceBranch. Widening is needed when at
428
        // least one other branch allows additional properties — an arbitrary input value can
429
        // then land in this slot when that branch is the one that matched.
430
        foreach ($allBranches as $branch) {
123✔
431
            if ($branch === $sourceBranch) {
123✔
432
                continue;
123✔
433
            }
434

435
            if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) {
123✔
436
                return true;
122✔
437
            }
438
        }
439

440
        // All other branches have additionalProperties:false — no arbitrary value can arrive.
441
        return false;
1✔
442
    }
443
}
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