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

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

20 May 2026 02:36PM UTC coverage: 98.483% (-0.07%) from 98.554%
26169686326

Pull #128

github

wol-soft
Close filter/composition coverage gaps: remove dead code, add targeted tests

- Remove unreachable !is_array() guards in CompositionBranchClassifier:
  the not branch always receives an array (NotValidatorFactory wraps it),
  and non-array allOf/anyOf/oneOf values crash before the classifier runs.

- Drop redundant testOutputTypeNotExtendedWhenReturnTypeAlreadyInBaseType:
  the applyOutputType early-return path is already exercised by the
  existing testOutputSpaceNotCompositionRunsPostTransform which uses the
  same type:["string","integer"] + stringToInt schema shape.

- Extend testNoExtendedInstanceOfCheckWhenAllObjectBranchesAreNonEmpty
  with an unparseable-string assertion: 'Hello' passes the input-space
  anyOf (via type:string) but fails the dateTime filter, throwing
  InvalidFilterValueException.

- Move testFilterOnObjectTypedPropertyWithNestedSchema to
  FilterTypeCompatibilityTest (no composition in that schema) and add a
  real runtime assertion: array input is instantiated to the inner class
  by ObjectInstantiationDecorator, then the filter converts it to DateTime.

- Add static test cases for root-level then/else constraining a filtered
  subproperty with output-type-space keywords, a filter inside an if
  sub-schema within an allOf branch, and root-level not with an
  input-space keyword on a filtered subproperty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #128: Filter composition ordering

771 of 790 new or added lines in 22 files covered. (97.59%)

5 existing lines in 3 files now uncovered.

5454 of 5538 relevant lines covered (98.48%)

594.2 hits per line

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

99.42
/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(
653✔
32
        SchemaProcessor $schemaProcessor,
33
        PropertyInterface $property,
34
        JsonSchema $propertySchema,
35
    ): void {
36
        if (
37
            empty($propertySchema->getJson()[$this->key]) &&
653✔
38
            $schemaProcessor->getGeneratorConfiguration()->isOutputEnabled()
653✔
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
813✔
55
    {
56
        return !($property instanceof BaseProperty)
813✔
57
            && ($propertySchema->getJson()['type'] ?? '') === 'object';
813✔
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(
752✔
79
        PropertyInterface $property,
80
        JsonSchema $propertySchema,
81
    ): void {
82
        $json = $propertySchema->getJson();
752✔
83

84
        if ($this->key === 'not') {
752✔
85
            $branch = $json['not'] ?? null;
103✔
86
            if (
87
                is_array($branch)
103✔
88
                && CompositionCompatibilityChecker::branchContainsFilter($branch)
103✔
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;
102✔
98
        }
99

100
        foreach ($json[$this->key] ?? [] as $index => $compositionElement) {
653✔
101
            if (
102
                is_array($compositionElement)
634✔
103
                && CompositionCompatibilityChecker::branchContainsFilter($compositionElement)
634✔
104
            ) {
105
                throw new SchemaException(sprintf(
7✔
106
                    'A filter keyword inside a %s composition branch is not supported'
7✔
107
                        . ' for property %s in file %s (branch #%d).',
7✔
108
                    $this->key,
7✔
109
                    $property->getName(),
7✔
110
                    $property->getJsonSchema()->getFile(),
7✔
111
                    $index,
7✔
112
                ));
7✔
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(
744✔
127
        SchemaProcessor $schemaProcessor,
128
        Schema $schema,
129
        PropertyInterface $property,
130
        JsonSchema $propertySchema,
131
        bool $merged,
132
    ): array {
133
        $propertyFactory = new PropertyFactory();
744✔
134
        $compositionProperties = [];
744✔
135
        $json = $propertySchema->getJson()['propertySchema']->getJson();
744✔
136

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

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

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

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

157
                $compositionProperty->filterValidators(
725✔
158
                    static function (Validator $validator) use ($nestedSchema): bool {
725✔
159
                        if (is_a($validator->getValidator(), RequiredPropertyValidator::class)) {
720✔
160
                            return false;
462✔
161
                        }
162
                        if (is_a($validator->getValidator(), ComposedPropertyValidator::class)) {
714✔
163
                            return false;
1✔
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)
714✔
173
                            && $nestedSchema !== null
714✔
174
                            && empty($nestedSchema->getProperties())
714✔
175
                        ) {
176
                            return false;
7✔
177
                        }
178
                        return true;
714✔
179
                    },
725✔
180
                );
725✔
181

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

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

190
        return $compositionProperties;
744✔
191
    }
192

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

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

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

220
        return $propertySchema->withJson($json);
417✔
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
73✔
227
    {
228
        $json = $propertySchema->getJson();
73✔
229

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

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

240
        return $propertySchema->withJson($json);
73✔
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(
629✔
263
        PropertyInterface $property,
264
        array $compositionProperties,
265
        bool $isAllOf,
266
    ): void {
267
        foreach ($compositionProperties as $compositionProperty) {
629✔
268
            if ($compositionProperty->getNestedSchema() !== null) {
629✔
269
                return;
468✔
270
            }
271
        }
272

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

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

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

290
        $this->transferAnyOfOneOfType($property, $compositionProperties, $hasBranchWithOptionalProperty);
120✔
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(
42✔
306
        PropertyInterface $property,
307
        array $compositionProperties,
308
        bool $hasBranchWithOptionalProperty,
309
    ): void {
310
        $constrainingBranches = array_values(array_filter(
42✔
311
            $compositionProperties,
42✔
312
            static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null,
42✔
313
        ));
42✔
314

315
        if (empty($constrainingBranches)) {
42✔
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(
39✔
323
            static fn(CompositionPropertyDecorator $p): array => array_values(array_filter(
39✔
324
                $p->getType()->getNames(),
39✔
325
                static fn(string $typeName): bool => $typeName !== 'null',
39✔
326
            )),
39✔
327
            $constrainingBranches,
39✔
328
        );
39✔
329
        $nonNullNames = array_shift($nonNullSets);
39✔
330
        foreach ($nonNullSets as $typeSet) {
39✔
331
            $nonNullNames = TypeIntersection::compute($nonNullNames, $typeSet);
32✔
332
        }
333

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

342
        if (empty($nonNullNames) && !$allBranchesAllowNull) {
39✔
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)) {
37✔
353
            // Only null survives the intersection; the null-processor path handles pure-null types.
354
            return;
1✔
355
        }
356

357
        $nullable = ($allBranchesAllowNull || $hasBranchWithOptionalProperty) ? true : null;
36✔
358
        $property->setType(new PropertyType($nonNullNames, $nullable));
36✔
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(
120✔
379
        PropertyInterface $property,
380
        array $compositionProperties,
381
        bool $hasBranchWithOptionalProperty,
382
    ): void {
383
        $hasExplicitNullBranch = false;
120✔
384

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

390
            if (str_contains($compositionProperty->getTypeHint(), 'null')) {
9✔
391
                // Explicit null-type branch ({type: null}): contributes nullable=true.
392
                $hasExplicitNullBranch = true;
2✔
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;
7✔
397
            }
398
        }
399

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

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

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

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

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

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