• 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

91.79
/src/PropertyProcessor/Filter/FilterProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\PropertyProcessor\Filter;
6

7
use Exception;
8
use PHPModelGenerator\Draft\Draft;
9
use PHPModelGenerator\Draft\DraftFactoryInterface;
10
use PHPModelGenerator\Exception\InvalidFilterException;
11
use PHPModelGenerator\Exception\Object\InvalidInstanceOfException;
12
use PHPModelGenerator\Exception\SchemaException;
13
use PHPModelGenerator\Filter\TransformingFilterInterface;
14
use PHPModelGenerator\Filter\ValidateOptionsInterface;
15
use PHPModelGenerator\Model\GeneratorConfiguration;
16
use PHPModelGenerator\Model\Property\PropertyInterface;
17
use PHPModelGenerator\Model\Property\PropertyType;
18
use PHPModelGenerator\Model\Schema;
19
use PHPModelGenerator\Model\Validator;
20
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
21
use PHPModelGenerator\Model\Validator\ComposedPropertyValidator;
22
use PHPModelGenerator\Model\Validator\EnumValidator;
23
use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory;
24
use PHPModelGenerator\Model\Validator\Factory\Composition\AnyOfValidatorFactory;
25
use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory;
26
use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory;
27
use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory;
28
use PHPModelGenerator\Model\Validator\FilterValidator;
29
use PHPModelGenerator\Model\Validator\InstanceOfValidator;
30
use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator;
31
use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator;
32
use PHPModelGenerator\Model\Validator\PropertyValidator;
33
use PHPModelGenerator\Model\Validator\TypeCheckValidator;
34
use PHPModelGenerator\Utils\FilterReflection;
35
use PHPModelGenerator\Utils\RenderHelper;
36
use PHPModelGenerator\Utils\TypeCheck;
37
use ReflectionException;
38

39
/**
40
 * Class FilterProcessor
41
 *
42
 * @package PHPModelGenerator\PropertyProcessor\Filter
43
 */
