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

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

27 May 2026 01:23PM UTC coverage: 98.865% (+0.2%) from 98.695%
26514074703

Pull #128

github

wol-soft
Add missing SchemaException import to AbstractCompositionValidatorFactory

The rebase dropped the use-import that the detectIfThenElseConflict and
detectAllOfTypeConflict methods need; add it back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #128: Filter composition ordering

754 of 755 new or added lines in 22 files covered. (99.87%)

1 existing line in 1 file now uncovered.

5837 of 5904 relevant lines covered (98.87%)

591.82 hits per line

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

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

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

60
        return $filterList;
40✔
61
    }
62

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

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

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

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

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

116
            $isTransformingFilter = $filter instanceof TransformingFilterInterface;
231✔
117

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

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

147
            if ($isTransformingFilter) {
215✔
148
                $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property);
149✔
149

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

162
                $this->reassignValidatorPriorities(
133✔
163
                    $property,
133✔
164
                    $actualFilterPriority,
133✔
165
                    $classifier,
133✔
166
                    $returnTypeNames,
133✔
167
                    $generatorConfiguration,
133✔
168
                );
133✔
169

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

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

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

209
                $transformingFilter = $filter;
133✔
210
            }
211
        }
212
    }
213

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

242
            // Unwrap a FilterPreTransformGuardValidator to reach the underlying composed validator.
243
            if ($validator instanceof FilterPreTransformGuardValidator) {
107✔
244
                $validator = $validator->getInnerValidator();
21✔
245
            }
246

247
            if (!is_a($validator, AbstractComposedPropertyValidator::class)) {
107✔
248
                continue;
107✔
249
            }
250

251
            /** @var AbstractComposedPropertyValidator $composedValidator */
252
            $composedValidator = $validator;
21✔
253

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

260
                // The ObjectModifier removes InstanceOfValidator synchronously during
261
                // nested-schema wiring, before this method runs, so empty-property nested
262
                // schemas always have their instanceof check removed at this point.
263
                $hasEmptyObjectBranch = true;
2✔
264
                break 2;
2✔
265
            }
266
        }
267

268
        if (!$hasEmptyObjectBranch) {
107✔
269
            return;
105✔
270
        }
271

272
        $instanceOfParts = implode(' || ', array_map(
2✔
273
            static fn(string $cls): string => "\$value instanceof $cls",
2✔
274
            $objectReturnTypes,
2✔
275
        ));
2✔
276

277
        $property->addValidator(
2✔
278
            new PropertyValidator(
2✔
279
                $property,
2✔
280
                "is_object(\$value) && !($instanceOfParts)",
2✔
281
                InvalidInstanceOfException::class,
2✔
282
                [reset($objectReturnTypes)],
2✔
283
            ),
2✔
284
            $filterPriority + 1,
2✔
285
        );
2✔
286
    }
287

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

320
        [$inputSpaceComposed, $mixedAllOf] = $this->classifyValidatorAdjustments(
133✔
321
            $property,
133✔
322
            $filterPriority,
133✔
323
            $classifier,
133✔
324
            $returnTypeNames,
133✔
325
        );
133✔
326

327
        $this->wrapInputSpaceGuards(
133✔
328
            $property,
133✔
329
            $inputSpaceComposed,
133✔
330
            $filterPriority,
133✔
331
            $skipCheck,
133✔
332
            $generatorConfiguration,
133✔
333
        );
133✔
334

335
        $this->splitMixedSpaceAllOf(
133✔
336
            $property,
133✔
337
            $mixedAllOf,
133✔
338
            $filterPriority,
133✔
339
            $skipCheck,
133✔
340
            $generatorConfiguration,
133✔
341
        );
133✔
342
    }
343

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

373
        /** @var list<array{container: Validator, validator: ComposedPropertyValidator, inputIndices: int[], outputIndices: int[]}> */
374
        $mixedAllOf = [];
133✔
375

