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

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

30 Mar 2026 09:08AM UTC coverage: 98.331% (-0.4%) from 98.717%
23736910562

Pull #123

github

Enno Woortmann
Merge remote-tracking branch 'origin/master' into drafts
Pull Request #123: Introduce Draft-based architecture: eliminate legacy processors, centralize modifier/validator registration

1374 of 1399 new or added lines in 60 files covered. (98.21%)

3 existing lines in 2 files now uncovered.

4243 of 4315 relevant lines covered (98.33%)

588.89 hits per line

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

99.24
/src/PropertyProcessor/PropertyFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor;
6

7
use Exception;
8
use PHPModelGenerator\Draft\Draft;
9
use PHPModelGenerator\Draft\DraftFactoryInterface;
10
use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier;
11
use PHPModelGenerator\Draft\Modifier\TypeCheckModifier;
12
use PHPModelGenerator\Exception\SchemaException;
13
use PHPModelGenerator\Model\Property\BaseProperty;
14
use PHPModelGenerator\Model\Property\Property;
15
use PHPModelGenerator\Model\Property\PropertyInterface;
16
use PHPModelGenerator\Model\Property\PropertyType;
17
use PHPModelGenerator\Model\Schema;
18
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
19
use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator;
20
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
21
use PHPModelGenerator\Model\Validator\TypeCheckInterface;
22
use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator;
23
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
24
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator;
25
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
26
use PHPModelGenerator\Utils\TypeConverter;
27

28
/**
29
 * Class PropertyFactory
30
 *
31
 * @package PHPModelGenerator\PropertyProcessor
32
 */
33
class PropertyFactory
34
{
35
    /** @var Draft[] Keyed by draft class name */
36
    private array $draftCache = [];
37

38
    /**
39
     * Create a property, applying all applicable Draft modifiers.
40
     *
41
     * @throws SchemaException
42
     */
43
    public function create(
2,179✔
44
        SchemaProcessor $schemaProcessor,
45
        Schema $schema,
46
        string $propertyName,
47
        JsonSchema $propertySchema,
48
        bool $required = false,
49
    ): PropertyInterface {
50
        $json = $propertySchema->getJson();
2,179✔
51

52
        // $ref: replace the property entirely via the definition dictionary.
53
        // This is a schema-identity primitive — it cannot be a Draft modifier because
54
        // ModifierInterface::modify returns void and cannot replace the property object.
55
        if (isset($json['$ref'])) {
2,179✔
56
            if (isset($json['type']) && $json['type'] === 'base') {
545✔
57
                return $this->processBaseReference(
41✔
58
                    $schemaProcessor,
41✔
59
                    $schema,
41✔
60
                    $propertyName,
41✔
61
                    $propertySchema,
41✔
62
                    $required,
41✔
63
                );
41✔
64
            }
65

66
            return $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required);
507✔
67
        }
68

69
        $resolvedType = $json['type'] ?? 'any';
2,179✔
70

71
        if (is_array($resolvedType)) {
2,179✔
72
            return $this->createMultiTypeProperty(
72✔
73
                $schemaProcessor,
72✔
74
                $schema,
72✔
75
                $propertyName,
72✔
76
                $propertySchema,
72✔
77
                $resolvedType,
72✔
78
                $required,
72✔
79
            );
72✔
80
        }
81

82
        $this->checkType($resolvedType, $schema);
2,179✔
83

84
        return match ($resolvedType) {
2,179✔
85
            'object' => $this->createObjectProperty(
952✔
86
                $schemaProcessor,
952✔
87
                $schema,
952✔
88
                $propertyName,
952✔
89
                $propertySchema,
952✔
90
                $required,
952✔
91
            ),
952✔
92
            'base'   => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema),
2,178✔
93
            default  => $this->createTypedProperty(
2,164✔
94
                $schemaProcessor,
2,164✔
95
                $schema,
2,164✔
96
                $propertyName,
2,164✔
97
                $propertySchema,
2,164✔
98
                $resolvedType,
2,164✔
99
                $required,
2,164✔
100
            ),
2,164✔
101
        };
2,179✔
102
    }
103

104
    /**
105
     * Handle a nested object property: generate the nested class, wire the outer property,
106
     * then apply universal modifiers (filter, enum, default, const) on the outer property.
107
     *
108
     * @throws SchemaException
109
     */
