• 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.83
/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(
178✔
42
        private readonly Draft $draft,
43
        private readonly array $inputTypes,
44
        private readonly array $outputTypes,
45
    ) {}
178✔
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
87✔
75
    {
76
        if (empty($branchSchema)) {
87✔
77
            return TypeSpace::Empty;
4✔
78
        }
79

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

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

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

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

97
        return match (true) {
98
            $hasInput && $hasOutput => TypeSpace::Mixed,
84✔
99
            $hasInput               => TypeSpace::Input,
78✔
100
            $hasOutput              => TypeSpace::Output,
43✔
101
            default                 => TypeSpace::Input,  // all ambiguous: liberal policy
84✔
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
84✔
120
    {
121
        if ($keyword === 'type') {
84✔
122
            return TypeSpace::Input;
29✔
123
        }
124

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

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

132
        if (empty($draftTypeNames)) {
72✔
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(
68✔
140
            array_map(
68✔
141
                static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType),
68✔
142
                $draftTypeNames,
68✔
143
            ),
68✔
144
            static fn(string $type): bool => $type !== 'any',
68✔
145
        ));
68✔
146

147
        return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames);
68✔
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
11✔
155
    {
156
        if ($keyword === 'not') {
11✔
157
            return !is_array($value) ? TypeSpace::Empty : $this->classify($value);
2✔
158
        }
159

160
        // allOf, anyOf, oneOf: value must be an array of branch schemas.
161
        if (!is_array($value)) {
9✔
NEW
162
            return TypeSpace::Empty;
×
163
        }
164

165
        $nonEmpty = array_values(array_filter(
9✔
166
            array_map(
9✔
167
                fn(mixed $branch): TypeSpace => is_array($branch)
9✔
168
                    ? $this->classify($branch)
9✔
169
                    : TypeSpace::Empty,
9✔
170
                $value,
9✔
171
            ),
9✔
172
            static fn(TypeSpace $space): bool => $space !== TypeSpace::Empty,
9✔
173
        ));
9✔
174

175
        if (empty($nonEmpty)) {
9✔
176
            return TypeSpace::Empty;
1✔
177
        }
178

179
        $hasMixed  = in_array(TypeSpace::Mixed, $nonEmpty, true);
8✔
180
        $hasInput  = in_array(TypeSpace::Input, $nonEmpty, true);
8✔
181
        $hasOutput = in_array(TypeSpace::Output, $nonEmpty, true);
8✔
182

183
        return match (true) {
184
            $hasMixed || ($hasInput && $hasOutput) => TypeSpace::Mixed,
8✔
185
            $hasInput                              => TypeSpace::Input,
6✔
186
            default                                => TypeSpace::Output,
8✔
187
        };
188
    }
189

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

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

224
        return match (true) {
225
            $inInput && $inOutput => TypeSpace::Mixed,
68✔
226
            $inInput              => TypeSpace::Input,
68✔
227
            $inOutput             => TypeSpace::Output,
44✔
228
            default               => TypeSpace::Empty,
68✔
229
        };
230
    }
231

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

250
        if ($hasInt && !$hasFloat) {
68✔
251
            $types[] = 'float';
15✔
252
        } elseif ($hasFloat && !$hasInt) {
68✔
NEW
253
            $types[] = 'int';
×
254
        }
255

256
        return $types;
68✔
257
    }
258

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