376
        foreach ($property->getValidators() as $validatorContainer) {
133✔
377
            if ($validatorContainer->getPriority() < $filterPriority) {
133✔
378
                continue; // Already scheduled before the filter; no adjustment needed.
112✔
379
            }
380

381
            if (is_a($validatorContainer->getValidator(), FilterValidator::class)) {
133✔
382
                continue; // Skip the filter validators themselves.
133✔
383
            }
384

385
            if (is_a($validatorContainer->getValidator(), AbstractComposedPropertyValidator::class)) {
32✔
386
                /** @var AbstractComposedPropertyValidator $composedValidator */
387
                $composedValidator = $validatorContainer->getValidator();
30✔
388

389
                [$inputIndices, $outputIndices] = $this->classifyComposedValidatorBranches(
30✔
390
                    $composedValidator,
30✔
391
                    $classifier,
30✔
392
                    $property->getJsonSchema()->getJson(),
30✔
393
                );
30✔
394

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

414
                if (empty($outputIndices)) {
28✔
415
                    // All branches are input-space or ambiguous (Empty → Input by liberal policy).
416
                    // Defer guard wrapping when return types are known; otherwise move directly.
417
                    if (!empty($returnTypeNames)) {
22✔
418
                        $inputSpaceComposed[] = [
21✔
419
                            'container' => $validatorContainer,
21✔
420
                            'validator' => $composedValidator,
21✔
421
                        ];
21✔
422
                    } else {
423
                        $validatorContainer->setPriority($filterPriority - 1);
1✔
424
                    }
425
                }
426
                // Output-space composition validators: leave at their current post-filter position.
427

428
                continue;
28✔
429
            }
430

431
            $sourceKey = $validatorContainer->getSourceKey();
2✔
432
            $typeSpace = $classifier->classifySchemaKey($sourceKey);
2✔
433

434
            if ($typeSpace === TypeSpace::Output) {
2✔
435
                continue; // Output-space validators belong after the filter.
2✔
436
            }
437

438
            // Input-space, mixed, and ambiguous validators must run before the filter
439
            // so they validate the raw input value.
440
            $validatorContainer->setPriority($filterPriority - 1);
2✔
441
        }
442

443
        return [$inputSpaceComposed, $mixedAllOf];
133✔
444
    }
445

446
    /**
447
     * Replace each uniform input-space composition validator with a FilterPreTransformGuardValidator
448
     * that short-circuits when the value is already in the filter's output type-space.
449
     *
450
     * @param list<array{container: Validator, validator: AbstractComposedPropertyValidator}> $inputSpaceComposed
451
     */
452
    private function wrapInputSpaceGuards(
133✔
453
        PropertyInterface $property,
454
        array $inputSpaceComposed,
455
        int $filterPriority,
456
        string $skipCheck,
457
        GeneratorConfiguration $generatorConfiguration,
458
    ): void {
459
        foreach ($inputSpaceComposed as ['container' => $originalContainer, 'validator' => $composedValidator]) {
133✔
460
            $property->filterValidators(
21✔
461
                static fn(Validator $container): bool => $container !== $originalContainer,
21✔
462
            );
21✔
463
            $property->addValidator(
21✔
464
                new FilterPreTransformGuardValidator(
21✔
465
                    $generatorConfiguration,
21✔
466
                    $property,
21✔
467
                    $composedValidator,
21✔
468
                    $skipCheck,
21✔
469
                ),
21✔
470
                $filterPriority - 1,
21✔
471
            );
21✔
472
        }
473
    }
474

475
    /**
476
     * Replace each mixed-space allOf validator with a pre-filter input-subset (wrapped in a
477
     * pass-through guard) and a post-filter output-subset at the original priority.
478
     *
479
     * @param list<array{
480
     *     container: Validator, validator: ComposedPropertyValidator,
481
     *     inputIndices: int[], outputIndices: int[]
482
     * }> $mixedAllOf
483
     */
484
    private function splitMixedSpaceAllOf(
133✔
485
        PropertyInterface $property,
486
        array $mixedAllOf,
487
        int $filterPriority,
488
        string $skipCheck,
489
        GeneratorConfiguration $generatorConfiguration,
490
    ): void {
491
        foreach (
492
            $mixedAllOf as [
133✔
493
            'container' => $originalContainer,
133✔
494
            'validator'     => $originalValidator,
133✔
495
            'inputIndices'  => $inputIndices,
133✔
496
            'outputIndices' => $outputIndices,
133✔
497
            ]
133✔
498
        ) {
499
            $property->filterValidators(
2✔
500
                static fn(Validator $container): bool => $container !== $originalContainer,
2✔
501
            );
2✔
502

503
            // Input-space subset runs before the filter, wrapped in a pass-through guard so that
504
            // already-transformed values bypass the pre-transform check.
505
            $preTransformValidator = $originalValidator->createSubsetValidator($inputIndices, '_pre_filter');
2✔
506
            $property->addValidator(
2✔
507
                new FilterPreTransformGuardValidator(
2✔
508
                    $generatorConfiguration,
2✔
509
                    $property,
2✔
510
                    $preTransformValidator,
2✔
511
                    $skipCheck,
2✔
512
                ),
2✔
513
                $filterPriority - 1,
2✔
514
            );
2✔
515

516
            // Output-space subset runs at the original validator's priority so its position
517
            // relative to other post-transform validators is preserved.
518
            $postTransformValidator = $originalValidator->createSubsetValidator($outputIndices, '_post_filter');
2✔
519
            $property->addValidator($postTransformValidator, $originalContainer->getPriority());
2✔
520
        }
521
    }
522

523
    /**
524
     * Classify the branches of a composition validator into input-space and output-space
525
     * index lists using the given CompositionBranchClassifier.
526
     *
527
     * Uses the original (pre-type-inheritance) branch schemas from the property's raw JSON
528
     * rather than the post-inheritance schemas stored on the CompositionPropertyDecorator.
529
     * This prevents inherited type annotations (injected to drive validator registration)
530
     * from shifting a pure output-space branch into Mixed classification.
531
     *
532
     * Empty/ambiguous branches (TypeSpace::Empty) are treated as input-space per the
533
     * liberal policy, consistent with CompositionBranchClassifier.
534
     *
535
     * @return array{int[], int[]}  [inputIndices, outputIndices]
536
     */
537
    private function classifyComposedValidatorBranches(
30✔
538
        AbstractComposedPropertyValidator $validator,
539
        CompositionBranchClassifier $classifier,
540
        array $originalPropertyJson,
541
    ): array {
542
        $inputIndices  = [];
30✔
543
        $outputIndices = [];
30✔
544

545
        $originalBranchSchemas = $this->resolveOriginalBranchSchemas($validator, $originalPropertyJson);
30✔
546

547
        foreach ($validator->getComposedProperties() as $index => $compositionProperty) {
30✔
548
            $branchSchema = ($originalBranchSchemas !== null && isset($originalBranchSchemas[$index]))
30✔
549
                ? $originalBranchSchemas[$index]
30✔
550
                : $compositionProperty->getBranchSchema()->getJson();
7✔
551

552
            $space = $classifier->classify($branchSchema);
30✔
553

554
            if ($space === TypeSpace::Output) {
30✔
555
                $outputIndices[] = $index;
8✔
556
            } else {
557
                // Input, Mixed (statically rejected — shouldn't occur), and Empty → Input
558
                $inputIndices[] = $index;
25✔
559
            }
560
        }
561

562
        return [$inputIndices, $outputIndices];
30✔
563
    }
564

565
    /**
566
     * Look up the original (pre-type-inheritance) branch schemas for a composition validator
567
     * from the property's raw JSON.
568
     *
569
     * Type inheritance injects the parent property's type into untyped branches so that
570
     * type-specific Draft validators (e.g. minimum for integer branches) are registered.
571
     * That injected type must not influence space classification, so we classify the
572
     * schemas as they appeared before inheritance.
573
     *
574
     * Returns null when the original schemas cannot be determined (falls back to
575
     * getBranchSchema() in classifyComposedValidatorBranches).
576
     *
577
     * @return list<array<string, mixed>>|null
578
     */
579
    private function resolveOriginalBranchSchemas(
30✔
580
        AbstractComposedPropertyValidator $validator,
581
        array $originalPropertyJson,
582
    ): ?array {
583
        $processorClass = $validator->getCompositionProcessor();
30✔
584

585
        $keywordMap = [
30✔
586
            AllOfValidatorFactory::class => 'allOf',
30✔
587
            AnyOfValidatorFactory::class => 'anyOf',
30✔
588
            OneOfValidatorFactory::class => 'oneOf',
30✔
589
        ];
30✔
590

591
        if (isset($keywordMap[$processorClass])) {
30✔
592
            $keyword = $keywordMap[$processorClass];
22✔
593
            return array_values($originalPropertyJson[$keyword]);
22✔
594
        }
595

596
        if ($processorClass === NotValidatorFactory::class) {
9✔
597
            // NotValidatorFactory wraps the single 'not' schema in an array; index 0 maps to it.
598
            return [$originalPropertyJson['not']];
2✔
599
        }
600

601
        // ConditionalPropertyValidator::getComposedProperties() returns the then and else
602
        // composition properties only — the if condition branch is stored separately in
603
        // getConditionBranches() and is not iterated in classifyComposedValidatorBranches().
604
        // Reproduce the then/else order from the original JSON so index 0 maps to then
605
        // and index 1 maps to else, matching the order of getComposedProperties().
606
        $result = [];
7✔
607
        foreach (['then', 'else'] as $keyword) {
7✔
608
            if (isset($originalPropertyJson[$keyword]) && is_array($originalPropertyJson[$keyword])) {
7✔
609
                $result[] = $originalPropertyJson[$keyword];
7✔
610
            }
611
        }
612
        return !empty($result) ? $result : null;
7✔
613
    }
614

615
    /**
616
     * Compute the output type using the bypass formula and apply it to the property.
617
     *
618
     * Formula:
619
     *   accepted      = filter callable's first-parameter types ([] = accepts all)
620
     *   bypass_names  = base_names − non-null accepted  ([] when accepted is empty)
621
     *   bypass_nullable = base_nullable AND 'null' NOT in accepted  (false when accepted is empty)
622
     *   output_names  = bypass_names ∪ return_type_names
623
     *   output_nullable = bypass_nullable OR return_nullable
624
     *
625
     * @param string[] $returnTypeNames Non-null return type names of the transforming filter.
626
     *
627
     * @throws ReflectionException
628
     * @throws SchemaException
629
     */
630
    public function applyOutputType(
116✔
631
        PropertyInterface $property,
632
        TransformingFilterInterface $filter,
633
        array $returnTypeNames,
634
        PropertyType $baseType,
635
        GeneratorConfiguration $generatorConfiguration,
636
        Schema $schema,
637
    ): void {
638
        $returnNullable = FilterReflection::isReturnNullable($filter);
116✔
639
        $acceptedTypes = FilterReflection::getAcceptedTypes($filter, $property);
116✔
640

641
        if (empty($acceptedTypes)) {
116✔
642
            $bypassNames = [];
24✔
643
            $bypassNullable = false;
24✔
644
        } else {
645
            $nonNullAccepted = array_values(
92✔
646
                array_filter($acceptedTypes, static fn(string $type): bool => $type !== 'null'),
92✔
647
            );
92✔
648
            $hasNullAccepted = in_array('null', $acceptedTypes, true);
92✔
649
            $bypassNames = array_values(array_diff($baseType->getNames(), $nonNullAccepted));
92✔
650
            $bypassNullable = ($baseType->isNullable() === true) && !$hasNullAccepted;
92✔
651
        }
652

653
        $baseNames = $baseType->getNames();
116✔
654
        $newReturnTypeNames = array_values(array_diff($returnTypeNames, $baseNames));
116✔
655

656
        if (empty($newReturnTypeNames)) {
116✔
657
            return;
9✔
658
        }
659

660
        $outputNames = array_values(array_unique(array_merge($bypassNames, $returnTypeNames)));
107✔
661
        $outputNullable = $bypassNullable || $returnNullable;
107✔
662

663
        $renderHelper = new RenderHelper($generatorConfiguration);
107✔
664
        $outputTypeNames = array_map(
107✔
665
            static fn(string $name): string => $renderHelper->getSimpleClassName($name),
107✔
666
            $outputNames,
107✔
667
        );
107✔
668

669
        $property->setType(
107✔
670
            $property->getType(),
107✔
671
            new PropertyType($outputTypeNames, $outputNullable),
107✔
672
        );
107✔
673

674
        foreach ($returnTypeNames as $typeName) {
107✔
675
            if (!TypeCheck::isPrimitive($typeName)) {
107✔
676
                $schema->addUsedClass($typeName);
93✔
677
            }
678
        }
679
    }
680

681
    /**
682
     * Replace the property's TypeCheckValidator / MultiTypeCheckValidator with a
683
     * PassThroughTypeCheckValidator that also allows the given pass-through type names.
684
     *
685
     * When called a second time, the TypeCheckValidator has already been replaced by a
686
     * PassThroughTypeCheckValidator, which does not match the filter predicate, so the call
687
     * is silently skipped.
688
     *
689
     * @param string[] $passThroughTypeNames
690
     */
691
    public function extendTypeCheckValidatorToAllowTransformedValue(
104✔
692
        PropertyInterface $property,
693
        array $passThroughTypeNames,
694
    ): void {
695
        $typeCheckValidator = null;
104✔
696

697
        $property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool {
104✔
698
            if (
699
                is_a($validator->getValidator(), TypeCheckValidator::class) ||
104✔
700
                is_a($validator->getValidator(), MultiTypeCheckValidator::class)
104✔
701
            ) {
702
                $typeCheckValidator = $validator->getValidator();
81✔
703
                return false;
81✔
704
            }
705

706
            return true;
104✔
707
        });
104✔
708

709
        if (
710
            $typeCheckValidator instanceof TypeCheckValidator
104✔
711
            || $typeCheckValidator instanceof MultiTypeCheckValidator
104✔
712
        ) {
713
            $property->addValidator(
81✔
714
                new PassThroughTypeCheckValidator($passThroughTypeNames, $property, $typeCheckValidator),
81✔
715
                2,
81✔
716
            );
81✔
717
        }
718
    }
719

720
    /**
721
     * Build and return the Draft instance for the given property's schema.
722
     *
723
     * Resolves DraftFactoryInterface vs DraftInterface from the GeneratorConfiguration
724
     * and builds the immutable Draft registry. Callers should cache the result rather
725
     * than calling this method more than once per process() invocation.
726
     */
727
    private function resolveBuiltDraft(
146✔
728
        GeneratorConfiguration $generatorConfiguration,
729
        PropertyInterface $property,
730
    ): Draft {
731
        $configDraft = $generatorConfiguration->getDraft();
146✔
732

733
        $draftInterface = $configDraft instanceof DraftFactoryInterface
146✔
734
            ? $configDraft->getDraftForSchema($property->getJsonSchema())
146✔
NEW
735
            : $configDraft;
×
736

737
        return $draftInterface->getDefinition()->build();
146✔
738
    }
739

740
    /**
741
     * Apply a pass-through check to each FilterValidator and EnumValidator already associated
742
     * with the given property so that pre-transform filters and enum checks are skipped when
743
     * an already-transformed value is provided.
744
     *
745
     * @param string[] $returnTypeNames Non-null return type names of the transforming filter.
746
     */
747
    public function addTransformedValuePassThrough(
131✔
748
        PropertyInterface $property,
749
        TransformingFilterInterface $filter,
750
        array $returnTypeNames,
751
    ): void {
752
        foreach ($property->getValidators() as $propertyValidator) {
131✔
753
            $validator = $propertyValidator->getValidator();
131✔
754

755
            if ($validator instanceof FilterValidator) {
131✔
756
                $validator->addTransformedCheck($filter, $property);
131✔
757
            }
758

759
            if ($validator instanceof FormatValidator) {
131✔
760
                $this->replaceValidatorWithGuardedCheck(
2✔
761
                    $property,
2✔
762
                    $validator,
2✔
763
                    FormatValidator::class,
2✔
764
                    sprintf('is_string($value) && %s', $validator->getCheck()),
2✔
765
                );
2✔
766
            }
767

768
            if ($validator instanceof EnumValidator) {
131✔
769
                $this->replaceValidatorWithGuardedCheck(
25✔
770
                    $property,
25✔
771
                    $validator,
25✔
772
                    EnumValidator::class,
25✔
773
                    sprintf('%s && %s', TypeCheck::buildNegatedCompound($returnTypeNames), $validator->getCheck()),
25✔
774
                );
25✔
775
            }
776
        }
777
    }
778

779
    /**
780
     * Remove all validators of the given class from the property and re-add the same validation
781
     * logic wrapped in a new check expression, at priority 3.
782
     *
783
     * The property name is stripped from exceptionParams before re-adding because
784
     * AbstractPropertyValidator::getExceptionParams() prepends it again automatically.
785
     */
786
    private function replaceValidatorWithGuardedCheck(
27✔
787
        PropertyInterface $property,
788
        AbstractPropertyValidator $validator,
789
        string $validatorClass,
790
        string $guardedCheck,
791
    ): void {
792
        $property->filterValidators(
27✔
793
            static fn(Validator $candidate): bool => !is_a($candidate->getValidator(), $validatorClass),
27✔
794
        );
27✔
795

796
        $exceptionParams = $validator->getExceptionParams();
27✔
797
        array_shift($exceptionParams);
27✔
798

799
        $property->addValidator(
27✔
800
            new PropertyValidator(
27✔
801
                $property,
27✔
802
                $guardedCheck,
27✔
803
                $validator->getExceptionClass(),
27✔
804
                $exceptionParams,
27✔
805
            ),
27✔
806
            3,
27✔
807
        );
27✔
808
    }
809
}
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