• 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

96.82
/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\PropertyFactory;
31
use PHPModelGenerator\SchemaProvider\SchemaProviderInterface;
32
use PHPModelGenerator\Utils\PropertyAttributeSynthesizer;
33

34
class SchemaProcessor
35
{
36
    protected string $currentClassPath;
37
    protected string $currentClassName;
38

39
    private PropertyAttributeSynthesizer $propertyAttributeSynthesizer;
40

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

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

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

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

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

123
        return $this->generateModel($classPath, $className, $jsonSchema, $dictionary, $initialClass);
2,713✔
124
    }
125

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

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

150
            return $this->processedSchema[$schemaSignature];
114✔
151
        }
152

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

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

178
        // Register by content signature (secondary dedup for content-identical inline schemas).
179
        $this->processedSchema[$schemaSignature] = $schema;
2,713✔
180
        // Register by canonical file path/URL (primary dedup for external $ref resolutions).
181
        // Registering here — before property processing — ensures that any $ref back to this
182
        // file encountered while processing the referencing schema finds this canonical schema
183
        // immediately, regardless of which schema was discovered first by the provider.
184
        $this->registerProcessedFileSchema($jsonSchema->getFile(), $schema);
2,713✔
185
        $json = $jsonSchema->getJson();
2,713✔
186
        $json['type'] = 'base';
2,713✔
187

188
        (new PropertyFactory())->create(
2,713✔
189
            $this,
2,713✔
190
            $schema,
2,713✔
191
            $className,
2,713✔
192
            $jsonSchema->withJson($json),
2,713✔
193
        );
2,713✔
194

195
        $this->generateClassFile($schema);
2,594✔
196

197
        return $schema;
2,594✔
198
    }
199

200
    /**
201
     * Attach a new class file render job to the render proxy
202
     */
203
    public function generateClassFile(Schema $schema): void
2,594✔
204
    {
205
        $this->renderQueue->addRenderJob(new RenderJob($schema));
2,594✔
206

207
        $this->generatorConfiguration->getLogger()->info(
2,594✔
208
            'Generated class {class}',
2,594✔
209
            [
2,594✔
210
                'class' => join(
2,594✔
211
                    '\\',
2,594✔
212
                    array_filter([
2,594✔
213
                        $this->generatorConfiguration->getNamespacePrefix(),
2,594✔
214
                        $schema->getClassPath(),
2,594✔
215
                        $schema->getClassName(),
2,594✔
216
                    ]),
2,594✔
217
                ),
2,594✔
218
            ],
2,594✔
219
        );
2,594✔
220

221
        $this->generatedFiles[] = $schema->getTargetFileName();
2,594✔
222
    }
223

224

225
    /**
226
     * Gather all nested object properties and merge them together into a single merged property
227
     *
228
     * @param CompositionPropertyDecorator[] $compositionProperties
229
     *
230
     * @throws SchemaException
231
     */
232
    public function createMergedProperty(
223✔
233
        Schema $schema,
234
        PropertyInterface $property,
235
        array $compositionProperties,
236
        JsonSchema $propertySchema,
237
    ): ?PropertyInterface {
238
        $redirectToProperty = $this->redirectMergedProperty($compositionProperties);
223✔
239
        if ($redirectToProperty === null || $redirectToProperty instanceof PropertyInterface) {
223✔
240
            if ($redirectToProperty) {
163✔
241
                $property->addTypeHintDecorator(new CompositionTypeHintDecorator($redirectToProperty));
32✔
242
            }
243

244
            return $redirectToProperty;
163✔
245
        }
246

247
        /** @var JsonSchema $jsonSchema */
248
        $jsonSchema = $propertySchema->getJson()['propertySchema'];
60✔
249
        $schemaSignature = $jsonSchema->getSignature();
60✔
250

251
        if (!isset($this->processedMergedProperties[$schemaSignature])) {
60✔
252
            $mergedClassName = $this
60✔
253
                ->getGeneratorConfiguration()
60✔
254
                ->getClassNameGenerator()
60✔
255
                ->getClassName(
60✔
256
                    $property->getName(),
60✔
257
                    $propertySchema,
60✔
258
                    true,
60✔
259
                    $this->getCurrentClassName(),
60✔
260
                );
60✔
261

262
            $mergedPropertySchema = new Schema(
60✔
263
                $this->getTargetFileName($schema->getClassPath(), $mergedClassName),
60✔
264
                $schema->getClassPath(),
60✔
265
                $mergedClassName,
60✔
266
                $propertySchema,
60✔
267
            );
60✔
268

269
            $this->processedMergedProperties[$schemaSignature] = (new Property(
60✔
270
                'MergedProperty',
60✔
271
                new PropertyType($mergedClassName),
60✔
272
                $mergedPropertySchema->getJsonSchema(),
60✔
273
            ))
60✔
274
                ->addDecorator(new ObjectInstantiationDecorator($mergedClassName, $this->getGeneratorConfiguration()))
60✔
275
                ->setNestedSchema($mergedPropertySchema);
60✔
276

277
            $this->transferPropertiesToMergedSchema($schema, $mergedPropertySchema, $compositionProperties);
60✔
278

279
            // make sure the merged schema knows all imports of the parent schema
280
            $mergedPropertySchema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema));
60✔
281

282
            $this->generateClassFile($mergedPropertySchema);
60✔
283
        }
284

285
        $mergedSchema = $this->processedMergedProperties[$schemaSignature]->getNestedSchema();
60✔
286
        $schema->addUsedClass(
60✔
287
            join(
60✔
288
                '\\',
60✔
289
                array_filter([
60✔
290
                    $this->generatorConfiguration->getNamespacePrefix(),
60✔
291
                    $mergedSchema->getClassPath(),
60✔
292
                    $mergedSchema->getClassName(),
60✔
293
                ]),
60✔
294
            )
60✔
295
        );
60✔
296

297
        $property->addTypeHintDecorator(
60✔
298
            new CompositionTypeHintDecorator($this->processedMergedProperties[$schemaSignature]),
60✔
299
        );
60✔
300

301
        return $this->processedMergedProperties[$schemaSignature];
60✔
302
    }
303

304
    /**
305
     * Check if multiple $compositionProperties contain nested schemas. Only in this case a merged property must be
306
     * created. If no nested schemas are detected null will be returned. If only one $compositionProperty contains a
307
     * nested schema the $compositionProperty will be used as a replacement for the merged property.
308
     *
309
     * Returns false if a merged property must be created.
310
     *
311
     * @param CompositionPropertyDecorator[] $compositionProperties
312
     *
313
     * @return PropertyInterface|null|false
314
     */
315
    private function redirectMergedProperty(array $compositionProperties)
223✔
316
    {
317
        $redirectToProperty = null;
223✔
318
        foreach ($compositionProperties as $property) {
223✔
319
            if ($property->getNestedSchema()) {
223✔
320
                if ($redirectToProperty !== null) {
92✔
321
                    return false;
60✔
322
                }
323

324
                $redirectToProperty = $property;
92✔
325
            }
326
        }
327

328
        return $redirectToProperty;
163✔
329
    }
330

331
    /**
332
     * @param PropertyInterface[] $compositionProperties
333
     */
334
    private function transferPropertiesToMergedSchema(
60✔
335
        Schema $schema,
336
        Schema $mergedPropertySchema,
337
        array $compositionProperties,
338
    ): void {
339
        foreach ($compositionProperties as $property) {
60✔
340
            if (!$property->getNestedSchema()) {
60✔
341
                continue;
26✔
342
            }
343

344
            $property->getNestedSchema()->onAllPropertiesResolved(
60✔
345
                function () use ($property, $schema, $mergedPropertySchema): void {
60✔
346
                    foreach ($property->getNestedSchema()->getProperties() as $nestedProperty) {
60✔
347
                        $mergedPropertySchema->addProperty(
59✔
348
                        // don't validate fields in merged properties. All fields were validated before
349
                        // corresponding to the defined constraints of the composition property.
350
                            (clone $nestedProperty)->filterValidators(static fn(): bool => false),
59✔
351
                        );
59✔
352
                    }
353
                },
60✔
354
            );
60✔
355
        }
356
    }
357

358
    /**
359
     * Get the class path out of the file path of a schema file
360
     */
361
    protected function setCurrentClassPath(string $jsonSchemaFile): void
2,713✔
362
    {
363
        $fileDir  = str_replace('\\', '/', dirname($jsonSchemaFile));
2,713✔
364
        $baseDir  = str_replace('\\', '/', $this->schemaProvider->getBaseDirectory());
2,713✔
365
        $relative = str_replace($baseDir, '', $fileDir);
2,713✔
366

367
        // If the file is outside the provider's base directory, str_replace leaves the absolute
368
        // path untouched. In that case fall back to using just the last directory component so
369
        // the generated class path stays sensible rather than encoding an absolute path.
370
        if ($relative === $fileDir) {
2,713✔
371
            $relative = basename($fileDir);
×
372
        }
373

374
        $pieces = array_map(
2,713✔
375
            static fn(string $directory): string => ucfirst((string) preg_replace('/\W/', '', $directory)),
2,713✔
376
            explode('/', $relative),
2,713✔
377
        );
2,713✔
378

379
        $this->currentClassPath = join('\\', array_filter($pieces));
2,713✔
380
    }
381

382
    public function getCurrentClassPath(): string
1,141✔
383
    {
384
        return $this->currentClassPath;
1,141✔
385
    }
386

387
    public function getCurrentClassName(): string
1,121✔
388
    {
389
        return $this->currentClassName;
1,121✔
390
    }
391

392
    public function getGeneratedFiles(): array
2,547✔
393
    {
394
        return $this->generatedFiles;
2,547✔
395
    }
396

397
    public function getGeneratorConfiguration(): GeneratorConfiguration
2,729✔
398
    {
399
        return $this->generatorConfiguration;
2,729✔
400
    }
401

402
    public function getSchemaProvider(): SchemaProviderInterface
216✔
403
    {
404
        return $this->schemaProvider;
216✔
405
    }
406

407
    public function getProcessedFileSchema(string $fileKey): ?Schema
210✔
408
    {
409
        return $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] ?? null;
210✔
410
    }
411

412
    public function registerProcessedFileSchema(string $fileKey, Schema $schema): void
2,713✔
413
    {
414
        $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] = $schema;
2,713✔
415
    }
416

417
    /**
418
     * Normalise a file path or URL to a consistent key for processedFileSchemas.
419
     * On Windows, RecursiveDirectoryIterator may produce backslash-separated paths while
420
     * RefResolverTrait produces forward-slash paths for the same file. Normalising to forward
421
     * slashes ensures the two representations map to the same key.
422
     */
423
    private function normaliseFileKey(string $fileKey): string
2,713✔
424
    {
425
        return str_replace('\\', '/', $fileKey);
2,713✔
426
    }
427

428
    /**
429
     * Process an external schema file with its canonical class name and path, exactly as
430
     * process() would, but without overwriting the current class path / class name context
431
     * (which belongs to the schema that triggered the $ref resolution).
432
     *
433
     * Returns the resulting Schema, or null if the file does not define an object/composition.
434
     *
435
     * @throws SchemaException
436
     */
437
    public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema
14✔
438
    {
439
        $savedClassPath  = $this->currentClassPath;
14✔
440
        $savedClassName  = $this->currentClassName;
14✔
441

442
        $this->setCurrentClassPath($jsonSchema->getFile());
14✔
443
        $this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
14✔
444
            str_ireplace('.json', '', basename($jsonSchema->getFile())),
14✔
445
            $jsonSchema,
14✔
446
            false,
14✔
447
        );
14✔
448

449
        $schema = $this->processSchema(
14✔
450
            $jsonSchema,
14✔
451
            $this->currentClassPath,
14✔
452
            $this->currentClassName,
14✔
453
            new SchemaDefinitionDictionary($jsonSchema),
14✔
454
            true,
14✔
455
        );
14✔
456

457
        $this->currentClassPath = $savedClassPath;
14✔
458
        $this->currentClassName = $savedClassName;
14✔
459

460
        return $schema;
14✔
461
    }
462

463
    /**
464
     * Transfer properties of composed properties to the given schema to offer a complete model
465
     * including all composed properties.
466
     *
467
     * This is an internal pipeline mechanic (Q5.1): not a JSON Schema keyword and therefore not
468
     * a Draft modifier. It is called as an explicit post-step from generateModel after all Draft
469
     * modifiers have run on the root-level BaseProperty.
470
     *
471
     * @throws SchemaException
472
     */
473
    public function transferComposedPropertiesToSchema(PropertyInterface $property, Schema $schema): void
2,595✔
474
    {
475
        foreach ($property->getValidators() as $validator) {
2,595✔
476
            $validator = $validator->getValidator();
498✔
477

478
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
498✔
479
                continue;
×
480
            }
481

482
            // If the transferred validator of the composed property is also a composed property
483
            // strip the nested composition validations from the added validator. The nested
484
            // composition will be validated in the object generated for the nested composition
485
            // which will be executed via an instantiation. Consequently, the validation must not
486
            // be executed in the outer composition.
487
            $schema->addBaseValidator(
498✔
488
                ($validator instanceof ComposedPropertyValidator)
498✔
489
                    ? $validator->withoutNestedCompositionValidation()
452✔
490
                    : $validator,
498✔
491
            );
498✔
492

493
            if (
494
                !is_a(
498✔
495
                    $validator->getCompositionProcessor(),
498✔
496
                    ComposedPropertiesValidatorFactoryInterface::class,
498✔
497
                    true,
498✔
498
                )
498✔
499
            ) {
500
                continue;
3✔
501
            }
502

503
            $branchesForValidator = $validator instanceof ConditionalPropertyValidator
495✔
504
                ? $validator->getConditionBranches()
64✔
505
                : $validator->getComposedProperties();
449✔
506

507
            $totalBranches = count($branchesForValidator);
495✔
508
            $resolvedPropertiesCallbacks = 0;
495✔
509
            $seenBranchPropertyNames = [];
495✔
510

511
            foreach ($validator->getComposedProperties() as $branchIndex => $composedProperty) {
495✔
512
                $composedProperty->onResolve(function () use (
494✔
513
                    $branchIndex,
494✔
514
                    $composedProperty,
494✔
515
                    $property,
494✔
516
                    $validator,
494✔
517
                    $branchesForValidator,
494✔
518
                    $totalBranches,
494✔
519
                    $schema,
494✔
520
                    &$resolvedPropertiesCallbacks,
494✔
521
                    &$seenBranchPropertyNames,
494✔
522
                ): void {
494✔
523
                    if (!$composedProperty->getNestedSchema()) {
494✔
524
                        if ($composedProperty->getType() !== null) {
1✔
525
                            throw new SchemaException(
1✔
526
                                sprintf(
1✔
527
                                    "No nested schema for composed property %s in file %s found",
1✔
528
                                    $property->getName(),
1✔
529
                                    $property->getJsonSchema()->getFile(),
1✔
530
                                ),
1✔
531
                                $property->getJsonSchema(),
1✔
532
                            );
1✔
533
                        }
534

535
                        // A branch with neither a nested schema nor an explicit type (e.g. a $ref
536
                        // to a definition carrying only annotation keywords such as example)
537
                        // matches any value and contributes no named properties to transfer -
538
                        // this is not a schema error, unlike a branch with an explicit type that
539
                        // still lacks a nested schema (a genuine type conflict, handled above).
NEW
540
                        $this->finalizeComposedBranchResolution(
×
NEW
541
                            in_array($composedProperty, $branchesForValidator, true),
×
NEW
542
                            $totalBranches,
×
NEW
543
                            $resolvedPropertiesCallbacks,
×
NEW
544
                            $seenBranchPropertyNames,
×
NEW
545
                            $validator,
×
NEW
546
                            $property,
×
NEW
547
                            $schema,
×
UNCOV
548
                        );
×
549

NEW
550
                        return;
×
551
                    }
552

553
                    $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true);
493✔
554

555
                    $composedProperty->getNestedSchema()->onAllPropertiesResolved(
493✔
556
                        function () use (
493✔
557
                            $branchIndex,
493✔
558
                            $composedProperty,
493✔
559
                            $property,
493✔
560
                            $validator,
493✔
561
                            $isBranchForValidator,
493✔
562
                            $totalBranches,
493✔
563
                            $schema,
493✔
564
                            &$resolvedPropertiesCallbacks,
493✔
565
                            &$seenBranchPropertyNames,
493✔
566
                        ): void {
493✔
567
                            $branchLabel = $this->getCompositionBranchLabel(
493✔
568
                                $validator,
493✔
569
                                $composedProperty,
493✔
570
                                $branchIndex,
493✔
571
                            );
493✔
572

573
                            foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) {
493✔
574
                                if ($branchLabel !== null) {
489✔
575
                                    $this->checkRootBranchDefaultConflict(
489✔
576
                                        $branchProperty,
489✔
577
                                        $branchLabel,
489✔
578
                                        $validator,
489✔
579
                                        $schema,
489✔
580
                                    );
489✔
581
                                }
582

583
                                $schema->addProperty(
488✔
584
                                    $this->cloneTransferredProperty(
488✔
585
                                        $branchProperty,
488✔
586
                                        $composedProperty,
488✔
587
                                        $validator,
488✔
588
                                    ),
488✔
589
                                    $validator->getCompositionProcessor(),
488✔
590
                                );
488✔
591

592
                                $composedProperty->appendAffectedObjectProperty($branchProperty);
486✔
593
                                $seenBranchPropertyNames[$branchProperty->getName()] = true;
486✔
594
                            }
595

596
                            $this->finalizeComposedBranchResolution(
490✔
597
                                $isBranchForValidator,
490✔
598
                                $totalBranches,
490✔
599
                                $resolvedPropertiesCallbacks,
490✔
600
                                $seenBranchPropertyNames,
490✔
601
                                $validator,
490✔
602
                                $property,
490✔
603
                                $schema,
490✔
604
                            );
490✔
605
                        },
493✔
606
                    );
493✔
607
                });
494✔
608
            }
609
        }
610
    }
611

612
    /**
613
     * Run the once-per-composition cross-branch checks and property-attribute synthesis after the
614
     * final branch of a composed property has finished contributing its properties to the schema -
615
     * whether by transferring named properties from a nested schema, or by contributing none
616
     * because the branch has neither a nested schema nor an explicit type.
617
     *
618
     * @param array<string, true> $seenBranchPropertyNames
619
     */
620
    private function finalizeComposedBranchResolution(
490✔
621
        bool $isBranchForValidator,
622
        int $totalBranches,
623
        int &$resolvedPropertiesCallbacks,
624
        array $seenBranchPropertyNames,
625
        AbstractComposedPropertyValidator $validator,
626
        PropertyInterface $property,
627
        Schema $schema,
628
    ): void {
629
        if (!$isBranchForValidator || ++$resolvedPropertiesCallbacks !== $totalBranches) {
490✔
630
            return;
439✔
631
        }
632

633
        foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) {
484✔
634
            $schema->getPropertyMerger()->checkForTotalConflict(
480✔
635
                $branchPropertyName,
480✔
636
                $totalBranches,
480✔
637
                $schema->getJsonSchema(),
480✔
638
            );
480✔
639
        }
640

641
        $this->checkCrossBranchDefaultConflicts($validator, $property);
481✔
642

643
        $this->propertyAttributeSynthesizer->synthesiseForValidator($validator, $schema, $seenBranchPropertyNames);
479✔
644
    }
645

646
    /**
647
     * Clone the provided property to transfer it to a schema. Sets the nullability and required
648
     * flag based on the composition processor used to set up the composition. Widens the type to
649
     * mixed when the property is exclusive to one anyOf/oneOf branch and at least one other branch
650
     * allows additional properties, preventing TypeError when raw input values of an arbitrary
651
     * type are stored in the property slot.
652
     */
653
    private function cloneTransferredProperty(
488✔
654
        PropertyInterface $property,
655
        CompositionPropertyDecorator $sourceBranch,
656
        AbstractComposedPropertyValidator $validator,
657
    ): PropertyInterface {
658
        $compositionProcessor = $validator->getCompositionProcessor();
488✔
659

660
        $transferredProperty = (clone $property)
488✔
661
            ->filterValidators(static fn(Validator $v): bool =>
488✔
662
                is_a($v->getValidator(), PropertyTemplateValidator::class))
488✔
663
            ->setDefaultValue(null)
488✔
664
            ->filterDecorators(static fn($decorator): bool =>
488✔
665
                !($decorator instanceof DefaultArrayToEmptyArrayDecorator));
488✔
666

667
        if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) {
488✔
668
            $transferredProperty->setRequired(false);
286✔
669

670
            if ($transferredProperty->getType()) {
286✔
671
                $transferredProperty->setType(
261✔
672
                    new PropertyType($transferredProperty->getType()->getNames(), true),
261✔
673
                    new PropertyType($transferredProperty->getType(true)->getNames(), true),
261✔
674
                );
261✔
675
            }
676

677
            $wideningBranches = $validator instanceof ConditionalPropertyValidator
286✔
678
                ? $validator->getConditionBranches()
64✔
679
                : $validator->getComposedProperties();
222✔
680

681
            if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) {
286✔
682
                $transferredProperty->setType(null, null, reset: true);
200✔
683
            }
