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

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

04 Mar 2026 11:34PM UTC coverage: 98.631% (-0.06%) from 98.693%
22694612910

Pull #115

github

web-flow
Merge c82ead07c into d14ae3d85
Pull Request #115: Add native PHP union type hints (fixes #110, fixes #114)

178 of 183 new or added lines in 18 files covered. (97.27%)

6 existing lines in 2 files now uncovered.

3601 of 3651 relevant lines covered (98.63%)

555.14 hits per line

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

95.45
/src/Model/Schema.php
1
<?php
2

3
declare(strict_types = 1);
4

5
namespace PHPModelGenerator\Model;
6

7
use PHPModelGenerator\Interfaces\JSONModelInterface;
8
use PHPModelGenerator\Model\Property\PropertyInterface;
9
use PHPModelGenerator\Model\Property\PropertyType;
10
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
11
use PHPModelGenerator\Model\SchemaDefinition\JsonSchemaTrait;
12
use PHPModelGenerator\Model\SchemaDefinition\SchemaDefinitionDictionary;
13
use PHPModelGenerator\Exception\SchemaException;
14
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
15
use PHPModelGenerator\Model\Validator\PropertyValidatorInterface;
16
use PHPModelGenerator\Model\Validator\SchemaDependencyValidator;
17
use PHPModelGenerator\Model\Validator\TypeCheckInterface;
18
use PHPModelGenerator\PropertyProcessor\ComposedValue\AllOfProcessor;
19
use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator;
20
use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookInterface;
21

22
/**
23
 * Class Schema
24
 *
25
 * @package PHPModelGenerator\Model
26
 */
27
class Schema
28
{
29
    use JsonSchemaTrait;
30

31
    /** @var string */
32
    protected $description;
33

34
    /** @var string[] */
35
    protected $traits = [];
36
    /** @var string[] */
37
    protected $interfaces = [];
38
    /** @var PropertyInterface[] The properties which are part of the class */
39
    protected $properties = [];
40
    /** @var MethodInterface[] */
41
    protected $methods = [];
42

43
    /** @var PropertyValidatorInterface[] A Collection of validators which must be applied
44
     *                                    before adding properties to the model
45
     */
46
    protected $baseValidators = [];
47
    /** @var string[] */
48
    protected $usedClasses = [];
49
    /** @var SchemaNamespaceTransferDecorator[] */
50
    protected $namespaceTransferDecorators = [];
51
    /** @var SchemaHookInterface[] */
52
    protected $schemaHooks = [];
53

54
    protected SchemaDefinitionDictionary $schemaDefinitionDictionary;
55

56
    private int $resolvedProperties = 0;
57
    /** @var callable[] */
58
    private array $onAllPropertiesResolvedCallbacks = [];
59

60
    /**
61
     * Schema constructor.
62
     */
63
    public function __construct(
2,041✔
64
        protected string $targetFileName,
65
        protected string $classPath,
66
        protected string $className,
67
        JsonSchema $schema,
68
        ?SchemaDefinitionDictionary $dictionary = null,
69
        protected bool $initialClass = false,
70
    ) {
71
        $this->jsonSchema = $schema;
2,041✔
72
        $this->schemaDefinitionDictionary = $dictionary ?? new SchemaDefinitionDictionary($schema);
2,041✔
73
        $this->description = $schema->getJson()['description'] ?? '';
2,041✔
74

75
        $this->addInterface(JSONModelInterface::class);
2,041✔
76
    }
77

78
    public function getTargetFileName(): string
1,970✔
79
    {
80
        return $this->targetFileName;
1,970✔
81
    }
82

83
    public function getClassName(): string
1,959✔
84
    {
85
        return $this->className;
1,959✔
86
    }
87

88
    public function getClassPath(): string
1,956✔
89
    {
90
        return $this->classPath;
1,956✔
91
    }
92

93
    public function getDescription(): string
1,954✔
94
    {
95
        return $this->description;
1,954✔
96
    }
97

98
    public function onAllPropertiesResolved(callable $callback): self
328✔
99
    {
100
        $this->resolvedProperties === count($this->properties)
328✔
101
            ? $callback()
328✔
UNCOV
102
            : $this->onAllPropertiesResolvedCallbacks[] = $callback;
×
103

104
        return $this;
328✔
105
    }
106

107
    /**
108
     * @return PropertyInterface[]
109
     */
110
    public function getProperties(): array
1,970✔
111
    {
112
        $hasSchemaDependencyValidator = static function (PropertyInterface $property): bool {
1,970✔
113
            foreach ($property->getValidators() as $validator) {
1,047✔
114
                if ($validator->getValidator() instanceof SchemaDependencyValidator) {
841✔
115
                    return true;
63✔
116
                }
117
            }
118

119
            return false;
1,047✔
120
        };
1,970✔
121

122
        // order the properties to make sure properties with a SchemaDependencyValidator are validated at the beginning
123
        // of the validation process for correct exception order of the messages
124
        usort(
1,970✔
125
            $this->properties,
1,970✔
126
            static function (
1,970✔
127
                PropertyInterface $property,
1,970✔
128
                PropertyInterface $comparedProperty,
1,970✔
129
            ) use ($hasSchemaDependencyValidator): int {
1,970✔
130
                $propertyHasSchemaDependencyValidator = $hasSchemaDependencyValidator($property);
1,047✔
131
                $comparedPropertyHasSchemaDependencyValidator = $hasSchemaDependencyValidator($comparedProperty);
1,047✔
132
                return $comparedPropertyHasSchemaDependencyValidator <=> $propertyHasSchemaDependencyValidator;
1,047✔
133
            },
1,970✔
134
        );
1,970✔
135

136
        return $this->properties;
1,970✔
137
    }
138

139
    /**
140
     * @param string|null $compositionProcessor The FQCN of the composition processor transferring this property,
141
     *                                           or null when not called from a composition context.
142
     *
143
     * @throws SchemaException
144
     */
145
    public function addProperty(PropertyInterface $property, ?string $compositionProcessor = null): self
1,941✔
146
    {
147
        if (!isset($this->properties[$property->getName()])) {
1,941✔
148
            $this->properties[$property->getName()] = $property;
1,941✔
149

150
            $property->onResolve(function (): void {
1,941✔
151
                if (++$this->resolvedProperties === count($this->properties)) {
1,897✔
152
                    foreach ($this->onAllPropertiesResolvedCallbacks as $callback) {
1,897✔
153
                        $callback();
×
154

155
                        $this->onAllPropertiesResolvedCallbacks = [];
×
156
                    }
157
                }
158
            });
1,941✔
159
        } else {
160
            $existing = $this->properties[$property->getName()];
74✔
161

162
            // Nested-object merging is owned by the merged-property system; don't interfere.
163
            if ($existing->getNestedSchema() !== null || $property->getNestedSchema() !== null) {
74✔
NEW
UNCOV
164
                return $this;
×
165
            }
166

167
            // Use getType(true) for the stored output type.
168
            // getType(false) post-Phase-5 returns a synthesised union and cannot be decomposed.
169
            $existingOutput = $existing->getType(true);
74✔
170
            $incomingOutput = $property->getType(true);
74✔
171

172
            if (!$incomingOutput) {
74✔
173
                // NullProcessor sets type=null but registers a 'null' type hint decorator.
174
                // Distinguish: truly untyped ("age: {}") vs explicit null-type ("age: {"type":"null"}").
175
                if (str_contains($property->getTypeHint(), 'null')) {
46✔
176
                    // Explicit null-type branch: treat as $hasNull=true with no scalar names.
177
                    if ($existingOutput) {
1✔
178
                        $existing->setType(
1✔
179
                            new PropertyType($existingOutput->getNames(), true),
1✔
180
                            new PropertyType($existingOutput->getNames(), true),
1✔
181
                        );
1✔
182
                        $existing->filterValidators(
1✔
183
                            static fn(Validator $validator): bool =>
1✔
184
                                !($validator->getValidator() instanceof TypeCheckInterface),
1✔
185
                        );
1✔
186
                    }
187
                } else {
188
                    // Truly untyped branch: combined type is unbounded — remove the type hint.
189
                    $existing->setType(null, null);
45✔
190
                }
191
                return $this;
46✔
192
            }
193

194
            // Existing type is null — this happens when a null branch (or untyped branch) was
195
            // added first and the incoming branch now brings a concrete type.
196
            if (!$existingOutput) {
58✔
197
                // Only promote if the existing property has a 'null' typeHint (i.e. it was an
198
                // explicit null-type branch, not a truly untyped branch).
199
                if (str_contains($existing->getTypeHint(), 'null')) {
2✔
200
                    $names = $incomingOutput->getNames();
1✔
201
                    $existing->setType(
1✔
202
                        new PropertyType($names, true),
1✔
203
                        new PropertyType($names, true),
1✔
204
                    );
1✔
205
                    $existing->filterValidators(
1✔
206
                        static fn(Validator $validator): bool =>
1✔
207
                            !($validator->getValidator() instanceof TypeCheckInterface),
1✔
208
                    );
1✔
209
                }
210
                // For truly untyped existing: keep as-is (no type hint).
211
                return $this;
2✔
212
            }
213

214
            $allNames = array_merge($existingOutput->getNames(), $incomingOutput->getNames());
56✔
215

216
            // Strip 'null' → nullable flag; PropertyType constructor deduplicates the rest.
217
            // Also propagate any nullable=true already set on either side (e.g. from
218
            // cloneTransferredProperty which marks all non-allOf branch properties as nullable).
219
            $hasNull = in_array('null', $allNames, true)
56✔
220
                || $existingOutput->isNullable() === true
56✔
221
                || $incomingOutput->isNullable() === true;
56✔
222
            $nonNullNames = array_values(array_filter($allNames, fn(string $t): bool => $t !== 'null'));
56✔
223

224
            if (!$nonNullNames) {
56✔
NEW
UNCOV
225
                return $this;
×
226
            }
227

228
            $mergedType = new PropertyType($nonNullNames, $hasNull ? true : null);
56✔
229

230
            if ($mergedType->getNames() === $existingOutput->getNames()
56✔
231
                && $mergedType->isNullable() === $existingOutput->isNullable()
56✔
232
            ) {
233
                return $this;
37✔
234
            }
235

236
            // allOf requires all branches to satisfy simultaneously — conflicting scalar type
237
            // names (e.g. string vs int) are unsatisfiable and must be rejected at generation time.
238
            // Differences in nullability alone are not a conflict: they represent constraints
239
            // that the runtime validator already enforces per-branch.
240
            $existingNonNull = array_values(array_filter($existingOutput->getNames(), fn(string $t): bool => $t !== 'null'));
21✔
241
            if ($compositionProcessor !== null
21✔
242
                && is_a($compositionProcessor, AllOfProcessor::class, true)
21✔
243
                && $mergedType->getNames() !== $existingNonNull
21✔
244
            ) {
245
                throw new SchemaException(
1✔
246
                    sprintf(
1✔
247
                        "Property '%s' is defined with conflicting types across allOf branches. " .
1✔
248
                        "allOf requires all constraints to hold simultaneously, making this schema unsatisfiable.",
1✔
249
                        $property->getName(),
1✔
250
                    ),
1✔
251
                );
1✔
252
            }
253

254
            $existing->setType($mergedType, $mergedType);
20✔
255

256
            $existing->filterValidators(
20✔
257
                static fn(Validator $validator): bool =>
20✔
258
                    !($validator->getValidator() instanceof TypeCheckInterface),
20✔
259
            );
20✔
260
        }
261

262
        return $this;
1,941✔
263
    }
264

265
    /**
266
     * @return PropertyValidatorInterface[]
267
     */
268
    public function getBaseValidators(): array
1,969✔
269
    {
270
        return $this->baseValidators;
1,969✔
271
    }
272

273
    /**
274
     * Get the keys of all composition base validators
275
     */
276
    public function getCompositionValidatorKeys(): array
560✔
277
    {
278
        $keys = [];
560✔
279

280
        foreach ($this->baseValidators as $key => $validator) {
560✔
281
            if (is_a($validator, AbstractComposedPropertyValidator::class)) {
560✔
282
                $keys[] = $key;
238✔
283
            }
284
        }
285

286
        return $keys;
560✔
287
    }
288

289
    public function addBaseValidator(PropertyValidatorInterface $baseValidator): self
564✔
290
    {
291
        $this->baseValidators[] = $baseValidator;
564✔
292

293
        return $this;
564✔
294
    }
295

296
    public function getSchemaDictionary(): SchemaDefinitionDictionary
2,033✔
297
    {
298
        return $this->schemaDefinitionDictionary;
2,033✔
299
    }
300

301
    /**
302
     * Add a class to the schema which is required
303
     */
304
    public function addUsedClass(string $fqcn): self
2,041✔
305
    {
306
        $this->usedClasses[] = trim($fqcn, '\\');
2,041✔
307

308
        return $this;
2,041✔
309
    }
310

311
    public function addNamespaceTransferDecorator(SchemaNamespaceTransferDecorator $decorator): self
862✔
312
    {
313
        $this->namespaceTransferDecorators[] = $decorator;
862✔
314

315
        return $this;
862✔
316
    }
317

318
    /**
319
     * @param Schema[] $visitedSchema
320
     *
321
     * @return string[]
322
     */
323
    public function getUsedClasses(array $visitedSchema = []): array
1,954✔
324
    {
325
        $usedClasses = $this->usedClasses;
1,954✔
326

327
        foreach ($this->namespaceTransferDecorators as $decorator) {
1,954✔
328
            $usedClasses = array_merge($usedClasses, $decorator->resolve(array_merge($visitedSchema, [$this])));
860✔
329
        }
330

331
        return $usedClasses;
1,954✔
332
    }
333

334
    /**
335
     * @param string $methodKey An unique key in the scope of the schema to identify the method
336
     */
337
    public function addMethod(string $methodKey, MethodInterface $method): self
999✔
338
    {
339
        $this->methods[$methodKey] = $method;
999✔
340

341
        return $this;
999✔
342
    }
343

344
    /**
345
     * @return MethodInterface[]
346
     */
347
    public function getMethods(): array
1,954✔
348
    {
349
        return $this->methods;
1,954✔
350
    }
351

352
    public function hasMethod(string $methodKey): bool
697✔
353
    {
354
        return isset($this->methods[$methodKey]);
697✔
355
    }
356

357
    /**
358
     * @return string[]
359
     */
360
    public function getTraits(): array
1,954✔
361
    {
362
        return $this->traits;
1,954✔
363
    }
364

365
    public function addTrait(string $trait): self
49✔
366
    {
367
        $this->traits[] = $trait;
49✔
368
        $this->addUsedClass($trait);
49✔
369

370
        return $this;
49✔
371
    }
372

373
    /**
374
     * @return string[]
375
     */
376
    public function getInterfaces(): array
1,954✔
377
    {
378
        return $this->interfaces;
1,954✔
379
    }
380

381
    public function addInterface(string $interface): self
2,041✔
382
    {
383
        $this->interfaces[] = $interface;
2,041✔
384
        $this->addUsedClass($interface);
2,041✔
385

386
        return $this;
2,041✔
387
    }
388

389
    /**
390
     * Add an additional schema hook
391
     */
392
    public function addSchemaHook(SchemaHookInterface $schemaHook): self
1,968✔
393
    {
394
        $this->schemaHooks[] = $schemaHook;
1,968✔
395

396
        return $this;
1,968✔
397
    }
398

399
    /**
400
     * @return SchemaHookInterface[]
401
     */
402
    public function getSchemaHooks(): array
1,954✔
403
    {
404
        return $this->schemaHooks;
1,954✔
405
    }
406

UNCOV
407
    public function isInitialClass(): bool
×
408
    {
UNCOV
409
        return $this->initialClass;
×
410
    }
411
}
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