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

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

27 May 2026 02:55PM UTC coverage: 98.912%. First build
26519349194

push

github

wol-soft
Merge remote-tracking branch 'origin/master' into fix/enum-types-in-composition-branches

# Conflicts:
#	src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php
#	src/SchemaProcessor/PostProcessor/EnumPostProcessor.php
#	src/Templates/Validator/ComposedItem.phptpl

830 of 832 new or added lines in 25 files covered. (99.76%)

5912 of 5977 relevant lines covered (98.91%)

587.24 hits per line

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

99.05
/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor\PostProcessor;
6

7
use Exception;
8
use PHPMicroTemplate\Render;
9
use PHPModelGenerator\Exception\FileSystemException;
10
use PHPModelGenerator\Exception\Generic\InvalidTypeException;
11
use PHPModelGenerator\Exception\SchemaException;
12
use PHPModelGenerator\Filter\TransformingFilterInterface;
13
use PHPModelGenerator\Model\GeneratorConfiguration;
14
use PHPModelGenerator\Model\Property\PropertyInterface;
15
use PHPModelGenerator\Model\Property\PropertyType;
16
use PHPModelGenerator\Model\Schema;
17
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
18
use PHPModelGenerator\Model\Validator;
19
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
20
use PHPModelGenerator\Model\Validator\ArrayItemValidator;
21
use PHPModelGenerator\Model\Validator\EnumValidator;
22
use PHPModelGenerator\Model\Validator\Factory\Composition\AbstractCompositionValidatorFactory;
23
use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory;
24
use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory;
25
use PHPModelGenerator\Model\Validator\FilterValidator;
26
use PHPModelGenerator\Model\Validator\PropertyValidator;
27
use PHPModelGenerator\ModelGenerator;
28
use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor;
29
use PHPModelGenerator\Utils\ArrayHash;
30
use PHPModelGenerator\Utils\NormalizedName;
31
use PHPModelGenerator\Utils\TypeCheck;
32

33
/**
34
 * Generates a PHP enum for enums from JSON schemas which are automatically mapped for properties holding the enum
35
 */
36
class EnumPostProcessor extends PostProcessor
37
{
38
    private array $generatedEnums = [];
39

40
    private readonly string $namespace;
41
    private readonly Render $renderer;
42
    private readonly string $targetDirectory;
43
    private readonly string $enumFilterToken;
44

45
    /**
46
     * @param string $targetDirectory  The directory where to put the generated PHP enums
47
     * @param string $namespace        The namespace for the generated enums
48
     * @param bool $skipNonMappedEnums By default, enums which not contain only strings and don't provide a mapping for
49
     *                                 the enum will throw an exception. If set to true, those enums will be skipped
50
     *
51
     * @throws Exception
52
     */
53
    public function __construct(
44✔
54
        string $targetDirectory,
55
        string $namespace,
56
        private readonly bool $skipNonMappedEnums = false,
57
    ) {
58
        (new ModelGenerator())->generateModelDirectory($targetDirectory);
44✔
59

60
        $this->renderer = new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR);
44✔
61
        $this->namespace = trim($namespace, '\\');
44✔
62
        $this->targetDirectory = $targetDirectory;
44✔
63
        $this->enumFilterToken = (new EnumFilter())->getToken();
44✔
64
    }
65

66
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
44✔
67
    {
68
        $generatorConfiguration->addFilter(new EnumFilter());
44✔
69

70
        foreach ($schema->getProperties() as $property) {
44✔
71
            $this->processProperty($property, $schema, $generatorConfiguration);
44✔
72
        }
73
    }
74

75
    /**
76
     * Convert enum sub-schemas to generated PHP enum classes on this property, then recurse
77
     * into its composition branches and array items. After branch recursion the parent
78
     * property's native type is recomputed so the enum class propagates into the type union.
79
     *
80
     * @throws SchemaException
81
     */