684

685
            // Blank the branch schema for disjunctive compositions (anyOf/oneOf/if): a constraint
686
            // declared on one branch (e.g. an enum on a single oneOf branch while another branch
687
            // accepts free-form values) must not leak onto the outer merged property, which a
688
            // value may satisfy via a different branch. For allOf every branch constraint applies
689
            // to the same value, so the schema is preserved instead - keeping representation-level
690
            // information such as enum sub-schemas visible to post processors on the merged
691
            // property, matching the dedicated _Merged_ class path.
692
            $transferredProperty->setJsonSchema($transferredProperty->getJsonSchema()->withJson([]));
286✔
693
        }
694

695
        return $transferredProperty;
488✔
696
    }
697

698
    /**
699
     * Returns true when the property named $propertyName is exclusive to $sourceBranch and at
700
     * least one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare
701
     * additionalProperties: false). In that case the property slot can receive an
702
     * arbitrarily-typed raw input value from a non-matching branch, so the type hint is removed.
703
     *
704
     * Returns false when the property appears in another branch too (Schema::addProperty handles
705
     * that via type merging) or when all other branches have additionalProperties: false (making
706
     * the property mutually exclusive with the other branches' properties).
707
     *
708
     * @param CompositionPropertyDecorator[] $allBranches
709
     */
710
    private function exclusiveBranchPropertyNeedsWidening(
286✔
711
        string $propertyName,
712
        CompositionPropertyDecorator $sourceBranch,
713
        array $allBranches,
714
    ): bool {
715
        foreach ($allBranches as $branch) {
286✔
716
            if ($branch === $sourceBranch) {
286✔
717
                continue;
284✔
718
            }
719

720
            $branchPropertyNames = $branch->getNestedSchema()
284✔
721
                ? array_map(
284✔
722
                    static fn(PropertyInterface $p): string => $p->getName(),
284✔
723
                    $branch->getNestedSchema()->getProperties(),
284✔
724
                )
284✔
725
                : [];
×
726

727
            if (in_array($propertyName, $branchPropertyNames, true)) {
284✔
728
                return false;
133✔
729
            }
730
        }
731

732
        foreach ($allBranches as $branch) {
208✔
733
            if ($branch === $sourceBranch) {
208✔
734
                continue;
159✔
735
            }
736

737
            if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) {
206✔
738
                return true;
200✔
739
            }
740
        }
741

742
        return false;
25✔
743
    }
744

745
    /**
746
     * Throws a SchemaException when a branch property carries a non-null default that differs
747
     * from a root-registered property of the same name in the parent schema.
748
     *
749
     * After the branch-default fix, all transferred branch properties have their default cleared
750
     * to null before being added to the parent schema. Only root-level properties therefore carry
751
     * a non-null default. This invariant makes `getDefaultValue() !== null` a reliable proxy for
752
     * "this property was added at the root level with a meaningful default."
753
     *
754
     * Does NOT classify against output-space defaults — only the raw branch default (before
755
     * transfer clears it) is compared to the existing root default.
756
     *
757
     * @param string $branchLabel Human-readable branch identifier for the error message
758
     *                            (e.g. 'allOf/0', 'then', 'else').
759
     */
760
    private function checkRootBranchDefaultConflict(
489✔
761
        PropertyInterface $branchProperty,
762
        string $branchLabel,
763
        AbstractComposedPropertyValidator $validator,
764
        Schema $schema,
765
    ): void {
766
        $branchDefault = $branchProperty->getDefaultValue();
489✔
767

768
        if ($branchDefault !== null) {
489✔
769
            $existingDefault = $schema->getProperty($branchProperty->getName())?->getDefaultValue();
34✔
770

771
            if ($existingDefault !== null && $existingDefault !== $branchDefault) {
34✔
772
                throw new SchemaException(
5✔
773
                    sprintf(
5✔
774
                        "Conflicting default values for property '%s' under %s composition in file %s:"
5✔
775
                            . " root=%s, %s=%s.",
5✔
776
                        $branchProperty->getName(),
5✔
777
                        $this->getCompositionKeyword($validator),
5✔
778
                        $branchProperty->getJsonSchema()->getFile(),
5✔
779
                        $existingDefault,
5✔
780
                        $branchLabel,
5✔
781
                        $branchDefault,
5✔
782
                    ),
5✔
783
                    $branchProperty->getJsonSchema(),
5✔
784
                );
5✔
785
            }
786
        }
787

788
        // Check patternProperties defaults declared on the outer schema. Any pattern that matches
789
        // the branch property name either agrees with the branch default (fine) or conflicts with
790
        // it (SchemaException). When the branch property has no explicit default, the first
791
        // matching pattern default is propagated to it; subsequent patterns must agree.
792
        $patternProperties = $schema->getJsonSchema()->getJson()['patternProperties'] ?? [];
486✔
793
        $effectiveBranchDefault = $branchDefault;
486✔
794

795
        foreach ($patternProperties as $pattern => $patternSchema) {
486✔
796
            if (!isset($patternSchema['default'])) {
7✔
797
                continue;
4✔
798
            }
799

800
            if (!preg_match('/' . addcslashes($pattern, '/') . '/', $branchProperty->getName())) {
3✔
801
                continue;
3✔
802
            }
803

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

806
            if ($effectiveBranchDefault !== null && $effectiveBranchDefault !== $patternDefault) {
3✔
807
                throw new SchemaException(
1✔
808
                    sprintf(
1✔
809
                        "Conflicting default values for property '%s' under %s composition in file %s:"
1✔
810
                            . " pattern '%s'=%s, %s=%s.",
1✔
811
                        $branchProperty->getName(),
1✔
812
                        $this->getCompositionKeyword($validator),
1✔
813
                        $branchProperty->getJsonSchema()->getFile(),
1✔
814
                        $pattern,
1✔
815
                        $patternDefault,
1✔
816
                        $branchLabel,
1✔
817
                        $effectiveBranchDefault,
1✔
818
                    ),
1✔
819
                    $branchProperty->getJsonSchema(),
1✔
820
                );
1✔
821
            }
822

823
            if ($effectiveBranchDefault === null) {
2✔
824
                $branchProperty->setDefaultValue($patternSchema['default']);
1✔
825
                $effectiveBranchDefault = $patternDefault;
1✔
826
            }
827
        }
828
    }
829

830
    /**
831
     * Returns a human-readable label for the given branch suitable for use in conflict error
832
     * messages, or null when the branch should be excluded from conflict checks.
833
     *
834
     * For if/then/else compositions the 'if' branch is purely a condition check and carries no
835
     * applied defaults, so it returns null. The 'then' and 'else' branches return their keyword.
836
     * For allOf / anyOf / oneOf the label is '<keyword>/<branchIndex>'.
837
     */
838
    private function getCompositionBranchLabel(
493✔
839
        AbstractComposedPropertyValidator $validator,
840
        CompositionPropertyDecorator $composedProperty,
841
        int $branchIndex,
842
    ): ?string {
843
        if (!($validator instanceof ConditionalPropertyValidator)) {
493✔
844
            return $this->getCompositionKeyword($validator) . '/' . $branchIndex;
447✔
845
        }
846

847
        $conditionBranches = $validator->getConditionBranches();
64✔
848
        $conditionIndex = array_search($composedProperty, $conditionBranches, true);
64✔
849

850
        if ($conditionIndex === false) {
64✔
851
            // This is the 'if' branch — it applies no defaults, so skip it.
852
            return null;
64✔
853
        }
854

855
        // Use object identity rather than the filtered-array index. When only an else branch
856
        // is present (no then), it occupies index 0 in conditionBranches after array_values,
857
        // so an index-based check would incorrectly return 'then' for the else branch.
858
        return $composedProperty === $validator->getThenBranch() ? 'then' : 'else';
64✔
859
    }