44
class FilterProcessor
45
{
46
    /**
47
     * Normalize a filter specification to a list of filter entries.
48
     *
49
     * Accepts a string token, a single filter-spec array (['filter' => 'token', ...]),
50
     * or a list of either. Always returns a list.
51
     */
52
    public static function normalizeFilterList(string|array $filterList): array
217✔
53
    {
54
        if (is_string($filterList) || (is_array($filterList) && isset($filterList['filter']))) {
217✔
55
            return [$filterList];
177✔
56
        }
57

58
        return $filterList;
40✔
59
    }
60

61
    /**
62
     * @throws InvalidFilterException
63
     * @throws ReflectionException
64
     * @throws SchemaException
65
     */
66
    public function process(
217✔
67
        PropertyInterface $property,
68
        string|array $filterList,
69
        GeneratorConfiguration $generatorConfiguration,
70
        Schema $schema,
71
        int $startPriority = 10,
72
    ): void {
73
        $filterList = self::normalizeFilterList($filterList);
217✔
74

75
        $transformingFilter = null;
217✔
76
        $builtDraft = null;
217✔
77
        // apply a different priority to each filter to make sure the order is kept
78
        $filterPriority = $startPriority + count($property->getValidators());
217✔
79

80
        foreach ($filterList as $filterToken) {
217✔
81
            $filterOptions = [];
217✔
82
            if (is_array($filterToken)) {
217✔
83
                $filterOptions = array_diff_key($filterToken, ['filter' => null]);
54✔
84
                $filterToken = $filterToken['filter'] ?? '';
54✔
85
            }
86

87
            if (!($filter = $generatorConfiguration->getFilter($filterToken))) {
217✔
88
                throw new SchemaException(
1✔
89
                    sprintf(
1✔
90
                        'Unsupported filter %s on property %s in file %s',
1✔
91
                        $filterToken,
1✔
92
                        $property->getName(),
1✔
93
                        $property->getJsonSchema()->getFile(),
1✔
94
                    )
1✔
95
                );
1✔
96
            }
97

98
            if ($filter instanceof ValidateOptionsInterface) {
216✔
99
                try {
100
                    $filter->validateOptions($filterOptions);
7✔
101
                } catch (Exception $exception) {
4✔
102
                    throw new SchemaException(
4✔
103
                        sprintf(
4✔
104
                            'Invalid filter options on filter %s on property %s in file %s: %s',
4✔
105
                            $filterToken,
4✔
106
                            $property->getName(),
4✔
107
                            $property->getJsonSchema()->getFile(),
4✔
108
                            $exception->getMessage(),
4✔
109
                        )
4✔
110
                    );
4✔
111
                }
112
            }
113

114
            $isTransformingFilter = $filter instanceof TransformingFilterInterface;
212✔
115

116
            if ($isTransformingFilter) {
212✔
117
                if ($property->getType() && in_array('array', $property->getType()->getNames(), true)) {
135✔
118
                    throw new SchemaException(
1✔
119
                        sprintf(
1✔
120
                            'Applying a transforming filter to the array property %s is not supported in file %s',
1✔
121
                            $property->getName(),
1✔
122
                            $property->getJsonSchema()->getFile(),
1✔
123
                        )
1✔
124
                    );
1✔
125
                }
126
                if ($transformingFilter) {
134✔
127
                    throw new SchemaException(
1✔
128
                        sprintf(
1✔
129
                            'Applying multiple transforming filters for property %s is not supported in file %s',
1✔
130
                            $property->getName(),
1✔
131
                            $property->getJsonSchema()->getFile(),
1✔
132
                        )
1✔
133
                    );
1✔
134
                }
135
            }
136

137
            // $transformingFilter is still null here when the current filter IS the transforming
138
            // filter — FilterValidator correctly receives null (no previous transforming filter).
139
            $actualFilterPriority = $filterPriority++;
211✔
140
            $property->addValidator(
211✔
141
                new FilterValidator($generatorConfiguration, $filter, $property, $filterOptions, $transformingFilter),
211✔
142
                $actualFilterPriority,
211✔
143
            );
211✔
144

145
            if ($isTransformingFilter) {
197✔
146
                $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property);
132✔
147

148
                $inputTypeNames = FilterReflection::getAcceptedTypes($filter, $property);
129✔
149
                // Build the Draft at most once per process() call. A filter chain can contain
150
                // only one transforming filter (a second throws above), but process() may be
151
                // called externally multiple times on the same property; the null-coalescing
152
                // assignment ensures we do not rebuild when called from a MediaStringModifier
153
                // followed by a FilterValidatorFactory invocation.
154
                $builtDraft ??= $this->resolveBuiltDraft($generatorConfiguration, $property);
129✔
155
                $classifier = new CompositionBranchClassifier($builtDraft, $inputTypeNames, $returnTypeNames);
129✔
156
                $checker = new CompositionCompatibilityChecker($classifier, $property);
129✔
157
                $checker->checkTransformingFilterCompositionConflicts($property->getJsonSchema()->getJson());
129✔
158
                $checker->checkTransformingFilterRootCompositionConflicts($schema->getJsonSchema()->getJson());
123✔
159

160
                $this->reassignValidatorPriorities(
118✔
161
                    $property,
118✔
162
                    $actualFilterPriority,
118✔
163
                    $classifier,
118✔
164
                    $returnTypeNames,
118✔
165
                    $generatorConfiguration,
118✔
166
                );
118✔
167

168
                if (!empty($returnTypeNames)) {
118✔
169
                    // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators
170
                    // so they are skipped when an already-transformed value is provided.
171
                    // Only validators present at this point (i.e. before the transforming filter)
172
                    // receive the check — post-transform validators are added later and use
173
                    // !$transformationFailed instead.
174
                    $this->addTransformedValuePassThrough($property, $filter, $returnTypeNames);
117✔
175

176
                    $objectReturnTypes = array_values(array_filter(
117✔
177
                        $returnTypeNames,
117✔
178
                        static fn(string $type): bool => !TypeCheck::isPrimitive($type),
117✔
179
                    ));
117✔
180
                    if (!empty($objectReturnTypes)) {
117✔
181
                        $this->addExtendedInstanceOfCheckForObjectBranches(
96✔
182
                            $property,
96✔
183
                            $objectReturnTypes,
96✔
184
                            $actualFilterPriority,
96✔
185
                        );
96✔
186
                    }
187

188
                    // Eagerly set the output type when the base type is already known.
189
                    // This preserves the output type through property cloning in merged composition
190
                    // schemas (where validators are stripped but the type fields are retained).
191
                    // When the base type is null (type comes from a sibling allOf branch), this is
192
                    // skipped and TransformingFilterOutputTypePostProcessor handles it after
193
                    // composition has resolved the final base type.
194
                    $baseType = $property->getType();
117✔
195
                    if ($baseType !== null) {
117✔
196
                        $this->applyOutputType(
110✔
197
                            $property,
110✔
198
                            $filter,
110✔
199
                            $returnTypeNames,
110✔
200
                            $baseType,
110✔
201
                            $generatorConfiguration,
110✔
202
                            $schema,
110✔
203
                        );
110✔
204
                    }
205
                }
206

207
                $transformingFilter = $filter;
118✔
208
            }
209
        }
210
    }
