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

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

27 May 2026 02:18PM UTC coverage: 98.857% (-0.008%) from 98.865%
26516974032

push

github

web-flow
Merge pull request #136 from wol-soft/fix/issue-129-enum-type-check-validator

Fix EnumPostProcessor

77 of 78 new or added lines in 4 files covered. (98.72%)

5882 of 5950 relevant lines covered (98.86%)

588.41 hits per line

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

99.46
/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\EnumValidator;
20
use PHPModelGenerator\Model\Validator\FilterValidator;
21
use PHPModelGenerator\Model\Validator\PropertyValidator;
22
use PHPModelGenerator\ModelGenerator;
23
use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor;
24
use PHPModelGenerator\Utils\ArrayHash;
25
use PHPModelGenerator\Utils\NormalizedName;
26
use PHPModelGenerator\Utils\TypeCheck;
27

28
/**
29
 * Generates a PHP enum for enums from JSON schemas which are automatically mapped for properties holding the enum
30
 */
31
class EnumPostProcessor extends PostProcessor
32
{
33
    private array $generatedEnums = [];
34

35
    private readonly string $namespace;
36
    private readonly Render $renderer;
37
    private readonly string $targetDirectory;
38

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

54
        $this->renderer = new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR);
40✔
55
        $this->namespace = trim($namespace, '\\');
40✔
56
        $this->targetDirectory = $targetDirectory;
40✔
57
    }
58

59
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
40✔
60
    {
61
        $generatorConfiguration->addFilter(new EnumFilter());
40✔
62

63
        foreach ($schema->getProperties() as $property) {
40✔
64
            $json = $property->getJsonSchema()->getJson();
40✔
65

66
            if (!isset($json['enum'])) {
40✔
67
                continue;
11✔
68
            }
69

70
            // Filter incompatible values before validation so that e.g. a string-typed enum
71
            // with a stray integer value is still valid (and the integer is removed with a warning).
72
            $values = $this->filterValuesByDeclaredType($json, $property);
40✔
73

74
            if (!$this->validateEnum($property, $values)) {
40✔
75
                continue;
1✔
76
            }
77

78
            $this->checkForExistingTransformingFilter($property);
28✔
79
            $enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', 'title', '$id', 'type']);
27✔
80
            $enumName = $json['title']
27✔
81
                ?? basename($json['$id'] ?? $schema->getClassName() . ucfirst($property->getName()));
27✔
82

83
            if (!isset($this->generatedEnums[$enumSignature])) {
27✔
84
                $this->generatedEnums[$enumSignature] = [
27✔
85
                    'name' => $enumName,
27✔
86
                    'fqcn' => $this->renderEnum(
27✔
87
                        $generatorConfiguration,
27✔
88
                        $schema->getJsonSchema(),
27✔
89
                        $enumName,
27✔
90
                        $values,
27✔
91
                        $json['enum-map'] ?? null,
27✔
92
                    ),
27✔
93
                ];
27✔
94
            } else {
95
                if ($generatorConfiguration->isOutputEnabled()) {
3✔
96
                    // @codeCoverageIgnoreStart
97
                    echo "Duplicated signature $enumSignature for enum $enumName." .
98
                        " Redirecting to {$this->generatedEnums[$enumSignature]['name']}\n";
99
                    // @codeCoverageIgnoreEnd
100
                }
101
            }
102

103
            $fqcn = $this->generatedEnums[$enumSignature]['fqcn'];
26✔
104
            $name = substr((string) $fqcn, strrpos((string) $fqcn, "\\") + 1);
26✔
105

106
            $inputType = $property->getType();
26✔
107

108
            (new FilterProcessor())->process(
26✔
109
                $property,
26✔
110
                ['filter' => (new EnumFilter())->getToken(), 'fqcn' => $fqcn],
26✔
111
                $generatorConfiguration,
26✔
112
                $schema,
26✔
113
            );
26✔
114

115
            $schema->addUsedClass($fqcn);
26✔
116
            $property->setType($inputType, new PropertyType($name, !$property->isRequired()), true);
26✔
117

118
            if ($property->getDefaultValue()) {
26✔
119
                $caseName = $this->getCaseName($json['enum-map'] ?? null, $json['default'], $property->getJsonSchema());
1✔
120
                $property->setDefaultValue("$name::$caseName", true);
1✔
121
            }
122

123
            // TransformingFilterOutputTypePostProcessor runs before user post-processors and
124
            // therefore never sees the FilterValidator added above. Call the extension directly
125
            // so that any TypeCheckValidator added by a "type" keyword is wrapped into a
126
            // PassThroughTypeCheckValidator that accepts already-transformed enum instances.
127
            TypeCheck::extendTypeCheckValidatorToAllowTransformedValue($property, [$name]);
26✔
128

129
            // remove the enum validator as the validation is performed by the PHP enum
130
            $property->filterValidators(
26✔
131
                static fn(Validator $validator): bool => !is_a($validator->getValidator(), EnumValidator::class),
26✔
132
            );
26✔
133

134
            // if an enum value is provided the transforming filter will add a value pass through. As the filter doesn't
135
            // know the exact enum type the pass through allows every UnitEnum instance. Consequently add a validator to
136
            // avoid wrong enums by validating against the generated enum
137
            $schema->addUsedClass($fqcn);
26✔
138
            $property->addValidator(
26✔
139
                new class ($property, $name) extends PropertyValidator {
26✔
140
                    public function __construct(PropertyInterface $property, string $enumName)
141
                    {
142
                        parent::__construct(
26✔
143
                            $property,
26✔
144
                            sprintf('$value instanceof UnitEnum && !($value instanceof %s)', $enumName),
26✔
145
                            InvalidTypeException::class,
26✔
146
                            [$enumName],
26✔
147
                        );
26✔
148
                    }
149
                },
26✔
150
                0,
26✔
151
            );
26✔
152
        }
153
    }
154

155
    /**
156
     * @throws SchemaException
157
     */
158
    private function checkForExistingTransformingFilter(PropertyInterface $property): void
28✔
159
    {
160
        foreach ($property->getValidators() as $validator) {
28✔
161
            $validator = $validator->getValidator();
28✔
162

163
            if (
164
                $validator instanceof FilterValidator
28✔
165
                && $validator->getFilter() instanceof TransformingFilterInterface
28✔
166
            ) {
167
                throw new SchemaException(sprintf(
1✔
168
                    "Can't apply enum filter to an already transformed value on property %s in file %s",
1✔
169
                    $property->getName(),
1✔
170
                    $property->getJsonSchema()->getFile(),
1✔
171
                ));
1✔
172
            }
173
        }
174
    }
175

176
    public function postProcess(): void
27✔
177
    {
178
        $this->generatedEnums = [];
27✔
179

180
        parent::postProcess();
27✔
181
    }
182

183
    /**
184
     * @throws SchemaException
185
     */
186
    private function validateEnum(PropertyInterface $property, array $values): bool
40✔
187
    {
188
        $throw = function (string $message) use ($property): void {
40✔
189
            throw new SchemaException(
11✔
190
                sprintf(
11✔
191
                    $message,
11✔
192
                    $property->getName(),
11✔
193
                    $property->getJsonSchema()->getFile(),
11✔
194
                )
11✔
195
            );
11✔
196
        };
40✔
197

198
        $json = $property->getJsonSchema()->getJson();
40✔
199

200
        $types = $this->getArrayTypes($values);
40✔
201

202
        // the enum must contain either only string values or provide a value map to resolve the values
203
        if ($types !== ['string'] && !isset($json['enum-map'])) {
40✔
204
            if ($this->skipNonMappedEnums) {
4✔
205
                return false;
1✔
206
            }
207

208
            $throw('Unmapped enum %s in file %s');
3✔
209
        }
210

211
        if (isset($json['enum-map'])) {
36✔
212
            $sortedValues = $values;
15✔
213
            asort($sortedValues);
15✔
214
            $enumMap = $json['enum-map'];
15✔
215
            if (is_array($enumMap)) {
15✔
216
                asort($enumMap);
14✔
217
            }
218

219
            if (
220
                !is_array($enumMap)
15✔
221
                || $this->getArrayTypes(array_keys($enumMap)) !== ['string']
14✔
222
                || count(array_uintersect(
15✔
223
                    $enumMap,
15✔
224
                    $sortedValues,
15✔
225
                    fn($a, $b): int => $a === $b ? 0 : 1,
15✔
226
                )) !== count($sortedValues)
15✔
227
            ) {
228
                $throw('invalid enum map %s in file %s');
8✔
229
            }
230
        }
231

232
        return true;
28✔
233
    }
234

235
    /**
236
     * Return the enum values restricted to those compatible with the declared "type" keyword.
237
     * Removes values that can never satisfy the type constraint and emits a warning for each
238
     * removed value so the developer is aware at generation time.
239
     */
240
    private function filterValuesByDeclaredType(array $json, PropertyInterface $property): array
