• 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

96.05
/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\Filter;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Model\Property\PropertyInterface;
9

10
/**
11
 * Checks composition keywords for type-space conflicts with a transforming filter,
12
 * and detects filter keywords inside composition branches.
13
 */
14
class CompositionCompatibilityChecker
15
{
16
    private const array ARRAY_COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf'];
17
    private const array SINGLE_COMPOSITION_KEYWORDS = ['not', 'if', 'then', 'else'];
18

19
    public function __construct(
139✔
20
        private readonly CompositionBranchClassifier $classifier,
21
        private readonly PropertyInterface $property,
22
    ) {}
139✔
23

24
    /**
25
     * Validate that composition keywords directly on the filtered property's schema do not
26
     * produce type-space conflicts with the transforming filter. Enforced rules:
27
     *   - allOf: no branch may span both type-spaces (Mixed).
28
     *   - anyOf / oneOf: all branches must share one type-space.
29
     *   - not: the inner schema must not span both type-spaces (Mixed).
30
     *   - if/then/else: all three sub-schemas must share one type-space.
31
     *
32
     * A SchemaException is thrown for every detected conflict. Ambiguous branches (those
33
     * that classify as Empty) are not treated as conflicts; liberal-Input policy applies
34
     * (consistent with CompositionBranchClassifier).
35
     *
36
     * @param array<string, mixed> $propertySchema
37
     *
38
     * @throws SchemaException
39
     */
40
    public function checkTransformingFilterCompositionConflicts(array $propertySchema): void
139✔
41
    {
42
        $this->checkAllOf($propertySchema['allOf'] ?? null);
139✔
43
        $this->checkAnyOfOrOneOf('anyOf', $propertySchema['anyOf'] ?? null);
138✔
44
        $this->checkAnyOfOrOneOf('oneOf', $propertySchema['oneOf'] ?? null);
136✔
45
        $this->checkNot($propertySchema['not'] ?? null);
135✔
46
        $this->checkIfThenElse($propertySchema);
134✔
47
    }
48

49
    /**
50
     * Validate that root-level composition branches on the parent object schema do not
51
     * constrain the filtered subproperty with output-type-space constraints.
52
     *
53
     * Throws when any root-level composition branch references the filtered subproperty
54
     * by name via a "properties" constraint whose content targets the output type-space.
55
     * Ambiguous and input-space constraints are permitted: they operate against the
56
     * original (pre-transform) value, which is the correct behaviour for root-level
57
     * composition today.
58
     *
59
     * @param array<string, mixed> $parentSchema
60
     *
61
     * @throws SchemaException
62
     */
63
    public function checkTransformingFilterRootCompositionConflicts(array $parentSchema): void
133✔
64
    {
65
        $propertyName = $this->property->getName();
133✔
66

67
        foreach (self::ARRAY_COMPOSITION_KEYWORDS as $keyword) {
133✔
68
            if (!isset($parentSchema[$keyword]) || !is_array($parentSchema[$keyword])) {
133✔
69
                continue;
132✔
70
            }
71

72
            foreach ($parentSchema[$keyword] as $index => $branch) {
8✔
73
                if (!is_array($branch) || !isset($branch['properties'][$propertyName])) {
8✔
NEW
74
                    continue;
×
75
                }
76

77
                $innerConstraint = $branch['properties'][$propertyName];
8✔
78

79
                if (!is_array($innerConstraint)) {
8✔
NEW
80
                    continue;
×
81
                }
82

83
                $space = $this->classifier->classify($innerConstraint);
8✔
84

85
                if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) {
8✔
86
                    // TODO: proper handling is deferred to a follow-up topic.
87
                    // Root-level composition branches cannot yet be split around the
88
                    // filter's transform boundary.
89
                    throw new SchemaException(sprintf(
3✔
90
                        'Composition %s in file %s constrains filtered subproperty %s'
3✔
91
                            . ' (branch #%d) with output-type-space constraints;'
3✔
92
                            . ' this combination is not yet supported.',
3✔
93
                        $keyword,
3✔
94
                        $this->property->getJsonSchema()->getFile(),
3✔
95
                        $propertyName,
3✔
96
                        $index,
3✔
97
                    ));
3✔
98
                }
99
            }
100
        }
101

102
        foreach (self::SINGLE_COMPOSITION_KEYWORDS as $keyword) {
130✔
103
            if (
104
                !isset($parentSchema[$keyword])
130✔
105
                || !is_array($parentSchema[$keyword])
5✔
106
                || !isset($parentSchema[$keyword]['properties'][$propertyName])
130✔
107
            ) {
108
                continue;
129✔
109
            }
110

111
            $innerConstraint = $parentSchema[$keyword]['properties'][$propertyName];
5✔
112

113
            if (!is_array($innerConstraint)) {
5✔
NEW
114
                continue;
×
115
            }
116

117
            $space = $this->classifier->classify($innerConstraint);
5✔
118

119
            if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) {
5✔
120
                // TODO: see above.
121
                throw new SchemaException(sprintf(
4✔
122
                    'Composition %s in file %s constrains filtered subproperty %s'
4✔
123
                        . ' with output-type-space constraints; this combination is not yet supported.',
4✔
124
                    $keyword,
4✔
125
                    $this->property->getJsonSchema()->getFile(),
4✔
126
                    $propertyName,
4✔
127
                ));
4✔
128
            }
129
        }
130
    }
131

132
    /**
133
     * @param mixed $branches
134
     *
135
     * @throws SchemaException
136
     */
137
    private function checkAllOf(mixed $branches): void
139✔
138
    {
139
        if (!is_array($branches)) {
139✔
140
            return;
129✔
141
        }
142

143
        foreach ($branches as $index => $branch) {
10✔
144
            if (!is_array($branch)) {
10✔
NEW
145
                continue;
×
146
            }
147

148
            if ($this->classifier->classify($branch) === TypeSpace::Mixed) {
10✔
149
                throw new SchemaException(sprintf(
1✔
150
                    'Composition allOf under property %s in file %s cannot be resolved:'
1✔
151
                        . ' branch #%d spans both input and output type-spaces;'
1✔
152
                        . ' allOf branches must not contain constraints from both type-spaces'
1✔
153
                        . ' when combined with a transforming filter.',
1✔
154
                    $this->property->getName(),
1✔
155
                    $this->property->getJsonSchema()->getFile(),
1✔
156
                    $index,
1✔
157
                ));
1✔
158
            }
159
        }
160
    }
161

162
    /**
163
     * @param mixed $branches
164
     *
165
     * @throws SchemaException
166
     */
167
    private function checkAnyOfOrOneOf(string $keyword, mixed $branches): void
138✔
168
    {
169
        if (!is_array($branches)) {
138✔
170
            return;
136✔
171
        }
172

173
        $firstInputIndex  = null;
12✔
174
        $firstOutputIndex = null;
12✔
175

176
        foreach ($branches as $index => $branch) {
12✔
177
            if (!is_array($branch)) {
12✔
NEW
178
                continue;
×
179
            }
180

181
            $space = $this->classifier->classify($branch);
12✔
182

183
            if ($space === TypeSpace::Mixed) {
12✔
184
                throw new SchemaException(sprintf(
1✔
185
                    'Composition %s under property %s in file %s cannot be resolved:'
1✔
186
                        . ' branch #%d spans both input and output type-spaces;'
1✔
187
                        . ' branches must not contain constraints from both type-spaces'
1✔
188
                        . ' when combined with a transforming filter.',
1✔
189
                    $keyword,
1✔
190
                    $this->property->getName(),
1✔
191
                    $this->property->getJsonSchema()->getFile(),
1✔
192
                    $index,
1✔
193
                ));
1✔
194
            }
195

196
            if ($space === TypeSpace::Input && $firstInputIndex === null) {
11✔
197
                $firstInputIndex = $index;
9✔
198
            }
199

200
            if ($space === TypeSpace::Output && $firstOutputIndex === null) {
11✔
201
                $firstOutputIndex = $index;
4✔
202
            }
203
        }
204

205
        if ($firstInputIndex !== null && $firstOutputIndex !== null) {
11✔
206
            throw new SchemaException(sprintf(
2✔
207
                'Composition %s under property %s in file %s cannot be resolved:'
2✔
208
                    . ' branch #%d constrains input type-space but branch #%d constrains output type-space;'
2✔
209
                    . ' %s branches must share a single type-space when combined with a transforming filter.',
2✔
210
                $keyword,
2✔
211
                $this->property->getName(),
2✔
212
                $this->property->getJsonSchema()->getFile(),
2✔
213
                $firstInputIndex,
2✔
214
                $firstOutputIndex,
2✔
215
                $keyword,
2✔
216
            ));
2✔
217
        }
218
    }
219

220
    /**
221
     * @throws SchemaException
222
     */
223
    private function checkNot(mixed $innerSchema): void
135✔
224
    {
225
        if (!is_array($innerSchema)) {
135✔
226
            return;
132✔
227
        }
228

229
        if ($this->classifier->classify($innerSchema) === TypeSpace::Mixed) {
3✔
230
            throw new SchemaException(sprintf(
1✔
231
                'Composition not under property %s in file %s cannot be resolved:'
1✔
232
                    . ' the inner schema spans both input and output type-spaces.',
1✔
233
                $this->property->getName(),
1✔
234
                $this->property->getJsonSchema()->getFile(),
1✔
235
            ));
1✔
236
        }
237
    }
238

239
    /**
240
     * @param array<string, mixed> $propertySchema
241
     *
242
     * @throws SchemaException
243
     */
244
    private function checkIfThenElse(array $propertySchema): void
134✔
245
    {
246
        if (!isset($propertySchema['if'])) {
134✔
247
            return;
127✔
248
        }
249

250
        /** @var array<string, TypeSpace> $subSchemaSpaces */
251
        $subSchemaSpaces = [];
7✔
252

253
        foreach (['if', 'then', 'else'] as $subKeyword) {
7✔
254
            if (isset($propertySchema[$subKeyword]) && is_array($propertySchema[$subKeyword])) {
7✔
255
                $subSchemaSpaces[$subKeyword] = $this->classifier->classify($propertySchema[$subKeyword]);
7✔
256
            }
257
        }
258

259
        $hasInput  = in_array(TypeSpace::Input, $subSchemaSpaces, true);
7✔
260
        $hasOutput = in_array(TypeSpace::Output, $subSchemaSpaces, true);
7✔
261
        $hasMixed  = in_array(TypeSpace::Mixed, $subSchemaSpaces, true);
7✔
262

263
        if ($hasMixed || ($hasInput && $hasOutput)) {
7✔
264
            throw new SchemaException(sprintf(
1✔
265
                'Composition if/then/else under property %s in file %s cannot be resolved:'
1✔
266
                    . ' sub-schemas span different type-spaces;'
1✔
267
                    . ' if/then/else sub-schemas must share a single type-space'
1✔
268
                    . ' when combined with a transforming filter.',
1✔
269
                $this->property->getName(),
1✔
270
                $this->property->getJsonSchema()->getFile(),
1✔
271
            ));
1✔
272
        }
273
    }
274

275
    /**
276
     * Recursively check whether a branch schema (or any of its nested composition branches
277
     * or named properties) contains a "filter" keyword.
278
     *
279
     * The check covers:
280
     *   - A direct "filter" key in the branch itself.
281
     *   - "filter" nested inside nested allOf / anyOf / oneOf / not / if / then / else.
282
     *   - "filter" inside a named property value under "properties" when the branch does NOT
283
     *     declare "type": "object". Object-typed branches create nested schemas whose
284
     *     properties are processed independently (not subject to ComposedItem.phptpl's
285
     *     $value reset), so their inner filters are correctly applied.
286
     *
287
     * @param array<string, mixed> $branchSchema
288
     */
289
    public static function branchContainsFilter(array $branchSchema): bool
793✔
290
    {
291
        if (array_key_exists('filter', $branchSchema)) {
793✔
292
            return true;
9✔
293
        }
294

295
        // Object-typed branches create nested schemas whose properties are processed
296
        // independently of ComposedItem $value resets, so their inner filters are applied
297
        // correctly. Accept both the string form ('object') and the single-element array form
298
        // (['object']) — type inheritance may inject the parent type as an array.
299
        $branchType = $branchSchema['type'] ?? null;
787✔
300
        $isObjectTyped = $branchType === 'object'
787✔
301
            || (is_array($branchType) && in_array('object', $branchType, true));
787✔
302

303
        if (
304
            !$isObjectTyped
787✔
305
            && isset($branchSchema['properties'])
787✔
306
            && is_array($branchSchema['properties'])
787✔
307
        ) {
308
            foreach ($branchSchema['properties'] as $propertySchema) {
14✔
309
                if (is_array($propertySchema) && static::branchContainsFilter($propertySchema)) {
14✔
NEW
310
                    return true;
×
311
                }
312
            }
313
        }
314

315
        foreach (self::ARRAY_COMPOSITION_KEYWORDS as $keyword) {
787✔
316
            if (!isset($branchSchema[$keyword]) || !is_array($branchSchema[$keyword])) {
787✔
317
                continue;
787✔
318
            }
319

320
            foreach ($branchSchema[$keyword] as $nestedBranch) {
4✔
321
                if (is_array($nestedBranch) && static::branchContainsFilter($nestedBranch)) {
4✔
322
                    return true;
1✔
323
                }
324
            }
325
        }
326

327
        foreach (self::SINGLE_COMPOSITION_KEYWORDS as $keyword) {
786✔
328
            if (
329
                isset($branchSchema[$keyword])
786✔
330
                && is_array($branchSchema[$keyword])
786✔
331
                && static::branchContainsFilter($branchSchema[$keyword])
786✔
332
            ) {
333
                return true;
2✔
334
            }
335
        }
336

337
        return false;
784✔
338
    }
339
}
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