860

861
    /**
862
     * Checks all composition branches for properties with conflicting non-null default values.
863
     * Only meaningful for allOf and anyOf, where multiple branches can match simultaneously —
864
     * for oneOf and if/then/else at most one branch matches, so per-branch defaults cannot
865
     * conflict at runtime and this check is skipped.
866
     */
867
    private function checkCrossBranchDefaultConflicts(
481✔
868
        AbstractComposedPropertyValidator $validator,
869
        PropertyInterface $compositionProperty,
870
    ): void {
871
        // Only allOf and anyOf can have multiple simultaneously-matching branches.
872
        // For oneOf and if/then/else exactly one branch matches, so different per-branch
873
        // defaults are not a conflict.
874
        if (
875
            !is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true)
481✔
876
            && !is_a($validator->getCompositionProcessor(), AnyOfValidatorFactory::class, true)
481✔
877
        ) {
878
            return;
175✔
879
        }
880

881
        $keyword = $this->getCompositionKeyword($validator);
324✔
882

883
        /** @var array<string, array<int, mixed>> $defaultsByProperty Property name → [branchIndex => default] */
884
        $defaultsByProperty = [];
324✔
885

886
        foreach ($validator->getComposedProperties() as $branchIndex => $composedBranch) {
324✔
887
            $nestedSchema = $composedBranch->getNestedSchema();
324✔
888
            if ($nestedSchema === null) {
324✔
889
                continue;
×
890
            }
891

892
            foreach ($nestedSchema->getProperties() as $branchProperty) {
324✔
893
                $branchDefault = $branchProperty->getDefaultValue();
320✔
894
                if ($branchDefault !== null) {
320✔
895
                    $defaultsByProperty[$branchProperty->getName()][$branchIndex] = $branchDefault;
14✔
896
                }
897
            }
898
        }
899

900
        foreach ($defaultsByProperty as $propertyName => $defaultsByBranch) {
324✔
901
            $firstDefault = reset($defaultsByBranch);
14✔
902
            $hasConflict = false;
14✔
903
            foreach ($defaultsByBranch as $branchDefault) {
14✔
904
                if ($branchDefault !== $firstDefault) {
14✔
905
                    $hasConflict = true;
2✔
906
                    break;
2✔
907
                }
908
            }
909

910
            if (!$hasConflict) {
14✔
911
                continue;
12✔
912
            }
913

914
            $sites = [];
2✔
915
            foreach ($defaultsByBranch as $branchIndex => $branchDefault) {
2✔
916
                $sites[] = sprintf('%s/%d=%s', $keyword, $branchIndex, $branchDefault);
2✔
917
            }
918

919
            throw new SchemaException(
2✔
920
                sprintf(
2✔
921
                    "Conflicting default values for property '%s' under %s composition in file %s: %s.",
2✔
922
                    $propertyName,
2✔
923
                    $keyword,
2✔
924
                    $compositionProperty->getJsonSchema()->getFile(),
2✔
925
                    implode(', ', $sites),
2✔
926
                ),
2✔
927
                $compositionProperty->getJsonSchema(),
2✔
928
            );
2✔
929
        }
930
    }
931

932
    private function getCompositionKeyword(AbstractComposedPropertyValidator $validator): string
449✔
933
    {
934
        return match (true) {
935
            is_a($validator->getCompositionProcessor(), AllOfValidatorFactory::class, true) => 'allOf',
449✔
936
            is_a($validator->getCompositionProcessor(), AnyOfValidatorFactory::class, true) => 'anyOf',
226✔
937
            is_a($validator->getCompositionProcessor(), OneOfValidatorFactory::class, true) => 'oneOf',
119✔
938
            default                                                                          => 'if/then/else',
449✔
939
        };
940
    }
941

942
    private function getTargetFileName(string $classPath, string $className): string
2,713✔
943
    {
944
        return join(
2,713✔
945
            DIRECTORY_SEPARATOR,
2,713✔
946
            array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]),
2,713✔
947
        ) . '.php';
2,713✔
948
    }
949
}
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