40✔
241
    {
242
        $values = $json['enum'];
40✔
243

244
        if (!isset($json['type'])) {
40✔
245
            return $values;
36✔
246
        }
247

248
        $declaredTypes = is_array($json['type']) ? $json['type'] : [$json['type']];
4✔
249

250
        // Map JSON Schema type names to PHP gettype() return values
251
        $phpTypeMap = [
4✔
252
            'string'  => ['string'],
4✔
253
            'integer' => ['integer'],
4✔
254
            'number'  => ['integer', 'double'],
4✔
255
            'boolean' => ['boolean'],
4✔
256
            'null'    => ['NULL'],
4✔
257
            'array'   => ['array'],
4✔
258
            'object'  => ['object'],
4✔
259
        ];
4✔
260

261
        $allowedPhpTypes = [];
4✔
262
        foreach ($declaredTypes as $declaredType) {
4✔
263
            if (isset($phpTypeMap[$declaredType])) {
4✔
264
                $allowedPhpTypes = array_merge($allowedPhpTypes, $phpTypeMap[$declaredType]);
4✔
265
            }
266
        }
267

268
        if (empty($allowedPhpTypes)) {
4✔
NEW
269
            return $values;
×
270
        }
271

272
        $compatibleValues = [];
4✔
273
        $removedValues    = [];
4✔
274

275
        foreach ($values as $value) {
4✔
276
            if (in_array(gettype($value), $allowedPhpTypes, true)) {
4✔
277
                $compatibleValues[] = $value;
4✔
278
            } else {
279
                $removedValues[] = $value;
1✔
280
            }
281
        }
282

283
        if (!empty($removedValues)) {
4✔
284
            $typeLabel   = implode('|', $declaredTypes);
1✔
285
            $removedList = implode(', ', array_map(
1✔
286
                static fn($value): string => var_export($value, true),
1✔
287
                $removedValues,
1✔
288
            ));
1✔
289

290
            echo sprintf(
1✔
291
                "Warning: enum property '%s' in file %s declares type '%s' but contains incompatible values: %s."
1✔
292
                    . " These values have been removed from the generated enum.\n",
1✔
293
                $property->getName(),
1✔
294
                $property->getJsonSchema()->getFile(),
1✔
295
                $typeLabel,
1✔
296
                $removedList,
1✔
297
            );
1✔
298
        }
299

300
        return $compatibleValues;
4✔
301
    }
302

303
    private function getArrayTypes(array $array): array
40✔
304
    {
305
        return array_unique(array_map(
40✔
306
            static fn($item): string => gettype($item),
40✔
307
            $array,
40✔
308
        ));
40✔
309
    }
310

311
    private function renderEnum(
27✔
312
        GeneratorConfiguration $generatorConfiguration,
313
        JsonSchema $jsonSchema,
314
        string $name,
315
        array $values,
316
        ?array $map,
317
    ): string {
318
        $cases = [];
27✔
319
        $name = ucfirst((string) preg_replace('/\W/', '', ucwords($name, '_-. ')));
27✔
320

321
        foreach ($values as $value) {
27✔
322
            $cases[$this->getCaseName($map, $value, $jsonSchema)] = var_export($value, true);
27✔
323
        }
324

325
        $backedType = null;
26✔
326
        switch ($this->getArrayTypes($values)) {
26✔
327
            case ['string']:
328
                $backedType = 'string';
24✔
329
                break;
24✔
330
            case ['integer']:
2✔
331
                $backedType = 'int';
1✔
332
                break;
1✔
333
        }
334

335
        // make sure different enums with an identical name don't overwrite each other
336
        while (in_array("$this->namespace\\$name", array_column($this->generatedEnums, 'fqcn'))) {
26✔
337
            $name .= '_1';
2✔
338
        }
339

340
        $result = file_put_contents(
26✔
341
            $filename = $this->targetDirectory . DIRECTORY_SEPARATOR . $name . '.php',
26✔
342
            $this->renderer->renderTemplate(
26✔
343
                'Enum.phptpl',
26✔
344
                [
26✔
345
                    'namespace' => $this->namespace,
26✔
346
                    'name' => $name,
26✔
347
                    'cases' => $cases,
26✔
348
                    'backedType' => $backedType,
26✔
349
                ],
26✔
350
            )
26✔
351
        );
26✔
352

353
        $fqcn = "$this->namespace\\$name";
26✔
354

355
        if ($result === false) {
26✔
356
            // @codeCoverageIgnoreStart
357
            throw new FileSystemException("Can't write enum $fqcn.");
358
            // @codeCoverageIgnoreEnd
359
        }
360

361
        require $filename;
26✔
362

363
        if ($generatorConfiguration->isOutputEnabled()) {
26✔
364
            // @codeCoverageIgnoreStart
365
            echo "Rendered enum $fqcn\n";
366
            // @codeCoverageIgnoreEnd
367
        }
368

369
        return $fqcn;
26✔
370
    }
371

372
    private function getCaseName(?array $map, mixed $value, JsonSchema $jsonSchema): string
27✔
373
    {
374
        $caseName = ucfirst(NormalizedName::from($map ? array_search($value, $map, true) : $value, $jsonSchema));
27✔
375

376
        if (preg_match('/^\d/', $caseName) === 1) {
26✔
377
            return "_$caseName";
2✔
378
        }
379

380
        return $caseName;
25✔
381
    }
382
}
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