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

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

11 Nov 2026 10:32AM UTC coverage: 99.346% (+0.01%) from 99.336%
43004429942

Pull #206

github

wol-soft
Remove the pinned nested unevaluatedItems gap test from this branch

The test pinned that an unevaluatedItems declared on an array which is an item
of another array dies with a call to an undefined method. The gap exists on
master independently of the unevaluated crediting work and is tracked in its
own issue, which carries reproduction tests for the crash and for the nested
arrays which share their evaluated indices; the pin does not belong in this
branch. The fixture it used is removed with it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pull Request #206: Credit nested, referenced and dependent applicators to the unevaluated accumulators

451 of 454 new or added lines in 22 files covered. (99.34%)

1 existing line in 1 file now uncovered.

10634 of 10704 relevant lines covered (99.35%)

618.92 hits per line

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

99.17
/src/Utils/ArrayItemFilterScope.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\Utils;
6

7
use PHPModelGenerator\Exception\SchemaException;
8
use PHPModelGenerator\Filter\TransformingFilterInterface;
9
use PHPModelGenerator\Model\Property\CompositionPropertyDecorator;
10
use PHPModelGenerator\Model\Property\PropertyInterface;
11
use PHPModelGenerator\Model\Schema;
12
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
13
use PHPModelGenerator\Model\Validator\ArrayItemValidator;
14
use PHPModelGenerator\Model\Validator\ArrayTupleValidator;
15
use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator;
16
use PHPModelGenerator\Model\Validator\FilterValidator;
17
use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator;
18

19
/**
20
 * Finds the validators which validate the items of an array property and decides which of them filter the items.
21
 *
22
 * Besides the property itself the items are validated by the branches of the compositions of the property. Two
23
 * branches never contribute a value of their own: the items of a `not` branch and of the condition of an
24
 * if/then/else are only validated, the property keeps the items as provided. Their filters are rejected when the
25
 * schema is processed, the branches are not visited here.
26
 */
27
class ArrayItemFilterScope
28
{
29
    private const string SCHEMA_FORM_SLOT = 'items';
30
    private const string UNEVALUATED_SLOT = 'unevaluated';
31

32
    /**
33
     * All validators of the items of the property, those of the property itself first, followed by the validators of
34
     * the branches of its compositions (nested compositions included) in declaration order.
35
     *
36
     * @param bool $includeValidationOnlyBranches Also visit the `not` branches and the conditions of if/then/else.
37
     *                                            They never hand items over, but their validators still run and, for
38
     *                                            example, credit evaluated indices.
39
     *
40
     * @return ArrayItemDeclaration[]
41
     */
42
    public static function declarationsOf(
3,128 ✔
43
        PropertyInterface $property,
44
        bool $includeValidationOnlyBranches = false,
45
    ): array {
46
        $declarations = [];
3,128 ✔
47
        self::collectDeclarations($property, false, $includeValidationOnlyBranches, [], $declarations);
3,128 ✔
48

49
        return $declarations;
3,128 ✔
50
    }
51

52
    /**
53
     * The transforming filter of an item property. A single filter is executed on the items, the serializer of
54
     * the first one is used.
55
     */
56
    public static function transformingFilterOf(PropertyInterface $itemProperty): ?FilterValidator
651 ✔
57
    {
58
        foreach (FilterValidator::of($itemProperty) as $filterValidator) {
651 ✔
59
            if ($filterValidator->getFilter() instanceof TransformingFilterInterface) {
75 ✔
60
                return $filterValidator;
75 ✔
61
            }
62
        }
63

64
        return null;
620 ✔
65
    }
66

67
    /**
68
     * Whether a transforming filter is declared for the items of the array property or, for items which are arrays
69
     * themselves, for the items of a nested array on any level.
70
     *
71
     * @param bool $includeUnevaluatedItems See filtersOfItems()
72
     */
73
    public static function filtersItemsOnAnyLevel(
1,068 ✔
74
        PropertyInterface $arrayProperty,
75
        bool $includeUnevaluatedItems = true,
76
    ): bool {
77
        return self::filtersOfItems($arrayProperty, $includeUnevaluatedItems) !== [];
1,068 ✔
78
    }
79

80
    /**
81
     * The transforming filters which are declared for the items of the array property or, for items which are arrays
82
     * themselves, for the items of a nested array on any level, keyed by the declaration (see declarationKey()).
83
     *
84
     * The unevaluatedItems of a nested array are not considered. Their filter is serialized through the indices the
85
     * keyword evaluated, which are tracked per property name: the nested arrays of all elements would share one entry.
86
     * Considering them would announce a serializer which the generated code can't deliver.
87
     *
88
     * @param bool $includeUnevaluatedItems Whether the unevaluatedItems of the array property itself are considered
89
     *
90
     * @return array<string, FilterValidator>
91
     */
92
    public static function filtersOfItems(PropertyInterface $arrayProperty, bool $includeUnevaluatedItems = true): array
1,538 ✔
93
    {
94
        $filters = [];
1,538 ✔
95
        self::collectFiltersOfLevel($arrayProperty, $includeUnevaluatedItems, [], $filters);
1,538 ✔
96

97
        return $filters;
1,538 ✔
98
    }
99

100
    /**
101
     * The identity of a property, a branch is a new decorator on every visit and shares the identity of the property
102
     * it wraps.
103
     */
104
    public static function identityOf(PropertyInterface $property): int
3,128 ✔
105
    {
106
        return spl_object_id(
3,128 ✔
107
            $property instanceof CompositionPropertyDecorator ? $property->getWrappedProperty() : $property,
3,128 ✔
108
        );
3,128 ✔
109
    }
110

111
    /**
112
     * A `not` branch and the condition of an if/then/else only decide whether the value is valid. Their filters run
113
     * while the branch is evaluated, but the property keeps the items as provided. Accepting the schema would
114
     * generate a model which widens the type check of the items for the output of the filter and serializes
115
     * items which were never transformed.
116
     *
117
     * The processed branch is inspected, not its JSON. Looking for a `filter` keyword in the schema of the items was
118
     * rejected: a filter reaches the items through a `$ref` or through an allOf/anyOf/oneOf/if nested in the branch
119
     * without any keyword in the schema of the items. A `not` or condition nested in the branch is validated by the
120
     * factory which creates it.
121
     *
122
     * @param string $branchDescription The branch as part of the message, e.g. "a 'not' composition branch"
123
     *
124
     * @throws SchemaException
125
     */
126
    public static function assertNoItemFilterInValidationOnlyBranch(
298 ✔
127
        PropertyInterface $branch,
128
        PropertyInterface $property,
129
        string $branchDescription,
130
    ): void {
131
        foreach (self::declarationsOf($branch) as $declaration) {
298 ✔
132
            foreach ($declaration->getItemProperties() as $itemProperty) {
9 ✔
133
                if (self::filtersOfSlotValue($itemProperty) === []) {
9 ✔
134
                    continue;
2 ✔
135
                }
136

137
                throw new SchemaException(
8 ✔
138
                    sprintf(
8 ✔
139
                        "A filter keyword inside the array items of %s is not supported for property '%s' in file %s",
8 ✔
140
                        $branchDescription,
8 ✔
141
                        $property->getName(),
8 ✔
142
                        $property->getJsonSchema()->getFile(),
8 ✔
143
                    ),
8 ✔
144
                    $property->getJsonSchema(),
8 ✔
145
                );
8 ✔
146
            }
147
        }
148
    }
149

150
    /**
151
     * The items of one slot (the schema-form items, a single tuple index or the unevaluatedItems) can only be
152
     * filtered by one declaration: the property itself and its branches together contribute at most one transforming
153
     * filter per slot. If the items are arrays themselves the filters of their items on any level count for the slot.
154
     *
155
     * Accepting a second declaration, also if it is identical, was rejected. The branches of an allOf filter the
156
     * same items twice. The branches which exclude each other (oneOf, then and else) transform the items into the
157
     * same type with different filters or options: the serializer of the stored value can't tell which branch
158
     * produced it (the object properties of branches track the producing filter on the model, a track per item
159
     * would be required here). The same holds if the branches filter the items of the arrays at different depths, an
160
     * array is serialized by the filters of its items whichever branch validated it.
161
     *
162
     * Several declarations of a slot may reach the very same filter. That is the case if a branch of the array refers
163
     * to the array itself (a tree of arrays): the filter is declared once and applies to all of them.
164
     *
165
     * Schema-form items validate every index, so they overlap with each tuple index. A tuple index never overlaps
166
     * with unevaluatedItems, which only validate the indices no other keyword evaluated.
167
     *
168
     * @throws SchemaException
169
     */
170
    public static function assertItemSlotsAreFilteredOnce(PropertyInterface $property, Schema $schema): void
3,107 ✔
171
    {
172
        /** @var array<int, array{int|string, array<string, FilterValidator>}> $filteredSlots */
173
        $filteredSlots = [];
3,107 ✔
174

175
        foreach (self::declarationsOf($property) as $declaration) {
3,107 ✔
176
            foreach (self::getFilteredItemSlots($declaration) as $slot => $filteredSlot) {
624 ✔
177
                $filters = $filteredSlot['filters'];
57 ✔
178

179
                foreach ($filteredSlots as [$recordedSlot, $recordedFilters]) {
57 ✔
180
                    if (
181
                        !self::slotsOverlap($recordedSlot, $slot)
14 ✔
182
                        || (
183
                            array_diff_key($recordedFilters, $filters) === []
14 ✔
184
                            && array_diff_key($filters, $recordedFilters) === []
14 ✔
185
                        )
186
                    ) {
187
                        continue;
6 ✔
188
                    }
189

190
                    throw new SchemaException(
8 ✔
191
                        sprintf(
8 ✔
192
                            "Array items of property '%s' are filtered more than once ('%s', '%s') in file %s:"
8 ✔
193
                                . ' filters of the same array item have no defined order',
8 ✔
194
                            $property->getName(),
8 ✔
195
                            self::firstToken(array_diff_key($recordedFilters, $filters) ?: $recordedFilters),
8 ✔
196
                            self::firstToken(array_diff_key($filters, $recordedFilters) ?: $filters),
8 ✔
197
                            $schema->getJsonSchema()->getFile(),
8 ✔
198
                        ),
8 ✔
199
                        $filteredSlot['property']->getJsonSchema(),
8 ✔
200
                    );
8 ✔
201
                }
202

203
                $filteredSlots[] = [$slot, $filters];
57 ✔
204
            }
205
        }
206
    }
207

208
    /**
209
     * @param array<int, true> $path Identities of the properties on the way to $property, a reference cycle must not
210
     *                               be walked endlessly (the same property in a branch is fine when it is reached
211
     *                               by another way).
212
     * @param ArrayItemDeclaration[] $declarations
213
     */
214
    private static function collectDeclarations(
3,128 ✔
215
        PropertyInterface $property,
216
        bool $conditional,
217
        bool $includeValidationOnlyBranches,
218
        array $path,
219
        array &$declarations,
220
    ): void {
221
        // A branch is a new decorator on every visit, the cycle is formed by the property it wraps.
222
        $identity = self::identityOf($property);
3,128 ✔
223

224
        if (isset($path[$identity])) {
3,128 ✔
225
            return;
1 ✔
226
        }
227

228
        $path[$identity] = true;
3,128 ✔
229

230
        foreach ($property->getValidators() as $propertyValidator) {
3,128 ✔
231
            $validator = $propertyValidator->getValidator();
3,096 ✔
232

233
            if (
234
                $validator instanceof ArrayItemValidator
3,096 ✔
235
                || $validator instanceof ArrayTupleValidator
3,096 ✔
236
                || $validator instanceof UnevaluatedItemsValidator
3,096 ✔
237
            ) {
238
                $declarations[] = new ArrayItemDeclaration($validator, $conditional);
632 ✔
239

240
                continue;
632 ✔
241
            }
242

243
            if (
244
                !($validator instanceof AbstractComposedPropertyValidator)
3,096 ✔
245
                || ($validator->isNotComposition() && !$includeValidationOnlyBranches)
3,096 ✔
246
            ) {
247
                continue;
3,077 ✔
248
            }
249

250
            foreach ($validator->getComposedProperties() as $branch) {
579 ✔
251
                if (
252
                    !$includeValidationOnlyBranches
557 ✔
253
                    && $validator instanceof ConditionalPropertyValidator
557 ✔
254
                    && $branch === $validator->getIfBranch()
557 ✔
255
                ) {
256
                    continue;
60 ✔
257
                }
258

259
                self::collectDeclarations(
557 ✔
260
                    $branch,
557 ✔
261
                    $conditional || !$validator->isAllOf(),
557 ✔
262
                    $includeValidationOnlyBranches,
557 ✔
263
                    $path,
557 ✔
264
                    $declarations,
557 ✔
265
                );
557 ✔
266
            }
267
        }
268
    }
269

270
    /**
271
     * The declaration of a filter: the location of its keyword in the schema. A definition which is referenced more
272
     * than once (or which references itself) is processed once per reference and every time yields a validator of its
273
     * own, all of them are the one declaration. The identity of the validator object can't tell, the location can.
274
     */
275
    private static function declarationKey(PropertyInterface $itemProperty, FilterValidator $filterValidator): string
69 ✔
276
    {
277
        return $itemProperty->getJsonSchema()->getFile() . '#' . $filterValidator->getJsonPointer();
69 ✔
278
    }
279

280
    /**
281
     * @param array<int, true> $path Identities of the array properties on the way to $arrayProperty, a reference cycle
282
     *                               must not be walked endlessly.
283
     * @param array<string, FilterValidator> $filters
284
     */
285
    private static function collectFiltersOfLevel(
1,538 ✔
286
        PropertyInterface $arrayProperty,
287
        bool $includeUnevaluatedItems,
288
        array $path,
289
        array &$filters,
290
    ): void {
291
        $identity = self::identityOf($arrayProperty);
1,538 ✔
292

293
        if (isset($path[$identity])) {
1,538 ✔
294
            return;
29 ✔
295
        }
296

297
        $path[$identity] = true;
1,538 ✔
298

299
        foreach (self::declarationsOf($arrayProperty) as $declaration) {
1,538 ✔
300
            if (!$includeUnevaluatedItems && $declaration->validator instanceof UnevaluatedItemsValidator) {
129 ✔
UNCOV
301
                continue;
×
302
            }
303

304
            foreach ($declaration->getItemProperties() as $itemProperty) {
129 ✔
305
                $filterValidator = self::transformingFilterOf($itemProperty);
129 ✔
306

307
                if ($filterValidator !== null) {
129 ✔
308
                    $filters[self::declarationKey($itemProperty, $filterValidator)] = $filterValidator;
56 ✔
309

310
                    continue;
56 ✔
311
                }
312

313
                self::collectFiltersOfLevel($itemProperty, false, $path, $filters);
108 ✔
314
            }
315
        }
316
    }
317

318
    /**
319
     * The transforming filters of the value of an item slot: the filter of the item itself or, if the item is an array,
320
     * the filters of its items on any level.
321
     *
322
     * @return array<string, FilterValidator>
323
     */
324
    private static function filtersOfSlotValue(PropertyInterface $itemProperty): array
632 ✔
325
    {
326
        $filterValidator = self::transformingFilterOf($itemProperty);
632 ✔
327

328
        return $filterValidator !== null
632 ✔
329
            ? [self::declarationKey($itemProperty, $filterValidator) => $filterValidator]
63 ✔
330
            : self::filtersOfItems($itemProperty, false);
632 ✔
331
    }
332

333
    /**
334
     * The slots whose value is filtered, by slot: 'items' for schema-form items, the index for tuple items,
335
     * 'unevaluated' for unevaluatedItems. The value is filtered by a transforming filter of the item itself or by one
336
     * on the items of a nested array.
337
     *
338
     * @return array<int|string, array{property: PropertyInterface, filters: array<string, FilterValidator>}>
339
     */
340
    private static function getFilteredItemSlots(ArrayItemDeclaration $declaration): array
624 ✔
341
    {
342
        $itemProperties = $declaration->getItemProperties();
624 ✔
343

344
        $slotProperties = match (true) {
624 ✔
345
            $declaration->validator instanceof ArrayItemValidator => [self::SCHEMA_FORM_SLOT => $itemProperties[0]],
624 ✔
346
            $declaration->validator instanceof ArrayTupleValidator => $itemProperties,
157 ✔
347
            default => [self::UNEVALUATED_SLOT => $itemProperties[0]],
15 ✔
348
        };
349

350
        $filteredSlots = [];
624 ✔
351
        foreach ($slotProperties as $slot => $itemProperty) {
624 ✔
352
            $filters = self::filtersOfSlotValue($itemProperty);
624 ✔
353

354
            if ($filters !== []) {
624 ✔
355
                $filteredSlots[$slot] = ['property' => $itemProperty, 'filters' => $filters];
57 ✔
356
            }
357
        }
358

359
        return $filteredSlots;
624 ✔
360
    }
361

362
    /**
363
     * @param array<string, FilterValidator> $filters
364
     */
365
    private static function firstToken(array $filters): string
8 ✔
366
    {
367
        return reset($filters)->getFilter()->getToken();
8 ✔
368
    }
369

370
    private static function slotsOverlap(int|string $firstSlot, int|string $secondSlot): bool
14 ✔
371
    {
372
        return $firstSlot === $secondSlot
14 ✔
373
            || ($firstSlot === self::SCHEMA_FORM_SLOT && is_int($secondSlot))
14 ✔
374
            || ($secondSlot === self::SCHEMA_FORM_SLOT && is_int($firstSlot));
14 ✔
375
    }
376
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc