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

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

26 Oct 2026 01:12PM UTC coverage: 99.322% (+0.004%) from 99.318%
40328603993

Pull #207

github

claude
Document the serialization of filtered items of nested arrays

The warning about filtered items of nested arrays which are not serialized is
replaced by the description of the supported behaviour: nested arrays at any
depth, arrays held by additionalProperties, patternProperties and
unevaluatedProperties, the guard for levels which may be inactive and a schema
which refers to itself. The rule that an item slot is filtered by a single
declaration now mentions the filters of nested levels. The only remaining
limitation is documented: unevaluatedItems on an array which is an item of
another array.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1R7TJejqtBJGe1uPS2p1L
Pull Request #207: Support transforming filters on array items inside composition branches

361 of 362 new or added lines in 9 files covered. (99.72%)

10258 of 10328 relevant lines covered (99.32%)

600.58 hits per line

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

99.75
/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor\PostProcessor\Internal;
6

7
use JsonSerializable;
8
use PHPModelGenerator\Filter\TransformingFilterInterface;
9
use PHPModelGenerator\Interfaces\SerializationInterface;
10
use PHPModelGenerator\Model\GeneratorConfiguration;
11
use PHPModelGenerator\Model\Property\Property;
12
use PHPModelGenerator\Model\Property\PropertyInterface;
13
use PHPModelGenerator\Model\Property\PropertyType;
14
use PHPModelGenerator\Model\Schema;
15
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
16
use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator;
17
use PHPModelGenerator\Model\Validator\ArrayItemValidator;
18
use PHPModelGenerator\Model\Validator\ArrayTupleValidator;
19
use PHPModelGenerator\Model\Validator\FilterValidator;
20
use PHPModelGenerator\Model\Validator\PatternPropertiesValidator;
21
use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator;
22
use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver;
23
use PHPModelGenerator\SchemaProcessor\Hook\SerializationHookInterface;
24
use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor;
25
use PHPModelGenerator\SchemaProcessor\PostProcessor\RenderedMethod;
26
use PHPModelGenerator\Traits\SerializableTrait;
27
use PHPModelGenerator\Utils\ArrayItemFilterScope;
28
use PHPModelGenerator\Utils\FilterReflection;
29
use PHPModelGenerator\Utils\RenderHelper;
30
use PHPModelGenerator\Utils\TypeCheck;
31
use ReflectionException;
32

33
class SerializationPostProcessor extends PostProcessor
34
{
35
    /**
36
     * Add serialization support to the provided schema
37
     */
38
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
122 ✔
39
    {
40
        $schema
122 ✔
41
            ->addTrait(SerializableTrait::class)
122 ✔
42
            ->addInterface(JsonSerializable::class)
122 ✔
43
            ->addInterface(SerializationInterface::class);
122 ✔
44

45
        $this->addSerializeFunctionsForTransformingFilters($schema, $generatorConfiguration);
122 ✔
46
        $this->addSerializationHookMethod($schema, $generatorConfiguration);
122 ✔
47
        $this->addSkipNotProvidedPropertiesMap($schema, $generatorConfiguration);
122 ✔
48
        $this->addWriteOnlyExclusion($schema, $generatorConfiguration);
122 ✔
49

50
        $json = $schema->getJsonSchema()->getJson();
122 ✔
51
        if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) {
122 ✔
52
            $this->addAdditionalPropertiesTransformingFilterSerializer($schema, $generatorConfiguration);
18 ✔
53
        }
54

55
        if (isset($json['unevaluatedProperties']) && $json['unevaluatedProperties'] !== false) {
122 ✔
56
            $this->addUnevaluatedPropertiesTransformingFilterSerializer($schema, $generatorConfiguration);
4 ✔
57
        }
58
    }
59

60
    /**
61
     * Renders the body of the serializer of a property which holds the value as provided or the output of one of
62
     * several filters. A filter which declares no return type can't be recognized by its output, it is tried last:
63
     * trying it earlier would make the serializers of the other filters unreachable. Of several such filters only
64
     * the first one is reachable.
65
     *
66
     * @param FilterValidator[] $filterValidators
67
     *
68
     * @throws ReflectionException
69
     */
70
    private function renderGuardedSerializers(array $filterValidators, PropertyInterface $property): string