211

212
    /**
213
     * Adds a property-level validator that rejects objects whose type is not in the filter's
214
     * declared non-primitive return types (e.g. rejects stdClass when the filter returns DateTime).
215
     *
216
     * Empty object schemas ({type: object} with no declared properties) in composition branches
217
     * have their strict instanceof check removed so that any PHP object passes the branch's
218
     * type check. Without a narrowing check at the property level, foreign objects would
219
     * silently pass through. Placing the validator at property level ensures
220
     * InvalidInstanceOfException propagates directly rather than being absorbed by the
221
     * composition template's catch block.
222
     *
223
     * Only adds the validator when at least one composition branch had its instanceof removed
224
     * (i.e. has an empty nested schema with no declared properties).
225
     *
226
     * @param string[] $objectReturnTypes Non-primitive PHP class names returned by the filter.
227
     * @param int      $filterPriority    Priority at which the transforming filter was added;
228
     *                                    the check is scheduled one step later so it runs on
229
     *                                    the already-transformed value.
230
     */
231
    private function addExtendedInstanceOfCheckForObjectBranches(
96✔
232
        PropertyInterface $property,
233
        array $objectReturnTypes,
234
        int $filterPriority,
235
    ): void {
236
        $hasEmptyObjectBranch = false;
96✔
237
        foreach ($property->getValidators() as $validatorContainer) {
96✔
238
            $validator = $validatorContainer->getValidator();
96✔
239

240
            // Unwrap a FilterPreTransformGuardValidator to reach the underlying composed validator.
241
            if ($validator instanceof FilterPreTransformGuardValidator) {
96✔
242
                $validator = $validator->getInnerValidator();
15✔
243
            }
244

245
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
96✔
246
                continue;
96✔
247
            }
248

249
            /** @var AbstractComposedPropertyValidator $composedValidator */
250
            $composedValidator = $validator;
15✔
251

252
            foreach ($composedValidator->getComposedProperties() as $compositionProperty) {
15✔
253
                $nestedSchema = $compositionProperty->getNestedSchema();
15✔
254
                if ($nestedSchema === null || !empty($nestedSchema->getProperties())) {
15✔
255
                    continue;
15✔
256
                }
257

NEW
258
                $instanceOfRemoved = true;
×
NEW
259
                foreach ($compositionProperty->getValidators() as $compositionValidator) {
×
NEW
260
                    if (is_a($compositionValidator->getValidator(), InstanceOfValidator::class)) {
×
NEW
261
                        $instanceOfRemoved = false;
×
NEW
262
                        break;
×
263
                    }
264
                }
265

NEW
266
                if ($instanceOfRemoved) {
×
NEW
267
                    $hasEmptyObjectBranch = true;
×
NEW
268
                    break 2;
×
269
                }
270
            }
271
        }
272

273
        if (!$hasEmptyObjectBranch) {
96✔
274
            return;
96✔
275
        }
276

NEW
277
        $instanceOfParts = implode(' || ', array_map(
×
NEW
278
            static fn(string $cls): string => "\$value instanceof $cls",
×
NEW
279
            $objectReturnTypes,
×
NEW
280
        ));
×
281

NEW
282
        $property->addValidator(
×
NEW
283
            new PropertyValidator(
×
NEW
284
                $property,
×
NEW
285
                "is_object(\$value) && !($instanceOfParts)",
×
NEW
286
                InvalidInstanceOfException::class,
×
NEW
287
                [reset($objectReturnTypes)],
×
NEW
288
            ),
×
NEW
289
            $filterPriority + 1,
×
NEW
290
        );
×
291
    }
292

293
    /**
294
     * After identifying a transforming filter at priority P, scan all existing validators
295
     * on the property and adjust their run order:
296
     *
297
     * - Validators with a source key whose Draft-registered types are a subset of the
298
     *   filter's output type-space → leave at their current priority (post-transform).
299
     * - All other validators with a source key (input-space, mixed, ambiguous) that are
300
     *   currently scheduled to run after the filter → move to just before the filter (P-1),
301
     *   so they execute against the raw input value.
302
     * - Validators without a source key (type-check, required, non-transforming filters)
303
     *   are untouched regardless of their priority.
304
     * - Composition validators (AbstractComposedPropertyValidator) are classified by their
305
     *   branch type-space and repositioned accordingly:
306
     *     - All input-space or ambiguous → moved to P-1, wrapped in a skip guard so that
307
     *       already-transformed values bypass the pre-transform check.
308
     *     - All output-space → left post-filter (default).
309
     *     - Mixed-space allOf (some input, some output branches) → split into a pre-filter
310
     *       input-only subset and a post-filter output-only subset; the original is removed.
311
     *
312
     * @param int      $filterPriority  The actual priority at which the transforming filter was added.
313
     * @param string[] $returnTypeNames Non-null return type names of the transforming filter;
314
     *                                  used to build the skip condition for the pre-transform guards.
315
     */
316
    private function reassignValidatorPriorities(
118✔
317
        PropertyInterface $property,
318
        int $filterPriority,
319
        CompositionBranchClassifier $classifier,
320
        array $returnTypeNames,
321
        GeneratorConfiguration $generatorConfiguration,
322
    ): void {
323
        $skipCheck = !empty($returnTypeNames) ? TypeCheck::buildCompound($returnTypeNames) : '';
118✔
324

325
        [$inputSpaceComposed, $mixedAllOf] = $this->classifyValidatorAdjustments(
118✔
326
            $property,
118✔
327
            $filterPriority,
118✔
328
            $classifier,
118✔
329
            $returnTypeNames,
118✔
330
        );
118✔
331

332
        $this->wrapInputSpaceGuards(
118✔
333
            $property,
118✔
334
            $inputSpaceComposed,
118✔
335
            $filterPriority,
118✔
336
            $skipCheck,
118✔
337
            $generatorConfiguration,
118✔
338
        );
118✔
339

340
        $this->splitMixedSpaceAllOf(
118✔
341
            $property,
118✔
342
            $mixedAllOf,
118✔
343
            $filterPriority,
118✔
344
            $skipCheck,
118✔
345
            $returnTypeNames,
118✔
346
            $generatorConfiguration,
118✔
347
        );
118✔
348
    }
349

350
    /**
351
     * Scan all post-filter validators on the property, move scalar input-space validators
352
     * to just before the filter, and return two accumulator lists for deferred structural
353
     * changes that cannot be applied while iterating the validator list:
354
     *
355
     *  - $inputSpaceComposed: uniform input-space composition validators that need a
356
     *    pre-transform guard when return types are known.
357
     *  - $mixedAllOf: allOf validators whose branches span both type-spaces and must be
358
     *    split into separate pre- and post-filter subsets.
359
     *
360
     * @param string[] $returnTypeNames
361
     *
362
     * @return array{
363
     *     list<array{container: Validator, validator: AbstractComposedPropertyValidator}>,
364
     *     list<array{
365
     *         container: Validator, validator: ComposedPropertyValidator,
366
     *         inputIndices: int[], outputIndices: int[]
367
     *     }>
368
     * }
369
     */
