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

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

10 Aug 2026 08:51PM UTC coverage: 98.897% (+0.09%) from 98.811%
31431102822

Pull #166

github

claude
Merge master: adopt draft-aware $ref sibling handling

Master replaced JsonSchema's allOf rewrite hack for {$ref, siblings} with a
draft-aware producer model, and moved the reference resolution out of
PropertyFactory into RefResolver. Three things had to be adopted rather
than merged mechanically.

The object-shape classifier merged $ref siblings unconditionally, on the
assumption - true before, false now - that the constructor rewrite made
sibling handling draft-independent. Draft 07 registers $ref as an
ExclusiveProducer and ignores every sibling, so merging them classified
`allOf: [{$ref: <object>, type: string}]` as unsatisfiable and rejected a
schema master generates without complaint. classifyReference() now reads
the draft's own $ref producer and follows it, instead of restating the
policy.

The #182/#183 fixes lived in PropertyFactory::processBaseReference(),
which master rewrote from the pre-fix base into RefResolver. Re-applied
there on top of master's structure, so a base-level $ref still transfers
the referenced schema's base validators and still resolves to a
composition rather than being refused outright. The referenced-schema
failure marker likewise had to be re-wired into RefResolver, which
otherwise rewraps a target-side failure as "Unresolved Reference".

createObjectProperty()'s guarded mode was lost when master's version was
taken wholesale; restored.

checkObjectRepresentability()'s $ref exemption now covers sibling-bearing
roots too, since the rewrite that used to strip that key is gone. That is
left as-is deliberately: whether siblings apply is the draft's decision,
and the reference resolution that follows already implements it.

Suite matches master's baseline exactly - the 8 remaining errors
(ArrayContainsTest 2020-12, Issue186Test, AdditionalPropertiesAccessor)
reproduce on pristine master and are not from this branch.
Pull Request #166: Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones

400 of 401 new or added lines in 15 files covered. (99.75%)

4 existing lines in 3 files now uncovered.

7893 of 7981 relevant lines covered (98.9%)

595.33 hits per line

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

99.39
/src/SchemaProcessor/SchemaProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Model\GeneratorConfiguration;
9
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
10
use PHPModelGenerator\Model\Property\Property;
11
use PHPModelGenerator\Model\Property\PropertyInterface;
12
use PHPModelGenerator\Model\Property\PropertyType;
13
use PHPModelGenerator\Model\RenderJob;
14
use PHPModelGenerator\Model\Schema;
15
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
16
use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary;
17
use PHPModelGenerator\Model\Validator;
18
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
19
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
20
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
21
use PHPModelGenerator\Model\Validator\PropertyTemplateValidator;
22
use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory;
23
use PHPModelGenerator\Model\Validator\Factory\Composition\AnyOfValidatorFactory;
24
use PHPModelGenerator\Model\Validator\Factory\Composition\ComposedPropertiesValidatorFactoryInterface;
25
use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory;
26
use PHPModelGenerator\PropertyProcessor\Decorator\Property\DefaultArrayToEmptyArrayDecorator;
27
use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator;
28
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
29
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
30
use PHPModelGenerator\PropertyProcessor\ObjectShape\ObjectShape;
31
use PHPModelGenerator\PropertyProcessor\ObjectShape\ObjectShapeResolver;
32
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
33
use PHPModelGenerator\SchemaProvider\SchemaProviderInterface;
34
use PHPModelGenerator\Utils\PropertyAttributeSynthesizer;
35

36
class SchemaProcessor
37
{
38
    protected string $currentClassPath;
39
    protected string $currentClassName;
40

41
    private PropertyAttributeSynthesizer $propertyAttributeSynthesizer;
42

43
    /** @var Schema[] Collect processed schemas to avoid duplicated classes */
44
    protected array $processedSchema = [];
45
    /** @var PropertyInterface[] Collect processed schemas to avoid duplicated classes */
46
    protected array $processedMergedProperties = [];
47
    /**
48
     * Global index of schemas keyed by the canonical file path or URL returned by
49
     * SchemaProviderInterface::getRef(). Used to deduplicate external $ref resolutions across
50
     * all schema processings, making class generation order-independent.
51
     *
52
     * When a $ref triggers processTopLevelSchema() for a file that the provider has not yet
53
     * reached, the canonical Schema is registered here before property processing begins. If
54
     * the provider later iterates the same file, generateModel() detects the match via the
55
     * combined file-path + content-signature check and returns the already-registered Schema
56
     * without creating a duplicate render job.
57
     *
58
     * Note: for providers such as OpenAPIv3Provider that yield multiple distinct schemas from
59
     * a single source file, each schema has a unique content signature; the signature check
60
     * prevents false-positive deduplication across schemas that merely share the same file.
61
     *
62
     * @var Schema[]
63
     */
64
    protected array $processedFileSchemas = [];
65
    /** @var string[] */
66
    protected array $generatedFiles = [];
67

68
    public function __construct(
2,897✔
69
        protected SchemaProviderInterface $schemaProvider,
70
        protected string $destination,
71
        protected GeneratorConfiguration $generatorConfiguration,
72
        protected RenderQueue $renderQueue,
73
    ) {
74
        $this->propertyAttributeSynthesizer = new PropertyAttributeSynthesizer($generatorConfiguration);
2,897✔
75
    }
76

77
    /**
78
     * Process a given json schema file
79
     *
80
     * @throws SchemaException
81
     */
82
    public function process(JsonSchema $jsonSchema): void
2,867✔
83
    {
84
        $this->setCurrentClassPath($jsonSchema->getFile());
2,867✔
85
        $this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
2,867✔
86
            str_ireplace('.json', '', basename($jsonSchema->getFile())),
2,867✔
87
            $jsonSchema,
2,867✔
88
            false,
2,867✔
89
        );
2,867✔
90

91
        $this->processSchema(
2,867✔
92
            $jsonSchema,
2,867✔
93
            $this->currentClassPath,
2,867✔
94
            $this->currentClassName,
2,867✔
95
            new SchemaDefinitionDictionary($jsonSchema),
2,867✔
96
            true,
2,867✔
97
        );
2,867✔
98
    }
99

100
    /**
101
     * Process a JSON schema stored as an associative array
102
     *
103
     * @param SchemaDefinitionDictionary $dictionary   If a nested object of a schema is processed import the
104
     *                                                 definitions of the parent schema to make them available for the
105
     *                                                 nested schema as well
106
     * @param bool                       $initialClass Is it an initial class or a nested class?
107
     *
108
     * @throws SchemaException
109
     */