8 ✔
71
    {
72
        $code = ['$value = $this->' . $property->getAttribute(true) . ';', ''];
8 ✔
73
        $unconditionalSerialization = null;
8 ✔
74

75
        // Several filters can produce a value of the same type: the property remembers the filter which produced the
76
        // value. A value no branch filter produced (e.g. set while no branch which filters the property is active)
77
        // falls back to the serializers guarded by the output type.
78
        if (FilterValidator::requiresSourceTracking($property)) {
8 ✔
79
            $code[] = sprintf(
5 ✔
80
                'switch ($this->_branchFilteredSources[%s] ?? null) {',
5 ✔
81
                var_export($property->getName(), true),
5 ✔
82
            );
5 ✔
83

84
            foreach (FilterValidator::branchTransformingFiltersOf($property) as $filterValidator) {
5 ✔
85
                array_push(
5 ✔
86
                    $code,
5 ✔
87
                    '    case ' . var_export($filterValidator->getIdentifier(), true) . ':',
5 ✔
88
                    '        ' . $this->renderSerialization($filterValidator),
5 ✔
89
                );
5 ✔
90
            }
91

92
            array_push($code, '}', '');
5 ✔
93
        }
94

95
        foreach ($filterValidators as $filterValidator) {
8 ✔
96
            $returnTypeNames = FilterReflection::getReturnTypeNames($filterValidator->getFilter(), $property);
8 ✔
97
            $serialization = $this->renderSerialization($filterValidator);
8 ✔
98

99
            if ($returnTypeNames === []) {
8 ✔
100
                $unconditionalSerialization ??= $serialization;
1 ✔
101

102
                continue;
1 ✔
103
            }
104

105
            array_push(
8 ✔
106
                $code,
8 ✔
107
                'if ' . TypeCheck::buildCompound($returnTypeNames) . ' {',
8 ✔
108
                "    $serialization",
8 ✔
109
                '}',
8 ✔
110
                '',
8 ✔
111
            );
8 ✔
112
        }
113

114
        $code[] = $unconditionalSerialization ?? 'return $value;';
8 ✔
115

116
        return implode("\n", $code);
8 ✔
117
    }
118

119
    private function renderSerialization(FilterValidator $filterValidator): string
8 ✔
120
    {
121
        [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer();
8 ✔
122

123
        return "return \\{$serializerClass}::{$serializerMethod}("
8 ✔
124
            . '$value, ' . RenderHelper::varExportArray($filterValidator->getFilterOptions()) . ');';
8 ✔
125
    }
126

127
    /**
128
     * Each transforming filter must provide a method to serialize the value. Add a method to the schema to call the
129
     * serialization for each property with a transforming filter
130
     */
131
    private function addSerializeFunctionsForTransformingFilters(
122 ✔
132
        Schema $schema,
133
        GeneratorConfiguration $generatorConfiguration,
134
    ): void {
135
        foreach ($schema->getProperties() as $property) {
122 ✔
136
            $transformingFilterValidators = [];
121 ✔
137

138
            foreach ($property->getValidators() as $propertyValidator) {
121 ✔
139
                $validator = $propertyValidator->getValidator();
115 ✔
140

141
                if (
142
                    $validator instanceof FilterValidator
115 ✔
143
                    && $validator->getFilter() instanceof TransformingFilterInterface
115 ✔
144
                ) {
145
                    $transformingFilterValidators[] = $validator;
34 ✔
146
                }
147
            }
148

149
            if ($transformingFilterValidators !== []) {
121 ✔
150
                $this->addPropertyTransformingFilterSerializer(
34 ✔
151
                    $schema,
34 ✔
152
                    $generatorConfiguration,
34 ✔
153
                    $property,
34 ✔
154
                    $transformingFilterValidators,
34 ✔
155
                );
34 ✔
156
            }
157

158
            $this->addArrayItemsTransformingFilterSerializer($schema, $generatorConfiguration, $property);
121 ✔
159
        }
160

161
        foreach ($schema->getBaseValidators() as $validator) {
122 ✔
162
            if (!$validator instanceof PatternPropertiesValidator) {
40 ✔
163
                continue;
37 ✔
164
            }
165

166
            $levelsOnPath = [];
11 ✔
167
            $entrySerializer = self::serializerOf($this->createSlot(
11 ✔
168
                $schema,
11 ✔
169
                $generatorConfiguration,
11 ✔
170
                $validator->getValidationProperty(),
11 ✔
171
                false,
11 ✔
172
                'Pattern' . $validator->getKey(),
11 ✔
173
                $levelsOnPath,
11 ✔
174
            ));
11 ✔
175

176
            if ($entrySerializer === null) {
11 ✔
177
                continue;
7 ✔
178
            }
179

180
            $schema->addMethod(
4 ✔
181
                "_serialize{$validator->getKey()}",
4 ✔
182
                new RenderedMethod(
4 ✔
183
                    $schema,
4 ✔
184
                    $generatorConfiguration,
4 ✔
185
                    join(
4 ✔
186
                        DIRECTORY_SEPARATOR,
4 ✔
187
                        ['Serialization', 'PatternPropertyTransformingFilterSerializer.phptpl'],
4 ✔
188
                    ),
4 ✔
189
                    ['key' => $validator->getKey(), 'entrySerializer' => $entrySerializer],
4 ✔
190
                )
4 ✔
191
            );
4 ✔
192
        }
193
    }
194

195
    /**
196
     * @param FilterValidator[] $transformingFilterValidators The transforming filters of the property
197
     *
198
     * @throws ReflectionException
199
     */
200
    private function addPropertyTransformingFilterSerializer(
34 ✔
201
        Schema $schema,
202
        GeneratorConfiguration $generatorConfiguration,
203
        PropertyInterface $property,
204
        array $transformingFilterValidators,
205
    ): void {
206
        $templateValues = ['property' => $property, 'guardedSerializers' => ''];
34 ✔
207

208
        // A filter which is owned by a branch of a composition may not have run: the property then still holds
209
        // the value as provided. Only a value of the output type of a filter is serialized by it.
210
        $ownedByBranch = array_filter(
34 ✔
211
            $transformingFilterValidators,
34 ✔
212
            static fn(FilterValidator $validator): bool => !$validator->isExecuted(),
34 ✔
213
        );
34 ✔
214

215
        if ($ownedByBranch !== []) {
34 ✔
216
            $templateValues['guardedSerializers'] = $this->renderGuardedSerializers(
8 ✔
217
                $transformingFilterValidators,
8 ✔
218
                $property,
8 ✔
219
            );
8 ✔
220
        } else {
221
            // Only one transforming filter can be executed on a property.
222
            $validator = $transformingFilterValidators[0];
34 ✔
223
            [$serializerClass, $serializerMethod] = $validator->getFilter()->getSerializer();
34 ✔
224

225
            $templateValues += [
34 ✔
226
                'serializerClass' => $serializerClass,
34 ✔
227
                'serializerMethod' => $serializerMethod,
34 ✔
228
                'serializerOptions' => RenderHelper::varExportArray($validator->getFilterOptions()),
34 ✔
229
            ];
34 ✔
230
        }
231

232
        $schema->addMethod(
34 ✔
233
            "_serialize{$property->getAttribute()}",
34 ✔
234
            new RenderedMethod(
34 ✔
235
                $schema,
34 ✔
236
                $generatorConfiguration,
34 ✔
237
                join(DIRECTORY_SEPARATOR, ['Serialization', 'TransformingFilterSerializer.phptpl']),
34 ✔
238
                $templateValues,
34 ✔
239
            ),
34 ✔
240
        );
34 ✔
241
    }
242

243
    /**
244
     * Generates the serializer of the items of an array property. The items are validated by the property itself and
245
     * by the branches of its compositions, the filters of every declaration are combined into a single generated
246
     * _serialize{Property}() method: two independent addMethod() calls with the same method name would silently
247
     * overwrite each other (Schema::addMethod() is a plain array write).
248
     *
249
     * Per slot a single declaration filters the items (see ArrayItemFilterScope::assertItemSlotsAreFilteredOnce()):
250
     * the schema-form items, each tuple index and the unevaluatedItems. Schema-form items and unevaluatedItems of the
251
     * same property are declared by different branches, the indices evaluated by unevaluatedItems are serialized by
252
     * its filter, all other indices by the filter of the schema-form items.
253
     *
254
     * @throws ReflectionException
255
     */
256
    private function addArrayItemsTransformingFilterSerializer(
121 ✔
257
        Schema $schema,
258
        GeneratorConfiguration $generatorConfiguration,
259
        PropertyInterface $arrayProperty,
260
    ): void {
261
        if (!ArrayItemFilterScope::filtersItemsOnAnyLevel($arrayProperty)) {
121 ✔
262
            return;
88 ✔
263
        }
264

265
        $levelsOnPath = [];
40 ✔
266
        $level = $this->buildArrayLevel(
40 ✔
267
            $schema,
40 ✔
268
            $generatorConfiguration,
40 ✔
269
            $arrayProperty,
40 ✔
270
            false,
40 ✔
271
            true,
40 ✔
272
            ucfirst($arrayProperty->getAttribute()),
40 ✔
273
            $levelsOnPath,
40 ✔
274
        );
40 ✔
275

276
        $schema->addMethod(
40 ✔
277
            "_serialize{$arrayProperty->getAttribute()}",
40 ✔
278
            new RenderedMethod(
40 ✔
279
                $schema,
40 ✔
280
                $generatorConfiguration,
40 ✔
281
                join(DIRECTORY_SEPARATOR, ['Serialization', 'ArrayItemsTransformingFilterSerializer.phptpl']),
40 ✔
282
                $level + [
40 ✔
283
                    'property' => $arrayProperty,
40 ✔
284
                    'arrayPropertyName' => $arrayProperty->getName(),
40 ✔
285
                    'methodName' => '',
40 ✔
286
                ],
40 ✔
287
            ),
40 ✔
288
        );
40 ✔
289
    }
290

291
    /**
292
     * Collects how the slots of one array level are serialized: the schema-form items, each tuple index and, on the
293
     * level of the property itself, the unevaluatedItems.
294
     *
295
     * The caller has to make sure that the level filters items (ArrayItemFilterScope::filtersItemsOnAnyLevel()). The
296
     * check follows the declarations the same way as this method, so at least one slot is serialized. A level which
297
     * serializes nothing is not guarded here, the method would only be an empty copy of its parameter.
298
     *
299
     * A slot holds either the serializer of a filter or, if the items are arrays themselves, a call of the generated
300
     * method of the nested level (see createSlot()). Several declarations may contribute to one slot (the branches of
301
     * an anyOf): see finishSlot().
302
     *
303
     * The unevaluatedItems of a nested level are not visited. Their indices are tracked per property name
304
     * (_evaluatedItemIndices), the nested arrays of all elements would share one entry and the serializer could not
305
     * tell which indices the keyword evaluated in which nested array.
306
     *
307
     * @param bool $conditional Whether the level itself is reached through a branch which may be inactive
308
     * @param bool $root Whether the level is the array property of the model (it owns the unevaluatedItems tracking)
309
     * @param string $levelName The part of the generated method name which identifies the level
310
     * @param array<string, string> $levelsOnPath Method names of the levels on the way to this level
311
     *
312
     * @return array{
313
     *     itemsSerializer: array{call: string, guard: string}|null,
314
     *     indexSerializers: array<int, array{index: int, call: string, guard: string}>,
315
     *     staticIndices: int[],
316
     *     dynamicSerializer: array{call: string, guard: string}|null,
317
     * }
318
     *
319
     * @throws ReflectionException
320
     */
321
    private function buildArrayLevel(
44 ✔
322
        Schema $schema,
323
        GeneratorConfiguration $generatorConfiguration,
324
        PropertyInterface $arrayProperty,
325
        bool $conditional,
326
        bool $root,
327
        string $levelName,
328
        array &$levelsOnPath,
329
    ): array {
330
        $itemsParts = self::emptySlot();
44 ✔
331
        $indexParts = [];
44 ✔
332
        $dynamicParts = self::emptySlot();
44 ✔
333

334
        foreach (ArrayItemFilterScope::declarationsOf($arrayProperty) as $declaration) {
44 ✔
335
            $validator = $declaration->validator;
44 ✔
336
            $declarationConditional = $conditional || $declaration->conditional;
44 ✔
337

338
            if ($validator instanceof ArrayItemValidator) {
44 ✔
339
                self::addToSlot(
35 ✔
340
                    $itemsParts,
35 ✔
341
                    $this->createSlot(
35 ✔
342
                        $schema,
35 ✔
343
                        $generatorConfiguration,
35 ✔
344
                        $validator->getNestedProperty(),
35 ✔
345
                        $declarationConditional,
35 ✔
346
                        $levelName . 'Items',
35 ✔
347
                        $levelsOnPath,
35 ✔
348
                    ),
35 ✔
349
                );
35 ✔
350
            } elseif ($validator instanceof ArrayTupleValidator) {
13 ✔
351
                foreach ($validator->getTupleProperties() as $index => $tupleProperty) {
12 ✔
352
                    $indexParts[$index] ??= self::emptySlot();
12 ✔
353

354
                    self::addToSlot(
12 ✔
355
                        $indexParts[$index],
12 ✔
356
                        $this->createSlot(
12 ✔
357
                            $schema,
12 ✔
358
                            $generatorConfiguration,
12 ✔
359
                            $tupleProperty,
12 ✔
360
                            $declarationConditional,
12 ✔
361
                            $levelName . 'Index' . $index,
12 ✔
362
                            $levelsOnPath,
12 ✔
363
                        ),
12 ✔
364
                    );
12 ✔
365
                }
366
            } elseif ($root) {
6 ✔
367
                self::addToSlot(
6 ✔
368
                    $dynamicParts,
6 ✔
369
                    $this->createSlot(
6 ✔
370
                        $schema,
6 ✔
371
                        $generatorConfiguration,
6 ✔
372
                        $validator->getValidationProperty(),
6 ✔
373
                        $declarationConditional,
6 ✔
374
                        $levelName . 'Unevaluated',
6 ✔
375
                        $levelsOnPath,
6 ✔
376
                    ),
6 ✔
377
                );
6 ✔
378
            }
379
        }
380

381
        $itemsSerializer = self::finishSlot($itemsParts);
44 ✔
382
        $dynamicSerializer = self::finishSlot($dynamicParts);
44 ✔
383
        $indexSerializers = [];
44 ✔
384

385
        foreach ($indexParts as $index => $parts) {
44 ✔
386
            $indexSerializer = self::finishSlot($parts);
12 ✔
387

388
            if ($indexSerializer !== null) {
12 ✔
389
                $indexSerializers[] = ['index' => $index] + $indexSerializer;
9 ✔
390
            }
391
        }
392

393
        return [
44 ✔
394
            'itemsSerializer' => $itemsSerializer,
44 ✔
395
            'indexSerializers' => $indexSerializers,
44 ✔
396
            'staticIndices' => array_keys($indexParts),
44 ✔
397
            'dynamicSerializer' => $dynamicSerializer,
44 ✔
398
        ];
44 ✔
399
    }
400

401
    /**
402
     * @return array{filter: array{call: string, guard: string}|null, levels: array<string, string>}
403
     */
404
    private static function emptySlot(): array
66 ✔
405
    {
406
        return ['filter' => null, 'levels' => []];
66 ✔
407
    }
408

409
    /**
410
     * @param array{filter: array{call: string, guard: string}|null, levels: array<string, string>} $slot
411
     * @param array{kind: string, call: string, guard: string, method?: string}|null $contribution
412
     */
413
    private static function addToSlot(array &$slot, ?array $contribution): void
66 ✔
414
    {
415
        if ($contribution === null) {
66 ✔
416
            return;
38 ✔
417
        }
418

419
        if ($contribution['kind'] === 'level') {
50 ✔
420
            $slot['levels'][$contribution['method']] = $contribution['method'];
19 ✔
421

422
            return;
19 ✔
423
        }
424

425
        // The declarations of one slot reach the same filter (ArrayItemFilterScope::assertItemSlotsAreFilteredOnce()),
426
        // the first stands for all of them.
427
        $slot['filter'] ??= ['call' => $contribution['call'], 'guard' => $contribution['guard']];
50 ✔
428
    }
429

430
    /**
431
     * Combines the contributions of the declarations of a slot into the serialization of one value.
432
     *
433
     * The declarations of a slot are alternatives if they are declared by the branches of a composition: an element is
434
     * either an array validated by one of the nested levels or it is filtered itself (a tree of arrays whose leaves
435
     * are arrays of filtered items). An element which is an array is run through the nested levels, any other element
436
     * through the filter. A nested level only serializes what its own filter produced, so the levels of alternative
437
     * branches can be applied one after the other.
438
     *
439
     * Choosing the first contribution only was rejected: the nested level of an earlier branch would hide the filter
440
     * of a later one.
441
     *
442
     * @param array{filter: array{call: string, guard: string}|null, levels: array<string, string>} $slot
443
     *
444
     * @return array{call: string, guard: string}|null
445
     */
446
    private static function finishSlot(array $slot): ?array
66 ✔
447
    {
448
        if ($slot['levels'] === []) {
66 ✔
449
            return $slot['filter'];
66 ✔
450
        }
451

452
        $levelCall = '$value';
19 ✔
453
        foreach ($slot['levels'] as $methodName) {
19 ✔
454
            $levelCall = '$this->' . $methodName . '(' . $levelCall . ')';
19 ✔
455
        }
456

457
        if ($slot['filter'] === null) {
19 ✔
458
            return ['call' => $levelCall, 'guard' => 'is_array($value)'];
19 ✔
459
        }
460

461
        $filterCall = $slot['filter']['guard'] === ''
2 ✔
NEW
462
            ? $slot['filter']['call']
×
463
            : '(' . $slot['filter']['guard'] . ' ? ' . $slot['filter']['call'] . ' : $value)';
2 ✔
464

465
        return ['call' => 'is_array($value) ? ' . $levelCall . ' : ' . $filterCall, 'guard' => ''];
2 ✔
466
    }
467

468
    /**
469
     * The serialization of the value of a slot which a single declaration contributes (an entry of the additional,
470
     * pattern or unevaluated properties). Null if the value isn't filtered.
471
     *
472
     * @param array{kind: string, call: string, guard: string, method?: string}|null $contribution
473
     *
474
     * @return array{call: string, guard: string}|null
475
     */
476
    private static function serializerOf(?array $contribution): ?array
27 ✔
477
    {
478
        $slot = self::emptySlot();
27 ✔
479
        self::addToSlot($slot, $contribution);
27 ✔
480

481
        return self::finishSlot($slot);
27 ✔
482
    }
483

484
    /**
485
     * Describes what a declaration contributes to the serialization of the value of one slot (the items of an array,
486
     * an entry of the additional, pattern or unevaluated properties). Null if the value of the slot isn't filtered.
487
     * The contribution is either `filter` (the value is filtered itself) or `level` (the value is an array with
488
     * filtered items on a nested level, `method` serializes it). `call` is an expression which serializes `$value`,
489
     * `guard` the condition under which the call is applied (empty if it always is).
490
     *
491
     * An array never has a transforming filter of its own (FilterProcessor rejects it), so a declaration contributes
492
     * one or the other.
493
     *
494
     * A nested level is generated as a method of its own. Inlining the levels was rejected: the levels of a
495
     * schema which references itself can't be unrolled, a call of the method of the level on the way back is the
496
     * natural form of the recursion.
497
     *
498
     * @param array<string, string> $levelsOnPath Method names of the levels on the way to this slot
499
     *
500
     * @return array{kind: string, call: string, guard: string, method?: string}|null
501
     *
502
     * @throws ReflectionException
503
     */
504
    private function createSlot(
66 ✔
505
        Schema $schema,
506
        GeneratorConfiguration $generatorConfiguration,
507
        PropertyInterface $valueProperty,
508
        bool $conditional,
509
        string $levelName,
510
        array &$levelsOnPath,
511
    ): ?array {
512
        $filterValidator = ArrayItemFilterScope::transformingFilterOf($valueProperty);
66 ✔
513
        if ($filterValidator !== null) {
66 ✔
514
            return $this->createFilterSlot($filterValidator, $valueProperty, $conditional);
50 ✔
515
        }
516

517
        if (!ArrayItemFilterScope::filtersItemsOnAnyLevel($valueProperty, false)) {
50 ✔
518
            return null;
38 ✔
519
        }
520

521
        // A level is shared by the paths which reach it with the same activity: the guards of the filters inside are
522
        // part of the method.
523
        $levelKey = ArrayItemFilterScope::identityOf($valueProperty) . ($conditional ? ':conditional' : ':certain');
19 ✔
524

525
        // The slot refers to a level which is being built further up (the schema references itself): the level calls
526
        // its own method.
527
        if (isset($levelsOnPath[$levelKey])) {
19 ✔
528
            return self::levelContribution($levelsOnPath[$levelKey]);
2 ✔
529
        }
530

531
        $methodName = $schema->reserveMethodName('_nestedItemsSerializer' . $levelName);
19 ✔
532
        $levelsOnPath[$levelKey] = $methodName;
19 ✔
533

534
        $level = $this->buildArrayLevel(
19 ✔
535
            $schema,
19 ✔
536
            $generatorConfiguration,
19 ✔
537
            $valueProperty,
19 ✔
538
            $conditional,
19 ✔
539
            false,
19 ✔
540
            $levelName,
19 ✔
541
            $levelsOnPath,
19 ✔
542
        );
19 ✔
543

544
        unset($levelsOnPath[$levelKey]);
19 ✔
545

546
        $schema->addMethod(
19 ✔
547
            $methodName,
19 ✔
548
            new RenderedMethod(
19 ✔
549
                $schema,
19 ✔
550
                $generatorConfiguration,
19 ✔
551
                join(DIRECTORY_SEPARATOR, ['Serialization', 'ArrayItemsTransformingFilterSerializer.phptpl']),
19 ✔
552
                $level + [
19 ✔
553
                    'property' => $valueProperty,
19 ✔
554
                    'arrayPropertyName' => $valueProperty->getName(),
19 ✔
555
                    'methodName' => $methodName,
19 ✔
556
                ],
19 ✔
557
            ),
19 ✔
558
        );
19 ✔
559

560
        return self::levelContribution($methodName);
19 ✔
561
    }
562

563
    /**
564
     * @return array{kind: string, call: string, guard: string, method: string}
565
     */
566
    private static function levelContribution(string $methodName): array
19 ✔
567
    {
568
        return [
19 ✔
569
            'kind' => 'level',
19 ✔
570
            'call' => '$this->' . $methodName . '($value)',
19 ✔
571
            'guard' => 'is_array($value)',
19 ✔
572
            'method' => $methodName,
19 ✔
573
        ];
19 ✔
574
    }
575

576
    /**
577
     * An item of a declaration inside a branch which may be inactive is only serialized if it is an instance of the
578
     * output type of the filter (the guard): the branch may not have transformed the items, an item which still has
579
     * the value as provided must not reach the serializer. A filter without a declared return type can't be guarded,
580
     * its serializer is always applied - the same limitation as for the filters of properties of several branches.
581
     *
582
     * @return array{kind: string, call: string, guard: string}
583
     *
584
     * @throws ReflectionException
585
     */
586
    private function createFilterSlot(
50 ✔
587
        FilterValidator $filterValidator,
588
        PropertyInterface $valueProperty,
589
        bool $conditional,
590
    ): array {
591
        [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer();
50 ✔
592

593
        $returnTypeNames = $conditional
50 ✔
594
            ? FilterReflection::getReturnTypeNames($filterValidator->getFilter(), $valueProperty)
17 ✔
595
            : [];
34 ✔
596

597
        return [
50 ✔
598
            'kind' => 'filter',
50 ✔
599
            'call' => sprintf(
50 ✔
600
                '\\%s::%s($value, %s)',
50 ✔
601
                $serializerClass,
50 ✔
602
                $serializerMethod,
50 ✔
603
                RenderHelper::varExportArray($filterValidator->getFilterOptions()),
50 ✔
604
            ),
50 ✔
605
            'guard' => $returnTypeNames === [] ? '' : TypeCheck::buildCompound($returnTypeNames),
50 ✔
606
        ];
50 ✔
607
    }
608

609
    private function addSerializationHookMethod(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
122 ✔
610
    {
611
        $schema->addMethod(
122 ✔
612
            '_resolveSerializationHook',
122 ✔
613
            new RenderedMethod(
122 ✔
614
                $schema,
122 ✔
615
                $generatorConfiguration,
122 ✔
616
                join(DIRECTORY_SEPARATOR, ['Serialization', 'SerializationHook.phptpl']),
122 ✔
617
                [
122 ✔
618
                    'schemaHookResolver' => new SchemaHookResolver($schema),
122 ✔
619
                ],
122 ✔
620
            )
122 ✔
621
        );
122 ✔
622
    }
623

624
    /**
625
     * When additional properties have a transforming filter, override _serializeAdditionalProperties on the model
626
     * so that the filter's deserializer runs before values are serialized.
627
     *
628
     * For the generic case (no transforming filter), SerializableTrait._serializeAdditionalProperties handles
629
     * serialization directly — no model-side override is needed.
630
     */
631
    public function addAdditionalPropertiesTransformingFilterSerializer(
22 ✔
632
        Schema $schema,
633
        GeneratorConfiguration $generatorConfiguration,
634
    ): void {
635
        $validationProperty = null;
22 ✔
636
        foreach ($schema->getBaseValidators() as $validator) {
22 ✔
637
            if (is_a($validator, AdditionalPropertiesValidator::class)) {
22 ✔
638
                $validationProperty = $validator->getValidationProperty();
17 ✔
639
            }
640
        }
641

642
        if (!$validationProperty) {
22 ✔
643
            return;
5 ✔
644
        }
645

646
        $levelsOnPath = [];
17 ✔
647
        $entrySerializer = self::serializerOf($this->createSlot(
17 ✔
648
            $schema,
17 ✔
649
            $generatorConfiguration,
17 ✔
650
            $validationProperty,
17 ✔
651
            false,
17 ✔
652
            'AdditionalProperties',
17 ✔
653
            $levelsOnPath,
17 ✔
654
        ));
17 ✔
655

656
        // Only generate the model-side override when the values are filtered themselves or are arrays with filtered
657
        // items. The trait's default _serializeAdditionalProperties handles the generic case.
658
        if ($entrySerializer === null) {
17 ✔
659
            return;
11 ✔
660
        }
661

662
        $schema->addMethod(
6 ✔
663
            '_serializeAdditionalProperties',
6 ✔
664
            new RenderedMethod(
6 ✔
665
                $schema,
6 ✔
666
                $generatorConfiguration,
6 ✔
667
                'Serialization/AdditionalPropertiesSerializer.phptpl',
6 ✔
668
                ['entrySerializer' => $entrySerializer],
6 ✔
669
            )
6 ✔
670
        );
6 ✔
671
    }
672

673
    /**
674
     * When unevaluated properties have a transforming filter, override
675
     * _serializeUnevaluatedProperties on the model so the filter's serialize() runs before the
676
     * values reach the generic serializer. Without this, transformed values (e.g. DateTime
677
     * instances) reach the generic path unfiltered and drop to empty arrays in the output.
678
     *
679
     * The generic case (no transforming filter) is handled by
680
     * SerializableTrait::_serializeUnevaluatedProperties.
681
     */
682
    public function addUnevaluatedPropertiesTransformingFilterSerializer(
4 ✔
683
        Schema $schema,
684
        GeneratorConfiguration $generatorConfiguration,
685
    ): void {
686
        $validationProperty = null;
4 ✔
687
        foreach ($schema->getPostCompositionValidators() as $validator) {
4 ✔
688
            if (is_a($validator, UnevaluatedPropertiesValidator::class)) {
3 ✔
689
                $validationProperty = $validator->getValidationProperty();
3 ✔
690
                break;
3 ✔
691
            }
692
        }
693

694
        if (!$validationProperty) {
4 ✔
695
            return;
1 ✔
696
        }
697

698
        $levelsOnPath = [];
3 ✔
699
        $entrySerializer = self::serializerOf($this->createSlot(
3 ✔
700
            $schema,
3 ✔
701
            $generatorConfiguration,
3 ✔
702
            $validationProperty,
3 ✔
703
            false,
3 ✔
704
            'UnevaluatedProperties',
3 ✔
705
            $levelsOnPath,
3 ✔
706
        ));
3 ✔
707

708
        if ($entrySerializer === null) {
3 ✔
709
            return;
1 ✔
710
        }
711

712
        $schema->addMethod(
2 ✔
713
            '_serializeUnevaluatedProperties',
2 ✔
714
            new RenderedMethod(
2 ✔
715
                $schema,
2 ✔
716
                $generatorConfiguration,
2 ✔
717
                'Serialization/UnevaluatedPropertiesSerializer.phptpl',
2 ✔
718
                ['entrySerializer' => $entrySerializer],
2 ✔
719
            )
2 ✔
720
        );
2 ✔
721
    }
722

723
    private function addWriteOnlyExclusion(
122 ✔
724
        Schema $schema,
725
        GeneratorConfiguration $generatorConfiguration,
726
    ): void {
727
        $writeOnlyAttributes = array_map(
122 ✔
728
            static fn(PropertyInterface $property): string => $property->getAttribute(true),
122 ✔
729
            array_filter(
122 ✔
730
                $schema->getProperties(),
122 ✔
731
                static fn(PropertyInterface $property): bool => $property->isWriteOnly(),
122 ✔
732
            ),
122 ✔
733
        );
122 ✔
734

735
        if (!$writeOnlyAttributes) {
122 ✔
736
            return;
120 ✔
737
        }
738

739
        $keysExport = RenderHelper::varExportArray(array_values($writeOnlyAttributes));
5 ✔
740

741
        $schema->addSchemaHook(
5 ✔
742
            new class ($keysExport) implements SerializationHookInterface
5 ✔
743
            {
5 ✔
744
                public function __construct(private readonly string $keysExport)
745
                {}
5 ✔
746

747
                public function getCode(): string
748
                {
749
                    return sprintf(
5 ✔
750
                        <<<'CODE'
5 ✔
751
                        foreach (%s as $writeOnlyKey) {
752
                            unset($data[$writeOnlyKey]);
753
                        }
754
                        CODE,
5 ✔
755
                        $this->keysExport,
5 ✔
756
                    );
5 ✔
757
                }
758
            },
5 ✔
759
        );
5 ✔
760
    }
761

762
    private function addSkipNotProvidedPropertiesMap(
122 ✔
763
        Schema $schema,
764
        GeneratorConfiguration $generatorConfiguration,
765
    ): void {
766
        if ($generatorConfiguration->isImplicitNullAllowed()) {
122 ✔
767
            return;
106 ✔
768
        }
769

770
        $skipNotProvidedValues = array_map(
16 ✔
771
            static fn(PropertyInterface $property): string => $property->getName(),
16 ✔
772
            array_filter(
16 ✔
773
                $schema->getProperties(),
16 ✔
774
                static fn(PropertyInterface $property): bool =>
16 ✔
775
                    !$property->isRequired() && !$property->getDefaultValue(),
16 ✔
776
            )
16 ✔
777
        );
16 ✔
778

779
        $schema->addProperty(
16 ✔
780
            (new Property(
16 ✔
781
                'skipNotProvidedPropertiesMap',
16 ✔
782
                new PropertyType('array'),
16 ✔
783
                new JsonSchema(__FILE__, []),
16 ✔
784
                'Values which might be skipped for serialization if not provided',
16 ✔
785
            ))
16 ✔
786
                ->setDefaultValue($skipNotProvidedValues)
16 ✔
787
                ->setInternal(true),
16 ✔
788
        );
16 ✔
789
    }
790
}
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