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

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

30 Jul 2026 09:16PM UTC coverage: 98.587% (-0.1%) from 98.735%
30582776386

Pull #166

github

claude
Align composition-error assertions with master's summary-only direct mode

The merge with master resolved the ComposedItem template conflict in favor of
master's structure (the extracted ComposedItemBody template), which does not
enumerate cleanly-validated branches in direct-exception mode. This drops the
per-branch "Valid" enumeration that this branch had added for direct mode.

Realign the affected direct-mode assertions to the actual summary-only output
(failing branches are still enumerated with their underlying reason; passing
branches are no longer listed, and the remaining failing branches renumber
sequentially). Also correct the impliedObjects doc example, which showed the
now-removed enumeration. Verified against pristine master: this matches
master's direct-mode composition-error format exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dm5LeHyWzk3JnMaQqSgXPy
Pull Request #166: Add characterization tests for issue #72 / PR #74 composition defects

194 of 206 new or added lines in 8 files covered. (94.17%)

2 existing lines in 2 files now uncovered.

7326 of 7431 relevant lines covered (98.59%)

577.82 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor;
6

7
use PHPModelGenerator\Attributes\Deprecated;
8
use PHPModelGenerator\Attributes\JsonPointer;
9
use PHPModelGenerator\Attributes\JsonSchema as JsonSchemaAttribute;
10
use PHPModelGenerator\Attributes\ReadOnlyProperty;
11
use PHPModelGenerator\Attributes\Required;
12
use PHPModelGenerator\Attributes\SchemaName;
13
use PHPModelGenerator\Attributes\WriteOnlyProperty;
14
use Exception;
15
use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier;
16
use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory;
17
use PHPModelGenerator\Draft\Modifier\TypeCheckModifier;
18
use PHPModelGenerator\Exception\SchemaException;
19
use PHPModelGenerator\Model\Attributes\PhpAttribute;
20
use PHPModelGenerator\Model\Property\BaseProperty;
21
use PHPModelGenerator\Model\Property\Property;
22
use PHPModelGenerator\Model\Property\PropertyInterface;
23
use PHPModelGenerator\Model\Property\PropertyType;
24
use PHPModelGenerator\Model\Schema;
25
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
26
use PHPModelGenerator\Model\Validator;
27
use PHPModelGenerator\Model\Validator\InstanceOfValidator;
28
use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator;
29
use PHPModelGenerator\Model\Validator\TypeCheckInterface;
30
use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator;
31
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
32
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator;
33
use PHPModelGenerator\PropertyProcessor\ObjectShape\ObjectShape;
34
use PHPModelGenerator\PropertyProcessor\ObjectShape\ObjectShapeResolver;
35
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
36
use PHPModelGenerator\Utils\TypeConverter;
37
use Throwable;
38

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

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

71
            return $this->processReference(
556✔
72
                $schemaProcessor,
556✔
73
                $schema,
556✔
74
                $propertyName,
556✔
75
                $propertySchema,
556✔
76
                $required,
556✔
77
                $isArrayItem,
556✔
78
            );
556✔
79
        }
80

81
        $resolvedType = $json['type'] ?? 'any';
2,713✔
82

83
        if (is_array($resolvedType)) {
2,713✔
84
            return $this->createMultiTypeProperty(
98✔
85
                $schemaProcessor,
98✔
86
                $schema,
98✔
87
                $propertyName,
98✔
88
                $propertySchema,
98✔
89
                $resolvedType,
98✔
90
                $required,
98✔
91
                $isArrayItem,
98✔
92
            );
98✔
93
        }
94

95
        // Re-route a composition-only schema that the composition itself guarantees to be an
96
        // object (e.g. an allOf of object branches, possibly multiple $ref levels deep) through
97
        // the object path, so it becomes a genuine nested class with instantiation and instanceof
98
        // validation instead of a bare composed validator. Gated on allOf as the outer keyword:
99
        // anyOf/oneOf deliberately keep their per-matched-branch runtime value identity and are
100
        // fixed instead at the branch level (a branch that is itself an object-asserting
101
        // composition re-routes here when it is created). Injecting an explicit type: object makes
102
        // the implied object-ness explicit so the existing object path (which processSchema forces
103
        // to a type: base nested class handling the composition internally) applies unchanged.
104
        if (
105
            !isset($json['type'])
2,713✔
106
            && !isset($json['filter'])
2,713✔
107
            && isset($json['allOf'])
2,713✔
108
            && $this->resolveObjectShape($schemaProcessor, $schema, $json) === ObjectShape::ObjectAsserting
2,713✔
109
        ) {
110
            $objectJson = $json;
91✔
111
            $objectJson['type'] = 'object';
91✔
112

113
            return $this->createObjectProperty(
91✔
114
                $schemaProcessor,
91✔
115
                $schema,
91✔
116
                $propertyName,
91✔
117
                $propertySchema->withJson($objectJson),
91✔
118
                $required,
91✔
119
                $isArrayItem,
91✔
120
            );
91✔
121
        }
122

123
        // A bare object-validator schema (object-constraining keywords, no type and no composition)
124
        // is object-describing: it constrains object values but is vacuously satisfied by
125
        // non-objects per strict spec. Give it a guarded representation class - instantiated for
126
        // object values, with non-objects passing through unchanged - so its constraints actually
127
        // run (they are registered on the object Type and would otherwise never execute on an
128
        // untyped property). No asserting object type check is added, preserving the strict-spec
129
        // pass-through of non-object values (this is why it is NOT the ObjectAsserting path above).
130
        if (
131
            !isset($json['type'])
2,713✔
132
            && !isset($json['filter'])
2,713✔
133
            && !array_intersect(array_keys($json), ['allOf', 'anyOf', 'oneOf', 'if', 'not', '$ref'])
2,713✔
134
            && $this->resolveObjectShape($schemaProcessor, $schema, $json) === ObjectShape::ObjectDescribing
2,713✔
135
        ) {
136
            $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning(
18✔
137
                "Property '{property}' carries object-constraining keywords (eg. 'properties',"
18✔
138
                    . " 'required') without a 'type' declaration and does not constrain non-object values",
18✔
139
                ['property' => $propertyName],
18✔
140
            );
18✔
141

142
            $objectJson = $json;
18✔
143
            $objectJson['type'] = 'object';
18✔
144

145
            return $this->createObjectProperty(
18✔
146
                $schemaProcessor,
18✔
147
                $schema,
18✔
148
                $propertyName,
18✔
149
                $propertySchema->withJson($objectJson),
18✔
150
                $required,
18✔
151
                $isArrayItem,
18✔
152
                guarded: true,
18✔
153
            );
18✔
154
        }
155

156
        $this->checkType($resolvedType, $schema);
2,713✔
157

158
        return match ($resolvedType) {
2,713✔
159
            'object' => $this->createObjectProperty(
1,094✔
160
                $schemaProcessor,
1,094✔
161
                $schema,
1,094✔
162
                $propertyName,
1,094✔
163
                $propertySchema,
1,094✔
164
                $required,
1,094✔
165
                $isArrayItem,
1,094✔
166
            ),
1,094✔
167
            'base'   => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema),
2,712✔
168
            default  => $this->createTypedProperty(
2,691✔
169
                $schemaProcessor,
2,691✔
170
                $schema,
2,691✔
171
                $propertyName,
2,691✔
172
                $propertySchema,
2,691✔
173
                $resolvedType,
2,691✔
174
                $required,
2,691✔
175
                $isArrayItem,
2,691✔
176
            ),
2,691✔
177
        };
178
    }
179

180
    /**
181
     * Statically classify the object shape of the given raw schema, peeking through `$ref` chains
182
     * via the schema definition dictionary. Only a raw, un-processed peek happens here - no
183
     * property is created for the target - so the classification stays side-effect-free for the
184
     * common same-file reference case (cross-file references may parse the external file, which the
185
     * order-independent external-schema machinery would parse moments later anyway).
186
     */
187
    private function resolveObjectShape(
500✔
188
        SchemaProcessor $schemaProcessor,
189
        Schema $schema,
190
        array $json,
191
    ): ObjectShape {
192
        $dictionary = $schema->getSchemaDictionary();
500✔
193

194
        $refResolver = static function (string $reference) use ($schemaProcessor, $dictionary): array|bool|null {
500✔
195
            $path = [];
46✔
196

197
            try {
198
                $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path);
46✔
199

200
                return $definition?->getSource()->navigate(implode('/', $path))->getJson();
46✔
NEW
201
            } catch (Throwable) {
×
202
                // An unresolvable, malformed, or boolean-leaf reference leaves object-ness
203
                // undecidable; returning null makes the resolver bail out conservatively to
204
                // NotObject, keeping the schema on its current processing path.
NEW
205
                return null;
×
206
            }
207
        };
500✔
208

209
        return (new ObjectShapeResolver($refResolver))->resolve($json);
500✔
210
    }
211

212
    /**
213
     * Handle a nested object property: generate the nested class, wire the outer property,
214
     * then apply universal modifiers (filter, enum, default, const) on the outer property.
215
     *
216
     * @throws SchemaException
217
     */
218
    private function createObjectProperty(
1,121✔
219
        SchemaProcessor $schemaProcessor,
220
        Schema $schema,
221
        string $propertyName,
222
        JsonSchema $propertySchema,
223
        bool $required,
224
        bool $isArrayItem = false,
225
        bool $guarded = false,
226
    ): PropertyInterface {
227
        $json     = $propertySchema->getJson();
1,121✔
228
        $property = $this->buildProperty(
1,121✔
229
            $schemaProcessor,
1,121✔
230
            $propertyName,
1,121✔
231
            null,
1,121✔
232
            $propertySchema,
1,121✔
233
            $required,
1,121✔
234
            $isArrayItem,
1,121✔
235
        );
1,121✔
236

237
        $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName(
1,121✔
238
            $propertyName,
1,121✔
239
            $propertySchema,
1,121✔
240
            false,
1,121✔
241
            $schemaProcessor->getCurrentClassName(),
1,121✔
242
        );
1,121✔
243

244
        // Strip property-level keywords before passing the schema to processSchema: these keywords
245
        // target the outer property and are handled by the universal modifiers below.
246
        $nestedJson = $json;
1,121✔
247
        unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']);
1,121✔
248

249
        $nestedSchema = $schemaProcessor->processSchema(
1,121✔
250
            $propertySchema->withJson($nestedJson),
1,121✔
251
            $schemaProcessor->getCurrentClassPath(),
1,121✔
252
            $className,
1,121✔
253
            $schema->getSchemaDictionary(),
1,121✔
254
        );
1,121✔
255

256
        if ($nestedSchema !== null) {
1,121✔
257
            $property->setNestedSchema($nestedSchema);
1,121✔
258

259
            if ($guarded) {
1,121✔
260
                $this->wireDescribingObjectProperty($schemaProcessor, $schema, $property, $propertySchema);
18✔
261
            } else {
262
                $this->wireObjectProperty($schemaProcessor, $schema, $property, $propertySchema);
1,103✔
263
            }
264
        }
265

266
        // Universal modifiers (filter, enum, default, const) run on the outer property.
267
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true);
1,121✔
268

269
        return $property;
1,119✔
270
    }
271

272
    /**
273
     * Handle a root-level schema (type=base): set up definitions, run all Draft modifiers,
274
     * then transfer any composed properties to the schema.
275
     *
276
     * @throws SchemaException
277
     */
278
    private function createBaseProperty(
2,712✔
279
        SchemaProcessor $schemaProcessor,
280
        Schema $schema,
281
        string $propertyName,
282
        JsonSchema $propertySchema,
283
    ): PropertyInterface {
284
        $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
2,712✔
285
        $property = new BaseProperty($propertyName, new PropertyType('object'), $propertySchema);
2,712✔
286

287
        $objectJson         = $propertySchema->getJson();
2,712✔
288
        $objectJson['type'] = 'object';
2,712✔
289
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema->withJson($objectJson));
2,712✔
290

291
        $schemaProcessor->transferComposedPropertiesToSchema($property, $schema);
2,595✔
292

293
        return $property;
2,594✔
294
    }
295

296
    /**
297
     * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers.
298
     *
299
     * @throws SchemaException
300
     */
301
    private function createTypedProperty(
2,616✔
302
        SchemaProcessor $schemaProcessor,
303
        Schema $schema,
304
        string $propertyName,
305
        JsonSchema $propertySchema,
306
        string $type,
307
        bool $required,
308
        bool $isArrayItem = false,
309
    ): PropertyInterface {
310
        $phpType  = $type !== 'any' ? TypeConverter::jsonSchemaToPHP($type) : null;
2,616✔
311
        $property = $this->buildProperty(
2,616✔
312
            $schemaProcessor,
2,616✔
313
            $propertyName,
2,616✔
314
            $phpType !== null ? new PropertyType($phpType) : null,
2,616✔
315
            $propertySchema,
2,616✔
316
            $required,
2,616✔
317
            $isArrayItem,
2,616✔
318
        );
2,616✔
319

320
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema);
2,614✔
321

322
        return $property;
2,530✔
323
    }
324

325
    /**
326
     * Construct a Property with the common required/readOnly/writeOnly setup.
327
     *
328
     * @throws SchemaException
329
     */
330
    private function buildProperty(
2,674✔
331
        SchemaProcessor $schemaProcessor,
332
        string $propertyName,
333
        ?PropertyType $type,
334
        JsonSchema $propertySchema,
335
        bool $required,
336
        bool $isArrayItem = false,
337
    ): Property {
338
        $json = $propertySchema->getJson();
2,674✔
339

340
        $isSchemaReadOnly = isset($json['readOnly']) && $json['readOnly'] === true;
2,674✔
341
        $isWriteOnly = isset($json['writeOnly']) && $json['writeOnly'] === true;
2,674✔
342

343
        if ($isSchemaReadOnly && $isWriteOnly) {
2,674✔
344
            throw new SchemaException(
1✔
345
                sprintf(
1✔
346
                    "Property '%s' in file '%s' cannot be both readOnly and writeOnly",
1✔
347
                    $propertyName,
1✔
348
                    $propertySchema->getFile(),
1✔
349
                ),
1✔
350
                $propertySchema,
1✔
351
            );
1✔
352
        }
353

354
        $property = (new Property($propertyName, $type, $propertySchema, $json['description'] ?? ''))
2,673✔
355
            ->setRequired($required)
2,673✔
356
            ->setArrayItem($isArrayItem)
2,673✔
357
            ->setReadOnly($isSchemaReadOnly || $schemaProcessor->getGeneratorConfiguration()->isImmutable())
2,673✔
358
            ->setWriteOnly($isWriteOnly);
2,673✔
359

360
        if (isset($json['$comment'])) {
2,672✔
361
            $property->setComment($json['$comment']);
7✔
362
        }
363

364
        if (isset($json['examples']) && is_array($json['examples'])) {
2,672✔
365
            $property->setExamples($json['examples']);
9✔
366
        }
367

368
        $configuration = $schemaProcessor->getGeneratorConfiguration();
2,672✔
369

370
        $property
2,672✔
371
            ->addAttribute(
2,672✔
372
                new PhpAttribute(JsonPointer::class, [$propertySchema->getPointer()]),
2,672✔
373
                $configuration,
2,672✔
374
                PhpAttribute::JSON_POINTER,
2,672✔
375
            )
2,672✔
376
            ->addAttribute(
2,672✔
377
                new PhpAttribute(SchemaName::class, [$propertyName]),
2,672✔
378
                $configuration,
2,672✔
379
                PhpAttribute::SCHEMA_NAME,
2,672✔
380
            )
2,672✔
381
            ->addAttribute(
2,672✔
382
                new PhpAttribute(
2,672✔
383
                    JsonSchemaAttribute::class,
2,672✔
384
                    [empty($propertySchema->getJson()) ? '{}' : json_encode($propertySchema->getJson())],
2,672✔
385
                ),
2,672✔
386
                $configuration,
2,672✔
387
                PhpAttribute::JSON_SCHEMA,
2,672✔
388
            );
2,672✔
389

390
        if ($required) {
2,672✔
391
            $property->addAttribute(new PhpAttribute(Required::class), $configuration, PhpAttribute::REQUIRED);
1,230✔
392
        }
393

394
        if (isset($json['readOnly']) && $json['readOnly'] === true) {
2,672✔
395
            $property->addAttribute(
4✔
396
                new PhpAttribute(ReadOnlyProperty::class),
4✔
397
                $configuration,
4✔
398
                PhpAttribute::READ_WRITE_ONLY,
4✔
399
            );
4✔
400
        }
401

402
        if (isset($json['writeOnly']) && $json['writeOnly'] === true) {
2,672✔
403
            $property->addAttribute(
14✔
404
                new PhpAttribute(WriteOnlyProperty::class),
14✔
405
                $configuration,
14✔
406
                PhpAttribute::READ_WRITE_ONLY,
14✔
407
            );
14✔
408
        }
409

410
        if (isset($json['deprecated']) && $json['deprecated'] === true) {
2,672✔
411
            $property->addAttribute(new PhpAttribute(Deprecated::class), $configuration, PhpAttribute::DEPRECATED);
2✔
412
        }
413

414
        return $property;
2,672✔
415
    }
416

417
    /**
418
     * Resolve a $ref reference by looking it up in the definition dictionary.
419
     *
420
     * @throws SchemaException
421
     */