110
    public function processSchema(
2,867✔
111
        JsonSchema $jsonSchema,
112
        string $classPath,
113
        string $className,
114
        SchemaDefinitionDictionary $dictionary,
115
        bool $initialClass = false,
116
    ): ?Schema {
117
        if (
118
            (!isset($jsonSchema->getJson()['type']) || $jsonSchema->getJson()['type'] !== 'object') &&
2,867✔
119
            !array_intersect(array_keys($jsonSchema->getJson()), ['anyOf', 'allOf', 'oneOf', 'if', '$ref'])
2,867✔
120
        ) {
121
            // skip the JSON schema as neither an object, a reference nor a composition is defined on the root level
122
            return null;
4✔
123
        }
124

125
        return $this->generateModel($classPath, $className, $jsonSchema, $dictionary, $initialClass);
2,867✔
126
    }
127

128
    /**
129
     * Generate a model and store the model to the file system
130
     *
131
     * @throws SchemaException
132
     */
133
    protected function generateModel(
2,867✔
134
        string $classPath,
135
        string $className,
136
        JsonSchema $jsonSchema,
137
        SchemaDefinitionDictionary $dictionary,
138
        bool $initialClass,
139
    ): Schema {
140
        $schemaSignature = $jsonSchema->getSignature();
2,867✔
141

142
        if (!$initialClass && isset($this->processedSchema[$schemaSignature])) {
2,867✔
143
            $this->generatorConfiguration->getLogger()->notice(
208✔
144
                'Duplicated signature {signature} for class {class}. Redirecting to {redirectClass}',
208✔
145
                [
208✔
146
                    'signature' => $schemaSignature,
208✔
147
                    'class' => $className,
208✔
148
                    'redirectClass' => $this->processedSchema[$schemaSignature]->getClassName(),
208✔
149
                ],
208✔
150
            );
208✔
151

152
            return $this->processedSchema[$schemaSignature];
208✔
153
        }
154

155
        // For initial-class calls: if this exact file+content was already processed eagerly via
156
        // processTopLevelSchema() (triggered by a $ref resolution), reuse that schema to avoid a
157
        // duplicate render job. Both checks are required:
158
        // - The file-path check detects that this file was already processed via a $ref.
159
        // - The signature check ensures we do not short-circuit when a different schema shares
160
        //   the same source file (e.g. OpenAPI v3 where all component schemas are yielded from
161
        //   the same spec file — each has a unique signature).
162
        if (
163
            $initialClass
2,867✔
164
            && isset($this->processedSchema[$schemaSignature])
2,867✔
165
            && $this->getProcessedFileSchema($jsonSchema->getFile()) !== null
2,867✔
166
        ) {
167
            return $this->processedSchema[$schemaSignature];
12✔
168
        }
169

170
        $schema = new Schema(
2,867✔
171
            $this->getTargetFileName($classPath, $className),
2,867✔
172
            $classPath,
2,867✔
173
            $className,
2,867✔
174
            $jsonSchema,
2,867✔
175
            $dictionary,
2,867✔
176
            $initialClass,
2,867✔
177
            $this->generatorConfiguration,
2,867✔
178
        );
2,867✔
179

180
        // Populated here (rather than lazily inside createBaseProperty(), the only other caller)
181
        // because checkObjectRepresentability() below needs it ready to resolve $ref chains.
182
        // addDefinition() is idempotent, so createBaseProperty()'s own call stays a harmless no-op.
183
        $dictionary->setUpDefinitionDictionary($this, $schema);
2,867✔
184
        $this->checkObjectRepresentability($jsonSchema, $className, $dictionary);
2,867✔
185

186
        // Register by content signature (secondary dedup for content-identical inline schemas).
187
        $this->processedSchema[$schemaSignature] = $schema;
2,856✔
188
        // Register by canonical file path/URL (primary dedup for external $ref resolutions).
189
        // Registering here — before property processing — ensures that any $ref back to this
190
        // file encountered while processing the referencing schema finds this canonical schema
191
        // immediately, regardless of which schema was discovered first by the provider.
192
        $this->registerProcessedFileSchema($jsonSchema->getFile(), $schema);
2,856✔
193
        $json = $jsonSchema->getJson();
2,856✔
194
        $json['type'] = 'base';
2,856✔
195

196
        (new PropertyFactory())->create(
2,856✔
197
            $this,
2,856✔
198
            $schema,
2,856✔
199
            $className,
2,856✔
200
            $jsonSchema->withJson($json),
2,856✔
201
        );
2,856✔
202

203
        $this->generateClassFile($schema);
2,715✔
204

205
        return $schema;
2,715✔
206
    }
207

208
    /**
209
     * Every schema reaching this point becomes a generated PHP class, so its value must be
210
     * guaranteed to be a JSON object - a composition that only sometimes resolves to an object
211
     * cannot be faithfully represented by a single generated class. Classifies the pristine
212
     * schema JSON, before generateModel() overwrites `type` with the internal 'base' dispatch
213
     * sentinel and thereby erases whether the author declared `type: object` themselves.
214
     *
215
     * A `$ref` schema is skipped here on purpose: the real reference resolution that runs moments
216
     * later already handles every non-representable-target case with better attribution than a
217
     * speculative peek from here could - and peeking was tried and reverted, since it triggers
218
     * that same real (non-speculative) resolution as a side effect, and a legitimate failure from
219
     * it gets swallowed by the peek's own conservative error handling, replacing a precise,
220
     * correctly-attributed error with a confusing one blaming the referencing wrapper instead.
221
     *
222
     * The check is on the raw top-level key, so it covers a `$ref` root whether or not it carries
223
     * siblings. That is deliberate for both: whether siblings apply at all is the draft's
224
     * decision (Draft 07 suppresses them, Draft 2019-09 applies them), and the reference
225
     * resolution that follows implements whichever rule is in force, so deferring to it keeps this
226
     * method out of a policy it would otherwise have to duplicate. A `$ref` nested inside a
227
     * composition has no top-level `$ref` key and is therefore still classified below, where
228
     * ObjectShapeResolver applies the same draft-derived sibling rule.
229
     *
230
     * A filter-bearing schema is not exempted by an early return at all (a filter nested inside a
231
     * composition branch, e.g. an `allOf` branch, has no top-level `filter` key to check for, so
232
     * an early return here could never have covered that shape anyway). It is instead classified
233
     * below like everything else: ObjectShapeResolver deliberately classifies filter-bearing
234
     * schemas as Undecidable (they are owned by the filter-composition subsystem, not the object
235
     * path), and the guard just after the shape is resolved lets that verdict through without
236
     * throwing here, so the filter subsystem's own compatibility check - which runs moments later
237
     * and reports a precise, correctly-attributed error (e.g. naming the incompatible filter and
238
     * property type) - is not preempted by a generic "does not resolve to a definite object"
239
     * verdict from this method.
240
     *
241
     * @throws SchemaException
242
     */
243
    private function checkObjectRepresentability(
2,867✔
244
        JsonSchema $jsonSchema,
245
        string $className,
246
        SchemaDefinitionDictionary $dictionary,
247
    ): void {
248
        if (array_key_exists('$ref', $jsonSchema->getJson())) {
2,867✔
249
            return;
87✔
250
        }
251

252
        $shape = ObjectShapeResolver::forDictionary(
2,863✔
253
            $this,
2,863✔
254
            $dictionary,
2,863✔
255
            $this->generatorConfiguration->getBuiltDraft($jsonSchema),
2,863✔
256
        )->resolve($jsonSchema->getJson());
2,863✔
257

258
        // Object-ness could not be determined at all (an unresolvable/cyclic $ref, or a
259
        // filter-bearing branch owned by another subsystem); let the real pipeline run and
260
        // produce its own precise, correctly-attributed error rather than rejecting here with a
261
        // generic representability message that would name the wrong cause.
262
        if ($shape === ObjectShape::Undecidable) {
2,863✔
263
            return;
3✔
264
        }
265

266
        $acceptedShapes = $this->generatorConfiguration->isImplicitObjectCompositionAllowed()
2,861✔
267
            ? [ObjectShape::ObjectAsserting, ObjectShape::ObjectDescribing]
9✔
268
            : [ObjectShape::ObjectAsserting];
2,852✔
269

270
        if (!in_array($shape, $acceptedShapes, true)) {
2,861✔
271
            $message = sprintf(
12✔
272
                "Composition for '%s' in file '%s' does not resolve to a definite object and"
12✔
273
                    . ' cannot be represented as a generated class',
12✔
274
                $className,
12✔
275
                $jsonSchema->getFile(),
12✔
276
            );
12✔
277

278
            // The flag only ever widens acceptance from ObjectAsserting to
279
            // ObjectAsserting|ObjectDescribing (see $acceptedShapes above), so it can rescue an
280
            // ObjectDescribing composition but never a NotObject one - mentioning either fix for a
281
            // NotObject rejection would point the user at options that provably cannot help.
282
            // Declaring the type is named first because it is the better fix: it makes the
283
            // author's intent explicit and is what the generator actually needs, whereas the
284
            // flag is only an escape hatch that leaves the ambiguity in the schema unresolved.
285
            if ($shape === ObjectShape::ObjectDescribing) {
12✔
286
                $message .= ': add an explicit \'"type": "object"\' constraint, or enable'
3✔
287
                    . " 'GeneratorConfiguration::setImplicitObjectComposition(true)' to accept it";
3✔
288
            }
289

290
            throw new SchemaException($message, $jsonSchema);
12✔
291
        }
292
    }
293

294
    /**
295
     * Create the merged Schema for a property-level $ref+object-sibling merge (Draft 2019-09+).
296
     *
297
     * Processes the sibling structural keywords (properties, required, additionalProperties, …)
298
     * directly into the merged Schema via a base-property pass. The caller is responsible for
299
     * transferring the $ref's nested Schema's properties into the merged Schema (with allOf
300
     * semantics for name collisions) inside an onResolve callback, and for calling
301
     * generateClassFile() once the ref has resolved.
302
     *
303
     * @throws SchemaException
304
     */
305
    public function createObjectRefSiblingMergedSchema(
11✔
306
        Schema $parentSchema,
307
        string $propertyName,
308
        JsonSchema $propertySchema,
309
        array $siblingJson,
310
    ): Schema {
311
        $mergedClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
11✔
312
            $propertyName,
11✔
313
            $propertySchema,
11✔
314
            true,
11✔
315
            $this->currentClassName,
11✔
316
        );
11✔
317

318
        // Exclude keywords that belong to the outer property (not the merged class body).
319
        // Mirrors the exclusions that createObjectProperty() applies before passing the schema
320
        // to processSchema().
321
        $mergedBaseJson = $siblingJson;
11✔
322
        unset($mergedBaseJson['filter'], $mergedBaseJson['enum'], $mergedBaseJson['default']);
11✔
323
        $mergedBaseJson['type'] = 'base';
11✔
324

325
        $mergedSchemaJson = $propertySchema->withJson($mergedBaseJson);
11✔
326

327
        $mergedSchema = new Schema(
11✔
328
            $this->getTargetFileName($parentSchema->getClassPath(), $mergedClassName),
11✔
329
            $parentSchema->getClassPath(),
11✔
330
            $mergedClassName,
11✔
331
            $mergedSchemaJson,
11✔
332
            $parentSchema->getSchemaDictionary(),
11✔
333
            false,
11✔
334
            $this->generatorConfiguration,
11✔
335
        );
11✔
336

337
        // Process the sibling structural keywords directly into the merged Schema as a base
338
        // property. This registers sibling-defined properties (via PropertiesValidatorFactory,
339
        // RequiredPropertyValidator, etc.) on the merged Schema before the $ref's properties
340
        // are wired in with allOf semantics.
341
        (new PropertyFactory())->create(
11✔
342
            $this,
11✔
343
            $mergedSchema,
11✔
344
            $mergedClassName,
11✔
345
            $mergedSchemaJson,
11✔
346
        );
11✔
347

348
        return $mergedSchema;
11✔
349
    }
350

351
    /**
352
     * Attach a new class file render job to the render proxy
353
     */
354
    public function generateClassFile(Schema $schema): void
2,715✔
355
    {
356
        $this->renderQueue->addRenderJob(new RenderJob($schema));
2,715✔
357

358
        $this->generatorConfiguration->getLogger()->info(
2,715✔
359
            'Generated class {class}',
2,715✔
360
            [
2,715✔
361
                'class' => join(
2,715✔
362
                    '\\',
2,715✔
363
                    array_filter([
2,715✔
364
                        $this->generatorConfiguration->getNamespacePrefix(),
2,715✔
365
                        $schema->getClassPath(),
2,715✔
366
                        $schema->getClassName(),
2,715✔
367
                    ]),
2,715✔
368
                ),
2,715✔
369
            ],
2,715✔
370
        );
2,715✔
371

372
        $this->generatedFiles[] = $schema->getTargetFileName();
2,715✔
373
    }
374

375

376
    /**
377
     * Gather all nested object properties and merge them together into a single merged property
378
     *
379
     * @param CompositionPropertyDecorator[] $compositionProperties
380
     *
381
     * @throws SchemaException
382
     */
383
    public function createMergedProperty(
216✔
384
        Schema $schema,
385
        PropertyInterface $property,
386
        array $compositionProperties,
387
        JsonSchema $propertySchema,
388
    ): ?PropertyInterface {
389
        $redirectToProperty = $this->redirectMergedProperty($compositionProperties);
216✔
390
        if ($redirectToProperty === null || $redirectToProperty instanceof PropertyInterface) {
216✔
391
            if ($redirectToProperty) {
162✔
392
                $property->addTypeHintDecorator(new CompositionTypeHintDecorator($redirectToProperty));
29✔
393
            }
394

395
            return $redirectToProperty;
162✔
396
        }
397

398
        /** @var JsonSchema $jsonSchema */
399
        $jsonSchema = $propertySchema->getJson()['propertySchema'];
54✔
400
        $schemaSignature = $jsonSchema->getSignature();
54✔
401

402
        if (!isset($this->processedMergedProperties[$schemaSignature])) {
54✔
403
            $mergedClassName = $this
54✔
404
                ->getGeneratorConfiguration()
54✔
405
                ->getClassNameGenerator()
54✔
406
                ->getClassName(
54✔
407
                    $property->getName(),
54✔
408
                    $propertySchema,
54✔
409
                    true,
54✔
410
                    $this->getCurrentClassName(),
54✔
411
                );
54✔
412

413
            $mergedPropertySchema = new Schema(
54✔
414
                $this->getTargetFileName($schema->getClassPath(), $mergedClassName),
54✔
415
                $schema->getClassPath(),
54✔
416
                $mergedClassName,
54✔
417
                $propertySchema,
54✔
418
            );
54✔
419

420
            $this->processedMergedProperties[$schemaSignature] = (new Property(
54✔
421
                'MergedProperty',
54✔
422
                new PropertyType($mergedClassName),
54✔
423
                $mergedPropertySchema->getJsonSchema(),
54✔
424
            ))
54✔
425
                ->addDecorator(new ObjectInstantiationDecorator($mergedClassName, $this->getGeneratorConfiguration()))
54✔
426
                ->setNestedSchema($mergedPropertySchema);
54✔
427

428
            $this->transferPropertiesToMergedSchema($schema, $mergedPropertySchema, $compositionProperties);
54✔
429

430
            // make sure the merged schema knows all imports of the parent schema
431
            $mergedPropertySchema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema));
54✔
432

433
            $this->generateClassFile($mergedPropertySchema);
54✔
434
        }
435

436
        $mergedSchema = $this->processedMergedProperties[$schemaSignature]->getNestedSchema();
54✔
437
        $schema->addUsedClass(
54✔
438
            join(
54✔
439
                '\\',
54✔
440
                array_filter([
54✔
441
                    $this->generatorConfiguration->getNamespacePrefix(),
54✔
442
                    $mergedSchema->getClassPath(),
54✔
443
                    $mergedSchema->getClassName(),
54✔
444
                ]),
54✔
445
            )
54✔
446
        );
54✔
447

448
        $property->addTypeHintDecorator(
54✔
449
            new CompositionTypeHintDecorator($this->processedMergedProperties[$schemaSignature]),
54✔
450
        );
54✔
451

452
        return $this->processedMergedProperties[$schemaSignature];
54✔
453
    }
454

455
    /**
456
     * Check if multiple $compositionProperties contain nested schemas. Only in this case a merged property must be
457
     * created. If no nested schemas are detected null will be returned. If only one $compositionProperty contains a
458
     * nested schema the $compositionProperty will be used as a replacement for the merged property.
459
     *
460
     * Returns false if a merged property must be created.
461
     *
462
     * @param CompositionPropertyDecorator[] $compositionProperties
463
     *
464
     * @return PropertyInterface|null|false
465
     */
466
    private function redirectMergedProperty(array $compositionProperties)
216✔
467
    {
468
        $redirectToProperty = null;
216✔
469
        foreach ($compositionProperties as $property) {
216✔
470
            if ($property->getNestedSchema()) {
216✔
471
                if ($redirectToProperty !== null) {
83✔
472
                    return false;
54✔
473
                }
474

475
                $redirectToProperty = $property;
83✔
476
            }
477
        }
478

479
        return $redirectToProperty;
162✔
480
    }
481

482
    /**
483
     * @param PropertyInterface[] $compositionProperties
484
     */
485
    private function transferPropertiesToMergedSchema(
54✔
486
        Schema $schema,
487
        Schema $mergedPropertySchema,
488
        array $compositionProperties,
489
    ): void {
490
        foreach ($compositionProperties as $property) {
54✔
491
            if (!$property->getNestedSchema()) {
54✔
492
                continue;
26✔
493
            }
494

495
            $property->getNestedSchema()->onAllPropertiesResolved(
54✔
496
                function () use ($property, $schema, $mergedPropertySchema): void {
54✔
497
                    foreach ($property->getNestedSchema()->getProperties() as $nestedProperty) {
54✔
498
                        $mergedPropertySchema->addProperty(
53✔
499
                        // don't validate fields in merged properties. All fields were validated before
500
                        // corresponding to the defined constraints of the composition property.
501
                            (clone $nestedProperty)->filterValidators(static fn(): bool => false),
53✔
502
                        );
53✔
503
                    }
504
                },
54✔
505
            );
54✔
506
        }
507
    }
508

509
    /**
510
     * Get the class path out of the file path of a schema file
511
     */
512
    protected function setCurrentClassPath(string $jsonSchemaFile): void
2,867✔
513
    {
514
        $fileDir  = str_replace('\\', '/', dirname($jsonSchemaFile));
2,867✔
515
        $baseDir  = str_replace('\\', '/', $this->schemaProvider->getBaseDirectory());
2,867✔
516
        $relative = str_replace($baseDir, '', $fileDir);
2,867✔
517

518
        // If the file is outside the provider's base directory, str_replace leaves the absolute
519
        // path untouched. In that case fall back to using just the last directory component so
520
        // the generated class path stays sensible rather than encoding an absolute path.
521
        if ($relative === $fileDir) {
2,867✔
UNCOV
522
            $relative = basename($fileDir);
×
523
        }
524

525
        $pieces = array_map(
2,867✔
526
            static fn(string $directory): string => ucfirst((string) preg_replace('/\W/', '', $directory)),
2,867✔
527
            explode('/', $relative),
2,867✔
528
        );
2,867✔
529

530
        $this->currentClassPath = join('\\', array_filter($pieces));
2,867✔
531
    }
532

533
    public function getCurrentClassPath(): string
1,211✔
534
    {
535
        return $this->currentClassPath;
1,211✔
536
    }
537

538
    public function getCurrentClassName(): string
1,191✔
539
    {
540
        return $this->currentClassName;
1,191✔
541
    }
542

543
    public function getGeneratedFiles(): array
2,665✔
544
    {
545
        return $this->generatedFiles;
2,665✔
546
    }
547

548
    public function getGeneratorConfiguration(): GeneratorConfiguration
2,872✔
549
    {
550
        return $this->generatorConfiguration;
2,872✔
551
    }
552

553
    public function getSchemaProvider(): SchemaProviderInterface
246✔
554
    {
555
        return $this->schemaProvider;
246✔
556
    }
557

558
    public function getProcessedFileSchema(string $fileKey): ?Schema
239✔
559
    {
560
        return $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] ?? null;
239✔
561
    }
562

563
    public function registerProcessedFileSchema(string $fileKey, Schema $schema): void
2,856✔
564
    {
565
        $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] = $schema;
2,856✔
566
    }
567

568
    /**
569
     * Normalise a file path or URL to a consistent key for processedFileSchemas.
570
     * On Windows, RecursiveDirectoryIterator may produce backslash-separated paths while
571
     * RefResolverTrait produces forward-slash paths for the same file. Normalising to forward
572
     * slashes ensures the two representations map to the same key.
573
     */
574
    private function normaliseFileKey(string $fileKey): string
2,856✔
575
    {
576
        return str_replace('\\', '/', $fileKey);
2,856✔
577
    }
578

579
    /**
580
     * Process an external schema file with its canonical class name and path, exactly as
581
     * process() would, but without overwriting the current class path / class name context
582
     * (which belongs to the schema that triggered the $ref resolution).
583
     *
584
     * Returns the resulting Schema, or null if the file does not define an object/composition.
585
     *
586
     * @throws SchemaException
587
     */
588
    public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema
18✔
589
    {
590
        $savedClassPath  = $this->currentClassPath;
18✔
591
        $savedClassName  = $this->currentClassName;
18✔
592

593
        $this->setCurrentClassPath($jsonSchema->getFile());
18✔
594
        $this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
18✔
595
            str_ireplace('.json', '', basename($jsonSchema->getFile())),
18✔
596
            $jsonSchema,
18✔
597
            false,
18✔
598
        );
18✔
599

600
        try {
601
            $schema = $this->processSchema(
18✔
602
                $jsonSchema,
18✔
603
                $this->currentClassPath,
18✔
604
                $this->currentClassName,
18✔
605
                new SchemaDefinitionDictionary($jsonSchema),
18✔
606
                true,
18✔
607
            );
18✔
608
        } catch (SchemaException $exception) {
1✔
609
            // Everything thrown here is a fault in the referenced schema itself, not a failure to
610
            // reach it - this method only runs once the reference has already resolved to a file.
611
            // Marking it keeps PropertyFactory::processReference() from restating it as
612
            // "Unresolved Reference", which would blame the reference site for a problem in the
613
            // content it points at.
614
            throw $exception->markAsReferencedSchemaFailure();
1✔
615
        }
616

617
        $this->currentClassPath = $savedClassPath;
17✔
618
        $this->currentClassName = $savedClassName;
17✔
619

620
        return $schema;
17✔
621
    }
622

623
    /**
624
     * Transfer properties of composed properties to the given schema to offer a complete model
625
     * including all composed properties.
626
     *
627
     * This is an internal pipeline mechanic (Q5.1): not a JSON Schema keyword and therefore not
628
     * a Draft modifier. It is called as an explicit post-step from generateModel after all Draft
629
     * modifiers have run on the root-level BaseProperty.
630
     *
631
     * @throws SchemaException
632
     */
633
    public function transferComposedPropertiesToSchema(PropertyInterface $property, Schema $schema): void
2,720✔
634
    {
635
        foreach ($property->getValidators() as $validator) {
2,720✔
636
            $validator = $validator->getValidator();
491✔
637

638
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
491✔
639
                continue;
×
640
            }
641

642
            // If the transferred validator of the composed property is also a composed property
643
            // strip the nested composition validations from the added validator. The nested
644
            // composition will be validated in the object generated for the nested composition
645
            // which will be executed via an instantiation. Consequently, the validation must not
646
            // be executed in the outer composition.
647
            $schema->addBaseValidator(
491✔
648
                ($validator instanceof ComposedPropertyValidator)
491✔
649
                    ? $validator->withoutNestedCompositionValidation()
444✔
650
                    : $validator,
491✔
651
            );
491✔
652

653
            if (
654
                !is_a(
491✔
655
                    $validator->getCompositionProcessor(),
491✔
656
                    ComposedPropertiesValidatorFactoryInterface::class,
491✔
657
                    true,
491✔
658
                )
491✔
659
            ) {
660
                continue;
3✔
661
            }
662

663
            $branchesForValidator = $validator instanceof ConditionalPropertyValidator
488✔
664
                ? $validator->getConditionBranches()
65✔
665
                : $validator->getComposedProperties();
441✔
666

667
            $totalBranches = count($branchesForValidator);
488✔
668
            $resolvedPropertiesCallbacks = 0;
488✔
669
            $seenBranchPropertyNames = [];
488✔
670

671
            foreach ($validator->getComposedProperties() as $branchIndex => $composedProperty) {
488✔
672
                $composedProperty->onResolve(function () use (
487✔
673
                    $branchIndex,
487✔
674
                    $composedProperty,
487✔
675
                    $property,
487✔
676
                    $validator,
487✔
677
                    $branchesForValidator,
487✔
678
                    $totalBranches,
487✔
679
                    $schema,
487✔
680
                    &$resolvedPropertiesCallbacks,
487✔
681
                    &$seenBranchPropertyNames,
487✔
682
                ): void {
487✔
683
                    if (!$composedProperty->getNestedSchema()) {
487✔
684
                        if ($composedProperty->getType() !== null) {
2✔
685
                            throw new SchemaException(
1✔
686
                                sprintf(
1✔
687
                                    "No nested schema for composed property %s in file %s found",
1✔
688
                                    $property->getName(),
1✔
689
                                    $property->getJsonSchema()->getFile(),
1✔
690
                                ),
1✔
691
                                $property->getJsonSchema(),
1✔
692
                            );
1✔
693
                        }
694

695
                        // A branch with neither a nested schema nor an explicit type (e.g. a $ref
696
                        // to a definition carrying only annotation keywords such as example)
697
                        // matches any value and contributes no named properties to transfer -
698
                        // this is not a schema error, unlike a branch with an explicit type that
699
                        // still lacks a nested schema (a genuine type conflict, handled above).
700
                        $this->finalizeComposedBranchResolution(
1✔
701
                            in_array($composedProperty, $branchesForValidator, true),
1✔
702
                            $totalBranches,
1✔
703
                            $resolvedPropertiesCallbacks,
1✔
704
                            $seenBranchPropertyNames,
1✔
705
                            $validator,
1✔
706
                            $property,
1✔
707
                            $schema,
1✔
708
                        );
1✔
709

710
                        return;
1✔
711
                    }
712

713
                    $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true);
486✔
714

715
                    $composedProperty->getNestedSchema()->onAllPropertiesResolved(
486✔
716
                        function () use (
486✔
717
                            $branchIndex,
486✔
718
                            $composedProperty,
486✔
719
                            $property,
486✔
720
                            $validator,
486✔
721
                            $isBranchForValidator,
486✔
722
                            $totalBranches,
486✔
723
                            $schema,
486✔
724
                            &$resolvedPropertiesCallbacks,
486✔
725
                            &$seenBranchPropertyNames,
486✔
726
                        ): void {
486✔
727
                            $branchLabel = $this->getCompositionBranchLabel(
486✔
728
                                $validator,
486✔
729
                                $composedProperty,
486✔
730
                                $branchIndex,
486✔
731
                            );
486✔
732

733
                            foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) {
486✔
734
                                if ($branchLabel !== null) {
482✔
735
                                    $this->checkRootBranchDefaultConflict(
482✔
736
                                        $branchProperty,
482✔
737
                                        $branchLabel,
482✔
738
                                        $validator,
482✔
739
                                        $schema,
482✔
740
                                    );
482✔
741
                                }
742

743
                                $schema->addProperty(
481✔
744
                                    $this->cloneTransferredProperty(
481✔
745
                                        $branchProperty,
481✔
746
                                        $composedProperty,
481✔
747
                                        $validator,
481✔
748
                                    ),
481✔
749
                                    $validator->getCompositionProcessor(),
481✔
750
                                );
481✔
751

752
                                $composedProperty->appendAffectedObjectProperty($branchProperty);
479✔
753
                                $seenBranchPropertyNames[$branchProperty->getName()] = true;
479✔
754
                            }
755

756
                            $this->finalizeComposedBranchResolution(
483✔
757
                                $isBranchForValidator,
483✔
758
                                $totalBranches,
483✔
759
                                $resolvedPropertiesCallbacks,
483✔
760
                                $seenBranchPropertyNames,
483✔
761
                                $validator,
483✔
762
                                $property,
483✔
763
                                $schema,
483✔
764
                            );
483✔
765
                        },
486✔
766
                    );
486✔
767
                });
487✔
768
            }
769
        }
770
    }
771

772
    /**
773
     * Run the once-per-composition cross-branch checks and property-attribute synthesis after the
774
     * final branch of a composed property has finished contributing its properties to the schema -
775
     * whether by transferring named properties from a nested schema, or by contributing none
776
     * because the branch has neither a nested schema nor an explicit type.
777
     *
778
     * @param array<string, true> $seenBranchPropertyNames
779
     */
780
    private function finalizeComposedBranchResolution(
483✔
781
        bool $isBranchForValidator,
782
        int $totalBranches,
783
        int &$resolvedPropertiesCallbacks,
784
        array $seenBranchPropertyNames,
785
        AbstractComposedPropertyValidator $validator,
786
        PropertyInterface $property,
787
        Schema $schema,
788
    ): void {
789
        if (!$isBranchForValidator || ++$resolvedPropertiesCallbacks !== $totalBranches) {
483✔
790
            return;
440✔
791
        }
792

793
        foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) {
477✔
794
            $schema->getPropertyMerger()->checkForTotalConflict(
473✔
795
                $branchPropertyName,
473✔
796
                $totalBranches,
473✔
797
                $schema->getJsonSchema(),
473✔
798
            );
473✔
799
        }
800

801
        $this->checkCrossBranchDefaultConflicts($validator, $property);
474✔
802

803
        $this->propertyAttributeSynthesizer->synthesiseForValidator($validator, $schema, $seenBranchPropertyNames);
472✔
804
    }
805

806
    /**
807
     * Clone the provided property to transfer it to a schema. Sets the nullability and required
808
     * flag based on the composition processor used to set up the composition. Widens the type to
809
     * mixed when the property is exclusive to one anyOf/oneOf branch and at least one other branch
810
     * allows additional properties, preventing TypeError when raw input values of an arbitrary
811
     * type are stored in the property slot.
812
     */
813
    private function cloneTransferredProperty(
481✔
814
        PropertyInterface $property,
815
        CompositionPropertyDecorator $sourceBranch,
816
        AbstractComposedPropertyValidator $validator,
817
    ): PropertyInterface {
818
        $compositionProcessor = $validator->getCompositionProcessor();
481✔
819

820
        $transferredProperty = (clone $property)
481✔
821
            ->filterValidators(static fn(Validator $v): bool =>
481✔
822
                is_a($v->getValidator(), PropertyTemplateValidator::class))
481✔
823
            ->setDefaultValue(null)
481✔
824
            ->filterDecorators(static fn($decorator): bool =>
481✔
825
                !($decorator instanceof DefaultArrayToEmptyArrayDecorator));
481✔
826

827
        if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) {
481✔
828
            $transferredProperty->setRequired(false);
298✔
829

830
            if ($transferredProperty->getType()) {
298✔
831
                $transferredProperty->setType(
273✔
832
                    new PropertyType($transferredProperty->getType()->getNames(), true),
273✔
833
                    new PropertyType($transferredProperty->getType(true)->getNames(), true),
273✔
834
                );
273✔
835
            }
836

837
            $wideningBranches = $validator instanceof ConditionalPropertyValidator
298✔
838
                ? $validator->getConditionBranches()
65✔
839
                : $validator->getComposedProperties();
233✔
840

841
            if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) {
298✔
842
                $transferredProperty->setType(null, null, reset: true);
212✔
843
            }
844

845
            // Blank the branch schema for disjunctive compositions (anyOf/oneOf/if): a constraint
846
            // declared on one branch (e.g. an enum on a single oneOf branch while another branch
847
            // accepts free-form values) must not leak onto the outer merged property, which a
848
            // value may satisfy via a different branch. For allOf every branch constraint applies
849
            // to the same value, so the schema is preserved instead - keeping representation-level
850
            // information such as enum sub-schemas visible to post processors on the merged
851
            // property, matching the dedicated _Merged_ class path.
852
            $transferredProperty->setJsonSchema($transferredProperty->getJsonSchema()->withJson([]));
298✔
853
        }
854

855
        return $transferredProperty;
481✔
856
    }
857

858
    /**
859
     * Returns true when the property named $propertyName is exclusive to $sourceBranch and at
860
     * least one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare
861
     * additionalProperties: false). In that case the property slot can receive an
862
     * arbitrarily-typed raw input value from a non-matching branch, so the type hint is removed.
863
     *
864
     * Returns false when the property appears in another branch too (Schema::addProperty handles
865
     * that via type merging) or when all other branches have additionalProperties: false (making
866
     * the property mutually exclusive with the other branches' properties).
867
     *
868
     * @param CompositionPropertyDecorator[] $allBranches
869
     */
870
    private function exclusiveBranchPropertyNeedsWidening(
298✔
871
        string $propertyName,
872
        CompositionPropertyDecorator $sourceBranch,
873
        array $allBranches,
874
    ): bool {
875
        foreach ($allBranches as $branch) {
298✔
876
            if ($branch === $sourceBranch) {
298✔
877
                continue;
296✔
878
            }
879

880
            $branchPropertyNames = $branch->getNestedSchema()
296✔
881
                ? array_map(
295✔
882
                    static fn(PropertyInterface $p): string => $p->getName(),
295✔
883
                    $branch->getNestedSchema()->getProperties(),
295✔
884
                )
295✔
885
                : [];
1✔
886

887
            if (in_array($propertyName, $branchPropertyNames, true)) {
296✔
888
                return false;
134✔
889
            }
890
        }
891

892
        foreach ($allBranches as $branch) {
220✔
893
            if ($branch === $sourceBranch) {
220✔
894
                continue;
171✔
895
            }
896

897
            if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) {
218✔
898
                return true;
212✔
899
            }
900
        }
901

902
        return false;
25✔
903
    }
904

905
    /**
906
     * Throws a SchemaException when a branch property carries a non-null default that differs
907
     * from a root-registered property of the same name in the parent schema.
908
     *
909
     * After the branch-default fix, all transferred branch properties have their default cleared
910
     * to null before being added to the parent schema. Only root-level properties therefore carry
911
     * a non-null default. This invariant makes `getDefaultValue() !== null` a reliable proxy for
912
     * "this property was added at the root level with a meaningful default."
913
     *
914
     * Does NOT classify against output-space defaults — only the raw branch default (before
915
     * transfer clears it) is compared to the existing root default.
916
     *
917
     * @param string $branchLabel Human-readable branch identifier for the error message
918
     *                            (e.g. 'allOf/0', 'then', 'else').
919
     */
920
    private function checkRootBranchDefaultConflict(
482✔
921
        PropertyInterface $branchProperty,
922
        string $branchLabel,
923
        AbstractComposedPropertyValidator $validator,
924
        Schema $schema,
925
    ): void {
926
        $branchDefault = $branchProperty->getDefaultValue();
482✔
927

928
        if ($branchDefault !== null) {
482✔
929
            $existingDefault = $schema->getProperty($branchProperty->getName())?->getDefaultValue();
34✔
930

931
            if ($existingDefault !== null && $existingDefault !== $branchDefault) {
34✔
932
                throw new SchemaException(
5✔
933
                    sprintf(
5✔
934
                        "Conflicting default values for property '%s' under %s composition in file %s:"
5✔
935
                            . " root=%s, %s=%s.",
5✔
936
                        $branchProperty->getName(),
5✔
937
                        $this->getCompositionKeyword($validator),
5✔
938
                        $branchProperty->getJsonSchema()->getFile(),
5✔
939
                        $existingDefault,
5✔
940
                        $branchLabel,
5✔
941
                        $branchDefault,
5✔
942
                    ),
5✔
943
                    $branchProperty->getJsonSchema(),
5✔
944
                );
5✔
945
            }
946
        }
947

948
        // Check patternProperties defaults declared on the outer schema. Any pattern that matches
949
        // the branch property name either agrees with the branch default (fine) or conflicts with
950
        // it (SchemaException). When the branch property has no explicit default, the first
951
        // matching pattern default is propagated to it; subsequent patterns must agree.
952
        $patternProperties = $schema->getJsonSchema()->getJson()['patternProperties'] ?? [];
479✔
953
        $effectiveBranchDefault = $branchDefault;
479✔
954

955
        foreach ($patternProperties as $pattern => $patternSchema) {
479✔
956
            if (!isset($patternSchema['default'])) {
7✔
957
                continue;
4✔
958
            }
959

960
            if (!preg_match('/' . addcslashes($pattern, '/') . '/', $branchProperty->getName())) {
3✔
961
                continue;
3✔
962
            }
963

964
            $patternDefault = var_export($patternSchema['default'], true);
3✔
965

966
            if ($effectiveBranchDefault !== null && $effectiveBranchDefault !== $patternDefault) {
3✔
967
                throw new SchemaException(
1✔
968
                    sprintf(
1✔
969
                        "Conflicting default values for property '%s' under %s composition in file %s:"
1✔
970
                            . " pattern '%s'=%s, %s=%s.",
1✔
971
                        $branchProperty->getName(),
1✔
972
                        $this->getCompositionKeyword($validator),
1✔
973
                        $branchProperty->getJsonSchema()->getFile(),
1✔
974
                        $pattern,
1✔
975
                        $patternDefault,
1✔
976
                        $branchLabel,
1✔
977
                        $effectiveBranchDefault,
1✔
978
                    ),
1✔
979
                    $branchProperty->getJsonSchema(),
1✔
980
                );
1✔
981
            }
982

983
            if ($effectiveBranchDefault === null) {
2✔
984
                $branchProperty->setDefaultValue($patternSchema['default']);
1✔
985
                $effectiveBranchDefault = $patternDefault;
1✔
986
            }
987
        }
988
    }
989

990
    /**
991
     * Returns a human-readable label for the given branch suitable for use in conflict error
992
     * messages, or null when the branch should be excluded from conflict checks.
993
     *
994
     * For if/then/else compositions the 'if' branch is purely a condition check and carries no
995
     * applied defaults, so it returns null. The 'then' and 'else' branches return their keyword.
996
     * For allOf / anyOf / oneOf the label is '<keyword>/<branchIndex>'.
997
     */
998
    private function getCompositionBranchLabel(
486✔
999
        AbstractComposedPropertyValidator $validator,
1000
        CompositionPropertyDecorator $composedProperty,
1001
        int $branchIndex,
1002
    ): ?string {
1003
        if (!($validator instanceof ConditionalPropertyValidator)) {
486✔
1004
            return $this->getCompositionKeyword($validator) . '/' . $branchIndex;
439✔
1005
        }
1006

1007
        $conditionBranches = $validator->getConditionBranches();
65✔
1008
        $conditionIndex = array_search($composedProperty, $conditionBranches, true);
65✔
1009

1010
        if ($conditionIndex === false) {
65✔
1011
            // This is the 'if' branch — it applies no defaults, so skip it.
1012
            return null;
65✔
1013
        }
1014

1015
        // Use object identity rather than the filtered-array index. When only an else branch
1016
        // is present (no then), it occupies index 0 in conditionBranches after array_values,
1017
        // so an index-based check would incorrectly return 'then' for the else branch.
1018
        return $composedProperty === $validator->getThenBranch() ? 'then' : 'else';
65✔
1019
    }
1020

1021
    /**
1022
     * Checks all composition branches for properties with conflicting non-null default values.
1023
     * Only meaningful for allOf and anyOf, where multiple branches can match simultaneously —
1024
     * for oneOf and if/then/else at most one branch matches, so per-branch defaults cannot
1025
     * conflict at runtime and this check is skipped.
1026
     */
1027
    private function checkCrossBranchDefaultConflicts(
474✔
1028
        AbstractComposedPropertyValidator $validator,
1029
        PropertyInterface $compositionProperty,
1030
    ): void {
1031
        // Only allOf and anyOf can have multiple simultaneously-matching branches.
1032
        // For oneOf and if/then/else exactly one branch matches, so different per-branch
1033
        // defaults are not a conflict.
1034
        if (
1035
            !is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true)
474✔
1036
            && !is_a($validator->getCompositionProcessor(), AnyOfValidatorFactory::class, true)
474✔
1037
        ) {
1038
            return;
187✔
1039
        }
1040

1041
        $keyword = $this->getCompositionKeyword($validator);
305✔
1042

1043
        /** @var array<string, array<int, mixed>> $defaultsByProperty Property name → [branchIndex => default] */
1044
        $defaultsByProperty = [];
305✔
1045

1046
        foreach ($validator->getComposedProperties() as $branchIndex => $composedBranch) {
305✔
1047
            $nestedSchema = $composedBranch->getNestedSchema();
305✔
1048
            if ($nestedSchema === null) {
305✔
UNCOV
1049
                continue;
×
1050
            }
1051

1052
            foreach ($nestedSchema->getProperties() as $branchProperty) {
305✔
1053
                $branchDefault = $branchProperty->getDefaultValue();
301✔
1054
                if ($branchDefault !== null) {
301✔
1055
                    $defaultsByProperty[$branchProperty->getName()][$branchIndex] = $branchDefault;
14✔
1056
                }
1057
            }
1058
        }
1059

1060
        foreach ($defaultsByProperty as $propertyName => $defaultsByBranch) {
305✔
1061
            $firstDefault = reset($defaultsByBranch);
14✔
1062
            $hasConflict = false;
14✔
1063
            foreach ($defaultsByBranch as $branchDefault) {
14✔
1064
                if ($branchDefault !== $firstDefault) {
14✔
1065
                    $hasConflict = true;
2✔
1066
                    break;
2✔
1067
                }
1068
            }
1069

1070
            if (!$hasConflict) {
14✔
1071
                continue;
12✔
1072
            }
1073

1074
            $sites = [];
2✔
1075
            foreach ($defaultsByBranch as $branchIndex => $branchDefault) {
2✔
1076
                $sites[] = sprintf('%s/%d=%s', $keyword, $branchIndex, $branchDefault);
2✔
1077
            }
1078

1079
            throw new SchemaException(
2✔
1080
                sprintf(
2✔
1081
                    "Conflicting default values for property '%s' under %s composition in file %s: %s.",
2✔
1082
                    $propertyName,
2✔
1083
                    $keyword,
2✔
1084
                    $compositionProperty->getJsonSchema()->getFile(),
2✔
1085
                    implode(', ', $sites),
2✔
1086
                ),
2✔
1087
                $compositionProperty->getJsonSchema(),
2✔
1088
            );
2✔
1089
        }
1090
    }
1091

1092
    private function getCompositionKeyword(AbstractComposedPropertyValidator $validator): string
441✔
1093
    {
1094
        return match (true) {
1095
            is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true) => 'allOf',
441✔
1096
            is_a($validator->getCompositionProcessor(), AnyOfValidatorFactory::class, true) => 'anyOf',
237✔
1097
            is_a($validator->getCompositionProcessor(), OneOfValidatorFactory::class, true) => 'oneOf',
130✔
1098
            default                                                                          => 'if/then/else',
441✔
1099
        };
1100
    }
1101

1102
    private function getTargetFileName(string $classPath, string $className): string
2,867✔
1103
    {
1104
        return join(
2,867✔
1105
            DIRECTORY_SEPARATOR,
2,867✔
1106
            array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]),
2,867✔
1107
        ) . '.php';
2,867✔
1108
    }
1109
}
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