82
    private function processProperty(
44✔
83
        PropertyInterface $property,
84
        Schema $schema,
85
        GeneratorConfiguration $generatorConfiguration,
86
    ): void {
87
        $json = $property->getJsonSchema()->getJson();
44✔
88

89
        // Branches and array items may share underlying Property instances via $ref deduplication.
90
        // If the EnumFilter is already attached, the conversion was performed on this property
91
        // already; skip re-conversion but still recurse so deeper sub-schemas are reached.
92
        if (isset($json['enum']) && !$this->hasEnumFilterAlreadyApplied($property)) {
44✔
93
            // Filter incompatible values before validation so that e.g. a string-typed enum
94
            // with a stray integer value is still valid (and the integer is removed with a warning).
95
            $values = $this->filterValuesByDeclaredType($json, $property);
44✔
96

97
            if ($this->validateEnum($property, $values)) {
44✔
98
                $this->convertEnumProperty($property, $schema, $generatorConfiguration, $json, $values);
32✔
99
            }
100
        }
101

102
        foreach ($property->getValidators() as $wrapped) {
31✔
103
            $validator = $wrapped->getValidator();
31✔
104

105
            if ($validator instanceof AbstractComposedPropertyValidator) {
31✔
106
                // `not` branches are excluded by design — a value that fails the inner
107
                // schema is not itself enum-typed and contributes no useful type hint.
108
                if ($validator->getCompositionProcessor() === NotValidatorFactory::class) {
5✔
109
                    continue;
×
110
                }
111

112
                foreach ($validator->getComposedProperties() as $branch) {
5✔
113
                    $this->processProperty($branch, $schema, $generatorConfiguration);
5✔
114
                }
115

116
                AbstractCompositionValidatorFactory::transferPropertyType(
5✔
117
                    $property,
5✔
118
                    $validator->getComposedProperties(),
5✔
119
                    $validator->getCompositionProcessor() === AllOfValidatorFactory::class,
5✔
120
                );
5✔
121

122
                continue;
5✔
123
            }
124

125
            if ($validator instanceof ArrayItemValidator) {
31✔
126
                $this->processProperty($validator->getNestedProperty(), $schema, $generatorConfiguration);
5✔
127
            }
128
        }
129
    }
130

131
    /**
132
     * Apply the enum-class conversion to a single property whose JSON schema declares an enum.
133
     *
134
     * @param array $values Enum values after filterValuesByDeclaredType has removed
135
     *                      type-incompatible entries.
136
     */
137
    private function convertEnumProperty(
32✔
138
        PropertyInterface $property,
139
        Schema $schema,
140
        GeneratorConfiguration $generatorConfiguration,
141
        array $json,
142
        array $values,
143
    ): void {
144
        $this->checkForExistingTransformingFilter($property);
32✔
145

146
        $enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', 'title', '$id', 'type']);
31✔
147
        $enumName = $json['title']
31✔
148
            ?? basename($json['$id'] ?? $schema->getClassName() . ucfirst($property->getName()));
31✔
149

150
        if (!isset($this->generatedEnums[$enumSignature])) {
31✔
151
            $this->generatedEnums[$enumSignature] = [
31✔
152
                'name' => $enumName,
31✔
153
                'fqcn' => $this->renderEnum(
31✔
154
                    $generatorConfiguration,
31✔
155
                    $schema->getJsonSchema(),
31✔
156
                    $enumName,
31✔
157
                    $values,
31✔
158
                    $json['enum-map'] ?? null,
31✔
159
                ),
31✔
160
            ];
31✔
161
        } else {
162
            if ($generatorConfiguration->isOutputEnabled()) {
3✔
163
                // @codeCoverageIgnoreStart
164
                echo "Duplicated signature $enumSignature for enum $enumName." .
165
                    " Redirecting to {$this->generatedEnums[$enumSignature]['name']}\n";
166
                // @codeCoverageIgnoreEnd
167
            }
168
        }
169

170
        $fqcn = $this->generatedEnums[$enumSignature]['fqcn'];
30✔
171
        $name = substr((string) $fqcn, strrpos((string) $fqcn, "\\") + 1);
30✔
172

173
        $inputType = $property->getType();
30✔
174

175
        (new FilterProcessor())->process(
30✔
176
            $property,
30✔
177
            ['filter' => (new EnumFilter())->getToken(), 'fqcn' => $fqcn],
30✔
178
            $generatorConfiguration,
30✔
179
            $schema,
30✔
180
        );
30✔
181

182
        $schema->addUsedClass($fqcn);
30✔
183
        $property->setType($inputType, new PropertyType($name, !$property->isRequired()), true);
30✔
184

185
        if ($property->getDefaultValue()) {
30✔
186
            $caseName = $this->getCaseName($json['enum-map'] ?? null, $json['default'], $property->getJsonSchema());
1✔
187
            $property->setDefaultValue("$name::$caseName", true);
1✔
188
        }
189

190
        // TransformingFilterOutputTypePostProcessor runs before user post-processors and
191
        // therefore never sees the FilterValidator added above. Call the extension directly
192
        // so that any TypeCheckValidator added by a "type" keyword is wrapped into a
193
        // PassThroughTypeCheckValidator that accepts already-transformed enum instances.
194
        TypeCheck::extendTypeCheckValidatorToAllowTransformedValue($property, [$name]);
30✔
195

196
        // remove the enum validator as the validation is performed by the PHP enum
197
        $property->filterValidators(
30✔
198
            static fn(Validator $validator): bool => !is_a($validator->getValidator(), EnumValidator::class),
30✔
199
        );
30✔
200

201
        // if an enum value is provided the transforming filter will add a value pass through. As the filter doesn't
202
        // know the exact enum type the pass through allows every UnitEnum instance. Consequently add a validator to
203
        // avoid wrong enums by validating against the generated enum
204
        $schema->addUsedClass($fqcn);
30✔
205
        $property->addValidator(
30✔
206
            new class ($property, $name) extends PropertyValidator {
30✔
207
                public function __construct(PropertyInterface $property, string $enumName)
208
                {
209
                    parent::__construct(
30✔
210
                        $property,
30✔
211
                        sprintf('$value instanceof UnitEnum && !($value instanceof %s)', $enumName),
30✔
212
                        InvalidTypeException::class,
30✔
213
                        [$enumName],
30✔
214
                    );
30✔
215
                }
216
            },
30✔
217
            0,
30✔
218
        );
30✔
219
    }
220

221
    /**
222
     * Returns true when the EnumFilter is already attached to the property (e.g. because the
223
     * property was reached previously through another composition branch sharing the same
224
     * underlying $ref-resolved Property instance).
225
     */
226
    private function hasEnumFilterAlreadyApplied(PropertyInterface $property): bool
44✔
227
    {
228
        foreach ($property->getValidators() as $wrapped) {
44✔
229
            $validator = $wrapped->getValidator();
44✔
230

231
            if (
232
                $validator instanceof FilterValidator
44✔
233
                && $validator->getFilter()->getToken() === $this->enumFilterToken
44✔
234
            ) {
235
                return true;
3✔
236
            }
237
        }
238

239
        return false;
44✔
240
    }
241

242
    /**
243
     * @throws SchemaException
244
     */
245
    private function checkForExistingTransformingFilter(PropertyInterface $property): void
32✔
246
    {
247
        foreach ($property->getValidators() as $validator) {
32✔
248
            $validator = $validator->getValidator();
32✔
249

250
            if (
251
                $validator instanceof FilterValidator
32✔
252
                && $validator->getFilter() instanceof TransformingFilterInterface
32✔
253
            ) {
254
                throw new SchemaException(sprintf(
1✔
255
                    "Can't apply enum filter to an already transformed value on property %s in file %s",
1✔
256
                    $property->getName(),
1✔
257
                    $property->getJsonSchema()->getFile(),
1✔
258
                ));
1✔
259
            }
260
        }
261
    }
262

263
    public function postProcess(): void
31✔
264
    {
265
        $this->generatedEnums = [];
31✔
266

267
        parent::postProcess();
31✔
268
    }
269

270
    /**
271
     * @throws SchemaException
272
     */
273
    private function validateEnum(PropertyInterface $property, array $values): bool
44✔
274
    {
275
        $throw = function (string $message) use ($property): void {
44✔
276
            throw new SchemaException(
11✔
277
                sprintf(
11✔
278
                    $message,
11✔
279
                    $property->getName(),
11✔
280
                    $property->getJsonSchema()->getFile(),
11✔
281
                )
11✔
282
            );
11✔
283
        };
44✔
284

285
        $json = $property->getJsonSchema()->getJson();
44✔
286

287
        $types = $this->getArrayTypes($values);
44✔
288

289
        // the enum must contain either only string values or provide a value map to resolve the values
290
        if ($types !== ['string'] && !isset($json['enum-map'])) {
44✔
291
            if ($this->skipNonMappedEnums) {
4✔
292
                return false;
1✔
293
            }
294

295
            $throw('Unmapped enum %s in file %s');
3✔
296
        }
297

298
        if (isset($json['enum-map'])) {
40✔
299
            $sortedValues = $values;
15✔
300
            asort($sortedValues);
15✔
301
            $enumMap = $json['enum-map'];
15✔
302
            if (is_array($enumMap)) {
15✔
303
                asort($enumMap);
14✔
304
            }
305

306
            if (
307
                !is_array($enumMap)
15✔
308
                || $this->getArrayTypes(array_keys($enumMap)) !== ['string']
14✔
309
                || count(array_uintersect(
15✔
310
                    $enumMap,
15✔
311
                    $sortedValues,
15✔
312
                    fn($a, $b): int => $a === $b ? 0 : 1,
15✔
313
                )) !== count($sortedValues)
15✔
314
            ) {
315
                $throw('invalid enum map %s in file %s');
8✔
316
            }
317
        }
318

319
        return true;
32✔
320
    }
321

322
    /**
323
     * Return the enum values restricted to those compatible with the declared "type" keyword.
324
     * Removes values that can never satisfy the type constraint and emits a warning for each
325
     * removed value so the developer is aware at generation time.
326
     */
327
    private function filterValuesByDeclaredType(array $json, PropertyInterface $property): array
44✔
328
    {
329
        $values = $json['enum'];
44✔
330

331
        if (!isset($json['type'])) {
44✔
332
            return $values;
36✔
333
        }
334

335
        $declaredTypes = is_array($json['type']) ? $json['type'] : [$json['type']];
8✔
336

337
        // Map JSON Schema type names to PHP gettype() return values
338
        $phpTypeMap = [
8✔
339
            'string'  => ['string'],
8✔
340
            'integer' => ['integer'],
8✔
341
            'number'  => ['integer', 'double'],
8✔
342
            'boolean' => ['boolean'],
8✔
343
            'null'    => ['NULL'],
8✔
344
            'array'   => ['array'],
8✔
345
            'object'  => ['object'],
8✔
346
        ];
8✔
347

348
        $allowedPhpTypes = [];
8✔
349
        foreach ($declaredTypes as $declaredType) {
8✔
350
            if (isset($phpTypeMap[$declaredType])) {
8✔
351
                $allowedPhpTypes = array_merge($allowedPhpTypes, $phpTypeMap[$declaredType]);
8✔
352
            }
353
        }
354

355
        if (empty($allowedPhpTypes)) {
8✔
NEW
356
            return $values;
×
357
        }
358

359
        $compatibleValues = [];
8✔
360
        $removedValues    = [];
8✔
361

362
        foreach ($values as $value) {
8✔
363
            if (in_array(gettype($value), $allowedPhpTypes, true)) {
8✔
364
                $compatibleValues[] = $value;
8✔
365
            } else {
366
                $removedValues[] = $value;
1✔
367
            }
368
        }
369

370
        if (!empty($removedValues)) {
8✔
371
            $typeLabel   = implode('|', $declaredTypes);
1✔
372
            $removedList = implode(', ', array_map(
1✔
373
                static fn($value): string => var_export($value, true),
1✔
374
                $removedValues,
1✔
375
            ));
1✔
376

377
            echo sprintf(
1✔
378
                "Warning: enum property '%s' in file %s declares type '%s' but contains incompatible values: %s."
1✔
379
                    . " These values have been removed from the generated enum.\n",
1✔
380
                $property->getName(),
1✔
381
                $property->getJsonSchema()->getFile(),
1✔
382
                $typeLabel,
1✔
383
                $removedList,
1✔
384
            );
1✔
385
        }
386

387
        return $compatibleValues;
8✔
388
    }
389

390
    private function getArrayTypes(array $array): array
44✔
391
    {
392
        return array_unique(array_map(
44✔
393
            static fn($item): string => gettype($item),
44✔
394
            $array,
44✔
395
        ));
44✔
396
    }
397

398
    private function renderEnum(
31✔
399
        GeneratorConfiguration $generatorConfiguration,
400
        JsonSchema $jsonSchema,
401
        string $name,
402
        array $values,
403
        ?array $map,
404
    ): string {
405
        $cases = [];
31✔
406
        $name = ucfirst((string) preg_replace('/\W/', '', ucwords($name, '_-. ')));
31✔
407

408
        foreach ($values as $value) {
31✔
409
            $cases[$this->getCaseName($map, $value, $jsonSchema)] = var_export($value, true);
31✔
410
        }
411

412
        $backedType = null;
30✔
413
        switch ($this->getArrayTypes($values)) {
30✔
414
            case ['string']:
415
                $backedType = 'string';
28✔
416
                break;
28✔
417
            case ['integer']:
2✔
418
                $backedType = 'int';
1✔
419
                break;
1✔
420
        }
421

422
        // make sure different enums with an identical name don't overwrite each other
423
        while (in_array("$this->namespace\\$name", array_column($this->generatedEnums, 'fqcn'))) {
30✔
424
            $name .= '_1';
2✔
425
        }
426

427
        $result = file_put_contents(
30✔
428
            $filename = $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php',
30✔
429
            $this->renderer->renderTemplate(
30✔
430
                'Enum.phptpl',
30✔
431
                [
30✔
432
                    'namespace' => $this->namespace,
30✔
433
                    'name' => $name,
30✔
434
                    'cases' => $cases,
30✔
435
                    'backedType' => $backedType,
30✔
436
                ],
30✔
437
            )
30✔
438
        );
30✔
439

440
        $fqcn = "$this->namespace\\$name";
30✔
441

442
        if ($result === false) {
30✔
443
            // @codeCoverageIgnoreStart
444
            throw new FileSystemException("Can't write enum $fqcn.");
445
            // @codeCoverageIgnoreEnd
446
        }
447

448
        require $filename;
30✔
449

450
        if ($generatorConfiguration->isOutputEnabled()) {
30✔
451
            // @codeCoverageIgnoreStart
452
            echo "Rendered enum $fqcn\n";
453
            // @codeCoverageIgnoreEnd
454
        }
455

456
        return $fqcn;
30✔
457
    }
458

459
    private function getCaseName(?array $map, mixed $value, JsonSchema $jsonSchema): string
31✔
460
    {
461
        $caseName = ucfirst(NormalizedName::from($map ? array_search($value, $map, true) : $value, $jsonSchema));
31✔
462

463
        if (preg_match('/^\d/', $caseName) === 1) {
30✔
464
            return "_$caseName";
2✔
465
        }
466

467
        return $caseName;
29✔
468
    }
469
}
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