422
    private function processReference(
597✔
423
        SchemaProcessor $schemaProcessor,
424
        Schema $schema,
425
        string $propertyName,
426
        JsonSchema $propertySchema,
427
        bool $required,
428
        bool $isArrayItem = false,
429
    ): PropertyInterface {
430
        $path       = [];
597✔
431
        $reference  = $propertySchema->getJson()['$ref'];
597✔
432
        $dictionary = $schema->getSchemaDictionary();
597✔
433

434
        try {
435
            $definition = $dictionary->getDefinition($reference, $schemaProcessor, $path);
597✔
436

437
            if ($definition) {
589✔
438
                $definitionSchema = $definition->getSchema();
589✔
439

440
                if (
441
                    $schema->getClassPath() !== $definitionSchema->getClassPath() ||
589✔
442
                    $schema->getClassName() !== $definitionSchema->getClassName() ||
588✔
443
                    (
444
                        $schema->getClassName() === 'ExternalSchema' &&
589✔
445
                        $definitionSchema->getClassName() === 'ExternalSchema'
589✔
446
                    )
447
                ) {
448
                    $schema->addNamespaceTransferDecorator(
314✔
449
                        new SchemaNamespaceTransferDecorator($definitionSchema),
314✔
450
                    );
314✔
451

452
                    if ($definitionSchema->getClassName() !== 'ExternalSchema') {
314✔
453
                        $schema->addUsedClass(join('\\', array_filter([
121✔
454
                            $schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(),
121✔
455
                            $definitionSchema->getClassPath(),
121✔
456
                            $definitionSchema->getClassName(),
121✔
457
                        ])));
121✔
458
                    }
459
                }
460

461
                $property = $definition->resolveReference(
589✔
462
                    $propertyName,
589✔
463
                    implode('/', $path),
589✔
464
                    $required,
589✔
465
                    $propertySchema->getJson()['_dependencies'] ?? null,
589✔
466
                    $isArrayItem,
589✔
467
                );
589✔
468

469
                // Use the reference site's pointer (where $ref appears in the schema) rather
470
                // than the definition's pointer. This is always meaningful and consistent with
471
                // how inline properties work — both show WHERE IN THE SCHEMA the property is
472
                // defined, not where the resolved type lives.
473
                $property->overrideJsonPointer(new PhpAttribute(JsonPointer::class, [$propertySchema->getPointer()]));
589✔
474

475
                return $property;
589✔
476
            }
477
        } catch (Exception $exception) {
8✔
478
            throw new SchemaException(
8✔
479
                "Unresolved Reference $reference in file {$propertySchema->getFile()}",
8✔
480
                null,
8✔
481
                0,
8✔
482
                $exception,
8✔
483
            );
8✔
484
        }
485

486
        throw new SchemaException(
×
487
            "Unresolved Reference $reference in file {$propertySchema->getFile()}",
×
488
            $propertySchema,
×
489
        );
×
490
    }
491

492
    /**
493
     * Resolve a $ref on a base-level schema: set up definitions, delegate to processReference,
494
     * then copy the referenced schema's properties to the parent schema.
495
     *
496
     * @throws SchemaException
497
     */
498
    private function processBaseReference(
46✔
499
        SchemaProcessor $schemaProcessor,
500
        Schema $schema,
501
        string $propertyName,
502
        JsonSchema $propertySchema,
503
        bool $required,
504
        bool $isArrayItem = false,
505
    ): PropertyInterface {
506
        $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
46✔
507

508
        $property = $this->processReference(
46✔
509
            $schemaProcessor,
46✔
510
            $schema,
46✔
511
            $propertyName,
46✔
512
            $propertySchema,
46✔
513
            $required,
46✔
514
            $isArrayItem,
46✔
515
        );
46✔
516

517
        if (!$property->getNestedSchema()) {
46✔
518
            throw new SchemaException(
1✔
519
                sprintf(
1✔
520
                    'A referenced schema on base level must provide an object definition for property %s in file %s',
1✔
521
                    $propertyName,
1✔
522
                    $propertySchema->getFile(),
1✔
523
                ),
1✔
524
                $propertySchema,
1✔
525
            );
1✔
526
        }
527

528
        foreach ($property->getNestedSchema()->getProperties() as $propertiesOfReferencedObject) {
45✔
529
            $schema->addProperty($propertiesOfReferencedObject);
45✔
530
        }
531

532
        // A referenced schema that is itself a composition (e.g. an allOf of further $refs, as
533
        // built by the object-shape re-routing in createObjectProperty()) enforces requiredness
534
        // and cross-branch constraints via its OWN base validator, not via validators attached to
535
        // the individual transferred properties - those are merged/redirected and carry no
536
        // validation of their own (object merging is owned elsewhere; see
537
        // transferComposedPropertiesToSchema(), which wires the same validator onto its schema
538
        // when the composition sits directly on this class instead of behind a $ref). Without
539
        // transferring it here too, a base-level $ref to such a schema would silently drop
540
        // whatever constraint only the composition validator enforces.
541
        foreach ($property->getNestedSchema()->getBaseValidators() as $baseValidator) {
45✔
542
            $schema->addBaseValidator($baseValidator);
2✔
543
        }
544

545
        return $property;
45✔
546
    }
