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

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

20 May 2026 10:28AM UTC coverage: 98.466% (-0.09%) from 98.554%
26158351968

Pull #128

github

wol-soft
Fix if/then/else branch conflict detection for null-typed and per-branch checks

IfValidatorFactory previously required both then and else branches to conflict
with the parent before throwing, and stripped null from parent type names — so a
null-typed branch against a non-null parent was silently accepted. Now each branch
is checked independently and null from isNullable() is included in the parent set.
getBranchTypeNames() distinguishes null-typed branches ({type: null}) from truly
untyped branches via getTypeHint(), so null-typed branches correctly intersect (or
conflict) with the parent type. CLAUDE.md extended with review-learning policy and
line-number-in-docblock rule. New test coverage for all affected edge cases.

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

772 of 792 new or added lines in 22 files covered. (97.47%)

5 existing lines in 3 files now uncovered.

5455 of 5540 relevant lines covered (98.47%)

592.57 hits per line

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

99.32
/src/Model/Validator/FilterValidator.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Model\Validator;
6

7
use PHPModelGenerator\Exception\Filter\IncompatibleFilterException;
8
use PHPModelGenerator\Exception\Filter\InvalidFilterValueException;
9
use PHPModelGenerator\Exception\SchemaException;
10
use PHPModelGenerator\Filter\FilterInterface;
11
use PHPModelGenerator\Filter\TransformingFilterInterface;
12
use PHPModelGenerator\Model\GeneratorConfiguration;
13
use PHPModelGenerator\Model\Property\PropertyInterface;
14
use PHPModelGenerator\Utils\FilterReflection;
15
use PHPModelGenerator\Utils\RenderHelper;
16
use PHPModelGenerator\Utils\TypeCheck;
17
use PHPModelGenerator\Utils\TypeConverter;
18
use ReflectionException;
19

20
/**
21
 * Class FilterValidator
22
 *
23
 * @package PHPModelGenerator\Model\Validator
24
 */
25
class FilterValidator extends PropertyTemplateValidator
26
{
27
    /**
28
     * FilterValidator constructor.
29
     *
30
     * @throws SchemaException
31
     * @throws ReflectionException
32
     */
33
    public function __construct(
218✔
34
        GeneratorConfiguration $generatorConfiguration,
35
        protected FilterInterface $filter,
36
        PropertyInterface $property,
37
        protected array $filterOptions = [],
38
        private readonly ?TransformingFilterInterface $transformingFilter = null,
39
    ) {
40
        $this->isResolved = true;
218✔
41

42
        $acceptedTypes = FilterReflection::getAcceptedTypes($this->filter, $property);
218✔
43

44
        $this->transformingFilter !== null
217✔
45
            ? $this->validateFilterCompatibilityWithTransformedType(
12✔
46
                $acceptedTypes,
12✔
47
                $this->transformingFilter,
12✔
48
                $property,
12✔
49
            )
12✔
50
            : $this->runCompatibilityCheck($acceptedTypes, $property);
217✔
51

52
        parent::__construct(
203✔
53
            $property,
203✔
54
            DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'Filter.phptpl',
203✔
55
            [
203✔
56
                'skipTransformedValuesCheck' => $this->transformingFilter !== null ? '!$transformationFailed' : '',
203✔
57
                'isTransformingFilter' => $this->filter instanceof TransformingFilterInterface,
203✔
58
                // Positive type guard: the filter only executes when the value's runtime type
59
                // matches one of the acceptedTypes. Non-matching values skip the filter entirely
60
                // (the && short-circuits before the filter function is called).
61
                // Empty acceptedTypes means "run for all types" — no guard needed.
62
                'typeCheck' => !empty($acceptedTypes)
203✔
63
                    ? TypeCheck::buildCompound($acceptedTypes)
176✔
64
                    : '',
203✔
65
                'filterClass' => $this->filter->getFilter()[0],
203✔
66
                'filterMethod' => $this->filter->getFilter()[1],
203✔
67
                'filterOptions' => var_export($this->filterOptions, true),
203✔
68
                'filterValueValidator' => new PropertyValidator(
203✔
69
                    $property,
203✔
70
                    '',
203✔
71
                    InvalidFilterValueException::class,
203✔
72
                    [$this->filter->getToken(), '&$filterException'],
203✔
73
                ),
203✔
74
                'viewHelper' => new RenderHelper($generatorConfiguration),
203✔
75
            ],
203✔
76
            IncompatibleFilterException::class,
203✔
77
            [$this->filter->getToken()],
203✔
78
        );
203✔
79
    }
80

81
    /**
82
     * Track if a transformation failed. If a transformation fails don't execute subsequent filter as they'd fail with
83
     * an invalid type
84
     */
85
    public function getValidatorSetUp(): string
182✔
86
    {
87
        return $this->filter instanceof TransformingFilterInterface
182✔
88
            ? '$transformationFailed = false;'
116✔
89
            : '';
182✔
90
    }
91

92
    /**
93
     * Make sure the filter is only executed if a non-transformed value is provided.
94
     * This is required as a setter (eg. for a string property which is modified by the DateTime filter into a DateTime
95
     * object) also accepts a transformed value (in this case a DateTime object).
96
     * If transformed values are provided neither filters defined before the transforming filter in the filter chain nor
97
     * the transforming filter must be executed as they are only compatible with the original value
98
     *
99
     * @throws ReflectionException
100
     * @throws SchemaException
101
     */
102
    public function addTransformedCheck(TransformingFilterInterface $filter, PropertyInterface $property): self
121✔
103
    {
104
        $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property);
121✔
105
        $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property);
121✔
106
        $nonAccepted = array_values(array_diff($returnTypeNames, $acceptedTypes));
121✔
107

108
        if (!empty($nonAccepted)) {
121✔
109
            $this->templateValues['skipTransformedValuesCheck'] = TypeCheck::buildNegatedCompound($nonAccepted);
101✔
110
        }
111

112
        return $this;
121✔
113
    }
114

115
    /**
116
     * Check if the given filter is compatible with the base type of the property defined in the schema.
117
     *
118
     * A filter is compatible when:
119
     * - it accepts all types (empty acceptedTypes derived from callable's first parameter type hint), or
120
     * - the property is untyped (any non-empty acceptedTypes has overlap with the infinite type space), or
121
     * - the property's types have at least one overlap with the filter's acceptedTypes.
122
     *
123
     * Only a complete zero overlap on a typed property is an error, because the filter could never
124
     * execute under any circumstances. Partial overlap is fine: the runtime typeCheck guard in the
125
     * generated code already skips the filter for non-matching value types.
126
     *
127
     * Type source: only an explicit 'type' key in the schema JSON is used for the check,
128
     * not $property->getType() — the resolved type may be widened by composition modifiers
129
     * (allOf, anyOf, oneOf) via deferred transferPropertyType callbacks, making it unreliable
130
     * at FilterValidator construction time. When no direct 'type' is declared, the effective
131
     * input type space would require full composition branch analysis to determine; the
132
     * filter's runtime type guard handles non-matching types, so the static check is skipped.
133
     * Skipping when there is no direct type declaration is order-independent and avoids
134
     * false-positive incompatibility errors regardless of which modifier ran first.
135
     *
136
     * @param string[] $acceptedTypes Pre-computed accepted types of the filter.
137
     *
138
     * @throws SchemaException
139
     */
140
    private function runCompatibilityCheck(array $acceptedTypes, PropertyInterface $property): void
217✔
141
    {
142
        if (empty($acceptedTypes)) {
217✔
143
            return;
27✔
144
        }
145

146
        if ($property->getNestedSchema() !== null) {
190✔
147
            $typeNames  = ['object'];
2✔
148
            $isNullable = false;
2✔
149
        } else {
150
            // Prefer the schema-declared type for the overlap check rather than $property->getType():
151
            // composition modifiers widen the resolved type with branch types via deferred
152
            // transferPropertyType callbacks — reading getType() here may return a wider type than
153
            // the schema-declared input type, producing false-positive compatibility results.
154
            $schemaJson  = $property->getJsonSchema()->getJson();
188✔
155
            $schemaType  = $schemaJson['type'] ?? null;
188✔
156

157
            if (is_string($schemaType) && $schemaType !== 'any') {
188✔
158
                // Single-string schema type: derive the PHP type name directly from the declaration.
159
                $typeNames  = [TypeConverter::jsonSchemaToPHP($schemaType)];
134✔
160
                $isNullable = $property->getType()?->isNullable() ?? false;
134✔
161
            } elseif ($property->getType() === null || $schemaType === null) {
55✔
162
                // No direct type declaration in the schema JSON: the property is either
163
                // unconstrained (any value is valid input) or its effective input type is
164
                // determined by composition branch constraints. Analysing composition branches
165
                // to derive the effective type is out of scope for this check — the filter's
166
                // runtime type guard handles non-matching types at execution time.
167
                // Exception: allOf branches with 'type' keywords narrow the set of values
168
                // that can legally reach the filter. When the intersection of those type sets
169
                // has no overlap with the filter's accepted types, the filter is unreachable.
170
                $this->detectDeadFilterViaAllOfConstraints($acceptedTypes, $property, $schemaJson);
31✔
171
                return;
28✔
172
            } else {
173
                $typeNames  = $property->getType()->getNames();
24✔
174
                $isNullable = $property->getType()->isNullable() ?? false;
24✔
175
            }
176
        }
177

178
        $hasOverlap = !empty(array_intersect($typeNames, $acceptedTypes))
160✔
179
            || ($isNullable && in_array('null', $acceptedTypes, true));
160✔
180

181
        if (!$hasOverlap) {
160✔
182
            throw new SchemaException(
11✔
183
                sprintf(
11✔
184
                    'Filter %s is not compatible with property type %s for property %s in file %s',
11✔
185
                    $this->filter->getToken(),
11✔
186
                    implode('|', array_merge($typeNames, $isNullable ? ['null'] : [])),
11✔
187
                    $property->getName(),
11✔
188
                    $property->getJsonSchema()->getFile(),
11✔
189
                )
11✔
190
            );
11✔
191
        }
192
    }
193

194
    /**
195
     * Check if the given filter is compatible with the result of the given transformation filter.
196
     *
197
     * All parts of the transformed output (including null when nullable) must be accepted by
198
     * the subsequent filter. Any unhandled return type is an error.
199
     *
200
     * @param string[] $filterAcceptedTypes Pre-computed accepted types of the current filter.
201
     *
202
     * @throws ReflectionException
203
     * @throws SchemaException
204
     */
205
    private function validateFilterCompatibilityWithTransformedType(
12✔
206
        array $filterAcceptedTypes,
207
        TransformingFilterInterface $transformingFilter,
208
        PropertyInterface $property,
209
    ): void {
210
        $returnTypeNames = FilterReflection::getReturnTypeNames($transformingFilter, $property);
12✔
211
        $returnNullable = FilterReflection::isReturnNullable($transformingFilter);
12✔
212

213
        if (empty($returnTypeNames) && !$returnNullable) {
12✔
214
            // Return type is mixed or null-only — subsequent filter must accept all types.
215
            if (!empty($filterAcceptedTypes)) {
2✔
216
                throw new SchemaException(
1✔
217
                    sprintf(
1✔
218
                        'Filter %s is not compatible with the unconstrained output of'
1✔
219
                            . ' transforming filter %s for property %s in file %s'
1✔
220
                            . ' (not all types are accepted)',
1✔
221
                        $this->filter->getToken(),
1✔
222
                        $transformingFilter->getToken(),
1✔
223
                        $property->getName(),
1✔
224
                        $property->getJsonSchema()->getFile(),
1✔
225
                    )
1✔
226
                );
1✔
227
            }
228

229
            return;
1✔
230
        }
231

232
        if (empty($filterAcceptedTypes)) {
11✔
233
            // Next filter accepts everything — always compatible.
234
            return;
1✔
235
        }
236

237
        // All parts of the return type must be handled by the next filter's accepted types.
238
        $allReturnTypes = $returnNullable
10✔
239
            ? array_merge($returnTypeNames, ['null'])
9✔
240
            : $returnTypeNames;
1✔
241
        $unhandled = array_diff($allReturnTypes, $filterAcceptedTypes);
10✔
242

243
        if (!empty($unhandled)) {
10✔
244
            $displayTypes = $returnNullable
3✔
245
                ? array_merge(['null'], $returnTypeNames)
2✔
246
                : $returnTypeNames;
1✔
247
            $typeDisplay = count($displayTypes) > 1
3✔
248
                ? '[' . implode(', ', $displayTypes) . ']'
2✔
249
                : $displayTypes[0];
1✔
250

251
            throw new SchemaException(
3✔
252
                sprintf(
3✔
253
                    'Filter %s is not compatible with transformed property type %s for property %s in file %s',
3✔
254
                    $this->filter->getToken(),
3✔
255
                    $typeDisplay,
3✔
256
                    $property->getName(),
3✔
257
                    $property->getJsonSchema()->getFile(),
3✔
258
                )
3✔
259
            );
3✔
260
        }
261
    }
262

263
    public function getFilter(): FilterInterface
166✔
264
    {
265
        return $this->filter;
166✔
266
    }
267

268
    public function getFilterOptions(): array
27✔
269
    {
270
        return $this->filterOptions;
27✔
271
    }
272

273
    /**
274
     * Detect a dead filter caused by allOf type constraints that fully exclude the filter's
275
     * accepted input types.
276
     *
277
     * When allOf branches narrow the effective input type to a set that has no overlap with
278
     * the filter's accepted types, the filter can never fire under any valid input — it is
279
     * unreachable regardless of the runtime value.
280
     *
281
     * Only allOf branches that declare a 'type' keyword contribute to the narrowed type set;
282
     * branches without 'type' impose no type constraint and are ignored. The effective type
283
     * is the intersection of all contributing branch type sets (allOf requires every branch
284
     * to pass, so only values matching all declared types can reach the filter).
285
     *
286
     * When the intersection is empty (contradictory branch types), the check is skipped —
287
     * composition validation reports the contradiction independently.
288
     *
289
     * @param string[]             $acceptedTypes PHP type names accepted by the filter.
290
     * @param array<string, mixed> $schemaJson    Raw JSON of the property's schema.
291
     *
292
     * @throws SchemaException
293
     */
294
    private function detectDeadFilterViaAllOfConstraints(
31✔
295
        array $acceptedTypes,
296
        PropertyInterface $property,
297
        array $schemaJson,
298
    ): void {
299
        $allOfBranches = $schemaJson['allOf'] ?? null;
31✔
300
        if (!is_array($allOfBranches) || empty($allOfBranches)) {
31✔
301
            return;
23✔
302
        }
303

304
        // Collect PHP type names from branches that declare a 'type' keyword.
305
        $constraintSets = [];
8✔
306
        foreach ($allOfBranches as $branch) {
8✔
307
            if (!is_array($branch) || !array_key_exists('type', $branch)) {
8✔
308
                continue;
4✔
309
            }
310

311
            $branchTypes = is_array($branch['type']) ? $branch['type'] : [$branch['type']];
5✔
312
            $constraintSets[] = array_values(array_map(
5✔
313
                static fn(string $typeName): string => TypeConverter::jsonSchemaToPHP($typeName),
5✔
314
                $branchTypes,
5✔
315
            ));
5✔
316
        }
317

318
        if (empty($constraintSets)) {
8✔
319
            return; // No type constraints in any allOf branch; cannot determine effective type.
3✔
320
        }
321

322
        // Effective type = intersection of all constrained type sets (allOf: all must pass).
323
        $effectiveTypes = array_shift($constraintSets);
5✔
324
        foreach ($constraintSets as $typeSet) {
5✔
325
            $effectiveTypes = array_values(array_intersect($effectiveTypes, $typeSet));
1✔
326
        }
327

328
        if (empty($effectiveTypes)) {
5✔
329
            // Contradictory constraints produce an empty effective type set; the schema is
330
            // already invalid — let composition validation report the contradiction.
NEW
331
            return;
×
332
        }
333

334
        if (!empty(array_intersect($effectiveTypes, $acceptedTypes))) {
5✔
335
            return; // Overlap exists; the filter can fire for at least some valid values.
2✔
336
        }
337

338
        throw new SchemaException(sprintf(
3✔
339
            'Filter %s on property %s in file %s can never be executed:'
3✔
340
                . ' allOf type constraints (%s) exclude all input types accepted by the filter (%s)',
3✔
341
            $this->filter->getToken(),
3✔
342
            $property->getName(),
3✔
343
            $property->getJsonSchema()->getFile(),
3✔
344
            implode('|', $effectiveTypes),
3✔
345
            implode('|', $acceptedTypes),
3✔
346
        ));
3✔
347
    }
348
}
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