110
    private function createObjectProperty(
959✔
111
        SchemaProcessor $schemaProcessor,
112
        Schema $schema,
113
        string $propertyName,
114
        JsonSchema $propertySchema,
115
        bool $required,
116
    ): PropertyInterface {
117
        $json     = $propertySchema->getJson();
959✔
118
        $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required);
959✔
119

120
        $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName(
959✔
121
            $propertyName,
959✔
122
            $propertySchema,
959✔
123
            false,
959✔
124
            $schemaProcessor->getCurrentClassName(),
959✔
125
        );
959✔
126

127
        // Strip property-level keywords before passing the schema to processSchema: these keywords
128
        // target the outer property and are handled by the universal modifiers below.
129
        $nestedJson = $json;
959✔
130
        unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']);
959✔
131
        $nestedSchema = $schemaProcessor->processSchema(
959✔
132
            $propertySchema->withJson($nestedJson),
959✔
133
            $schemaProcessor->getCurrentClassPath(),
959✔
134
            $className,
959✔
135
            $schema->getSchemaDictionary(),
959✔
136
        );
959✔
137

138
        if ($nestedSchema !== null) {
959✔
139
            $property->setNestedSchema($nestedSchema);
959✔
140
            $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema);
959✔
141
        }
142

143
        // Universal modifiers (filter, enum, default, const) run on the outer property.
144
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true);
959✔
145

146
        return $property;
957✔
147
    }
148

149
    /**
150
     * Handle a root-level schema (type=base): set up definitions, run all Draft modifiers,
151
     * then transfer any composed properties to the schema.
152
     *
153
     * @throws SchemaException
154
     */
155
    private function createBaseProperty(
2,178✔
156
        SchemaProcessor $schemaProcessor,
157
        Schema $schema,
158
        string $propertyName,
159
        JsonSchema $propertySchema,
160
    ): PropertyInterface {
161
        $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
2,178✔
162
        $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema);
2,178✔
163

164
        $objectJson         = $propertySchema->getJson();
2,178✔
165
        $objectJson['type'] = 'object';
2,178✔
166
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson));
2,178✔
167

168
        $schemaProcessor->transferComposedPropertiesToSchema($property, $schema);
2,119✔
169

170
        return $property;
2,118✔
171
    }
172

173
    /**
174
     * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers.
175
     *
176
     * @throws SchemaException
177
     */
178
    private function createTypedProperty(
2,109✔
179
        SchemaProcessor $schemaProcessor,
180
        Schema $schema,
181
        string $propertyName,
182
        JsonSchema $propertySchema,
183
        string $type,
184
        bool $required,
185
    ): PropertyInterface {
186
        $phpType  = $type !== 'any' ? TypeConverter::jsonSchemaToPhp($type) : null;
2,109✔
187
        $property = $this->buildProperty(
2,109✔
188
            $schemaProcessor,
2,109✔
189
            $propertyName,
2,109✔
190
            $phpType !== null ? new PropertyType($phpType) : null,
2,109✔
191
            $propertySchema,
2,109✔
192
            $required,
2,109✔
193
        );
2,109✔
194

195
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema);
2,108✔
196

197
        return $property;
2,066✔
198
    }
199

200
    /**
201
     * Construct a Property with the common required/readOnly setup.
202
     */
203
    private function buildProperty(
2,152✔
204
        SchemaProcessor $schemaProcessor,
205
        string $propertyName,
206
        ?PropertyType $type,
207
        JsonSchema $propertySchema,
208
        bool $required,
209
    ): Property {
210
        $json = $propertySchema->getJson();
2,152✔
211

212
        $property = (new Property($propertyName, $type, $propertySchema, $json['description'] ?? ''))
2,152✔
213
            ->setRequired($required)
2,152✔
214
            ->setReadOnly(
2,152✔
215
                (isset($json['readOnly']) && $json['readOnly'] === true) ||
2,152✔
216
                $schemaProcessor->getGeneratorConfiguration()->isImmutable(),
2,152✔
217
            );
2,152✔
218

219
        if ($required && !str_starts_with($propertyName, 'item of array ')) {
2,151✔
220
            $property->addValidator(new RequiredPropertyValidator($property), 1);
1,067✔
221
        }
222

223
        return $property;
2,151✔
224
    }
225

226
    /**
227
     * Resolve a $ref reference by looking it up in the definition dictionary.
228
     *
229
     * @throws SchemaException
230
     */
231
    private function processReference(
545✔
232
        SchemaProcessor $schemaProcessor,
233
        Schema $schema,
234
        string $propertyName,
235
        JsonSchema $propertySchema,
236
        bool $required,
237
    ): PropertyInterface {
238
        $path       = [];
545✔
239
        $reference  = $propertySchema->getJson()['$ref'];
545✔
240
        $dictionary = $schema->getSchemaDictionary();
545✔
241

242
        try {
243
            $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path);
545✔
244

245
            if ($definition) {
538✔
246
                $definitionSchema = $definition->getSchema();
538✔
247

248
                if (
249
                    $schema->getClassPath() !== $definitionSchema->getClassPath() ||
538✔
250
                    $schema->getClassName() !== $definitionSchema->getClassName() ||
537✔
251
                    (
252
                        $schema->getClassName() === 'ExternalSchema' &&
538✔
253
                        $definitionSchema->getClassName() === 'ExternalSchema'
538✔
254
                    )
255
                ) {
256
                    $schema->addNamespaceTransferDecorator(
259✔
257
                        new SchemaNamespaceTransferDecorator($definitionSchema),
259✔
258
                    );
259✔
259

260
                    if ($definitionSchema->getClassName() !== 'ExternalSchema') {
259✔
261
                        $schema->addUsedClass(join('\\', array_filter([
72✔
262
                            $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(),
72✔
263
                            $definitionSchema->getClassPath(),
72✔
264
                            $definitionSchema->getClassName(),
72✔
265
                        ])));
72✔
266
                    }
267
                }
268

269
                return $definition->resolveReference(
538✔
270
                    $propertyName,
538✔
271
                    $path,
538✔
272
                    $required,
538✔
273
                    $propertySchema->getJson()['_dependencies'] ?? null,
538✔
274
                );
538✔
275
            }
276
        } catch (Exception $exception) {
7✔
277
            throw new SchemaException(
7✔
278
                "Unresolved Reference $reference in file {$propertySchema->getFile()}",
7✔
279
                0,
7✔
280
                $exception,
7✔
281
            );
7✔
282
        }
283

NEW
284
        throw new SchemaException("Unresolved Reference $reference in file {$propertySchema->getFile()}");
×
285
    }
286

287
    /**
288
     * Resolve a $ref on a base-level schema: set up definitions, delegate to processReference,
289
     * then copy the referenced schema's properties to the parent schema.
290
     *
291
     * @throws SchemaException
292
     */
293
    private function processBaseReference(
41✔
294
        SchemaProcessor $schemaProcessor,
295
        Schema $schema,
296
        string $propertyName,
297
        JsonSchema $propertySchema,
298
        bool $required,
299
    ): PropertyInterface {
300
        $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
41✔
301

302
        $property = $this->processReference($schemaProcessor, $schema, $propertyName, $propertySchema, $required);
41✔
303

304
        if (!$property->getNestedSchema()) {
41✔
305
            throw new SchemaException(
1✔
306
                sprintf(
1✔
307
                    'A referenced schema on base level must provide an object definition for property %s in file %s',
1✔
308
                    $propertyName,
1✔
309
                    $propertySchema->getFile(),
1✔
310
                )
1✔
311
            );
1✔
312
        }
313

314
        foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) {
40✔
315
            $schema->addProperty($propertiesOfReferencedObject);
40✔
316
        }
317

318
        return $property;
40✔
319
    }
320

321
    /**
322
     * Handle "type": [...] properties by processing each type through its Draft modifiers,
323
     * merging validators and decorators onto a single property, then consolidating type checks.
324
     *
325
     * @param string[] $types
326
     *
327
     * @throws SchemaException
328
     */
329
    private function createMultiTypeProperty(
72✔
330
        SchemaProcessor $schemaProcessor,
331
        Schema $schema,
332
        string $propertyName,
333
        JsonSchema $propertySchema,
334
        array $types,
335
        bool $required,
336
    ): PropertyInterface {
337
        $json     = $propertySchema->getJson();
72✔
338
        $property = $this->buildProperty($schemaProcessor, $propertyName, null, $propertySchema, $required);
72✔
339

340
        $collectedTypes   = [];
72✔
341
        $typeHints        = [];
72✔
342
        $resolvedSubCount = 0;
72✔
343
        $totalSubCount    = count($types);
72✔
344

345
        // Strip the default from sub-schemas so that default handling runs only once via the
346
        // universal DefaultValueModifier below, which already handles the multi-type case.
347
        $subJson = $json;
72✔
348
        unset($subJson['default']);
72✔
349

350
        foreach ($types as $type) {
72✔
351
            $this->checkType($type, $schema);
72✔
352

353
            $subJson['type'] = $type;
72✔
354
            $subSchema       = $propertySchema->withJson($subJson);
72✔
355

356
            // For type=object, delegate to the same object path (processSchema + wireObjectProperty).
357
            $subProperty = $type === 'object'
72✔
358
                ? $this->createObjectProperty($schemaProcessor, $schema, $propertyName, $subSchema, $required)
7✔
359
                : $this->createSubTypeProperty(
72✔
360
                    $schemaProcessor,
72✔
361
                    $schema,
72✔
362
                    $propertyName,
72✔
363
                    $subSchema,
72✔
364
                    $type,
72✔
365
                    $required,
72✔
366
                );
72✔
367

368
            $subProperty->onResolve(function () use (
72✔
369
                $property,
72✔
370
                $subProperty,
72✔
371
                $schemaProcessor,
72✔
372
                $schema,
72✔
373
                $propertySchema,
72✔
374
                $totalSubCount,
72✔
375
                &$collectedTypes,
72✔
376
                &$typeHints,
72✔
377
                &$resolvedSubCount,
72✔
378
            ): void {
72✔
379
                foreach ($subProperty->getValidators() as $validatorContainer) {
72✔
380
                    $validator = $validatorContainer->getValidator();
72✔
381

382
                    if ($validator instanceof TypeCheckInterface) {
72✔
383
                        array_push($collectedTypes, ...$validator->getTypes());
72✔
384
                        continue;
72✔
385
                    }
386

387
                    $property->addValidator($validator, $validatorContainer->getPriority());
43✔
388
                }
389

390
                if ($subProperty->getDecorators()) {
72✔
391
                    $property->addDecorator(new PropertyTransferDecorator($subProperty));
33✔
392
                }
393

394
                $typeHints[] = $subProperty->getTypeHint();
72✔
395

396
                if (++$resolvedSubCount < $totalSubCount || empty($collectedTypes)) {
72✔
397
                    return;
72✔
398
                }
399

400
                $this->finalizeMultiTypeProperty(
72✔
401
                    $property,
72✔
402
                    array_unique($collectedTypes),
72✔
403
                    $typeHints,
72✔
404
                    $schemaProcessor,
72✔
405
                    $schema,
72✔
406
                    $propertySchema,
72✔
407
                );
72✔
408
            });
72✔
409
        }
410

411
        return $property;
68✔
412
    }
413

414
    /**
415
     * Build a non-object sub-property for a multi-type array, applying only type-specific
416
     * modifiers (no universal 'any' modifiers — those run once on the parent after finalization).
417
     *
418
     * @throws SchemaException
419
     */
420
    private function createSubTypeProperty(
72✔
421
        SchemaProcessor $schemaProcessor,
422
        Schema $schema,
423
        string $propertyName,
424
        JsonSchema $propertySchema,
425
        string $type,
426
        bool $required,
427
    ): Property {
428
        $subProperty = $this->buildProperty(
72✔
429
            $schemaProcessor,
72✔
430
            $propertyName,
72✔
431
            new PropertyType(TypeConverter::jsonSchemaToPhp($type)),
72✔
432
            $propertySchema,
72✔
433
            $required,
72✔
434
        );
72✔
435

436
        $this->applyModifiers($schemaProcessor, $schema, $subProperty, $propertySchema, anyOnly: false, typeOnly: true);
72✔
437

438
        return $subProperty;
72✔
439
    }
440

441
    /**
442
     * Called once all sub-properties of a multi-type property have resolved.
443
     * Adds the consolidated MultiTypeCheckValidator, sets the union PropertyType,
444
     * attaches the type-hint decorator, and runs universal modifiers.
445
     *
446
     * @param string[] $collectedTypes
447
     * @param string[] $typeHints
448
     *
449
     * @throws SchemaException
450
     */
451
    private function finalizeMultiTypeProperty(
72✔
452
        PropertyInterface $property,
453
        array $collectedTypes,
454
        array $typeHints,
455
        SchemaProcessor $schemaProcessor,
456
        Schema $schema,
457
        JsonSchema $propertySchema,
458
    ): void {
459
        $hasNull      = in_array('null', $collectedTypes, true);
72✔
460
        $nonNullTypes = array_values(array_filter(
72✔
461
            $collectedTypes,
72✔
462
            static fn(string $type): bool => $type !== 'null',
72✔
463
        ));
72✔
464

465
        $allowImplicitNull = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()
72✔
466
            && !$property->isRequired();
72✔
467

468
        $property->addValidator(
72✔
469
            new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull),
72✔
470
            2,
72✔
471
        );
72✔
472

473
        if ($nonNullTypes) {
72✔
474
            $property->setType(
72✔
475
                new PropertyType($nonNullTypes, $hasNull ? true : null),
72✔
476
                new PropertyType($nonNullTypes, $hasNull ? true : null),
72✔
477
            );
72✔
478
        }
479

480
        $property->addTypeHintDecorator(new TypeHintDecorator($typeHints));
72✔
481

482
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, true);
72✔
483
    }
484

485
    /**
486
     * Wire the outer property for a nested object: add the type-check validator and instantiation
487
     * linkage. Schema-targeting modifiers are intentionally NOT run here because processSchema
488
     * already applied them to the nested schema.
489
     *
490
     * @throws SchemaException
491
     */
492
    private function wireObjectProperty(
959✔
493
        SchemaProcessor $schemaProcessor,
494
        Schema $schema,
495
        PropertyInterface $property,
496
        JsonSchema $propertySchema,
497
    ): void {
498
        (new TypeCheckModifier(TypeConverter::jsonSchemaToPhp('object')))->modify(
959✔
499
            $schemaProcessor,
959✔
500
            $schema,
959✔
501
            $property,
959✔
502
            $propertySchema,
959✔
503
        );
959✔
504

505
        (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema);
959✔
506
    }
507

508
    /**
509
     * Run Draft modifiers for the given property.
510
     *
511
     * By default all covered types (type-specific + 'any') run. Pass $anyOnly=true to run
512
     * only the 'any' entry (used for object outer-property universal keywords), or $typeOnly=true
513
     * to run only type-specific entries (used for multi-type sub-properties).
514
     *
515
     * @throws SchemaException
516
     */
517
    private function applyModifiers(
2,179✔
518
        SchemaProcessor $schemaProcessor,
519
        Schema $schema,
520
        PropertyInterface $property,
521
        JsonSchema $propertySchema,
522
        bool $anyOnly = false,
523
        bool $typeOnly = false,
524
    ): void {
525
        $type       = $propertySchema->getJson()['type'] ?? 'any';
2,179✔
526
        $builtDraft = $this->resolveBuiltDraft($schemaProcessor, $propertySchema);
2,179✔
527

528
        // For untyped properties ('any'), only run the 'any' entry — getCoveredTypes('any')
529
        // returns all types, which would incorrectly apply type-specific modifiers.
530
        $coveredTypes = $type === 'any'
2,179✔
531
            ? array_filter($builtDraft->getCoveredTypes('any'), static fn($t) => $t->getType() === 'any')
557✔
532
            : $builtDraft->getCoveredTypes($type);
2,179✔
533

534
        foreach ($coveredTypes as $coveredType) {
2,179✔
535
            $isAnyEntry = $coveredType->getType() === 'any';
2,179✔
536

537
            if ($anyOnly && !$isAnyEntry) {
2,179✔
538
                continue;
1,017✔
539
            }
540

541
            if ($typeOnly && $isAnyEntry) {
2,179✔
542
                continue;
72✔
543
            }
544

545
            foreach ($coveredType->getModifiers() as $modifier) {
2,179✔
546
                $modifier->modify($schemaProcessor, $schema, $property, $propertySchema);
2,179✔
547
            }
548
        }
549
    }
550

551
    /**
552
     * @throws SchemaException
553
     */
554
    private function checkType(mixed $type, Schema $schema): void
2,179✔
555
    {
556
        if (is_string($type)) {
2,179✔
557
            return;
2,179✔
558
        }
559

560
        throw new SchemaException(
1✔
561
            sprintf(
1✔
562
                'Invalid property type %s in file %s',
1✔
563
                $type,
1✔
564
                $schema->getJsonSchema()->getFile(),
1✔
565
            )
1✔
566
        );
1✔
567
    }
568

569
    private function resolveBuiltDraft(SchemaProcessor $schemaProcessor, JsonSchema $propertySchema): Draft
2,179✔
570
    {
571
        $configDraft = $schemaProcessor->getGeneratorConfiguration()->getDraft();
2,179✔
572

573
        $draft = $configDraft instanceof DraftFactoryInterface
2,179✔
574
            ? $configDraft->getDraftForSchema($propertySchema)
2,179✔
NEW
575
            : $configDraft;
×
576

577
        return $this->draftCache[$draft::class] ??= $draft->getDefinition()->build();
2,179✔
578
    }
579
}
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