547

548
    /**
549
     * Handle "type": [...] properties by processing each type through its Draft modifiers,
550
     * merging validators and decorators onto a single property, then consolidating type checks.
551
     *
552
     * @param string[] $types
553
     *
554
     * @throws SchemaException
555
     */
556
    private function createMultiTypeProperty(
98✔
557
        SchemaProcessor $schemaProcessor,
558
        Schema $schema,
559
        string $propertyName,
560
        JsonSchema $propertySchema,
561
        array $types,
562
        bool $required,
563
        bool $isArrayItem = false,
564
    ): PropertyInterface {
565
        $json     = $propertySchema->getJson();
98✔
566
        $property = $this->buildProperty(
98✔
567
            $schemaProcessor,
98✔
568
            $propertyName,
98✔
569
            null,
98✔
570
            $propertySchema,
98✔
571
            $required,
98✔
572
            $isArrayItem,
98✔
573
        );
98✔
574

575
        $collectedTypes   = [];
98✔
576
        $typeHints        = [];
98✔
577
        $resolvedSubCount = 0;
98✔
578
        $totalSubCount    = count($types);
98✔
579

580
        // Strip the default from sub-schemas so that default handling runs only once via the
581
        // universal DefaultValueModifier below, which already handles the multi-type case.
582
        $subJson = $json;
98✔
583
        unset($subJson['default']);
98✔
584

585
        foreach ($types as $type) {
98✔
586
            $this->checkType($type, $schema);
98✔
587

588
            $subJson['type'] = $type;
98✔
589
            $subSchema       = $propertySchema->withJson($subJson);
98✔
590

591
            // For type=object, delegate to the same object path (processSchema + wireObjectProperty).
592
            $subProperty = $type === 'object'
98✔
593
                ? $this->createObjectProperty(
9✔
594
                    $schemaProcessor,
9✔
595
                    $schema,
9✔
596
                    $propertyName,
9✔
597
                    $subSchema,
9✔
598
                    $required,
9✔
599
                    $isArrayItem,
9✔
600
                )
9✔
601
                : $this->createSubTypeProperty(
97✔
602
                    $schemaProcessor,
97✔
603
                    $schema,
97✔
604
                    $propertyName,
97✔
605
                    $subSchema,
97✔
606
                    $type,
97✔
607
                    $required,
97✔
608
                    $isArrayItem,
97✔
609
                );
97✔
610

611
            $subProperty->onResolve(function () use (
98✔
612
                $property,
98✔
613
                $subProperty,
98✔
614
                $schemaProcessor,
98✔
615
                $schema,
98✔
616
                $propertySchema,
98✔
617
                $totalSubCount,
98✔
618
                &$collectedTypes,
98✔
619
                &$typeHints,
98✔
620
                &$resolvedSubCount,
98✔
621
            ): void {
98✔
622
                foreach ($subProperty->getValidators() as $validatorContainer) {
98✔
623
                    $validator = $validatorContainer->getValidator();
98✔
624

625
                    if ($validator instanceof TypeCheckInterface) {
98✔
626
                        array_push($collectedTypes, ...$validator->getTypes());
98✔
627
                        continue;
98✔
628
                    }
629

630
                    $property->addValidator(
52✔
631
                        $validator,
52✔
632
                        $validatorContainer->getPriority(),
52✔
633
                        $validatorContainer->getSourceKey(),
52✔
634
                    );
52✔
635
                }
636

637
                if ($subProperty->getDecorators()) {
98✔
638
                    $property->addDecorator(new PropertyTransferDecorator($subProperty));
35✔
639
                }
640

641
                $typeHints[] = $subProperty->getTypeHint();
98✔
642

643
                if (++$resolvedSubCount < $totalSubCount || empty($collectedTypes)) {
98✔
644
                    return;
97✔
645
                }
646

647
                $this->finalizeMultiTypeProperty(
98✔
648
                    $property,
98✔
649
                    array_unique($collectedTypes),
98✔
650
                    $typeHints,
98✔
651
                    $schemaProcessor,
98✔
652
                    $schema,
98✔
653
                    $propertySchema,
98✔
654
                );
98✔
655
            });
98✔
656
        }
657

658
        return $property;
95✔
659
    }
660

661
    /**
662
     * Build a non-object sub-property for a multi-type array, applying only type-specific
663
     * modifiers (no universal 'any' modifiers — those run once on the parent after finalization).
664
     *
665
     * @throws SchemaException
666
     */
667
    private function createSubTypeProperty(
97✔
668
        SchemaProcessor $schemaProcessor,
669
        Schema $schema,
670
        string $propertyName,
671
        JsonSchema $propertySchema,
672
        string $type,
673
        bool $required,
674
        bool $isArrayItem = false,
675
    ): Property {
676
        $subProperty = $this->buildProperty(
97✔
677
            $schemaProcessor,
97✔
678
            $propertyName,
97✔
679
            new PropertyType(TypeConverter::jsonSchemaToPHP($type)),
97✔
680
            $propertySchema,
97✔
681
            $required,
97✔
682
            $isArrayItem,
97✔
683
        );
97✔
684

685
        $this->applyModifiers($schemaProcessor, $schema, $subProperty, $propertySchema, anyOnly: false, typeOnly: true);
97✔
686

687
        return $subProperty;
97✔
688
    }
689

690
    /**
691
     * Called once all sub-properties of a multi-type property have resolved.
692
     * Adds the consolidated MultiTypeCheckValidator, sets the union PropertyType,
693
     * attaches the type-hint decorator, and runs universal modifiers.
694
     *
695
     * @param string[] $collectedTypes
696
     * @param string[] $typeHints
697
     *
698
     * @throws SchemaException
699
     */
700
    private function finalizeMultiTypeProperty(
98✔
701
        PropertyInterface $property,
702
        array $collectedTypes,
703
        array $typeHints,
704
        SchemaProcessor $schemaProcessor,
705
        Schema $schema,
706
        JsonSchema $propertySchema,
707
    ): void {
708
        $hasNull      = in_array('null', $collectedTypes, true);
98✔
709
        $nonNullTypes = array_values(array_filter(
98✔
710
            $collectedTypes,
98✔
711
            static fn(string $type): bool => $type !== 'null',
98✔
712
        ));
98✔
713

714
        $allowImplicitNull = $schemaProcessor->getGeneratorConfiguration()->isImplicitNullAllowed()
98✔
715
            && !$property->isRequired();
98✔
716

717
        $property->addValidator(
98✔
718
            (new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull))
98✔
719
                ->withJsonPointer($propertySchema->getPointer() . '/type'),
98✔
720
            2,
98✔
721
        );
98✔
722

723
        if ($nonNullTypes) {
98✔
724
            $property->setType(
98✔
725
                new PropertyType($nonNullTypes, $hasNull ? true : null),
98✔
726
                new PropertyType($nonNullTypes, $hasNull ? true : null),
98✔
727
            );
98✔
728
        }
729

730
        $property->addTypeHintDecorator(new TypeHintDecorator($typeHints));
98✔
731

732
        $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, true);
98✔
733
    }
734

735
    /**
736
     * Wire the outer property for a guarded (object-describing) nested object: attach the
737
     * instantiation linkage but NOT the asserting object type check. The instantiation decorator
738
     * only instantiates genuine JSON-object values (`is_array($value) && !array_is_list($value)`,
739
     * with an empty-array carve-out so `{}` still instantiates), so a non-object value - including
740
     * a JSON array, which `json_decode(..., true)` would otherwise make indistinguishable from an
741
     * object - passes through unchanged and vacuously satisfies the schema per strict JSON Schema
742
     * semantics, while an object value is instantiated and validated against the representation
743
     * class.
744
     *
745
     * @throws SchemaException
746
     */
747
    private function wireDescribingObjectProperty(
18✔
748
        SchemaProcessor $schemaProcessor,
749
        Schema $schema,
750
        PropertyInterface $property,
751
        JsonSchema $propertySchema,
752
    ): void {
753
        (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema);
18✔
754

755
        // ObjectModifier adds an asserting InstanceOfValidator that rejects non-object values.
756
        // A describing schema must accept them vacuously, so drop it - the guarded instantiation
757
        // decorator remains and carries the object-value validation.
758
        $property->filterValidators(
18✔
759
            static fn(Validator $validator): bool => !($validator->getValidator() instanceof InstanceOfValidator),
18✔
760
        );
18✔
761

762
        // ObjectModifier also typed the property as the representation class. A describing property
763
        // is not exclusively object-valued (a non-object passes through unchanged), so keep it
764
        // untyped: the value is either an instance of the representation class or the raw
765
        // non-object input. Without this a non-object value would violate the getter's object
766
        // return type at read time even though it validated cleanly.
767
        $property->setType(null, null, reset: true);
18✔
768
    }
769

770
    /**
771
     * Wire the outer property for a nested object: add the type-check validator and instantiation
772
     * linkage. Schema-targeting modifiers are intentionally NOT run here because processSchema
773
     * already applied them to the nested schema.
774
     *
775
     * @throws SchemaException
776
     */
777
    private function wireObjectProperty(
1,103✔
778
        SchemaProcessor $schemaProcessor,
779
        Schema $schema,
780
        PropertyInterface $property,
781
        JsonSchema $propertySchema,
782
    ): void {
783
        (new TypeCheckModifier(TypeConverter::jsonSchemaToPHP('object')))->modify(
1,103✔
784
            $schemaProcessor,
1,103✔
785
            $schema,
1,103✔
786
            $property,
1,103✔
787
            $propertySchema,
1,103✔
788
        );
1,103✔
789

790
        (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema);
1,103✔
791
    }
792

793
    /**
794
     * Run Draft modifiers for the given property.
795
     *
796
     * By default all covered types (type-specific + 'any') run. Pass $anyOnly=true to run
797
     * only the 'any' entry (used for object outer-property universal keywords), or $typeOnly=true
798
     * to run only type-specific entries (used for multi-type sub-properties).
799
     *
800
     * @throws SchemaException
801
     */
802
    private function applyModifiers(
2,713✔
803
        SchemaProcessor $schemaProcessor,
804
        Schema $schema,
805
        PropertyInterface $property,
806
        JsonSchema $propertySchema,
807
        bool $anyOnly = false,
808
        bool $typeOnly = false,
809
    ): void {
810
        $type       = $propertySchema->getJson()['type'] ?? 'any';
2,713✔
811
        $builtDraft = $schemaProcessor->getGeneratorConfiguration()->getBuiltDraft($propertySchema);
2,713✔
812

813
        // For untyped properties ('any'), only run the 'any' entry — getCoveredTypes('any')
814
        // returns all types, which would incorrectly apply type-specific modifiers.
815
        $coveredTypes = $type === 'any'
2,713✔
816
            ? array_filter($builtDraft->getCoveredTypes('any'), static fn($t) => $t->getType() === 'any')
738✔
817
            : $builtDraft->getCoveredTypes($type);
2,713✔
818

819
        foreach ($coveredTypes as $coveredType) {
2,713✔
820
            $isAnyEntry = $coveredType->getType() === 'any';
2,713✔
821

822
            if ($anyOnly && !$isAnyEntry) {
2,713✔
823
                continue;
1,203✔
824
            }
825

826
            if ($typeOnly && $isAnyEntry) {
2,713✔
827
                continue;
97✔
828
            }
829

830
            foreach ($coveredType->getModifiers() as $modifier) {
2,713✔
831
                $countBefore = count($property->getValidators());
2,713✔
832
                $modifier->modify($schemaProcessor, $schema, $property, $propertySchema);
2,713✔
833

834
                // Tag every validator that was just added by this modifier with its schema
835
                // keyword so FilterProcessor can later classify each validator as
836
                // input-space or output-space relative to a transforming filter.
837
                // This must cover all Draft-registered validators — not only those known
838
                // to interact with filters today — because a custom Draft may register
839
                // any validator factory under any type, and the classification must work
840
                // without enumerating individual keywords.
841
                if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) {
2,698✔
842
                    foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) {
2,697✔
843
                        $validatorWrapper->setSourceKey($modifierKey);
2,007✔
844
                    }
845
                }
846
            }
847
        }
848
    }
849

850
    /**
851
     * @throws SchemaException
852
     */
853
    private function checkType(mixed $type, Schema $schema): void
2,713✔
854
    {
855
        if (is_string($type)) {
2,713✔
856
            return;
2,713✔
857
        }
858

859
        throw new SchemaException(
1✔
860
            sprintf(
1✔
861
                'Invalid property type %s in file %s',
1✔
862
                $type,
1✔
863
                $schema->getJsonSchema()->getFile(),
1✔
864
            ),
1✔
865
            $schema->getJsonSchema(),
1✔
866
        );
1✔
867
    }
868
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc