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

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

03 Jun 2026 08:37PM UTC coverage: 98.72% (+0.006%) from 98.714%
26912113495

push

github

wol-soft
Merge remote-tracking branch 'origin/master'

157 of 168 new or added lines in 9 files covered. (93.45%)

42 existing lines in 5 files now uncovered.

6172 of 6252 relevant lines covered (98.72%)

574.49 hits per line

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

99.01
/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\ComposedPropertiesValidatorFactoryInterface;
24
use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator;
25
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
26
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
27
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
28
use PHPModelGenerator\SchemaProvider\SchemaProviderInterface;
29
use PHPModelGenerator\Utils\PropertyAttributeSynthesizer;
30

31
/**
32
 * Class SchemaProcessor
33
 *
34
 * @package PHPModelGenerator\SchemaProcessor
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,577✔
69
        protected SchemaProviderInterface $schemaProvider,
70
        protected string $destination,
71
        protected GeneratorConfiguration $generatorConfiguration,
72
        protected RenderQueue $renderQueue,
73
    ) {
74
        $this->propertyAttributeSynthesizer = new PropertyAttributeSynthesizer($generatorConfiguration);
2,577✔
75
    }
76

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

91
        $this->processSchema(
2,556✔
92
            $jsonSchema,
2,556✔
93
            $this->currentClassPath,
2,556✔
94
            $this->currentClassName,
2,556✔
95
            new SchemaDefinitionDictionary($jsonSchema),
2,556✔
96
            true,
2,556✔
97
        );
2,556✔
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,556✔
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,556✔
119
            !array_intersect(array_keys($jsonSchema->getJson()), ['anyOf', 'allOf', 'oneOf', 'if', '$ref'])
2,556✔
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,556✔
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,556✔
134
        string $classPath,
135
        string $className,
136
        JsonSchema $jsonSchema,
137
        SchemaDefinitionDictionary $dictionary,
138
        bool $initialClass,
139
    ): Schema {
140
        $schemaSignature = $jsonSchema->getSignature();
2,556✔
141

142
        if (!$initialClass && isset($this->processedSchema[$schemaSignature])) {
2,556✔
143
            if ($this->generatorConfiguration->isOutputEnabled()) {
104✔
144
                echo "Duplicated signature $schemaSignature for class $className." .
1✔
145
                    " Redirecting to {$this->processedSchema[$schemaSignature]->getClassName()}\n";
1✔
146
            }
147

148
            return $this->processedSchema[$schemaSignature];
104✔
149
        }
150

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

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

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

186
        (new PropertyFactory())->create(
2,556✔
187
            $this,
2,556✔
188
            $schema,
2,556✔
189
            $className,
2,556✔
190
            $jsonSchema->withJson($json),
2,556✔
191
        );
2,556✔
192

193
        $this->generateClassFile($schema);
2,434✔
194

195
        return $schema;
2,434✔
196
    }
197

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

205
        if ($this->generatorConfiguration->isOutputEnabled()) {
2,434✔
206
            echo sprintf(
2✔
207
                "Generated class %s\n",
2✔
208
                join(
2✔
209
                    '\\',
2✔
210
                    array_filter([
2✔
211
                        $this->generatorConfiguration->getNamespacePrefix(),
2✔
212
                        $schema->getClassPath(),
2✔
213
                        $schema->getClassName(),
2✔
214
                    ]),
2✔
215
                ),
2✔
216
            );
2✔
217
        }
218

219
        $this->generatedFiles[] = $schema->getTargetFileName();
2,434✔
220
    }
221

222

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

242
            return $redirectToProperty;
142✔
243
        }
244

245
        /** @var JsonSchema $jsonSchema */
246
        $jsonSchema = $propertySchema->getJson()['propertySchema'];
102✔
247
        $schemaSignature = $jsonSchema->getSignature();
102✔
248

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

260
            $mergedPropertySchema = new Schema(
102✔
261
                $this->getTargetFileName($schema->getClassPath(), $mergedClassName),
102✔
262
                $schema->getClassPath(),
102✔
263
                $mergedClassName,
102✔
264
                $propertySchema,
102✔
265
            );
102✔
266

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

275
            $this->transferPropertiesToMergedSchema($schema, $mergedPropertySchema, $compositionProperties);
102✔
276

277
            // make sure the merged schema knows all imports of the parent schema
278
            $mergedPropertySchema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema));
102✔
279

280
            $this->generateClassFile($mergedPropertySchema);
102✔
281
        }
282

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

295
        $property->addTypeHintDecorator(
102✔
296
            new CompositionTypeHintDecorator($this->processedMergedProperties[$schemaSignature]),
102✔
297
        );
102✔
298

299
        return $this->processedMergedProperties[$schemaSignature];
102✔
300
    }
301

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

322
                $redirectToProperty = $property;
125✔
323
            }
324
        }
325

326
        return $redirectToProperty;
142✔
327
    }
328

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

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

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

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

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

377
        $this->currentClassPath = join('\\', array_filter($pieces));
2,556✔
378
    }
379

380
    public function getCurrentClassPath(): string
1,012✔
381
    {
382
        return $this->currentClassPath;
1,012✔
383
    }
384

385
    public function getCurrentClassName(): string
992✔
386
    {
387
        return $this->currentClassName;
992✔
388
    }
389

390
    public function getGeneratedFiles(): array
2,406✔
391
    {
392
        return $this->generatedFiles;
2,406✔
393
    }
394

395
    public function getGeneratorConfiguration(): GeneratorConfiguration
2,565✔
396
    {
397
        return $this->generatorConfiguration;
2,565✔
398
    }
399

400
    public function getSchemaProvider(): SchemaProviderInterface
209✔
401
    {
402
        return $this->schemaProvider;
209✔
403
    }
404

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

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

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

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

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

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

455
        $this->currentClassPath = $savedClassPath;
14✔
456
        $this->currentClassName = $savedClassName;
14✔
457

458
        return $schema;
14✔
459
    }
460

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

476
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
386✔
UNCOV
477
                continue;
×
478
            }
479

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

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

501
            $branchesForValidator = $validator instanceof ConditionalPropertyValidator
383✔
502
                ? $validator->getConditionBranches()
57✔
503
                : $validator->getComposedProperties();
344✔
504

505
            $totalBranches = count($branchesForValidator);
383✔
506
            $resolvedPropertiesCallbacks = 0;
383✔
507
            $seenBranchPropertyNames = [];
383✔
508

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

530
                    $isBranchForValidator = in_array($composedProperty, $branchesForValidator, true);
381✔
531

532
                    $composedProperty->getNestedSchema()->onAllPropertiesResolved(
381✔
533
                        function () use (
381✔
534
                            $composedProperty,
381✔
535
                            $validator,
381✔
536
                            $isBranchForValidator,
381✔
537
                            $totalBranches,
381✔
538
                            $schema,
381✔
539
                            &$resolvedPropertiesCallbacks,
381✔
540
                            &$seenBranchPropertyNames,
381✔
541
                        ): void {
381✔
542
                            foreach ($composedProperty->getNestedSchema()->getProperties() as $branchProperty) {
381✔
543
                                $schema->addProperty(
378✔
544
                                    $this->cloneTransferredProperty(
378✔
545
                                        $branchProperty,
378✔
546
                                        $composedProperty,
378✔
547
                                        $validator,
378✔
548
                                    ),
378✔
549
                                    $validator->getCompositionProcessor(),
378✔
550
                                );
378✔
551

552
                                $composedProperty->appendAffectedObjectProperty($branchProperty);
376✔
553
                                $seenBranchPropertyNames[$branchProperty->getName()] = true;
376✔
554
                            }
555

556
                            if ($isBranchForValidator && ++$resolvedPropertiesCallbacks === $totalBranches) {
379✔
557
                                foreach (array_keys($seenBranchPropertyNames) as $branchPropertyName) {
378✔
558
                                    $schema->getPropertyMerger()->checkForTotalConflict(
375✔
559
                                        $branchPropertyName,
375✔
560
                                        $totalBranches,
375✔
561
                                    );
375✔
562
                                }
563

564
                                $this->propertyAttributeSynthesizer->synthesiseForValidator(
375✔
565
                                    $validator,
375✔
566
                                    $schema,
375✔
567
                                    $seenBranchPropertyNames,
375✔
568
                                );
375✔
569
                            }
570
                        },
381✔
571
                    );
381✔
572
                });
