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

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

30 Mar 2026 09:07AM UTC coverage: 98.717% (+0.1%) from 98.599%
23736867000

push

github

web-flow
Merge pull request #122 from wol-soft/refImprovements

Ref improvements

76 of 77 new or added lines in 6 files covered. (98.7%)

4002 of 4054 relevant lines covered (98.72%)

578.81 hits per line

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

99.47
/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\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator;
18
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
19
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
20
use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection;
21
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
22
use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory;
23
use PHPModelGenerator\SchemaProvider\SchemaProviderInterface;
24

25
/**
26
 * Class SchemaProcessor
27
 *
28
 * @package PHPModelGenerator\SchemaProcessor
29
 */
30
class SchemaProcessor
31
{
32
    protected string $currentClassPath;
33
    protected string $currentClassName;
34

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

60
    public function __construct(
2,187✔
61
        protected SchemaProviderInterface $schemaProvider,
62
        protected string $destination,
63
        protected GeneratorConfiguration $generatorConfiguration,
64
        protected RenderQueue $renderQueue,
65
    ) {}
2,187✔
66

67
    /**
68
     * Process a given json schema file
69
     *
70
     * @throws SchemaException
71
     */
72
    public function process(JsonSchema $jsonSchema): void
2,178✔
73
    {
74
        $this->setCurrentClassPath($jsonSchema->getFile());
2,178✔
75
        $this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
2,178✔
76
            str_ireplace('.json', '', basename($jsonSchema->getFile())),
2,178✔
77
            $jsonSchema,
2,178✔
78
            false,
2,178✔
79
        );
2,178✔
80

81
        $this->processSchema(
2,178✔
82
            $jsonSchema,
2,178✔
83
            $this->currentClassPath,
2,178✔
84
            $this->currentClassName,
2,178✔
85
            new SchemaDefinitionDictionary($jsonSchema),
2,178✔
86
            true,
2,178✔
87
        );
2,178✔
88
    }
89

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

115
        return $this->generateModel($classPath, $className, $jsonSchema, $dictionary, $initialClass);
2,178✔
116
    }
117

118
    /**
119
     * Generate a model and store the model to the file system
120
     *
121
     * @throws SchemaException
122
     */
123
    protected function generateModel(
2,178✔
124
        string $classPath,
125
        string $className,
126
        JsonSchema $jsonSchema,
127
        SchemaDefinitionDictionary $dictionary,
128
        bool $initialClass,
129
    ): Schema {
130
        $schemaSignature = $jsonSchema->getSignature();
2,178✔
131

132
        if (!$initialClass && isset($this->processedSchema[$schemaSignature])) {
2,178✔
133
            if ($this->generatorConfiguration->isOutputEnabled()) {
101✔
134
                echo "Duplicated signature $schemaSignature for class $className." .
1✔
135
                    " Redirecting to {$this->processedSchema[$schemaSignature]->getClassName()}\n";
1✔
136
            }
137

138
            return $this->processedSchema[$schemaSignature];
101✔
139
        }
140

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

156
        $schema = new Schema(
2,178✔
157
            $this->getTargetFileName($classPath, $className),
2,178✔
158
            $classPath,
2,178✔
159
            $className,
2,178✔
160
            $jsonSchema,
2,178✔
161
            $dictionary,
2,178✔
162
            $initialClass,
2,178✔
163
            $this->generatorConfiguration,
2,178✔
164
        );
2,178✔
165

166
        // Register by content signature (secondary dedup for content-identical inline schemas).
167
        $this->processedSchema[$schemaSignature] = $schema;
2,178✔
168
        // Register by canonical file path/URL (primary dedup for external $ref resolutions).
169
        // Registering here — before property processing — ensures that any $ref back to this
170
        // file encountered while processing the referencing schema finds this canonical schema
171
        // immediately, regardless of which schema was discovered first by the provider.
172
        $this->registerProcessedFileSchema($jsonSchema->getFile(), $schema);
2,178✔
173
        $json = $jsonSchema->getJson();
2,178✔
174
        $json['type'] = 'base';
2,178✔
175

176
        (new PropertyFactory(new PropertyProcessorFactory()))->create(
2,178✔
177
            new PropertyMetaDataCollection($jsonSchema->getJson()['required'] ?? []),
2,178✔
178
            $this,
2,178✔
179
            $schema,
2,178✔
180
            $className,
2,178✔
181
            $jsonSchema->withJson($json),
2,178✔
182
        );
2,178✔
183

184
        $this->generateClassFile($schema);
2,115✔
185

186
        return $schema;
2,115✔
187
    }
188

189
    /**
190
     * Attach a new class file render job to the render proxy
191
     */
192
    public function generateClassFile(Schema $schema): void
2,115✔
193
    {
194
        $this->renderQueue->addRenderJob(new RenderJob($schema));
2,115✔
195

196
        if ($this->generatorConfiguration->isOutputEnabled()) {
2,115✔
197
            echo sprintf(
2✔
198
                "Generated class %s\n",
2✔
199
                join(
2✔
200
                    '\\',
2✔
201
                    array_filter([
2✔
202
                        $this->generatorConfiguration->getNamespacePrefix(),
2✔
203
                        $schema->getClassPath(),
2✔
204
                        $schema->getClassName(),
2✔
205
                    ]),
2✔
206
                ),
2✔
207
            );
2✔
208
        }
209

210
        $this->generatedFiles[] = $schema->getTargetFileName();
2,115✔
211
    }
212

213

214
    /**
215
     * Gather all nested object properties and merge them together into a single merged property
216
     *
217
     * @param CompositionPropertyDecorator[] $compositionProperties
218
     *
219
     * @throws SchemaException
220
     */
221
    public function createMergedProperty(
186✔
222
        Schema $schema,
223
        PropertyInterface $property,
224
        array $compositionProperties,
225
        JsonSchema $propertySchema,
226
    ): ?PropertyInterface {
227
        $redirectToProperty = $this->redirectMergedProperty($compositionProperties);
186✔
228
        if ($redirectToProperty === null || $redirectToProperty instanceof PropertyInterface) {
186✔
229
            if ($redirectToProperty) {
84✔
230
                $property->addTypeHintDecorator(new CompositionTypeHintDecorator($redirectToProperty));
20✔
231
            }
232

233
            return $redirectToProperty;
84✔
234
        }
235

236
        /** @var JsonSchema $jsonSchema */
237
        $jsonSchema = $propertySchema->getJson()['propertySchema'];
102✔
238
        $schemaSignature = $jsonSchema->getSignature();
102✔
239

240
        if (!isset($this->processedMergedProperties[$schemaSignature])) {
102✔
241
            $mergedClassName = $this
102✔
242
                ->getGeneratorConfiguration()
102✔
243
                ->getClassNameGenerator()
102✔
244
                ->getClassName(
102✔
245
                    $property->getName(),
102✔
246
                    $propertySchema,
102✔
247
                    true,
102✔
248
                    $this->getCurrentClassName(),
102✔
249
                );
102✔
250

251
            $mergedPropertySchema = new Schema(
102✔
252
                $this->getTargetFileName($schema->getClassPath(), $mergedClassName),
102✔
253
                $schema->getClassPath(),
102✔
254
                $mergedClassName,
102✔
255
                $propertySchema,
102✔
256
            );
102✔
257

258
            $this->processedMergedProperties[$schemaSignature] = (new Property(
102✔
259
                'MergedProperty',
102✔
260
                new PropertyType($mergedClassName),
102✔
261
                $mergedPropertySchema->getJsonSchema(),
102✔
262
            ))
102✔
263
                ->addDecorator(new ObjectInstantiationDecorator($mergedClassName, $this->getGeneratorConfiguration()))
102✔
264
                ->setNestedSchema($mergedPropertySchema);
102✔
265

266
            $this->transferPropertiesToMergedSchema($schema, $mergedPropertySchema, $compositionProperties);
102✔
267

268
            // make sure the merged schema knows all imports of the parent schema
269
            $mergedPropertySchema->addNamespaceTransferDecorator(new SchemaNamespaceTransferDecorator($schema));
102✔
270

271
            $this->generateClassFile($mergedPropertySchema);
102✔
272
        }
273

274
        $mergedSchema = $this->processedMergedProperties[$schemaSignature]->getNestedSchema();
102✔
275
        $schema->addUsedClass(
102✔
276
            join(
102✔
277
                '\\',
102✔
278
                array_filter([
102✔
279
                    $this->generatorConfiguration->getNamespacePrefix(),
102✔
280
                    $mergedSchema->getClassPath(),
102✔
281
                    $mergedSchema->getClassName(),
102✔
282
                ]),
102✔
283
            )
102✔
284
        );
102✔
285

286
        $property->addTypeHintDecorator(
102✔
287
            new CompositionTypeHintDecorator($this->processedMergedProperties[$schemaSignature]),
102✔
288
        );
102✔
289

290
        return $this->processedMergedProperties[$schemaSignature];
102✔
291
    }
292

293
    /**
294
     * Check if multiple $compositionProperties contain nested schemas. Only in this case a merged property must be
295
     * created. If no nested schemas are detected null will be returned. If only one $compositionProperty contains a
296
     * nested schema the $compositionProperty will be used as a replacement for the merged property.
297
     *
298
     * Returns false if a merged property must be created.
299
     *
300
     * @param CompositionPropertyDecorator[] $compositionProperties
301
     *
302
     * @return PropertyInterface|null|false
303
     */
304
    private function redirectMergedProperty(array $compositionProperties)
186✔
305
    {
306
        $redirectToProperty = null;
186✔
307
        foreach ($compositionProperties as $property) {
186✔
308
            if ($property->getNestedSchema()) {
186✔
309
                if ($redirectToProperty !== null) {
122✔
310
                    return false;
102✔
311
                }
312

313
                $redirectToProperty = $property;
122✔
314
            }
315
        }
316

317
        return $redirectToProperty;
84✔
318
    }
319

320
    /**
321
     * @param PropertyInterface[] $compositionProperties
322
     */
323
    private function transferPropertiesToMergedSchema(
102✔
324
        Schema $schema,
325
        Schema $mergedPropertySchema,
326
        array $compositionProperties,
327
    ): void {
328
        foreach ($compositionProperties as $property) {
102✔
329
            if (!$property->getNestedSchema()) {
102✔
330
                continue;
26✔
331
            }
332

333
            $property->getNestedSchema()->onAllPropertiesResolved(
102✔
334
                function () use ($property, $schema, $mergedPropertySchema): void {
102✔
335
                    foreach ($property->getNestedSchema()->getProperties() as $nestedProperty) {
102✔
336
                        $mergedPropertySchema->addProperty(
102✔
337
                        // don't validate fields in merged properties. All fields were validated before
338
                        // corresponding to the defined constraints of the composition property.
339
                            (clone $nestedProperty)->filterValidators(static fn(): bool => false),
102✔
340
                        );
102✔
341
                    }
342
                },
102✔
343
            );
102✔
344
        }
345
    }
346

347
    /**
348
     * Get the class path out of the file path of a schema file
349
     */
350
    protected function setCurrentClassPath(string $jsonSchemaFile): void
2,178✔
351
    {
352
        $fileDir  = str_replace('\\', '/', dirname($jsonSchemaFile));
2,178✔
353
        $baseDir  = str_replace('\\', '/', $this->schemaProvider->getBaseDirectory());
2,178✔
354
        $relative = str_replace($baseDir, '', $fileDir);
2,178✔
355

356
        // If the file is outside the provider's base directory, str_replace leaves the absolute
357
        // path untouched. In that case fall back to using just the last directory component so
358
        // the generated class path stays sensible rather than encoding an absolute path.
359
        if ($relative === $fileDir) {
2,178✔
NEW
360
            $relative = basename($fileDir);
×
361
        }
362

363
        $pieces = array_map(
2,178✔
364
            static fn(string $directory): string => ucfirst((string) preg_replace('/\W/', '', $directory)),
2,178✔
365
            explode('/', $relative),
2,178✔
366
        );
2,178✔
367

368
        $this->currentClassPath = join('\\', array_filter($pieces));
2,178✔
369
    }
370

371
    public function getCurrentClassPath(): string
977✔
372
    {
373
        return $this->currentClassPath;
977✔
374
    }
375

376
    public function getCurrentClassName(): string
957✔
377
    {
378
        return $this->currentClassName;
957✔
379
    }
380

381
    public function getGeneratedFiles(): array
2,091✔
382
    {
383
        return $this->generatedFiles;
2,091✔
384
    }
385

386
    public function getGeneratorConfiguration(): GeneratorConfiguration
2,163✔
387
    {
388
        return $this->generatorConfiguration;
2,163✔
389
    }
390

391
    public function getSchemaProvider(): SchemaProviderInterface
208✔
392
    {
393
        return $this->schemaProvider;
208✔
394
    }
395

396
    public function getProcessedFileSchema(string $fileKey): ?Schema
203✔
397
    {
398
        return $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] ?? null;
203✔
399
    }
400

401
    public function registerProcessedFileSchema(string $fileKey, Schema $schema): void
2,178✔
402
    {
403
        $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] = $schema;
2,178✔
404
    }
405

406
    /**
407
     * Normalise a file path or URL to a consistent key for processedFileSchemas.
408
     * On Windows, RecursiveDirectoryIterator may produce backslash-separated paths while
409
     * RefResolverTrait produces forward-slash paths for the same file. Normalising to forward
410
     * slashes ensures the two representations map to the same key.
411
     */
412
    private function normaliseFileKey(string $fileKey): string
2,178✔
413
    {
414
        return str_replace('\\', '/', $fileKey);
2,178✔
415
    }
416

417
    /**
418
     * Process an external schema file with its canonical class name and path, exactly as
419
     * process() would, but without overwriting the current class path / class name context
420
     * (which belongs to the schema that triggered the $ref resolution).
421
     *
422
     * Returns the resulting Schema, or null if the file does not define an object/composition.
423
     *
424
     * @throws SchemaException
425
     */
426
    public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema
13✔
427
    {
428
        $savedClassPath  = $this->currentClassPath;
13✔
429
        $savedClassName  = $this->currentClassName;
13✔
430

431
        $this->setCurrentClassPath($jsonSchema->getFile());
13✔
432
        $this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
13✔
433
            str_ireplace('.json', '', basename($jsonSchema->getFile())),
13✔
434
            $jsonSchema,
13✔
435
            false,
13✔
436
        );
13✔
437

438
        $schema = $this->processSchema(
13✔
439
            $jsonSchema,
13✔
440
            $this->currentClassPath,
13✔
441
            $this->currentClassName,
13✔
442
            new SchemaDefinitionDictionary($jsonSchema),
13✔
443
            true,
13✔
444
        );
13✔
445

446
        $this->currentClassPath = $savedClassPath;
13✔
447
        $this->currentClassName = $savedClassName;
13✔
448

449
        return $schema;
13✔
450
    }
451

452
    private function getTargetFileName(string $classPath, string $className): string
2,178✔
453
    {
454
        return join(
2,178✔
455
            DIRECTORY_SEPARATOR,
2,178✔
456
            array_filter([$this->destination, str_replace('\\', DIRECTORY_SEPARATOR, $classPath), $className]),
2,178✔
457
        ) . '.php';
2,178✔
458
    }
459
}
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