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

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

15 May 2026 11:04PM UTC coverage: 97.998% (-0.6%) from 98.554%
25945556987

Pull #128

github

web-flow
Merge ce9326ed2 into fddd87e19
Pull Request #128: Filter composition ordering

750 of 796 new or added lines in 22 files covered. (94.22%)

9 existing lines in 3 files now uncovered.

5433 of 5544 relevant lines covered (98.0%)

588.63 hits per line

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

97.66
/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Model\Validator\Factory\Composition;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Model\Property\BaseProperty;
9
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
10
use PHPModelGenerator\Model\Property\PropertyInterface;
11
use PHPModelGenerator\Model\Property\PropertyType;
12
use PHPModelGenerator\Model\Schema;
13
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
14
use PHPModelGenerator\Model\Validator;
15
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
16
use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory;
17
use PHPModelGenerator\Model\Validator\InstanceOfValidator;
18
use PHPModelGenerator\Model\Validator\RequiredPropertyValidator;
19
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator;
20
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator;
21
use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker;
22
use PHPModelGenerator\PropertyProcessor\PropertyFactory;
23
use PHPModelGenerator\SchemaProcessor\SchemaProcessor;
24
use PHPModelGenerator\Utils\TypeIntersection;
25

26
abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory
27
{
28
    /**
29
     * Emit a warning when the composition array for the current keyword is empty.
30
     */
31
    protected function warnIfEmpty(
643✔
32
        SchemaProcessor $schemaProcessor,
33
        PropertyInterface $property,
34
        JsonSchema $propertySchema,
35
    ): void {
36
        if (
37
            empty($propertySchema->getJson()[$this->key]) &&
643✔
38
            $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()
643✔
39
        ) {
40
            // @codeCoverageIgnoreStart
41
            echo "Warning: empty composition for {$property->getName()} may lead to unexpected results\n";
42
            // @codeCoverageIgnoreEnd
43
        }
44
    }
45

46
    /**
47
     * Returns true when composition processing should be skipped for this property.
48
     *
49
     * For non-root object-typed properties, composition keywords are processed inside
50
     * the nested schema by processSchema (with the type=base path). Adding a composition
51
     * validator at the parent level would duplicate validation and inject a _Merged_ type
52
     * hint that overrides the correct nested-class type.
53
     */
54
    protected function shouldSkip(PropertyInterface $property, JsonSchema $propertySchema): bool
799✔
55
    {
56
        return !($property instanceof BaseProperty)
799✔
57
            && ($propertySchema->getJson()['type'] ?? '') === 'object';
799✔
58
    }
59

60
    /**
61
     * Check the (post-type-inheritance) composition branches for filter keywords.
62
     *
63
     * Must be called AFTER inheritPropertyType() in each modify() method. A branch that
64
     * inherits "object" from the parent is genuinely object-typed: PropertyFactory routes
65
     * it through processSchema, producing a nested class whose properties are processed
66
     * independently and are not subject to ComposedItem $value reset. branchContainsFilter()
67
     * correctly skips the properties scan for such branches.
68
     *
69
     * For "not", the value is a single branch schema (not an array); all other keywords
70
     * use an array of branches.
71
     *
72
     * TODO: filters inside composition branches cannot be correctly applied
73
     * (ComposedItem.phptpl resets $value to $originalModelData after each branch).
74
     * Proper per-branch filter chaining is deferred to a follow-up topic.
75
     *
76
     * @throws SchemaException
77
     */
78
    protected function checkForFilterInBranches(
741✔
79
        PropertyInterface $property,
80
        JsonSchema $propertySchema,
81
    ): void {
82
        $json = $propertySchema->getJson();
741✔
83

84
        if ($this->key === 'not') {
741✔
85
            $branch = $json['not'] ?? null;
102✔
86
            if (
87
                is_array($branch)
102✔
88
                && CompositionCompatibilityChecker::branchContainsFilter($branch)
102✔
89
            ) {
90
                throw new SchemaException(sprintf(
1✔
91
                    'A filter keyword inside a not composition branch is not supported'
1✔
92
                        . ' for property %s in file %s.',
1✔
93
                    $property->getName(),
1✔
94
                    $property->getJsonSchema()->getFile(),
1✔
95
                ));
1✔
96
            }
97
            return;
101✔
98
        }
99

100
        foreach ($json[$this->key] ?? [] as $index => $compositionElement) {
643✔
101
            if (
102
                is_array($compositionElement)
624✔
103
                && CompositionCompatibilityChecker::branchContainsFilter($compositionElement)
624✔
104
            ) {
105
                throw new SchemaException(sprintf(
5✔
106
                    'A filter keyword inside a %s composition branch is not supported'
5✔
107
                        . ' for property %s in file %s (branch #%d).',
5✔
108
                    $this->key,
5✔
109
                    $property->getName(),
5✔
110
                    $property->getJsonSchema()->getFile(),
5✔
111
                    $index,
5✔
112
                ));
5✔
113
            }
114
        }
115
    }
116

117
    /**
118
     * Build composition sub-properties for the current keyword's branches.
119
     *
120
     * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches.
121
     *
122
     * @return CompositionPropertyDecorator[]
123
     *
124
     * @throws SchemaException
125
     */
126
    protected function getCompositionProperties(
735✔
127
        SchemaProcessor $schemaProcessor,
128
        Schema $schema,
129
        PropertyInterface $property,
130
        JsonSchema $propertySchema,
131
        bool $merged,
132
    ): array {
133
        $propertyFactory = new PropertyFactory();
735✔
134
        $compositionProperties = [];
735✔
135
        $json = $propertySchema->getJson()['propertySchema']->getJson();
735✔
136

137
        $property->addTypeHintDecorator(new ClearTypeHintDecorator());
735✔
138

139
        foreach ($json[$this->key] as $index => $compositionElement) {
735✔
140
            $compositionSchema = $propertySchema->getJson()['propertySchema']->navigate("$this->key/$index");
716✔
141

142
            $compositionProperty = new CompositionPropertyDecorator(
716✔
143
                $property->getName(),
716✔
144
                $compositionSchema,
716✔
145
                $propertyFactory->create(
716✔
146
                    $schemaProcessor,
716✔
147
                    $schema,
716✔
148
                    $property->getName(),
716✔
149
                    $compositionSchema,
716✔
150
                    $property->isRequired(),
716✔
151
                ),
716✔
152
            );
716✔
153

154
            $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void {
716✔
155
                $nestedSchema = $compositionProperty->getNestedSchema();
716✔
156

157
                $compositionProperty->filterValidators(
716✔
158
                    static function (Validator $validator) use ($nestedSchema): bool {
716✔
159
                        if (is_a($validator->getValidator(), RequiredPropertyValidator::class)) {
711✔
160
                            return false;
461✔
161
                        }
162
                        if (is_a($validator->getValidator(), ComposedPropertyValidator::class)) {
705✔
NEW
UNCOV
163
                            return false;
×
164
                        }
165
                        // An empty object schema ({type: object} with no declared properties)
166
                        // must accept any PHP object in composition context. The generated
167
                        // placeholder class carries no semantic constraints, so the strict
168
                        // instanceof check against it would incorrectly reject valid objects
169
                        // (e.g. a DateTime produced by a transforming filter) that are perfectly
170
                        // acceptable under the schema's actual semantics.
171
                        if (
172
                            is_a($validator->getValidator(), InstanceOfValidator::class)
705✔
173
                            && $nestedSchema !== null
705✔
174
                            && empty($nestedSchema->getProperties())
705✔
175
                        ) {
176
                            return false;
5✔
177
                        }
178
                        return true;
705✔
179
                    },
716✔
180
                );
716✔
181

182
                if (!($merged && $compositionProperty->getNestedSchema())) {
716✔
183
                    $property->addTypeHintDecorator(new CompositionTypeHintDecorator($compositionProperty));
421✔
184
                }
185
            });
716✔
186

187
            $compositionProperties[] = $compositionProperty;
716✔
188
        }
189

190
        return $compositionProperties;
735✔
191
    }
192

193
    /**
194
     * Inherit a parent-level type into composition branches that declare no type.
195
     */
196
    protected function inheritPropertyType(JsonSchema $propertySchema): JsonSchema
798✔
197
    {
198
        $json = $propertySchema->getJson();
798✔
199

200
        if (!isset($json['type'])) {
798✔
201
            return $propertySchema;
352✔
202
        }
203

204
        switch ($this->key) {
467✔
205
            case 'not':
467✔
206
                if (!isset($json[$this->key]['type'])) {
28✔
207
                    $json[$this->key]['type'] = $json['type'];
28✔
208
                }
209
                break;
28✔
210
            case 'if':
439✔
211
                return $this->inheritIfPropertyType($propertySchema->withJson($json));
70✔
212
            default:
213
                foreach ($json[$this->key] as &$composedElement) {
387✔
214
                    if (!isset($composedElement['type'])) {
386✔
215
                        $composedElement['type'] = $json['type'];
175✔
216
                    }
217
                }
218
        }
219

220
        return $propertySchema->withJson($json);
415✔
221
    }
222

223
    /**
224
     * Inherit the parent type into all branches of an if/then/else composition.
225
     */
226
    protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema
70✔
227
    {
228
        $json = $propertySchema->getJson();
70✔
229

230
        foreach (['if', 'then', 'else'] as $keyword) {
70✔
231
            if (!isset($json[$keyword])) {
70✔
232
                continue;
17✔
233
            }
234

235
            if (!isset($json[$keyword]['type'])) {
70✔
236
                $json[$keyword]['type'] = $json['type'];
70✔
237
            }
238
        }
239

240
        return $propertySchema->withJson($json);
70✔
241
    }
242

243
    /**
244
     * After all composition branches resolve, derive the parent property's type from the
245
     * branch types and apply it. Skips when any branch has a nested schema (object merging
246
     * is handled elsewhere).
247
     *
248
     * allOf: intersect all typed branch types — only values satisfying every branch simultaneously
249
     * are valid, so the PHP type is the intersection. Branches with no declared type impose no
250
     * constraint and are excluded from the intersection. An empty intersection (contradictory
251
     * branch types) throws SchemaException because no value can ever be valid.
252
     *
253
     * anyOf / oneOf: union of all typed branch types — at least one branch must pass, so the PHP
254
     * type is the union. An untyped branch accepts every value, making the composition satisfied
255
     * by any input; the property's type hint is removed (remains mixed) in that case.
256
     *
257
     * @param bool $isAllOf true for allOf, false for anyOf/oneOf.
258
     * @param CompositionPropertyDecorator[] $compositionProperties
259
     *
260
     * @throws SchemaException when allOf branches declare contradictory types.
261
     */
262
    protected function transferPropertyType(
621✔
263
        PropertyInterface $property,
264
        array $compositionProperties,
265
        bool $isAllOf,
266
    ): void {
267
        foreach ($compositionProperties as $compositionProperty) {
621✔
268
            if ($compositionProperty->getNestedSchema() !== null) {
621✔
269
                return;
466✔
270
            }
271
        }
272

273
        $hasBranchWithRequiredProperty = array_filter(
156✔
274
            $compositionProperties,
156✔
275
            static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(),
156✔
276
        ) !== [];
156✔
277

278
        $hasBranchWithOptionalProperty = $isAllOf
156✔
279
            ? !$hasBranchWithRequiredProperty
40✔
280
            : array_filter(
116✔
281
                $compositionProperties,
116✔
282
                static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(),
116✔
283
            ) !== [];
116✔
284

285
        if ($isAllOf) {
156✔
286
            $this->transferAllOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
40✔
287
            return;
38✔
288
        }
289

290
        $this->transferAnyOfOneOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
116✔
291
    }
292

293
    /**
294
     * Derive and apply the parent property's type using allOf intersection semantics.
295
     *
296
     * Only typed branches (those that declare a type keyword) constrain the intersection.
297
     * Untyped branches impose no type restriction and are excluded. Null is valid only when
298
     * ALL typed branches allow it. An empty non-null intersection (contradictory types) throws
299
     * SchemaException — no value can satisfy all branch type constraints simultaneously.
300
     *
301
     * @param CompositionPropertyDecorator[] $compositionProperties
302
     *
303
     * @throws SchemaException
304
     */
305
    private function transferAllOfType(
40✔
306
        PropertyInterface $property,
307
        array $compositionProperties,
308
        bool $hasBranchWithOptionalProperty,
309
    ): void {
310
        $constrainingBranches = array_values(array_filter(
40✔
311
            $compositionProperties,
40✔
312
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
40✔
313
        ));
40✔
314

315
        if (empty($constrainingBranches)) {
40✔
316
            // No typed branches — no type constraint to apply.
317
            return;
3✔
318
        }
319

320
        // Intersection of non-null type names across all typed branches.
321
        // TypeIntersection::compute handles int ⊂ float (integer is a subtype of number in JSON Schema).
322
        $nonNullSets = array_map(
37✔
323
            static fn(CompositionPropertyDecorator $p): array => array_values(array_filter(
37✔
324
                $p->getType()->getNames(),
37✔
325
                static fn(string $typeName): bool => $typeName !== 'null',
37✔
326
            )),
37✔
327
            $constrainingBranches,
37✔
328
        );
37✔
329
        $nonNullNames = array_shift($nonNullSets);
37✔
330
        foreach ($nonNullSets as $typeSet) {
37✔
331
            $nonNullNames = TypeIntersection::compute($nonNullNames, $typeSet);
30✔
332
        }
333

334
        // Null is valid in allOf only when ALL typed branches allow it.
335
        $allBranchesAllowNull = count(array_filter(
37✔
336
            $constrainingBranches,
37✔
337
            static fn(CompositionPropertyDecorator $p): bool =>
37✔
338
                in_array('null', $p->getType()->getNames(), true)
37✔
339
                || $p->getType()->isNullable() === true,
37✔
340
        )) === count($constrainingBranches);
37✔
341

342
        if (empty($nonNullNames) && !$allBranchesAllowNull) {
37✔
343
            throw new SchemaException(sprintf(
2✔
344
                "Property '%s' is defined with conflicting types in allOf composition branches"
2✔
345
                    . ' (file %s). allOf requires all constraints to hold simultaneously,'
2✔
346
                    . ' making this schema unsatisfiable.',
2✔
347
                $property->getName(),
2✔
348
                $property->getJsonSchema()->getFile(),
2✔
349
            ));
2✔
350
        }
351

352
        if (empty($nonNullNames)) {
35✔
353
            // Only null survives the intersection; the null-processor path handles pure-null types.
NEW
UNCOV
354
            return;
×
355
        }
356

357
        $nullable = ($allBranchesAllowNull || $hasBranchWithOptionalProperty) ? true : null;
35✔
358
        $property->setType(new PropertyType($nonNullNames, $nullable));
35✔
359
    }
360

361
    /**
362
     * Derive and apply the parent property's type using anyOf/oneOf union semantics.
363
     *
364
     * Branches are partitioned into three categories:
365
     * - Typed (getType() !== null): contribute their names to the union.
366
     * - Explicit null-type ({type:null}): getType() is null but typeHint contains 'null';
367
     *   contributes nullable=true to the result.
368
     * - Truly untyped ({}): getType() is null and typeHint does not contain 'null'; the branch
369
     *   accepts every value, so the composition is always satisfiable and no type hint applies.
370
     *
371
     * A truly untyped branch causes early return without setting a type (property remains mixed),
372
     * matching the behaviour of PropertyMerger::mergeNullableBranch for object-level compositions.
373
     * An explicit null-type branch ({type:null}) is NOT treated as untyped — it adds nullable=true
374
     * to the typed union rather than removing the type hint.
375
     *
376
     * @param CompositionPropertyDecorator[] $compositionProperties
377
     */
378
    private function transferAnyOfOneOfType(
116✔
379
        PropertyInterface $property,
380
        array $compositionProperties,
381
        bool $hasBranchWithOptionalProperty,
382
    ): void {
383
        $hasExplicitNullBranch = false;
116✔
384

385
        foreach ($compositionProperties as $compositionProperty) {
116✔
386
            if ($compositionProperty->getType() !== null) {
116✔
387
                continue;
112✔
388
            }
389

390
            if (str_contains($compositionProperty->getTypeHint(), 'null')) {
7✔
391
                // Explicit null-type branch ({type: null}): contributes nullable=true.
392
                $hasExplicitNullBranch = true;
1✔
393
            } else {
394
                // Truly untyped branch ({}): any value is valid, so the composition is
395
                // always satisfiable — no type hint is appropriate for this property.
396
                return;
6✔
397
            }
398
        }
399

400
        $typedBranches = array_values(array_filter(
110✔
401
            $compositionProperties,
110✔
402
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
110✔
403
        ));
110✔
404

405
        if (empty($typedBranches)) {
110✔
406
            // Only explicit null branches; no scalar type to build a union from.
NEW
UNCOV
407
            return;
×
408
        }
409

410
        $allNames = array_merge(...array_map(
110✔
411
            static fn(CompositionPropertyDecorator $p): array => $p->getType()->getNames(),
110✔
412
            $typedBranches,
110✔
413
        ));
110✔
414

415
        $hasNull = $hasExplicitNullBranch || in_array('null', $allNames, true);
110✔
416
        $nonNullNames = array_values(array_filter(
110✔
417
            array_unique($allNames),
110✔
418
            static fn(string $typeName): bool => $typeName !== 'null',
110✔
419
        ));
110✔
420

421
        if (!$nonNullNames) {
110✔
UNCOV
422
            return;
×
423
        }
424

425
        $nullable = ($hasNull || $hasBranchWithOptionalProperty) ? true : null;
110✔
426
        $property->setType(new PropertyType($nonNullNames, $nullable));
110✔
427
    }
428
}
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