370
    private function classifyValidatorAdjustments(
118✔
371
        PropertyInterface $property,
372
        int $filterPriority,
373
        CompositionBranchClassifier $classifier,
374
        array $returnTypeNames,
375
    ): array {
376
        /** @var list<array{container: Validator, validator: AbstractComposedPropertyValidator}> */
377
        $inputSpaceComposed = [];
118✔
378

379
        /** @var list<array{container: Validator, validator: ComposedPropertyValidator, inputIndices: int[], outputIndices: int[]}> */
380
        $mixedAllOf = [];
118✔
381

382
        foreach ($property->getValidators() as $validatorContainer) {
118✔
383
            if ($validatorContainer->getPriority() < $filterPriority) {
118✔
384
                continue; // Already scheduled before the filter; no adjustment needed.
108✔
385
            }
386

387
            if (is_a($validatorContainer->getValidator(), FilterValidator::class)) {
118✔
388
                continue; // Skip the filter validators themselves.
118✔
389
            }
390

391
            if (is_a($validatorContainer->getValidator(), AbstractComposedPropertyValidator::class)) {
24✔
392
                /** @var AbstractComposedPropertyValidator $composedValidator */
393
                $composedValidator = $validatorContainer->getValidator();
22✔
394

395
                [$inputIndices, $outputIndices] = $this->classifyComposedValidatorBranches(
22✔
396
                    $composedValidator,
22✔
397
                    $classifier,
22✔
398
                    $property->getJsonSchema()->getJson(),
22✔
399
                );
22✔
400

401
                // Only allOf can have mixed spaces. The CompositionCompatibilityChecker
402
                // statically guarantees that anyOf, oneOf, not, and if/then/else validators
403
                // all have uniform spaces (entirely input-space or entirely output-space),
404
                // so only allOf needs splitting. Collect mixed-space allOf validators for
405
                // splitting in splitMixedSpaceAllOf().
406
                if (
407
                    !empty($inputIndices) && !empty($outputIndices)
22✔
408
                    && $composedValidator instanceof ComposedPropertyValidator
22✔
409
                    && $composedValidator->getCompositionProcessor() === AllOfValidatorFactory::class
22✔
410
                ) {
411
                    $mixedAllOf[] = [
2✔
412
                        'container'     => $validatorContainer,
2✔
413
                        'validator'     => $composedValidator,
2✔
414
                        'inputIndices'  => $inputIndices,
2✔
415
                        'outputIndices' => $outputIndices,
2✔
416
                    ];
2✔
417
                    continue;
2✔
418
                }
419

420
                if (empty($outputIndices)) {
20✔
421
                    // All branches are input-space or ambiguous (Empty → Input by liberal policy).
422
                    // Defer guard wrapping when return types are known; otherwise move directly.
423
                    if (!empty($returnTypeNames)) {
15✔
424
                        $inputSpaceComposed[] = [
15✔
425
                            'container' => $validatorContainer,
15✔
426
                            'validator' => $composedValidator,
15✔
427
                        ];
15✔
428
                    } else {
NEW
429
                        $validatorContainer->setPriority($filterPriority - 1);
×
430
                    }
431
                }
432
                // Output-space composition validators: leave at their current post-filter position.
433

434
                continue;
20✔
435
            }
436

437
            $sourceKey = $validatorContainer->getSourceKey();
2✔
438
            if ($sourceKey === null) {
2✔
439
                // No source key: validator was not produced by a Draft AbstractValidatorFactory
440
                // (e.g. PassThroughTypeCheckValidator). Leave at its current position.
NEW
441
                continue;
×
442
            }
443

444
            $typeSpace = $classifier->classifySchemaKey($sourceKey);
2✔
445

446
            if ($typeSpace === TypeSpace::Output) {
2✔
447
                continue; // Output-space validators belong after the filter.
2✔
448
            }
449

450
            // Input-space, mixed, and ambiguous validators must run before the filter
451
            // so they validate the raw input value.
452
            $validatorContainer->setPriority($filterPriority - 1);
2✔
453
        }
454

455
        return [$inputSpaceComposed, $mixedAllOf];
118✔
456
    }
457

458
    /**
459
     * Replace each uniform input-space composition validator with a FilterPreTransformGuardValidator
460
     * that short-circuits when the value is already in the filter's output type-space.
461
     *
462
     * @param list<array{container: Validator, validator: AbstractComposedPropertyValidator}> $inputSpaceComposed
463
     */
464
    private function wrapInputSpaceGuards(
118✔
465
        PropertyInterface $property,
466
        array $inputSpaceComposed,
467
        int $filterPriority,
468
        string $skipCheck,
469
        GeneratorConfiguration $generatorConfiguration,
470
    ): void {
471
        foreach ($inputSpaceComposed as ['container' => $originalContainer, 'validator' => $composedValidator]) {
118✔
472
            $property->filterValidators(
15✔
473
                static fn(Validator $container): bool => $container !== $originalContainer,
15✔
474
            );
15✔
475
            $property->addValidator(
15✔
476
                new FilterPreTransformGuardValidator(
15✔
477
                    $generatorConfiguration,
15✔
478
                    $property,
15✔
479
                    $composedValidator,
15✔
480
                    $skipCheck,
15✔
481
                ),
15✔
482
                $filterPriority - 1,
15✔
483
            );
15✔
484
        }
485
    }
486

487
    /**
488
     * Replace each mixed-space allOf validator with a pre-filter input-subset (wrapped in a guard
489
     * when return types are known) and a post-filter output-subset at the original priority.
490
     *
491
     * @param list<array{
492
     *     container: Validator, validator: ComposedPropertyValidator,
493
     *     inputIndices: int[], outputIndices: int[]
494
     * }> $mixedAllOf
495
     * @param string[] $returnTypeNames
496
     */
497
    private function splitMixedSpaceAllOf(
118✔
498
        PropertyInterface $property,
499
        array $mixedAllOf,
500
        int $filterPriority,
501
        string $skipCheck,
502
        array $returnTypeNames,
503
        GeneratorConfiguration $generatorConfiguration,
504
    ): void {
505
        foreach (
506
            $mixedAllOf as [
118✔
507
            'container' => $originalContainer,
118✔
508
            'validator'     => $originalValidator,
118✔
509
            'inputIndices'  => $inputIndices,
118✔
510
            'outputIndices' => $outputIndices,
118✔
511
            ]
118✔
512
        ) {
513
            $property->filterValidators(
2✔
514
                static fn(Validator $container): bool => $container !== $originalContainer,
2✔
515
            );
2✔
516

517
            // Input-space subset runs before the filter; wrap in a guard when return types are known.
518
            $preTransformValidator = $originalValidator->createSubsetValidator($inputIndices, '_pre_filter');
2✔
519
            if (!empty($returnTypeNames)) {
2✔
520
                $property->addValidator(
2✔
521
                    new FilterPreTransformGuardValidator(
2✔
522
                        $generatorConfiguration,
2✔
523
                        $property,
2✔
524
                        $preTransformValidator,
2✔
525
                        $skipCheck,
2✔
526
                    ),
2✔
527
                    $filterPriority - 1,
2✔
528
                );
2✔
529
            } else {
NEW
530
                $property->addValidator($preTransformValidator, $filterPriority - 1);
×
531
            }
532

533
            // Output-space subset runs at the original validator's priority so its position
534
            // relative to other post-transform validators is preserved.
535
            $postTransformValidator = $originalValidator->createSubsetValidator($outputIndices, '_post_filter');
2✔
536
            $property->addValidator($postTransformValidator, $originalContainer->getPriority());
2✔
537
        }
538
    }
539

540
    /**
541
     * Classify the branches of a composition validator into input-space and output-space
542
     * index lists using the given CompositionBranchClassifier.
543
     *
544
     * Uses the original (pre-type-inheritance) branch schemas from the property's raw JSON
545
     * rather than the post-inheritance schemas stored on the CompositionPropertyDecorator.
546
     * This prevents inherited type annotations (injected to drive validator registration)
547
     * from shifting a pure output-space branch into Mixed classification.
548
     *
549
     * Empty/ambiguous branches (TypeSpace::Empty) are treated as input-space per the
550
     * liberal policy, consistent with CompositionBranchClassifier.
551
     *
552
     * @return array{int[], int[]}  [inputIndices, outputIndices]
553
     */
554
    private function classifyComposedValidatorBranches(
22✔
555
        AbstractComposedPropertyValidator $validator,
556
        CompositionBranchClassifier $classifier,
557
        array $originalPropertyJson,
558
    ): array {
559
        $inputIndices  = [];
22✔
560
        $outputIndices = [];
22✔
561

562
        $originalBranchSchemas = $this->resolveOriginalBranchSchemas($validator, $originalPropertyJson);
22✔
563

564
        foreach ($validator->getComposedProperties() as $index => $compositionProperty) {
22✔
565
            $branchSchema = ($originalBranchSchemas !== null && isset($originalBranchSchemas[$index]))
22✔
566
                ? $originalBranchSchemas[$index]
22✔
567
                : $compositionProperty->getBranchSchema()->getJson();
6✔
568

569
            $space = $classifier->classify($branchSchema);
22✔
570

571
            if ($space === TypeSpace::Output) {
22✔
572
                $outputIndices[] = $index;
7✔
573
            } else {
574
                // Input, Mixed (statically rejected — shouldn't occur), and Empty → Input
575
                $inputIndices[] = $index;
18✔
576
            }
577
        }
578

579
        return [$inputIndices, $outputIndices];
22✔
580
    }
581

582
    /**
583
     * Look up the original (pre-type-inheritance) branch schemas for a composition validator
584
     * from the property's raw JSON.
585
     *
586
     * Type inheritance injects the parent property's type into untyped branches so that
587
     * type-specific Draft validators (e.g. minimum for integer branches) are registered.
588
     * That injected type must not influence space classification, so we classify the
589
     * schemas as they appeared before inheritance.
590
     *
591
     * Returns null when the original schemas cannot be determined (falls back to
592
     * getBranchSchema() in classifyComposedValidatorBranches).
593
     *
594
     * @return list<array<string, mixed>>|null
595
     */
596
    private function resolveOriginalBranchSchemas(
22✔
597
        AbstractComposedPropertyValidator $validator,
598
        array $originalPropertyJson,
599
    ): ?array {
600
        $processorClass = $validator->getCompositionProcessor();
22✔
601

602
        $keywordMap = [
22✔
603
            AllOfValidatorFactory::class => 'allOf',
22✔
604
            AnyOfValidatorFactory::class => 'anyOf',
22✔
605
            OneOfValidatorFactory::class => 'oneOf',
22✔
606
        ];
22✔
607

608
        if (isset($keywordMap[$processorClass])) {
22✔
609
            $keyword = $keywordMap[$processorClass];
14✔
610
            if (!isset($originalPropertyJson[$keyword]) || !is_array($originalPropertyJson[$keyword])) {
14✔
NEW
611
                return null;
×
612
            }
613
            return array_values($originalPropertyJson[$keyword]);
14✔
614
        }
615

616
        if ($processorClass === NotValidatorFactory::class) {
8✔
617
            if (!isset($originalPropertyJson['not']) || !is_array($originalPropertyJson['not'])) {
2✔
NEW
UNCOV
618
                return null;
×
619
            }
620
            // NotValidatorFactory wraps the single 'not' schema in an array; index 0 maps to it.
621
            return [$originalPropertyJson['not']];
2✔
622
        }
623

624
        if ($processorClass === IfValidatorFactory::class) {
6✔
625
            // ConditionalPropertyValidator::getComposedProperties() returns the then and else
626
            // composition properties only — the if condition branch is stored separately in
627
            // getConditionBranches() and is not iterated in classifyComposedValidatorBranches().
628
            // Reproduce the then/else order from the original JSON so index 0 maps to then
629
            // and index 1 maps to else, matching the order of getComposedProperties().
630
            $result = [];
6✔
631
            foreach (['then', 'else'] as $keyword) {
6✔
632
                if (isset($originalPropertyJson[$keyword]) && is_array($originalPropertyJson[$keyword])) {
6✔
633
                    $result[] = $originalPropertyJson[$keyword];
6✔
634
                }
635
            }
636
            return !empty($result) ? $result : null;
6✔
637
        }
638

NEW
UNCOV
639
        return null;
×
640
    }
641

642
    /**
643
     * Compute the output type using the bypass formula and apply it to the property.
644
     *
645
     * Formula:
646
     *   accepted      = filter callable's first-parameter types ([] = accepts all)
647
     *   bypass_names  = base_names − non-null accepted  ([] when accepted is empty)
648
     *   bypass_nullable = base_nullable AND 'null' NOT in accepted  (false when accepted is empty)
649
     *   output_names  = bypass_names ∪ return_type_names
650
     *   output_nullable = bypass_nullable OR return_nullable
651
     *
652
     * @param string[] $returnTypeNames Non-null return type names of the transforming filter.
653
     *
654
     * @throws ReflectionException
655
     * @throws SchemaException
656
     */
657
    public function applyOutputType(
110✔
658
        PropertyInterface $property,
659
        TransformingFilterInterface $filter,
660
        array $returnTypeNames,
661
        PropertyType $baseType,
662
        GeneratorConfiguration $generatorConfiguration,
663
        Schema $schema,
664
    ): void {
665
        $returnNullable = FilterReflection::isReturnNullable($filter);
110✔
666
        $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property);
110✔
667

668
        if (empty($acceptedTypes)) {
110✔
669
            $bypassNames = [];
23✔
670
            $bypassNullable = false;
23✔
671
        } else {
672
            $nonNullAccepted = array_values(
87✔
673
                array_filter($acceptedTypes, static fn(string $type): bool => $type !== 'null'),
87✔
674
            );
87✔
675
            $hasNullAccepted = in_array('null', $acceptedTypes, true);
87✔
676
            $bypassNames = array_values(array_diff($baseType->getNames(), $nonNullAccepted));
87✔
677
            $bypassNullable = ($baseType->isNullable() === true) && !$hasNullAccepted;
87✔
678
        }
679

680
        $baseNames = $baseType->getNames();
110✔
681
        $newReturnTypeNames = array_values(array_diff($returnTypeNames, $baseNames));
110✔
682

683
        if (empty($newReturnTypeNames)) {
110✔
684
            return;
9✔
685
        }
686

687
        $outputNames = array_values(array_unique(array_merge($bypassNames, $returnTypeNames)));
101✔
688
        $outputNullable = $bypassNullable || $returnNullable;
101✔
689

690
        $renderHelper = new RenderHelper($generatorConfiguration);
101✔
691
        $outputTypeNames = array_map(
101✔
692
            static fn(string $name): string => $renderHelper->getSimpleClassName($name),
101✔
693
            $outputNames,
101✔
694
        );
101✔
695

696
        $property->setType(
101✔
697
            $property->getType(),
101✔
698
            new PropertyType($outputTypeNames, $outputNullable),
101✔
699
        );
101✔
700

701
        foreach ($returnTypeNames as $typeName) {
101✔
702
            if (!TypeCheck::isPrimitive($typeName)) {
101✔
703
                $schema->addUsedClass($typeName);
89✔
704
            }
705
        }
706
    }
707

708
    /**
709
     * Replace the property's TypeCheckValidator / MultiTypeCheckValidator with a
710
     * PassThroughTypeCheckValidator that also allows the given pass-through type names.
711
     *
712
     * When called a second time, the TypeCheckValidator has already been replaced by a
713
     * PassThroughTypeCheckValidator, which does not match the filter predicate, so the call
714
     * is silently skipped.
715
     *
716
     * @param string[] $passThroughTypeNames
717
     */
718
    public function extendTypeCheckValidatorToAllowTransformedValue(
90✔
719
        PropertyInterface $property,
720
        array $passThroughTypeNames,
721
    ): void {
722
        $typeCheckValidator = null;
90✔
723

724
        $property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool {
90✔
725
            if (
726
                is_a($validator->getValidator(), TypeCheckValidator::class) ||
90✔
727
                is_a($validator->getValidator(), MultiTypeCheckValidator::class)
90✔
728
            ) {
729
                $typeCheckValidator = $validator->getValidator();
78✔
730
                return false;
78✔
731
            }
732

733
            return true;
90✔
734
        });
90✔
735

736
        if (
737
            $typeCheckValidator instanceof TypeCheckValidator
90✔
738
            || $typeCheckValidator instanceof MultiTypeCheckValidator
90✔
739
        ) {
740
            $property->addValidator(
78✔
741
                new PassThroughTypeCheckValidator($passThroughTypeNames, $property, $typeCheckValidator),
78✔
742
                2,
78✔
743
            );
78✔
744
        }
745
    }
746

747
    /**
748
     * Build and return the Draft instance for the given property's schema.
749
     *
750
     * Resolves DraftFactoryInterface vs DraftInterface from the GeneratorConfiguration
751
     * and builds the immutable Draft registry. Callers should cache the result rather
752
     * than calling this method more than once per process() invocation.
753
     */
754
    private function resolveBuiltDraft(
129✔
755
        GeneratorConfiguration $generatorConfiguration,
756
        PropertyInterface $property,
757
    ): Draft {
758
        $configDraft = $generatorConfiguration->getDraft();
129✔
759

760
        $draftInterface = $configDraft instanceof DraftFactoryInterface
129✔
761
            ? $configDraft->getDraftForSchema($property->getJsonSchema())
129✔
NEW
UNCOV
762
            : $configDraft;
×
763

764
        return $draftInterface->getDefinition()->build();
129✔
765
    }
766

767
    /**
768
     * Apply a pass-through check to each FilterValidator and EnumValidator already associated
769
     * with the given property so that pre-transform filters and enum checks are skipped when
770
     * an already-transformed value is provided.
771
     *
772
     * @param string[] $returnTypeNames Non-null return type names of the transforming filter.
773
     */
774
    public function addTransformedValuePassThrough(
117✔
775
        PropertyInterface $property,
776
        TransformingFilterInterface $filter,
777
        array $returnTypeNames,
778
    ): void {
779
        foreach ($property->getValidators() as $propertyValidator) {
117✔
780
            $validator = $propertyValidator->getValidator();
117✔
781

782
            if ($validator instanceof FilterValidator) {
117✔
783
                $validator->addTransformedCheck($filter, $property);
117✔
784
            }
785

786
            if ($validator instanceof EnumValidator) {
117✔
787
                $property->filterValidators(
25✔
788
                    static fn(Validator $candidate): bool => !$candidate->getValidator() instanceof EnumValidator,
25✔
789
                );
25✔
790

791
                $exceptionParams = $validator->getExceptionParams();
25✔
792
                array_shift($exceptionParams);
25✔
793

794
                $property->addValidator(
25✔
795
                    new PropertyValidator(
25✔
796
                        $property,
25✔
797
                        sprintf('%s && %s', TypeCheck::buildNegatedCompound($returnTypeNames), $validator->getCheck()),
25✔
798
                        $validator->getExceptionClass(),
25✔
799
                        $exceptionParams,
25✔
800
                    ),
25✔
801
                    3,
25✔
802
                );
25✔
803
            }
804
        }
805
    }
806
}
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