• 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

98.89
/src/PropertyProcessor/Filter/CompositionBranchClassifier.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\Filter;
6

7
use PHPModelGenerator\Draft\Draft;
8
use PHPModelGenerator\Utils\TypeCheck;
9
use PHPModelGenerator\Utils\TypeConverter;
10

11
/**
12
 * Classifies a single composition branch as Input, Output, Mixed, or Empty with
13
 * respect to the type-spaces of a transforming filter T: InputTypes → OutputTypes.
14
 *
15
 * Classification rules:
16
 *   - Input  — every constraint targets the filter's input type-space.
17
 *   - Output — every constraint targets the filter's output type-space.
18
 *   - Mixed  — constraints span both type-spaces.
19
 *   - Empty  — the branch imposes no structural constraints (empty schema `{}`).
20
 *
21
 * Ambiguous keywords (registered only on the 'any' type, or not registered at all)
22
 * are treated as non-constraining and do not shift the branch away from a unanimous
23
 * single-space classification. When ALL keywords in a branch are ambiguous, the
24
 * liberal policy applies and the branch is classified as Input.
25
 */
26
class CompositionBranchClassifier
27
{
28
    /**
29
     * JSON Schema composition keywords that may contain nested branch schemas.
30
     * Each is classified recursively rather than via the Draft type registry.
31
     */
32
    private const array NESTED_COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'not'];
33

34
    /**
35
     * @param Draft    $draft       The active Draft instance used to resolve keyword type-spaces.
36
     * @param string[] $inputTypes  PHP type names accepted by the transforming filter
37
     *                              (e.g. ['string']).
38
     * @param string[] $outputTypes PHP type names returned by the transforming filter
39
     *                              (e.g. ['DateTime']).
40
     */
41
    public function __construct(
188✔
42
        private readonly Draft $draft,
43
        private readonly array $inputTypes,
44
        private readonly array $outputTypes,
45
    ) {}
188✔
46

47
    /**
48
     * Classify a single schema keyword by looking up which Draft-registered types carry it
49
     * and mapping those types onto the filter's input / output type-spaces.
50
     *
51
     * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') and the structural 'type' keyword
52
     * are not handled here — call classify() for full branch analysis.  Returns TypeSpace::Empty
53
     * for keywords registered only on the 'any' pseudo-type (e.g. 'enum', 'filter') or for
54
     * keywords not registered at all (e.g. '$schema', 'description').
55
     */
56
    public function classifySchemaKey(string $key): TypeSpace
2✔
57
    {
58
        $phpTypeNames = array_values(array_filter(
2✔
59
            array_map(
2✔
60
                static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType),
2✔
61
                $this->draft->getTypesForKeyword($key),
2✔
62
            ),
2✔
63
            static fn(string $type): bool => $type !== 'any',
2✔
64
        ));
2✔
65

66
        return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames);
2✔
67
    }
68

69
    /**
70
     * Classify a single composition branch schema.
71
     *
72
     * @param array<string, mixed> $branchSchema Decoded JSON object for one branch.
73
     */
74
    public function classify(array $branchSchema): TypeSpace
94✔
75
    {
76
        if (empty($branchSchema)) {
94✔
77
            return TypeSpace::Empty;
4✔
78
        }
79

80
        $hasInput  = false;
91✔
81
        $hasOutput = false;
91✔
82

83
        foreach ($branchSchema as $keyword => $value) {
91✔
84
            $contribution = $this->classifyKeyword($keyword, $value);
91✔
85

86
            if ($contribution === TypeSpace::Mixed) {
91✔
87
                return TypeSpace::Mixed;
2✔
88
            }
89

90
            match ($contribution) {
91
                TypeSpace::Input  => $hasInput  = true,
91✔
92
                TypeSpace::Output => $hasOutput = true,
51✔
93
                default           => null,  // Empty — no constraint, skip
18✔
94
            };
95
        }
96

97
        return match (true) {
98
            $hasInput && $hasOutput => TypeSpace::Mixed,
91✔
99
            $hasInput               => TypeSpace::Input,
85✔
100
            $hasOutput              => TypeSpace::Output,
45✔
101
            default                 => TypeSpace::Input,  // all ambiguous: liberal policy
91✔
102
        };
103
    }
104

105
    /**
106
     * Determine the type-space contribution of a single keyword within a branch.
107
     *
108
     * The 'type' keyword is always classified as Input: it asserts raw JSON value structure
109
     * and must validate against the unfiltered input regardless of the filter's output type.
110
     * Combining 'type' with output-targeted constraints (e.g. minimum/maximum) in the same
111
     * branch therefore produces TypeSpace::Mixed, which is correctly rejected as unresolvable.
112
     *
113
     * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') are classified recursively via
114
     * classifyNestedComposition() — call classify() for full branch analysis.
115
     *
116
     * Returns TypeSpace::Empty when the keyword carries no spatial information
117
     * (registered only on 'any', not registered at all, or an unrecognised key).
118
     */
119
    private function classifyKeyword(string $keyword, mixed $value): TypeSpace
91✔
120
    {
121
        if ($keyword === 'type') {
91✔
122
            return TypeSpace::Input;
32✔
123
        }
124

125
        if (in_array($keyword, self::NESTED_COMPOSITION_KEYWORDS, true)) {
81✔
126
            return $this->classifyNestedComposition($keyword, $value);
12✔
127
        }
128

129
        // Derive the spatial contribution from the Draft type registry.
130
        $draftTypeNames = $this->draft->getTypesForKeyword($keyword);
77✔
131

132
        if (empty($draftTypeNames)) {
77✔
133
            // Keyword not registered in any type (e.g. $schema, title, description).
134
            return TypeSpace::Empty;
4✔
135
        }
136

137
        // Convert JSON Schema type names to PHP type names and discard the 'any'
138
        // pseudo-type — it is not spatially specific (e.g. enum, const, filter).
139
        $phpTypeNames = array_values(array_filter(
73✔
140
            array_map(
73✔
141
                static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType),
73✔
142
                $draftTypeNames,
73✔
143
            ),
73✔
144
            static fn(string $type): bool => $type !== 'any',
73✔
145
        ));
73✔
146

147
        return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames);
73✔
148
    }
149

150
    /**
151
     * Classify nested composition keywords (allOf, anyOf, oneOf, not) by recursing
152
     * into their inner branch schemas.
153
     */
154
    private function classifyNestedComposition(string $keyword, mixed $value): TypeSpace
12✔
155
    {
156
        if ($keyword === 'not') {
12✔
157
            return $this->classify($value);
2✔
158
        }
159

160
        $nonEmpty = array_values(array_filter(
10✔
161
            array_map(
10✔
162
                fn(mixed $branch): TypeSpace => is_array($branch)
10✔
163
                    ? $this->classify($branch)
10✔
164
                    : TypeSpace::Empty,
10✔
165
                $value,
10✔
166
            ),
10✔
167
            static fn(TypeSpace $space): bool => $space !== TypeSpace::Empty,
10✔
168
        ));
10✔
169

170
        if (empty($nonEmpty)) {
10✔
171
            return TypeSpace::Empty;
1✔
172
        }
173

174
        $hasMixed  = in_array(TypeSpace::Mixed, $nonEmpty, true);
9✔
175
        $hasInput  = in_array(TypeSpace::Input, $nonEmpty, true);
9✔
176
        $hasOutput = in_array(TypeSpace::Output, $nonEmpty, true);
9✔
177

178
        return match (true) {
179
            $hasMixed || ($hasInput && $hasOutput) => TypeSpace::Mixed,
9✔
180
            $hasInput                              => TypeSpace::Input,
7✔
181
            default                                => TypeSpace::Output,
9✔
182
        };
183
    }
184

185
    /**
186
     * Map a list of PHP type names onto the Input / Output / Mixed / Empty TypeSpace
187
     * based on whether they overlap with the filter's declared input and output types.
188
     *
189
     * Non-primitive class names in the declared output types are expanded to include
190
     * 'object', so that object-targeted Draft keywords (e.g. minProperties) classify as
191
     * output-space when the filter returns a class instance.
192
     *
193
     * 'int' and 'float' are treated as equivalent for overlap detection (JSON Schema:
194
     * integer is a subtype of number). Number-typed Draft keywords such as 'minimum' and
195
     * 'maximum' register under the 'number' → PHP 'float' type, so they must classify as
196
     * output-space for filters that return 'int', and as input-space for filters that
197
     * accept 'int'.
198
     *
199
     * @param string[] $phpTypeNames
200
     */
201
    private function resolveTypeSpace(array $phpTypeNames): TypeSpace
73✔
202
    {
203
        return $this->classifyAgainstSpaces($phpTypeNames, $this->getEffectiveOutputTypes());
73✔
204
    }
205

206
    /**
207
     * Core classification: map a list of PHP type names onto the Input / Output / Mixed /
208
     * Empty TypeSpace based on whether they overlap with the filter's input types and the
209
     * given output type list.
210
     *
211
     * @param string[] $phpTypeNames
212
     * @param string[] $outputTypes
213
     */
214
    private function classifyAgainstSpaces(array $phpTypeNames, array $outputTypes): TypeSpace
73✔
215
    {
216
        $inInput  = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($this->inputTypes)));
73✔
217
        $inOutput = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($outputTypes)));
73✔
218

219
        return match (true) {
220
            $inInput && $inOutput => TypeSpace::Mixed,
73✔
221
            $inInput              => TypeSpace::Input,
73✔
222
            $inOutput             => TypeSpace::Output,
46✔
223
            default               => TypeSpace::Empty,
73✔
224
        };
225
    }
226

227
    /**
228
     * Expand a set of PHP type names so that 'int' and 'float' are treated as
229
     * interchangeable for overlap detection.
230
     *
231
     * JSON Schema defines integer as a subtype of number. In the Draft type registry
232
     * 'number' maps to PHP 'float', so number-typed keywords (minimum, maximum, …)
233
     * carry 'float' as their type name. A filter that returns 'int' should still make
234
     * those keywords classify as output-space; a filter that accepts 'int' should make
235
     * them classify as input-space.
236
     *
237
     * @param string[] $types
238
     * @return string[]
239
     */
240
    private function expandNumericTypes(array $types): array
73✔
241
    {
242
        $hasInt   = in_array('int', $types, true);
73✔
243
        $hasFloat = in_array('float', $types, true);
73✔
244

245
        if ($hasInt && !$hasFloat) {
73✔
246
            $types[] = 'float';
15✔
247
        } elseif ($hasFloat && !$hasInt) {
73✔
NEW
248
            $types[] = 'int';
×
249
        }
250

251
        return $types;
73✔
252
    }
253

254
    /**
255
     * Expand the declared output types to include 'object' when any output type is a
256
     * non-primitive class name. This allows object-type Draft keywords (e.g. minProperties,
257
     * properties) to be classified as output-targeted when the filter returns a class instance.
258
     *
259
     * The 'type' keyword is deliberately excluded from this expansion: it validates raw JSON
260
     * value structure and must classify against the declared output types only (see
261
     * classifyKeyword). This method is used only for non-'type' structural keywords.
262
     *
263
     * @return string[]
264
     */
265
    private function getEffectiveOutputTypes(): array
73✔
266
    {
267
        return array_values(array_unique(array_merge(
73✔
268
            $this->outputTypes,
73✔
269
            array_map(
73✔
270
                static fn(string $type): string => !TypeCheck::isPrimitive($type) && $type !== 'null'
73✔
271
                    ? 'object'
57✔
272
                    : $type,
73✔
273
                $this->outputTypes,
73✔
274
            ),
73✔
275
        )));
73✔
276
    }
277
}
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