382✔
573
            }
574
        }
575
    }
576

577
    /**
578
     * Clone the provided property to transfer it to a schema. Sets the nullability and required
579
     * flag based on the composition processor used to set up the composition. Widens the type to
580
     * mixed when the property is exclusive to one anyOf/oneOf branch and at least one other branch
581
     * allows additional properties, preventing TypeError when raw input values of an arbitrary
582
     * type are stored in the property slot.
583
     */
584
    private function cloneTransferredProperty(
378✔
585
        PropertyInterface $property,
586
        CompositionPropertyDecorator $sourceBranch,
587
        AbstractComposedPropertyValidator $validator,
588
    ): PropertyInterface {
589
        $compositionProcessor = $validator->getCompositionProcessor();
378✔
590

591
        $transferredProperty = (clone $property)
378✔
592
            ->filterValidators(static fn(Validator $v): bool =>
378✔
593
                is_a($v->getValidator(), PropertyTemplateValidator::class));
378✔
594

595
        if (!is_a($compositionProcessor, AllOfValidatorFactory::class, true)) {
378✔
596
            $transferredProperty->setRequired(false);
259✔
597

598
            if ($transferredProperty->getType()) {
259✔
599
                $transferredProperty->setType(
234✔
600
                    new PropertyType($transferredProperty->getType()->getNames(), true),
234✔
601
                    new PropertyType($transferredProperty->getType(true)->getNames(), true),
234✔
602
                );
234✔
603
            }
604

605
            $wideningBranches = $validator instanceof ConditionalPropertyValidator
259✔
606
                ? $validator->getConditionBranches()
57✔
607
                : $validator->getComposedProperties();
202✔
608

609
            if ($this->exclusiveBranchPropertyNeedsWidening($property->getName(), $sourceBranch, $wideningBranches)) {
259✔
610
                $transferredProperty->setType(null, null, reset: true);
186✔
611
            }
612
        }
613

614
        $transferredProperty->setJsonSchema($transferredProperty->getJsonSchema()->withJson([]));
378✔
615

616
        return $transferredProperty;
378✔
617
    }
618

619
    /**
620
     * Returns true when the property named $propertyName is exclusive to $sourceBranch and at
621
     * least one other anyOf/oneOf branch allows additional properties (i.e. does NOT declare
622
     * additionalProperties: false). In that case the property slot can receive an
623
     * arbitrarily-typed raw input value from a non-matching branch, so the type hint is removed.
624
     *
625
     * Returns false when the property appears in another branch too (Schema::addProperty handles
626
     * that via type merging) or when all other branches have additionalProperties: false (making
627
     * the property mutually exclusive with the other branches' properties).
628
     *
629
     * @param CompositionPropertyDecorator[] $allBranches
630
     */
631
    private function exclusiveBranchPropertyNeedsWidening(
259✔
632
        string $propertyName,
633
        CompositionPropertyDecorator $sourceBranch,
634
        array $allBranches,
635
    ): bool {
636
        foreach ($allBranches as $branch) {
259✔
637
            if ($branch === $sourceBranch) {
259✔
638
                continue;
259✔
639
            }
640

641
            $branchPropertyNames = $branch->getNestedSchema()
257✔
642
                ? array_map(
257✔
643
                    static fn(PropertyInterface $p): string => $p->getName(),
257✔
644
                    $branch->getNestedSchema()->getProperties(),
257✔
645
                )
257✔
UNCOV
646
                : [];
×
647

648
            if (in_array($propertyName, $branchPropertyNames, true)) {
257✔
649
                return false;
114✔
650
            }
651
        }
652

653
        foreach ($allBranches as $branch) {
189✔
654
            if ($branch === $sourceBranch) {
189✔
655
                continue;
148✔
656
            }
657

658
            if (($branch->getBranchSchema()->getJson()['additionalProperties'] ?? true) !== false) {
187✔
659
                return true;
186✔
660
            }
661
        }
662

663
        return false;
17✔
664
    }
665

666
    private function getTargetFileName(string $classPath, string $className): string
2,556✔
667
    {
668
        return join(
2,556✔
669
            DIRECTORY_SEPARATOR,
2,556✔
670
            array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]),
2,556✔
671
        ) . '.php';
2,556✔
672
    }
673
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc