From 67b9399da88a1626ca24d357e82ac74b1ae4d02c Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Mon, 20 Apr 2026 09:51:36 +0200 Subject: [PATCH 01/11] Phase 1: CompositionBranchClassifier and Draft::getTypesForKeyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the branch classifier that will drive the static rejection (Phase 2) and priority reassignment (Phase 3) of composition validators around transforming filters. Changes: - Draft::getTypesForKeyword(string $keyword): string[] — maps a JSON Schema keyword to the Draft type names whose modifier/factory list carries that keyword, enabling type-space lookup without hard-coded keyword tables. - AbstractValidatorFactory::getKey(): ?string — exposes the schema key that was already stored via setKey(), providing the backlink needed in Phase 3 to trace a live validator back to its source keyword. - TypeSpace enum (Input / Output / Mixed / Empty) — classification result type. - CompositionBranchClassifier — classifies a single composition branch relative to a transforming filter's input/output type-spaces. Uses the Draft registry for extensibility; resolves class-name output types to the 'object' draft space; liberal policy defaults ambiguous branches to Input. - Tests: data-provider-driven Draft::getTypesForKeyword coverage in DraftTest; full unit test suite for CompositionBranchClassifier covering all TypeSpace outcomes, every keyword category, nested compositions, and custom Draft extensibility. - CLAUDE.md: note that .claude/ files must never be added to git. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 3 + src/Draft/Draft.php | 19 + .../Factory/AbstractValidatorFactory.php | 5 + .../Filter/CompositionBranchClassifier.php | 202 +++++++ src/PropertyProcessor/Filter/TypeSpace.php | 25 + tests/Draft/DraftTest.php | 69 +++ .../CompositionBranchClassifierTest.php | 495 ++++++++++++++++++ 7 files changed, 818 insertions(+) create mode 100644 src/PropertyProcessor/Filter/CompositionBranchClassifier.php create mode 100644 src/PropertyProcessor/Filter/TypeSpace.php create mode 100644 tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 75a7465b..affb44f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,9 @@ a wrapper class here. After finishing an implementation task, always stage all relevant changed files for commit using `git add`. Do not wait for the user to ask — stage immediately when the work is done. +Never add `.claude/` files (issues, topics, memory, etc.) to git unless the user explicitly asks. +These are working notes for the session and must not appear in commits. + ### Reading files Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed. diff --git a/src/Draft/Draft.php b/src/Draft/Draft.php index 7cde1d58..4a5749ef 100644 --- a/src/Draft/Draft.php +++ b/src/Draft/Draft.php @@ -29,6 +29,25 @@ public function hasType(string $type): bool return isset($this->types[$type]); } + /** + * Returns the JSON Schema type names (e.g. 'string', 'integer', 'object') whose registered + * modifiers or validator-factories carry the given schema keyword. + * + * @return string[] + */ + public function getTypesForKeyword(string $keyword): array + { + $typeNames = []; + + foreach ($this->types as $typeName => $type) { + if (array_key_exists($keyword, $type->getModifiers())) { + $typeNames[] = $typeName; + } + } + + return $typeNames; + } + /** * Returns the Type entries whose modifiers apply to a property of the given type(s). * The special type 'any' always applies to every property; passing 'any' returns all types. diff --git a/src/Model/Validator/Factory/AbstractValidatorFactory.php b/src/Model/Validator/Factory/AbstractValidatorFactory.php index 9545a705..c3b39530 100644 --- a/src/Model/Validator/Factory/AbstractValidatorFactory.php +++ b/src/Model/Validator/Factory/AbstractValidatorFactory.php @@ -14,4 +14,9 @@ public function setKey(string $key): void { $this->key = $key; } + + public function getKey(): ?string + { + return isset($this->key) ? $this->key : null; + } } diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php new file mode 100644 index 00000000..223fa4be --- /dev/null +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -0,0 +1,202 @@ + $branchSchema Decoded JSON object for one branch. + */ + public function classify(array $branchSchema): TypeSpace + { + if (empty($branchSchema)) { + return TypeSpace::Empty; + } + + $hasInput = false; + $hasOutput = false; + + foreach ($branchSchema as $keyword => $value) { + $contribution = $this->classifyKeyword($keyword, $value); + + if ($contribution === TypeSpace::Mixed) { + return TypeSpace::Mixed; + } + + match ($contribution) { + TypeSpace::Input => $hasInput = true, + TypeSpace::Output => $hasOutput = true, + default => null, // Empty — no constraint, skip + }; + } + + return match (true) { + $hasInput && $hasOutput => TypeSpace::Mixed, + $hasInput => TypeSpace::Input, + $hasOutput => TypeSpace::Output, + default => TypeSpace::Input, // all ambiguous: liberal policy + }; + } + + /** + * Determine the type-space contribution of a single keyword within a branch. + * + * Returns TypeSpace::Empty when the keyword carries no spatial information + * (registered only on 'any', not registered at all, or an unrecognised key). + */ + private function classifyKeyword(string $keyword, mixed $value): TypeSpace + { + if ($keyword === 'type') { + return $this->resolveTypeSpace(array_map( + static fn(string $jsonType): string => TypeConverter::jsonSchemaToPHP($jsonType), + (array) $value, + )); + } + + if (in_array($keyword, self::NESTED_COMPOSITION_KEYWORDS, true)) { + return $this->classifyNestedComposition($keyword, $value); + } + + // Derive the spatial contribution from the Draft type registry. + $draftTypeNames = $this->draft->getTypesForKeyword($keyword); + + if (empty($draftTypeNames)) { + // Keyword not registered in any type (e.g. $schema, title, description). + return TypeSpace::Empty; + } + + // Convert JSON Schema type names to PHP type names and discard the 'any' + // pseudo-type — it is not spatially specific (e.g. enum, const, filter). + $phpTypeNames = array_values(array_filter( + array_map( + static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType), + $draftTypeNames, + ), + static fn(string $type): bool => $type !== 'any', + )); + + return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames); + } + + /** + * Classify nested composition keywords (allOf, anyOf, oneOf, not) by recursing + * into their inner branch schemas. + */ + private function classifyNestedComposition(string $keyword, mixed $value): TypeSpace + { + if ($keyword === 'not') { + return !is_array($value) ? TypeSpace::Empty : $this->classify($value); + } + + // allOf, anyOf, oneOf: value must be an array of branch schemas. + if (!is_array($value)) { + return TypeSpace::Empty; + } + + $nonEmpty = array_values(array_filter( + array_map( + fn(mixed $branch): TypeSpace => is_array($branch) + ? $this->classify($branch) + : TypeSpace::Empty, + $value, + ), + static fn(TypeSpace $space): bool => $space !== TypeSpace::Empty, + )); + + if (empty($nonEmpty)) { + return TypeSpace::Empty; + } + + $hasMixed = in_array(TypeSpace::Mixed, $nonEmpty, true); + $hasInput = in_array(TypeSpace::Input, $nonEmpty, true); + $hasOutput = in_array(TypeSpace::Output, $nonEmpty, true); + + return match (true) { + $hasMixed || ($hasInput && $hasOutput) => TypeSpace::Mixed, + $hasInput => TypeSpace::Input, + default => TypeSpace::Output, + }; + } + + /** + * Map a list of PHP type names onto the Input / Output / Mixed / Empty TypeSpace + * based on whether they overlap with the filter's declared input and output types. + * + * @param string[] $phpTypeNames + */ + private function resolveTypeSpace(array $phpTypeNames): TypeSpace + { + $effectiveOutputTypes = $this->getEffectiveOutputTypes(); + $inInput = !empty(array_intersect($phpTypeNames, $this->inputTypes)); + $inOutput = !empty(array_intersect($phpTypeNames, $effectiveOutputTypes)); + + return match (true) { + $inInput && $inOutput => TypeSpace::Mixed, + $inInput => TypeSpace::Input, + $inOutput => TypeSpace::Output, + default => TypeSpace::Empty, + }; + } + + /** + * Expand the declared output types to include 'object' when any output type is a + * non-primitive class name. This allows object-type Draft keywords (e.g. minProperties, + * properties) to be classified as output-targeted when the filter returns a class instance. + * + * @return string[] + */ + private function getEffectiveOutputTypes(): array + { + return array_values(array_unique(array_merge( + $this->outputTypes, + array_map( + static fn(string $type): string => !TypeCheck::isPrimitive($type) && $type !== 'null' + ? 'object' + : $type, + $this->outputTypes, + ), + ))); + } +} diff --git a/src/PropertyProcessor/Filter/TypeSpace.php b/src/PropertyProcessor/Filter/TypeSpace.php new file mode 100644 index 00000000..78b88828 --- /dev/null +++ b/src/PropertyProcessor/Filter/TypeSpace.php @@ -0,0 +1,25 @@ +getDefinition()->build()->getCoveredTypes('nonexistent'); } + // --- Draft::getTypesForKeyword --- + + /** @return array */ + public static function keywordToExpectedTypesProvider(): array + { + return [ + // one representative per type-space so the registry wiring is exercised + 'string keyword (minLength)' => ['minLength', ['string']], + 'numeric keyword (minimum)' => ['minimum', ['integer', 'number']], + 'array keyword (minItems)' => ['minItems', ['array']], + 'object keyword (minProperties)' => ['minProperties', ['object']], + 'any keyword (enum)' => ['enum', ['any']], + 'any keyword (allOf)' => ['allOf', ['any']], + 'unknown keyword' => ['nonexistentKeyword', []], + 'metadata keyword ($schema)' => ['$schema', []], + ]; + } + + /** + * @param string[] $expectedTypes + */ + #[DataProvider('keywordToExpectedTypesProvider')] + public function testGetTypesForKeywordReturnsRegisteredTypes( + string $keyword, + array $expectedTypes, + ): void { + $types = (new Draft_07())->getDefinition()->build()->getTypesForKeyword($keyword); + + foreach ($expectedTypes as $expected) { + $this->assertContains($expected, $types); + } + + $this->assertCount(count($expectedTypes), $types); + } + + public function testGetTypesForKeywordReflectsCustomRegistration(): void + { + $customFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return is_int($value) && $value >= 0; + } + + protected function getValidator( + PropertyInterface $property, + mixed $value, + ): PropertyValidatorInterface { + return new PropertyValidator( + $property, + "is_string(\$value) && mb_strlen(\$value) < $value", + MinLengthException::class, + [$value], + ); + } + }; + + $builder = (new Draft_07())->getDefinition(); + $builder->getType('string')->addValidator('customMin', $customFactory); + $draft = $builder->build(); + + $this->assertSame(['string'], $draft->getTypesForKeyword('customMin')); + } + // --- AutoDetectionDraft --- public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void diff --git a/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php new file mode 100644 index 00000000..ebbdc005 --- /dev/null +++ b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php @@ -0,0 +1,495 @@ +getDefinition()->build(), + ['string'], + ['DateTime'], + ); + } + + /** + * Classifier for an integer → string filter (hypothetical reverse scenario). + * inputTypes = ['int'] (Draft type: integer) + * outputTypes = ['string'] (Draft type: string) + */ + private function classifierForIntegerToString(): CompositionBranchClassifier + { + return new CompositionBranchClassifier( + (new Draft_07())->getDefinition()->build(), + ['int'], + ['string'], + ); + } + + // ------------------------------------------------------------------------- + // Empty branch + // ------------------------------------------------------------------------- + + public function testEmptyBranchClassifiesAsEmpty(): void + { + $this->assertSame(TypeSpace::Empty, $this->classifierForStringToDateTime()->classify([])); + } + + // ------------------------------------------------------------------------- + // `type` keyword + // ------------------------------------------------------------------------- + + public function testTypeBranchStringClassifiesAsInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['type' => 'string']), + ); + } + + public function testTypeBranchObjectClassifiesAsOutput(): void + { + // DateTime is an object — `type: object` targets the output space. + $this->assertSame( + TypeSpace::Output, + $this->classifierForStringToDateTime()->classify(['type' => 'object']), + ); + } + + public function testTypeBranchMultipleSpanningBothSpacesClassifiesAsMixed(): void + { + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify(['type' => ['string', 'object']]), + ); + } + + public function testTypeBranchOutsideBothSpacesDefaultsToInput(): void + { + // integer is neither string (input) nor object/DateTime (output) for this filter; + // the type keyword contributes nothing → all keywords ambiguous → liberal: Input. + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['type' => 'integer']), + ); + } + + public function testTypeBranchIntegerClassifiesAsInputForIntToStringFilter(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForIntegerToString()->classify(['type' => 'integer']), + ); + } + + public function testTypeBranchStringClassifiesAsOutputForIntToStringFilter(): void + { + $this->assertSame( + TypeSpace::Output, + $this->classifierForIntegerToString()->classify(['type' => 'string']), + ); + } + + // ------------------------------------------------------------------------- + // Ambiguous keywords (liberal → Input) + // ------------------------------------------------------------------------- + + public function testBranchWithOnlyEnumDefaultsToInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['enum' => ['foo', 'bar']]), + ); + } + + public function testBranchWithOnlyConstDefaultsToInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['const' => 'foo']), + ); + } + + public function testBranchWithUnregisteredKeywordsDefaultsToInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([ + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'title' => 'My branch', + ]), + ); + } + + // ------------------------------------------------------------------------- + // Type-gated keywords — one provider per keyword category + // ------------------------------------------------------------------------- + + /** @return array */ + public static function stringKeywordProvider(): array + { + return [ + 'minLength' => ['minLength', 5], + 'maxLength' => ['maxLength', 20], + 'pattern' => ['pattern', '^[a-z]+$'], + 'format' => ['format', 'date'], + ]; + } + + #[DataProvider('stringKeywordProvider')] + public function testStringKeywordClassifiesAsInputForStringInputFilter( + string $keyword, + mixed $value, + ): void { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([$keyword => $value]), + ); + } + + /** @return array */ + public static function numericKeywordProvider(): array + { + return [ + 'minimum' => ['minimum', 0], + 'maximum' => ['maximum', 100], + 'exclusiveMinimum' => ['exclusiveMinimum', 0], + 'exclusiveMaximum' => ['exclusiveMaximum', 100], + 'multipleOf' => ['multipleOf', 5], + ]; + } + + #[DataProvider('numericKeywordProvider')] + public function testNumericKeywordDefaultsToInputForStringToDateTimeFilter( + string $keyword, + mixed $value, + ): void { + // integer/number are not in either space for this filter → ambiguous → liberal: Input. + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([$keyword => $value]), + ); + } + + #[DataProvider('numericKeywordProvider')] + public function testNumericKeywordClassifiesAsInputForIntegerInputFilter( + string $keyword, + mixed $value, + ): void { + $this->assertSame( + TypeSpace::Input, + $this->classifierForIntegerToString()->classify([$keyword => $value]), + ); + } + + /** @return array */ + public static function objectKeywordProvider(): array + { + return [ + 'properties' => ['properties', ['foo' => ['type' => 'string']]], + 'minProperties' => ['minProperties', 1], + 'maxProperties' => ['maxProperties', 10], + 'propertyNames' => ['propertyNames', ['type' => 'string']], + 'additionalProperties' => ['additionalProperties', false], + ]; + } + + #[DataProvider('objectKeywordProvider')] + public function testObjectKeywordClassifiesAsOutputForStringToDateTimeFilter( + string $keyword, + mixed $value, + ): void { + // DateTime is an object; object-space keywords target the output type-space. + $this->assertSame( + TypeSpace::Output, + $this->classifierForStringToDateTime()->classify([$keyword => $value]), + ); + } + + /** @return array */ + public static function arrayKeywordProvider(): array + { + return [ + 'items' => ['items', ['type' => 'string']], + 'minItems' => ['minItems', 1], + 'maxItems' => ['maxItems', 10], + 'uniqueItems' => ['uniqueItems', true], + 'contains' => ['contains', ['type' => 'string']], + ]; + } + + #[DataProvider('arrayKeywordProvider')] + public function testArrayKeywordDefaultsToInputForStringToDateTimeFilter( + string $keyword, + mixed $value, + ): void { + // array type is not in either space for this filter → ambiguous → liberal: Input. + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([$keyword => $value]), + ); + } + + // ------------------------------------------------------------------------- + // Mixed branches (input + output keywords together) + // ------------------------------------------------------------------------- + + public function testBranchWithInputAndOutputKeywordsClassifiesAsMixed(): void + { + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'minLength' => 5, + 'minProperties' => 1, + ]), + ); + } + + public function testBranchWithInputTypeAndOutputKeywordClassifiesAsMixed(): void + { + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'type' => 'string', + 'minProperties' => 1, + ]), + ); + } + + // ------------------------------------------------------------------------- + // Nested allOf / anyOf / oneOf / not inside a branch + // ------------------------------------------------------------------------- + + public function testNestedAllOfWithAllEmptyBranchesDefaultsToInput(): void + { + // All inner branches are empty — no spatial constraint → liberal: Input. + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['allOf' => [[], []]]), + ); + } + + public function testNestedAllOfWithInputBranchClassifiesAsInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([ + 'allOf' => [ + ['type' => 'string'], + ['minLength' => 1], + ], + ]), + ); + } + + public function testNestedAllOfWithOutputBranchClassifiesAsOutput(): void + { + $this->assertSame( + TypeSpace::Output, + $this->classifierForStringToDateTime()->classify([ + 'allOf' => [ + ['type' => 'object'], + ['minProperties' => 1], + ], + ]), + ); + } + + public function testNestedAllOfWithCrossSpaceBranchesClassifiesAsMixed(): void + { + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'allOf' => [ + ['type' => 'string'], + ['type' => 'object'], + ], + ]), + ); + } + + public function testNestedNotWithInputSchemaClassifiesAsInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['not' => ['type' => 'string']]), + ); + } + + public function testNestedNotWithOutputSchemaClassifiesAsOutput(): void + { + $this->assertSame( + TypeSpace::Output, + $this->classifierForStringToDateTime()->classify(['not' => ['minProperties' => 1]]), + ); + } + + public function testNestedOneOfWithAllInputBranchesClassifiesAsInput(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify([ + 'oneOf' => [ + ['minLength' => 1], + ['minLength' => 5], + ], + ]), + ); + } + + public function testNestedAnyOfWithAllOutputBranchesClassifiesAsOutput(): void + { + $this->assertSame( + TypeSpace::Output, + $this->classifierForStringToDateTime()->classify([ + 'anyOf' => [ + ['type' => 'object'], + ['minProperties' => 1], + ], + ]), + ); + } + + public function testNestedCompositionCombinedWithOtherKeywordsContributes(): void + { + // allOf is output-targeted; minLength is input-targeted → Mixed. + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'allOf' => [['type' => 'object']], + 'minLength' => 1, + ]), + ); + } + + // ------------------------------------------------------------------------- + // Custom modifier on a custom Draft type — extensibility test + // ------------------------------------------------------------------------- + + public function testCustomKeywordOnCustomTypeClassifiesCorrectlyViaRealDraftRegistry(): void + { + $customFactory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return true; + } + + protected function getValidator( + PropertyInterface $property, + mixed $value, + ): PropertyValidatorInterface { + return new PropertyValidator($property, 'true', MinLengthException::class, [1]); + } + }; + + $customDraft = new class ($customFactory) implements DraftInterface { + public function __construct(private readonly SimplePropertyValidatorFactory $factory) + {} + + public function getDefinition(): DraftBuilder + { + $builder = (new Draft_07())->getDefinition(); + $builder->addType( + (new Type('special', false))->addValidator('customDateCheck', $this->factory), + ); + + return $builder; + } + }; + + $draft = $customDraft->getDefinition()->build(); + + // 'special' is not in inputTypes=['string'] nor in outputTypes=['DateTime'] → + // keyword contributes nothing → liberal: Input. + $classifier = new CompositionBranchClassifier($draft, ['string'], ['DateTime']); + $this->assertSame(TypeSpace::Input, $classifier->classify(['customDateCheck' => 'x'])); + + // When output type IS 'special', the keyword maps to Output. + $classifierSpecialOutput = new CompositionBranchClassifier($draft, ['string'], ['special']); + $this->assertSame(TypeSpace::Output, $classifierSpecialOutput->classify(['customDateCheck' => 'x'])); + + // When input type IS 'special', the keyword maps to Input. + $classifierSpecialInput = new CompositionBranchClassifier($draft, ['special'], ['DateTime']); + $this->assertSame(TypeSpace::Input, $classifierSpecialInput->classify(['customDateCheck' => 'x'])); + } + + // ------------------------------------------------------------------------- + // AbstractValidatorFactory::getKey() + // ------------------------------------------------------------------------- + + public function testAbstractValidatorFactoryGetKeyReturnsNullWhenKeyNotSet(): void + { + $factory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return true; + } + + protected function getValidator( + PropertyInterface $property, + mixed $value, + ): PropertyValidatorInterface { + return new PropertyValidator($property, 'true', MinLengthException::class, [1]); + } + }; + + $this->assertNull($factory->getKey()); + } + + public function testAbstractValidatorFactoryGetKeyReturnsKeyAfterSetKey(): void + { + $factory = new class extends SimplePropertyValidatorFactory { + protected function isValueValid(mixed $value): bool + { + return true; + } + + protected function getValidator( + PropertyInterface $property, + mixed $value, + ): PropertyValidatorInterface { + return new PropertyValidator($property, 'true', MinLengthException::class, [1]); + } + }; + + $factory->setKey('myKeyword'); + $this->assertSame('myKeyword', $factory->getKey()); + } +} From 60d71540bc4c8ee85db0be2883d83022c078d14a Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 22 Apr 2026 23:35:32 +0200 Subject: [PATCH 02/11] Phase 2: composition/filter compatibility checking and R-7 enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce CompositionCompatibilityChecker with two public responsibilities: checkTransformingFilterCompositionConflicts: validates that composition keywords directly on a filtered property do not create unresolvable type-space conflicts with a transforming filter (allOf Mixed branch, anyOf/oneOf cross-space branches, not Mixed inner schema, if/then/else cross-space sub-schemas). checkTransformingFilterRootCompositionConflicts: enforces R-4 — throws when a root-level composition branch constrains the filtered subproperty with output-type-space constraints. Both are wired into FilterProcessor and exercised by the new rejectedCompositionProvider / acceptedCompositionProvider test pairs. Add checkForFilterInBranches to AbstractCompositionValidatorFactory to enforce R-7 (filter keyword inside a composition branch) universally across all five factories, independently of whether an outer transforming filter is present. The check runs after inheritPropertyType so that branches inheriting type:object from their parent are correctly exempt from the filter-in-properties scan, avoiding a false-positive SchemaException for those schemas. Code-quality fixes from review: - Exception messages use "for property %s in file %s" inline style; redundant branch index dropped from the not variant - Class constants in CompositionCompatibilityChecker and CompositionBranchClassifier carry explicit array type annotations - CompositionCompatibilityChecker class docblock condensed to one line with detail moved to the individual method docblocks - checkPropertyComposition renamed to checkTransformingFilterCompositionConflicts and checkRootComposition renamed to checkTransformingFilterRootCompositionConflicts for clarity Co-Authored-By: Claude Sonnet 4.5 --- .../AbstractCompositionValidatorFactory.php | 58 +++ .../Composition/AllOfValidatorFactory.php | 1 + .../Composition/AnyOfValidatorFactory.php | 1 + .../Composition/IfValidatorFactory.php | 29 +- .../Composition/NotValidatorFactory.php | 4 + .../Composition/OneOfValidatorFactory.php | 1 + .../Filter/CompositionBranchClassifier.php | 2 +- .../CompositionCompatibilityChecker.php | 331 ++++++++++++++++++ .../Filter/FilterProcessor.php | 30 ++ tests/Basic/FilterTest.php | 130 +++++++ ...ertyWithMixedAcceptTransformingFilter.json | 13 + .../FilterCompositionAllOfInputOnly.json | 16 + .../FilterCompositionAllOfMixedBranch.json | 14 + .../FilterCompositionAllOfOutputOnly.json | 16 + .../FilterCompositionAnyOfCrossSpace.json | 16 + .../FilterCompositionAnyOfInputOnly.json | 16 + .../FilterCompositionFilterInBranch.json | 13 + ...ompositionFilterInBranchNoOuterFilter.json | 13 + ...FilterCompositionIfThenElseCrossSpace.json | 14 + .../FilterCompositionIfThenElseInputOnly.json | 17 + .../FilterTest/FilterCompositionNotMixed.json | 12 + .../FilterCompositionOneOfCrossSpace.json | 16 + .../FilterCompositionOneOfInputOnly.json | 16 + ...ositionRootBranchWithFilterInProperty.json | 18 + ...tionRootConstrainsFilteredSubproperty.json | 17 + 25 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php create mode 100644 tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json create mode 100644 tests/Schema/FilterTest/FilterCompositionNotMixed.json create mode 100644 tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index 98535951..be15d5dd 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -17,6 +17,7 @@ use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; +use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; @@ -54,6 +55,63 @@ protected function shouldSkip(PropertyInterface $property, JsonSchema $propertyS && ($propertySchema->getJson()['type'] ?? '') === 'object'; } + /** + * Check the (post-type-inheritance) composition branches for filter keywords. + * + * Must be called AFTER inheritPropertyType() in each modify() method. A branch that + * inherits "object" from the parent is genuinely object-typed: PropertyFactory routes + * it through processSchema, producing a nested class whose properties are processed + * independently and are not subject to ComposedItem $value reset. branchContainsFilter() + * correctly skips the properties scan for such branches. + * + * For "not", the value is a single branch schema (not an array); all other keywords + * use an array of branches. + * + * TODO: R-7 — filters inside composition branches cannot be correctly applied + * (ComposedItem.phptpl resets $value to $originalModelData after each branch). + * Proper per-branch filter chaining is deferred to a follow-up topic. + * + * @throws SchemaException + */ + protected function checkForFilterInBranches( + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $json = $propertySchema->getJson(); + + if ($this->key === 'not') { + $branch = $json['not'] ?? null; + if ( + is_array($branch) + && CompositionCompatibilityChecker::branchContainsFilter($branch) + ) { + throw new SchemaException(sprintf( + 'A filter keyword inside a not composition branch is not supported' + . ' for property %s in file %s.', + $property->getName(), + $property->getJsonSchema()->getFile(), + )); + } + return; + } + + foreach ($json[$this->key] ?? [] as $index => $compositionElement) { + if ( + is_array($compositionElement) + && CompositionCompatibilityChecker::branchContainsFilter($compositionElement) + ) { + throw new SchemaException(sprintf( + 'A filter keyword inside a %s composition branch is not supported' + . ' for property %s in file %s (branch #%d).', + $this->key, + $property->getName(), + $property->getJsonSchema()->getFile(), + $index, + )); + } + } + } + /** * Build composition sub-properties for the current keyword's branches. * diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php index 9b76ec2e..f83c6ba1 100644 --- a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -33,6 +33,7 @@ public function modify( $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); + $this->checkForFilterInBranches($property, $propertySchema); $wrappedSchema = $propertySchema->withJson([ 'type' => $this->key, diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php index 33c25085..6978ae96 100644 --- a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -33,6 +33,7 @@ public function modify( $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); + $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) && (!$property->isRequired() diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index 64d50c4c..b48747f0 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -14,6 +14,7 @@ use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\ConditionalPropertyValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; +use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; @@ -35,9 +36,7 @@ public function modify( return; } - $json = $propertySchema->getJson(); - - if (!isset($json['then']) && !isset($json['else'])) { + if (!isset($propertySchema->getJson()['then']) && !isset($propertySchema->getJson()['else'])) { throw new SchemaException( sprintf( 'Incomplete conditional composition for property %s in file %s', @@ -47,9 +46,33 @@ public function modify( ); } + // Inherit the parent type into if/then/else sub-schemas before the filter check so + // that sub-schemas that inherit 'object' are correctly recognised as object-typed. + // Object-typed sub-schemas create nested schemas whose properties are processed + // independently and are not subject to ComposedItem $value reset (R-7). $propertySchema = $this->inheritPropertyType($propertySchema); $json = $propertySchema->getJson(); + // Check for filter keywords in if/then/else sub-schemas after type inheritance. + // TODO: R-7 — filters inside if/then/else sub-schemas cannot be correctly applied + // (ComposedItem.phptpl resets $value to $originalModelData after each branch). + // Proper per-branch filter chaining is deferred to a follow-up topic. + foreach (['if', 'then', 'else'] as $keyword) { + if ( + isset($json[$keyword]) + && is_array($json[$keyword]) + && CompositionCompatibilityChecker::branchContainsFilter($json[$keyword]) + ) { + throw new SchemaException(sprintf( + 'A filter keyword inside an if/then/else composition branch is not supported' + . ' for property %s in file %s (%s sub-schema).', + $property->getName(), + $property->getJsonSchema()->getFile(), + $keyword, + )); + } + } + $propertyFactory = new PropertyFactory(); $onlyForDefinedValues = !($property instanceof BaseProperty) diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php index 2f240535..58ee4cb5 100644 --- a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -32,6 +32,10 @@ public function modify( // inheritPropertyType for 'not' treats $json['not'] as a single schema object, // so it must run before we wrap it in an array for iteration. $propertySchema = $this->inheritPropertyType($propertySchema); + // Check for filter keywords after type inheritance so that branches that inherit + // 'object' from the parent are correctly treated as object-typed (their properties + // are processed as a nested schema and are not subject to ComposedItem $value reset). + $this->checkForFilterInBranches($property, $propertySchema); $json = $propertySchema->getJson(); // Wrap the single 'not' schema in an array so getCompositionProperties can iterate it. diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php index 8921981a..462b2c51 100644 --- a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -33,6 +33,7 @@ public function modify( $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); $propertySchema = $this->inheritPropertyType($propertySchema); + $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) && (!$property->isRequired() diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php index 223fa4be..3452d916 100644 --- a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -29,7 +29,7 @@ class CompositionBranchClassifier * JSON Schema composition keywords that may contain nested branch schemas. * Each is classified recursively rather than via the Draft type registry. */ - private const NESTED_COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'not']; + private const array NESTED_COMPOSITION_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'not']; /** * @param Draft $draft The active Draft instance used to resolve keyword type-spaces. diff --git a/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php new file mode 100644 index 00000000..2973f10f --- /dev/null +++ b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php @@ -0,0 +1,331 @@ + $propertySchema + * + * @throws SchemaException + */ + public function checkTransformingFilterCompositionConflicts(array $propertySchema): void + { + $this->checkAllOf($propertySchema['allOf'] ?? null); + $this->checkAnyOfOrOneOf('anyOf', $propertySchema['anyOf'] ?? null); + $this->checkAnyOfOrOneOf('oneOf', $propertySchema['oneOf'] ?? null); + $this->checkNot($propertySchema['not'] ?? null); + $this->checkIfThenElse($propertySchema); + } + + /** + * Validate that root-level composition branches on the parent object schema do not + * constrain the filtered subproperty with output-type-space constraints (R-4). + * + * Throws when any root-level composition branch references the filtered subproperty + * by name via a "properties" constraint whose content targets the output type-space. + * Ambiguous and input-space constraints are permitted: they operate against the + * original (pre-transform) value, which is the correct behaviour for root-level + * composition today. + * + * @param array $parentSchema + * + * @throws SchemaException + */ + public function checkTransformingFilterRootCompositionConflicts(array $parentSchema): void + { + $propertyName = $this->property->getName(); + + foreach (self::ARRAY_COMPOSITION_KEYWORDS as $keyword) { + if (!isset($parentSchema[$keyword]) || !is_array($parentSchema[$keyword])) { + continue; + } + + foreach ($parentSchema[$keyword] as $index => $branch) { + if (!is_array($branch) || !isset($branch['properties'][$propertyName])) { + continue; + } + + $innerConstraint = $branch['properties'][$propertyName]; + + if (!is_array($innerConstraint)) { + continue; + } + + $space = $this->classifier->classify($innerConstraint); + + if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) { + // TODO: R-4 — proper handling is deferred to a follow-up topic. + // Root-level composition branches cannot yet be split around the + // filter's transform boundary. See implementation-plan.md. + throw new SchemaException(sprintf( + 'Composition %s in file %s constrains filtered subproperty %s' + . ' (branch #%d) with output-type-space constraints;' + . ' this combination is not yet supported.', + $keyword, + $this->property->getJsonSchema()->getFile(), + $propertyName, + $index, + )); + } + } + } + + foreach (self::SINGLE_COMPOSITION_KEYWORDS as $keyword) { + if ( + !isset($parentSchema[$keyword]) + || !is_array($parentSchema[$keyword]) + || !isset($parentSchema[$keyword]['properties'][$propertyName]) + ) { + continue; + } + + $innerConstraint = $parentSchema[$keyword]['properties'][$propertyName]; + + if (!is_array($innerConstraint)) { + continue; + } + + $space = $this->classifier->classify($innerConstraint); + + if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) { + // TODO: R-4 — see above. + throw new SchemaException(sprintf( + 'Composition %s in file %s constrains filtered subproperty %s' + . ' with output-type-space constraints; this combination is not yet supported.', + $keyword, + $this->property->getJsonSchema()->getFile(), + $propertyName, + )); + } + } + } + + /** + * @param mixed $branches + * + * @throws SchemaException + */ + private function checkAllOf(mixed $branches): void + { + if (!is_array($branches)) { + return; + } + + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + continue; + } + + if ($this->classifier->classify($branch) === TypeSpace::Mixed) { + throw new SchemaException(sprintf( + 'Composition allOf under property %s in file %s cannot be resolved:' + . ' branch #%d spans both input and output type-spaces;' + . ' allOf branches must not contain constraints from both type-spaces' + . ' when combined with a transforming filter.', + $this->property->getName(), + $this->property->getJsonSchema()->getFile(), + $index, + )); + } + } + } + + /** + * @param mixed $branches + * + * @throws SchemaException + */ + private function checkAnyOfOrOneOf(string $keyword, mixed $branches): void + { + if (!is_array($branches)) { + return; + } + + $firstInputIndex = null; + $firstOutputIndex = null; + + foreach ($branches as $index => $branch) { + if (!is_array($branch)) { + continue; + } + + $space = $this->classifier->classify($branch); + + if ($space === TypeSpace::Mixed) { + throw new SchemaException(sprintf( + 'Composition %s under property %s in file %s cannot be resolved:' + . ' branch #%d spans both input and output type-spaces;' + . ' branches must not contain constraints from both type-spaces' + . ' when combined with a transforming filter.', + $keyword, + $this->property->getName(), + $this->property->getJsonSchema()->getFile(), + $index, + )); + } + + if ($space === TypeSpace::Input && $firstInputIndex === null) { + $firstInputIndex = $index; + } + + if ($space === TypeSpace::Output && $firstOutputIndex === null) { + $firstOutputIndex = $index; + } + } + + if ($firstInputIndex !== null && $firstOutputIndex !== null) { + throw new SchemaException(sprintf( + 'Composition %s under property %s in file %s cannot be resolved:' + . ' branch #%d constrains input type-space but branch #%d constrains output type-space;' + . ' %s branches must share a single type-space when combined with a transforming filter.', + $keyword, + $this->property->getName(), + $this->property->getJsonSchema()->getFile(), + $firstInputIndex, + $firstOutputIndex, + $keyword, + )); + } + } + + /** + * @throws SchemaException + */ + private function checkNot(mixed $innerSchema): void + { + if (!is_array($innerSchema)) { + return; + } + + if ($this->classifier->classify($innerSchema) === TypeSpace::Mixed) { + throw new SchemaException(sprintf( + 'Composition not under property %s in file %s cannot be resolved:' + . ' the inner schema spans both input and output type-spaces.', + $this->property->getName(), + $this->property->getJsonSchema()->getFile(), + )); + } + } + + /** + * @param array $propertySchema + * + * @throws SchemaException + */ + private function checkIfThenElse(array $propertySchema): void + { + if (!isset($propertySchema['if'])) { + return; + } + + /** @var array $subSchemaSpaces */ + $subSchemaSpaces = []; + + foreach (['if', 'then', 'else'] as $subKeyword) { + if (isset($propertySchema[$subKeyword]) && is_array($propertySchema[$subKeyword])) { + $subSchemaSpaces[$subKeyword] = $this->classifier->classify($propertySchema[$subKeyword]); + } + } + + $hasInput = in_array(TypeSpace::Input, $subSchemaSpaces, true); + $hasOutput = in_array(TypeSpace::Output, $subSchemaSpaces, true); + $hasMixed = in_array(TypeSpace::Mixed, $subSchemaSpaces, true); + + if ($hasMixed || ($hasInput && $hasOutput)) { + throw new SchemaException(sprintf( + 'Composition if/then/else under property %s in file %s cannot be resolved:' + . ' sub-schemas span different type-spaces;' + . ' if/then/else sub-schemas must share a single type-space' + . ' when combined with a transforming filter.', + $this->property->getName(), + $this->property->getJsonSchema()->getFile(), + )); + } + } + + /** + * Recursively check whether a branch schema (or any of its nested composition branches + * or named properties) contains a "filter" keyword. + * + * The check covers: + * - A direct "filter" key in the branch itself. + * - "filter" nested inside nested allOf / anyOf / oneOf / not / if / then / else. + * - "filter" inside a named property value under "properties" when the branch does NOT + * declare "type": "object". Object-typed branches create nested schemas whose + * properties are processed independently (not subject to ComposedItem.phptpl's + * $value reset), so their inner filters are correctly applied. + * + * @param array $branchSchema + */ + public static function branchContainsFilter(array $branchSchema): bool + { + if (array_key_exists('filter', $branchSchema)) { + return true; + } + + if ( + ($branchSchema['type'] ?? null) !== 'object' + && isset($branchSchema['properties']) + && is_array($branchSchema['properties']) + ) { + foreach ($branchSchema['properties'] as $propertySchema) { + if (is_array($propertySchema) && static::branchContainsFilter($propertySchema)) { + return true; + } + } + } + + foreach (self::ARRAY_COMPOSITION_KEYWORDS as $keyword) { + if (!isset($branchSchema[$keyword]) || !is_array($branchSchema[$keyword])) { + continue; + } + + foreach ($branchSchema[$keyword] as $nestedBranch) { + if (is_array($nestedBranch) && static::branchContainsFilter($nestedBranch)) { + return true; + } + } + } + + foreach (self::SINGLE_COMPOSITION_KEYWORDS as $keyword) { + if ( + isset($branchSchema[$keyword]) + && is_array($branchSchema[$keyword]) + && static::branchContainsFilter($branchSchema[$keyword]) + ) { + return true; + } + } + + return false; + } +} diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index fb5e9dce..f31f6e8a 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -5,6 +5,9 @@ namespace PHPModelGenerator\PropertyProcessor\Filter; use Exception; +use PHPModelGenerator\Draft\Draft; +use PHPModelGenerator\Draft\DraftFactoryInterface; +use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Filter\TransformingFilterInterface; use PHPModelGenerator\Filter\ValidateOptionsInterface; @@ -47,6 +50,7 @@ public static function normalizeFilterList(mixed $filterList): array } /** + * @throws InvalidFilterException * @throws ReflectionException * @throws SchemaException */ @@ -129,6 +133,13 @@ public function process( if ($isTransformingFilter) { $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); + $inputTypeNames = FilterReflection::getAcceptedTypes($filter, $property); + $builtDraft = $this->resolveBuiltDraft($generatorConfiguration, $property); + $classifier = new CompositionBranchClassifier($builtDraft, $inputTypeNames, $returnTypeNames); + $checker = new CompositionCompatibilityChecker($classifier, $property); + $checker->checkTransformingFilterCompositionConflicts($property->getJsonSchema()->getJson()); + $checker->checkTransformingFilterRootCompositionConflicts($schema->getJsonSchema()->getJson()); + if (!empty($returnTypeNames)) { // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators // so they are skipped when an already-transformed value is provided. @@ -266,6 +277,25 @@ public function extendTypeCheckValidatorToAllowTransformedValue( } } + /** + * Build and return the Draft instance for the given property's schema. + * + * Resolves DraftFactoryInterface vs DraftInterface from the GeneratorConfiguration + * and builds the immutable Draft registry. + */ + private function resolveBuiltDraft( + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $property, + ): Draft { + $configDraft = $generatorConfiguration->getDraft(); + + $draftInterface = $configDraft instanceof DraftFactoryInterface + ? $configDraft->getDraftForSchema($property->getJsonSchema()) + : $configDraft; + + return $draftInterface->getDefinition()->build(); + } + /** * Apply a pass-through check to each FilterValidator and EnumValidator already associated * with the given property so that pre-transform filters and enum checks are skipped when diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 174d73f4..874beb24 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -1588,4 +1588,134 @@ public static function serializeIntReturn(int $value): string { return (string) $value; } + + // ------------------------------------------------------------------------- + // Phase 2 — Static rejection of unresolvable compositions + // ------------------------------------------------------------------------- + + /** @return array */ + public static function rejectedCompositionProvider(): array + { + return [ + 'allOf with Mixed branch' => [ + 'FilterCompositionAllOfMixedBranch.json', + '/Composition allOf under property filteredProperty.*branch #0 spans both input and output type-spaces/', + ], + 'anyOf with cross-space branches' => [ + 'FilterCompositionAnyOfCrossSpace.json', + '/Composition anyOf under property filteredProperty.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + ], + 'oneOf with cross-space branches' => [ + 'FilterCompositionOneOfCrossSpace.json', + '/Composition oneOf under property filteredProperty.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + ], + 'not with Mixed inner schema' => [ + 'FilterCompositionNotMixed.json', + '/Composition not under property filteredProperty.*inner schema spans both input and output type-spaces/', + ], + 'if\/then with cross-space sub-schemas' => [ + 'FilterCompositionIfThenElseCrossSpace.json', + '/Composition if\/then\/else under property filteredProperty.*sub-schemas span different type-spaces/', + ], + 'filter inside allOf branch (with outer filter)' => [ + 'FilterCompositionFilterInBranch.json', + '/A filter keyword inside a allOf composition branch is not supported for property filteredProperty.*branch #0/', + ], + 'filter inside allOf branch (no outer filter)' => [ + 'FilterCompositionFilterInBranchNoOuterFilter.json', + '/A filter keyword inside a allOf composition branch is not supported for property filteredProperty.*branch #0/', + ], + 'root-level allOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootConstrainsFilteredSubproperty.json', + '/Composition allOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + ]; + } + + #[DataProvider('rejectedCompositionProvider')] + public function testUnresolvableCompositionOnTransformingFilterPropertyThrowsSchemaException( + string $schemaFile, + string $expectedMessagePattern, + ): void { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches($expectedMessagePattern); + + $this->generateClassFromFile($schemaFile); + } + + /** @return array */ + public static function acceptedCompositionProvider(): array + { + return [ + 'allOf with input-only branches' => ['FilterCompositionAllOfInputOnly.json'], + 'allOf with output-only branches' => ['FilterCompositionAllOfOutputOnly.json'], + 'anyOf with input-only branches' => ['FilterCompositionAnyOfInputOnly.json'], + 'oneOf with input-only branches' => ['FilterCompositionOneOfInputOnly.json'], + 'if/then/else input-only branches' => ['FilterCompositionIfThenElseInputOnly.json'], + 'root-level allOf branch: filter in inherited-object branch property' => + ['FilterCompositionRootBranchWithFilterInProperty.json'], + ]; + } + + #[DataProvider('acceptedCompositionProvider')] + public function testCompatibleCompositionOnTransformingFilterPropertyGeneratesSuccessfully( + string $schemaFile, + ): void { + // Should not throw — generation must succeed for compatible compositions. + $this->generateClassFromFile($schemaFile); + $this->addToAssertionCount(1); + } + + /** + * FC-A1: A transforming filter whose callable accepts mixed (empty accepted types) applied + * to a property that gets its type from an allOf sibling branch. + * + * At filter-processing time the property has no type yet (type comes later via the allOf + * resolution), so FilterProcessor skips applyOutputType. After composition is resolved the + * TransformingFilterOutputTypePostProcessor sets the output type. + * + * Covers TransformingFilterOutputTypePostProcessor lines 110–111 + * ($bypassNames = []; $bypassNullable = false; when accepted types are empty) and lines + * 146–156 ($property->setType(...) when the post-processor must compute the output type). + */ + public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void + { + $className = $this->generateClassFromFile( + 'AllOfPropertyWithMixedAcceptTransformingFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedToDateTime'], + [self::class, 'filterMixedToDateTime'], + 'mixedAcceptDateTimeFilter', + ), + ), + ); + + // Generation succeeds. The post-processor set the output type to DateTime for this + // property (lines 146–156). Verify via reflection that the setter's type hint includes + // DateTime (confirming the output type was wired correctly). + $reflection = new ReflectionClass($className); + $setterParam = $reflection->getMethod('setFilteredProperty')->getParameters()[0]; + $this->assertStringContainsString('DateTime', (string) $setterParam->getType()); + } + + /** + * Transforming filter callable that accepts mixed and returns DateTime. + * Used for FC-A1. + */ + public static function filterMixedToDateTime(mixed $value): DateTime + { + return new DateTime((string) $value); + } + + /** + * Serializer for filterMixedToDateTime. + */ + public static function serializeMixedToDateTime(DateTime $value): string + { + return $value->format(DATE_ATOM); + } } diff --git a/tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json b/tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json new file mode 100644 index 00000000..9a3f2047 --- /dev/null +++ b/tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "mixedAcceptDateTimeFilter", + "allOf": [ + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json b/tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json new file mode 100644 index 00000000..406aa517 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "minLength": 1 + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json b/tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json new file mode 100644 index 00000000..7fe948b1 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "minLength": 5, + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json new file mode 100644 index 00000000..af9a306d --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "type": "object" + }, + { + "minProperties": 0 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json b/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json new file mode 100644 index 00000000..f2195f1b --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json b/tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json new file mode 100644 index 00000000..869a15ab --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInBranch.json new file mode 100644 index 00000000..c9c3d5c5 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json b/tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json new file mode 100644 index 00000000..8241bf3d --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "allOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json new file mode 100644 index 00000000..eaf431c9 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "if": { + "type": "string" + }, + "then": { + "type": "object" + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json new file mode 100644 index 00000000..02790dc5 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "if": { + "type": "string" + }, + "then": { + "minLength": 5 + }, + "else": { + "type": "integer" + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionNotMixed.json b/tests/Schema/FilterTest/FilterCompositionNotMixed.json new file mode 100644 index 00000000..e7892b20 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionNotMixed.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "not": { + "minLength": 5, + "minProperties": 1 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json b/tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json new file mode 100644 index 00000000..55b7ac89 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "oneOf": [ + { + "minLength": 1 + }, + { + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json b/tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json new file mode 100644 index 00000000..cb7a265f --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "oneOf": [ + { + "minLength": 1 + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json b/tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json new file mode 100644 index 00000000..dd7399e0 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "filter": "trim" + } + } + } + ] +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..a6a7454e --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } + ] +} From 4ae554e8994eae3dc8cd7f136b48c90e29ceb459 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 23 Apr 2026 17:47:06 +0200 Subject: [PATCH 03/11] Phase 3: reassign validator priorities around transforming filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema validators whose Draft-registered types are a subset of the transforming filter's output type-space (e.g. 'minimum' for a string→int filter) are left to run post-transform. All other schema validators with a known source keyword (e.g. 'pattern', 'minLength') are moved to priority P-1 so they execute against the raw input value before the filter runs. Infrastructure added: - Validator: sourceKey field + getSourceKey/setSourceKey/setPriority - PropertyInterface/Property/PropertyProxy: optional sourceKey parameter on addValidator(), used when transferring validators from multi-type sub-properties so the source key survives the wrapper recreation - PropertyFactory.applyModifiers(): after each AbstractValidatorFactory modifier runs, tags newly-added Validator wrappers with the factory's schema key via setSourceKey(). Tagging must cover all Draft-registered validators because the filter-keyword combination is not known at tagging time — classification happens later in FilterProcessor when it knows the transforming filter's type signature - CompositionBranchClassifier: classifySchemaKey(string) public method - FilterProcessor: reassignValidatorPriorities() called immediately after the transforming filter is registered; skips FilterValidator, AbstractComposedPropertyValidator (Phase 4), and validators with no source key; leaves Output-classified validators post-transform; moves all others to priority P-1 Tests: ValidatorPriorityWithTransformingFilter schema + two new test methods covering the pre/post-transform split and a regression guard for non-transforming filters. Co-Authored-By: Claude Sonnet 4.6 --- src/Model/Property/Property.php | 14 +- src/Model/Property/PropertyInterface.php | 13 +- src/Model/Property/PropertyProxy.php | 9 +- src/Model/Validator.php | 22 +++ .../Filter/CompositionBranchClassifier.php | 22 +++ .../Filter/FilterProcessor.php | 65 ++++++++- src/PropertyProcessor/PropertyFactory.php | 21 ++- tests/Basic/FilterTest.php | 130 ++++++++++++++++-- ...lidatorPriorityWithTransformingFilter.json | 14 ++ 9 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json diff --git a/src/Model/Property/Property.php b/src/Model/Property/Property.php index bd88777b..309b8cc3 100644 --- a/src/Model/Property/Property.php +++ b/src/Model/Property/Property.php @@ -167,8 +167,11 @@ public function getDescription(): string /** * @inheritdoc */ - public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface - { + public function addValidator( + PropertyValidatorInterface $validator, + int $priority = 99, + ?string $sourceKey = null, + ): PropertyInterface { if (!$validator->isResolved()) { $this->isResolved = false; @@ -181,7 +184,12 @@ public function addValidator(PropertyValidatorInterface $validator, int $priorit }); } - $this->validators[] = new Validator($validator, $priority); + $wrapper = new Validator($validator, $priority); + if ($sourceKey !== null) { + $wrapper->setSourceKey($sourceKey); + } + + $this->validators[] = $wrapper; return $this; } diff --git a/src/Model/Property/PropertyInterface.php b/src/Model/Property/PropertyInterface.php index e6bc37e7..200f28be 100644 --- a/src/Model/Property/PropertyInterface.php +++ b/src/Model/Property/PropertyInterface.php @@ -71,8 +71,17 @@ public function getDescription(): string; * Priority 10+: Filter validators * Priority 99: Default priority used for casual validators * Priority 100: Validators for compositions - */ - public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface; + * + * The optional $sourceKey records which schema keyword (e.g. 'pattern', 'minimum') + * caused this validator to be added. Normally set automatically by PropertyFactory + * after each Draft modifier runs; pass it explicitly only when transferring a validator + * from another property (e.g. multi-type sub-property transfer). + */ + public function addValidator( + PropertyValidatorInterface $validator, + int $priority = 99, + ?string $sourceKey = null, + ): PropertyInterface; /** * @return Validator[] diff --git a/src/Model/Property/PropertyProxy.php b/src/Model/Property/PropertyProxy.php index 1777c34c..c707f961 100644 --- a/src/Model/Property/PropertyProxy.php +++ b/src/Model/Property/PropertyProxy.php @@ -93,9 +93,12 @@ public function getDescription(): string /** * @inheritdoc */ - public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface - { - return $this->getProperty()->addValidator($validator, $priority); + public function addValidator( + PropertyValidatorInterface $validator, + int $priority = 99, + ?string $sourceKey = null, + ): PropertyInterface { + return $this->getProperty()->addValidator($validator, $priority, $sourceKey); } /** diff --git a/src/Model/Validator.php b/src/Model/Validator.php index ab3d87e5..b5affa6e 100644 --- a/src/Model/Validator.php +++ b/src/Model/Validator.php @@ -13,6 +13,8 @@ */ class Validator { + private ?string $sourceKey = null; + public function __construct(protected PropertyValidatorInterface $validator, protected int $priority) {} @@ -25,4 +27,24 @@ public function getPriority(): int { return $this->priority; } + + public function setPriority(int $priority): void + { + $this->priority = $priority; + } + + /** + * The schema keyword (e.g. 'pattern', 'minimum') that caused this validator to be added, + * as determined by the Draft modifier registry. Null for validators not produced by a + * Draft AbstractValidatorFactory (e.g. TypeCheckValidator, RequiredPropertyValidator). + */ + public function getSourceKey(): ?string + { + return $this->sourceKey; + } + + public function setSourceKey(?string $sourceKey): void + { + $this->sourceKey = $sourceKey; + } } diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php index 3452d916..21bb98a3 100644 --- a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -44,6 +44,28 @@ public function __construct( private readonly array $outputTypes, ) {} + /** + * Classify a single schema keyword by looking up which Draft-registered types carry it + * and mapping those types onto the filter's input / output type-spaces. + * + * Composition and structural keywords ('type', 'allOf', 'anyOf', 'oneOf', 'not') are not + * handled here — call classify() for full branch analysis. Returns TypeSpace::Empty for + * keywords registered only on the 'any' pseudo-type (e.g. 'enum', 'filter') or for + * keywords not registered at all (e.g. '$schema', 'description'). + */ + public function classifySchemaKey(string $key): TypeSpace + { + $phpTypeNames = array_values(array_filter( + array_map( + static fn(string $draftType): string => TypeConverter::jsonSchemaToPHP($draftType), + $this->draft->getTypesForKeyword($key), + ), + static fn(string $type): bool => $type !== 'any', + )); + + return empty($phpTypeNames) ? TypeSpace::Empty : $this->resolveTypeSpace($phpTypeNames); + } + /** * Classify a single composition branch schema. * diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index f31f6e8a..1b441284 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -16,6 +16,7 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; +use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; @@ -125,9 +126,10 @@ public function process( // $transformingFilter is still null here when the current filter IS the transforming // filter — FilterValidator correctly receives null (no previous transforming filter). + $actualFilterPriority = $filterPriority++; $property->addValidator( new FilterValidator($generatorConfiguration, $filter, $property, $filterOptions, $transformingFilter), - $filterPriority++, + $actualFilterPriority, ); if ($isTransformingFilter) { @@ -140,6 +142,8 @@ public function process( $checker->checkTransformingFilterCompositionConflicts($property->getJsonSchema()->getJson()); $checker->checkTransformingFilterRootCompositionConflicts($schema->getJsonSchema()->getJson()); + $this->reassignValidatorPriorities($property, $actualFilterPriority, $classifier); + if (!empty($returnTypeNames)) { // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators // so they are skipped when an already-transformed value is provided. @@ -172,6 +176,65 @@ public function process( } } + /** + * After identifying a transforming filter at priority P, scan all existing validators + * on the property and adjust their run order: + * + * - Validators with a source key whose Draft-registered types are a subset of the + * filter's output type-space → leave at their current priority (post-transform). + * - All other validators with a source key (input-space, mixed, ambiguous) that are + * currently scheduled to run after the filter → move to just before the filter (P-1), + * so they execute against the raw input value. + * - Validators without a source key (type-check, required, non-transforming filters) + * are untouched regardless of their priority. + * - Composition validators (AbstractComposedPropertyValidator) are left for Phase 4, + * which will split them into pre- and post-transform blocks. + * + * @param int $filterPriority The actual priority at which the transforming filter was added. + */ + private function reassignValidatorPriorities( + PropertyInterface $property, + int $filterPriority, + CompositionBranchClassifier $classifier, + ): void { + foreach ($property->getValidators() as $validatorContainer) { + // Validators already scheduled before the filter need no adjustment. + if ($validatorContainer->getPriority() < $filterPriority) { + continue; + } + + // Skip the filter validators themselves. + if (is_a($validatorContainer->getValidator(), FilterValidator::class)) { + continue; + } + + // Composition validators are handled in Phase 4. + if (is_a($validatorContainer->getValidator(), AbstractComposedPropertyValidator::class)) { + continue; + } + + $sourceKey = $validatorContainer->getSourceKey(); + if ($sourceKey === null) { + // No source key: validator was not produced by a Draft AbstractValidatorFactory + // (e.g. PassThroughTypeCheckValidator from addTransformedValuePassThrough). + // Leave at its current position. + continue; + } + + $typeSpace = $classifier->classifySchemaKey($sourceKey); + + // Pure output-space validators (e.g. 'minimum' for a string→int filter) are + // correct where they are: they must validate the transformed value. + if ($typeSpace === TypeSpace::Output) { + continue; + } + + // Input-space, mixed, and ambiguous validators must run before the filter + // so they validate the raw input value. + $validatorContainer->setPriority($filterPriority - 1); + } + } + /** * Compute the output type using the bypass formula and apply it to the property. * diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index e4c03b7b..bb5918ea 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -15,6 +15,7 @@ use PHPModelGenerator\Draft\Draft; use PHPModelGenerator\Draft\DraftFactoryInterface; use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; +use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; use PHPModelGenerator\Draft\Modifier\TypeCheckModifier; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Attributes\PhpAttribute; @@ -439,7 +440,11 @@ private function createMultiTypeProperty( continue; } - $property->addValidator($validator, $validatorContainer->getPriority()); + $property->addValidator( + $validator, + $validatorContainer->getPriority(), + $validatorContainer->getSourceKey(), + ); } if ($subProperty->getDecorators()) { @@ -598,7 +603,21 @@ private function applyModifiers( } foreach ($coveredType->getModifiers() as $modifier) { + $countBefore = count($property->getValidators()); $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + + // Tag every validator that was just added by this modifier with its schema + // keyword so FilterProcessor can later classify each validator as + // input-space or output-space relative to a transforming filter. + // This must cover all Draft-registered validators — not only those known + // to interact with filters today — because a custom Draft may register + // any validator factory under any type, and the classification must work + // without enumerating individual keywords. + if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) { + foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) { + $validatorWrapper->setSourceKey($modifierKey); + } + } } } } diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 874beb24..8987e9a9 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -10,7 +10,9 @@ use RuntimeException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\InvalidFilterException; +use PHPModelGenerator\Exception\Number\MinimumException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\String\PatternException; use PHPModelGenerator\Exception\ValidationException; use PHPModelGenerator\Filter\FilterInterface; use PHPModelGenerator\Filter\TransformingFilterInterface; @@ -260,7 +262,10 @@ public static function invalidEncodingFilterConfigurationsDataProvider(): array { return [ 'simple notation without options' => ['"encode"', 'Missing charset configuration'], - 'object notation without charset configuration' => ['{"filter": "encode"}', 'Missing charset configuration'], + 'object notation without charset configuration' => [ + '{"filter": "encode"}', + 'Missing charset configuration', + ], 'Invalid charset configuration' => ['{"filter": "encode", "charset": 1}', 'Unsupported charset'], 'Invalid charset configuration 2' => ['{"filter": "encode", "charset": "UTF-16"}', 'Unsupported charset'], ]; @@ -365,7 +370,9 @@ protected function getCustomTransformingFilter( array $customFilter = [], string $token = 'customTransformingFilter', ): TransformingFilterInterface { - return new class ($customSerializer, $customFilter, $token) extends TrimFilter implements TransformingFilterInterface + return new class ($customSerializer, $customFilter, $token) + extends TrimFilter + implements TransformingFilterInterface { public function __construct( private readonly array $customSerializer, @@ -1599,19 +1606,21 @@ public static function rejectedCompositionProvider(): array return [ 'allOf with Mixed branch' => [ 'FilterCompositionAllOfMixedBranch.json', - '/Composition allOf under property filteredProperty.*branch #0 spans both input and output type-spaces/', + '/Composition allOf under property filteredProperty.*branch #0 spans both input and output type-spaces/', // phpcs:ignore Generic.Files.LineLength.TooLong ], 'anyOf with cross-space branches' => [ 'FilterCompositionAnyOfCrossSpace.json', - '/Composition anyOf under property filteredProperty.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + '/Composition anyOf under property filteredProperty' + . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', ], 'oneOf with cross-space branches' => [ 'FilterCompositionOneOfCrossSpace.json', - '/Composition oneOf under property filteredProperty.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + '/Composition oneOf under property filteredProperty' + . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', ], 'not with Mixed inner schema' => [ 'FilterCompositionNotMixed.json', - '/Composition not under property filteredProperty.*inner schema spans both input and output type-spaces/', + '/Composition not under property filteredProperty.*inner schema spans both input and output type-spaces/', // phpcs:ignore Generic.Files.LineLength.TooLong ], 'if\/then with cross-space sub-schemas' => [ 'FilterCompositionIfThenElseCrossSpace.json', @@ -1619,11 +1628,13 @@ public static function rejectedCompositionProvider(): array ], 'filter inside allOf branch (with outer filter)' => [ 'FilterCompositionFilterInBranch.json', - '/A filter keyword inside a allOf composition branch is not supported for property filteredProperty.*branch #0/', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', ], 'filter inside allOf branch (no outer filter)' => [ 'FilterCompositionFilterInBranchNoOuterFilter.json', - '/A filter keyword inside a allOf composition branch is not supported for property filteredProperty.*branch #0/', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', ], 'root-level allOf constrains filtered subproperty with output-type constraint' => [ 'FilterCompositionRootConstrainsFilteredSubproperty.json', @@ -1718,4 +1729,107 @@ public static function serializeMixedToDateTime(DateTime $value): string { return $value->format(DATE_ATOM); } + + // ------------------------------------------------------------------------- + // Phase 3: validator priority reassignment around transforming filters + // ------------------------------------------------------------------------- + + /** + * Phase 3 core test: with a string→int transforming filter, schema validators that are + * registered for input types (pattern → string-space) must run PRE-transform, while + * validators registered for output types (minimum → int-space) must run POST-transform. + * + * Schema: { type: [string, integer], filter: stringToInt, pattern: "^\d+$", minimum: 0 } + * + * Pre-transform proof: "hello" filtered by (int)cast becomes 0 — a value that would pass + * both pattern (is_string(0) == false, so silently skipped) and minimum (0 >= 0) if the + * validator ran post-transform. With the fixed ordering, pattern runs against the raw + * string "hello" and correctly fails. + * + * Post-transform proof: -5 passed as an already-transformed integer skips the pre-transform + * pipeline and goes straight to minimum, which catches the negative value. + */ + public function testValidatorPriorityReassignmentAroundTransformingFilter(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )); + + $className = $this->generateClassFromFile( + 'ValidatorPriorityWithTransformingFilter.json', + $configuration, + ); + + // "hello" casts to 0, which would pass both validators post-transform. + // The fixed ordering makes pattern catch it against the raw string. + try { + new $className(['value' => 'hello']); + $this->fail('Expected PatternException for input "hello"'); + } catch (PatternException $patternException) { + $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); + } + + // "-5" would silently become -5 post-transform, causing MinimumException instead. + // The fixed ordering catches it at pattern (pre-transform) because "-5" ∉ \d+. + try { + new $className(['value' => '-5']); + $this->fail('Expected PatternException for input "-5"'); + } catch (PatternException $patternException) { + $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); + } + + // Valid string input: pattern passes, filter transforms to 42, minimum passes. + $object = new $className(['value' => '42']); + $this->assertSame(42, $object->getValue()); + + // Already-transformed int that satisfies minimum: skips pre-transform pipeline. + $object = new $className(['value' => 42]); + $this->assertSame(42, $object->getValue()); + + // Already-transformed int that fails minimum: minimum runs post-transform. + try { + new $className(['value' => -5]); + $this->fail('Expected MinimumException for input -5'); + } catch (MinimumException $minimumException) { + $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); + } + } + + /** + * Phase 3 regression guard: a non-transforming filter must not trigger any priority + * reassignment. The existing TrimAsStringWithLengthValidation schema exercises this by + * verifying that minLength validates the *trimmed* value (i.e. the validator runs after + * trim, not before it). Re-running that assertion here makes the regression explicit. + */ + public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): void + { + $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); + + // " AB \n" trims to "AB" (length 2) — passes minLength: 2. + $object = new $className(['property' => " AB \n"]); + $this->assertSame('AB', $object->getProperty()); + + // " a " trims to "a" (length 1) — fails minLength: 2 (validates trimmed value). + try { + new $className(['property' => ' a ']); + $this->fail('Expected ValidationException for input " a "'); + } catch (ValidationException $validationException) { + $this->assertStringContainsString('must not be shorter than 2', $validationException->getMessage()); + } + } + + public static function convertStringToInt(string $value): int + { + return (int) $value; + } + + public static function serializeIntToString(int $value): string + { + return (string) $value; + } } diff --git a/tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json b/tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json new file mode 100644 index 00000000..8120859d --- /dev/null +++ b/tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "value": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "pattern": "^\\d+$", + "minimum": 0 + } + } +} From b7ef60b44b09cc98367ebe56d635789e4d302c70 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 12 May 2026 12:56:30 +0200 Subject: [PATCH 04/11] Phase 4: composition runtime integration around transforming filters Split composition validators around the transforming filter boundary: input-space branches run pre-transform against the raw value; output-space branches run post-transform against the filtered value. FilterPreTransformGuardValidator wraps any input-space composition validator with a skip guard that returns early when the value is already in the filter's output type-space (R-8 already-transformed passthrough). ComposedPropertyValidator gains createSubsetValidator() to split a mixed-space allOf into a pre-filter input-subset and a post-filter output-subset, each rendered as its own extracted method. FilterProcessor.reassignValidatorPriorities() is decomposed into three focused private methods (classifyValidatorAdjustments, wrapInputSpaceGuards, splitMixedSpaceAllOf) and its accumulator arrays use named keys. The $filterList parameter on process() and normalizeFilterList() is narrowed from mixed to string|array. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 40 + src/Draft/AutoDetectionDraft.php | 2 +- src/Draft/Draft_07.php | 2 +- .../Validator/ComposedPropertyValidator.php | 166 ++- .../ConditionalPropertyValidator.php | 3 + .../Validator/ExtractedMethodValidator.php | 2 +- .../AbstractCompositionValidatorFactory.php | 30 +- .../Composition/IfValidatorFactory.php | 4 +- src/Model/Validator/FilterValidator.php | 43 +- .../Filter/CompositionBranchClassifier.php | 74 +- .../CompositionCompatibilityChecker.php | 8 +- .../FilterPreTransformGuardValidator.php | 82 ++ .../Filter/FilterProcessor.php | 351 ++++- src/Templates/Validator/ComposedItem.phptpl | 8 +- src/Utils/PropertyMerger.php | 2 +- tests/Basic/FilterTest.php | 1211 ++++++++++++++++- tests/Objects/BasePropertyPrecedenceTest.php | 2 +- .../CompositionBranchClassifierTest.php | 63 +- .../FilterCompositionAllOfEmptyBranch.json | 11 + .../FilterCompositionAllOfInputSpace.json | 14 + .../FilterCompositionAllOfMixedSpaces.json | 18 + ...terCompositionAllOfObjectBranchOutput.json | 13 + .../FilterCompositionAllOfOutputOnly.json | 8 +- .../FilterCompositionAllOfOutputSpace.json | 15 + .../FilterCompositionAllOfWithTrim.json | 14 + .../FilterCompositionAnyOfCrossSpace.json | 2 +- .../FilterCompositionAnyOfOutputSpace.json | 21 + .../FilterCompositionFilterInAnyOfBranch.json | 12 + ...erCompositionFilterInIfThenElseBranch.json | 13 + ...ionFilterInIfThenElseIfThenElseBranch.json | 17 + .../FilterCompositionFilterInNotBranch.json | 10 + .../FilterCompositionFilterInOneOfBranch.json | 12 + ...FilterCompositionIfElseOnlyInputSpace.json | 15 + ...FilterCompositionIfThenElseCrossSpace.json | 2 +- ...FilterCompositionIfThenElseInputSpace.json | 18 + ...ilterCompositionIfThenElseOutputSpace.json | 21 + ...FilterCompositionIfThenOnlyInputSpace.json | 15 + .../FilterCompositionNotInputSpace.json | 12 + .../FilterCompositionNotOutputSpace.json | 12 + .../FilterCompositionOneOfInputSpace.json | 17 + .../FilterCompositionOneOfOutputSpace.json | 21 + ...ootAnyOfConstrainsFilteredSubproperty.json | 17 + ...onRootIfConstrainsFilteredSubproperty.json | 16 + ...putSpaceConstrainsFilteredSubproperty.json | 17 + ...nRootNotConstrainsFilteredSubproperty.json | 15 + ...ootOneOfConstrainsFilteredSubproperty.json | 17 + 46 files changed, 2308 insertions(+), 180 deletions(-) create mode 100644 src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionNotInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json diff --git a/CLAUDE.md b/CLAUDE.md index affb44f6..2d90dc52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,6 +288,38 @@ that works for one specific schema shape but breaks or ignores others is not acc implementing, ask: "Does this solution handle the general case, or only the example at hand?" If only the specific case, redesign until the solution is general. +#### Never narrow test scope to evade failures + +When a test exposes a real bug, fix the bug — do not simplify, remove, or replace the test with +one that avoids the failing scenario. A failing test is evidence of a defect; discarding it hides +the defect rather than resolving it. + +This applies in both directions: +- Never swap a schema or assertion for a simpler variant just because the original triggers an + error in the implementation. The original schema is the spec; make the implementation handle it. +- Never stub out, skip, or weaken assertions to make a test green. If the assertion is wrong, + fix the assertion with an explicit justification; if the implementation is wrong, fix the + implementation. + +When the straightforward test case surfaces a deeper issue (object instantiation, type conflict, +priority ordering, etc.), that is precisely the issue that needs solving. Open it as a tracked +topic if it cannot be addressed immediately, but keep the test in place and marked as expected to +fail (`@expectedExceptionMessage`, `$this->expectException(...)`) until the fix lands. + +#### No implementation-plan references in code + +Do not embed references to implementation-plan phases, issue numbers, or source-code line numbers +in comments, docblocks, filenames, or any other artifact that lands in the repository. These +references decay immediately (phases complete, line numbers shift) and add noise without adding +meaning. + +- ❌ `// Phase 2 guarantees anyOf/oneOf have uniform spaces` +- ✅ `// Static rejection guarantees anyOf/oneOf have uniform spaces` +- ❌ `* Covers FilterValidator::runCompatibilityCheck lines 130–158` +- ✅ `* Validates the zero-overlap rejection path in FilterValidator` + +Describe *what the code does or why* — not where it came from in a planning document. + #### Test coverage Every identified edge case must have a corresponding test. During planning, enumerate all edge @@ -303,6 +335,14 @@ https://qlty.sh/gh/wol-soft/projects/php-json-schema-model-generator/pull/draftInstances[Draft_07::class] ??= new Draft_07(); } } diff --git a/src/Draft/Draft_07.php b/src/Draft/Draft_07.php index d9de3114..b274828d 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -82,12 +82,12 @@ public function getDefinition(): DraftBuilder ->addModifier(new NullModifier())) ->addType((new Type('any', false)) ->addValidator('enum', new EnumValidatorFactory()) - ->addValidator('filter', new FilterValidatorFactory()) ->addValidator('allOf', new AllOfValidatorFactory()) ->addValidator('anyOf', new AnyOfValidatorFactory()) ->addValidator('oneOf', new OneOfValidatorFactory()) ->addValidator('not', new NotValidatorFactory()) ->addValidator('if', new IfValidatorFactory()) + ->addValidator('filter', new FilterValidatorFactory()) ->addModifier(new DefaultValueModifier()) ->addModifier(new ConstModifier())); } diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index fa074662..c67938f9 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -44,72 +44,93 @@ public function __construct( } /** - * TODO: add method only if nested objects contain filter (else also skip method call) + * Registers the helper method that propagates filter-transformed values from nested + * composition objects back to the merged model, but only when at least one composition + * branch has a nested schema with declared properties (the only case where the method + * would ever produce non-empty results). */ public function getCheck(): string { - /** - * Add a method to the schema to gather values from a nested object which are modified. - * This is required to adopt filter changes to the values which are passed into a merged property - */ - $this->scope->addMethod( - $this->modifiedValuesMethod, - new class ($this->composedProperties, $this->modifiedValuesMethod) implements MethodInterface { - public function __construct( - /** @var CompositionPropertyDecorator[] $compositionProperties */ - private readonly array $compositionProperties, - private readonly string $modifiedValuesMethod - ) {} - - public function getCode(): string - { - $defaultValueMap = []; - $propertyAccessors = []; - foreach ($this->compositionProperties as $compositionProperty) { - if (!$compositionProperty->getNestedSchema()) { - continue; - } + $hasNestedSchemaWithProperties = $this->hasNestedSchemaWithProperties(); + + $this->templateValues['hasModifiedValuesMethod'] = $hasNestedSchemaWithProperties; + + if ($hasNestedSchemaWithProperties) { + $this->scope->addMethod( + $this->modifiedValuesMethod, + new class ($this->composedProperties, $this->modifiedValuesMethod) implements MethodInterface { + public function __construct( + /** @var CompositionPropertyDecorator[] $compositionProperties */ + private readonly array $compositionProperties, + private readonly string $modifiedValuesMethod + ) {} + + public function getCode(): string + { + $defaultValueMap = []; + $propertyAccessors = []; + foreach ($this->compositionProperties as $compositionProperty) { + if (!$compositionProperty->getNestedSchema()) { + continue; + } - foreach ($compositionProperty->getNestedSchema()->getProperties() as $property) { - $propertyAccessors[$property->getName()] = 'get' . ucfirst($property->getAttribute()); + foreach ($compositionProperty->getNestedSchema()->getProperties() as $property) { + $propertyAccessors[$property->getName()] = 'get' . ucfirst($property->getAttribute()); - if ($property->getDefaultValue() !== null) { - $defaultValueMap[] = $property->getName(); + if ($property->getDefaultValue() !== null) { + $defaultValueMap[] = $property->getName(); + } } } - } - return sprintf( - ' - private function %s(array $originalModelData, object $nestedCompositionObject): array { - $modifiedValues = []; - $defaultValueMap = %s; - - foreach (%s as $key => $accessor) { - if ((isset($originalModelData[$key]) || in_array($key, $defaultValueMap)) - && method_exists($nestedCompositionObject, $accessor) - && ($modifiedValue = $nestedCompositionObject->$accessor()) - !== ($originalModelData[$key] ?? !$modifiedValue) - ) { - $modifiedValues[$key] = $modifiedValue; + return sprintf( + ' + private function %s(array $originalModelData, object $nestedCompositionObject): array { + $modifiedValues = []; + $defaultValueMap = %s; + + foreach (%s as $key => $accessor) { + if ((isset($originalModelData[$key]) || in_array($key, $defaultValueMap)) + && method_exists($nestedCompositionObject, $accessor) + && ($modifiedValue = $nestedCompositionObject->$accessor()) + !== ($originalModelData[$key] ?? !$modifiedValue) + ) { + $modifiedValues[$key] = $modifiedValue; + } } - } - - return $modifiedValues; - }', - $this->modifiedValuesMethod, - var_export($defaultValueMap, true), - var_export($propertyAccessors, true), - ); - } - }, - ); + + return $modifiedValues; + }', + $this->modifiedValuesMethod, + var_export($defaultValueMap, true), + var_export($propertyAccessors, true), + ); + } + }, + ); + } return parent::getCheck(); } /** - * Initialize all variables which are required to execute a composed property validator + * Returns true when at least one composition branch has a nested schema with declared + * properties, meaning the modified-values helper method may produce non-empty results. + */ + private function hasNestedSchemaWithProperties(): bool + { + foreach ($this->composedProperties as $compositionProperty) { + $nestedSchema = $compositionProperty->getNestedSchema(); + if ($nestedSchema !== null && !empty($nestedSchema->getProperties())) { + return true; + } + } + + return false; + } + + /** + * Initialize all variables which are required to execute a composed property validator. */ public function getValidatorSetUp(): string { @@ -119,6 +140,47 @@ public function getValidatorSetUp(): string '; } + /** + * Create a subset of this validator containing only the composition branches at the + * given indices. Used by FilterProcessor to split an allOf validator whose branches + * span both input-space and output-space around a transforming filter. + * + * @param int[] $branchIndices Branch indices (0-based) to retain. + * @param string $methodSuffix Appended to the extracted method name to keep + * the subset validators distinct in the generated class. + * + * @return self + */ + public function createSubsetValidator(array $branchIndices, string $methodSuffix): self + { + $filteredProperties = array_values( + array_intersect_key($this->composedProperties, array_flip($branchIndices)), + ); + $availableAmount = count($filteredProperties); + + $subsetValidator = clone $this; + + // Give the subset validator a unique extracted method name so it generates + // its own method in the target class instead of colliding with the original. + $subsetValidator->extractedMethodName = $this->getExtractedMethodName() . $methodSuffix; + + // Regenerate the modifiedValuesMethod name so the subset validator's helper + // method is distinct from the original's. + $subsetValidator->modifiedValuesMethod = + '_getModifiedValues_' . substr(md5(spl_object_hash($subsetValidator)), 0, 5); + + $subsetValidator->composedProperties = $filteredProperties; + $subsetValidator->templateValues = array_merge($this->templateValues, [ + 'compositionProperties' => $filteredProperties, + 'availableAmount' => $availableAmount, + 'composedValueValidation' => "\$succeededCompositionElements === $availableAmount", + 'mergedProperty' => null, + 'modifiedValuesMethod' => $subsetValidator->modifiedValuesMethod, + ]); + + return $subsetValidator; + } + /** * Creates a copy of the validator and strips all nested composition validations from the composed properties. * See usage in BaseProcessor for more details why the nested validators can be filtered out. diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index c256d43d..6bc5040e 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -53,6 +53,9 @@ public function getConditionBranches(): array return $this->conditionBranches; } + /** + * Initialize variables required by the conditional validator. + */ public function getValidatorSetUp(): string { return '$ifException = $thenException = $elseException = null;'; diff --git a/src/Model/Validator/ExtractedMethodValidator.php b/src/Model/Validator/ExtractedMethodValidator.php index 9a88cff8..005eff0f 100644 --- a/src/Model/Validator/ExtractedMethodValidator.php +++ b/src/Model/Validator/ExtractedMethodValidator.php @@ -15,7 +15,7 @@ */ abstract class ExtractedMethodValidator extends PropertyTemplateValidator { - private readonly string $extractedMethodName; + protected string $extractedMethodName; public function __construct( private readonly GeneratorConfiguration $generatorConfiguration, diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index be15d5dd..29d875b9 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -14,6 +14,7 @@ use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; +use PHPModelGenerator\Model\Validator\InstanceOfValidator; use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ClearTypeHintDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\CompositionTypeHintDecorator; @@ -67,7 +68,7 @@ protected function shouldSkip(PropertyInterface $property, JsonSchema $propertyS * For "not", the value is a single branch schema (not an array); all other keywords * use an array of branches. * - * TODO: R-7 — filters inside composition branches cannot be correctly applied + * TODO: filters inside composition branches cannot be correctly applied * (ComposedItem.phptpl resets $value to $originalModelData after each branch). * Proper per-branch filter chaining is deferred to a follow-up topic. * @@ -150,10 +151,31 @@ protected function getCompositionProperties( ); $compositionProperty->onResolve(function () use ($compositionProperty, $property, $merged): void { + $nestedSchema = $compositionProperty->getNestedSchema(); + $compositionProperty->filterValidators( - static fn(Validator $validator): bool => - !is_a($validator->getValidator(), RequiredPropertyValidator::class) && - !is_a($validator->getValidator(), ComposedPropertyValidator::class), + static function (Validator $validator) use ($nestedSchema): bool { + if (is_a($validator->getValidator(), RequiredPropertyValidator::class)) { + return false; + } + if (is_a($validator->getValidator(), ComposedPropertyValidator::class)) { + return false; + } + // An empty object schema ({type: object} with no declared properties) + // must accept any PHP object in composition context. The generated + // placeholder class carries no semantic constraints, so the strict + // instanceof check against it would incorrectly reject valid objects + // (e.g. a DateTime produced by a transforming filter) that are perfectly + // acceptable under the schema's actual semantics. + if ( + is_a($validator->getValidator(), InstanceOfValidator::class) + && $nestedSchema !== null + && empty($nestedSchema->getProperties()) + ) { + return false; + } + return true; + }, ); if (!($merged && $compositionProperty->getNestedSchema())) { diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index b48747f0..16524560 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -49,12 +49,12 @@ public function modify( // Inherit the parent type into if/then/else sub-schemas before the filter check so // that sub-schemas that inherit 'object' are correctly recognised as object-typed. // Object-typed sub-schemas create nested schemas whose properties are processed - // independently and are not subject to ComposedItem $value reset (R-7). + // independently and are not subject to ComposedItem $value reset. $propertySchema = $this->inheritPropertyType($propertySchema); $json = $propertySchema->getJson(); // Check for filter keywords in if/then/else sub-schemas after type inheritance. - // TODO: R-7 — filters inside if/then/else sub-schemas cannot be correctly applied + // TODO: filters inside if/then/else sub-schemas cannot be correctly applied // (ComposedItem.phptpl resets $value to $originalModelData after each branch). // Proper per-branch filter chaining is deferred to a follow-up topic. foreach (['if', 'then', 'else'] as $keyword) { diff --git a/src/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 8f978566..3110083e 100644 --- a/src/Model/Validator/FilterValidator.php +++ b/src/Model/Validator/FilterValidator.php @@ -14,6 +14,7 @@ use PHPModelGenerator\Utils\FilterReflection; use PHPModelGenerator\Utils\RenderHelper; use PHPModelGenerator\Utils\TypeCheck; +use PHPModelGenerator\Utils\TypeConverter; use ReflectionException; /** @@ -123,6 +124,14 @@ public function addTransformedCheck(TransformingFilterInterface $filter, Propert * execute under any circumstances. Partial overlap is fine: the runtime typeCheck guard in the * generated code already skips the filter for non-matching value types. * + * Type source: only an explicit 'type' key in the schema JSON is used for the check. + * When no direct 'type' is declared, the property's resolved type (if any) was set by + * composition modifiers (allOf, anyOf, oneOf) and is not reliable for input-type checking + * — output-space branches mutate the resolved type to the filter's output type rather than + * its input type. Skipping the check when there is no direct type declaration is + * order-independent and avoids false-positive incompatibility errors regardless of which + * modifier ran first. + * * @param string[] $acceptedTypes Pre-computed accepted types of the filter. * * @throws SchemaException @@ -133,14 +142,34 @@ private function runCompatibilityCheck(array $acceptedTypes, PropertyInterface $ return; } - if ($property->getType() === null && $property->getNestedSchema() === null) { - return; - } + if ($property->getNestedSchema() !== null) { + $typeNames = ['object']; + $isNullable = false; + } else { + // Prefer the schema-declared type for the overlap check. Composition modifiers may + // have mutated property->getType() with output-space branch types (e.g. allOf with a + // {type:integer} branch for a string→int filter), causing false incompatibility errors. + $schemaJson = $property->getJsonSchema()->getJson(); + $schemaType = $schemaJson['type'] ?? null; - $typeNames = $property->getNestedSchema() !== null - ? ['object'] - : $property->getType()->getNames(); - $isNullable = $property->getType()?->isNullable() ?? false; + if (is_string($schemaType) && $schemaType !== 'any') { + // Single-string schema type: derive the PHP type name directly from the declaration. + $typeNames = [TypeConverter::jsonSchemaToPHP($schemaType)]; + $isNullable = $property->getType()?->isNullable() ?? false; + } elseif ($property->getType() === null || $schemaType === null) { + // No direct type declaration in the schema JSON — the property is either + // unconstrained or its resolved type was set by composition modifiers (allOf, + // anyOf, oneOf). Composition branches that target the filter's output type-space + // (e.g. {type:integer} on a string→int filter) mutate the resolved type to the + // output type, making it unreliable for input-type compatibility checking. The + // filter's runtime type guard and any input-space composition validators enforce + // type safety, so no static check is needed here. + return; + } else { + $typeNames = $property->getType()->getNames(); + $isNullable = $property->getType()->isNullable() ?? false; + } + } $hasOverlap = !empty(array_intersect($typeNames, $acceptedTypes)) || ($isNullable && in_array('null', $acceptedTypes, true)); diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php index 21bb98a3..e34cfd81 100644 --- a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -111,10 +111,17 @@ public function classify(array $branchSchema): TypeSpace private function classifyKeyword(string $keyword, mixed $value): TypeSpace { if ($keyword === 'type') { - return $this->resolveTypeSpace(array_map( - static fn(string $jsonType): string => TypeConverter::jsonSchemaToPHP($jsonType), - (array) $value, - )); + // The 'type' keyword asserts raw JSON value structure. It must classify + // against the declared output types directly — without the 'object' expansion + // from getEffectiveOutputTypes() — so that 'type: object' stays input-space for + // filters that return a PHP class instance (e.g. DateTime for dateTime filter). + return $this->classifyAgainstSpaces( + array_map( + static fn(string $jsonType): string => TypeConverter::jsonSchemaToPHP($jsonType), + (array) $value, + ), + $this->outputTypes, + ); } if (in_array($keyword, self::NESTED_COMPOSITION_KEYWORDS, true)) { @@ -186,13 +193,35 @@ private function classifyNestedComposition(string $keyword, mixed $value): TypeS * Map a list of PHP type names onto the Input / Output / Mixed / Empty TypeSpace * based on whether they overlap with the filter's declared input and output types. * + * Non-primitive class names in the declared output types are expanded to include + * 'object', so that object-targeted Draft keywords (e.g. minProperties) classify as + * output-space when the filter returns a class instance. + * + * 'int' and 'float' are treated as equivalent for overlap detection (JSON Schema: + * integer is a subtype of number). Number-typed Draft keywords such as 'minimum' and + * 'maximum' register under the 'number' → PHP 'float' type, so they must classify as + * output-space for filters that return 'int', and as input-space for filters that + * accept 'int'. + * * @param string[] $phpTypeNames */ private function resolveTypeSpace(array $phpTypeNames): TypeSpace { - $effectiveOutputTypes = $this->getEffectiveOutputTypes(); - $inInput = !empty(array_intersect($phpTypeNames, $this->inputTypes)); - $inOutput = !empty(array_intersect($phpTypeNames, $effectiveOutputTypes)); + return $this->classifyAgainstSpaces($phpTypeNames, $this->getEffectiveOutputTypes()); + } + + /** + * Core classification: map a list of PHP type names onto the Input / Output / Mixed / + * Empty TypeSpace based on whether they overlap with the filter's input types and the + * given output type list. + * + * @param string[] $phpTypeNames + * @param string[] $outputTypes + */ + private function classifyAgainstSpaces(array $phpTypeNames, array $outputTypes): TypeSpace + { + $inInput = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($this->inputTypes))); + $inOutput = !empty(array_intersect($phpTypeNames, $this->expandNumericTypes($outputTypes))); return match (true) { $inInput && $inOutput => TypeSpace::Mixed, @@ -202,11 +231,42 @@ private function resolveTypeSpace(array $phpTypeNames): TypeSpace }; } + /** + * Expand a set of PHP type names so that 'int' and 'float' are treated as + * interchangeable for overlap detection. + * + * JSON Schema defines integer as a subtype of number. In the Draft type registry + * 'number' maps to PHP 'float', so number-typed keywords (minimum, maximum, …) + * carry 'float' as their type name. A filter that returns 'int' should still make + * those keywords classify as output-space; a filter that accepts 'int' should make + * them classify as input-space. + * + * @param string[] $types + * @return string[] + */ + private function expandNumericTypes(array $types): array + { + $hasInt = in_array('int', $types, true); + $hasFloat = in_array('float', $types, true); + + if ($hasInt && !$hasFloat) { + $types[] = 'float'; + } elseif ($hasFloat && !$hasInt) { + $types[] = 'int'; + } + + return $types; + } + /** * Expand the declared output types to include 'object' when any output type is a * non-primitive class name. This allows object-type Draft keywords (e.g. minProperties, * properties) to be classified as output-targeted when the filter returns a class instance. * + * The 'type' keyword is deliberately excluded from this expansion: it validates raw JSON + * value structure and must classify against the declared output types only (see + * classifyKeyword). This method is used only for non-'type' structural keywords. + * * @return string[] */ private function getEffectiveOutputTypes(): array diff --git a/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php index 2973f10f..d39351e9 100644 --- a/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php +++ b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php @@ -48,7 +48,7 @@ public function checkTransformingFilterCompositionConflicts(array $propertySchem /** * Validate that root-level composition branches on the parent object schema do not - * constrain the filtered subproperty with output-type-space constraints (R-4). + * constrain the filtered subproperty with output-type-space constraints. * * Throws when any root-level composition branch references the filtered subproperty * by name via a "properties" constraint whose content targets the output type-space. @@ -83,9 +83,9 @@ public function checkTransformingFilterRootCompositionConflicts(array $parentSch $space = $this->classifier->classify($innerConstraint); if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) { - // TODO: R-4 — proper handling is deferred to a follow-up topic. + // TODO: proper handling is deferred to a follow-up topic. // Root-level composition branches cannot yet be split around the - // filter's transform boundary. See implementation-plan.md. + // filter's transform boundary. throw new SchemaException(sprintf( 'Composition %s in file %s constrains filtered subproperty %s' . ' (branch #%d) with output-type-space constraints;' @@ -117,7 +117,7 @@ public function checkTransformingFilterRootCompositionConflicts(array $parentSch $space = $this->classifier->classify($innerConstraint); if ($space === TypeSpace::Output || $space === TypeSpace::Mixed) { - // TODO: R-4 — see above. + // TODO: see above. throw new SchemaException(sprintf( 'Composition %s in file %s constrains filtered subproperty %s' . ' with output-type-space constraints; this combination is not yet supported.', diff --git a/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php new file mode 100644 index 00000000..65b35ffb --- /dev/null +++ b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php @@ -0,0 +1,82 @@ +extractedMethodName = 'filterPreTransformGuard_' . $inner->getExtractedMethodName(); + $this->isResolved = true; + } + + /** + * Sets scope on both this guard and the wrapped inner validator, and registers the + * inner validator's extracted method on the schema so the guard method can call it. + */ + public function setScope(Schema $schema): void + { + parent::setScope($schema); + $this->inner->setScope($schema); + + if (!$schema->hasMethod($this->inner->getExtractedMethodName())) { + $schema->addMethod($this->inner->getExtractedMethodName(), $this->inner->getMethod()); + } + } + + /** + * Returns the wrapped input-space composition validator. + */ + public function getInnerValidator(): AbstractComposedPropertyValidator + { + return $this->inner; + } + + /** + * Returns a method that short-circuits when the value is already in the filter's + * output type-space, and otherwise delegates to the wrapped composition validator. + */ + public function getMethod(): MethodInterface + { + $guardMethodName = $this->getExtractedMethodName(); + $innerMethodName = $this->inner->getExtractedMethodName(); + $skipCheck = $this->skipCheck; + + return new class ($guardMethodName, $innerMethodName, $skipCheck) implements MethodInterface { + public function __construct( + private readonly string $guardMethodName, + private readonly string $innerMethodName, + private readonly string $skipCheck, + ) {} + + public function getCode(): string + { + return "private function {$this->guardMethodName}(&\$value, \$modelData): void { + if ({$this->skipCheck}) { + return; + } + \$this->{$this->innerMethodName}(\$value, \$modelData); + }"; + } + }; + } +} diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index 1b441284..458c2997 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -8,6 +8,7 @@ use PHPModelGenerator\Draft\Draft; use PHPModelGenerator\Draft\DraftFactoryInterface; use PHPModelGenerator\Exception\InvalidFilterException; +use PHPModelGenerator\Exception\Object\InvalidInstanceOfException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Filter\TransformingFilterInterface; use PHPModelGenerator\Filter\ValidateOptionsInterface; @@ -17,8 +18,11 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator; +use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\EnumValidator; +use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\Model\Validator\FilterValidator; +use PHPModelGenerator\Model\Validator\InstanceOfValidator; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; @@ -41,7 +45,7 @@ class FilterProcessor * Accepts a string token, a single filter-spec array (['filter' => 'token', ...]), * or a list of either. Always returns a list. */ - public static function normalizeFilterList(mixed $filterList): array + public static function normalizeFilterList(string|array $filterList): array { if (is_string($filterList) || (is_array($filterList) && isset($filterList['filter']))) { return [$filterList]; @@ -57,7 +61,7 @@ public static function normalizeFilterList(mixed $filterList): array */ public function process( PropertyInterface $property, - mixed $filterList, + string|array $filterList, GeneratorConfiguration $generatorConfiguration, Schema $schema, ): void { @@ -142,7 +146,13 @@ public function process( $checker->checkTransformingFilterCompositionConflicts($property->getJsonSchema()->getJson()); $checker->checkTransformingFilterRootCompositionConflicts($schema->getJsonSchema()->getJson()); - $this->reassignValidatorPriorities($property, $actualFilterPriority, $classifier); + $this->reassignValidatorPriorities( + $property, + $actualFilterPriority, + $classifier, + $returnTypeNames, + $generatorConfiguration, + ); if (!empty($returnTypeNames)) { // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators @@ -152,6 +162,18 @@ public function process( // !$transformationFailed instead. $this->addTransformedValuePassThrough($property, $filter, $returnTypeNames); + $objectReturnTypes = array_values(array_filter( + $returnTypeNames, + static fn(string $type): bool => !TypeCheck::isPrimitive($type), + )); + if (!empty($objectReturnTypes)) { + $this->addExtendedInstanceOfCheckForObjectBranches( + $property, + $objectReturnTypes, + $actualFilterPriority, + ); + } + // Eagerly set the output type when the base type is already known. // This preserves the output type through property cloning in merged composition // schemas (where validators are stripped but the type fields are retained). @@ -176,6 +198,87 @@ public function process( } } + /** + * Adds a property-level validator that rejects objects whose type is not in the filter's + * declared non-primitive return types (e.g. rejects stdClass when the filter returns DateTime). + * + * Empty object schemas ({type: object} with no declared properties) in composition branches + * have their strict instanceof check removed so that any PHP object passes the branch's + * type check. Without a narrowing check at the property level, foreign objects would + * silently pass through. Placing the validator at property level ensures + * InvalidInstanceOfException propagates directly rather than being absorbed by the + * composition template's catch block. + * + * Only adds the validator when at least one composition branch had its instanceof removed + * (i.e. has an empty nested schema with no declared properties). + * + * @param string[] $objectReturnTypes Non-primitive PHP class names returned by the filter. + * @param int $filterPriority Priority at which the transforming filter was added; + * the check is scheduled one step later so it runs on + * the already-transformed value. + */ + private function addExtendedInstanceOfCheckForObjectBranches( + PropertyInterface $property, + array $objectReturnTypes, + int $filterPriority, + ): void { + $hasEmptyObjectBranch = false; + foreach ($property->getValidators() as $validatorContainer) { + $validator = $validatorContainer->getValidator(); + + // Unwrap a FilterPreTransformGuardValidator to reach the underlying composed validator. + if ($validator instanceof FilterPreTransformGuardValidator) { + $validator = $validator->getInnerValidator(); + } + + if (!is_a($validator, AbstractComposedPropertyValidator::class)) { + continue; + } + + /** @var AbstractComposedPropertyValidator $composedValidator */ + $composedValidator = $validator; + + foreach ($composedValidator->getComposedProperties() as $compositionProperty) { + $nestedSchema = $compositionProperty->getNestedSchema(); + if ($nestedSchema === null || !empty($nestedSchema->getProperties())) { + continue; + } + + $instanceOfRemoved = true; + foreach ($compositionProperty->getValidators() as $compositionValidator) { + if (is_a($compositionValidator->getValidator(), InstanceOfValidator::class)) { + $instanceOfRemoved = false; + break; + } + } + + if ($instanceOfRemoved) { + $hasEmptyObjectBranch = true; + break 2; + } + } + } + + if (!$hasEmptyObjectBranch) { + return; + } + + $instanceOfParts = implode(' || ', array_map( + static fn(string $cls): string => "\$value instanceof $cls", + $objectReturnTypes, + )); + + $property->addValidator( + new PropertyValidator( + $property, + "is_object(\$value) && !(\$value instanceof \\Exception) && !($instanceOfParts)", + InvalidInstanceOfException::class, + [reset($objectReturnTypes)], + ), + $filterPriority + 1, + ); + } + /** * After identifying a transforming filter at priority P, scan all existing validators * on the property and adjust their run order: @@ -187,52 +290,268 @@ public function process( * so they execute against the raw input value. * - Validators without a source key (type-check, required, non-transforming filters) * are untouched regardless of their priority. - * - Composition validators (AbstractComposedPropertyValidator) are left for Phase 4, - * which will split them into pre- and post-transform blocks. + * - Composition validators (AbstractComposedPropertyValidator) are classified by their + * branch type-space and repositioned accordingly: + * - All input-space or ambiguous → moved to P-1, wrapped in a skip guard so that + * already-transformed values bypass the pre-transform check. + * - All output-space → left post-filter (default). + * - Mixed-space allOf (some input, some output branches) → split into a pre-filter + * input-only subset and a post-filter output-only subset; the original is removed. * - * @param int $filterPriority The actual priority at which the transforming filter was added. + * @param int $filterPriority The actual priority at which the transforming filter was added. + * @param string[] $returnTypeNames Non-null return type names of the transforming filter; + * used to build the skip condition for the pre-transform guards. */ private function reassignValidatorPriorities( PropertyInterface $property, int $filterPriority, CompositionBranchClassifier $classifier, + array $returnTypeNames, + GeneratorConfiguration $generatorConfiguration, ): void { + $skipCheck = !empty($returnTypeNames) ? TypeCheck::buildCompound($returnTypeNames) : ''; + + [$inputSpaceComposed, $mixedAllOf] = $this->classifyValidatorAdjustments( + $property, + $filterPriority, + $classifier, + $returnTypeNames, + ); + + $this->wrapInputSpaceGuards( + $property, + $inputSpaceComposed, + $filterPriority, + $skipCheck, + $generatorConfiguration, + ); + + $this->splitMixedSpaceAllOf( + $property, + $mixedAllOf, + $filterPriority, + $skipCheck, + $returnTypeNames, + $generatorConfiguration, + ); + } + + /** + * Scan all post-filter validators on the property, move scalar input-space validators + * to just before the filter, and return two accumulator lists for deferred structural + * changes that cannot be applied while iterating the validator list: + * + * - $inputSpaceComposed: uniform input-space composition validators that need a + * pre-transform guard when return types are known. + * - $mixedAllOf: allOf validators whose branches span both type-spaces and must be + * split into separate pre- and post-filter subsets. + * + * @param string[] $returnTypeNames + * + * @return array{ + * list, + * list + * } + */ + private function classifyValidatorAdjustments( + PropertyInterface $property, + int $filterPriority, + CompositionBranchClassifier $classifier, + array $returnTypeNames, + ): array { + /** @var list */ + $inputSpaceComposed = []; + + /** @var list */ + $mixedAllOf = []; + foreach ($property->getValidators() as $validatorContainer) { - // Validators already scheduled before the filter need no adjustment. if ($validatorContainer->getPriority() < $filterPriority) { - continue; + continue; // Already scheduled before the filter; no adjustment needed. } - // Skip the filter validators themselves. if (is_a($validatorContainer->getValidator(), FilterValidator::class)) { - continue; + continue; // Skip the filter validators themselves. } - // Composition validators are handled in Phase 4. if (is_a($validatorContainer->getValidator(), AbstractComposedPropertyValidator::class)) { + /** @var AbstractComposedPropertyValidator $composedValidator */ + $composedValidator = $validatorContainer->getValidator(); + + [$inputIndices, $outputIndices] = $this->classifyComposedValidatorBranches( + $composedValidator, + $classifier, + ); + + // Only allOf can have mixed spaces (static rejection guarantees anyOf/oneOf/not/ + // if-then-else have uniform spaces). Collect mixed-space allOf validators for + // splitting in splitMixedSpaceAllOf(). + if ( + !empty($inputIndices) && !empty($outputIndices) + && $composedValidator instanceof ComposedPropertyValidator + && $composedValidator->getCompositionProcessor() === AllOfValidatorFactory::class + ) { + $mixedAllOf[] = [ + 'container' => $validatorContainer, + 'validator' => $composedValidator, + 'inputIndices' => $inputIndices, + 'outputIndices' => $outputIndices, + ]; + continue; + } + + if (empty($outputIndices)) { + // All branches are input-space or ambiguous (Empty → Input by liberal policy). + // Defer guard wrapping when return types are known; otherwise move directly. + if (!empty($returnTypeNames)) { + $inputSpaceComposed[] = [ + 'container' => $validatorContainer, + 'validator' => $composedValidator, + ]; + } else { + $validatorContainer->setPriority($filterPriority - 1); + } + } + // Output-space composition validators: leave at their current post-filter position. + continue; } $sourceKey = $validatorContainer->getSourceKey(); if ($sourceKey === null) { // No source key: validator was not produced by a Draft AbstractValidatorFactory - // (e.g. PassThroughTypeCheckValidator from addTransformedValuePassThrough). - // Leave at its current position. + // (e.g. PassThroughTypeCheckValidator). Leave at its current position. continue; } $typeSpace = $classifier->classifySchemaKey($sourceKey); - // Pure output-space validators (e.g. 'minimum' for a string→int filter) are - // correct where they are: they must validate the transformed value. if ($typeSpace === TypeSpace::Output) { - continue; + continue; // Output-space validators belong after the filter. } // Input-space, mixed, and ambiguous validators must run before the filter // so they validate the raw input value. $validatorContainer->setPriority($filterPriority - 1); } + + return [$inputSpaceComposed, $mixedAllOf]; + } + + /** + * Replace each uniform input-space composition validator with a FilterPreTransformGuardValidator + * that short-circuits when the value is already in the filter's output type-space. + * + * @param list $inputSpaceComposed + */ + private function wrapInputSpaceGuards( + PropertyInterface $property, + array $inputSpaceComposed, + int $filterPriority, + string $skipCheck, + GeneratorConfiguration $generatorConfiguration, + ): void { + foreach ($inputSpaceComposed as ['container' => $originalContainer, 'validator' => $composedValidator]) { + $property->filterValidators( + static fn(Validator $container): bool => $container !== $originalContainer, + ); + $property->addValidator( + new FilterPreTransformGuardValidator( + $generatorConfiguration, + $property, + $composedValidator, + $skipCheck, + ), + $filterPriority - 1, + ); + } + } + + /** + * Replace each mixed-space allOf validator with a pre-filter input-subset (wrapped in a guard + * when return types are known) and a post-filter output-subset at the original priority. + * + * @param list $mixedAllOf + * @param string[] $returnTypeNames + */ + private function splitMixedSpaceAllOf( + PropertyInterface $property, + array $mixedAllOf, + int $filterPriority, + string $skipCheck, + array $returnTypeNames, + GeneratorConfiguration $generatorConfiguration, + ): void { + foreach ( + $mixedAllOf as [ + 'container' => $originalContainer, + 'validator' => $originalValidator, + 'inputIndices' => $inputIndices, + 'outputIndices' => $outputIndices, + ] + ) { + $property->filterValidators( + static fn(Validator $container): bool => $container !== $originalContainer, + ); + + // Input-space subset runs before the filter; wrap in a guard when return types are known. + $preTransformValidator = $originalValidator->createSubsetValidator($inputIndices, '_pre_filter'); + if (!empty($returnTypeNames)) { + $property->addValidator( + new FilterPreTransformGuardValidator( + $generatorConfiguration, + $property, + $preTransformValidator, + $skipCheck, + ), + $filterPriority - 1, + ); + } else { + $property->addValidator($preTransformValidator, $filterPriority - 1); + } + + // Output-space subset runs at the original validator's priority so its position + // relative to other post-transform validators is preserved. + $postTransformValidator = $originalValidator->createSubsetValidator($outputIndices, '_post_filter'); + $property->addValidator($postTransformValidator, $originalContainer->getPriority()); + } + } + + /** + * Classify the branches of a composition validator into input-space and output-space + * index lists using the given CompositionBranchClassifier. + * + * Empty/ambiguous branches (TypeSpace::Empty) are treated as input-space per the + * liberal policy, consistent with CompositionBranchClassifier. + * + * @return array{int[], int[]} [inputIndices, outputIndices] + */ + private function classifyComposedValidatorBranches( + AbstractComposedPropertyValidator $validator, + CompositionBranchClassifier $classifier, + ): array { + $inputIndices = []; + $outputIndices = []; + + foreach ($validator->getComposedProperties() as $index => $compositionProperty) { + $branchSchema = $compositionProperty->getBranchSchema()->getJson(); + $space = $classifier->classify($branchSchema); + + if ($space === TypeSpace::Output) { + $outputIndices[] = $index; + } else { + // Input, Mixed (statically rejected — shouldn't occur), and Empty → Input + $inputIndices[] = $index; + } + } + + return [$inputIndices, $outputIndices]; } /** diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 7acd03b4..c39c114f 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -88,9 +88,11 @@ $proposedValue = $proposedValue ?? $value; {% endif %} - if (is_object($value)) { - $modifiedValues = array_merge($modifiedValues, $this->{{ modifiedValuesMethod }}($originalModelData, $value)); - } + {% if hasModifiedValuesMethod %} + if (is_object($value)) { + $modifiedValues = array_merge($modifiedValues, $this->{{ modifiedValuesMethod }}($originalModelData, $value)); + } + {% endif %} {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} {% if not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { diff --git a/src/Utils/PropertyMerger.php b/src/Utils/PropertyMerger.php index a840daa4..880c538a 100644 --- a/src/Utils/PropertyMerger.php +++ b/src/Utils/PropertyMerger.php @@ -68,7 +68,7 @@ public function merge( } // Use getType(true) for the stored output type. - // getType(false) post-Phase-5 returns a synthesised union and cannot be decomposed. + // getType(false) returns a synthesised union type and cannot be decomposed into its parts. // // For allOf: a truly-untyped incoming branch (no type keyword, not an explicit null-type // branch) adds no type constraint — all allOf branches apply simultaneously, so the diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 8987e9a9..3fc28002 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -8,6 +8,12 @@ use Exception; use ReflectionClass; use RuntimeException; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; +use PHPModelGenerator\Exception\ComposedValue\AnyOfException; +use PHPModelGenerator\Exception\ComposedValue\ConditionalException; +use PHPModelGenerator\Exception\ComposedValue\NotException; +use PHPModelGenerator\Exception\ComposedValue\OneOfException; +use PHPModelGenerator\Exception\Object\InvalidInstanceOfException; use PHPModelGenerator\Exception\ErrorRegistryException; use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Exception\Number\MinimumException; @@ -370,9 +376,11 @@ protected function getCustomTransformingFilter( array $customFilter = [], string $token = 'customTransformingFilter', ): TransformingFilterInterface { - return new class ($customSerializer, $customFilter, $token) - extends TrimFilter - implements TransformingFilterInterface + return new class ( + $customSerializer, + $customFilter, + $token, + ) extends TrimFilter implements TransformingFilterInterface { public function __construct( private readonly array $customSerializer, @@ -1104,7 +1112,7 @@ public function testMixedTypedCallableGeneratesNoRuntimeTypeCheck(): void $this->assertSame(-5, $object->getProperty()); } - // --- Static callables for Phase 4d tests --- + // --- Static callables for transforming filter output type tests --- /** * Accepts string or int, converts to string. Used for the union-type-hint guard test. @@ -1173,7 +1181,7 @@ public static function filterWithNeverReturnType(string $value): never throw new RuntimeException('never'); } - // --- Phase 4d: output type formula, reflection, filter chain tests --- + // --- Transforming filter output type, reflection, and filter chain tests --- /** * R2: TransformingFilter (int→string via binary) on a string|integer property. @@ -1442,12 +1450,8 @@ public function testFilterChainWithTransformingFilterOnUntypedProperty(): void } /** - * FC-M1: A transforming filter with a mixed return type followed by a filter that does NOT + * A transforming filter with a mixed return type followed by a filter that does NOT * accept all types must throw a SchemaException. - * - * Covers FilterValidator::validateFilterCompatibilityWithTransformedType lines 187–198 - * (throw when the transforming filter's return type is mixed/unconstrained but the next - * filter has non-empty accepted types). */ public function testMixedReturnTransformingFilterFollowedByTypedFilterThrowsException(): void { @@ -1472,23 +1476,15 @@ public function testMixedReturnTransformingFilterFollowedByTypedFilterThrowsExce } /** - * FC-M2: A transforming filter with a mixed return type followed by a filter that accepts - * all types (mixed first parameter) must not throw. - * FC-M3: A transforming filter with a concrete return type followed by an accept-all filter - * must not throw. - * - * FC-M2 covers FilterValidator line 201 (return after the unconstrained-output block when - * the next filter accepts all types) and TransformingFilterOutputTypePostProcessor line 95 - * (early return when the transforming filter itself has a mixed/unconstrained return type). - * FC-M3 covers FilterValidator line 206 (return when the next filter's accepted types are - * empty, i.e. it accepts all types). + * A transforming filter with a mixed return type followed by an accept-all filter must not + * throw, and neither must a concrete-return transforming filter followed by an accept-all + * filter. */ public function testFilterChainWithAcceptAllNextFilter(): void { $acceptAllFilter = $this->getCustomFilter([self::class, 'acceptAllFilter'], 'acceptAll'); - // FC-M2: mixed-return transforming filter + accept-all follow-up — no SchemaException. - // Lines 201 (FilterValidator) and 95 (TransformingFilterOutputTypePostProcessor) covered. + // Mixed-return transforming filter + accept-all follow-up — no SchemaException. $mixedReturnClassName = $this->generateClassFromFileTemplate( 'FilterChain.json', ['["mixedReturnFilter", "acceptAll"]'], @@ -1508,8 +1504,8 @@ public function testFilterChainWithAcceptAllNextFilter(): void $object = new $mixedReturnClassName(['filteredProperty' => 'hello']); $this->assertSame('hello', $object->getFilteredProperty()); - // FC-M3: concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up - // — no SchemaException. Line 206 (FilterValidator) covered. + // Concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up + // — no SchemaException. $dateTimeClassName = $this->generateClassFromFileTemplate( 'FilterChain.json', ['["dateTime", "acceptAll"]'], @@ -1523,11 +1519,8 @@ public function testFilterChainWithAcceptAllNextFilter(): void } /** - * FC-I1: A transforming filter with a non-nullable return type followed by a filter that - * does not accept that return type must throw a SchemaException. - * - * Covers FilterValidator::validateFilterCompatibilityWithTransformedType lines 212, 218, 221 - * (false branches of $returnNullable ternaries and single-type display path). + * A transforming filter with a non-nullable return type followed by a filter that does not + * accept that return type must throw a SchemaException. */ public function testNonNullableReturnTransformingFilterWithIncompatibleNextFilterThrowsException(): void { @@ -1553,93 +1546,142 @@ public function testNonNullableReturnTransformingFilterWithIncompatibleNextFilte // --- Callables for mixed-return / accept-all / int-return / mixed-accept filter tests --- - /** - * Transforming filter callable that returns mixed. - * Used for FC-M1 and FC-M2. - */ + /** Transforming filter callable that returns mixed; used by testMixedReturn* tests. */ public static function filterWithMixedReturn(string $value): mixed { return $value; } - /** - * Serializer for filterWithMixedReturn. - */ + /** Serializer paired with filterWithMixedReturn. */ public static function serializeMixedReturn(mixed $value): string { return (string) $value; } - /** - * Regular filter callable that accepts and returns mixed (accept-all filter). - * Used for FC-M2 and FC-M3. - */ + /** Regular filter callable that accepts and returns mixed (accept-all filter). */ public static function acceptAllFilter(mixed $value): mixed { return $value; } - /** - * Transforming filter callable that returns a non-nullable int. - * Used for FC-I1. - */ + /** Transforming filter callable that accepts string and returns a non-nullable int. */ public static function filterWithIntReturn(string $value): int { return (int) $value; } - /** - * Serializer for filterWithIntReturn. - */ + /** Serializer paired with filterWithIntReturn. */ public static function serializeIntReturn(int $value): string { return (string) $value; } // ------------------------------------------------------------------------- - // Phase 2 — Static rejection of unresolvable compositions + // ------------------------------------------------------------------------- + // Static rejection of unresolvable compositions + // ------------------------------------------------------------------------- // ------------------------------------------------------------------------- /** @return array */ public static function rejectedCompositionProvider(): array { return [ + // A single allOf branch spans both input-space and output-space keywords; it cannot + // be placed on either side of the filter boundary without losing one of the constraints. 'allOf with Mixed branch' => [ 'FilterCompositionAllOfMixedBranch.json', - '/Composition allOf under property filteredProperty.*branch #0 spans both input and output type-spaces/', // phpcs:ignore Generic.Files.LineLength.TooLong + '/Composition allOf under property filteredProperty' + . '.*branch #0 spans both input and output type-spaces/', ], + // anyOf branches disagree on type-space (one input-space, one output-space); all + // branches of a non-allOf composition must be uniformly pre- or post-transform. 'anyOf with cross-space branches' => [ 'FilterCompositionAnyOfCrossSpace.json', '/Composition anyOf under property filteredProperty' . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', ], + // Same as anyOf: oneOf branches cannot span different type-spaces. 'oneOf with cross-space branches' => [ 'FilterCompositionOneOfCrossSpace.json', '/Composition oneOf under property filteredProperty' . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', ], + // The not inner schema spans both spaces; the type-space classification is ambiguous. 'not with Mixed inner schema' => [ 'FilterCompositionNotMixed.json', - '/Composition not under property filteredProperty.*inner schema spans both input and output type-spaces/', // phpcs:ignore Generic.Files.LineLength.TooLong + '/Composition not under property filteredProperty' + . '.*inner schema spans both input and output type-spaces/', ], + // if/then/else sub-schemas span different type-spaces; all three sub-schemas must be + // uniformly classified so the whole conditional can be placed on one side of the filter. 'if\/then with cross-space sub-schemas' => [ 'FilterCompositionIfThenElseCrossSpace.json', '/Composition if\/then\/else under property filteredProperty.*sub-schemas span different type-spaces/', ], + // A filter keyword inside a composition branch cannot be correctly applied because the + // ComposedItem template resets $value to the original input after each branch evaluation. 'filter inside allOf branch (with outer filter)' => [ 'FilterCompositionFilterInBranch.json', '/A filter keyword inside a allOf composition branch is not supported' . ' for property filteredProperty.*branch #0/', ], + // Same as above; the rejection applies regardless of whether the property itself also + // declares an outer filter. 'filter inside allOf branch (no outer filter)' => [ 'FilterCompositionFilterInBranchNoOuterFilter.json', '/A filter keyword inside a allOf composition branch is not supported' . ' for property filteredProperty.*branch #0/', ], + // Root-level allOf constrains the filtered subproperty with output-type-space keywords. + // Splitting the root-level allOf around the filter's transform boundary is not supported. 'root-level allOf constrains filtered subproperty with output-type constraint' => [ 'FilterCompositionRootConstrainsFilteredSubproperty.json', '/Composition allOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', ], + // Same constraint applies to root-level anyOf. + 'root-level anyOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json', + '/Composition anyOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + // Same constraint applies to root-level oneOf. + 'root-level oneOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootOneOfConstrainsFilteredSubproperty.json', + '/Composition oneOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + // Same constraint applies to root-level not. + 'root-level not constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootNotConstrainsFilteredSubproperty.json', + '/Composition not.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + // Same constraint applies to root-level if/then/else. + 'root-level if constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootIfConstrainsFilteredSubproperty.json', + '/Composition if.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + // Filter inside a not branch: same $value-reset issue as for array composition keywords. + 'filter inside not branch' => [ + 'FilterCompositionFilterInNotBranch.json', + '/A filter keyword inside a not composition branch is not supported' + . ' for property filteredProperty/', + ], + // Filter inside an anyOf branch: same $value-reset issue. + 'filter inside anyOf branch' => [ + 'FilterCompositionFilterInAnyOfBranch.json', + '/A filter keyword inside a anyOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Filter inside a oneOf branch: same $value-reset issue. + 'filter inside oneOf branch' => [ + 'FilterCompositionFilterInOneOfBranch.json', + '/A filter keyword inside a oneOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Filter inside an if/then/else sub-schema: same $value-reset issue. + 'filter inside if\/then\/else branch' => [ + 'FilterCompositionFilterInIfThenElseIfThenElseBranch.json', + '/A filter keyword inside an if\/then\/else composition branch is not supported' + . ' for property filteredProperty.*if sub-schema/', + ], ]; } @@ -1659,10 +1701,14 @@ public static function acceptedCompositionProvider(): array { return [ 'allOf with input-only branches' => ['FilterCompositionAllOfInputOnly.json'], - 'allOf with output-only branches' => ['FilterCompositionAllOfOutputOnly.json'], 'anyOf with input-only branches' => ['FilterCompositionAnyOfInputOnly.json'], 'oneOf with input-only branches' => ['FilterCompositionOneOfInputOnly.json'], 'if/then/else input-only branches' => ['FilterCompositionIfThenElseInputOnly.json'], + 'if/then only (no else) input-only branches' => ['FilterCompositionIfThenOnlyInputSpace.json'], + 'if/else only (no then) input-only branches' => ['FilterCompositionIfElseOnlyInputSpace.json'], + 'allOf with empty {} branch' => ['FilterCompositionAllOfEmptyBranch.json'], + 'root-level allOf: input-space constraint on filtered subproperty' => + ['FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json'], 'root-level allOf branch: filter in inherited-object branch property' => ['FilterCompositionRootBranchWithFilterInProperty.json'], ]; @@ -1678,16 +1724,104 @@ public function testCompatibleCompositionOnTransformingFilterPropertyGeneratesSu } /** - * FC-A1: A transforming filter whose callable accepts mixed (empty accepted types) applied + * Output-only allOf (all branches output-space) runs POST-transform. + * + * Schema: { filter: stringToInt, allOf: [{type:integer, minimum: 0}, {type:integer, maximum: 100}] } + * Both branches declare type:integer — output-space for the string→int filter. + * The allOf must run AFTER the filter, validating the transformed integer. + * + * Observable proof: "200" → filter → 200 → maximum:100 fails → AllOfException. + * If the allOf ran PRE-transform, "200" is a string, the type:integer check would fail + * the branch immediately, producing AllOfException for the wrong reason (type mismatch + * rather than range violation). "50" → filter → 50 → both branches pass → success. + * If allOf ran pre-transform, "50" (string) would fail type:integer on both branches. + * + * Already-transformed int 50 supplied directly skips the filter; the output-space allOf + * still runs and passes. + * + * Note: the property has no explicit base type, so FilterProcessor cannot call + * applyOutputType at filter-processing time; the output type is instead wired later by + * TransformingFilterOutputTypePostProcessor. Compare testOutputSpaceAllOfCompositionRunsPostTransform + * where an explicit type:string causes applyOutputType to run immediately at processing time. + */ + public function testOutputOnlyAllOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfOutputOnly.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "50": filter → 50 → minimum:0 passes, maximum:100 passes → result: 50. + $object = new $className(['filteredProperty' => '50']); + $this->assertSame(50, $object->getFilteredProperty()); + + // "200": filter → 200 → maximum:100 fails → AllOfException. + // Proves post-transform: if allOf ran on the raw string "200", maximum would be a + // no-op (non-numeric) and always pass vacuously. + // Branch 0 (minimum:0) passes, branch 1 (maximum:100) fails → succeeded=1. + try { + new $className(['filteredProperty' => '200']); + $this->fail('Expected AllOfException for "200"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // "-5": filter → -5 → minimum:0 fails → AllOfException. + // Branch 0 (minimum:0) fails, branch 1 (maximum:100) passes → succeeded=1. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected AllOfException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 50 → filter skipped → output-space allOf still runs and passes. + $object = new $className(['filteredProperty' => 50]); + $this->assertSame(50, $object->getFilteredProperty()); + } + + /** + * A transforming filter whose callable accepts mixed (empty accepted types) applied * to a property that gets its type from an allOf sibling branch. * * At filter-processing time the property has no type yet (type comes later via the allOf * resolution), so FilterProcessor skips applyOutputType. After composition is resolved the * TransformingFilterOutputTypePostProcessor sets the output type. * - * Covers TransformingFilterOutputTypePostProcessor lines 110–111 - * ($bypassNames = []; $bypassNullable = false; when accepted types are empty) and lines - * 146–156 ($property->setType(...) when the post-processor must compute the output type). + * Exercises the path where accepted types are empty and the post-processor must compute + * and set the output type on the property. */ public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void { @@ -1706,8 +1840,8 @@ public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void ); // Generation succeeds. The post-processor set the output type to DateTime for this - // property (lines 146–156). Verify via reflection that the setter's type hint includes - // DateTime (confirming the output type was wired correctly). + // property. Verify via reflection that the setter's type hint includes DateTime + // (confirming the output type was wired correctly). $reflection = new ReflectionClass($className); $setterParam = $reflection->getMethod('setFilteredProperty')->getParameters()[0]; $this->assertStringContainsString('DateTime', (string) $setterParam->getType()); @@ -1715,7 +1849,6 @@ public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void /** * Transforming filter callable that accepts mixed and returns DateTime. - * Used for FC-A1. */ public static function filterMixedToDateTime(mixed $value): DateTime { @@ -1731,11 +1864,11 @@ public static function serializeMixedToDateTime(DateTime $value): string } // ------------------------------------------------------------------------- - // Phase 3: validator priority reassignment around transforming filters + // Validator priority reassignment around transforming filters // ------------------------------------------------------------------------- /** - * Phase 3 core test: with a string→int transforming filter, schema validators that are + * With a string→int transforming filter, schema validators that are * registered for input types (pattern → string-space) must run PRE-transform, while * validators registered for output types (minimum → int-space) must run POST-transform. * @@ -1801,10 +1934,10 @@ public function testValidatorPriorityReassignmentAroundTransformingFilter(): voi } /** - * Phase 3 regression guard: a non-transforming filter must not trigger any priority - * reassignment. The existing TrimAsStringWithLengthValidation schema exercises this by - * verifying that minLength validates the *trimmed* value (i.e. the validator runs after - * trim, not before it). Re-running that assertion here makes the regression explicit. + * Regression guard: a non-transforming filter must not trigger any priority reassignment. + * The existing TrimAsStringWithLengthValidation schema exercises this by verifying that + * minLength validates the *trimmed* value (i.e. the validator runs after trim, not before + * it). Re-running that assertion here makes the regression explicit. */ public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): void { @@ -1823,6 +1956,773 @@ public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): v } } + // ------------------------------------------------------------------------- + // Composition runtime integration around transforming filters + // ------------------------------------------------------------------------- + + /** + * Input-space allOf runs PRE-transform. + * + * Schema: { type: string, filter: dateTime, allOf: [{minLength: 5}] } + * + * With the parent property typed as "string", minLength:5 is inherited into the branch + * and the validator fires. Before the fix the allOf ran POST-transform: DateTime is not + * a string so minLength never fires, and even a too-short string slipped through. After + * the fix the allOf runs on the raw input string before the filter. + * + * Observable proof: "2024" (4 chars < 5) throws AllOfException. Post-transform that + * input would produce a DateTime and the minLength check would silently skip. + * + * Already-transformed value: when a DateTime is supplied directly the input-space allOf is + * skipped entirely. + */ + public function testInputSpaceAllOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "20240101" (8 chars ≥ 5): allOf passes pre-transform → filter → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024" (4 chars < 5): allOf fails pre-transform → AllOfException. + // If the allOf ran POST-transform it would see a DateTime, minLength would skip + // (is_string check fails), and no exception would be thrown. + // Single branch (minLength:5) fails → succeeded=0. + try { + new $className(['filteredProperty' => '2024']); + $this->fail('Expected AllOfException for "2024"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime skips the input-space allOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space allOf runs POST-transform. + * + * Schema: { type: string, filter: stringToInt, allOf: [{type: integer, minimum: 0}] } + * + * {type: integer, minimum: 0} targets the filter's output type (int) → output-space. + * The allOf must run AFTER the filter, validating the transformed integer. + * + * Observable proof: "5" (a valid string) succeeds and produces 5. If the allOf ran + * pre-transform it would check the string "5" against {type: integer} and fail. + * "−5" produces AllOfException because minimum:0 rejects the negative transformed int. + * + * Already-transformed value: int 5 supplied directly skips the filter; the output-space allOf + * still runs and passes. + * + * Note: the explicit type:string means FilterProcessor can call applyOutputType immediately + * at filter-processing time. Compare testOutputOnlyAllOfCompositionRunsPostTransform where + * the absent base type defers output-type assignment to TransformingFilterOutputTypePostProcessor. + */ + public function testOutputSpaceAllOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": filter → 5 → allOf {type:integer, minimum:0} passes. + // If allOf ran pre-transform, "5" (string) would fail {type:integer} → AllOfException. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "-5": filter → −5 → allOf minimum:0 fails → AllOfException. + // Single branch (minimum:0) fails → succeeded=0. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected AllOfException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 5 → filter skipped → output-space allOf still runs and passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + } + + /** + * Mixed-space allOf is split around the transforming filter. + * + * Schema: { filter: stringToInt, allOf: [{type:string, minLength:1}, {type:integer, minimum:0}] } + * - {type:string, minLength:1} is input-space (string constraint, runs PRE-transform). + * - {type:integer, minimum:0} is output-space (int constraint, runs POST-transform). + * + * Validated behaviours: + * (a) "5" → pre-allOf passes (string, len≥1), filter→5, post-allOf passes (int≥0) → 5. + * (b) "" → pre-allOf fails (minLength:1) → AllOfException before the filter. + * (c) "-5" → pre-allOf passes (len≥1), filter→-5, post-allOf fails (minimum:0) → AllOfException. + * (d) 5 → already-int, skip pre-allOf, post-allOf passes → 5. + * (e) -5 → already-int, skip pre-allOf, post-allOf fails → AllOfException. + */ + public function testMixedSpaceAllOfSplitAroundTransformingFilter(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfMixedSpaces.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // (a) Valid string — both spaces satisfied. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // (b) Empty string — input-space minLength:1 fails before filter runs. + // Pre-subset has one branch (minLength:1); it fails → succeeded=0. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected AllOfException for ""'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // (c) "-5" passes input-space (len≥1) but transforms to -5 which fails minimum:0. + // Post-subset has one branch (minimum:0); it fails → succeeded=0. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected AllOfException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // (d) Already-int 5: skip input pipeline, post-allOf minimum:0 passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + + // (e) Already-int -5: skip input pipeline, post-allOf minimum:0 fails → succeeded=0. + try { + new $className(['filteredProperty' => -5]); + $this->fail('Expected AllOfException for -5 (already-int)'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * Input-space anyOf runs PRE-transform. + * + * Schema: { filter: dateTime, anyOf: [{type: string}, {type: integer}] } + * + * Both branches are input-space (string and integer are both in the dateTime filter's + * accepted-type set). The anyOf must run on the raw value before the filter. + * + * Observable proof: "2024-01-01" succeeds (type:string branch passes pre-transform). + * If anyOf ran POST-transform on DateTime, neither branch would pass (DateTime is not a + * string or integer) → AnyOfException. Receiving a DateTime confirms pre-transform. + * + * Already-transformed value: DateTime supplied directly skips the input-space anyOf. + */ + public function testInputSpaceAnyOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAnyOfInputOnly.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "2024-01-01" is a string → type:string branch passes → anyOf passes → DateTime. + // Proof: if anyOf ran POST-transform, DateTime would fail both {type:string} and + // {type:integer}, causing AnyOfException. Success proves pre-transform. + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Already-transformed DateTime skips the input-space anyOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Input-space oneOf runs PRE-transform. + * + * Schema: { type: string, filter: dateTime, oneOf: [{minLength: 5}, {maxLength: 3}] } + * + * With type:string inherited, the validators fire on the raw string. Exactly one branch + * must pass for oneOf to succeed. + * + * Observable proof: "20240101" (8 chars) passes only {minLength:5} → exactly one → + * oneOf passes → DateTime. Post-transform, DateTime is not a string so both minLength and + * maxLength skip (is_string check fails), both branches "pass", two pass → OneOfException. + * Receiving a DateTime proves the oneOf ran on the raw string. + * + * "2024" (4 chars) fails both branches → OneOfException pre-transform. + * + * Already-transformed value: DateTime supplied directly skips the input-space oneOf. + */ + public function testInputSpaceOneOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionOneOfInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "20240101" (8 chars): minLength:5 passes, maxLength:3 fails → 1 match → DateTime. + // If oneOf ran POST-transform, is_string(DateTime)=false → both branches pass → + // 2 matches → OneOfException. Success proves pre-transform. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024" (4 chars): minLength:5 fails, maxLength:3 fails → 0 matches → OneOfException. + // Both branches fail for the 4-char string → succeeded=0. + try { + new $className(['filteredProperty' => '2024']); + $this->fail('Expected OneOfException for "2024"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime skips the input-space oneOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Input-space if/then/else runs PRE-transform. + * + * Schema: { type: string, filter: dateTime, + * if: {minLength: 8}, then: {maxLength: 20}, else: {minLength: 1} } + * + * With type:string inherited into every sub-schema, all validators fire on the raw string. + * + * Observable proof: "" (0 chars) triggers ConditionalException pre-transform. + * - if minLength:8 fails → $ifException; else minLength:1 also fails → $elseException + * - ConditionalException thrown. + * Post-transform, DateTime is not a string so both minLength checks would skip, the + * if-branch would "pass", the then-branch would "pass", and no exception would be thrown. + * Getting a ConditionalException for "" proves the conditional ran on the raw string. + * + * Already-transformed value: DateTime supplied directly skips the input-space conditional. + */ + public function testInputSpaceIfThenElseCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenElseInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "20240101" (8 chars): if minLength:8 passes → then maxLength:20 passes → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "" (0 chars): if minLength:8 fails, else minLength:1 fails → ConditionalException. + // Post-transform, DateTime is not a string, minLength checks would silently skip and + // both branches would pass — no exception. The exception proves pre-transform. + // if-branch fails → ifException set; else-branch fails → elseException set; then not evaluated. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected ConditionalException for ""'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNotNull($exception->getIfException()); + $this->assertNull($exception->getThenException()); + $this->assertNotNull($exception->getElseException()); + } + + // Already-transformed DateTime skips the input-space conditional. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space anyOf runs POST-transform. + * + * Schema: { type: string, filter: stringToInt, + * anyOf: [{type:integer, min:0, max:10}, {type:integer, min:20, max:30}] } + * + * Both branches declare type:integer — output-space for the string→int filter. + * The anyOf must run AFTER the filter, validating the transformed integer. + * + * Observable proof: "5" → filter → 5 → {min:0,max:10} passes → anyOf passes → result 5. + * If the anyOf ran PRE-transform, "5" (string) would fail type:integer on both branches → + * AnyOfException. Success proves post-transform execution. + * + * "15" → filter → 15 → neither branch passes → AnyOfException (proves it ran on the integer). + * + * Already-transformed value: int 5 directly skips the filter; the output-space anyOf still runs. + */ + public function testOutputSpaceAnyOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAnyOfOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": filter → 5 → {min:0, max:10} passes → anyOf passes. + // If anyOf ran pre-transform, "5" (string) would fail type:integer → AnyOfException. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "15": filter → 15 → neither branch passes → AnyOfException. + // Proves anyOf ran on the integer (15 is out of both ranges); both fail → succeeded=0. + // Branch 0 (max:10) rejects 15 → "must not be larger than 10" in message. + try { + new $className(['filteredProperty' => '15']); + $this->fail('Expected AnyOfException for "15"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AnyOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 5 → filter skipped → output-space anyOf still runs and passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + } + + /** + * Output-space oneOf runs POST-transform. + * + * Schema: { type: string, filter: stringToInt, + * oneOf: [{type:integer, min:0, max:10}, {type:integer, min:20, max:30}] } + * + * Both branches declare type:integer — output-space for the string→int filter. + * The oneOf must run AFTER the filter, validating the transformed integer. + * + * Observable proof: "5" → filter → 5 → exactly {min:0,max:10} passes → oneOf passes. + * If the oneOf ran PRE-transform, "5" (string) would fail type:integer on both branches → + * 0 pass → OneOfException. Success proves post-transform execution. + * + * "7" → filter → 7 → both branches pass (0≤7≤10, but 7<20 fails second) wait — + * 7 is in [0,10] only; second branch [20,30] fails. Exactly 1 pass → oneOf passes. + * "25" → 25 → {min:20,max:30} passes, {min:0,max:10} fails → 1 pass → oneOf passes. + * "15" → 15 → neither branch passes → OneOfException. + * + * Already-transformed value: int 5 directly skips the filter; the output-space oneOf still runs. + */ + public function testOutputSpaceOneOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionOneOfOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": filter → 5 → {min:0, max:10} passes, {min:20, max:30} fails → exactly 1 → oneOf passes. + // If oneOf ran pre-transform, "5" (string) fails type:integer on both → OneOfException. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "15": filter → 15 → neither branch passes → OneOfException. + // Proves oneOf ran on the integer; both branches fail → succeeded=0. + // Branch 0 (max:10) rejects 15 → "must not be larger than 10" in message. + try { + new $className(['filteredProperty' => '15']); + $this->fail('Expected OneOfException for "15"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 5 → filter skipped → output-space oneOf still runs and passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + } + + /** + * Output-space if/then/else runs POST-transform. + * + * Schema: { type: string, filter: stringToInt, + * if: {type:integer, minimum:0}, then: {type:integer, maximum:100}, + * else: {type:integer, minimum:-100} } + * + * All branches declare type:integer — output-space for the string→int filter. + * The conditional must run AFTER the filter, validating the transformed integer. + * + * Observable proof: "50" → filter → 50 → if:{min:0} passes → then:{max:100} passes → 50. + * If the conditional ran PRE-transform, "50" (string) would fail type:integer on the if-branch → + * $ifException set → then-branch skipped → else:{type:integer,min:-100}: "50" fails type:integer → + * $elseException set → ConditionalException. Success proves post-transform execution. + * + * "200" → 200 → if passes (200≥0) → then:{max:100} fails → ConditionalException. + * "-200" → -200 → if fails (−200<0) → else:{min:-100} fails (−200<−100) → ConditionalException. + * + * Already-transformed value: int 50 directly skips the filter; the output-space conditional still runs. + */ + public function testOutputSpaceIfThenElseCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenElseOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "50": filter → 50 → if:{min:0} passes → then:{max:100} passes → success. + // If conditional ran pre-transform, "50" (string) fails type:integer on if-branch → elseException + // path → else:{type:integer,min:-100} also fails string → ConditionalException. + $object = new $className(['filteredProperty' => '50']); + $this->assertSame(50, $object->getFilteredProperty()); + + // "-5": filter → -5 → if:{min:0} fails → else:{min:-100} passes (-5 ≥ -100) → success. + $object = new $className(['filteredProperty' => '-5']); + $this->assertSame(-5, $object->getFilteredProperty()); + + // "200": filter → 200 → if passes (200≥0) → ifException=null; then fails (200>100) → thenException set. + // then:{max:100} fails → "must not be larger than 100" embedded in message. + try { + new $className(['filteredProperty' => '200']); + $this->fail('Expected ConditionalException for "200"'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNull($exception->getIfException()); + $this->assertNotNull($exception->getThenException()); + $this->assertNull($exception->getElseException()); + } + + // "-200": filter → -200 → if fails (-200<0) → ifException set; else:{min:-100} also fails → elseException set. + try { + new $className(['filteredProperty' => '-200']); + $this->fail('Expected ConditionalException for "-200"'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNotNull($exception->getIfException()); + $this->assertNull($exception->getThenException()); + $this->assertNotNull($exception->getElseException()); + } + + // Already-transformed int 50 → filter skipped → output-space conditional still runs and passes. + $object = new $className(['filteredProperty' => 50]); + $this->assertSame(50, $object->getFilteredProperty()); + } + + /** + * Mixed-space allOf split in collect-errors mode collects errors from both subsets. + * + * Schema: { filter: stringToInt, allOf: [{type:string, minLength:1}, {type:integer, minimum:0}] } + * + * In collect-errors mode validation continues after each failure, so: + * - A pre-transform error (minLength) and a post-transform error (minimum) are each + * independently collected. + * - Success cases still produce the correct transformed value. + */ + public function testMixedSpaceAllOfSplitWithCollectErrors(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfMixedSpaces.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": both spaces satisfied → no errors. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "": pre-allOf minLength:1 fails → ErrorRegistryException containing AllOfException. + // One pre-subset AllOfException (1 branch, 0 pass) → "must not be shorter than 1". + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected ErrorRegistryException for ""'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // "-5": pre-allOf passes (len≥1), filter→-5, post-allOf minimum:0 fails → + // ErrorRegistryException containing one AllOfException → "must not be smaller than 0". + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected ErrorRegistryException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * Object-returning transforming filter with an empty-schema allOf branch. + * + * Schema: { filter: dateTime, allOf: [{type: object}] } + * + * The `type: object` constraint in the allOf validates the RAW input (it classifies as + * input-space because the type keyword never uses the object-expansion of effective output + * types). A string therefore fails the allOf even though the filter would convert it to a + * DateTime. Only values that ARE already objects bypass the type check via the + * pre-transform guard. + * + * For the objects that do reach the post-filter stage, the property-level extended + * instanceof check narrows acceptance to the filter's declared output type (DateTime), + * rejecting unrelated objects such as stdClass. + * + * Observable proof: + * - "2024-01-01" → allOf type:object runs on raw string → fails → AllOfException. + * - DateTime directly → pre-transform guard fires → allOf skipped → passes. + * - stdClass → allOf type:object passes (is_object=true) but property-level instanceof + * check rejects it → InvalidInstanceOfException. + * - stdClass with collectErrors=true → error collected, no immediate throw. + */ + public function testObjectOutputTypeAllOfBranchTypeCheckRunsOnRawInput(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfObjectBranchOutput.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // String raw input: allOf {type:object} runs on the string → fails. + // Branch 0 ({type:object}) rejects the string → succeeded=0; nested message identifies the type mismatch. + try { + new $className(['filteredProperty' => '2024-01-01']); + $this->fail('Expected AllOfException for "2024-01-01"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + public function testObjectOutputTypeAllOfBranchAcceptsDirectObjectInput(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfObjectBranchOutput.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // Already-transformed DateTime → pre-transform guard fires → allOf skipped → passes. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + public function testObjectOutputTypeAllOfBranchRejectsForeignObject(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfObjectBranchOutput.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // stdClass: allOf type:object passes (is_object=true), but the property-level + // extended instanceof check narrows acceptance to DateTime only. + $this->expectException(InvalidInstanceOfException::class); + $this->expectExceptionMessage('Requires DateTime, got stdClass'); + new $className(['filteredProperty' => new \stdClass()]); + } + + public function testObjectOutputTypeAllOfBranchCollectsErrorForForeignObject(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfObjectBranchOutput.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // stdClass with collectErrors=true: InvalidInstanceOfException is added to the + // error registry rather than thrown immediately. + $exception = null; + try { + new $className(['filteredProperty' => new \stdClass()]); + } catch (ErrorRegistryException $registryException) { + $exception = $registryException; + } + + $this->assertNotNull($exception); + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertInstanceOf(InvalidInstanceOfException::class, $errors[0]); + $this->assertStringContainsString('Requires DateTime, got stdClass', $errors[0]->getMessage()); + $this->assertSame('DateTime', $errors[0]->getExpectedClass()); + } + public static function convertStringToInt(string $value): int { return (int) $value; @@ -1832,4 +2732,187 @@ public static function serializeIntToString(int $value): string { return (string) $value; } + + /** + * Non-transforming filter + allOf: the allOf validates the POST-filter (trimmed) value. + * + * Schema: { type: string, filter: trim, allOf: [{minLength: 5}] } + * + * Since trim is non-transforming, no priority reassignment occurs. The allOf validator + * runs after trim (default priority 100 > trim priority ~10), so minLength fires against + * the already-trimmed string. + * + * Observable proof: " hi " trims to "hi" (2 chars) and fails minLength:5 → AllOfException. + * If the allOf ran pre-trim, " hi " has 8 chars and would pass minLength:5. + */ + public function testNonTransformingFilterWithAllOfValidatesAfterFilter(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfWithTrim.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // " hello " trims to "hello" (5 chars) → allOf minLength:5 passes. + $object = new $className(['filteredProperty' => ' hello ']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // " hi " trims to "hi" (2 chars) → allOf minLength:5 fails → AllOfException. + // If allOf ran pre-trim, the 8-char padded string would pass minLength:5. + // Single branch (minLength:5) fails → succeeded=0. + try { + new $className(['filteredProperty' => ' hi ']); + $this->fail('Expected AllOfException for " hi "'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * Input-space not runs PRE-transform. + * + * Schema: { type: string, filter: dateTime, not: { minLength: 5 } } + * + * The not inner schema { minLength: 5 } is input-space (string-targeted keyword) and is + * moved to run before the filter. The not passes when the inner schema FAILS. + * + * Observable proof: "2024-01-01" (10 chars, valid date) → inner minLength:5 passes → not + * violated → NotException. Post-transform, DateTime is not a string so minLength would skip, + * the branch would "fail" (0 chars?), not would pass, and no exception would be thrown. + * Getting a NotException for "2024-01-01" proves the not ran on the raw string. + * + * A valid date string is used so the filter itself does not fail, keeping the error registry + * to exactly one NotException (the not violation). + * + * Already-transformed value: DateTime directly skips the input-space not. + */ + public function testInputSpaceNotCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionNotInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "now" (3 chars): not { minLength: 5 } → inner fails (3 < 5) → not passes → DateTime. + $object = new $className(['filteredProperty' => 'now']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024-01-01" (10 chars, valid date): not { minLength: 5 } → inner passes → not violated. + // Post-transform, DateTime is not a string, minLength silently skips, inner "fails", + // not passes — no exception. The NotException proves the not ran on the raw string. + // A valid date string keeps the filter from failing, so the registry holds exactly one error. + // Inner schema (minLength:5) passes → succeeded=1 → composition element is Valid. + try { + new $className(['filteredProperty' => '2024-01-01']); + $this->fail('Expected NotException for "2024-01-01"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime → input-space not skipped → passes. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space not runs POST-transform. + * + * Schema: { filter: stringToInt, not: { minimum: 0 } } + * + * The not inner schema { minimum: 0 } is output-space (int-targeted keyword) and stays at + * its default post-transform position. The not passes when the inner schema FAILS. + * + * Observable proof: "5" → filter → 5 → not { minimum: 0 }: 5 ≥ 0 → inner passes → not + * violated → NotException. If the not ran pre-transform, minimum would not apply to the + * string "5" (is_int check fails), the inner schema would fail, and not would pass — no + * exception. The exception proves post-transform execution. + * + * Already-transformed value: int directly skips the filter; the output-space not still runs. + */ + public function testOutputSpaceNotCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionNotOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "-5": filter → -5 → not { minimum: 0 }: -5 < 0 → inner fails → not passes → -5. + $object = new $className(['filteredProperty' => '-5']); + $this->assertSame(-5, $object->getFilteredProperty()); + + // "5": filter → 5 → not { minimum: 0 }: 5 ≥ 0 → inner passes → not violated → NotException. + // If not ran pre-transform, minimum would skip for the string "5", inner fails, not passes. + // The exception proves not ran on the transformed integer; inner succeeds → succeeded=1. + try { + new $className(['filteredProperty' => '5']); + $this->fail('Expected NotException for "5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int -5 → filter skipped → not { minimum: 0 }: -5 < 0 → inner fails → passes. + $object = new $className(['filteredProperty' => -5]); + $this->assertSame(-5, $object->getFilteredProperty()); + + // Already-transformed int 5 → filter skipped → not { minimum: 0 }: 5 ≥ 0 → not violated. + // Inner schema succeeds → succeeded=1 → composition element is Valid. + try { + new $className(['filteredProperty' => 5]); + $this->fail('Expected NotException for already-transformed int 5'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + } } diff --git a/tests/Objects/BasePropertyPrecedenceTest.php b/tests/Objects/BasePropertyPrecedenceTest.php index 9cb2cd26..47a58512 100644 --- a/tests/Objects/BasePropertyPrecedenceTest.php +++ b/tests/Objects/BasePropertyPrecedenceTest.php @@ -331,7 +331,7 @@ public function testAllOfIntersectionNarrowsMultiTypeToSubset(): void * Root `age: [integer, string, null]` (required, nullable=true), allOf branch `age: [integer, string]` * (required in branch, nullable=null). Intersection of effective sets = [integer, string] (no null), * but existing.isNullable()===true → explicit nullable is preserved → result is int|string|null. - * This covers the explicit-nullable preservation path (lines 245–246) in applyAllOfIntersection. + * This covers the explicit-nullable preservation path in applyAllOfIntersection. * * @throws FileSystemException * @throws RenderException diff --git a/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php index ebbdc005..6348ed08 100644 --- a/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php +++ b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php @@ -84,19 +84,23 @@ public function testTypeBranchStringClassifiesAsInput(): void ); } - public function testTypeBranchObjectClassifiesAsOutput(): void + public function testTypeBranchObjectClassifiesAsInput(): void { - // DateTime is an object — `type: object` targets the output space. + // `type` validates raw JSON value structure — not the post-filter PHP class instance. + // 'object' is not in the declared output types (['DateTime']), so the type constraint + // is input-space regardless of the object-expansion used for other keywords. $this->assertSame( - TypeSpace::Output, + TypeSpace::Input, $this->classifierForStringToDateTime()->classify(['type' => 'object']), ); } - public function testTypeBranchMultipleSpanningBothSpacesClassifiesAsMixed(): void + public function testTypeBranchStringAndObjectBothClassifyAsInput(): void { + // 'string' → Input (in inputTypes); 'object' → not in declared outputTypes → Empty → Input. + // Net contribution: Input only → branch is Input. $this->assertSame( - TypeSpace::Mixed, + TypeSpace::Input, $this->classifierForStringToDateTime()->classify(['type' => ['string', 'object']]), ); } @@ -319,23 +323,43 @@ public function testNestedAllOfWithInputBranchClassifiesAsInput(): void ); } - public function testNestedAllOfWithOutputBranchClassifiesAsOutput(): void + public function testNestedAllOfWithAllOutputKeywordBranchesClassifiesAsOutput(): void { + // Both branches use object-targeted keywords (not the type keyword), which + // classify as Output via getEffectiveOutputTypes(). $this->assertSame( TypeSpace::Output, $this->classifierForStringToDateTime()->classify([ 'allOf' => [ - ['type' => 'object'], ['minProperties' => 1], + ['maxProperties' => 10], ], ]), ); } - public function testNestedAllOfWithCrossSpaceBranchesClassifiesAsMixed(): void + public function testNestedAllOfWithTypeObjectBranchAndOutputKeywordClassifiesAsMixed(): void { + // type:object → Input (type keyword uses declared output types, 'object' ∉ ['DateTime']). + // minProperties → Output (object-targeted keyword; effective output includes 'object'). + // Mix → Mixed. $this->assertSame( TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'allOf' => [ + ['type' => 'object'], + ['minProperties' => 1], + ], + ]), + ); + } + + public function testNestedAllOfWithTwoInputTypeBranchesClassifiesAsInput(): void + { + // type:string → Input; type:object → Input (type keyword uses declared output types, + // 'object' ∉ ['DateTime']). Both branches are Input → allOf is Input. + $this->assertSame( + TypeSpace::Input, $this->classifierForStringToDateTime()->classify([ 'allOf' => [ ['type' => 'string'], @@ -374,10 +398,26 @@ public function testNestedOneOfWithAllInputBranchesClassifiesAsInput(): void ); } - public function testNestedAnyOfWithAllOutputBranchesClassifiesAsOutput(): void + public function testNestedAnyOfWithAllOutputKeywordBranchesClassifiesAsOutput(): void { + // Both branches use object-targeted keywords (not type keyword) → both Output → Output. $this->assertSame( TypeSpace::Output, + $this->classifierForStringToDateTime()->classify([ + 'anyOf' => [ + ['minProperties' => 1], + ['maxProperties' => 10], + ], + ]), + ); + } + + public function testNestedAnyOfWithTypeObjectBranchAndOutputKeywordClassifiesAsMixed(): void + { + // type:object → Input (type keyword uses declared output types, 'object' ∉ ['DateTime']). + // minProperties → Output. Mix → Mixed. + $this->assertSame( + TypeSpace::Mixed, $this->classifierForStringToDateTime()->classify([ 'anyOf' => [ ['type' => 'object'], @@ -389,9 +429,10 @@ public function testNestedAnyOfWithAllOutputBranchesClassifiesAsOutput(): void public function testNestedCompositionCombinedWithOtherKeywordsContributes(): void { - // allOf is output-targeted; minLength is input-targeted → Mixed. + // allOf [{type: object}] → Input (type:object is Input; only branch → allOf is Input). + // minLength → Input. Both Input → branch is Input. $this->assertSame( - TypeSpace::Mixed, + TypeSpace::Input, $this->classifierForStringToDateTime()->classify([ 'allOf' => [['type' => 'object']], 'minLength' => 1, diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json b/tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json new file mode 100644 index 00000000..2a5bf70e --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + {} + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json b/tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json new file mode 100644 index 00000000..984b2dc6 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "allOf": [ + { + "minLength": 5 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json b/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json new file mode 100644 index 00000000..d34c8521 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "allOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "integer", + "minimum": 0 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json b/tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json new file mode 100644 index 00000000..a906dd32 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "type": "object" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json index af9a306d..0ccb5a5f 100644 --- a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json +++ b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json @@ -2,13 +2,15 @@ "type": "object", "properties": { "filteredProperty": { - "filter": "dateTime", + "filter": "stringToInt", "allOf": [ { - "type": "object" + "type": "integer", + "minimum": 0 }, { - "minProperties": 0 + "type": "integer", + "maximum": 100 } ] } diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json new file mode 100644 index 00000000..dccd40f0 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "stringToInt", + "allOf": [ + { + "type": "integer", + "minimum": 0 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json b/tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json new file mode 100644 index 00000000..1a62cd62 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "trim", + "allOf": [ + { + "minLength": 5 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json b/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json index f2195f1b..7fde7624 100644 --- a/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json @@ -8,7 +8,7 @@ "type": "string" }, { - "type": "object" + "minProperties": 1 } ] } diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json new file mode 100644 index 00000000..53cdf355 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "stringToInt", + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + { + "type": "integer", + "minimum": 20, + "maximum": 30 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json new file mode 100644 index 00000000..2881e0f9 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "anyOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json new file mode 100644 index 00000000..948a3a15 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "if": { + "filter": "trim" + }, + "then": { + "minLength": 5 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json new file mode 100644 index 00000000..6fec336b --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "trim", + "if": { + "filter": "trim" + }, + "then": { + "minLength": 5 + }, + "else": { + "maxLength": 10 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json new file mode 100644 index 00000000..f76cd2b2 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "not": { + "filter": "trim" + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json new file mode 100644 index 00000000..7bdb6897 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "oneOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json b/tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json new file mode 100644 index 00000000..b924d92f --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "if": { + "minLength": 8 + }, + "else": { + "minLength": 1 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json index eaf431c9..6c6ef28c 100644 --- a/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json @@ -7,7 +7,7 @@ "type": "string" }, "then": { - "type": "object" + "minProperties": 1 } } } diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json new file mode 100644 index 00000000..3471115c --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "if": { + "minLength": 8 + }, + "then": { + "maxLength": 20 + }, + "else": { + "minLength": 1 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json new file mode 100644 index 00000000..3162c916 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "stringToInt", + "if": { + "type": "integer", + "minimum": 0 + }, + "then": { + "type": "integer", + "maximum": 100 + }, + "else": { + "type": "integer", + "minimum": -100 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json new file mode 100644 index 00000000..4b4ac1c9 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "if": { + "minLength": 8 + }, + "then": { + "maxLength": 20 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionNotInputSpace.json b/tests/Schema/FilterTest/FilterCompositionNotInputSpace.json new file mode 100644 index 00000000..ec70cf49 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionNotInputSpace.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "not": { + "minLength": 5 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json new file mode 100644 index 00000000..8242b906 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "not": { + "type": "integer", + "minimum": 0 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json b/tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json new file mode 100644 index 00000000..5f17c652 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "oneOf": [ + { + "minLength": 5 + }, + { + "maxLength": 3 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json new file mode 100644 index 00000000..22165300 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "stringToInt", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + { + "type": "integer", + "minimum": 20, + "maximum": 30 + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..c5430434 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "anyOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } + ] +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..9970a0d2 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "if": { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + }, + "then": {} +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..9c0488bf --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "minLength": 1 + } + } + } + ] +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..b7763861 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "not": { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..f34c8e81 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "oneOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } + ] +} From 7d04aefbae403110fd53074ddd31fb29930f9bb3 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 12 May 2026 15:18:13 +0200 Subject: [PATCH 05/11] =?UTF-8?q?Phase=205:=20silent=20cast=20bug=20fix=20?= =?UTF-8?q?=E2=80=94=20FormatValidator=20type=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormatValidator.getCheck() now prepends is_string($value) && to its generated check, matching the existing pattern of every other string-space validator (pattern, minLength, maxLength). Without the guard, a property with type [string, integer], a format validator, and a transforming filter would call FormatValidatorFromRegEx::validate(int) under strict types when an already-transformed integer was passed, throwing TypeError instead of the expected post-transform validation exception. Adds testFormatValidatorOnMultiTypePropertyDoesNotFireForAlreadyTransformedValue and its schema MultiTypeFormatWithTransformingFilter.json to cover the four cases: valid string input, invalid string input (FormatException), already- transformed negative int (MinimumException, not TypeError), and already- transformed valid int (passes). Co-Authored-By: Claude Sonnet 4.6 --- src/Model/Validator/FormatValidator.php | 4 +- tests/Basic/FilterTest.php | 60 +++++++++++++++++++ ...MultiTypeFormatWithTransformingFilter.json | 14 +++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json diff --git a/src/Model/Validator/FormatValidator.php b/src/Model/Validator/FormatValidator.php index 6b536d06..22a9607c 100644 --- a/src/Model/Validator/FormatValidator.php +++ b/src/Model/Validator/FormatValidator.php @@ -34,12 +34,14 @@ public function __construct( */ public function getCheck(): string { - return $this->validator instanceof FormatValidatorFromRegEx + $call = $this->validator instanceof FormatValidatorFromRegEx ? sprintf( '!\%s::validate($value, %s)', $this->validator::class, var_export($this->validator->getPattern(), true), ) : sprintf('!\%s::validate($value)', $this->validator::class); + + return 'is_string($value) && ' . $call; } } diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 3fc28002..07f6ffc4 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -18,8 +18,10 @@ use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Exception\Number\MinimumException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\String\FormatException; use PHPModelGenerator\Exception\String\PatternException; use PHPModelGenerator\Exception\ValidationException; +use PHPModelGenerator\Format\FormatValidatorFromRegEx; use PHPModelGenerator\Filter\FilterInterface; use PHPModelGenerator\Filter\TransformingFilterInterface; use PHPModelGenerator\Filter\Trim; @@ -1956,6 +1958,64 @@ public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): v } } + /** + * A format validator (registered under the string type, hence input-space) that is moved + * pre-filter must not run when the property value is already in the filter's output + * type-space. FormatValidatorFromRegEx::validate() declares a string parameter under + * strict types, so calling it with an already-transformed int throws TypeError rather than + * a validation exception. + * + * Schema: { type: [string, integer], format: onlyNumbers, filter: stringToInt, minimum: 0 } + * + * Bug: int -5 arrives as an already-transformed value; the moved format validator fires + * against the int and throws TypeError instead of the expected MinimumException. + * + * Fix: the skip guard prepended to moved input-space validators prevents the format check + * from running when the value is already in the output type-space. + */ + public function testFormatValidatorOnMultiTypePropertyDoesNotFireForAlreadyTransformedValue(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFormat('onlyNumbers', new FormatValidatorFromRegEx('/^\d+$/')) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )); + + $className = $this->generateClassFromFile( + 'MultiTypeFormatWithTransformingFilter.json', + $configuration, + ); + + // String "42": format passes pre-transform, filter converts to int 42, minimum passes. + $object = new $className(['value' => '42']); + $this->assertSame(42, $object->getValue()); + + // String "hello": format check fires against the raw string → FormatException. + try { + new $className(['value' => 'hello']); + $this->fail('Expected FormatException for string input "hello"'); + } catch (FormatException $formatException) { + $this->assertStringContainsString('onlyNumbers', $formatException->getMessage()); + } + + // Already-transformed int -5: skip guard bypasses format check (no TypeError), + // execution reaches minimum and correctly throws MinimumException. + try { + new $className(['value' => -5]); + $this->fail('Expected MinimumException for int input -5'); + } catch (MinimumException $minimumException) { + $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); + } + + // Already-transformed int 42: skip guard bypasses format check, minimum passes. + $object = new $className(['value' => 42]); + $this->assertSame(42, $object->getValue()); + } + // ------------------------------------------------------------------------- // Composition runtime integration around transforming filters // ------------------------------------------------------------------------- diff --git a/tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json b/tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json new file mode 100644 index 00000000..72451a28 --- /dev/null +++ b/tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "value": { + "type": [ + "string", + "integer" + ], + "format": "onlyNumbers", + "filter": "stringToInt", + "minimum": 0 + } + } +} From 13c774bc96376435a1b76330cf12732bc43bd841 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 12 May 2026 17:01:20 +0200 Subject: [PATCH 06/11] Phase 6: replace reflection workaround with runtime assertions testAllOfPropertyWithMixedAcceptTransformingFilter previously used ReflectionClass to verify the setter's type hint as a proxy for the output type being wired correctly. Replace it with three direct runtime assertions: - Valid string input passes the input-space allOf and is transformed to DateTime by the filter. - Integer input via the constructor (which bypasses the setter type hint) reaches the allOf validator directly and produces AllOfException with the full composition error message. - An already-constructed DateTime is accepted as-is via the R-8 passthrough, skipping the pre-transform pipeline. Co-Authored-By: Claude Sonnet 4.6 --- tests/Basic/FilterTest.php | 43 +++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php index 461dd8ae..5b8875de 100644 --- a/tests/Basic/FilterTest.php +++ b/tests/Basic/FilterTest.php @@ -6,7 +6,6 @@ use DateTime; use Exception; -use ReflectionClass; use RuntimeException; use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\ComposedValue\AnyOfException; @@ -1822,15 +1821,15 @@ public function testOutputOnlyAllOfCompositionRunsPostTransform(): void * resolution), so FilterProcessor skips applyOutputType. After composition is resolved the * TransformingFilterOutputTypePostProcessor sets the output type. * - * Exercises the path where accepted types are empty and the post-processor must compute - * and set the output type on the property. + * The allOf branch {type:string} is classified as input-space, so it runs pre-transform. + * The filter then converts the string to DateTime. No post-transform composition exists. */ public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void { $className = $this->generateClassFromFile( 'AllOfPropertyWithMixedAcceptTransformingFilter.json', (new GeneratorConfiguration()) - ->setCollectErrors(false) + ->setCollectErrors(true) ->setImmutable(false) ->addFilter( $this->getCustomTransformingFilter( @@ -1841,12 +1840,36 @@ public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void ), ); - // Generation succeeds. The post-processor set the output type to DateTime for this - // property. Verify via reflection that the setter's type hint includes DateTime - // (confirming the output type was wired correctly). - $reflection = new ReflectionClass($className); - $setterParam = $reflection->getMethod('setFilteredProperty')->getParameters()[0]; - $this->assertStringContainsString('DateTime', (string) $setterParam->getType()); + // Valid string input: input-space allOf passes, filter transforms to DateTime. + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Non-string raw input via constructor: the constructor bypasses the setter type hint, + // so 42 reaches validateFilteredProperty directly. The input-space allOf fires pre-transform + // and rejects it with AllOfException (not a TypeError). + try { + new $className(['filteredProperty' => 42]); + $this->fail('Expected AllOfException for non-string raw input'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-constructed DateTime: pre-transform pipeline skipped (R-8), accepted as-is. + $existingDateTime = new DateTime('2024-06-01'); + $object->setFilteredProperty($existingDateTime); + $this->assertSame($existingDateTime, $object->getFilteredProperty()); } /** From fe3a53b8b962c86c3df363d2e9dd8118a160f877 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 15 May 2026 12:44:48 +0200 Subject: [PATCH 07/11] Fix property-level composition type semantics; filter/composition phase 7 Property-level composition type semantics: - allOf on a property now uses intersection semantics: conflicting branch types throw SchemaException; int|number resolves to int via the integer-subtype-of- number rule, now centralised in the new TypeIntersection::compute utility - anyOf/oneOf with a truly untyped branch ({}) correctly leaves the property as mixed instead of wrongly adding nullable=true - Property-level if/then/else gains type widening (union of then+else types when the parent has no declared type) and conflict detection (SchemaException when the parent type is incompatible with both branches); object-level if/then/else with nested schemas is correctly excluded from both paths - TypeIntersection::compute extracted as a standalone PHPModelGenerator\Utils utility, replacing PropertyMerger::computeDeclaredIntersection - Dead data pruned from ComposedAllOfTest data providers Filter / composition ordering - phase 7: - Updated filter.rst with a new "Composition with transforming filters" section covering branch classification into Input/Output type-spaces, accepted and rejected composition combinations, and correct type-keyword usage - Fixed CompositionBranchClassifier: the type keyword now always classifies as Input - it validates raw JSON input and must never be routed against output types (a branch {type: integer} under a stringToInt filter must reject the raw string pre-transform, not pass it post-transform) - Fixed FilterProcessor::classifyComposedValidatorBranches to look up original pre-inheritPropertyType branch schemas, preventing branches that inherited a multi-type parent type from being misclassified as Mixed after injection Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 4 +- CLAUDE.md | 23 ++ docs/source/nonStandardExtensions/filter.rst | 70 +++- .../AbstractCompositionValidatorFactory.php | 167 ++++++++-- .../Composition/IfValidatorFactory.php | 139 ++++++++ src/Model/Validator/FilterValidator.php | 114 ++++++- .../Filter/CompositionBranchClassifier.php | 26 +- .../Filter/FilterProcessor.php | 75 ++++- src/Utils/PropertyMerger.php | 34 +- src/Utils/TypeIntersection.php | 39 +++ tests/Basic/FilterTest.php | 306 ++++++------------ tests/ComposedValue/ComposedAllOfTest.php | 93 +++++- tests/ComposedValue/ComposedAnyOfTest.php | 16 + tests/ComposedValue/ComposedIfTest.php | 99 ++++++ .../CompositionBranchClassifierTest.php | 45 ++- .../PropertyLevelAllOfConflictingTypes.json | 15 + .../PropertyLevelAllOfIntegerNumber.json | 15 + .../PropertyLevelAllOfUntypedBranch.json | 16 + .../PropertyLevelAnyOfUntypedBranch.json | 16 + .../PropertyLevelIfThenElseAbsentElse.json | 13 + ...opertyLevelIfThenElseConflictingTypes.json | 17 + ...rtyLevelIfThenElseParentTypePreserved.json | 17 + .../PropertyLevelIfThenElseTypeWidening.json | 18 ++ ...erCompositionAllOfContradictoryTypes.json} | 7 +- .../FilterCompositionAllOfDeadFilter.json | 13 + .../FilterCompositionAllOfMixedSpaces.json | 5 +- .../FilterCompositionAllOfOutputOnly.json | 6 +- .../FilterCompositionAnyOfOutputSpace.json | 7 +- ...ilterCompositionIfThenElseOutputSpace.json | 8 +- .../FilterCompositionNotOutputSpace.json | 5 +- .../FilterCompositionOneOfOutputSpace.json | 7 +- 31 files changed, 1106 insertions(+), 329 deletions(-) create mode 100644 src/Utils/TypeIntersection.php create mode 100644 tests/Schema/ComposedAllOfTest/PropertyLevelAllOfConflictingTypes.json create mode 100644 tests/Schema/ComposedAllOfTest/PropertyLevelAllOfIntegerNumber.json create mode 100644 tests/Schema/ComposedAllOfTest/PropertyLevelAllOfUntypedBranch.json create mode 100644 tests/Schema/ComposedAnyOfTest/PropertyLevelAnyOfUntypedBranch.json create mode 100644 tests/Schema/ComposedIfTest/PropertyLevelIfThenElseAbsentElse.json create mode 100644 tests/Schema/ComposedIfTest/PropertyLevelIfThenElseConflictingTypes.json create mode 100644 tests/Schema/ComposedIfTest/PropertyLevelIfThenElseParentTypePreserved.json create mode 100644 tests/Schema/ComposedIfTest/PropertyLevelIfThenElseTypeWidening.json rename tests/Schema/FilterTest/{FilterCompositionAllOfOutputSpace.json => FilterCompositionAllOfContradictoryTypes.json} (66%) create mode 100644 tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 73b47827..4e9bca34 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,9 @@ "WebFetch(domain:datatracker.ietf.org)", "Bash(for f:*)", "Bash(do)", - "Bash(done)" + "Bash(done)", + "Bash(rm /tmp/phpunit-output.txt)", + "Bash(grep -E \"FAIL|ERROR|WARN|Tests:\" /tmp/phpunit-output.txt; rm /tmp/phpunit-output.txt)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 2d90dc52..790643f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,14 @@ Rules: git history. - Update the plan file(s) as the work progresses — record decisions made, phases completed, and any pivots in approach. +- **Record every non-obvious design decision as it is made**: state the option chosen, every + alternative that was considered and rejected, and the reasoning that ruled each alternative out. + A rejected alternative that is not recorded can be silently re-introduced in a later session + when context is compressed. The record must be specific enough that a cold reader can reconstruct + *why* the chosen approach is correct — not just *what* it is. Example: "Classifying `type` + against `$outputTypes` was considered and rejected: a branch `{type: integer}` under a + `stringToInt` filter must validate the raw input, so routing it through output-type matching + would allow string `'50'` to pass a `type: integer` check post-transform." - Once a topic is **ready to merge**, delete the entire `.claude/issues//` or `.claude/topics//` directory and commit that deletion as the final commit on the branch, **before** merging to `master`. The tracking files are working notes and must never land on @@ -320,6 +328,21 @@ meaning. Describe *what the code does or why* — not where it came from in a planning document. +#### Name the rejected alternative in non-obvious comments + +When a decision is non-obvious — especially one where a "natural correction" would silently +re-introduce a wrong approach — the comment must name the rejected alternative and explain why it +fails, not just assert the chosen approach. + +- ❌ `// type keyword is always classified as Input` +- ✅ `// Always Input — do NOT classify against outputTypes. A branch {type: integer} under a + // stringToInt filter must validate the raw input; treating it as Output would allow string + // '50' to pass a type: integer check post-transform.` + +The same decision must also be covered by a test whose name encodes the specific scenario, so +that any regression surfaces immediately as a named, self-explaining failure rather than a +cryptic assertion error. + #### Test coverage Every identified edge case must have a corresponding test. During planning, enumerate all edge diff --git a/docs/source/nonStandardExtensions/filter.rst b/docs/source/nonStandardExtensions/filter.rst index 4093b903..581baf7d 100644 --- a/docs/source/nonStandardExtensions/filter.rst +++ b/docs/source/nonStandardExtensions/filter.rst @@ -88,7 +88,12 @@ Transforming filter You may keep it simple and skip this for your first tries and only experiment with non-transforming filters like the trim filter -Filters may change the type of the property. For example the builtin filter **dateTime** creates a DateTime object. Consequently further type-related validations like pattern checks for the string property won't be performed. If you use a transforming filter which transforms the value into another accepted type (eg. your property accepts ['string', 'integer'] and your transforming filter transforms provided strings into integers) the additional provided validators for integers (like minimum or maximum checks) will be executed (only if your property accepts integer values; if the property only accepts strings and the transforming filter converts them to integer values integer validators won't be added to the property). Additionally enum validations will not be executed if an already transformed value is provided. +Filters may change the type of the property. For example the builtin filter **dateTime** creates a DateTime object. When a transforming filter is present, the generator automatically classifies all validators on the property into two groups based on which type-space they target: + +- **Input-space validators** (e.g. ``pattern``, ``minLength`` for a string property) run *before* the filter, against the raw input value. +- **Output-space validators** (e.g. ``minimum``, ``maximum`` for an integer returned by a string-to-int filter) run *after* the filter, against the transformed value. + +This classification is derived from the Draft type registry and applies to both schema validators and composition branches (see `Composition with transforming filters`_ below). For multi-type properties (e.g. ``['string', 'integer']``) with a transforming filter, validators that target only the string type run pre-transform and validators that target only the integer type run post-transform. Additionally enum validations will not be executed if an already transformed value is provided. As the required check is executed before the filter a filter may transform a required value into a null value. Be aware when writing custom filters which transform values to not break your validation rules by adding filters to a property. @@ -100,6 +105,69 @@ The return type of the transforming filter will be used to define the type of th If you use a filter on a property which accepts multiple types (e.g. ``['string', 'null']`` or ``['string', 'integer']``), the filter only needs to overlap with **at least one** of those types. Values whose type is not accepted by the filter are silently skipped at runtime. Only if the filter's accepted types have *no overlap at all* with the property's types is a ``SchemaException`` raised at generation time. +Composition with transforming filters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Composition keywords (``allOf``, ``anyOf``, ``oneOf``, ``if``/``then``/``else``, ``not``) may be combined with a transforming filter on the same property. Each branch is automatically classified at generation time as targeting the **input type-space** or the **output type-space** of the filter: + +- **Input-space branches** are evaluated *before* the filter, against the raw input value. +- **Output-space branches** are evaluated *after* the filter, against the transformed value. + +A branch that spans both type-spaces raises a ``SchemaException`` at generation time because it cannot be placed correctly in either phase of the pipeline. The following additional constraints are also enforced: + +- A filter keyword inside any composition branch always raises a ``SchemaException``, regardless of whether the property itself carries a filter. The composition engine resets the value to the original input after each branch evaluation, which would silently discard any transformation applied inside the branch. +- ``anyOf`` / ``oneOf``: all branches must share a single type-space; cross-space branches raise a ``SchemaException``. +- ``not``: the inner schema must target a single type-space. +- ``if`` / ``then`` / ``else``: all three sub-schemas must share the same type-space. + +**Example — input-space allOf** (validates the raw string before the dateTime filter runs): + +.. code-block:: json + + { + "type": "object", + "properties": { + "scheduledAt": { + "type": "string", + "filter": "dateTime", + "allOf": [ + { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + ] + } + } + } + +The ``pattern`` constraint fires against the raw string *before* the ``dateTime`` filter transforms it to a ``DateTime`` object. Passing ``"hello"`` raises an ``AllOfException`` because the pattern fails. Passing ``"2024-01-01"`` passes the pattern and is then converted to a ``DateTime``. Passing an already-constructed ``DateTime`` object bypasses the pre-transform pipeline entirely and is accepted as-is. + +**Example — output-space allOf** (validates the integer *after* a string-to-int filter): + +.. code-block:: json + + { + "type": "object", + "properties": { + "quantity": { + "type": ["string", "integer"], + "filter": "stringToInt", + "allOf": [ + { + "minimum": 0, + "maximum": 100 + } + ] + } + } + } + +The property accepts both raw strings (transformed by the filter) and already-converted integers (which bypass the filter). The ``minimum`` and ``maximum`` constraints are output-space and fire against the final integer *after* the filter has run. Passing ``"50"`` is transformed to ``50`` and passes both constraints. Passing ``"200"`` is transformed to ``200`` and fails ``maximum``. Passing the already-transformed integer ``50`` directly skips the filter and is still validated by the output-space allOf. + +.. hint:: + + The ``type`` keyword inside a composition branch always validates against the **raw input value** (before the filter runs). For a ``stringToInt`` filter, a branch like ``{"type": "integer", "minimum": 0}`` mixes an input-space constraint (``type``) with an output-space constraint (``minimum``), which raises a ``SchemaException`` at generation time. Declare the type at the **property level** instead. + Exceptions from filter ---------------------- diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index 29d875b9..3b10cd41 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -21,6 +21,7 @@ use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\TypeIntersection; abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory { @@ -240,11 +241,23 @@ protected function inheritIfPropertyType(JsonSchema $propertySchema): JsonSchema } /** - * After all composition branches resolve, attempt to widen the parent property's type - * to cover all branch types. Skips for branches with nested schemas. + * After all composition branches resolve, derive the parent property's type from the + * branch types and apply it. Skips when any branch has a nested schema (object merging + * is handled elsewhere). * - * @param bool $isAllOf Whether allOf semantics apply (affects nullable detection). + * allOf: intersect all typed branch types — only values satisfying every branch simultaneously + * are valid, so the PHP type is the intersection. Branches with no declared type impose no + * constraint and are excluded from the intersection. An empty intersection (contradictory + * branch types) throws SchemaException because no value can ever be valid. + * + * anyOf / oneOf: union of all typed branch types — at least one branch must pass, so the PHP + * type is the union. An untyped branch accepts every value, making the composition satisfied + * by any input; the property's type hint is removed (remains mixed) in that case. + * + * @param bool $isAllOf true for allOf, false for anyOf/oneOf. * @param CompositionPropertyDecorator[] $compositionProperties + * + * @throws SchemaException when allOf branches declare contradictory types. */ protected function transferPropertyType( PropertyInterface $property, @@ -257,17 +270,6 @@ protected function transferPropertyType( } } - $allNames = array_merge(...array_map( - static fn(CompositionPropertyDecorator $p): array => - $p->getType() ? $p->getType()->getNames() : [], - $compositionProperties, - )); - - $hasBranchWithNoType = array_filter( - $compositionProperties, - static fn(CompositionPropertyDecorator $p): bool => $p->getType() === null, - ) !== []; - $hasBranchWithRequiredProperty = array_filter( $compositionProperties, static fn(CompositionPropertyDecorator $p): bool => $p->isRequired(), @@ -280,18 +282,147 @@ protected function transferPropertyType( static fn(CompositionPropertyDecorator $p): bool => !$p->isRequired(), ) !== []; - $hasNull = in_array('null', $allNames, true); + if ($isAllOf) { + $this->transferAllOfType($property, $compositionProperties, $hasBranchWithOptionalProperty); + return; + } + + $this->transferAnyOfOneOfType($property, $compositionProperties, $hasBranchWithOptionalProperty); + } + + /** + * Derive and apply the parent property's type using allOf intersection semantics. + * + * Only typed branches (those that declare a type keyword) constrain the intersection. + * Untyped branches impose no type restriction and are excluded. Null is valid only when + * ALL typed branches allow it. An empty non-null intersection (contradictory types) throws + * SchemaException — no value can satisfy all branch type constraints simultaneously. + * + * @param CompositionPropertyDecorator[] $compositionProperties + * + * @throws SchemaException + */ + private function transferAllOfType( + PropertyInterface $property, + array $compositionProperties, + bool $hasBranchWithOptionalProperty, + ): void { + $constrainingBranches = array_values(array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null, + )); + + if (empty($constrainingBranches)) { + // No typed branches — no type constraint to apply. + return; + } + + // Intersection of non-null type names across all typed branches. + // TypeIntersection::compute handles int ⊂ float (integer is a subtype of number in JSON Schema). + $nonNullSets = array_map( + static fn(CompositionPropertyDecorator $p): array => array_values(array_filter( + $p->getType()->getNames(), + static fn(string $typeName): bool => $typeName !== 'null', + )), + $constrainingBranches, + ); + $nonNullNames = array_shift($nonNullSets); + foreach ($nonNullSets as $typeSet) { + $nonNullNames = TypeIntersection::compute($nonNullNames, $typeSet); + } + + // Null is valid in allOf only when ALL typed branches allow it. + $allBranchesAllowNull = count(array_filter( + $constrainingBranches, + static fn(CompositionPropertyDecorator $p): bool => + in_array('null', $p->getType()->getNames(), true) + || $p->getType()->isNullable() === true, + )) === count($constrainingBranches); + + if (empty($nonNullNames) && !$allBranchesAllowNull) { + throw new SchemaException(sprintf( + "Property '%s' is defined with conflicting types in allOf composition branches" + . ' (file %s). allOf requires all constraints to hold simultaneously,' + . ' making this schema unsatisfiable.', + $property->getName(), + $property->getJsonSchema()->getFile(), + )); + } + + if (empty($nonNullNames)) { + // Only null survives the intersection; the null-processor path handles pure-null types. + return; + } + + $nullable = ($allBranchesAllowNull || $hasBranchWithOptionalProperty) ? true : null; + $property->setType(new PropertyType($nonNullNames, $nullable)); + } + + /** + * Derive and apply the parent property's type using anyOf/oneOf union semantics. + * + * Branches are partitioned into three categories: + * - Typed (getType() !== null): contribute their names to the union. + * - Explicit null-type ({type:null}): getType() is null but typeHint contains 'null'; + * contributes nullable=true to the result. + * - Truly untyped ({}): getType() is null and typeHint does not contain 'null'; the branch + * accepts every value, so the composition is always satisfiable and no type hint applies. + * + * A truly untyped branch causes early return without setting a type (property remains mixed), + * matching the behaviour of PropertyMerger::mergeNullableBranch for object-level compositions. + * An explicit null-type branch ({type:null}) is NOT treated as untyped — it adds nullable=true + * to the typed union rather than removing the type hint. + * + * @param CompositionPropertyDecorator[] $compositionProperties + */ + private function transferAnyOfOneOfType( + PropertyInterface $property, + array $compositionProperties, + bool $hasBranchWithOptionalProperty, + ): void { + $hasExplicitNullBranch = false; + + foreach ($compositionProperties as $compositionProperty) { + if ($compositionProperty->getType() !== null) { + continue; + } + + if (str_contains($compositionProperty->getTypeHint(), 'null')) { + // Explicit null-type branch ({type: null}): contributes nullable=true. + $hasExplicitNullBranch = true; + } else { + // Truly untyped branch ({}): any value is valid, so the composition is + // always satisfiable — no type hint is appropriate for this property. + return; + } + } + + $typedBranches = array_values(array_filter( + $compositionProperties, + static fn(CompositionPropertyDecorator $p): bool => $p->getType() !== null, + )); + + if (empty($typedBranches)) { + // Only explicit null branches; no scalar type to build a union from. + return; + } + + $allNames = array_merge(...array_map( + static fn(CompositionPropertyDecorator $p): array => $p->getType()->getNames(), + $typedBranches, + )); + + $hasNull = $hasExplicitNullBranch || in_array('null', $allNames, true); $nonNullNames = array_values(array_filter( array_unique($allNames), - fn(string $t): bool => $t !== 'null', + static fn(string $typeName): bool => $typeName !== 'null', )); if (!$nonNullNames) { return; } - $nullable = ($hasNull || $hasBranchWithNoType || $hasBranchWithOptionalProperty) ? true : null; - + $nullable = ($hasNull || $hasBranchWithOptionalProperty) ? true : null; $property->setType(new PropertyType($nonNullNames, $nullable)); } } diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index 16524560..8e5901fc 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -8,6 +8,7 @@ use PHPModelGenerator\Model\Property\BaseProperty; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; use PHPModelGenerator\Model\Property\PropertyInterface; +use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator; @@ -18,6 +19,7 @@ use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; use PHPModelGenerator\Utils\RenderHelper; +use PHPModelGenerator\Utils\TypeIntersection; class IfValidatorFactory extends AbstractCompositionValidatorFactory @@ -113,6 +115,8 @@ public function modify( $properties[$keyword] = $compositionProperty; } + $this->applyIfThenElseTypeSemantics($property, $properties); + $property->addValidator( new ConditionalPropertyValidator( $schemaProcessor->getGeneratorConfiguration(), @@ -132,4 +136,139 @@ public function modify( 100, ); } + + /** + * Apply type widening and conflict detection for property-level if/then/else. + * + * When both then and else branches are present, a coordinated callback fires after both + * resolve to: + * - Detect unsatisfiable schemas: if the parent property has a declared type that is + * incompatible (empty intersection) with BOTH then and else types, throw SchemaException. + * - Widen the property type: when the parent property has no declared type, compute the + * union of then and else types (anyOf-like semantics) and apply it to the parent property. + * + * If else is absent, the absent branch accepts any value when `if` evaluates to false, so + * the composition cannot constrain the type further; widening is skipped (property stays + * mixed). Conflict detection also requires both branches to be present — a single conflicting + * branch does not make the schema unsatisfiable. + * + * The parent type is captured at call time (before branch resolution) to avoid reading a + * value that may have been mutated by concurrent onResolve side-effects. + * + * @param array $properties + * + * @throws SchemaException + */ + private function applyIfThenElseTypeSemantics( + PropertyInterface $property, + array $properties, + ): void { + $thenProperty = $properties['then']; + $elseProperty = $properties['else']; + + if ($thenProperty === null || $elseProperty === null) { + // Absent branch = untyped (accepts any value on that path) — no type widening or + // conflict detection possible without both branches. + return; + } + + // Capture before any branch-resolution callback can mutate the parent type. + $originalParentType = $property->getType(true); + + $resolvedCount = 0; + $onBothResolved = function () use ( + &$resolvedCount, + $property, + $thenProperty, + $elseProperty, + $originalParentType, + ): void { + $resolvedCount++; + if ($resolvedCount < 2) { + return; + } + + // Object-level if/then/else creates nested schemas in the branches; type merging for + // that case is owned by PropertyMerger. Skip here to avoid false conflict detection + // (the parent 'object' type and branch generated-class types appear disjoint to + // TypeIntersection::compute even though they are semantically compatible). + if ($thenProperty->getNestedSchema() !== null || $elseProperty->getNestedSchema() !== null) { + return; + } + + if ($originalParentType !== null) { + $this->detectIfThenElseConflict( + $property, + $originalParentType, + $thenProperty, + $elseProperty, + ); + // Parent type constrains the property independently; widening is not applied. + return; + } + + // No parent type: widen to the union of then and else types (anyOf-like semantics). + $this->transferPropertyType($property, [$thenProperty, $elseProperty], false); + }; + + $thenProperty->onResolve($onBothResolved); + $elseProperty->onResolve($onBothResolved); + } + + /** + * Throw a SchemaException when the parent property's declared type is incompatible with + * BOTH the then and the else branch types (empty intersection on both sides). + * + * A schema is unsatisfiable under this condition: no value can simultaneously satisfy the + * parent type constraint and whichever composition branch fires. The schema is NOT + * unsatisfiable when only one branch conflicts (values that satisfy the compatible branch + * are still valid). + * + * @throws SchemaException + */ + private function detectIfThenElseConflict( + PropertyInterface $property, + PropertyType $originalParentType, + CompositionPropertyDecorator $thenProperty, + CompositionPropertyDecorator $elseProperty, + ): void { + $parentNames = array_filter( + $originalParentType->getNames(), + static fn(string $typeName): bool => $typeName !== 'null', + ); + + if (empty($parentNames)) { + return; + } + + $thenType = $thenProperty->getType(); + $elseType = $elseProperty->getType(); + + if ($thenType === null || $elseType === null) { + // At least one branch is untyped — it accepts any value, so no total conflict. + return; + } + + $thenNonNull = array_filter( + $thenType->getNames(), + static fn(string $typeName): bool => $typeName !== 'null', + ); + $elseNonNull = array_filter( + $elseType->getNames(), + static fn(string $typeName): bool => $typeName !== 'null', + ); + + $thenConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($thenNonNull))); + $elseConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($elseNonNull))); + + if ($thenConflicts && $elseConflicts) { + throw new SchemaException(sprintf( + "Property '%s' has a type that conflicts with all if/then/else composition branches" + . ' (file %s). No value can satisfy both the property type and the applicable' + . ' branch constraint, making this schema unsatisfiable.', + $property->getName(), + $property->getJsonSchema()->getFile(), + )); + } + } } diff --git a/src/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 3110083e..271c8b33 100644 --- a/src/Model/Validator/FilterValidator.php +++ b/src/Model/Validator/FilterValidator.php @@ -124,13 +124,14 @@ public function addTransformedCheck(TransformingFilterInterface $filter, Propert * execute under any circumstances. Partial overlap is fine: the runtime typeCheck guard in the * generated code already skips the filter for non-matching value types. * - * Type source: only an explicit 'type' key in the schema JSON is used for the check. - * When no direct 'type' is declared, the property's resolved type (if any) was set by - * composition modifiers (allOf, anyOf, oneOf) and is not reliable for input-type checking - * — output-space branches mutate the resolved type to the filter's output type rather than - * its input type. Skipping the check when there is no direct type declaration is - * order-independent and avoids false-positive incompatibility errors regardless of which - * modifier ran first. + * Type source: only an explicit 'type' key in the schema JSON is used for the check, + * not $property->getType() — the resolved type may be widened by composition modifiers + * (allOf, anyOf, oneOf) via deferred transferPropertyType callbacks, making it unreliable + * at FilterValidator construction time. When no direct 'type' is declared, the effective + * input type space would require full composition branch analysis to determine; the + * filter's runtime type guard handles non-matching types, so the static check is skipped. + * Skipping when there is no direct type declaration is order-independent and avoids + * false-positive incompatibility errors regardless of which modifier ran first. * * @param string[] $acceptedTypes Pre-computed accepted types of the filter. * @@ -146,9 +147,10 @@ private function runCompatibilityCheck(array $acceptedTypes, PropertyInterface $ $typeNames = ['object']; $isNullable = false; } else { - // Prefer the schema-declared type for the overlap check. Composition modifiers may - // have mutated property->getType() with output-space branch types (e.g. allOf with a - // {type:integer} branch for a string→int filter), causing false incompatibility errors. + // Prefer the schema-declared type for the overlap check rather than $property->getType(): + // composition modifiers widen the resolved type with branch types via deferred + // transferPropertyType callbacks — reading getType() here may return a wider type than + // the schema-declared input type, producing false-positive compatibility results. $schemaJson = $property->getJsonSchema()->getJson(); $schemaType = $schemaJson['type'] ?? null; @@ -157,13 +159,15 @@ private function runCompatibilityCheck(array $acceptedTypes, PropertyInterface $ $typeNames = [TypeConverter::jsonSchemaToPHP($schemaType)]; $isNullable = $property->getType()?->isNullable() ?? false; } elseif ($property->getType() === null || $schemaType === null) { - // No direct type declaration in the schema JSON — the property is either - // unconstrained or its resolved type was set by composition modifiers (allOf, - // anyOf, oneOf). Composition branches that target the filter's output type-space - // (e.g. {type:integer} on a string→int filter) mutate the resolved type to the - // output type, making it unreliable for input-type compatibility checking. The - // filter's runtime type guard and any input-space composition validators enforce - // type safety, so no static check is needed here. + // No direct type declaration in the schema JSON: the property is either + // unconstrained (any value is valid input) or its effective input type is + // determined by composition branch constraints. Analysing composition branches + // to derive the effective type is out of scope for this check — the filter's + // runtime type guard handles non-matching types at execution time. + // Exception: allOf branches with 'type' keywords narrow the set of values + // that can legally reach the filter. When the intersection of those type sets + // has no overlap with the filter's accepted types, the filter is unreachable. + $this->detectDeadFilterViaAllOfConstraints($acceptedTypes, $property, $schemaJson); return; } else { $typeNames = $property->getType()->getNames(); @@ -265,4 +269,80 @@ public function getFilterOptions(): array { return $this->filterOptions; } + + /** + * Detect a dead filter caused by allOf type constraints that fully exclude the filter's + * accepted input types. + * + * When allOf branches narrow the effective input type to a set that has no overlap with + * the filter's accepted types, the filter can never fire under any valid input — it is + * unreachable regardless of the runtime value. + * + * Only allOf branches that declare a 'type' keyword contribute to the narrowed type set; + * branches without 'type' impose no type constraint and are ignored. The effective type + * is the intersection of all contributing branch type sets (allOf requires every branch + * to pass, so only values matching all declared types can reach the filter). + * + * When the intersection is empty (contradictory branch types), the check is skipped — + * composition validation reports the contradiction independently. + * + * @param string[] $acceptedTypes PHP type names accepted by the filter. + * @param array $schemaJson Raw JSON of the property's schema. + * + * @throws SchemaException + */ + private function detectDeadFilterViaAllOfConstraints( + array $acceptedTypes, + PropertyInterface $property, + array $schemaJson, + ): void { + $allOfBranches = $schemaJson['allOf'] ?? null; + if (!is_array($allOfBranches) || empty($allOfBranches)) { + return; + } + + // Collect PHP type names from branches that declare a 'type' keyword. + $constraintSets = []; + foreach ($allOfBranches as $branch) { + if (!is_array($branch) || !array_key_exists('type', $branch)) { + continue; + } + + $branchTypes = is_array($branch['type']) ? $branch['type'] : [$branch['type']]; + $constraintSets[] = array_values(array_map( + static fn(string $typeName): string => TypeConverter::jsonSchemaToPHP($typeName), + $branchTypes, + )); + } + + if (empty($constraintSets)) { + return; // No type constraints in any allOf branch; cannot determine effective type. + } + + // Effective type = intersection of all constrained type sets (allOf: all must pass). + $effectiveTypes = array_shift($constraintSets); + foreach ($constraintSets as $typeSet) { + $effectiveTypes = array_values(array_intersect($effectiveTypes, $typeSet)); + } + + if (empty($effectiveTypes)) { + // Contradictory constraints produce an empty effective type set; the schema is + // already invalid — let composition validation report the contradiction. + return; + } + + if (!empty(array_intersect($effectiveTypes, $acceptedTypes))) { + return; // Overlap exists; the filter can fire for at least some valid values. + } + + throw new SchemaException(sprintf( + 'Filter %s on property %s in file %s can never be executed:' + . ' allOf type constraints (%s) exclude all input types accepted by the filter (%s)', + $this->filter->getToken(), + $property->getName(), + $property->getJsonSchema()->getFile(), + implode('|', $effectiveTypes), + implode('|', $acceptedTypes), + )); + } } diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php index e34cfd81..ae7cfe57 100644 --- a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -48,9 +48,9 @@ public function __construct( * Classify a single schema keyword by looking up which Draft-registered types carry it * and mapping those types onto the filter's input / output type-spaces. * - * Composition and structural keywords ('type', 'allOf', 'anyOf', 'oneOf', 'not') are not - * handled here — call classify() for full branch analysis. Returns TypeSpace::Empty for - * keywords registered only on the 'any' pseudo-type (e.g. 'enum', 'filter') or for + * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') and the structural 'type' keyword + * are not handled here — call classify() for full branch analysis. Returns TypeSpace::Empty + * for keywords registered only on the 'any' pseudo-type (e.g. 'enum', 'filter') or for * keywords not registered at all (e.g. '$schema', 'description'). */ public function classifySchemaKey(string $key): TypeSpace @@ -105,23 +105,21 @@ public function classify(array $branchSchema): TypeSpace /** * Determine the type-space contribution of a single keyword within a branch. * + * The 'type' keyword is always classified as Input: it asserts raw JSON value structure + * and must validate against the unfiltered input regardless of the filter's output type. + * Combining 'type' with output-targeted constraints (e.g. minimum/maximum) in the same + * branch therefore produces TypeSpace::Mixed, which is correctly rejected as unresolvable. + * + * Composition keywords ('allOf', 'anyOf', 'oneOf', 'not') are classified recursively via + * classifyNestedComposition() — call classify() for full branch analysis. + * * Returns TypeSpace::Empty when the keyword carries no spatial information * (registered only on 'any', not registered at all, or an unrecognised key). */ private function classifyKeyword(string $keyword, mixed $value): TypeSpace { if ($keyword === 'type') { - // The 'type' keyword asserts raw JSON value structure. It must classify - // against the declared output types directly — without the 'object' expansion - // from getEffectiveOutputTypes() — so that 'type: object' stays input-space for - // filters that return a PHP class instance (e.g. DateTime for dateTime filter). - return $this->classifyAgainstSpaces( - array_map( - static fn(string $jsonType): string => TypeConverter::jsonSchemaToPHP($jsonType), - (array) $value, - ), - $this->outputTypes, - ); + return TypeSpace::Input; } if (in_array($keyword, self::NESTED_COMPOSITION_KEYWORDS, true)) { diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index b62c1f5e..ddeaeeeb 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -21,6 +21,10 @@ use PHPModelGenerator\Model\Validator\ComposedPropertyValidator; use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\AnyOfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\InstanceOfValidator; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; @@ -385,6 +389,7 @@ private function classifyValidatorAdjustments( [$inputIndices, $outputIndices] = $this->classifyComposedValidatorBranches( $composedValidator, $classifier, + $property->getJsonSchema()->getJson(), ); // Only allOf can have mixed spaces (static rejection guarantees anyOf/oneOf/not/ @@ -528,6 +533,11 @@ private function splitMixedSpaceAllOf( * Classify the branches of a composition validator into input-space and output-space * index lists using the given CompositionBranchClassifier. * + * Uses the original (pre-type-inheritance) branch schemas from the property's raw JSON + * rather than the post-inheritance schemas stored on the CompositionPropertyDecorator. + * This prevents inherited type annotations (injected to drive validator registration) + * from shifting a pure output-space branch into Mixed classification. + * * Empty/ambiguous branches (TypeSpace::Empty) are treated as input-space per the * liberal policy, consistent with CompositionBranchClassifier. * @@ -536,12 +546,18 @@ private function splitMixedSpaceAllOf( private function classifyComposedValidatorBranches( AbstractComposedPropertyValidator $validator, CompositionBranchClassifier $classifier, + array $originalPropertyJson, ): array { $inputIndices = []; $outputIndices = []; + $originalBranchSchemas = $this->resolveOriginalBranchSchemas($validator, $originalPropertyJson); + foreach ($validator->getComposedProperties() as $index => $compositionProperty) { - $branchSchema = $compositionProperty->getBranchSchema()->getJson(); + $branchSchema = ($originalBranchSchemas !== null && isset($originalBranchSchemas[$index])) + ? $originalBranchSchemas[$index] + : $compositionProperty->getBranchSchema()->getJson(); + $space = $classifier->classify($branchSchema); if ($space === TypeSpace::Output) { @@ -555,6 +571,63 @@ private function classifyComposedValidatorBranches( return [$inputIndices, $outputIndices]; } + /** + * Look up the original (pre-type-inheritance) branch schemas for a composition validator + * from the property's raw JSON. + * + * Type inheritance injects the parent property's type into untyped branches so that + * type-specific Draft validators (e.g. minimum for integer branches) are registered. + * That injected type must not influence space classification, so we classify the + * schemas as they appeared before inheritance. + * + * Returns null when the original schemas cannot be determined (falls back to + * getBranchSchema() in classifyComposedValidatorBranches). + * + * @return list>|null + */ + private function resolveOriginalBranchSchemas( + AbstractComposedPropertyValidator $validator, + array $originalPropertyJson, + ): ?array { + $processorClass = $validator->getCompositionProcessor(); + + $keywordMap = [ + AllOfValidatorFactory::class => 'allOf', + AnyOfValidatorFactory::class => 'anyOf', + OneOfValidatorFactory::class => 'oneOf', + ]; + + if (isset($keywordMap[$processorClass])) { + $keyword = $keywordMap[$processorClass]; + if (!isset($originalPropertyJson[$keyword]) || !is_array($originalPropertyJson[$keyword])) { + return null; + } + return array_values($originalPropertyJson[$keyword]); + } + + if ($processorClass === NotValidatorFactory::class) { + if (!isset($originalPropertyJson['not']) || !is_array($originalPropertyJson['not'])) { + return null; + } + // NotValidatorFactory wraps the single 'not' schema in an array; index 0 maps to it. + return [$originalPropertyJson['not']]; + } + + if ($processorClass === IfValidatorFactory::class) { + // ConditionalPropertyValidator's getComposedProperties() returns non-null if/then/else + // properties in declaration order. Reproduce that order from the original JSON. + $result = []; + foreach (['if', 'then', 'else'] as $keyword) { + if (isset($originalPropertyJson[$keyword]) && is_array($originalPropertyJson[$keyword])) { + $result[] = $originalPropertyJson[$keyword]; + } + } + return !empty($result) ? $result : null; + } + + return null; + } + /** * Compute the output type using the bypass formula and apply it to the property. * diff --git a/src/Utils/PropertyMerger.php b/src/Utils/PropertyMerger.php index 880c538a..2937e0e2 100644 --- a/src/Utils/PropertyMerger.php +++ b/src/Utils/PropertyMerger.php @@ -122,7 +122,7 @@ private function guardRootPrecedence( $incomingOutput = $incoming->getType(true); $intersection = $incomingOutput && $existingOutput - ? $this->computeDeclaredIntersection($incomingOutput->getNames(), $existingOutput->getNames()) + ? TypeIntersection::compute($incomingOutput->getNames(), $existingOutput->getNames()) : null; if ($intersection === []) { @@ -334,14 +334,14 @@ private function resolveEffectiveIntersection( ): ?array { $implicitNull = $this->generatorConfiguration?->isImplicitNullAllowed() ?? false; - if (!$this->computeDeclaredIntersection($existingType->getNames(), $incomingType->getNames())) { + if (!TypeIntersection::compute($existingType->getNames(), $incomingType->getNames())) { throw new SchemaException($conflictMessage); } $existingEffective = $this->buildEffectiveTypeSet($existingType, $existingIsRequired, $implicitNull); $incomingEffective = $this->buildEffectiveTypeSet($incomingType, $incomingIsRequired, $implicitNull); - $intersection = $this->computeDeclaredIntersection($existingEffective, $incomingEffective); + $intersection = TypeIntersection::compute($existingEffective, $incomingEffective); // No-op when the intersection already equals the existing effective set. if (!array_diff($existingEffective, $intersection) && !array_diff($intersection, $existingEffective)) { @@ -455,32 +455,4 @@ private function buildEffectiveTypeSet( return $names; } - - /** - * Compute the intersection of two type-name sets, treating 'int' as a subtype of 'float' - * (JSON Schema: integer is a subset of number). - * - * When one side contains 'float' and the other contains 'int' (but not 'float'), the - * intersection resolves to 'int' — the narrower concrete type — rather than empty. - * - * @param string[] $a - * @param string[] $b - * @return string[] - */ - private function computeDeclaredIntersection(array $a, array $b): array - { - $intersection = array_values(array_intersect($a, $b)); - - // int ⊂ float (JSON Schema: integer is a subtype of number). - // When one side has float and the other has int (without float), resolve to int. - if (!in_array('float', $intersection, true)) { - if (in_array('float', $a, true) && in_array('int', $b, true)) { - $intersection[] = 'int'; - } elseif (in_array('int', $a, true) && in_array('float', $b, true)) { - $intersection[] = 'int'; - } - } - - return array_values(array_unique($intersection)); - } } diff --git a/src/Utils/TypeIntersection.php b/src/Utils/TypeIntersection.php new file mode 100644 index 00000000..07427496 --- /dev/null +++ b/src/Utils/TypeIntersection.php @@ -0,0 +1,39 @@ +expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter stringToInt on property filteredProperty.*can never be executed' + . '.*allOf type constraints \(int\) exclude all input types accepted by the filter \(string\)/', + ); + + // allOf requires integer values but stringToInt only accepts strings; no value + // can pass both the allOf validation and reach the filter. + $this->generateClassFromFile( + 'FilterCompositionAllOfDeadFilter.json', + (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + } + + /** + * Contradictory allOf type constraints (no value can satisfy all branches simultaneously) + * produce an empty intersection. The property-level allOf intersection check fires and + * throws SchemaException for the type contradiction. The dead-filter check skips on empty + * intersection and is NOT the source of this exception. + */ + public function testContradictoryAllOfTypeConstraintsThrowSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'filteredProperty' is defined with conflicting types in allOf composition branches/", + ); + + // Contradictory branches: integer AND string simultaneously — impossible. The + // property-level allOf intersection detects the empty intersection and rejects the schema. + // The dead-filter check skips (empty intersection is handled by type-contradiction logic). + $this->generateClassFromFile( + 'FilterCompositionAllOfContradictoryTypes.json', + (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + } + #[DataProvider('rejectedCompositionProvider')] public function testUnresolvableCompositionOnTransformingFilterPropertyThrowsSchemaException( string $schemaFile, @@ -1727,23 +1779,18 @@ public function testCompatibleCompositionOnTransformingFilterPropertyGeneratesSu /** * Output-only allOf (all branches output-space) runs POST-transform. * - * Schema: { filter: stringToInt, allOf: [{type:integer, minimum: 0}, {type:integer, maximum: 100}] } - * Both branches declare type:integer — output-space for the string→int filter. + * Schema: { filter: stringToInt, type: [string, integer], + * allOf: [{minimum: 0}, {maximum: 100}] } + * Both branches constrain the numeric range — output-space for the string→int filter. * The allOf must run AFTER the filter, validating the transformed integer. * * Observable proof: "200" → filter → 200 → maximum:100 fails → AllOfException. - * If the allOf ran PRE-transform, "200" is a string, the type:integer check would fail - * the branch immediately, producing AllOfException for the wrong reason (type mismatch - * rather than range violation). "50" → filter → 50 → both branches pass → success. - * If allOf ran pre-transform, "50" (string) would fail type:integer on both branches. + * If the allOf ran PRE-transform, minimum and maximum would be no-ops on the string "200" + * and both branches would always pass vacuously — no exception would be thrown. + * The AllOfException for "200" proves the allOf ran on the transformed integer. * * Already-transformed int 50 supplied directly skips the filter; the output-space allOf * still runs and passes. - * - * Note: the property has no explicit base type, so FilterProcessor cannot call - * applyOutputType at filter-processing time; the output type is instead wired later by - * TransformingFilterOutputTypePostProcessor. Compare testOutputSpaceAllOfCompositionRunsPostTransform - * where an explicit type:string causes applyOutputType to run immediately at processing time. */ public function testOutputOnlyAllOfCompositionRunsPostTransform(): void { @@ -1764,8 +1811,8 @@ public function testOutputOnlyAllOfCompositionRunsPostTransform(): void $this->assertSame(50, $object->getFilteredProperty()); // "200": filter → 200 → maximum:100 fails → AllOfException. - // Proves post-transform: if allOf ran on the raw string "200", maximum would be a - // no-op (non-numeric) and always pass vacuously. + // Proves post-transform: if allOf ran on the raw string "200", minimum/maximum would + // be no-ops (non-numeric) and always pass vacuously — the exception proves integer evaluation. // Branch 0 (minimum:0) passes, branch 1 (maximum:100) fails → succeeded=1. try { new $className(['filteredProperty' => '200']); @@ -2099,76 +2146,13 @@ public function testInputSpaceAllOfCompositionRunsPreTransform(): void $this->assertSame($dateTime, $object->getFilteredProperty()); } - /** - * Output-space allOf runs POST-transform. - * - * Schema: { type: string, filter: stringToInt, allOf: [{type: integer, minimum: 0}] } - * - * {type: integer, minimum: 0} targets the filter's output type (int) → output-space. - * The allOf must run AFTER the filter, validating the transformed integer. - * - * Observable proof: "5" (a valid string) succeeds and produces 5. If the allOf ran - * pre-transform it would check the string "5" against {type: integer} and fail. - * "−5" produces AllOfException because minimum:0 rejects the negative transformed int. - * - * Already-transformed value: int 5 supplied directly skips the filter; the output-space allOf - * still runs and passes. - * - * Note: the explicit type:string means FilterProcessor can call applyOutputType immediately - * at filter-processing time. Compare testOutputOnlyAllOfCompositionRunsPostTransform where - * the absent base type defers output-type assignment to TransformingFilterOutputTypePostProcessor. - */ - public function testOutputSpaceAllOfCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfOutputSpace.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "5": filter → 5 → allOf {type:integer, minimum:0} passes. - // If allOf ran pre-transform, "5" (string) would fail {type:integer} → AllOfException. - $object = new $className(['filteredProperty' => '5']); - $this->assertSame(5, $object->getFilteredProperty()); - - // "-5": filter → −5 → allOf minimum:0 fails → AllOfException. - // Single branch (minimum:0) fails → succeeded=0. - try { - new $className(['filteredProperty' => '-5']); - $this->fail('Expected AllOfException for "-5"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed int 5 → filter skipped → output-space allOf still runs and passes. - $object = new $className(['filteredProperty' => 5]); - $this->assertSame(5, $object->getFilteredProperty()); - } - /** * Mixed-space allOf is split around the transforming filter. * - * Schema: { filter: stringToInt, allOf: [{type:string, minLength:1}, {type:integer, minimum:0}] } + * Schema: { type: [string, integer], filter: stringToInt, + * allOf: [{type:string, minLength:1}, {minimum:0}] } * - {type:string, minLength:1} is input-space (string constraint, runs PRE-transform). - * - {type:integer, minimum:0} is output-space (int constraint, runs POST-transform). + * - {minimum:0} is output-space (numeric constraint, runs POST-transform). * * Validated behaviours: * (a) "5" → pre-allOf passes (string, len≥1), filter→5, post-allOf passes (int≥0) → 5. @@ -2414,17 +2398,18 @@ public function testInputSpaceIfThenElseCompositionRunsPreTransform(): void /** * Output-space anyOf runs POST-transform. * - * Schema: { type: string, filter: stringToInt, - * anyOf: [{type:integer, min:0, max:10}, {type:integer, min:20, max:30}] } + * Schema: { type: [string, integer], filter: stringToInt, + * anyOf: [{min:0, max:10}, {min:20, max:30}] } * - * Both branches declare type:integer — output-space for the string→int filter. + * Both branches constrain the numeric range — output-space for the string→int filter. * The anyOf must run AFTER the filter, validating the transformed integer. * - * Observable proof: "5" → filter → 5 → {min:0,max:10} passes → anyOf passes → result 5. - * If the anyOf ran PRE-transform, "5" (string) would fail type:integer on both branches → - * AnyOfException. Success proves post-transform execution. + * Observable proof: "15" → filter → 15 → neither branch passes → AnyOfException. + * If the anyOf ran PRE-transform, minimum/maximum would be no-ops on the string "15" and + * both branches would always pass vacuously — no exception would be thrown. + * The AnyOfException for "15" proves the anyOf ran on the transformed integer. * - * "15" → filter → 15 → neither branch passes → AnyOfException (proves it ran on the integer). + * "15" → filter → 15 → neither branch passes → AnyOfException (proves integer evaluation). * * Already-transformed value: int 5 directly skips the filter; the output-space anyOf still runs. */ @@ -2443,7 +2428,6 @@ public function testOutputSpaceAnyOfCompositionRunsPostTransform(): void ); // "5": filter → 5 → {min:0, max:10} passes → anyOf passes. - // If anyOf ran pre-transform, "5" (string) would fail type:integer → AnyOfException. $object = new $className(['filteredProperty' => '5']); $this->assertSame(5, $object->getFilteredProperty()); @@ -2479,20 +2463,17 @@ public function testOutputSpaceAnyOfCompositionRunsPostTransform(): void /** * Output-space oneOf runs POST-transform. * - * Schema: { type: string, filter: stringToInt, - * oneOf: [{type:integer, min:0, max:10}, {type:integer, min:20, max:30}] } + * Schema: { type: [string, integer], filter: stringToInt, + * oneOf: [{min:0, max:10}, {min:20, max:30}] } * - * Both branches declare type:integer — output-space for the string→int filter. + * Both branches constrain the numeric range — output-space for the string→int filter. * The oneOf must run AFTER the filter, validating the transformed integer. * - * Observable proof: "5" → filter → 5 → exactly {min:0,max:10} passes → oneOf passes. - * If the oneOf ran PRE-transform, "5" (string) would fail type:integer on both branches → - * 0 pass → OneOfException. Success proves post-transform execution. - * - * "7" → filter → 7 → both branches pass (0≤7≤10, but 7<20 fails second) wait — - * 7 is in [0,10] only; second branch [20,30] fails. Exactly 1 pass → oneOf passes. - * "25" → 25 → {min:20,max:30} passes, {min:0,max:10} fails → 1 pass → oneOf passes. - * "15" → 15 → neither branch passes → OneOfException. + * Observable proof: "15" → filter → 15 → neither branch passes → OneOfException. + * If the oneOf ran PRE-transform, minimum/maximum would be no-ops on the string "15" and + * both branches would always pass vacuously → 2 pass → OneOfException for the wrong reason. + * "5" → filter → 5 → exactly {min:0,max:10} passes → 1 match → oneOf passes. + * "25" → 25 → {min:20,max:30} passes → 1 match → oneOf passes. * * Already-transformed value: int 5 directly skips the filter; the output-space oneOf still runs. */ @@ -2511,7 +2492,6 @@ public function testOutputSpaceOneOfCompositionRunsPostTransform(): void ); // "5": filter → 5 → {min:0, max:10} passes, {min:20, max:30} fails → exactly 1 → oneOf passes. - // If oneOf ran pre-transform, "5" (string) fails type:integer on both → OneOfException. $object = new $className(['filteredProperty' => '5']); $this->assertSame(5, $object->getFilteredProperty()); @@ -2547,19 +2527,17 @@ public function testOutputSpaceOneOfCompositionRunsPostTransform(): void /** * Output-space if/then/else runs POST-transform. * - * Schema: { type: string, filter: stringToInt, - * if: {type:integer, minimum:0}, then: {type:integer, maximum:100}, - * else: {type:integer, minimum:-100} } + * Schema: { type: [string, integer], filter: stringToInt, + * if: {minimum:0}, then: {maximum:100}, else: {minimum:-100} } * - * All branches declare type:integer — output-space for the string→int filter. + * All sub-schemas constrain numeric ranges — output-space for the string→int filter. * The conditional must run AFTER the filter, validating the transformed integer. * - * Observable proof: "50" → filter → 50 → if:{min:0} passes → then:{max:100} passes → 50. - * If the conditional ran PRE-transform, "50" (string) would fail type:integer on the if-branch → - * $ifException set → then-branch skipped → else:{type:integer,min:-100}: "50" fails type:integer → - * $elseException set → ConditionalException. Success proves post-transform execution. + * Observable proof: "200" → filter → 200 → if:{min:0} passes → then:{max:100} fails + * → ConditionalException. If the conditional ran PRE-transform, minimum/maximum would be + * no-ops on the string "200" and all branches would always pass vacuously — no exception. + * The ConditionalException for "200" proves the conditional ran on the transformed integer. * - * "200" → 200 → if passes (200≥0) → then:{max:100} fails → ConditionalException. * "-200" → -200 → if fails (−200<0) → else:{min:-100} fails (−200<−100) → ConditionalException. * * Already-transformed value: int 50 directly skips the filter; the output-space conditional still runs. @@ -2579,8 +2557,6 @@ public function testOutputSpaceIfThenElseCompositionRunsPostTransform(): void ); // "50": filter → 50 → if:{min:0} passes → then:{max:100} passes → success. - // If conditional ran pre-transform, "50" (string) fails type:integer on if-branch → elseException - // path → else:{type:integer,min:-100} also fails string → ConditionalException. $object = new $className(['filteredProperty' => '50']); $this->assertSame(50, $object->getFilteredProperty()); @@ -2636,7 +2612,8 @@ public function testOutputSpaceIfThenElseCompositionRunsPostTransform(): void /** * Mixed-space allOf split in collect-errors mode collects errors from both subsets. * - * Schema: { filter: stringToInt, allOf: [{type:string, minLength:1}, {type:integer, minimum:0}] } + * Schema: { type: [string, integer], filter: stringToInt, + * allOf: [{type:string, minLength:1}, {minimum:0}] } * * In collect-errors mode validation continues after each failure, so: * - A pre-transform error (minLength) and a post-transform error (minimum) are each @@ -2705,105 +2682,28 @@ public function testMixedSpaceAllOfSplitWithCollectErrors(): void } /** - * Object-returning transforming filter with an empty-schema allOf branch. - * * Schema: { filter: dateTime, allOf: [{type: object}] } * - * The `type: object` constraint in the allOf validates the RAW input (it classifies as - * input-space because the type keyword never uses the object-expansion of effective output - * types). A string therefore fails the allOf even though the filter would convert it to a - * DateTime. Only values that ARE already objects bypass the type check via the - * pre-transform guard. - * - * For the objects that do reach the post-filter stage, the property-level extended - * instanceof check narrows acceptance to the filter's declared output type (DateTime), - * rejecting unrelated objects such as stdClass. + * The 'type' keyword inside a composition branch always validates the RAW input value + * (it is always Input-space — never reclassified as Output-space). The allOf branch + * {type: object} therefore requires the raw input to be an object. Because the + * dateTime filter only accepts strings (not objects), no raw value can ever both pass + * the allOf validation AND be accepted by the filter — the filter is unreachable. * - * Observable proof: - * - "2024-01-01" → allOf type:object runs on raw string → fails → AllOfException. - * - DateTime directly → pre-transform guard fires → allOf skipped → passes. - * - stdClass → allOf type:object passes (is_object=true) but property-level instanceof - * check rejects it → InvalidInstanceOfException. - * - stdClass with collectErrors=true → error collected, no immediate throw. + * This schema is rejected at generation time with a dead-filter SchemaException. If the + * intent is to accept only already-converted DateTime objects, the filter should be + * removed entirely; if the intent is to accept raw date strings too, the allOf type + * constraint must not exclude the filter's accepted types. */ - public function testObjectOutputTypeAllOfBranchTypeCheckRunsOnRawInput(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfObjectBranchOutput.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - // String raw input: allOf {type:object} runs on the string → fails. - // Branch 0 ({type:object}) rejects the string → succeeded=0; nested message identifies the type mismatch. - try { - new $className(['filteredProperty' => '2024-01-01']); - $this->fail('Expected AllOfException for "2024-01-01"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - } - - public function testObjectOutputTypeAllOfBranchAcceptsDirectObjectInput(): void + public function testObjectOutputTypeConstraintMakingFilterDeadThrowsSchemaException(): void { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfObjectBranchOutput.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // Already-transformed DateTime → pre-transform guard fires → allOf skipped → passes. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - public function testObjectOutputTypeAllOfBranchRejectsForeignObject(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfObjectBranchOutput.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // stdClass: allOf type:object passes (is_object=true), but the property-level - // extended instanceof check narrows acceptance to DateTime only. - $this->expectException(InvalidInstanceOfException::class); - $this->expectExceptionMessage('Requires DateTime, got stdClass'); - new $className(['filteredProperty' => new \stdClass()]); - } - - public function testObjectOutputTypeAllOfBranchCollectsErrorForForeignObject(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfObjectBranchOutput.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter dateTime on property filteredProperty.*can never be executed' + . '.*allOf type constraints \(object\) exclude/', ); - // stdClass with collectErrors=true: InvalidInstanceOfException is added to the - // error registry rather than thrown immediately. - $exception = null; - try { - new $className(['filteredProperty' => new \stdClass()]); - } catch (ErrorRegistryException $registryException) { - $exception = $registryException; - } - - $this->assertNotNull($exception); - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertInstanceOf(InvalidInstanceOfException::class, $errors[0]); - $this->assertStringContainsString('Requires DateTime, got stdClass', $errors[0]->getMessage()); - $this->assertSame('DateTime', $errors[0]->getExpectedClass()); + $this->generateClassFromFile('FilterCompositionAllOfObjectBranchOutput.json'); } public static function convertStringToInt(string $value): int diff --git a/tests/ComposedValue/ComposedAllOfTest.php b/tests/ComposedValue/ComposedAllOfTest.php index da731c2c..2c87a18a 100644 --- a/tests/ComposedValue/ComposedAllOfTest.php +++ b/tests/ComposedValue/ComposedAllOfTest.php @@ -356,7 +356,7 @@ public static function validComposedObjectDataProvider(): array } #[DataProvider('invalidComposedObjectDataProvider')] - public function testNotMatchingPropertyForComposedAllOfObjectThrowsAnException(array $input, mixed $_stringValue = null, mixed $_intValue = null): void + public function testNotMatchingPropertyForComposedAllOfObjectThrowsAnException(array $input): void { $this->expectException(ValidationException::class); @@ -373,13 +373,13 @@ public static function invalidComposedObjectDataProvider(): array 'both invalid types bool' => [['integerProperty' => true, 'stringProperty' => false]], 'both invalid types object' => [['integerProperty' => new stdClass(), 'stringProperty' => new stdClass()]], 'both invalid types array' => [['integerProperty' => [], 'stringProperty' => []]], - 'one invalid negative int' => [['integerProperty' => -10, 'stringProperty' => -10], null, -10], - 'one invalid zero int' => [['integerProperty' => 0, 'stringProperty' => 0], null, 0], - 'one invalid positive int' => [['integerProperty' => 10, 'stringProperty' => 10], null, 10], - 'one invalid empty string' => [['integerProperty' => '', 'stringProperty' => ''], '', null], - 'one invalid numeric string' => [['integerProperty' => '100', 'stringProperty' => '100'], '100', null], - 'one invalid filled string' => [['integerProperty' => 'Hello', 'stringProperty' => 'Hello'], 'Hello', null], - 'one invalid additional property' => [['integerProperty' => 'A', 'stringProperty' => 'A', 'test' => 1234], 'A', null], + 'one invalid negative int' => [['integerProperty' => -10, 'stringProperty' => -10]], + 'one invalid zero int' => [['integerProperty' => 0, 'stringProperty' => 0]], + 'one invalid positive int' => [['integerProperty' => 10, 'stringProperty' => 10]], + 'one invalid empty string' => [['integerProperty' => '', 'stringProperty' => '']], + 'one invalid numeric string' => [['integerProperty' => '100', 'stringProperty' => '100']], + 'one invalid filled string' => [['integerProperty' => 'Hello', 'stringProperty' => 'Hello']], + 'one invalid additional property' => [['integerProperty' => 'A', 'stringProperty' => 'A', 'test' => 1234]], ]; } @@ -387,11 +387,9 @@ public static function invalidComposedObjectDataProvider(): array /** * Must throw an exception as only one option matches */ - #[DataProvider('validComposedObjectWithRequiredPropertiesDataProvider')] + #[DataProvider('validComposedObjectWithRequiredPropertiesInputDataProvider')] public function testMatchingPropertyForComposedAllOfObjectWithRequiredPropertiesThrowsAnException( array $input, - mixed $_stringValue = null, - mixed $_intValue = null, ): void { $this->expectException(ValidationException::class); @@ -403,8 +401,6 @@ public function testMatchingPropertyForComposedAllOfObjectWithRequiredProperties #[DataProvider('invalidComposedObjectDataProvider')] public function testNotMatchingPropertyForComposedAllOfObjectWithRequiredPropertiesThrowsAnException( array $input, - mixed $_stringValue = null, - mixed $_intValue = null, ): void { $this->expectException(ValidationException::class); @@ -423,6 +419,16 @@ public static function validComposedObjectWithRequiredPropertiesDataProvider(): ]; } + public static function validComposedObjectWithRequiredPropertiesInputDataProvider(): array + { + return [ + 'only int property' => [['integerProperty' => 4]], + 'only string property' => [['stringProperty' => 'B']], + 'only int property with additional property' => [['integerProperty' => 4, 'test' => 1234]], + 'only string property with additional property' => [['stringProperty' => 'B', 'test' => 1234]], + ]; + } + #[DataProvider('nestedObjectDataProvider')] public function testObjectLevelCompositionArrayWithNestedObject(string $schema): void { @@ -605,4 +611,65 @@ public function testIdenticalMergedSchemaIsRedirected(): void $this->getParameterTypeAnnotation($className, 'setCfo'), ); } + + /** + * A property-level allOf whose branch type constraints have an empty intersection is + * unsatisfiable — no value can pass all branch type checks simultaneously. + */ + public function testPropertyLevelAllOfWithConflictingTypesThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'property' is defined with conflicting types in allOf composition branches/", + ); + + $this->generateClassFromFile('PropertyLevelAllOfConflictingTypes.json'); + } + + /** + * A property-level allOf where one branch has no type keyword and another declares + * integer: the untyped branch imposes no type restriction, so the effective type + * comes solely from the typed branch — ?int. + */ + public function testPropertyLevelAllOfWithUntypedBranchPreservesTypedBranchType(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelAllOfUntypedBranch.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getReturnTypeNames($className, 'getProperty'), + ); + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getParameterTypeNames($className, 'setProperty'), + ); + } + + /** + * JSON Schema: integer is a subtype of number (int ⊂ float). A property-level + * allOf with one branch typed integer and another typed number must resolve the + * intersection to int rather than throwing a contradictory-types SchemaException. + */ + public function testPropertyLevelAllOfIntegerSubtypeOfNumberResolvesToInt(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelAllOfIntegerNumber.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getReturnTypeNames($className, 'getProperty'), + ); + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getParameterTypeNames($className, 'setProperty'), + ); + + $object = new $className(['property' => 42]); + $this->assertSame(42, $object->getProperty()); + } } diff --git a/tests/ComposedValue/ComposedAnyOfTest.php b/tests/ComposedValue/ComposedAnyOfTest.php index 4f2092c0..bb3b1264 100644 --- a/tests/ComposedValue/ComposedAnyOfTest.php +++ b/tests/ComposedValue/ComposedAnyOfTest.php @@ -634,4 +634,20 @@ public static function validationInSetterDataProvider(): array ], ]; } + + /** + * A property-level anyOf where one branch has no type keyword accepts every value, + * making the anyOf always satisfiable. The property has no effective type restriction + * and must carry no type hint (mixed). + */ + public function testPropertyLevelAnyOfWithUntypedBranchProducesMixedType(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelAnyOfUntypedBranch.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $this->assertSame('mixed', $this->getReturnType($className, 'getProperty')->getName()); + $this->assertSame('mixed', $this->getParameterType($className, 'setProperty')->getName()); + } } diff --git a/tests/ComposedValue/ComposedIfTest.php b/tests/ComposedValue/ComposedIfTest.php index 80a199d2..e4461174 100644 --- a/tests/ComposedValue/ComposedIfTest.php +++ b/tests/ComposedValue/ComposedIfTest.php @@ -302,4 +302,103 @@ public function testExclusiveBranchPropertiesAreTransferred(): void $this->assertPropertyHasJsonPointer($object, 'amount', '/then/properties/amount'); $this->assertPropertyHasJsonPointer($object, 'label', '/else/properties/label'); } + + /** + * A property-level if/then/else where the property has no declared type and both then and + * else branches declare distinct types. The effective property type is the union of the two + * branch types (anyOf-like semantics: either branch can fire at runtime). + */ + public function testPropertyLevelIfThenElseWidensTypeToUnionOfBranches(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelIfThenElseTypeWidening.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $this->assertEqualsCanonicalizing( + ['int', 'string', 'null'], + $this->getReturnTypeNames($className, 'getProperty'), + ); + $this->assertEqualsCanonicalizing( + ['int', 'string', 'null'], + $this->getParameterTypeNames($className, 'setProperty'), + ); + + // Non-negative integer: if passes, then applies + $object = new $className(['property' => 5]); + $this->assertSame(5, $object->getProperty()); + + // Non-empty string: if fails (not integer), else applies + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // Negative integer: if passes (integer), then (minimum: 0) fails + $this->expectException(ConditionalException::class); + new $className(['property' => -1]); + } + + /** + * A property-level if/then/else where else is absent. The absent else branch accepts any + * value when if evaluates to false, making the composition unconstraining on that path. + * The property type must remain mixed (not widened to the then branch type only). + */ + public function testPropertyLevelIfThenWithAbsentElseProducesMixedType(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelIfThenElseAbsentElse.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $this->assertSame('mixed', $this->getReturnType($className, 'getProperty')->getName()); + $this->assertSame('mixed', $this->getParameterType($className, 'setProperty')->getName()); + } + + /** + * A property-level if/then/else where the parent declares type:string but both then and + * else explicitly declare types incompatible with string. No value can satisfy the parent + * type constraint together with whichever branch fires — the schema is unsatisfiable. + */ + public function testPropertyLevelIfThenElseConflictingBranchTypesThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'property' has a type that conflicts with all if\/then\/else composition branches/", + ); + + $this->generateClassFromFile('PropertyLevelIfThenElseConflictingTypes.json'); + } + + /** + * A property-level if/then/else where the parent declares type:integer and the then/else + * branches add only numeric constraints (no explicit type). The branches inherit the parent + * type; the property type must remain int (not widened). + */ + public function testPropertyLevelIfThenElseWithCompatibleBranchesPreservesParentType(): void + { + $className = $this->generateClassFromFile( + 'PropertyLevelIfThenElseParentTypePreserved.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getReturnTypeNames($className, 'getProperty'), + ); + $this->assertEqualsCanonicalizing( + ['int', 'null'], + $this->getParameterTypeNames($className, 'setProperty'), + ); + + // Even non-negative integer: if passes, then (multipleOf 2) applies + $object = new $className(['property' => 4]); + $this->assertSame(4, $object->getProperty()); + + // Negative integer: if fails, else (maximum -1) applies + $object = new $className(['property' => -3]); + $this->assertSame(-3, $object->getProperty()); + + // Odd non-negative integer: if passes (≥ 0), then (multipleOf 2) fails + $this->expectException(ConditionalException::class); + new $className(['property' => 3]); + } } diff --git a/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php index 6348ed08..5e210e0b 100644 --- a/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php +++ b/tests/PropertyProcessor/Filter/CompositionBranchClassifierTest.php @@ -86,9 +86,8 @@ public function testTypeBranchStringClassifiesAsInput(): void public function testTypeBranchObjectClassifiesAsInput(): void { - // `type` validates raw JSON value structure — not the post-filter PHP class instance. - // 'object' is not in the declared output types (['DateTime']), so the type constraint - // is input-space regardless of the object-expansion used for other keywords. + // The `type` keyword always validates the raw JSON value structure (pre-transform), + // so it always returns Input regardless of filter output types. $this->assertSame( TypeSpace::Input, $this->classifierForStringToDateTime()->classify(['type' => 'object']), @@ -97,8 +96,7 @@ public function testTypeBranchObjectClassifiesAsInput(): void public function testTypeBranchStringAndObjectBothClassifyAsInput(): void { - // 'string' → Input (in inputTypes); 'object' → not in declared outputTypes → Empty → Input. - // Net contribution: Input only → branch is Input. + // `type` always returns Input — multi-value type arrays are also Input. $this->assertSame( TypeSpace::Input, $this->classifierForStringToDateTime()->classify(['type' => ['string', 'object']]), @@ -107,8 +105,8 @@ public function testTypeBranchStringAndObjectBothClassifyAsInput(): void public function testTypeBranchOutsideBothSpacesDefaultsToInput(): void { - // integer is neither string (input) nor object/DateTime (output) for this filter; - // the type keyword contributes nothing → all keywords ambiguous → liberal: Input. + // `type` always returns Input regardless of whether the declared type is in the + // filter's input or output type-space. $this->assertSame( TypeSpace::Input, $this->classifierForStringToDateTime()->classify(['type' => 'integer']), @@ -123,10 +121,13 @@ public function testTypeBranchIntegerClassifiesAsInputForIntToStringFilter(): vo ); } - public function testTypeBranchStringClassifiesAsOutputForIntToStringFilter(): void + public function testTypeBranchStringClassifiesAsInputForIntToStringFilter(): void { + // `type` is always Input — it validates the raw input structure, not the transformed output. + // Even though 'string' is in the output types of the int→string filter, the type constraint + // applies to the raw (pre-transform) value and must run pre-transform. $this->assertSame( - TypeSpace::Output, + TypeSpace::Input, $this->classifierForIntegerToString()->classify(['type' => 'string']), ); } @@ -275,6 +276,24 @@ public function testArrayKeywordDefaultsToInputForStringToDateTimeFilter( // Mixed branches (input + output keywords together) // ------------------------------------------------------------------------- + public function testTypeBranchWithNumericConstraintClassifiesAsMixedForStringToIntFilter(): void + { + // type:integer → Input (type always validates raw input). + // minimum:0 → Output (numeric keyword targets the int output type of stringToInt). + // Combination → Mixed. This is the key correctness invariant: schemas that combine a + // type assertion with value constraints in the same branch are correctly rejected as + // unresolvable rather than silently classifying the type check as output-space. + $classifier = new CompositionBranchClassifier( + (new Draft_07())->getDefinition()->build(), + ['string'], + ['int'], + ); + $this->assertSame( + TypeSpace::Mixed, + $classifier->classify(['type' => 'integer', 'minimum' => 0]), + ); + } + public function testBranchWithInputAndOutputKeywordsClassifiesAsMixed(): void { $this->assertSame( @@ -340,7 +359,7 @@ public function testNestedAllOfWithAllOutputKeywordBranchesClassifiesAsOutput(): public function testNestedAllOfWithTypeObjectBranchAndOutputKeywordClassifiesAsMixed(): void { - // type:object → Input (type keyword uses declared output types, 'object' ∉ ['DateTime']). + // type:object → Input (type always validates raw input). // minProperties → Output (object-targeted keyword; effective output includes 'object'). // Mix → Mixed. $this->assertSame( @@ -356,8 +375,8 @@ public function testNestedAllOfWithTypeObjectBranchAndOutputKeywordClassifiesAsM public function testNestedAllOfWithTwoInputTypeBranchesClassifiesAsInput(): void { - // type:string → Input; type:object → Input (type keyword uses declared output types, - // 'object' ∉ ['DateTime']). Both branches are Input → allOf is Input. + // type:string → Input; type:object → Input (type always validates raw input). + // Both branches are Input → allOf is Input. $this->assertSame( TypeSpace::Input, $this->classifierForStringToDateTime()->classify([ @@ -414,7 +433,7 @@ public function testNestedAnyOfWithAllOutputKeywordBranchesClassifiesAsOutput(): public function testNestedAnyOfWithTypeObjectBranchAndOutputKeywordClassifiesAsMixed(): void { - // type:object → Input (type keyword uses declared output types, 'object' ∉ ['DateTime']). + // type:object → Input (type always validates raw input). // minProperties → Output. Mix → Mixed. $this->assertSame( TypeSpace::Mixed, diff --git a/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfConflictingTypes.json b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfConflictingTypes.json new file mode 100644 index 00000000..01e16b59 --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfConflictingTypes.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "property": { + "allOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfIntegerNumber.json b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfIntegerNumber.json new file mode 100644 index 00000000..89d279aa --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfIntegerNumber.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "property": { + "allOf": [ + { + "type": "integer" + }, + { + "type": "number" + } + ] + } + } +} diff --git a/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfUntypedBranch.json b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfUntypedBranch.json new file mode 100644 index 00000000..fe9fd119 --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/PropertyLevelAllOfUntypedBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "property": { + "allOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "maximum": 150 + } + ] + } + } +} diff --git a/tests/Schema/ComposedAnyOfTest/PropertyLevelAnyOfUntypedBranch.json b/tests/Schema/ComposedAnyOfTest/PropertyLevelAnyOfUntypedBranch.json new file mode 100644 index 00000000..e40cd69a --- /dev/null +++ b/tests/Schema/ComposedAnyOfTest/PropertyLevelAnyOfUntypedBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "property": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "maximum": 150 + } + ] + } + } +} diff --git a/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseAbsentElse.json b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseAbsentElse.json new file mode 100644 index 00000000..2d239144 --- /dev/null +++ b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseAbsentElse.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "property": { + "if": { + "minimum": 0 + }, + "then": { + "type": "integer" + } + } + } +} diff --git a/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseConflictingTypes.json b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseConflictingTypes.json new file mode 100644 index 00000000..1fc7da22 --- /dev/null +++ b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseConflictingTypes.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "if": { + "minLength": 5 + }, + "then": { + "type": "integer" + }, + "else": { + "type": "boolean" + } + } + } +} diff --git a/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseParentTypePreserved.json b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseParentTypePreserved.json new file mode 100644 index 00000000..c344762c --- /dev/null +++ b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseParentTypePreserved.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "integer", + "if": { + "minimum": 0 + }, + "then": { + "multipleOf": 2 + }, + "else": { + "maximum": -1 + } + } + } +} diff --git a/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseTypeWidening.json b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseTypeWidening.json new file mode 100644 index 00000000..e6e407fd --- /dev/null +++ b/tests/Schema/ComposedIfTest/PropertyLevelIfThenElseTypeWidening.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "property": { + "if": { + "type": "integer" + }, + "then": { + "type": "integer", + "minimum": 0 + }, + "else": { + "type": "string", + "minLength": 1 + } + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionAllOfContradictoryTypes.json similarity index 66% rename from tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json rename to tests/Schema/FilterTest/FilterCompositionAllOfContradictoryTypes.json index dccd40f0..b4840198 100644 --- a/tests/Schema/FilterTest/FilterCompositionAllOfOutputSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionAllOfContradictoryTypes.json @@ -2,12 +2,13 @@ "type": "object", "properties": { "filteredProperty": { - "type": "string", "filter": "stringToInt", "allOf": [ { - "type": "integer", - "minimum": 0 + "type": "integer" + }, + { + "type": "string" } ] } diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json b/tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json new file mode 100644 index 00000000..3e351d13 --- /dev/null +++ b/tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "allOf": [ + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json b/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json index d34c8521..d3bfd4a9 100644 --- a/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json +++ b/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json @@ -2,6 +2,10 @@ "type": "object", "properties": { "filteredProperty": { + "type": [ + "string", + "integer" + ], "filter": "stringToInt", "allOf": [ { @@ -9,7 +13,6 @@ "minLength": 1 }, { - "type": "integer", "minimum": 0 } ] diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json index 0ccb5a5f..6ec0649a 100644 --- a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json +++ b/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json @@ -3,13 +3,15 @@ "properties": { "filteredProperty": { "filter": "stringToInt", + "type": [ + "string", + "integer" + ], "allOf": [ { - "type": "integer", "minimum": 0 }, { - "type": "integer", "maximum": 100 } ] diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json index 53cdf355..3984c94b 100644 --- a/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json @@ -2,16 +2,17 @@ "type": "object", "properties": { "filteredProperty": { - "type": "string", + "type": [ + "string", + "integer" + ], "filter": "stringToInt", "anyOf": [ { - "type": "integer", "minimum": 0, "maximum": 10 }, { - "type": "integer", "minimum": 20, "maximum": 30 } diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json index 3162c916..165401ab 100644 --- a/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json @@ -2,18 +2,18 @@ "type": "object", "properties": { "filteredProperty": { - "type": "string", + "type": [ + "string", + "integer" + ], "filter": "stringToInt", "if": { - "type": "integer", "minimum": 0 }, "then": { - "type": "integer", "maximum": 100 }, "else": { - "type": "integer", "minimum": -100 } } diff --git a/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json index 8242b906..a2daa143 100644 --- a/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json @@ -2,9 +2,12 @@ "type": "object", "properties": { "filteredProperty": { + "type": [ + "string", + "integer" + ], "filter": "stringToInt", "not": { - "type": "integer", "minimum": 0 } } diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json b/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json index 22165300..a50b4615 100644 --- a/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json +++ b/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json @@ -2,16 +2,17 @@ "type": "object", "properties": { "filteredProperty": { - "type": "string", + "type": [ + "string", + "integer" + ], "filter": "stringToInt", "oneOf": [ { - "type": "integer", "minimum": 0, "maximum": 10 }, { - "type": "integer", "minimum": 20, "maximum": 30 } From 9e628a3f1df9721fa46e78aa5edf227a63ce3b97 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Fri, 15 May 2026 16:52:30 +0200 Subject: [PATCH 08/11] Coverage gaps, heredoc style, and filter/composition test methods - Add B.3/C.4 rejection provider entries and 5 schema fixtures for filter/composition coverage gaps - Add A.3, B.5, C.1, C.6 test methods covering if/then-only pre-transform, empty allOf branch no-op, root-level input-space constraint, and non-transforming filter composition variants with exception message assertions - Convert all old-style (column-0) heredoc closing markers to PHP 7.3+ indented style across 16 test files - Move inline << --- .../CompositionCompatibilityChecker.php | 10 +- .../FilterPreTransformGuardValidator.php | 12 +- .../Filter/FilterProcessor.php | 28 +- tests/Basic/AdditionalPropertiesTest.php | 28 +- tests/Basic/ErrorCollectionTest.php | 56 ++-- tests/Basic/FilterTest.php | 202 ++++++++++++- tests/Basic/PropertyDependencyTest.php | 38 +-- tests/Basic/PropertyNamesTest.php | 68 ++--- tests/Basic/SchemaDependencyTest.php | 122 ++++---- tests/ComposedValue/ComposedAllOfTest.php | 38 ++- tests/ComposedValue/ComposedAnyOfTest.php | 29 +- tests/ComposedValue/ComposedIfTest.php | 34 +-- tests/ComposedValue/ComposedNotTest.php | 24 +- tests/ComposedValue/ComposedOneOfTest.php | 53 ++-- tests/Objects/ArrayPropertyTest.php | 270 +++++++++--------- tests/Objects/MultiTypePropertyTest.php | 48 ++-- tests/Objects/ReferencePropertyTest.php | 24 +- tests/Objects/TupleArrayPropertyTest.php | 116 ++++---- ...ernPropertiesAccessorPostProcessorTest.php | 34 +-- .../PopulatePostProcessorTest.php | 38 ++- .../FilterCompositionAnyOfMixedBranch.json | 14 + .../FilterCompositionAnyOfWithTrim.json | 17 ++ ...FilterCompositionFilterInNestedBranch.json | 17 ++ .../FilterCompositionIfThenWithTrim.json | 15 + .../FilterCompositionNotWithTrim.json | 12 + 25 files changed, 820 insertions(+), 527 deletions(-) create mode 100644 tests/Schema/FilterTest/FilterCompositionAnyOfMixedBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionAnyOfWithTrim.json create mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInNestedBranch.json create mode 100644 tests/Schema/FilterTest/FilterCompositionIfThenWithTrim.json create mode 100644 tests/Schema/FilterTest/FilterCompositionNotWithTrim.json diff --git a/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php index d39351e9..bddd1ed0 100644 --- a/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php +++ b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php @@ -292,8 +292,16 @@ public static function branchContainsFilter(array $branchSchema): bool return true; } + // Object-typed branches create nested schemas whose properties are processed + // independently of ComposedItem $value resets, so their inner filters are applied + // correctly. Accept both the string form ('object') and the single-element array form + // (['object']) — type inheritance may inject the parent type as an array. + $branchType = $branchSchema['type'] ?? null; + $isObjectTyped = $branchType === 'object' + || (is_array($branchType) && in_array('object', $branchType, true)); + if ( - ($branchSchema['type'] ?? null) !== 'object' + !$isObjectTyped && isset($branchSchema['properties']) && is_array($branchSchema['properties']) ) { diff --git a/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php index 65b35ffb..46a78c09 100644 --- a/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php +++ b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php @@ -31,7 +31,17 @@ public function __construct( /** * Sets scope on both this guard and the wrapped inner validator, and registers the - * inner validator's extracted method on the schema so the guard method can call it. + * inner validator's extracted method in the schema. + * + * RenderHelper::renderValidator() processes only validators that are directly in the + * property's validators list. The inner wrapped validator is not on that list, so + * RenderHelper never registers its extracted method automatically. The guard method's + * generated code calls $this->innerMethodName by name, which must exist as a class + * method. This block registers it manually; the hasMethod check prevents + * double-registration when setScope() is called more than once on the same guard. + * + * $this->inner->setScope($schema) sets the inner validator's $scope field so that + * its getCheck() can reference $this->scope during template rendering. */ public function setScope(Schema $schema): void { diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index ddeaeeeb..42fc62f8 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -73,6 +73,7 @@ public function process( $filterList = self::normalizeFilterList($filterList); $transformingFilter = null; + $builtDraft = null; // apply a different priority to each filter to make sure the order is kept $filterPriority = $startPriority + count($property->getValidators()); @@ -145,7 +146,12 @@ public function process( $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); $inputTypeNames = FilterReflection::getAcceptedTypes($filter, $property); - $builtDraft = $this->resolveBuiltDraft($generatorConfiguration, $property); + // Build the Draft at most once per process() call. A filter chain can contain + // only one transforming filter (a second throws above), but process() may be + // called externally multiple times on the same property; the null-coalescing + // assignment ensures we do not rebuild when called from a MediaStringModifier + // followed by a FilterValidatorFactory invocation. + $builtDraft ??= $this->resolveBuiltDraft($generatorConfiguration, $property); $classifier = new CompositionBranchClassifier($builtDraft, $inputTypeNames, $returnTypeNames); $checker = new CompositionCompatibilityChecker($classifier, $property); $checker->checkTransformingFilterCompositionConflicts($property->getJsonSchema()->getJson()); @@ -276,7 +282,7 @@ private function addExtendedInstanceOfCheckForObjectBranches( $property->addValidator( new PropertyValidator( $property, - "is_object(\$value) && !(\$value instanceof \\Exception) && !($instanceOfParts)", + "is_object(\$value) && !($instanceOfParts)", InvalidInstanceOfException::class, [reset($objectReturnTypes)], ), @@ -392,8 +398,10 @@ private function classifyValidatorAdjustments( $property->getJsonSchema()->getJson(), ); - // Only allOf can have mixed spaces (static rejection guarantees anyOf/oneOf/not/ - // if-then-else have uniform spaces). Collect mixed-space allOf validators for + // Only allOf can have mixed spaces. The CompositionCompatibilityChecker + // statically guarantees that anyOf, oneOf, not, and if/then/else validators + // all have uniform spaces (entirely input-space or entirely output-space), + // so only allOf needs splitting. Collect mixed-space allOf validators for // splitting in splitMixedSpaceAllOf(). if ( !empty($inputIndices) && !empty($outputIndices) @@ -614,10 +622,13 @@ private function resolveOriginalBranchSchemas( } if ($processorClass === IfValidatorFactory::class) { - // ConditionalPropertyValidator's getComposedProperties() returns non-null if/then/else - // properties in declaration order. Reproduce that order from the original JSON. + // ConditionalPropertyValidator::getComposedProperties() returns the then and else + // composition properties only — the if condition branch is stored separately in + // getConditionBranches() and is not iterated in classifyComposedValidatorBranches(). + // Reproduce the then/else order from the original JSON so index 0 maps to then + // and index 1 maps to else, matching the order of getComposedProperties(). $result = []; - foreach (['if', 'then', 'else'] as $keyword) { + foreach (['then', 'else'] as $keyword) { if (isset($originalPropertyJson[$keyword]) && is_array($originalPropertyJson[$keyword])) { $result[] = $originalPropertyJson[$keyword]; } @@ -737,7 +748,8 @@ public function extendTypeCheckValidatorToAllowTransformedValue( * Build and return the Draft instance for the given property's schema. * * Resolves DraftFactoryInterface vs DraftInterface from the GeneratorConfiguration - * and builds the immutable Draft registry. + * and builds the immutable Draft registry. Callers should cache the result rather + * than calling this method more than once per process() invocation. */ private function resolveBuiltDraft( GeneratorConfiguration $generatorConfiguration, diff --git a/tests/Basic/AdditionalPropertiesTest.php b/tests/Basic/AdditionalPropertiesTest.php index 84f6ff7f..accfaec9 100644 --- a/tests/Basic/AdditionalPropertiesTest.php +++ b/tests/Basic/AdditionalPropertiesTest.php @@ -146,10 +146,10 @@ public function testInvalidTypedAdditionalPropertiesThrowsAnException( public static function invalidTypedAdditionalPropertiesDataProvider(): array { $exception = << [ ['additional1' => ['name' => 12], 'additional2' => ['name' => 'AB', 'age' => '12']], << [ 6, << [ 0, << [ 1, << [ "4", <<expectException(ErrorRegistryException::class); - $this->expectExceptionMessage(<<expectExceptionMessage( + <<generateClassFromFile( 'TransformingFilter.json', @@ -1683,6 +1685,18 @@ public static function rejectedCompositionProvider(): array '/A filter keyword inside an if\/then\/else composition branch is not supported' . ' for property filteredProperty.*if sub-schema/', ], + // Filter inside a deeply-nested allOf/anyOf branch: recursive scan must descend. + 'filter inside nested allOf\/anyOf branch' => [ + 'FilterCompositionFilterInNestedBranch.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // anyOf branch spanning both input and output type-spaces is ambiguous. + 'anyOf with single Mixed branch' => [ + 'FilterCompositionAnyOfMixedBranch.json', + '/Composition anyOf under property filteredProperty' + . '.*branch #0 spans both input and output type-spaces/', + ], ]; } @@ -2949,4 +2963,184 @@ public function testTransformingFilterWithSelfOrStaticReturnType( $object->setFilteredProperty($existing); $this->assertSame($existing, $object->getFilteredProperty()); } + + /** + * if/then (no else) under a transforming filter: the conditional runs pre-transform. + * When the if-condition fails (short string), no else means the value passes through. + * When the if-condition passes but then fails, ConditionalException is thrown pre-transform. + * An already-transformed DateTime bypasses the pre-transform conditional entirely. + */ + public function testInputSpaceIfThenOnlyCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenOnlyInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "20240101" satisfies if (minLength:8) and then (maxLength:20) → filter runs → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "now" (3 chars) fails if (minLength:8) → no else → conditional passes → filter → DateTime. + $object = new $className(['filteredProperty' => 'now']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Overlong string: if passes, then fails → ConditionalException thrown pre-transform. + try { + new $className(['filteredProperty' => 'abcdefghijklmnopqrstuvwxyz']); + $this->fail('Expected ConditionalException for overlong string'); + } catch (ConditionalException $exception) { + $this->assertNull($exception->getIfException()); + $this->assertNotNull($exception->getThenException()); + $this->assertNull($exception->getElseException()); + $this->assertStringContainsString( + 'Value for filteredProperty must not be longer than 20', + $exception->getMessage(), + ); + } + + // Already-transformed DateTime skips the pre-transform conditional (R-8 passthrough). + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * An empty {} allOf branch under a transforming filter is a no-op: it imposes no constraints + * so the filter runs and the value is transformed. An already-transformed DateTime passes + * through unchanged (R-8 passthrough). + */ + public function testEmptyAllOfBranchWithTransformingFilterIsNoOp(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfEmptyBranch.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * A root-level allOf with an input-space constraint targeting a filtered sub-property is + * allowed at generation time. At runtime the property validator runs first (including the + * filter), so the root-level constraint sees the already-transformed output value. For the + * dateTime filter, minLength:1 on a DateTime object is a no-op — the constraint is + * statically allowed but effectively inert against the transformed value. + */ + public function testRootLevelInputSpaceConstraintOnFilteredSubpropertyRunsSuccessfully(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Non-transforming filter (trim) with anyOf, not, and if/then composition: + * the composition validators run on the already-trimmed value, not the raw string. + */ + public function testNonTransformingFilterCompositionVariantsValidateAfterFilter(): void + { + // anyOf: collectErrors(true) required so per-branch failure details are populated. + $anyOfClass = $this->generateClassFromFile( + 'FilterCompositionAnyOfWithTrim.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + $object = new $anyOfClass(['filteredProperty' => ' hello ']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // " no " → trim → "no" (2 chars): both anyOf branches fail post-trim. + // Branch 1: minLength:5 fails (proves trim ran, not raw 12-char string). + // Branch 2: const:"hi" fails. + try { + new $anyOfClass(['filteredProperty' => ' no ']); + $this->fail('Expected AnyOfException for padded "no"'); + } catch (ErrorRegistryException $registryException) { + $errors = $registryException->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AnyOfException::class, $errors); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + $this->assertStringContainsString( + <<getMessage(), + ); + } + + // not: collectErrors(true) required so per-element details are populated. + $notClass = $this->generateClassFromFile( + 'FilterCompositionNotWithTrim.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + $object = new $notClass(['filteredProperty' => ' hello ']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // " " → trim → "" (empty string): not{const:""} is violated post-trim. + // Raw " " (non-empty) would pass not{const:""} pre-trim, proving trim ran first. + try { + new $notClass(['filteredProperty' => ' ']); + $this->fail('Expected NotException for whitespace-only string'); + } catch (ErrorRegistryException $registryException) { + $errors = $registryException->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + $this->assertStringContainsString( + <<getMessage(), + ); + } + + // if/then: collectErrors(false) is fine; ConditionalException is thrown directly. + $ifThenClass = $this->generateClassFromFile( + 'FilterCompositionIfThenWithTrim.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // " Alice " → trim → "Alice" → pattern:^A passes → no exception (proves post-trim). + $object = new $ifThenClass(['filteredProperty' => ' Alice ']); + $this->assertSame('Alice', $object->getFilteredProperty()); + + // " Bob " → trim → "Bob" → if minLength:1 passes → then pattern:^A fails → ConditionalException. + try { + new $ifThenClass(['filteredProperty' => ' Bob ']); + $this->fail('Expected ConditionalException for " Bob "'); + } catch (ConditionalException $exception) { + $this->assertNull($exception->getIfException()); + $this->assertNotNull($exception->getThenException()); + $this->assertStringContainsString( + <<getMessage(), + ); + } + } } diff --git a/tests/Basic/PropertyDependencyTest.php b/tests/Basic/PropertyDependencyTest.php index 08b96133..acc94994 100644 --- a/tests/Basic/PropertyDependencyTest.php +++ b/tests/Basic/PropertyDependencyTest.php @@ -79,9 +79,9 @@ public function testInvalidPropertyDependencyThrowsAnException(GeneratorConfigur $this->expectValidationError( $configuration, <<generateClassFromFile('PropertyDependency.json', $configuration); @@ -110,24 +110,24 @@ public static function invalidMultiplePropertyDependenciesDataProvider(): array 'no required attribute provided' => [ ['credit_card' => 12345], << [ ['credit_card' => 12345, 'billing_address' => '555 Debitors Lane'], << [ ['credit_card' => 12345, 'name' => 'John'], << 12345], << '555 Debitors Lane'], << 1, ], << [ '{"pattern": "^test[0-9]+$"}', @@ -135,14 +135,14 @@ public static function invalidPropertyNamesDataProvider(): array 'test12w12' => 1, ], << [ '{"const": "test"}', @@ -152,12 +152,12 @@ public static function invalidPropertyNamesDataProvider(): array 'bla' => 3, ], << 1, ], << [ new GeneratorConfiguration(), @@ -191,14 +191,14 @@ public static function invalidCombinedPropertyNamesDataProvider(): array 'test' => 1, ], << [ ['credit_card' => 12345], << [ ['credit_card' => 12345, 'billing_address' => '555 Debitors Lane'], << [ ['credit_card' => 12345, 'date_of_birth' => false], << [ ['credit_card' => 12345], << [ ['credit_card' => 12345, 'age' => 42], << [ ['credit_card' => 12345, 'name' => false], << [ ['credit_card' => 12345], << [ ['credit_card' => 12345, 'age' => 42], << [ ['credit_card' => 12345, 'name' => false, 'age' => 42], << [ ['credit_card' => 12345], << [ ['credit_card' => 12345, 'owner' => ['age' => 42]], << [ [ @@ -333,10 +331,10 @@ public static function invalidSchemaDependencyNestedObjectDataProvider(): array 'billing_address' => '555 Debitors Lane', ], << [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), <<expectException(ValidationException::class); - $this->expectExceptionMessage(<<expectExceptionMessage( + <<generateClassFromFile('EmptyAnyOf.json'); @@ -617,20 +618,20 @@ public static function validationInSetterDataProvider(): array 'Exception Collection' => [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), << [ -50, << [ 50, << [ 120, <<expectException(ValidationException::class); - $this->expectExceptionMessage(<<expectExceptionMessage( + <<generateClassFromFile('EmptyNot.json'); @@ -365,18 +366,17 @@ public static function validationInSetterDataProvider(): array 'Exception Collection' => [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), <<expectException(ValidationException::class); - $this->expectExceptionMessage(<<expectExceptionMessage( + <<generateClassFromFile('EmptyOneOf.json'); @@ -107,9 +108,9 @@ public function testNotProvidedObjectLevelOneOfThrowsAnException(string $schema, $this->expectException(ValidationException::class); $this->expectExceptionMessageMatches( <<generateClassFromFile($schema); @@ -523,32 +524,30 @@ public static function validationInSetterDataProvider(): array 'Exception Collection' => [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), << [ '"string"', ['a', 'b', null], << [ '"integer"', [1, 2, 3, '4'], << [ '"integer"', [1, 2, 3, 2.5], << [ '"number"', [1, 1.1, 4.5, 6, []], << [ '"boolean"', [true, false, true, 3], << [ '"null"', [null, null, 'null'], << [ '"boolean"', [true, false, true, 3, true, 'true'], << [ '"array","items":{"type":"integer"}', [[1, 2], [], null], << [ '"array","items":{"type":"integer"}', [[1, 2], [], 3], << [ '"array","items":{"type":"integer"}', [[1, '2'], [], [3]], << [ '["string", "integer"]', ['a', 1, true, 'true', [], -6], << [ [null], << [ [['name' => 'Hannes'], true], << [ [['name' => 'Hannes'], ['age' => 42]], << [ [['name' => 'Hannes'], ['name' => false, 'age' => 42]], << [ [['name' => false, 'age' => 42], ['name' => 'Frida', 'age' => 'yes'], 5, []], << [ [['name' => 'Hannes'], true], << [ [['name' => 'Hannes'], ['age' => 42]], << [ [['name' => 'Hannes'], ['name' => false, 'age' => 42]], << [ [['name' => false, 'age' => 42], ['name' => 'F', 'age' => 'yes'], 5, []], << [ ['Hello', 123], << [ ['name' => 42], << [ ['name' => 'Hans', 'age' => 42], << [ [], @@ -299,21 +299,21 @@ public static function invalidRecursiveMultiTypeDataProvider(): array ['Test1', [3, 'Test3']], InvalidItemException::class, << [ ['Test1', []], InvalidItemException::class, << [ ['personA' => 10, 'personB' => false], << [ ['personA' => ['name' => 'A'], 'personB' => ['name' => 10]], << [ ['personA' => ['name' => 'A'], 'personB' => 10], << [ [400, ''], << [ ['400', 'Avenue', ['name' => 'Hans', 'age' => 42]], << [ [2, 'Boulevard', ['name' => 'Hans', 'age' => 42]], << [ [400, 'Way', ['name' => 'Hans', 'age' => 42]], << [ [400, 'Street', ['age' => 42]], << [ [400, 'Street', ['name' => 'Hans', 'age' => true]], << [ [['name' => 'Hans', 'age' => 42], 'Street', 400], << [ [null, null, ['name' => 'Hans', 'age' => 42]], << [ [3, 'Avenue', 0, 'asx', null, 'ADC', false], << 'Jens', 'age' => false] ], << [ 'b0', 100, << [ 'alpha', 5, << [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), << Date: Sat, 16 May 2026 01:01:33 +0200 Subject: [PATCH 09/11] Split FilterTest into focused test classes under tests/Filter/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monolithic Basic/FilterTest (~3050 lines) is replaced by eight focused classes under tests/Filter/, each with its own schema directory: - FilterConfigurationTest — registration, lookup, invalid callbacks - BuiltInFilterTest — trim filter, type rejection, length validation - CustomFilterTest — custom callables, multiple filters, array filter, ValidateOptionsInterface - TransformingFilterTest — dateTime filter, serialization, unsupported scenarios, scalar transforms, enum interaction, self/static return types - FilterTypeCompatibilityTest — overlap rules, untyped properties, union type hints, bypass formulas, callable reflection errors - FilterChainTest — chain ordering, skip logic, incompatibility rejection, mixed-return and accept-all follow-up - FilterCompositionStaticTest — generation-time rejection/acceptance of filter+composition combinations - FilterCompositionRuntimeTest — runtime pre/post-transform ordering for all composition types, validator priority, format validator skip guard AbstractFilterTestCase holds shared helper factories (getCustomFilter, getCustomTransformingFilter) and static callables used by multiple classes. SelfReturningFilterCallable and StaticReturningFilterCallable are moved to the new namespace. Schema files are distributed into per-class directories; five schemas used by two classes are duplicated; four orphaned schemas with no referencing test are deleted. Co-Authored-By: Claude Sonnet 4.5 --- tests/Basic/FilterTest.php | 3146 ----------------- tests/Filter/AbstractFilterTestCase.php | 131 + tests/Filter/BuiltInFilterTest.php | 153 + tests/Filter/CustomFilterTest.php | 212 ++ tests/Filter/FilterChainTest.php | 428 +++ tests/Filter/FilterCompositionRuntimeTest.php | 1039 ++++++ tests/Filter/FilterCompositionStaticTest.php | 241 ++ tests/Filter/FilterConfigurationTest.php | 61 + tests/Filter/FilterTypeCompatibilityTest.php | 532 +++ .../SelfReturningFilterCallable.php | 8 +- .../StaticReturningFilterCallable.php | 8 +- tests/Filter/TransformingFilterTest.php | 351 ++ .../TrimAsList.json | 0 .../TrimAsString.json | 0 .../TrimAsStringWithLengthValidation.json | 0 .../ArrayFilter.json | 0 .../Encode.json | 0 .../MultipleFilters.json | 0 .../Uppercase.json | 0 .../FilterChain.json | 0 .../FilterChainMultiType.json | 0 .../StringIntegerPropertyFilterChain.json | 0 .../UntypedPropertyFilterChain.json | 0 ...ertyWithMixedAcceptTransformingFilter.json | 0 .../FilterCompositionAllOfEmptyBranch.json | 0 .../FilterCompositionAllOfInputSpace.json | 0 .../FilterCompositionAllOfMixedSpaces.json | 0 .../FilterCompositionAllOfOutputOnly.json | 0 .../FilterCompositionAllOfWithTrim.json | 0 .../FilterCompositionAnyOfInputOnly.json | 0 .../FilterCompositionAnyOfOutputSpace.json | 0 ...FilterCompositionIfThenElseInputSpace.json | 0 ...ilterCompositionIfThenElseOutputSpace.json | 0 ...FilterCompositionIfThenOnlyInputSpace.json | 0 .../FilterCompositionNotInputSpace.json | 0 .../FilterCompositionNotOutputSpace.json | 0 .../FilterCompositionOneOfInputSpace.json | 0 .../FilterCompositionOneOfOutputSpace.json | 0 ...putSpaceConstrainsFilteredSubproperty.json | 0 ...MultiTypeFormatWithTransformingFilter.json | 0 ...lidatorPriorityWithTransformingFilter.json | 0 ...terCompositionAllOfContradictoryTypes.json | 0 .../FilterCompositionAllOfDeadFilter.json | 0 .../FilterCompositionAllOfEmptyBranch.json | 11 + .../FilterCompositionAllOfInputOnly.json | 0 .../FilterCompositionAllOfMixedBranch.json | 0 ...terCompositionAllOfObjectBranchOutput.json | 0 .../FilterCompositionAnyOfCrossSpace.json | 0 .../FilterCompositionAnyOfInputOnly.json} | 7 +- .../FilterCompositionAnyOfMixedBranch.json | 0 .../FilterCompositionFilterInAnyOfBranch.json | 0 .../FilterCompositionFilterInBranch.json | 0 ...ompositionFilterInBranchNoOuterFilter.json | 0 ...ionFilterInIfThenElseIfThenElseBranch.json | 0 ...FilterCompositionFilterInNestedBranch.json | 0 .../FilterCompositionFilterInNotBranch.json | 0 .../FilterCompositionFilterInOneOfBranch.json | 0 ...FilterCompositionIfElseOnlyInputSpace.json | 0 ...FilterCompositionIfThenElseCrossSpace.json | 0 .../FilterCompositionIfThenElseInputOnly.json | 0 ...ilterCompositionIfThenOnlyInputSpace.json} | 6 +- .../FilterCompositionNotMixed.json | 0 .../FilterCompositionOneOfCrossSpace.json | 0 .../FilterCompositionOneOfInputOnly.json | 0 ...ootAnyOfConstrainsFilteredSubproperty.json | 0 ...ositionRootBranchWithFilterInProperty.json | 0 ...tionRootConstrainsFilteredSubproperty.json | 0 ...onRootIfConstrainsFilteredSubproperty.json | 0 ...putSpaceConstrainsFilteredSubproperty.json | 17 + ...nRootNotConstrainsFilteredSubproperty.json | 0 ...ootOneOfConstrainsFilteredSubproperty.json | 0 .../NonExistingFilter.json | 0 ...erCompositionFilterInIfThenElseBranch.json | 13 - .../IntegerPropertyCustomFilter.json | 0 .../IntegerPropertyMixedFilter.json | 0 .../IntegerPropertyZeroOverlapFilter.json | 0 .../StringIntegerPropertyBinaryFilter.json | 0 .../StringIntegerPropertyCustomFilter.json | 0 .../StringNullPropertyCustomFilter.json | 0 .../StringNullPropertyStrOrNullFilter.json | 0 .../StringPropertyAcceptAllFilter.json | 0 .../StringPropertyIntOrStringFilter.json | 0 .../StringPropertyMixedFilter.json | 0 .../StringPropertyNeverReturnFilter.json | 0 .../StringPropertyNoReturnTypeFilter.json | 0 .../StringPropertyNoTypeHintFilter.json | 0 .../StringPropertyVoidReturnFilter.json | 0 .../UntypedPropertyCustomFilter.json | 0 .../UntypedPropertyFilter.json | 0 .../UntypedPropertyMixedFilter.json | 0 .../ArrayTransformingFilter.json | 0 .../DefaultValueFilter.json | 0 .../EnumBeforeFilter.json | 0 .../FilterChain.json} | 7 +- .../FilterOptions.json | 0 .../FilterOptionsChainNotation.json | 0 .../TransformingFilter.json | 0 .../TransformingScalarFilter.json | 0 98 files changed, 3192 insertions(+), 3179 deletions(-) delete mode 100644 tests/Basic/FilterTest.php create mode 100644 tests/Filter/AbstractFilterTestCase.php create mode 100644 tests/Filter/BuiltInFilterTest.php create mode 100644 tests/Filter/CustomFilterTest.php create mode 100644 tests/Filter/FilterChainTest.php create mode 100644 tests/Filter/FilterCompositionRuntimeTest.php create mode 100644 tests/Filter/FilterCompositionStaticTest.php create mode 100644 tests/Filter/FilterConfigurationTest.php create mode 100644 tests/Filter/FilterTypeCompatibilityTest.php rename tests/{Basic => Filter}/SelfReturningFilterCallable.php (72%) rename tests/{Basic => Filter}/StaticReturningFilterCallable.php (71%) create mode 100644 tests/Filter/TransformingFilterTest.php rename tests/Schema/{FilterTest => BuiltInFilterTest}/TrimAsList.json (100%) rename tests/Schema/{FilterTest => BuiltInFilterTest}/TrimAsString.json (100%) rename tests/Schema/{FilterTest => BuiltInFilterTest}/TrimAsStringWithLengthValidation.json (100%) rename tests/Schema/{FilterTest => CustomFilterTest}/ArrayFilter.json (100%) rename tests/Schema/{FilterTest => CustomFilterTest}/Encode.json (100%) rename tests/Schema/{FilterTest => CustomFilterTest}/MultipleFilters.json (100%) rename tests/Schema/{FilterTest => CustomFilterTest}/Uppercase.json (100%) rename tests/Schema/{FilterTest => FilterChainTest}/FilterChain.json (100%) rename tests/Schema/{FilterTest => FilterChainTest}/FilterChainMultiType.json (100%) rename tests/Schema/{FilterTest => FilterChainTest}/StringIntegerPropertyFilterChain.json (100%) rename tests/Schema/{FilterTest => FilterChainTest}/UntypedPropertyFilterChain.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/AllOfPropertyWithMixedAcceptTransformingFilter.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAllOfEmptyBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAllOfInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAllOfMixedSpaces.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAllOfOutputOnly.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAllOfWithTrim.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAnyOfInputOnly.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionAnyOfOutputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionIfThenElseInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionIfThenElseOutputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionIfThenOnlyInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionNotInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionNotOutputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionOneOfInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionOneOfOutputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/MultiTypeFormatWithTransformingFilter.json (100%) rename tests/Schema/{FilterTest => FilterCompositionRuntimeTest}/ValidatorPriorityWithTransformingFilter.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAllOfContradictoryTypes.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAllOfDeadFilter.json (100%) create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfEmptyBranch.json rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAllOfInputOnly.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAllOfMixedBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAllOfObjectBranchOutput.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAnyOfCrossSpace.json (100%) rename tests/Schema/{FilterTest/FilterCompositionAnyOfWithTrim.json => FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json} (59%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionAnyOfMixedBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInAnyOfBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInBranchNoOuterFilter.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInIfThenElseIfThenElseBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInNestedBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInNotBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionFilterInOneOfBranch.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionIfElseOnlyInputSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionIfThenElseCrossSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionIfThenElseInputOnly.json (100%) rename tests/Schema/{FilterTest/FilterCompositionIfThenWithTrim.json => FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json} (66%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionNotMixed.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionOneOfCrossSpace.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionOneOfInputOnly.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootBranchWithFilterInProperty.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootConstrainsFilteredSubproperty.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootIfConstrainsFilteredSubproperty.json (100%) create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootNotConstrainsFilteredSubproperty.json (100%) rename tests/Schema/{FilterTest => FilterCompositionStaticTest}/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json (100%) rename tests/Schema/{FilterTest => FilterConfigurationTest}/NonExistingFilter.json (100%) delete mode 100644 tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/IntegerPropertyCustomFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/IntegerPropertyMixedFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/IntegerPropertyZeroOverlapFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringIntegerPropertyBinaryFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringIntegerPropertyCustomFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringNullPropertyCustomFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringNullPropertyStrOrNullFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyAcceptAllFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyIntOrStringFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyMixedFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyNeverReturnFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyNoReturnTypeFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyNoTypeHintFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/StringPropertyVoidReturnFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/UntypedPropertyCustomFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/UntypedPropertyFilter.json (100%) rename tests/Schema/{FilterTest => FilterTypeCompatibilityTest}/UntypedPropertyMixedFilter.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/ArrayTransformingFilter.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/DefaultValueFilter.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/EnumBeforeFilter.json (100%) rename tests/Schema/{FilterTest/FilterCompositionNotWithTrim.json => TransformingFilterTest/FilterChain.json} (59%) rename tests/Schema/{FilterTest => TransformingFilterTest}/FilterOptions.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/FilterOptionsChainNotation.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/TransformingFilter.json (100%) rename tests/Schema/{FilterTest => TransformingFilterTest}/TransformingScalarFilter.json (100%) diff --git a/tests/Basic/FilterTest.php b/tests/Basic/FilterTest.php deleted file mode 100644 index c63a7922..00000000 --- a/tests/Basic/FilterTest.php +++ /dev/null @@ -1,3146 +0,0 @@ -assertSame('trim', (new GeneratorConfiguration())->getFilter('trim')->getToken()); - } - - public function testGetFilterReturnsNullForNonExistingFilter(): void - { - $this->assertNull((new GeneratorConfiguration())->getFilter('somethingElse')); - } - - #[DataProvider('invalidCustomFilterDataProvider')] - public function testAddInvalidFilterThrowsAnException(array $customInvalidFilter): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessage('Invalid filter callback for filter customFilter'); - - (new GeneratorConfiguration())->addFilter($this->getCustomFilter($customInvalidFilter)); - } - - public static function invalidCustomFilterDataProvider(): array - { - return [ - 'empty array' => [[]], - 'one element array' => [[Trim::class]], - 'Invalid class' => [[123, 'filter']], - 'Invalid function' => [[Trim::class, 123]], - 'Non existing class' => [['NonExistingClass', 'filter']], - 'Non existing function' => [[Trim::class, 'nonExistingMethod']], - 'three array' => [[Trim::class, 'filter', 'abc']], - ]; - } - - public function testNonExistingFilterThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage('Unsupported filter nonExistingFilter'); - - $this->generateClassFromFile('NonExistingFilter.json'); - } - - protected function getCustomFilter( - array $customFilter, - string $token = 'customFilter', - ): FilterInterface { - return new class ($customFilter, $token) implements FilterInterface { - public function __construct( - private readonly array $customFilter, - private readonly string $token, - ) {} - - public function getToken(): string - { - return $this->token; - } - - public function getFilter(): array - { - return $this->customFilter; - } - }; - } - - #[DataProvider('validBuiltInFilterDataProvider')] - public function testValidUsageOfBuiltInFilter(string $template, array $input, ?string $expected): void - { - $className = $this->generateClassFromFileTemplate($template, ['"string"'], null, false); - - $object = new $className($input); - - $this->assertSame($object->getProperty(), $expected); - // make sure the raw inout isn't affected by the filter - $this->assertSame($input, $object->getRawModelDataInput()); - } - - #[DataProvider('validTrimDataFormatProvider')] - public function testNotProvidedOptionalValueWithFilterIsValid(string $template): void - { - $className = $this->generateClassFromFileTemplate($template, ['"string"'], null, false); - - $object = new $className([]); - - $this->assertNull($object->getProperty()); - } - - public static function validTrimDataFormatProvider(): array - { - return [ - 'trimAsList' => ['TrimAsList.json'], - 'trimAsString' => ['TrimAsString.json'], - ]; - } - - public static function validBuiltInFilterDataProvider(): array - { - return self::combineDataProvider( - self::validTrimDataFormatProvider(), - [ - 'Optional Value not provided' => [[], null], - 'Null' => [['property' => null], null], - 'Empty string' => [['property' => ''], ''], - 'String containing only whitespaces' => [['property' => " \t \n \r "], ''], - 'Numeric string' => [['property' => ' 12 '], '12'], - 'Text' => [['property' => ' Hello World! '], 'Hello World!'], - ], - ); - } - - #[DataProvider('invalidUsageOfBuiltInFilterDataProvider')] - public function testInvalidUsageOfBuiltInFilterThrowsAnException( - string $template, - string $jsonType, - string $phpType, - ): void { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - "/Filter trim is not compatible with property type $phpType for property property/", - ); - - $this->generateClassFromFileTemplate($template, ['"' . $jsonType . '"'], null, false); - } - - public static function invalidUsageOfBuiltInFilterDataProvider(): array - { - return self::combineDataProvider( - self::validTrimDataFormatProvider(), - [ - 'boolean' => ['boolean', 'bool'], - 'integer' => ['integer', 'int'], - 'number' => ['number', 'float'], - 'array' => ['array', 'array'], - 'object' => ['object', 'object'], - ], - ); - } - - #[DataProvider('validLengthAfterFilterDataProvider')] - public function testLengthValidationForFilteredValueForValidValues(?string $input, ?string $expectedValue): void - { - $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); - - $object = new $className(['property' => $input]); - $this->assertSame($expectedValue, $object->getProperty()); - } - - public static function validLengthAfterFilterDataProvider(): array - { - return [ - 'String with two chars' => [" AB \n", "AB"], - 'null' => [null, null], - ]; - } - - #[DataProvider('invalidLengthAfterFilterDataProvider')] - public function testLengthValidationForFilteredValueForInvalidValuesThrowsAnException(string $input): void - { - $this->expectException(ValidationException::class); - $this->expectExceptionMessage('Value for property must not be shorter than 2'); - - $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); - - new $className(['property' => $input]); - } - - public static function invalidLengthAfterFilterDataProvider(): array - { - return [ - 'Empty string' => [''], - 'String with only whitespaces' => [" \n \t "], - 'Too short string' => [' a '], - ]; - } - - public static function uppercaseFilter(?string $value): ?string - { - return $value !== null ? strtoupper($value) : null; - } - - #[DataProvider('customFilterDataProvider')] - public function testCustomFilter(?string $input, ?string $expectedValue): void - { - $className = $this->generateClassFromFile( - 'Uppercase.json', - (new GeneratorConfiguration()) - ->setImmutable(false) - ->addFilter($this->getCustomFilter([self::class, 'uppercaseFilter'], 'uppercase')), - ); - - $object = new $className(['property' => $input]); - $this->assertSame($expectedValue, $object->getProperty()); - $this->assertSame($input, $object->getRawModelDataInput()['property']); - - $object->setProperty($input); - $this->assertSame($expectedValue, $object->getProperty()); - - $object->setProperty('hi'); - $this->assertSame('HI', $object->getProperty()); - $this->assertSame('hi', $object->getRawModelDataInput()['property']); - } - - public static function customFilterDataProvider(): array - { - return [ - 'null' => [null, null], - 'empty string' => ['', ''], - 'numeric' => ['123', '123'], - 'spaces' => [' ', ' '], - 'uppercase string' => ['ABC', 'ABC'], - 'mixed string' => ['Hello World!', 'HELLO WORLD!'], - ]; - } - - #[DataProvider('invalidEncodingFilterConfigurationsDataProvider')] - public function testInvalidCustomFilterOptionValidation(string $configuration, string $expectedErrorMessage): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - "/Invalid filter options on filter encode on property .*\: $expectedErrorMessage/", - ); - - $this->generateClassFromFileTemplate( - 'Encode.json', - [$configuration], - (new GeneratorConfiguration())->setImmutable(false)->addFilter($this->getEncodeFilter()), - false, - ); - } - - public static function invalidEncodingFilterConfigurationsDataProvider(): array - { - return [ - 'simple notation without options' => ['"encode"', 'Missing charset configuration'], - 'object notation without charset configuration' => [ - '{"filter": "encode"}', - 'Missing charset configuration', - ], - 'Invalid charset configuration' => ['{"filter": "encode", "charset": 1}', 'Unsupported charset'], - 'Invalid charset configuration 2' => ['{"filter": "encode", "charset": "UTF-16"}', 'Unsupported charset'], - ]; - } - - #[DataProvider('validEncodingsDataProvider')] - public function testValidCustomFilterOptionValidation(string $encoding, string $input, string $output): void - { - $classname = $this->generateClassFromFileTemplate( - 'Encode.json', - [sprintf('{"filter": "encode", "charset": "%s"}', $encoding)], - (new GeneratorConfiguration())->setImmutable(false)->addFilter($this->getEncodeFilter()), - false, - ); - - $object = new $classname(['property' => $input]); - - $this->assertSame($encoding, mb_detect_encoding($object->getProperty())); - $this->assertSame($output, $object->getProperty()); - } - - public static function validEncodingsDataProvider(): array - { - return [ - 'ASCII to ASCII' => ['ASCII', 'Hello World', 'Hello World'], - 'UTF-8 to ASCII' => ['ASCII', 'áéó', '???'], - 'UTF-8 to UTF-8' => ['UTF-8', 'áéó', 'áéó'], - ]; - } - - private function getEncodeFilter(): FilterInterface - { - return new class () implements FilterInterface, ValidateOptionsInterface { - public function getToken(): string - { - return 'encode'; - } - - public function getFilter(): array - { - return [FilterTest::class, 'encode']; - } - - public function validateOptions(array $options): void - { - if (!isset($options['charset'])) { - throw new Exception('Missing charset configuration'); - } - - if (!in_array($options['charset'], ['UTF-8', 'ASCII'])) { - throw new Exception('Unsupported charset'); - } - } - }; - } - - public static function encode(string $value, array $options): string - { - return mb_convert_encoding($value, $options['charset'], 'auto'); - } - - #[DataProvider('multipleFilterDataProvider')] - public function testMultipleFilters(?string $input, ?string $expectedValue): void - { - $className = $this->generateClassFromFile( - 'MultipleFilters.json', - (new GeneratorConfiguration()) - ->setImmutable(false) - ->addFilter($this->getCustomFilter([self::class, 'uppercaseFilter'], 'uppercase')), - ); - - $object = new $className(['property' => $input]); - $this->assertSame($expectedValue, $object->getProperty()); - - $object->setProperty($input); - $this->assertSame($expectedValue, $object->getProperty()); - } - - public static function multipleFilterDataProvider(): array - { - return [ - 'null' => [null, null], - 'empty string' => ['', ''], - 'numeric' => [' 123 ', '123'], - 'spaces' => [' ', ''], - 'uppercase string' => [" ABC\n", 'ABC'], - 'mixed string' => [" \t Hello World! ", 'HELLO WORLD!'], - ]; - } - - #[DataProvider('invalidCustomFilterDataProvider')] - public function testAddFilterWithInvalidSerializerThrowsAnException(array $customInvalidFilter): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessage('Invalid serializer callback for filter customTransformingFilter'); - - (new GeneratorConfiguration())->addFilter($this->getCustomTransformingFilter($customInvalidFilter)); - } - - protected function getCustomTransformingFilter( - array $customSerializer, - array $customFilter = [], - string $token = 'customTransformingFilter', - ): TransformingFilterInterface { - return new class ( - $customSerializer, - $customFilter, - $token, - ) extends TrimFilter implements TransformingFilterInterface - { - public function __construct( - private readonly array $customSerializer, - private readonly array $customFilter, - private readonly string $token, - ) {} - - public function getToken(): string - { - return $this->token; - } - - public function getFilter(): array - { - return empty($this->customFilter) ? parent::getFilter() : $this->customFilter; - } - - public function getSerializer(): array - { - return $this->customSerializer; - } - }; - } - - #[DataProvider('validDateTimeFilterDataProvider')] - public function testTransformingFilter(array $input, ?string $expected): void - { - $className = $this->generateClassFromFile( - 'TransformingFilter.json', - (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), - ); - - $object = new $className($input); - - if ($expected === null) { - $this->assertNull($object->getCreated()); - } else { - $expectedDateTime = new DateTime($expected); - - $this->assertInstanceOf(DateTime::class, $object->getCreated()); - $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); - } - - // test if the setter accepts the raw model data - if (isset($input['created'])) { - $object->setCreated($input['created']); - - if ($expected === null) { - $this->assertNull($object->getCreated()); - } else { - $expectedDateTime = new DateTime($expected); - - $this->assertInstanceOf(DateTime::class, $object->getCreated()); - $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); - - // test if the setter accepts a DateTime object - $object->setCreated($expectedDateTime); - - $this->assertInstanceOf(DateTime::class, $object->getCreated()); - $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); - } - } - - // test if the model can be serialized - $expectedSerialization = [ - 'created' => $expected !== null ? (new DateTime($expected))->format(DATE_ISO8601) : null, - 'name' => null, - ]; - - $this->assertSame($expectedSerialization, $object->toArray()); - $this->assertSame(json_encode($expectedSerialization), $object->toJSON()); - } - - public static function validDateTimeFilterDataProvider(): array - { - return [ - 'Optional Value not provided' => [[], null], - 'Null' => [['created' => null], null], - 'Empty string' => [['created' => ''], 'now'], - 'valid date' => [['created' => "12.12.2020 12:00"], '12.12.2020 12:00'], - 'valid DateTime constructor string' => [['created' => '+1 day'], '+1 day'], - ]; - } - - public function testFilterExceptionsAreCaught(): void - { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessage( - <<generateClassFromFile( - 'TransformingFilter.json', - (new GeneratorConfiguration())->setCollectErrors(true), - ); - - new $className(['created' => 'Hello', 'name' => 12]); - } - - #[DataProvider('additionalFilterOptionsDataProvider')] - public function testAdditionalFilterOptions(string $namespace, string $schemaFile): void - { - $className = $this->generateClassFromFile( - $schemaFile, - (new GeneratorConfiguration())->setSerialization(true)->setNamespacePrefix($namespace), - ); - - $fqcn = $namespace . $className; - $object = new $fqcn(['created' => '10122020']); - - $this->assertSame((new DateTime('2020-12-10'))->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); - - $expectedSerialization = ['created' => '20201210']; - $this->assertSame($expectedSerialization, $object->toArray()); - $this->assertSame(json_encode($expectedSerialization), $object->toJSON()); - } - - public static function additionalFilterOptionsDataProvider(): array - { - return self::combineDataProvider( - self::namespaceDataProvider(), - [ - 'Chain notation' => ['FilterOptionsChainNotation.json'], - 'Single filter notation' => ['FilterOptions.json'], - ], - ); - } - - public function testTransformingFilterAppliedToAnArrayPropertyThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Applying a transforming filter to the array property list is not supported', - ); - - $this->generateClassFromFile( - 'ArrayTransformingFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeBinaryToInt'], - [self::class, 'filterIntToBinary'], - 'customArrayTransformer', - ) - ), - ); - } - - public function testMultipleTransformingFiltersAppliedToOnePropertyThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Applying multiple transforming filters for property filteredProperty is not supported', - ); - - $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["dateTime", "customTransformer"]'], - (new GeneratorConfiguration())->addFilter( - new class () extends DateTimeFilter { - public function getToken(): string - { - return 'customTransformer'; - } - }, - ), - false, - ); - } - - public function testFilterBeforeTransformingFilterIsExecutedIfNonTransformedValueIsProvided(): void - { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessage( - 'Invalid value for property filteredProperty denied by filter exceptionFilter: ' . - 'Exception filter called with 12.12.2020', - ); - - $className = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["exceptionFilter", "dateTime"]'], - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter'), - ), - false, - ); - - new $className(['filteredProperty' => '12.12.2020']); - } - - public function testFilterBeforeTransformingFilterIsSkippedIfTransformedValueIsProvided(): void - { - $className = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["exceptionFilter", "dateTime"]'], - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter'), - ), - false, - ); - - $object = new $className(['filteredProperty' => new DateTime('2020-12-10')]); - - $this->assertSame( - (new DateTime('2020-12-10'))->format(DATE_ATOM), - $object->getFilteredProperty()->format(DATE_ATOM), - ); - } - - public static function exceptionFilter(string $value): void - { - throw new Exception("Exception filter called with $value"); - } - - #[DataProvider('implicitNullNamespaceDataProvider')] - public function testTransformingToScalarType(bool $implicitNull, string $namespace): void - { - $className = $this->generateClassFromFile( - 'TransformingScalarFilter.json', - (new GeneratorConfiguration()) - ->setNamespacePrefix($namespace) - ->setSerialization(true) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeBinaryToInt'], - [self::class, 'filterIntToBinary'], - 'binary', - ) - ), - false, - $implicitNull, - ); - - $fqcn = $namespace . $className; - $object = new $fqcn(['value' => 9]); - - $this->assertSame('1001', $object->getValue()); - $this->assertSame('1010', $object->setValue('1010')->getValue()); - $this->assertSame('1011', $object->setValue(11)->getValue()); - - $this->assertSame(['value' => 11], $object->toArray()); - $this->assertSame('{"value":11}', $object->toJSON()); - - if (!$implicitNull) { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessage('Invalid type for value. Requires [string, int], got NULL'); - new $fqcn(['value' => null]); - } - } - - public static function filterIntToBinary(int $value): string - { - return decbin($value); - } - - public static function serializeBinaryToInt(string $binary): int - { - return bindec($binary); - } - - public function testInvalidFilterChainWithTransformingFilterThrowsAnException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Filter trim is not compatible with transformed property type ' . - '[null, DateTime] for property filteredProperty', - ); - - $this->generateClassFromFileTemplate('FilterChain.json', ['["dateTime", "trim"]'], null, false); - } - - public function testFilterChainWithTransformingFilter(): void - { - $className = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["trim", "dateTime", "stripTime"]'], - (new GeneratorConfiguration()) - ->setImmutable(false) - ->addFilter( - $this->getCustomFilter( - [self::class, 'stripTimeFilter'], - 'stripTime', - ) - ), - false, - ); - - $object = new $className(['filteredProperty' => '2020-12-12 12:12:12']); - - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); - - $object->setFilteredProperty(null); - $this->assertNull($object->getFilteredProperty()); - - $object->setFilteredProperty(new DateTime('2020-12-12 12:12:12')); - $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); - } - - #[DataProvider('implicitNullNamespaceDataProvider')] - public function testFilterChainWithTransformingFilterOnMultiTypeProperty( - bool $implicitNull, - string $namespace, - ): void { - $className = $this->generateClassFromFile( - 'FilterChainMultiType.json', - (new GeneratorConfiguration()) - ->setNamespacePrefix($namespace) - ->setSerialization(true) - ->setImmutable(false) - ->addFilter( - $this->getCustomFilter( - [self::class, 'stripTimeFilter'], - 'stripTime', - ) - ), - false, - $implicitNull, - ); - - $fqcn = $namespace . $className; - $object = new $fqcn(['filteredProperty' => '2020-12-12 12:12:12']); - - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); - - $object->setFilteredProperty(null); - $this->assertNull($object->getFilteredProperty()); - - $object->setFilteredProperty(new DateTime('2020-12-12 12:12:12')); - $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); - - $this->assertSame(['filteredProperty' => '2020-12-12T00:00:00+0000'], $object->toArray()); - } - - public static function implicitNullNamespaceDataProvider(): array - { - return self::combineDataProvider( - self::implicitNullDataProvider(), - self::namespaceDataProvider(), - ); - } - - public function testFilterChainWithIncompatibleFilterAfterTransformingFilterOnMultiTypeProperty(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Filter stripTime is not compatible with transformed ' . - 'property type [null, DateTime] for property filteredProperty', - ); - - $this->generateClassFromFile( - 'FilterChainMultiType.json', - (new GeneratorConfiguration()) - ->addFilter( - $this->getCustomFilter( - [self::class, 'stripTimeFilterStrict'], - 'stripTime', - ) - ), - ); - } - - public function testFilterAfterTransformingFilterIsSkippedIfTransformingFilterFails(): void - { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessage( - 'Invalid value for property filteredProperty denied by filter dateTime: Invalid Date Time value "Hello"', - ); - - $className = $this->generateClassFromFile( - 'FilterChainMultiType.json', - (new GeneratorConfiguration()) - ->addFilter( - $this->getCustomFilter( - [self::class, 'exceptionFilterDateTime'], - 'stripTime', - ) - ), - false, - ); - - new $className(['filteredProperty' => 'Hello']); - } - - public function testFilterWhichAppliesToMultiTypePropertyPartiallyIsAllowed(): void - { - // A filter with acceptedTypes = ['string'] applied to a string|null property has partial - // overlap and is valid — the runtime typeCheck skips the filter for null values. - $className = $this->generateClassFromFile( - 'FilterChainMultiType.json', - (new GeneratorConfiguration()) - ->addFilter( - $this->getCustomFilter( - [self::class, 'uppercaseFilterStringOnly'], - 'trim', - ) - ) - ->addFilter( - $this->getCustomFilter( - [self::class, 'stripTimeFilter'], - 'stripTime', - ) - ), - false, - ); - - $this->assertNotNull($className); - } - - public static function stripTimeFilter(?DateTime $value): ?DateTime - { - return $value !== null ? $value->setTime(0, 0) : null; - } - - public static function stripTimeFilterStrict(DateTime $value): DateTime - { - return $value->setTime(0, 0); - } - - #[DataProvider('arrayFilterDataProvider')] - public function testArrayFilter(?array $input, ?array $output): void - { - $className = $this->generateClassFromFile('ArrayFilter.json'); - - $object = new $className(['list' => $input]); - $this->assertSame($output, $object->getList()); - } - - public static function arrayFilterDataProvider(): array - { - return [ - 'null' => [null, null], - 'empty array' => [[], []], - 'string array' => [['', 'Hello', null, '123'], ['Hello', '123']], - 'numeric array' => [[12, 0, 43], [12, 43]], - 'nested array' => [[['Hello'], [], [12], ['']], [['Hello'], [12], ['']]], - ]; - } - - public function testEnumCheckWithTransformingFilterIsExecutedForNonTransformedValues(): void - { - $className = $this->generateClassFromFile('EnumBeforeFilter.json'); - - $object = new $className(['filteredProperty' => '2020-12-12']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - $this->assertSame( - (new DateTime('2020-12-12'))->format(DATE_ATOM), - $object->getFilteredProperty()->format(DATE_ATOM), - ); - - $this->expectException(ValidationException::class); - $this->expectExceptionMessage('Invalid value for filteredProperty declined by enum constraint'); - - new $className(['filteredProperty' => '1999-12-12']); - } - - public function testEnumCheckWithTransformingFilterIsNotExecutedForTransformedValues(): void - { - $className = $this->generateClassFromFile('EnumBeforeFilter.json'); - $object = new $className(['filteredProperty' => new DateTime('1999-12-12')]); - - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - $this->assertSame( - (new DateTime('1999-12-12'))->format(DATE_ATOM), - $object->getFilteredProperty()->format(DATE_ATOM), - ); - } - - #[DataProvider('implicitNullDataProvider')] - public function testDefaultValuesAreTransformed(bool $implicitNull): void - { - $className = $this->generateClassFromFile('DefaultValueFilter.json', null, false, $implicitNull); - $object = new $className(); - - $this->assertInstanceOf(DateTime::class, $object->getCreated()); - $this->assertSame( - (new DateTime('2020-12-12'))->format(DATE_ATOM), - $object->getCreated()->format(DATE_ATOM), - ); - } - - // --- Filter callables used in the tests below --- - - public static function uppercaseFilterAllTypes(mixed $value): ?string - { - return is_string($value) ? strtoupper($value) : null; - } - - public static function uppercaseFilterStringOnly(string $value): string - { - return strtoupper($value); - } - - public static function uppercaseFilterFloat(float $value): string - { - return (string) $value; - } - - public static function uppercaseFilterMixed(mixed $value): ?string - { - return is_string($value) ? strtoupper($value) : null; - } - - public static function nullPassthrough(null $value): mixed - { - return $value; - } - - public static function exceptionFilterDateTime(?\DateTime $value): void - { - throw new Exception("Exception filter called with DateTime"); - } - - public static function negateFilterMixed(mixed $value): mixed - { - return is_int($value) ? -$value : $value; - } - - // --- Tests --- - - public function testFilterWithMixedTypeHintIsCompatibleWithAnyPropertyType(): void - { - // A callable with 'mixed' type hint derives empty acceptedTypes — no runtime type guard, - // the filter runs for all value types. - $className = $this->generateClassFromFile( - 'StringPropertyAcceptAllFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterAllTypes'], 'acceptAll'), - ), - ); - - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); - } - - public function testRestrictedFilterOnUntypedPropertyIsAllowed(): void - { - // 'trim' accepts string|null (from ?string type hint). An untyped property can hold any - // value, so the filter is applied only when the runtime type matches — generation must - // succeed without throwing a SchemaException. - $className = $this->generateClassFromFile('UntypedPropertyFilter.json'); - - $object = new $className(['property' => ' hello ']); - $this->assertSame('hello', $object->getProperty()); - - $object = new $className(['property' => null]); - $this->assertNull($object->getProperty()); - } - - public function testZeroOverlapThrowsSchemaException(): void - { - // float has zero overlap with int — SchemaException at generation time. - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - '/Filter numberFilter is not compatible with property type int for property property/', - ); - - $this->generateClassFromFile( - 'IntegerPropertyZeroOverlapFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterFloat'], 'numberFilter'), - ), - ); - } - - // --- P2: string|integer property with string-only filter --- - - public function testPartialOverlapStringFilterOnMultiTypeProperty(): void - { - // Filter callable has (string $value) — accepted type is string only. - // Filter applies for string values; integer is not accepted so the filter - // is skipped and the integer value passes through unchanged. - $className = $this->generateClassFromFile( - 'StringIntegerPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), - ), - ); - - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); // filter applied - - $object = new $className(['property' => 5]); - $this->assertSame(5, $object->getProperty()); // filter skipped, value unchanged - } - - // --- P3: string|null property, filter does not cover null --- - - public function testPartialOverlapStringFilterSkipsNullOnNullableProperty(): void - { - // Filter callable has (string $value) — null is not accepted. - // Filter applies for string values; null passes through unchanged. - $className = $this->generateClassFromFile( - 'StringNullPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), - ), - ); - - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); // filter applied - - $object = new $className(['property' => null]); - $this->assertNull($object->getProperty()); // filter skipped, null unchanged - } - - // --- P4: string|null property, filter covers only null --- - - public function testPartialOverlapNullFilterSkipsStringOnNullableProperty(): void - { - // Filter callable has (null $value) — only null is accepted. - // Filter runs for null (passes through); string is not accepted so skipped. - $className = $this->generateClassFromFile( - 'StringNullPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'nullPassthrough'], 'customFilter'), - ), - ); - - $object = new $className(['property' => null]); - $this->assertNull($object->getProperty()); // filter ran, returned null - - $object = new $className(['property' => 'hello']); - $this->assertSame('hello', $object->getProperty()); // filter skipped, string unchanged - } - - // --- P5: integer property, filter covers integer --- - - public function testPartialOverlapFilterRunsWhenPropertyTypeIsInAcceptedTypes(): void - { - // Filter callable has (int $value) — overlap with integer property type, filter runs. - $className = $this->generateClassFromFile( - 'IntegerPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'negateFilter'], 'customFilter'), - ), - ); - - $object = new $className(['property' => 5]); - $this->assertSame(-5, $object->getProperty()); - } - - public static function negateFilter(int $value): int - { - return -$value; - } - - // --- U3: mixed-typed callable on untyped property, no typeCheck generated --- - - public function testMixedTypedCallableFilterOnUntypedProperty(): void - { - // callable with (mixed $value) derives empty acceptedTypes — no runtime typeCheck. - $className = $this->generateClassFromFile( - 'UntypedPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'customFilter'), - ), - ); - - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); // filter applied for string - } - - // --- U4: narrow filter on untyped property --- - - public function testNarrowFilterOnUntypedPropertySkipsNonMatchingType(): void - { - // Callable with (string $value) on an untyped property — filter applies for string, - // integer is not accepted so the filter is skipped and the value passes through. - $className = $this->generateClassFromFile( - 'UntypedPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), - ), - ); - - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); // filter applied for string - - $object = new $className(['property' => 5]); - $this->assertSame(5, $object->getProperty()); // filter skipped, integer unchanged - } - - public function testAddFilterWithMixedTypedCallableIsAllowed(): void - { - // A callable with (mixed $value) derives empty acceptedTypes — always compatible. - $config = (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), - ); - - $this->assertNotNull($config->getFilter('mixedFilter')); - } - - public function testMixedTypedCallableGeneratesNoRuntimeTypeCheck(): void - { - // A callable with (mixed $value) means "accept all types" — generation succeeds for both - // typed and untyped properties, and no runtime typeCheck guard is emitted. - - // typed string property — filter runs - $className = $this->generateClassFromFile( - 'StringPropertyMixedFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), - ), - ); - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); - - // untyped property — filter runs - $className = $this->generateClassFromFile( - 'UntypedPropertyMixedFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), - ), - ); - $object = new $className(['property' => 'hello']); - $this->assertSame('HELLO', $object->getProperty()); - - // typed integer property — generation succeeds, filter runs (no typeCheck guard) - $className = $this->generateClassFromFile( - 'IntegerPropertyMixedFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'negateFilterMixed'], 'mixedFilter'), - ), - ); - $object = new $className(['property' => 5]); - $this->assertSame(-5, $object->getProperty()); - } - - // --- Static callables for transforming filter output type tests --- - - /** - * Accepts string or int, converts to string. Used for the union-type-hint guard test. - */ - public static function intOrStringFilter(string|int $value): string - { - return (string) $value; - } - - /** - * Accepts string or null, always returns string (never null). Used for null-consumed test. - */ - public static function stringOrNullToStringFilter(string|null $value): string - { - return (string) $value; - } - - /** - * Accepts string, returns int|string union. Used for union-return-type test. - */ - public static function stringToIntOrStringFilter(string $value): int|string - { - return is_numeric($value) ? (int) $value : $value; - } - - /** - * Serializer for stringToIntOrStringFilter. - */ - public static function intOrStringSerializer(int|string $value): string - { - return (string) $value; - } - - /** - * No type hint on first parameter. Used for the no-type-hint InvalidFilterException test. - * - * @param mixed $value - */ - public static function untypedFilter($value): string - { - return (string) $value; - } - - /** - * No return type hint. Used for the missing-return-type InvalidFilterException test (F5). - * - * @return string - */ - public static function filterWithNoReturnType(string $value) - { - return $value; - } - - /** - * Void return type. Used for the void-return-type InvalidFilterException test (F6). - */ - public static function filterWithVoidReturnType(string $value): void - { - } - - /** - * Never return type. Used for the never-return-type InvalidFilterException test (F7). - */ - public static function filterWithNeverReturnType(string $value): never - { - throw new RuntimeException('never'); - } - - // --- Transforming filter output type, reflection, and filter chain tests --- - - /** - * R2: TransformingFilter (int→string via binary) on a string|integer property. - * The filter callable accepts only int, so string values bypass the filter unchanged. - * Verifies the bypass formula: bypass_names = base_names − accepted_non_null. - */ - public function testTransformingFilterWithBypassOnMultiTypeProperty(): void - { - // base type = string|int; filter accepts int only → string bypasses, int is transformed. - $className = $this->generateClassFromFile( - 'StringIntegerPropertyBinaryFilter.json', - (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeBinaryToInt'], - [self::class, 'filterIntToBinary'], - 'binary', - ), - ), - ); - - // int input: filter applies (decbin), returns binary string - $object = new $className(['property' => 9]); - $this->assertSame('1001', $object->getProperty()); - - // string input: filter is skipped (string bypasses), value passes through unchanged - $object = new $className(['property' => 'hello']); - $this->assertSame('hello', $object->getProperty()); - - // setter: int is re-transformed - $object->setProperty(5); - $this->assertSame('101', $object->getProperty()); - - // setter: string is preserved (bypass) - $object->setProperty('world'); - $this->assertSame('world', $object->getProperty()); - } - - /** - * R6: TransformingFilter with a union return type (int|string) on a string property. - * The output type is widened to int|string; the setter must accept both int and string. - */ - public function testTransformingFilterWithUnionReturnType(): void - { - $className = $this->generateClassFromFile( - 'StringPropertyIntOrStringFilter.json', - (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'intOrStringSerializer'], - [self::class, 'stringToIntOrStringFilter'], - 'intOrString', - ), - ), - ); - - // numeric string → filter converts to int - $object = new $className(['property' => '42']); - $this->assertSame(42, $object->getProperty()); - - // non-numeric string → filter returns as-is (string) - $object = new $className(['property' => 'hello']); - $this->assertSame('hello', $object->getProperty()); - - // setter accepts int (pass-through: already a transformed output type) - $object->setProperty(7); - $this->assertSame(7, $object->getProperty()); - - // setter accepts string (base type or output type string) - $object->setProperty('abc'); - $this->assertSame('abc', $object->getProperty()); - } - - /** - * R7: TransformingFilter where both string and null are in its accepted types. - * Null is NOT a bypass type — the filter runs for null and converts it to string. - */ - public function testTransformingFilterNullConsumedByFilter(): void - { - $className = $this->generateClassFromFile( - 'StringNullPropertyStrOrNullFilter.json', - (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'intOrStringSerializer'], - [self::class, 'stringOrNullToStringFilter'], - 'strOrNull', - ), - ), - ); - - // string input: filter runs and returns string - $object = new $className(['property' => 'hello']); - $this->assertSame('hello', $object->getProperty()); - - // null input: filter runs (null IS accepted) and converts null → '' - $object = new $className(['property' => null]); - $this->assertSame('', $object->getProperty()); - } - - /** - * F3: Filter callable whose first parameter has no type hint throws an InvalidFilterException - * at class-generation time (reflection cannot derive the accepted types). - * This is not a SchemaException because the error is in the filter definition, not the schema. - */ - public function testFilterCallableWithNoTypeHintThrowsInvalidFilterException(): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessageMatches('/Filter noTypeHint must declare a type hint/'); - - $this->generateClassFromFile( - 'StringPropertyNoTypeHintFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomFilter([self::class, 'untypedFilter'], 'noTypeHint'), - ), - ); - } - - /** - * F5: Transforming filter callable with no return type hint throws an InvalidFilterException - * at class-generation time (reflection cannot derive the output type). - * This is not a SchemaException because the error is in the filter definition, not the schema. - */ - public function testTransformingFilterWithMissingReturnTypeThrowsInvalidFilterException(): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessageMatches('/Transforming filter noReturnType must declare a return type/'); - - $this->generateClassFromFile( - 'StringPropertyNoReturnTypeFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'intOrStringSerializer'], - [self::class, 'filterWithNoReturnType'], - 'noReturnType', - ), - ), - ); - } - - /** - * F6: Transforming filter callable with a void return type throws an InvalidFilterException - * at class-generation time (void is not a valid output type for a transforming filter). - * This is not a SchemaException because the error is in the filter definition, not the schema. - */ - public function testTransformingFilterWithVoidReturnTypeThrowsInvalidFilterException(): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessageMatches('/Transforming filter voidReturn must not declare a void return type/'); - - $this->generateClassFromFile( - 'StringPropertyVoidReturnFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'intOrStringSerializer'], - [self::class, 'filterWithVoidReturnType'], - 'voidReturn', - ), - ), - ); - } - - /** - * F7: Transforming filter callable with a never return type throws an InvalidFilterException - * at class-generation time (never, like void, cannot produce a usable return value). - * This is not a SchemaException because the error is in the filter definition, not the schema. - */ - public function testTransformingFilterWithNeverReturnTypeThrowsInvalidFilterException(): void - { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessageMatches('/Transforming filter neverReturn must not declare a never return type/'); - - $this->generateClassFromFile( - 'StringPropertyNeverReturnFilter.json', - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'intOrStringSerializer'], - [self::class, 'filterWithNeverReturnType'], - 'neverReturn', - ), - ), - ); - } - - /** - * F4: Filter callable with a union type hint (string|int) generates a compound typeCheck - * guard: (is_string($value) || is_int($value)). The filter runs for both accepted types. - */ - public function testFilterCallableWithUnionTypeHintAppliesFilterForBothAcceptedTypes(): void - { - // Both string and int are in the callable's union type hint — both pass the runtime guard. - $className = $this->generateClassFromFile( - 'StringIntegerPropertyCustomFilter.json', - (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( - $this->getCustomFilter([self::class, 'intOrStringFilter'], 'customFilter'), - ), - ); - - // string input: is_string passes → filter runs → result is string (unchanged) - $object = new $className(['property' => 'hello']); - $this->assertSame('hello', $object->getProperty()); - - // int input: is_int passes → filter runs → result is string '42' - $object = new $className(['property' => 42]); - $this->assertSame('42', $object->getProperty()); - } - - /** - * CH2: [trim, dateTime] filter chain on a string|integer property. - * trim accepts only string|null — the int input bypasses trim. - * dateTime accepts string|int|float|null — both inputs are converted to DateTime. - */ - public function testFilterChainTrimDateTimeOnStringIntegerProperty(): void - { - $className = $this->generateClassFromFile( - 'StringIntegerPropertyFilterChain.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // string input: trim trims whitespace, dateTime converts to DateTime - $object = new $className(['created' => ' 2020-12-12 ']); - $this->assertInstanceOf(\DateTime::class, $object->getCreated()); - $this->assertSame( - (new \DateTime('2020-12-12'))->format(DATE_ATOM), - $object->getCreated()->format(DATE_ATOM), - ); - - // int input: trim is skipped (not a string), dateTime converts timestamp to DateTime - $object = new $className(['created' => 0]); - $this->assertInstanceOf(\DateTime::class, $object->getCreated()); - $this->assertSame( - (new \DateTime('@0'))->format(DATE_ATOM), - $object->getCreated()->format(DATE_ATOM), - ); - - // setter accepts DateTime (already-transformed output type) - $object->setCreated(new \DateTime('2020-12-12')); - $this->assertSame( - (new \DateTime('2020-12-12'))->format(DATE_ATOM), - $object->getCreated()->format(DATE_ATOM), - ); - } - - public function testFilterChainWithTransformingFilterOnUntypedProperty(): void - { - // ['trim', 'dateTime'] on an untyped property — trim accepts string|null (from ?string - // type hint) but the property is untyped, so no SchemaException is thrown and the - // chain works correctly. - $className = $this->generateClassFromFile('UntypedPropertyFilterChain.json'); - - $object = new $className(['filteredProperty' => ' 2020-12-12 ']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - $this->assertSame( - (new DateTime('2020-12-12'))->format(DATE_ATOM), - $object->getFilteredProperty()->format(DATE_ATOM), - ); - - $object = new $className(['filteredProperty' => null]); - $this->assertNull($object->getFilteredProperty()); - } - - /** - * A transforming filter with a mixed return type followed by a filter that does NOT - * accept all types must throw a SchemaException. - */ - public function testMixedReturnTransformingFilterFollowedByTypedFilterThrowsException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Filter trim is not compatible with the unconstrained output of' - . ' transforming filter mixedReturnFilter for property filteredProperty', - ); - - $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["mixedReturnFilter", "trim"]'], - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeMixedReturn'], - [self::class, 'filterWithMixedReturn'], - 'mixedReturnFilter', - ), - ), - false, - ); - } - - /** - * A transforming filter with a mixed return type followed by an accept-all filter must not - * throw, and neither must a concrete-return transforming filter followed by an accept-all - * filter. - */ - public function testFilterChainWithAcceptAllNextFilter(): void - { - $acceptAllFilter = $this->getCustomFilter([self::class, 'acceptAllFilter'], 'acceptAll'); - - // Mixed-return transforming filter + accept-all follow-up — no SchemaException. - $mixedReturnClassName = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["mixedReturnFilter", "acceptAll"]'], - (new GeneratorConfiguration()) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeMixedReturn'], - [self::class, 'filterWithMixedReturn'], - 'mixedReturnFilter', - ), - ) - ->addFilter($acceptAllFilter), - false, - ); - - // The mixed-return filter just passes the string through; value is still a string. - $object = new $mixedReturnClassName(['filteredProperty' => 'hello']); - $this->assertSame('hello', $object->getFilteredProperty()); - - // Concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up - // — no SchemaException. - $dateTimeClassName = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["dateTime", "acceptAll"]'], - (new GeneratorConfiguration())->addFilter($acceptAllFilter), - false, - ); - - // The dateTime filter converts the string to DateTime; acceptAll passes it through. - $object = new $dateTimeClassName(['filteredProperty' => '2020-12-12']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - } - - /** - * A transforming filter with a non-nullable return type followed by a filter that does not - * accept that return type must throw a SchemaException. - */ - public function testNonNullableReturnTransformingFilterWithIncompatibleNextFilterThrowsException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessage( - 'Filter trim is not compatible with transformed property type int' - . ' for property filteredProperty', - ); - - $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['["intReturnFilter", "trim"]'], - (new GeneratorConfiguration())->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeIntReturn'], - [self::class, 'filterWithIntReturn'], - 'intReturnFilter', - ), - ), - false, - ); - } - - // --- Callables for mixed-return / accept-all / int-return / mixed-accept filter tests --- - - /** Transforming filter callable that returns mixed; used by testMixedReturn* tests. */ - public static function filterWithMixedReturn(string $value): mixed - { - return $value; - } - - /** Serializer paired with filterWithMixedReturn. */ - public static function serializeMixedReturn(mixed $value): string - { - return (string) $value; - } - - /** Regular filter callable that accepts and returns mixed (accept-all filter). */ - public static function acceptAllFilter(mixed $value): mixed - { - return $value; - } - - /** Transforming filter callable that accepts string and returns a non-nullable int. */ - public static function filterWithIntReturn(string $value): int - { - return (int) $value; - } - - /** Serializer paired with filterWithIntReturn. */ - public static function serializeIntReturn(int $value): string - { - return (string) $value; - } - - // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- - // Static rejection of unresolvable compositions - // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- - - /** @return array */ - public static function rejectedCompositionProvider(): array - { - return [ - // A single allOf branch spans both input-space and output-space keywords; it cannot - // be placed on either side of the filter boundary without losing one of the constraints. - 'allOf with Mixed branch' => [ - 'FilterCompositionAllOfMixedBranch.json', - '/Composition allOf under property filteredProperty' - . '.*branch #0 spans both input and output type-spaces/', - ], - // anyOf branches disagree on type-space (one input-space, one output-space); all - // branches of a non-allOf composition must be uniformly pre- or post-transform. - 'anyOf with cross-space branches' => [ - 'FilterCompositionAnyOfCrossSpace.json', - '/Composition anyOf under property filteredProperty' - . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', - ], - // Same as anyOf: oneOf branches cannot span different type-spaces. - 'oneOf with cross-space branches' => [ - 'FilterCompositionOneOfCrossSpace.json', - '/Composition oneOf under property filteredProperty' - . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', - ], - // The not inner schema spans both spaces; the type-space classification is ambiguous. - 'not with Mixed inner schema' => [ - 'FilterCompositionNotMixed.json', - '/Composition not under property filteredProperty' - . '.*inner schema spans both input and output type-spaces/', - ], - // if/then/else sub-schemas span different type-spaces; all three sub-schemas must be - // uniformly classified so the whole conditional can be placed on one side of the filter. - 'if\/then with cross-space sub-schemas' => [ - 'FilterCompositionIfThenElseCrossSpace.json', - '/Composition if\/then\/else under property filteredProperty.*sub-schemas span different type-spaces/', - ], - // A filter keyword inside a composition branch cannot be correctly applied because the - // ComposedItem template resets $value to the original input after each branch evaluation. - 'filter inside allOf branch (with outer filter)' => [ - 'FilterCompositionFilterInBranch.json', - '/A filter keyword inside a allOf composition branch is not supported' - . ' for property filteredProperty.*branch #0/', - ], - // Same as above; the rejection applies regardless of whether the property itself also - // declares an outer filter. - 'filter inside allOf branch (no outer filter)' => [ - 'FilterCompositionFilterInBranchNoOuterFilter.json', - '/A filter keyword inside a allOf composition branch is not supported' - . ' for property filteredProperty.*branch #0/', - ], - // Root-level allOf constrains the filtered subproperty with output-type-space keywords. - // Splitting the root-level allOf around the filter's transform boundary is not supported. - 'root-level allOf constrains filtered subproperty with output-type constraint' => [ - 'FilterCompositionRootConstrainsFilteredSubproperty.json', - '/Composition allOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', - ], - // Same constraint applies to root-level anyOf. - 'root-level anyOf constrains filtered subproperty with output-type constraint' => [ - 'FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json', - '/Composition anyOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', - ], - // Same constraint applies to root-level oneOf. - 'root-level oneOf constrains filtered subproperty with output-type constraint' => [ - 'FilterCompositionRootOneOfConstrainsFilteredSubproperty.json', - '/Composition oneOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', - ], - // Same constraint applies to root-level not. - 'root-level not constrains filtered subproperty with output-type constraint' => [ - 'FilterCompositionRootNotConstrainsFilteredSubproperty.json', - '/Composition not.*constrains filtered subproperty filteredProperty.*output-type-space/', - ], - // Same constraint applies to root-level if/then/else. - 'root-level if constrains filtered subproperty with output-type constraint' => [ - 'FilterCompositionRootIfConstrainsFilteredSubproperty.json', - '/Composition if.*constrains filtered subproperty filteredProperty.*output-type-space/', - ], - // Filter inside a not branch: same $value-reset issue as for array composition keywords. - 'filter inside not branch' => [ - 'FilterCompositionFilterInNotBranch.json', - '/A filter keyword inside a not composition branch is not supported' - . ' for property filteredProperty/', - ], - // Filter inside an anyOf branch: same $value-reset issue. - 'filter inside anyOf branch' => [ - 'FilterCompositionFilterInAnyOfBranch.json', - '/A filter keyword inside a anyOf composition branch is not supported' - . ' for property filteredProperty.*branch #0/', - ], - // Filter inside a oneOf branch: same $value-reset issue. - 'filter inside oneOf branch' => [ - 'FilterCompositionFilterInOneOfBranch.json', - '/A filter keyword inside a oneOf composition branch is not supported' - . ' for property filteredProperty.*branch #0/', - ], - // Filter inside an if/then/else sub-schema: same $value-reset issue. - 'filter inside if\/then\/else branch' => [ - 'FilterCompositionFilterInIfThenElseIfThenElseBranch.json', - '/A filter keyword inside an if\/then\/else composition branch is not supported' - . ' for property filteredProperty.*if sub-schema/', - ], - // Filter inside a deeply-nested allOf/anyOf branch: recursive scan must descend. - 'filter inside nested allOf\/anyOf branch' => [ - 'FilterCompositionFilterInNestedBranch.json', - '/A filter keyword inside a allOf composition branch is not supported' - . ' for property filteredProperty.*branch #0/', - ], - // anyOf branch spanning both input and output type-spaces is ambiguous. - 'anyOf with single Mixed branch' => [ - 'FilterCompositionAnyOfMixedBranch.json', - '/Composition anyOf under property filteredProperty' - . '.*branch #0 spans both input and output type-spaces/', - ], - ]; - } - - /** - * An allOf branch whose 'type' constraint excludes the filter's accepted input types means - * the filter can never receive any value that passes validation — it is a dead filter. - */ - public function testDeadFilterViaAllOfTypeConstraintThrowsSchemaException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - '/Filter stringToInt on property filteredProperty.*can never be executed' - . '.*allOf type constraints \(int\) exclude all input types accepted by the filter \(string\)/', - ); - - // allOf requires integer values but stringToInt only accepts strings; no value - // can pass both the allOf validation and reach the filter. - $this->generateClassFromFile( - 'FilterCompositionAllOfDeadFilter.json', - (new GeneratorConfiguration()) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - } - - /** - * Contradictory allOf type constraints (no value can satisfy all branches simultaneously) - * produce an empty intersection. The property-level allOf intersection check fires and - * throws SchemaException for the type contradiction. The dead-filter check skips on empty - * intersection and is NOT the source of this exception. - */ - public function testContradictoryAllOfTypeConstraintsThrowSchemaException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - "/Property 'filteredProperty' is defined with conflicting types in allOf composition branches/", - ); - - // Contradictory branches: integer AND string simultaneously — impossible. The - // property-level allOf intersection detects the empty intersection and rejects the schema. - // The dead-filter check skips (empty intersection is handled by type-contradiction logic). - $this->generateClassFromFile( - 'FilterCompositionAllOfContradictoryTypes.json', - (new GeneratorConfiguration()) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - } - - #[DataProvider('rejectedCompositionProvider')] - public function testUnresolvableCompositionOnTransformingFilterPropertyThrowsSchemaException( - string $schemaFile, - string $expectedMessagePattern, - ): void { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches($expectedMessagePattern); - - $this->generateClassFromFile($schemaFile); - } - - /** @return array */ - public static function acceptedCompositionProvider(): array - { - return [ - 'allOf with input-only branches' => ['FilterCompositionAllOfInputOnly.json'], - 'anyOf with input-only branches' => ['FilterCompositionAnyOfInputOnly.json'], - 'oneOf with input-only branches' => ['FilterCompositionOneOfInputOnly.json'], - 'if/then/else input-only branches' => ['FilterCompositionIfThenElseInputOnly.json'], - 'if/then only (no else) input-only branches' => ['FilterCompositionIfThenOnlyInputSpace.json'], - 'if/else only (no then) input-only branches' => ['FilterCompositionIfElseOnlyInputSpace.json'], - 'allOf with empty {} branch' => ['FilterCompositionAllOfEmptyBranch.json'], - 'root-level allOf: input-space constraint on filtered subproperty' => - ['FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json'], - 'root-level allOf branch: filter in inherited-object branch property' => - ['FilterCompositionRootBranchWithFilterInProperty.json'], - ]; - } - - #[DataProvider('acceptedCompositionProvider')] - public function testCompatibleCompositionOnTransformingFilterPropertyGeneratesSuccessfully( - string $schemaFile, - ): void { - // Should not throw — generation must succeed for compatible compositions. - $this->generateClassFromFile($schemaFile); - $this->addToAssertionCount(1); - } - - /** - * Output-only allOf (all branches output-space) runs POST-transform. - * - * Schema: { filter: stringToInt, type: [string, integer], - * allOf: [{minimum: 0}, {maximum: 100}] } - * Both branches constrain the numeric range — output-space for the string→int filter. - * The allOf must run AFTER the filter, validating the transformed integer. - * - * Observable proof: "200" → filter → 200 → maximum:100 fails → AllOfException. - * If the allOf ran PRE-transform, minimum and maximum would be no-ops on the string "200" - * and both branches would always pass vacuously — no exception would be thrown. - * The AllOfException for "200" proves the allOf ran on the transformed integer. - * - * Already-transformed int 50 supplied directly skips the filter; the output-space allOf - * still runs and passes. - */ - public function testOutputOnlyAllOfCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfOutputOnly.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "50": filter → 50 → minimum:0 passes, maximum:100 passes → result: 50. - $object = new $className(['filteredProperty' => '50']); - $this->assertSame(50, $object->getFilteredProperty()); - - // "200": filter → 200 → maximum:100 fails → AllOfException. - // Proves post-transform: if allOf ran on the raw string "200", minimum/maximum would - // be no-ops (non-numeric) and always pass vacuously — the exception proves integer evaluation. - // Branch 0 (minimum:0) passes, branch 1 (maximum:100) fails → succeeded=1. - try { - new $className(['filteredProperty' => '200']); - $this->fail('Expected AllOfException for "200"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - } - - // "-5": filter → -5 → minimum:0 fails → AllOfException. - // Branch 0 (minimum:0) fails, branch 1 (maximum:100) passes → succeeded=1. - try { - new $className(['filteredProperty' => '-5']); - $this->fail('Expected AllOfException for "-5"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed int 50 → filter skipped → output-space allOf still runs and passes. - $object = new $className(['filteredProperty' => 50]); - $this->assertSame(50, $object->getFilteredProperty()); - } - - /** - * A transforming filter whose callable accepts mixed (empty accepted types) applied - * to a property that gets its type from an allOf sibling branch. - * - * At filter-processing time the property has no type yet (type comes later via the allOf - * resolution), so FilterProcessor skips applyOutputType. After composition is resolved the - * TransformingFilterOutputTypePostProcessor sets the output type. - * - * The allOf branch {type:string} is classified as input-space, so it runs pre-transform. - * The filter then converts the string to DateTime. No post-transform composition exists. - */ - public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void - { - $className = $this->generateClassFromFile( - 'AllOfPropertyWithMixedAcceptTransformingFilter.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [self::class, 'serializeMixedToDateTime'], - [self::class, 'filterMixedToDateTime'], - 'mixedAcceptDateTimeFilter', - ), - ), - ); - - // Valid string input: input-space allOf passes, filter transforms to DateTime. - $object = new $className(['filteredProperty' => '2024-01-01']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // Non-string raw input via constructor: the constructor bypasses the setter type hint, - // so 42 reaches validateFilteredProperty directly. The input-space allOf fires pre-transform - // and rejects it with AllOfException (not a TypeError). - try { - new $className(['filteredProperty' => 42]); - $this->fail('Expected AllOfException for non-string raw input'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-constructed DateTime: pre-transform pipeline skipped (R-8), accepted as-is. - $existingDateTime = new DateTime('2024-06-01'); - $object->setFilteredProperty($existingDateTime); - $this->assertSame($existingDateTime, $object->getFilteredProperty()); - } - - /** - * Transforming filter callable that accepts mixed and returns DateTime. - */ - public static function filterMixedToDateTime(mixed $value): DateTime - { - return new DateTime((string) $value); - } - - /** - * Serializer for filterMixedToDateTime. - */ - public static function serializeMixedToDateTime(DateTime $value): string - { - return $value->format(DATE_ATOM); - } - - // ------------------------------------------------------------------------- - // Validator priority reassignment around transforming filters - // ------------------------------------------------------------------------- - - /** - * With a string→int transforming filter, schema validators that are - * registered for input types (pattern → string-space) must run PRE-transform, while - * validators registered for output types (minimum → int-space) must run POST-transform. - * - * Schema: { type: [string, integer], filter: stringToInt, pattern: "^\d+$", minimum: 0 } - * - * Pre-transform proof: "hello" filtered by (int)cast becomes 0 — a value that would pass - * both pattern (is_string(0) == false, so silently skipped) and minimum (0 >= 0) if the - * validator ran post-transform. With the fixed ordering, pattern runs against the raw - * string "hello" and correctly fails. - * - * Post-transform proof: -5 passed as an already-transformed integer skips the pre-transform - * pipeline and goes straight to minimum, which catches the negative value. - */ - public function testValidatorPriorityReassignmentAroundTransformingFilter(): void - { - $configuration = (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )); - - $className = $this->generateClassFromFile( - 'ValidatorPriorityWithTransformingFilter.json', - $configuration, - ); - - // "hello" casts to 0, which would pass both validators post-transform. - // The fixed ordering makes pattern catch it against the raw string. - try { - new $className(['value' => 'hello']); - $this->fail('Expected PatternException for input "hello"'); - } catch (PatternException $patternException) { - $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); - } - - // "-5" would silently become -5 post-transform, causing MinimumException instead. - // The fixed ordering catches it at pattern (pre-transform) because "-5" ∉ \d+. - try { - new $className(['value' => '-5']); - $this->fail('Expected PatternException for input "-5"'); - } catch (PatternException $patternException) { - $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); - } - - // Valid string input: pattern passes, filter transforms to 42, minimum passes. - $object = new $className(['value' => '42']); - $this->assertSame(42, $object->getValue()); - - // Already-transformed int that satisfies minimum: skips pre-transform pipeline. - $object = new $className(['value' => 42]); - $this->assertSame(42, $object->getValue()); - - // Already-transformed int that fails minimum: minimum runs post-transform. - try { - new $className(['value' => -5]); - $this->fail('Expected MinimumException for input -5'); - } catch (MinimumException $minimumException) { - $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); - } - } - - /** - * Regression guard: a non-transforming filter must not trigger any priority reassignment. - * The existing TrimAsStringWithLengthValidation schema exercises this by verifying that - * minLength validates the *trimmed* value (i.e. the validator runs after trim, not before - * it). Re-running that assertion here makes the regression explicit. - */ - public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): void - { - $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); - - // " AB \n" trims to "AB" (length 2) — passes minLength: 2. - $object = new $className(['property' => " AB \n"]); - $this->assertSame('AB', $object->getProperty()); - - // " a " trims to "a" (length 1) — fails minLength: 2 (validates trimmed value). - try { - new $className(['property' => ' a ']); - $this->fail('Expected ValidationException for input " a "'); - } catch (ValidationException $validationException) { - $this->assertStringContainsString('must not be shorter than 2', $validationException->getMessage()); - } - } - - /** - * A format validator (registered under the string type, hence input-space) that is moved - * pre-filter must not run when the property value is already in the filter's output - * type-space. FormatValidatorFromRegEx::validate() declares a string parameter under - * strict types, so calling it with an already-transformed int throws TypeError rather than - * a validation exception. - * - * Schema: { type: [string, integer], format: onlyNumbers, filter: stringToInt, minimum: 0 } - * - * Bug: int -5 arrives as an already-transformed value; the moved format validator fires - * against the int and throws TypeError instead of the expected MinimumException. - * - * Fix: the skip guard prepended to moved input-space validators prevents the format check - * from running when the value is already in the output type-space. - */ - public function testFormatValidatorOnMultiTypePropertyDoesNotFireForAlreadyTransformedValue(): void - { - $configuration = (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFormat('onlyNumbers', new FormatValidatorFromRegEx('/^\d+$/')) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )); - - $className = $this->generateClassFromFile( - 'MultiTypeFormatWithTransformingFilter.json', - $configuration, - ); - - // String "42": format passes pre-transform, filter converts to int 42, minimum passes. - $object = new $className(['value' => '42']); - $this->assertSame(42, $object->getValue()); - - // String "hello": format check fires against the raw string → FormatException. - try { - new $className(['value' => 'hello']); - $this->fail('Expected FormatException for string input "hello"'); - } catch (FormatException $formatException) { - $this->assertStringContainsString('onlyNumbers', $formatException->getMessage()); - } - - // Already-transformed int -5: skip guard bypasses format check (no TypeError), - // execution reaches minimum and correctly throws MinimumException. - try { - new $className(['value' => -5]); - $this->fail('Expected MinimumException for int input -5'); - } catch (MinimumException $minimumException) { - $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); - } - - // Already-transformed int 42: skip guard bypasses format check, minimum passes. - $object = new $className(['value' => 42]); - $this->assertSame(42, $object->getValue()); - } - - // ------------------------------------------------------------------------- - // Composition runtime integration around transforming filters - // ------------------------------------------------------------------------- - - /** - * Input-space allOf runs PRE-transform. - * - * Schema: { type: string, filter: dateTime, allOf: [{minLength: 5}] } - * - * With the parent property typed as "string", minLength:5 is inherited into the branch - * and the validator fires. Before the fix the allOf ran POST-transform: DateTime is not - * a string so minLength never fires, and even a too-short string slipped through. After - * the fix the allOf runs on the raw input string before the filter. - * - * Observable proof: "2024" (4 chars < 5) throws AllOfException. Post-transform that - * input would produce a DateTime and the minLength check would silently skip. - * - * Already-transformed value: when a DateTime is supplied directly the input-space allOf is - * skipped entirely. - */ - public function testInputSpaceAllOfCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfInputSpace.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - // "20240101" (8 chars ≥ 5): allOf passes pre-transform → filter → DateTime. - $object = new $className(['filteredProperty' => '20240101']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // "2024" (4 chars < 5): allOf fails pre-transform → AllOfException. - // If the allOf ran POST-transform it would see a DateTime, minLength would skip - // (is_string check fails), and no exception would be thrown. - // Single branch (minLength:5) fails → succeeded=0. - try { - new $className(['filteredProperty' => '2024']); - $this->fail('Expected AllOfException for "2024"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed DateTime skips the input-space allOf. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Mixed-space allOf is split around the transforming filter. - * - * Schema: { type: [string, integer], filter: stringToInt, - * allOf: [{type:string, minLength:1}, {minimum:0}] } - * - {type:string, minLength:1} is input-space (string constraint, runs PRE-transform). - * - {minimum:0} is output-space (numeric constraint, runs POST-transform). - * - * Validated behaviours: - * (a) "5" → pre-allOf passes (string, len≥1), filter→5, post-allOf passes (int≥0) → 5. - * (b) "" → pre-allOf fails (minLength:1) → AllOfException before the filter. - * (c) "-5" → pre-allOf passes (len≥1), filter→-5, post-allOf fails (minimum:0) → AllOfException. - * (d) 5 → already-int, skip pre-allOf, post-allOf passes → 5. - * (e) -5 → already-int, skip pre-allOf, post-allOf fails → AllOfException. - */ - public function testMixedSpaceAllOfSplitAroundTransformingFilter(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfMixedSpaces.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // (a) Valid string — both spaces satisfied. - $object = new $className(['filteredProperty' => '5']); - $this->assertSame(5, $object->getFilteredProperty()); - - // (b) Empty string — input-space minLength:1 fails before filter runs. - // Pre-subset has one branch (minLength:1); it fails → succeeded=0. - try { - new $className(['filteredProperty' => '']); - $this->fail('Expected AllOfException for ""'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // (c) "-5" passes input-space (len≥1) but transforms to -5 which fails minimum:0. - // Post-subset has one branch (minimum:0); it fails → succeeded=0. - try { - new $className(['filteredProperty' => '-5']); - $this->fail('Expected AllOfException for "-5"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // (d) Already-int 5: skip input pipeline, post-allOf minimum:0 passes. - $object = new $className(['filteredProperty' => 5]); - $this->assertSame(5, $object->getFilteredProperty()); - - // (e) Already-int -5: skip input pipeline, post-allOf minimum:0 fails → succeeded=0. - try { - new $className(['filteredProperty' => -5]); - $this->fail('Expected AllOfException for -5 (already-int)'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - } - - /** - * Input-space anyOf runs PRE-transform. - * - * Schema: { filter: dateTime, anyOf: [{type: string}, {type: integer}] } - * - * Both branches are input-space (string and integer are both in the dateTime filter's - * accepted-type set). The anyOf must run on the raw value before the filter. - * - * Observable proof: "2024-01-01" succeeds (type:string branch passes pre-transform). - * If anyOf ran POST-transform on DateTime, neither branch would pass (DateTime is not a - * string or integer) → AnyOfException. Receiving a DateTime confirms pre-transform. - * - * Already-transformed value: DateTime supplied directly skips the input-space anyOf. - */ - public function testInputSpaceAnyOfCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAnyOfInputOnly.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // "2024-01-01" is a string → type:string branch passes → anyOf passes → DateTime. - // Proof: if anyOf ran POST-transform, DateTime would fail both {type:string} and - // {type:integer}, causing AnyOfException. Success proves pre-transform. - $object = new $className(['filteredProperty' => '2024-01-01']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // Already-transformed DateTime skips the input-space anyOf. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Input-space oneOf runs PRE-transform. - * - * Schema: { type: string, filter: dateTime, oneOf: [{minLength: 5}, {maxLength: 3}] } - * - * With type:string inherited, the validators fire on the raw string. Exactly one branch - * must pass for oneOf to succeed. - * - * Observable proof: "20240101" (8 chars) passes only {minLength:5} → exactly one → - * oneOf passes → DateTime. Post-transform, DateTime is not a string so both minLength and - * maxLength skip (is_string check fails), both branches "pass", two pass → OneOfException. - * Receiving a DateTime proves the oneOf ran on the raw string. - * - * "2024" (4 chars) fails both branches → OneOfException pre-transform. - * - * Already-transformed value: DateTime supplied directly skips the input-space oneOf. - */ - public function testInputSpaceOneOfCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionOneOfInputSpace.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - // "20240101" (8 chars): minLength:5 passes, maxLength:3 fails → 1 match → DateTime. - // If oneOf ran POST-transform, is_string(DateTime)=false → both branches pass → - // 2 matches → OneOfException. Success proves pre-transform. - $object = new $className(['filteredProperty' => '20240101']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // "2024" (4 chars): minLength:5 fails, maxLength:3 fails → 0 matches → OneOfException. - // Both branches fail for the 4-char string → succeeded=0. - try { - new $className(['filteredProperty' => '2024']); - $this->fail('Expected OneOfException for "2024"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed DateTime skips the input-space oneOf. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Input-space if/then/else runs PRE-transform. - * - * Schema: { type: string, filter: dateTime, - * if: {minLength: 8}, then: {maxLength: 20}, else: {minLength: 1} } - * - * With type:string inherited into every sub-schema, all validators fire on the raw string. - * - * Observable proof: "" (0 chars) triggers ConditionalException pre-transform. - * - if minLength:8 fails → $ifException; else minLength:1 also fails → $elseException - * - ConditionalException thrown. - * Post-transform, DateTime is not a string so both minLength checks would skip, the - * if-branch would "pass", the then-branch would "pass", and no exception would be thrown. - * Getting a ConditionalException for "" proves the conditional ran on the raw string. - * - * Already-transformed value: DateTime supplied directly skips the input-space conditional. - */ - public function testInputSpaceIfThenElseCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionIfThenElseInputSpace.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // "20240101" (8 chars): if minLength:8 passes → then maxLength:20 passes → DateTime. - $object = new $className(['filteredProperty' => '20240101']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // "" (0 chars): if minLength:8 fails, else minLength:1 fails → ConditionalException. - // Post-transform, DateTime is not a string, minLength checks would silently skip and - // both branches would pass — no exception. The exception proves pre-transform. - // if-branch fails → ifException set; else-branch fails → elseException set; then not evaluated. - try { - new $className(['filteredProperty' => '']); - $this->fail('Expected ConditionalException for ""'); - } catch (ConditionalException $exception) { - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertNotNull($exception->getIfException()); - $this->assertNull($exception->getThenException()); - $this->assertNotNull($exception->getElseException()); - } - - // Already-transformed DateTime skips the input-space conditional. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Output-space anyOf runs POST-transform. - * - * Schema: { type: [string, integer], filter: stringToInt, - * anyOf: [{min:0, max:10}, {min:20, max:30}] } - * - * Both branches constrain the numeric range — output-space for the string→int filter. - * The anyOf must run AFTER the filter, validating the transformed integer. - * - * Observable proof: "15" → filter → 15 → neither branch passes → AnyOfException. - * If the anyOf ran PRE-transform, minimum/maximum would be no-ops on the string "15" and - * both branches would always pass vacuously — no exception would be thrown. - * The AnyOfException for "15" proves the anyOf ran on the transformed integer. - * - * "15" → filter → 15 → neither branch passes → AnyOfException (proves integer evaluation). - * - * Already-transformed value: int 5 directly skips the filter; the output-space anyOf still runs. - */ - public function testOutputSpaceAnyOfCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAnyOfOutputSpace.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "5": filter → 5 → {min:0, max:10} passes → anyOf passes. - $object = new $className(['filteredProperty' => '5']); - $this->assertSame(5, $object->getFilteredProperty()); - - // "15": filter → 15 → neither branch passes → AnyOfException. - // Proves anyOf ran on the integer (15 is out of both ranges); both fail → succeeded=0. - // Branch 0 (max:10) rejects 15 → "must not be larger than 10" in message. - try { - new $className(['filteredProperty' => '15']); - $this->fail('Expected AnyOfException for "15"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AnyOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed int 5 → filter skipped → output-space anyOf still runs and passes. - $object = new $className(['filteredProperty' => 5]); - $this->assertSame(5, $object->getFilteredProperty()); - } - - /** - * Output-space oneOf runs POST-transform. - * - * Schema: { type: [string, integer], filter: stringToInt, - * oneOf: [{min:0, max:10}, {min:20, max:30}] } - * - * Both branches constrain the numeric range — output-space for the string→int filter. - * The oneOf must run AFTER the filter, validating the transformed integer. - * - * Observable proof: "15" → filter → 15 → neither branch passes → OneOfException. - * If the oneOf ran PRE-transform, minimum/maximum would be no-ops on the string "15" and - * both branches would always pass vacuously → 2 pass → OneOfException for the wrong reason. - * "5" → filter → 5 → exactly {min:0,max:10} passes → 1 match → oneOf passes. - * "25" → 25 → {min:20,max:30} passes → 1 match → oneOf passes. - * - * Already-transformed value: int 5 directly skips the filter; the output-space oneOf still runs. - */ - public function testOutputSpaceOneOfCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionOneOfOutputSpace.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "5": filter → 5 → {min:0, max:10} passes, {min:20, max:30} fails → exactly 1 → oneOf passes. - $object = new $className(['filteredProperty' => '5']); - $this->assertSame(5, $object->getFilteredProperty()); - - // "15": filter → 15 → neither branch passes → OneOfException. - // Proves oneOf ran on the integer; both branches fail → succeeded=0. - // Branch 0 (max:10) rejects 15 → "must not be larger than 10" in message. - try { - new $className(['filteredProperty' => '15']); - $this->fail('Expected OneOfException for "15"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed int 5 → filter skipped → output-space oneOf still runs and passes. - $object = new $className(['filteredProperty' => 5]); - $this->assertSame(5, $object->getFilteredProperty()); - } - - /** - * Output-space if/then/else runs POST-transform. - * - * Schema: { type: [string, integer], filter: stringToInt, - * if: {minimum:0}, then: {maximum:100}, else: {minimum:-100} } - * - * All sub-schemas constrain numeric ranges — output-space for the string→int filter. - * The conditional must run AFTER the filter, validating the transformed integer. - * - * Observable proof: "200" → filter → 200 → if:{min:0} passes → then:{max:100} fails - * → ConditionalException. If the conditional ran PRE-transform, minimum/maximum would be - * no-ops on the string "200" and all branches would always pass vacuously — no exception. - * The ConditionalException for "200" proves the conditional ran on the transformed integer. - * - * "-200" → -200 → if fails (−200<0) → else:{min:-100} fails (−200<−100) → ConditionalException. - * - * Already-transformed value: int 50 directly skips the filter; the output-space conditional still runs. - */ - public function testOutputSpaceIfThenElseCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionIfThenElseOutputSpace.json', - (new GeneratorConfiguration()) - ->setCollectErrors(false) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "50": filter → 50 → if:{min:0} passes → then:{max:100} passes → success. - $object = new $className(['filteredProperty' => '50']); - $this->assertSame(50, $object->getFilteredProperty()); - - // "-5": filter → -5 → if:{min:0} fails → else:{min:-100} passes (-5 ≥ -100) → success. - $object = new $className(['filteredProperty' => '-5']); - $this->assertSame(-5, $object->getFilteredProperty()); - - // "200": filter → 200 → if passes (200≥0) → ifException=null; then fails (200>100) → thenException set. - // then:{max:100} fails → "must not be larger than 100" embedded in message. - try { - new $className(['filteredProperty' => '200']); - $this->fail('Expected ConditionalException for "200"'); - } catch (ConditionalException $exception) { - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertNull($exception->getIfException()); - $this->assertNotNull($exception->getThenException()); - $this->assertNull($exception->getElseException()); - } - - // "-200": filter → -200 → if fails (-200<0) → ifException set; else:{min:-100} also fails → elseException set. - try { - new $className(['filteredProperty' => '-200']); - $this->fail('Expected ConditionalException for "-200"'); - } catch (ConditionalException $exception) { - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertNotNull($exception->getIfException()); - $this->assertNull($exception->getThenException()); - $this->assertNotNull($exception->getElseException()); - } - - // Already-transformed int 50 → filter skipped → output-space conditional still runs and passes. - $object = new $className(['filteredProperty' => 50]); - $this->assertSame(50, $object->getFilteredProperty()); - } - - /** - * Mixed-space allOf split in collect-errors mode collects errors from both subsets. - * - * Schema: { type: [string, integer], filter: stringToInt, - * allOf: [{type:string, minLength:1}, {minimum:0}] } - * - * In collect-errors mode validation continues after each failure, so: - * - A pre-transform error (minLength) and a post-transform error (minimum) are each - * independently collected. - * - Success cases still produce the correct transformed value. - */ - public function testMixedSpaceAllOfSplitWithCollectErrors(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfMixedSpaces.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "5": both spaces satisfied → no errors. - $object = new $className(['filteredProperty' => '5']); - $this->assertSame(5, $object->getFilteredProperty()); - - // "": pre-allOf minLength:1 fails → ErrorRegistryException containing AllOfException. - // One pre-subset AllOfException (1 branch, 0 pass) → "must not be shorter than 1". - try { - new $className(['filteredProperty' => '']); - $this->fail('Expected ErrorRegistryException for ""'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - - // "-5": pre-allOf passes (len≥1), filter→-5, post-allOf minimum:0 fails → - // ErrorRegistryException containing one AllOfException → "must not be smaller than 0". - try { - new $className(['filteredProperty' => '-5']); - $this->fail('Expected ErrorRegistryException for "-5"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - } - - /** - * Schema: { filter: dateTime, allOf: [{type: object}] } - * - * The 'type' keyword inside a composition branch always validates the RAW input value - * (it is always Input-space — never reclassified as Output-space). The allOf branch - * {type: object} therefore requires the raw input to be an object. Because the - * dateTime filter only accepts strings (not objects), no raw value can ever both pass - * the allOf validation AND be accepted by the filter — the filter is unreachable. - * - * This schema is rejected at generation time with a dead-filter SchemaException. If the - * intent is to accept only already-converted DateTime objects, the filter should be - * removed entirely; if the intent is to accept raw date strings too, the allOf type - * constraint must not exclude the filter's accepted types. - */ - public function testObjectOutputTypeConstraintMakingFilterDeadThrowsSchemaException(): void - { - $this->expectException(SchemaException::class); - $this->expectExceptionMessageMatches( - '/Filter dateTime on property filteredProperty.*can never be executed' - . '.*allOf type constraints \(object\) exclude/', - ); - - $this->generateClassFromFile('FilterCompositionAllOfObjectBranchOutput.json'); - } - - public static function convertStringToInt(string $value): int - { - return (int) $value; - } - - public static function serializeIntToString(int $value): string - { - return (string) $value; - } - - /** - * Non-transforming filter + allOf: the allOf validates the POST-filter (trimmed) value. - * - * Schema: { type: string, filter: trim, allOf: [{minLength: 5}] } - * - * Since trim is non-transforming, no priority reassignment occurs. The allOf validator - * runs after trim (default priority 100 > trim priority ~10), so minLength fires against - * the already-trimmed string. - * - * Observable proof: " hi " trims to "hi" (2 chars) and fails minLength:5 → AllOfException. - * If the allOf ran pre-trim, " hi " has 8 chars and would pass minLength:5. - */ - public function testNonTransformingFilterWithAllOfValidatesAfterFilter(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfWithTrim.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - // " hello " trims to "hello" (5 chars) → allOf minLength:5 passes. - $object = new $className(['filteredProperty' => ' hello ']); - $this->assertSame('hello', $object->getFilteredProperty()); - - // " hi " trims to "hi" (2 chars) → allOf minLength:5 fails → AllOfException. - // If allOf ran pre-trim, the 8-char padded string would pass minLength:5. - // Single branch (minLength:5) fails → succeeded=0. - try { - new $className(['filteredProperty' => ' hi ']); - $this->fail('Expected AllOfException for " hi "'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - } - } - - /** - * Input-space not runs PRE-transform. - * - * Schema: { type: string, filter: dateTime, not: { minLength: 5 } } - * - * The not inner schema { minLength: 5 } is input-space (string-targeted keyword) and is - * moved to run before the filter. The not passes when the inner schema FAILS. - * - * Observable proof: "2024-01-01" (10 chars, valid date) → inner minLength:5 passes → not - * violated → NotException. Post-transform, DateTime is not a string so minLength would skip, - * the branch would "fail" (0 chars?), not would pass, and no exception would be thrown. - * Getting a NotException for "2024-01-01" proves the not ran on the raw string. - * - * A valid date string is used so the filter itself does not fail, keeping the error registry - * to exactly one NotException (the not violation). - * - * Already-transformed value: DateTime directly skips the input-space not. - */ - public function testInputSpaceNotCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionNotInputSpace.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - // "now" (3 chars): not { minLength: 5 } → inner fails (3 < 5) → not passes → DateTime. - $object = new $className(['filteredProperty' => 'now']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // "2024-01-01" (10 chars, valid date): not { minLength: 5 } → inner passes → not violated. - // Post-transform, DateTime is not a string, minLength silently skips, inner "fails", - // not passes — no exception. The NotException proves the not ran on the raw string. - // A valid date string keeps the filter from failing, so the registry holds exactly one error. - // Inner schema (minLength:5) passes → succeeded=1 → composition element is Valid. - try { - new $className(['filteredProperty' => '2024-01-01']); - $this->fail('Expected NotException for "2024-01-01"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(NotException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed DateTime → input-space not skipped → passes. - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Output-space not runs POST-transform. - * - * Schema: { filter: stringToInt, not: { minimum: 0 } } - * - * The not inner schema { minimum: 0 } is output-space (int-targeted keyword) and stays at - * its default post-transform position. The not passes when the inner schema FAILS. - * - * Observable proof: "5" → filter → 5 → not { minimum: 0 }: 5 ≥ 0 → inner passes → not - * violated → NotException. If the not ran pre-transform, minimum would not apply to the - * string "5" (is_int check fails), the inner schema would fail, and not would pass — no - * exception. The exception proves post-transform execution. - * - * Already-transformed value: int directly skips the filter; the output-space not still runs. - */ - public function testOutputSpaceNotCompositionRunsPostTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionNotOutputSpace.json', - (new GeneratorConfiguration()) - ->setCollectErrors(true) - ->setImmutable(false) - ->addFilter($this->getCustomTransformingFilter( - [self::class, 'serializeIntToString'], - [self::class, 'convertStringToInt'], - 'stringToInt', - )), - ); - - // "-5": filter → -5 → not { minimum: 0 }: -5 < 0 → inner fails → not passes → -5. - $object = new $className(['filteredProperty' => '-5']); - $this->assertSame(-5, $object->getFilteredProperty()); - - // "5": filter → 5 → not { minimum: 0 }: 5 ≥ 0 → inner passes → not violated → NotException. - // If not ran pre-transform, minimum would skip for the string "5", inner fails, not passes. - // The exception proves not ran on the transformed integer; inner succeeds → succeeded=1. - try { - new $className(['filteredProperty' => '5']); - $this->fail('Expected NotException for "5"'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(NotException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - } - - // Already-transformed int -5 → filter skipped → not { minimum: 0 }: -5 < 0 → inner fails → passes. - $object = new $className(['filteredProperty' => -5]); - $this->assertSame(-5, $object->getFilteredProperty()); - - // Already-transformed int 5 → filter skipped → not { minimum: 0 }: 5 ≥ 0 → not violated. - // Inner schema succeeds → succeeded=1 → composition element is Valid. - try { - new $className(['filteredProperty' => 5]); - $this->fail('Expected NotException for already-transformed int 5'); - } catch (ErrorRegistryException $exception) { - $errors = $exception->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(NotException::class, $errors); - $this->assertStringContainsString( - <<getMessage(), - ); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - } - } - - // --- self / static return type tests --- - - public static function selfStaticReturnTypeDataProvider(): array - { - return [ - 'self return type' => [SelfReturningFilterCallable::class, 'selfReturn', 'hello'], - 'static return type' => [StaticReturningFilterCallable::class, 'staticReturn', 'world'], - ]; - } - - /** - * A transforming filter whose callable declares '?self' or '?static' as return type. - * FilterReflection must resolve both to the declaring class FQCN so that the generated - * output type and pass-through type check use a valid class name. - */ - #[DataProvider('selfStaticReturnTypeDataProvider')] - public function testTransformingFilterWithSelfOrStaticReturnType( - string $callableClass, - string $token, - string $inputValue, - ): void { - $className = $this->generateClassFromFileTemplate( - 'FilterChain.json', - ['"' . $token . '"'], - (new GeneratorConfiguration()) - ->setImmutable(false) - ->addFilter( - $this->getCustomTransformingFilter( - [$callableClass, 'serialize'], - [$callableClass, 'filter'], - $token, - ), - ), - false, - ); - - // string input → filter wraps it in an instance of the declaring class - $object = new $className(['filteredProperty' => $inputValue]); - $this->assertInstanceOf($callableClass, $object->getFilteredProperty()); - $this->assertSame($inputValue, $object->getFilteredProperty()->getValue()); - - // null input → null stored - $object = new $className(['filteredProperty' => null]); - $this->assertNull($object->getFilteredProperty()); - - // pre-existing instance → passed through unchanged (setter accepts output type) - $existing = new $callableClass('existing'); - $object->setFilteredProperty($existing); - $this->assertSame($existing, $object->getFilteredProperty()); - } - - /** - * if/then (no else) under a transforming filter: the conditional runs pre-transform. - * When the if-condition fails (short string), no else means the value passes through. - * When the if-condition passes but then fails, ConditionalException is thrown pre-transform. - * An already-transformed DateTime bypasses the pre-transform conditional entirely. - */ - public function testInputSpaceIfThenOnlyCompositionRunsPreTransform(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionIfThenOnlyInputSpace.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // "20240101" satisfies if (minLength:8) and then (maxLength:20) → filter runs → DateTime. - $object = new $className(['filteredProperty' => '20240101']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // "now" (3 chars) fails if (minLength:8) → no else → conditional passes → filter → DateTime. - $object = new $className(['filteredProperty' => 'now']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - // Overlong string: if passes, then fails → ConditionalException thrown pre-transform. - try { - new $className(['filteredProperty' => 'abcdefghijklmnopqrstuvwxyz']); - $this->fail('Expected ConditionalException for overlong string'); - } catch (ConditionalException $exception) { - $this->assertNull($exception->getIfException()); - $this->assertNotNull($exception->getThenException()); - $this->assertNull($exception->getElseException()); - $this->assertStringContainsString( - 'Value for filteredProperty must not be longer than 20', - $exception->getMessage(), - ); - } - - // Already-transformed DateTime skips the pre-transform conditional (R-8 passthrough). - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * An empty {} allOf branch under a transforming filter is a no-op: it imposes no constraints - * so the filter runs and the value is transformed. An already-transformed DateTime passes - * through unchanged (R-8 passthrough). - */ - public function testEmptyAllOfBranchWithTransformingFilterIsNoOp(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionAllOfEmptyBranch.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - $object = new $className(['filteredProperty' => '2024-01-01']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * A root-level allOf with an input-space constraint targeting a filtered sub-property is - * allowed at generation time. At runtime the property validator runs first (including the - * filter), so the root-level constraint sees the already-transformed output value. For the - * dateTime filter, minLength:1 on a DateTime object is a no-op — the constraint is - * statically allowed but effectively inert against the transformed value. - */ - public function testRootLevelInputSpaceConstraintOnFilteredSubpropertyRunsSuccessfully(): void - { - $className = $this->generateClassFromFile( - 'FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - $object = new $className(['filteredProperty' => '2024-01-01']); - $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); - - $dateTime = new DateTime('2024-06-01'); - $object = new $className(['filteredProperty' => $dateTime]); - $this->assertSame($dateTime, $object->getFilteredProperty()); - } - - /** - * Non-transforming filter (trim) with anyOf, not, and if/then composition: - * the composition validators run on the already-trimmed value, not the raw string. - */ - public function testNonTransformingFilterCompositionVariantsValidateAfterFilter(): void - { - // anyOf: collectErrors(true) required so per-branch failure details are populated. - $anyOfClass = $this->generateClassFromFile( - 'FilterCompositionAnyOfWithTrim.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - $object = new $anyOfClass(['filteredProperty' => ' hello ']); - $this->assertSame('hello', $object->getFilteredProperty()); - - // " no " → trim → "no" (2 chars): both anyOf branches fail post-trim. - // Branch 1: minLength:5 fails (proves trim ran, not raw 12-char string). - // Branch 2: const:"hi" fails. - try { - new $anyOfClass(['filteredProperty' => ' no ']); - $this->fail('Expected AnyOfException for padded "no"'); - } catch (ErrorRegistryException $registryException) { - $errors = $registryException->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(AnyOfException::class, $errors); - $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); - $this->assertStringContainsString( - <<getMessage(), - ); - } - - // not: collectErrors(true) required so per-element details are populated. - $notClass = $this->generateClassFromFile( - 'FilterCompositionNotWithTrim.json', - (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), - ); - - $object = new $notClass(['filteredProperty' => ' hello ']); - $this->assertSame('hello', $object->getFilteredProperty()); - - // " " → trim → "" (empty string): not{const:""} is violated post-trim. - // Raw " " (non-empty) would pass not{const:""} pre-trim, proving trim ran first. - try { - new $notClass(['filteredProperty' => ' ']); - $this->fail('Expected NotException for whitespace-only string'); - } catch (ErrorRegistryException $registryException) { - $errors = $registryException->getErrors(); - $this->assertCount(1, $errors); - $this->assertContainsOnlyInstancesOf(NotException::class, $errors); - $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); - $this->assertStringContainsString( - <<getMessage(), - ); - } - - // if/then: collectErrors(false) is fine; ConditionalException is thrown directly. - $ifThenClass = $this->generateClassFromFile( - 'FilterCompositionIfThenWithTrim.json', - (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), - ); - - // " Alice " → trim → "Alice" → pattern:^A passes → no exception (proves post-trim). - $object = new $ifThenClass(['filteredProperty' => ' Alice ']); - $this->assertSame('Alice', $object->getFilteredProperty()); - - // " Bob " → trim → "Bob" → if minLength:1 passes → then pattern:^A fails → ConditionalException. - try { - new $ifThenClass(['filteredProperty' => ' Bob ']); - $this->fail('Expected ConditionalException for " Bob "'); - } catch (ConditionalException $exception) { - $this->assertNull($exception->getIfException()); - $this->assertNotNull($exception->getThenException()); - $this->assertStringContainsString( - <<getMessage(), - ); - } - } -} diff --git a/tests/Filter/AbstractFilterTestCase.php b/tests/Filter/AbstractFilterTestCase.php new file mode 100644 index 00000000..dd59da86 --- /dev/null +++ b/tests/Filter/AbstractFilterTestCase.php @@ -0,0 +1,131 @@ +/ directory via the default getStaticClassName() behaviour. + */ +abstract class AbstractFilterTestCase extends AbstractPHPModelGeneratorTestCase +{ + // ------------------------------------------------------------------------- + // Helper factory methods + // ------------------------------------------------------------------------- + + protected function getCustomFilter( + array $customFilter, + string $token = 'customFilter', + ): FilterInterface { + return new class ($customFilter, $token) implements FilterInterface { + public function __construct( + private readonly array $customFilter, + private readonly string $token, + ) {} + + public function getToken(): string + { + return $this->token; + } + + public function getFilter(): array + { + return $this->customFilter; + } + }; + } + + protected function getCustomTransformingFilter( + array $customSerializer, + array $customFilter = [], + string $token = 'customTransformingFilter', + ): TransformingFilterInterface { + return new class ( + $customSerializer, + $customFilter, + $token, + ) extends TrimFilter implements TransformingFilterInterface + { + public function __construct( + private readonly array $customSerializer, + private readonly array $customFilter, + private readonly string $token, + ) {} + + public function getToken(): string + { + return $this->token; + } + + public function getFilter(): array + { + return empty($this->customFilter) ? parent::getFilter() : $this->customFilter; + } + + public function getSerializer(): array + { + return $this->customSerializer; + } + }; + } + + // ------------------------------------------------------------------------- + // Shared data providers + // ------------------------------------------------------------------------- + + /** + * Cross-product of implicitNull × namespace; used by transforming-filter and chain tests. + */ + public static function implicitNullNamespaceDataProvider(): array + { + return self::combineDataProvider( + self::implicitNullDataProvider(), + self::namespaceDataProvider(), + ); + } + + // ------------------------------------------------------------------------- + // Shared static callables (used by two or more test classes) + // ------------------------------------------------------------------------- + + /** Accepts string; returns uppercase string. Used by type-compatibility and chain tests. */ + public static function uppercaseFilterStringOnly(string $value): string + { + return strtoupper($value); + } + + /** Converts an integer to its binary string representation. Used by transforming-filter and type-compatibility tests. */ + public static function filterIntToBinary(int $value): string + { + return decbin($value); + } + + /** Converts a binary string back to an integer. Used by transforming-filter and type-compatibility tests. */ + public static function serializeBinaryToInt(string $binary): int + { + return bindec($binary); + } + + /** Casts a string to int. Used by composition-static and composition-runtime tests. */ + public static function convertStringToInt(string $value): int + { + return (int) $value; + } + + /** Casts an int to string. Used by composition-static and composition-runtime tests. */ + public static function serializeIntToString(int $value): string + { + return (string) $value; + } +} diff --git a/tests/Filter/BuiltInFilterTest.php b/tests/Filter/BuiltInFilterTest.php new file mode 100644 index 00000000..1a7d453d --- /dev/null +++ b/tests/Filter/BuiltInFilterTest.php @@ -0,0 +1,153 @@ +generateClassFromFileTemplate($template, ['"string"'], null, false); + + $object = new $className($input); + + $this->assertSame($object->getProperty(), $expected); + // make sure the raw input isn't affected by the filter + $this->assertSame($input, $object->getRawModelDataInput()); + } + + #[DataProvider('validTrimDataFormatProvider')] + public function testNotProvidedOptionalValueWithFilterIsValid(string $template): void + { + $className = $this->generateClassFromFileTemplate($template, ['"string"'], null, false); + + $object = new $className([]); + + $this->assertNull($object->getProperty()); + } + + public static function validTrimDataFormatProvider(): array + { + return [ + 'trimAsList' => ['TrimAsList.json'], + 'trimAsString' => ['TrimAsString.json'], + ]; + } + + public static function validBuiltInFilterDataProvider(): array + { + return self::combineDataProvider( + self::validTrimDataFormatProvider(), + [ + 'Optional Value not provided' => [[], null], + 'Null' => [['property' => null], null], + 'Empty string' => [['property' => ''], ''], + 'String containing only whitespaces' => [['property' => " \t \n \r "], ''], + 'Numeric string' => [['property' => ' 12 '], '12'], + 'Text' => [['property' => ' Hello World! '], 'Hello World!'], + ], + ); + } + + #[DataProvider('invalidUsageOfBuiltInFilterDataProvider')] + public function testInvalidUsageOfBuiltInFilterThrowsAnException( + string $template, + string $jsonType, + string $phpType, + ): void { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Filter trim is not compatible with property type $phpType for property property/", + ); + + $this->generateClassFromFileTemplate($template, ['"' . $jsonType . '"'], null, false); + } + + public static function invalidUsageOfBuiltInFilterDataProvider(): array + { + return self::combineDataProvider( + self::validTrimDataFormatProvider(), + [ + 'boolean' => ['boolean', 'bool'], + 'integer' => ['integer', 'int'], + 'number' => ['number', 'float'], + 'array' => ['array', 'array'], + 'object' => ['object', 'object'], + ], + ); + } + + #[DataProvider('validLengthAfterFilterDataProvider')] + public function testLengthValidationForFilteredValueForValidValues(?string $input, ?string $expectedValue): void + { + $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); + + $object = new $className(['property' => $input]); + $this->assertSame($expectedValue, $object->getProperty()); + } + + public static function validLengthAfterFilterDataProvider(): array + { + return [ + 'String with two chars' => [" AB \n", "AB"], + 'null' => [null, null], + ]; + } + + #[DataProvider('invalidLengthAfterFilterDataProvider')] + public function testLengthValidationForFilteredValueForInvalidValuesThrowsAnException(string $input): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Value for property must not be shorter than 2'); + + $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); + + new $className(['property' => $input]); + } + + public static function invalidLengthAfterFilterDataProvider(): array + { + return [ + 'Empty string' => [''], + 'String with only whitespaces' => [" \n \t "], + 'Too short string' => [' a '], + ]; + } + + /** + * Regression guard: a non-transforming filter must not trigger validator-priority reassignment. + * minLength validates the *trimmed* value (filter runs first, validator runs after). + */ + public function testNonTransformingFilterDoesNotTriggerPriorityReassignment(): void + { + $className = $this->generateClassFromFile('TrimAsStringWithLengthValidation.json'); + + // " AB \n" trims to "AB" (length 2) — passes minLength: 2. + $object = new $className(['property' => " AB \n"]); + $this->assertSame('AB', $object->getProperty()); + + // " a " trims to "a" (length 1) — fails minLength: 2 (validates trimmed value). + try { + new $className(['property' => ' a ']); + $this->fail('Expected ValidationException for input " a "'); + } catch (ValidationException $validationException) { + $this->assertStringContainsString('must not be shorter than 2', $validationException->getMessage()); + } + } +} diff --git a/tests/Filter/CustomFilterTest.php b/tests/Filter/CustomFilterTest.php new file mode 100644 index 00000000..2633561f --- /dev/null +++ b/tests/Filter/CustomFilterTest.php @@ -0,0 +1,212 @@ +generateClassFromFile( + 'Uppercase.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->addFilter($this->getCustomFilter([self::class, 'uppercaseFilter'], 'uppercase')), + ); + + $object = new $className(['property' => $input]); + $this->assertSame($expectedValue, $object->getProperty()); + $this->assertSame($input, $object->getRawModelDataInput()['property']); + + $object->setProperty($input); + $this->assertSame($expectedValue, $object->getProperty()); + + $object->setProperty('hi'); + $this->assertSame('HI', $object->getProperty()); + $this->assertSame('hi', $object->getRawModelDataInput()['property']); + } + + public static function customFilterDataProvider(): array + { + return [ + 'null' => [null, null], + 'empty string' => ['', ''], + 'numeric' => ['123', '123'], + 'spaces' => [' ', ' '], + 'uppercase string' => ['ABC', 'ABC'], + 'mixed string' => ['Hello World!', 'HELLO WORLD!'], + ]; + } + + // ------------------------------------------------------------------------- + // Multiple filters chained + // ------------------------------------------------------------------------- + + #[DataProvider('multipleFilterDataProvider')] + public function testMultipleFilters(?string $input, ?string $expectedValue): void + { + $className = $this->generateClassFromFile( + 'MultipleFilters.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->addFilter($this->getCustomFilter([self::class, 'uppercaseFilter'], 'uppercase')), + ); + + $object = new $className(['property' => $input]); + $this->assertSame($expectedValue, $object->getProperty()); + + $object->setProperty($input); + $this->assertSame($expectedValue, $object->getProperty()); + } + + public static function multipleFilterDataProvider(): array + { + return [ + 'null' => [null, null], + 'empty string' => ['', ''], + 'numeric' => [' 123 ', '123'], + 'spaces' => [' ', ''], + 'uppercase string' => [" ABC\n", 'ABC'], + 'mixed string' => [" \t Hello World! ", 'HELLO WORLD!'], + ]; + } + + // ------------------------------------------------------------------------- + // Array item filter + // ------------------------------------------------------------------------- + + #[DataProvider('arrayFilterDataProvider')] + public function testArrayFilter(?array $input, ?array $output): void + { + $className = $this->generateClassFromFile('ArrayFilter.json'); + + $object = new $className(['list' => $input]); + $this->assertSame($output, $object->getList()); + } + + public static function arrayFilterDataProvider(): array + { + return [ + 'null' => [null, null], + 'empty array' => [[], []], + 'string array' => [['', 'Hello', null, '123'], ['Hello', '123']], + 'numeric array' => [[12, 0, 43], [12, 43]], + 'nested array' => [[['Hello'], [], [12], ['']], [['Hello'], [12], ['']]], + ]; + } + + // ------------------------------------------------------------------------- + // Custom filter option validation (ValidateOptionsInterface) + // ------------------------------------------------------------------------- + + #[DataProvider('invalidEncodingFilterConfigurationsDataProvider')] + public function testInvalidCustomFilterOptionValidation(string $configuration, string $expectedErrorMessage): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Invalid filter options on filter encode on property .*\: $expectedErrorMessage/", + ); + + $this->generateClassFromFileTemplate( + 'Encode.json', + [$configuration], + (new GeneratorConfiguration())->setImmutable(false)->addFilter($this->getEncodeFilter()), + false, + ); + } + + public static function invalidEncodingFilterConfigurationsDataProvider(): array + { + return [ + 'simple notation without options' => ['"encode"', 'Missing charset configuration'], + 'object notation without charset configuration' => [ + '{"filter": "encode"}', + 'Missing charset configuration', + ], + 'Invalid charset configuration' => ['{"filter": "encode", "charset": 1}', 'Unsupported charset'], + 'Invalid charset configuration 2' => ['{"filter": "encode", "charset": "UTF-16"}', 'Unsupported charset'], + ]; + } + + #[DataProvider('validEncodingsDataProvider')] + public function testValidCustomFilterOptionValidation(string $encoding, string $input, string $output): void + { + $classname = $this->generateClassFromFileTemplate( + 'Encode.json', + [sprintf('{"filter": "encode", "charset": "%s"}', $encoding)], + (new GeneratorConfiguration())->setImmutable(false)->addFilter($this->getEncodeFilter()), + false, + ); + + $object = new $classname(['property' => $input]); + + $this->assertSame($encoding, mb_detect_encoding($object->getProperty())); + $this->assertSame($output, $object->getProperty()); + } + + public static function validEncodingsDataProvider(): array + { + return [ + 'ASCII to ASCII' => ['ASCII', 'Hello World', 'Hello World'], + 'UTF-8 to ASCII' => ['ASCII', 'áéó', '???'], + 'UTF-8 to UTF-8' => ['UTF-8', 'áéó', 'áéó'], + ]; + } + + private function getEncodeFilter(): FilterInterface + { + return new class () implements FilterInterface, ValidateOptionsInterface { + public function getToken(): string + { + return 'encode'; + } + + public function getFilter(): array + { + return [CustomFilterTest::class, 'encode']; + } + + public function validateOptions(array $options): void + { + if (!isset($options['charset'])) { + throw new Exception('Missing charset configuration'); + } + + if (!in_array($options['charset'], ['UTF-8', 'ASCII'])) { + throw new Exception('Unsupported charset'); + } + } + }; + } + + public static function encode(string $value, array $options): string + { + return mb_convert_encoding($value, $options['charset'], 'auto'); + } +} diff --git a/tests/Filter/FilterChainTest.php b/tests/Filter/FilterChainTest.php new file mode 100644 index 00000000..23a76e71 --- /dev/null +++ b/tests/Filter/FilterChainTest.php @@ -0,0 +1,428 @@ +setTime(0, 0) : null; + } + + public static function stripTimeFilterStrict(DateTime $value): DateTime + { + return $value->setTime(0, 0); + } + + public static function exceptionFilter(string $value): void + { + throw new Exception("Exception filter called with $value"); + } + + public static function exceptionFilterDateTime(?\DateTime $value): void + { + throw new Exception("Exception filter called with DateTime"); + } + + /** Transforming filter callable that returns mixed; used by mixed-return chain tests. */ + public static function filterWithMixedReturn(string $value): mixed + { + return $value; + } + + /** Serializer paired with filterWithMixedReturn. */ + public static function serializeMixedReturn(mixed $value): string + { + return (string) $value; + } + + /** Regular filter callable that accepts and returns mixed (accept-all filter). */ + public static function acceptAllFilter(mixed $value): mixed + { + return $value; + } + + /** Transforming filter callable that accepts string and returns a non-nullable int. */ + public static function filterWithIntReturn(string $value): int + { + return (int) $value; + } + + /** Serializer paired with filterWithIntReturn. */ + public static function serializeIntReturn(int $value): string + { + return (string) $value; + } + + // ------------------------------------------------------------------------- + // Pre-transforming-filter execution / skip + // ------------------------------------------------------------------------- + + public function testFilterBeforeTransformingFilterIsExecutedIfNonTransformedValueIsProvided(): void + { + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage( + 'Invalid value for property filteredProperty denied by filter exceptionFilter: ' . + 'Exception filter called with 12.12.2020', + ); + + $className = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["exceptionFilter", "dateTime"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter'), + ), + false, + ); + + new $className(['filteredProperty' => '12.12.2020']); + } + + public function testFilterBeforeTransformingFilterIsSkippedIfTransformedValueIsProvided(): void + { + $className = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["exceptionFilter", "dateTime"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter'), + ), + false, + ); + + $object = new $className(['filteredProperty' => new DateTime('2020-12-10')]); + + $this->assertSame( + (new DateTime('2020-12-10'))->format(DATE_ATOM), + $object->getFilteredProperty()->format(DATE_ATOM), + ); + } + + // ------------------------------------------------------------------------- + // Incompatible chain rejection at generation time + // ------------------------------------------------------------------------- + + public function testInvalidFilterChainWithTransformingFilterThrowsAnException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter trim is not compatible with transformed property type ' . + '[null, DateTime] for property filteredProperty', + ); + + $this->generateClassFromFileTemplate('FilterChain.json', ['["dateTime", "trim"]'], null, false); + } + + public function testFilterAfterTransformingFilterIsSkippedIfTransformingFilterFails(): void + { + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage( + 'Invalid value for property filteredProperty denied by filter dateTime: Invalid Date Time value "Hello"', + ); + + $className = $this->generateClassFromFile( + 'FilterChainMultiType.json', + (new GeneratorConfiguration()) + ->addFilter( + $this->getCustomFilter( + [self::class, 'exceptionFilterDateTime'], + 'stripTime', + ) + ), + false, + ); + + new $className(['filteredProperty' => 'Hello']); + } + + // ------------------------------------------------------------------------- + // Chains on multi-type properties + // ------------------------------------------------------------------------- + + public function testFilterChainWithTransformingFilter(): void + { + $className = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["trim", "dateTime", "stripTime"]'], + (new GeneratorConfiguration()) + ->setImmutable(false) + ->addFilter( + $this->getCustomFilter( + [self::class, 'stripTimeFilter'], + 'stripTime', + ) + ), + false, + ); + + $object = new $className(['filteredProperty' => '2020-12-12 12:12:12']); + + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); + + $object->setFilteredProperty(null); + $this->assertNull($object->getFilteredProperty()); + + $object->setFilteredProperty(new DateTime('2020-12-12 12:12:12')); + $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); + } + + #[DataProvider('implicitNullNamespaceDataProvider')] + public function testFilterChainWithTransformingFilterOnMultiTypeProperty( + bool $implicitNull, + string $namespace, + ): void { + $className = $this->generateClassFromFile( + 'FilterChainMultiType.json', + (new GeneratorConfiguration()) + ->setNamespacePrefix($namespace) + ->setSerialization(true) + ->setImmutable(false) + ->addFilter( + $this->getCustomFilter( + [self::class, 'stripTimeFilter'], + 'stripTime', + ) + ), + false, + $implicitNull, + ); + + $fqcn = $namespace . $className; + $object = new $fqcn(['filteredProperty' => '2020-12-12 12:12:12']); + + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); + + $object->setFilteredProperty(null); + $this->assertNull($object->getFilteredProperty()); + + $object->setFilteredProperty(new DateTime('2020-12-12 12:12:12')); + $this->assertSame('2020-12-12T00:00:00+00:00', $object->getFilteredProperty()->format(DateTime::ATOM)); + + $this->assertSame(['filteredProperty' => '2020-12-12T00:00:00+0000'], $object->toArray()); + } + + public function testFilterChainWithIncompatibleFilterAfterTransformingFilterOnMultiTypeProperty(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter stripTime is not compatible with transformed ' . + 'property type [null, DateTime] for property filteredProperty', + ); + + $this->generateClassFromFile( + 'FilterChainMultiType.json', + (new GeneratorConfiguration()) + ->addFilter( + $this->getCustomFilter( + [self::class, 'stripTimeFilterStrict'], + 'stripTime', + ) + ), + ); + } + + public function testFilterWhichAppliesToMultiTypePropertyPartiallyIsAllowed(): void + { + // A filter with acceptedTypes = ['string'] applied to a string|null property has partial + // overlap and is valid — the runtime typeCheck skips the filter for null values. + $className = $this->generateClassFromFile( + 'FilterChainMultiType.json', + (new GeneratorConfiguration()) + ->addFilter( + $this->getCustomFilter( + [self::class, 'uppercaseFilterStringOnly'], + 'trim', + ) + ) + ->addFilter( + $this->getCustomFilter( + [self::class, 'stripTimeFilter'], + 'stripTime', + ) + ), + false, + ); + + $this->assertNotNull($className); + } + + /** + * [trim, dateTime] filter chain on a string|integer property. + * trim accepts only string|null — the int input bypasses trim. + * dateTime accepts string|int|float|null — both inputs are converted to DateTime. + */ + public function testFilterChainTrimDateTimeOnStringIntegerProperty(): void + { + $className = $this->generateClassFromFile( + 'StringIntegerPropertyFilterChain.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // string input: trim trims whitespace, dateTime converts to DateTime + $object = new $className(['created' => ' 2020-12-12 ']); + $this->assertInstanceOf(\DateTime::class, $object->getCreated()); + $this->assertSame( + (new \DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + + // int input: trim is skipped (not a string), dateTime converts timestamp to DateTime + $object = new $className(['created' => 0]); + $this->assertInstanceOf(\DateTime::class, $object->getCreated()); + $this->assertSame( + (new \DateTime('@0'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + + // setter accepts DateTime (already-transformed output type) + $object->setCreated(new \DateTime('2020-12-12')); + $this->assertSame( + (new \DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + } + + // ------------------------------------------------------------------------- + // Chains on untyped properties + // ------------------------------------------------------------------------- + + public function testFilterChainWithTransformingFilterOnUntypedProperty(): void + { + // ['trim', 'dateTime'] on an untyped property — trim accepts string|null (from ?string + // type hint) but the property is untyped, so no SchemaException is thrown and the + // chain works correctly. + $className = $this->generateClassFromFile('UntypedPropertyFilterChain.json'); + + $object = new $className(['filteredProperty' => ' 2020-12-12 ']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame( + (new DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getFilteredProperty()->format(DATE_ATOM), + ); + + $object = new $className(['filteredProperty' => null]); + $this->assertNull($object->getFilteredProperty()); + } + + // ------------------------------------------------------------------------- + // Mixed-return and accept-all follow-up filter + // ------------------------------------------------------------------------- + + /** + * A transforming filter with a mixed return type followed by a filter that does NOT + * accept all types must throw a SchemaException. + */ + public function testMixedReturnTransformingFilterFollowedByTypedFilterThrowsException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter trim is not compatible with the unconstrained output of' + . ' transforming filter mixedReturnFilter for property filteredProperty', + ); + + $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["mixedReturnFilter", "trim"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedReturn'], + [self::class, 'filterWithMixedReturn'], + 'mixedReturnFilter', + ), + ), + false, + ); + } + + /** + * A transforming filter with a mixed return type followed by an accept-all filter must not + * throw, and neither must a concrete-return transforming filter followed by an accept-all + * filter. + */ + public function testFilterChainWithAcceptAllNextFilter(): void + { + $acceptAllFilter = $this->getCustomFilter([self::class, 'acceptAllFilter'], 'acceptAll'); + + // Mixed-return transforming filter + accept-all follow-up — no SchemaException. + $mixedReturnClassName = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["mixedReturnFilter", "acceptAll"]'], + (new GeneratorConfiguration()) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedReturn'], + [self::class, 'filterWithMixedReturn'], + 'mixedReturnFilter', + ), + ) + ->addFilter($acceptAllFilter), + false, + ); + + // The mixed-return filter just passes the string through; value is still a string. + $object = new $mixedReturnClassName(['filteredProperty' => 'hello']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // Concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up + // — no SchemaException. + $dateTimeClassName = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["dateTime", "acceptAll"]'], + (new GeneratorConfiguration())->addFilter($acceptAllFilter), + false, + ); + + // The dateTime filter converts the string to DateTime; acceptAll passes it through. + $object = new $dateTimeClassName(['filteredProperty' => '2020-12-12']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + } + + /** + * A transforming filter with a non-nullable return type followed by a filter that does not + * accept that return type must throw a SchemaException. + */ + public function testNonNullableReturnTransformingFilterWithIncompatibleNextFilterThrowsException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Filter trim is not compatible with transformed property type int' + . ' for property filteredProperty', + ); + + $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["intReturnFilter", "trim"]'], + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeIntReturn'], + [self::class, 'filterWithIntReturn'], + 'intReturnFilter', + ), + ), + false, + ); + } +} diff --git a/tests/Filter/FilterCompositionRuntimeTest.php b/tests/Filter/FilterCompositionRuntimeTest.php new file mode 100644 index 00000000..67d5b34c --- /dev/null +++ b/tests/Filter/FilterCompositionRuntimeTest.php @@ -0,0 +1,1039 @@ +format(DATE_ATOM); + } + + // ------------------------------------------------------------------------- + // Validator-priority reassignment + // ------------------------------------------------------------------------- + + /** + * With a string→int transforming filter, schema validators registered for input types + * (pattern → string-space) must run PRE-transform, while validators registered for output + * types (minimum → int-space) must run POST-transform. + * + * Pre-transform proof: "hello" filtered by (int)cast becomes 0 — a value that would pass + * both pattern and minimum if the validator ran post-transform. With the fixed ordering, + * pattern runs against the raw string "hello" and correctly fails. + * + * Post-transform proof: -5 passed as an already-transformed integer skips the pre-transform + * pipeline and goes straight to minimum, which catches the negative value. + */ + public function testValidatorPriorityReassignmentAroundTransformingFilter(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )); + + $className = $this->generateClassFromFile( + 'ValidatorPriorityWithTransformingFilter.json', + $configuration, + ); + + // "hello" casts to 0, which would pass both validators post-transform. + // The fixed ordering makes pattern catch it against the raw string. + try { + new $className(['value' => 'hello']); + $this->fail('Expected PatternException for input "hello"'); + } catch (PatternException $patternException) { + $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); + } + + // "-5" would silently become -5 post-transform, causing MinimumException instead. + // The fixed ordering catches it at pattern (pre-transform) because "-5" ∉ \d+. + try { + new $className(['value' => '-5']); + $this->fail('Expected PatternException for input "-5"'); + } catch (PatternException $patternException) { + $this->assertStringContainsString("doesn't match pattern", $patternException->getMessage()); + } + + // Valid string input: pattern passes, filter transforms to 42, minimum passes. + $object = new $className(['value' => '42']); + $this->assertSame(42, $object->getValue()); + + // Already-transformed int that satisfies minimum: skips pre-transform pipeline. + $object = new $className(['value' => 42]); + $this->assertSame(42, $object->getValue()); + + // Already-transformed int that fails minimum: minimum runs post-transform. + try { + new $className(['value' => -5]); + $this->fail('Expected MinimumException for input -5'); + } catch (MinimumException $minimumException) { + $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); + } + } + + /** + * A format validator (registered under the string type, hence input-space) that is moved + * pre-filter must not run when the property value is already in the filter's output type-space. + * FormatValidatorFromRegEx::validate() declares a string parameter under strict types, so + * calling it with an already-transformed int throws TypeError rather than a validation + * exception. The skip guard prepended to moved input-space validators prevents this. + */ + public function testFormatValidatorOnMultiTypePropertyDoesNotFireForAlreadyTransformedValue(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFormat('onlyNumbers', new FormatValidatorFromRegEx('/^\d+$/')) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )); + + $className = $this->generateClassFromFile( + 'MultiTypeFormatWithTransformingFilter.json', + $configuration, + ); + + // String "42": format passes pre-transform, filter converts to int 42, minimum passes. + $object = new $className(['value' => '42']); + $this->assertSame(42, $object->getValue()); + + // String "hello": format check fires against the raw string → FormatException. + try { + new $className(['value' => 'hello']); + $this->fail('Expected FormatException for string input "hello"'); + } catch (FormatException $formatException) { + $this->assertStringContainsString('onlyNumbers', $formatException->getMessage()); + } + + // Already-transformed int -5: skip guard bypasses format check (no TypeError), + // execution reaches minimum and correctly throws MinimumException. + try { + new $className(['value' => -5]); + $this->fail('Expected MinimumException for int input -5'); + } catch (MinimumException $minimumException) { + $this->assertStringContainsString('must not be smaller than 0', $minimumException->getMessage()); + } + + // Already-transformed int 42: skip guard bypasses format check, minimum passes. + $object = new $className(['value' => 42]); + $this->assertSame(42, $object->getValue()); + } + + // ------------------------------------------------------------------------- + // allOf: output-only, input-only, mixed-space, non-transforming, empty branch + // ------------------------------------------------------------------------- + + /** + * Output-only allOf (all branches output-space) runs POST-transform. + * + * Observable proof: "200" → filter → 200 → maximum:100 fails → AllOfException. + * If the allOf ran PRE-transform, minimum and maximum would be no-ops on the string "200" + * and both branches would always pass vacuously — no exception would be thrown. + */ + public function testOutputOnlyAllOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfOutputOnly.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "50": filter → 50 → minimum:0 passes, maximum:100 passes → result: 50. + $object = new $className(['filteredProperty' => '50']); + $this->assertSame(50, $object->getFilteredProperty()); + + // "200": filter → 200 → maximum:100 fails → AllOfException. + // Branch 0 (minimum:0) passes, branch 1 (maximum:100) fails → succeeded=1. + try { + new $className(['filteredProperty' => '200']); + $this->fail('Expected AllOfException for "200"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // "-5": filter → -5 → minimum:0 fails → AllOfException. + // Branch 0 (minimum:0) fails, branch 1 (maximum:100) passes → succeeded=1. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected AllOfException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 50 → filter skipped → output-space allOf still runs and passes. + $object = new $className(['filteredProperty' => 50]); + $this->assertSame(50, $object->getFilteredProperty()); + } + + /** + * A transforming filter with a mixed accept type (empty accepted types) applied to a property + * that gets its type from an allOf sibling branch. The allOf branch {type:string} is + * classified as input-space, so it runs pre-transform. The filter then converts the string + * to DateTime. No post-transform composition exists. + */ + public function testAllOfPropertyWithMixedAcceptTransformingFilter(): void + { + $className = $this->generateClassFromFile( + 'AllOfPropertyWithMixedAcceptTransformingFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeMixedToDateTime'], + [self::class, 'filterMixedToDateTime'], + 'mixedAcceptDateTimeFilter', + ), + ), + ); + + // Valid string input: input-space allOf passes, filter transforms to DateTime. + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Non-string raw input via constructor: the constructor bypasses the setter type hint, + // so 42 reaches validateFilteredProperty directly. The input-space allOf fires pre-transform + // and rejects it with AllOfException (not a TypeError). + try { + new $className(['filteredProperty' => 42]); + $this->fail('Expected AllOfException for non-string raw input'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-constructed DateTime: pre-transform pipeline skipped, accepted as-is. + $existingDateTime = new DateTime('2024-06-01'); + $object->setFilteredProperty($existingDateTime); + $this->assertSame($existingDateTime, $object->getFilteredProperty()); + } + + /** + * Input-space allOf runs PRE-transform. + * + * Observable proof: "2024" (4 chars < 5) throws AllOfException. Post-transform that + * input would produce a DateTime and the minLength check would silently skip. + */ + public function testInputSpaceAllOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "20240101" (8 chars ≥ 5): allOf passes pre-transform → filter → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024" (4 chars < 5): allOf fails pre-transform → AllOfException. + // Single branch (minLength:5) fails → succeeded=0. + try { + new $className(['filteredProperty' => '2024']); + $this->fail('Expected AllOfException for "2024"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime skips the input-space allOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Mixed-space allOf is split around the transforming filter. + * + * - {type:string, minLength:1} is input-space (string constraint, runs PRE-transform). + * - {minimum:0} is output-space (numeric constraint, runs POST-transform). + * + * (a) "5" → pre-allOf passes, filter→5, post-allOf passes → 5. + * (b) "" → pre-allOf fails (minLength:1) → AllOfException before the filter. + * (c) "-5" → pre-allOf passes, filter→-5, post-allOf fails (minimum:0) → AllOfException. + * (d) 5 → already-int, skip pre-allOf, post-allOf passes → 5. + * (e) -5 → already-int, skip pre-allOf, post-allOf fails → AllOfException. + */ + public function testMixedSpaceAllOfSplitAroundTransformingFilter(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfMixedSpaces.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // (a) Valid string — both spaces satisfied. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // (b) Empty string — input-space minLength:1 fails before filter runs. + // Pre-subset has one branch (minLength:1); it fails → succeeded=0. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected AllOfException for ""'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // (c) "-5" passes input-space (len≥1) but transforms to -5 which fails minimum:0. + // Post-subset has one branch (minimum:0); it fails → succeeded=0. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected AllOfException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // (d) Already-int 5: skip input pipeline, post-allOf minimum:0 passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + + // (e) Already-int -5: skip input pipeline, post-allOf minimum:0 fails → succeeded=0. + try { + new $className(['filteredProperty' => -5]); + $this->fail('Expected AllOfException for -5 (already-int)'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * Mixed-space allOf split in collect-errors mode collects errors from both subsets + * independently. + */ + public function testMixedSpaceAllOfSplitWithCollectErrors(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfMixedSpaces.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": both spaces satisfied → no errors. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "": pre-allOf minLength:1 fails → ErrorRegistryException containing AllOfException. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected ErrorRegistryException for ""'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // "-5": pre-allOf passes (len≥1), filter→-5, post-allOf minimum:0 fails → + // ErrorRegistryException containing one AllOfException. + try { + new $className(['filteredProperty' => '-5']); + $this->fail('Expected ErrorRegistryException for "-5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * Non-transforming filter + allOf: the allOf validates the POST-filter (trimmed) value. + * + * Observable proof: " hi " trims to "hi" (2 chars) and fails minLength:5 → AllOfException. + * If the allOf ran pre-trim, " hi " has 8 chars and would pass minLength:5. + */ + public function testNonTransformingFilterWithAllOfValidatesAfterFilter(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfWithTrim.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // " hello " trims to "hello" (5 chars) → allOf minLength:5 passes. + $object = new $className(['filteredProperty' => ' hello ']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // " hi " trims to "hi" (2 chars) → allOf minLength:5 fails → AllOfException. + try { + new $className(['filteredProperty' => ' hi ']); + $this->fail('Expected AllOfException for " hi "'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AllOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + } + + /** + * An empty {} allOf branch under a transforming filter is a no-op: it imposes no constraints + * so the filter runs and the value is transformed. + */ + public function testEmptyAllOfBranchWithTransformingFilterIsNoOp(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAllOfEmptyBranch.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + // ------------------------------------------------------------------------- + // anyOf: input-space and output-space + // ------------------------------------------------------------------------- + + /** + * Input-space anyOf runs PRE-transform. + * + * Observable proof: "2024-01-01" succeeds (type:string branch passes pre-transform). + * If anyOf ran POST-transform on DateTime, neither branch would pass → AnyOfException. + */ + public function testInputSpaceAnyOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAnyOfInputOnly.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "2024-01-01" is a string → type:string branch passes → anyOf passes → DateTime. + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Already-transformed DateTime skips the input-space anyOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space anyOf runs POST-transform. + * + * Observable proof: "15" → filter → 15 → neither branch passes → AnyOfException. + * If anyOf ran PRE-transform, minimum/maximum would be no-ops on the string "15" + * and both branches would always pass vacuously — no exception would be thrown. + */ + public function testOutputSpaceAnyOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionAnyOfOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": filter → 5 → {min:0, max:10} passes → anyOf passes. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "15": filter → 15 → neither branch passes → AnyOfException; both fail → succeeded=0. + try { + new $className(['filteredProperty' => '15']); + $this->fail('Expected AnyOfException for "15"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(AnyOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 5 → filter skipped → output-space anyOf still runs and passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + } + + // ------------------------------------------------------------------------- + // oneOf: input-space and output-space + // ------------------------------------------------------------------------- + + /** + * Input-space oneOf runs PRE-transform. + * + * Observable proof: "20240101" (8 chars) passes only {minLength:5} → exactly one → + * oneOf passes → DateTime. Post-transform, DateTime is not a string so both minLength and + * maxLength skip, both branches "pass", two pass → OneOfException. + */ + public function testInputSpaceOneOfCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionOneOfInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "20240101" (8 chars): minLength:5 passes, maxLength:3 fails → 1 match → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024" (4 chars): minLength:5 fails, maxLength:3 fails → 0 matches → OneOfException. + try { + new $className(['filteredProperty' => '2024']); + $this->fail('Expected OneOfException for "2024"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime skips the input-space oneOf. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space oneOf runs POST-transform. + * + * Observable proof: "5" → 5 → exactly {min:0,max:10} passes → 1 match → oneOf passes. + * "15" → 15 → neither branch passes → OneOfException. + */ + public function testOutputSpaceOneOfCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionOneOfOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "5": filter → 5 → {min:0, max:10} passes, {min:20, max:30} fails → exactly 1 → passes. + $object = new $className(['filteredProperty' => '5']); + $this->assertSame(5, $object->getFilteredProperty()); + + // "15": filter → 15 → neither branch passes → OneOfException; both fail → succeeded=0. + try { + new $className(['filteredProperty' => '15']); + $this->fail('Expected OneOfException for "15"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(OneOfException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(0, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int 5 → filter skipped → output-space oneOf still runs and passes. + $object = new $className(['filteredProperty' => 5]); + $this->assertSame(5, $object->getFilteredProperty()); + } + + // ------------------------------------------------------------------------- + // not: input-space and output-space + // ------------------------------------------------------------------------- + + /** + * Input-space not runs PRE-transform. + * + * Observable proof: "2024-01-01" (10 chars, valid date) → inner minLength:5 passes → not + * violated → NotException. Post-transform, DateTime is not a string so minLength would skip, + * the branch would "fail", not would pass — no exception. + */ + public function testInputSpaceNotCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionNotInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(true)->setImmutable(false), + ); + + // "now" (3 chars): not { minLength: 5 } → inner fails (3 < 5) → not passes → DateTime. + $object = new $className(['filteredProperty' => 'now']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "2024-01-01" (10 chars): inner minLength:5 passes → not violated → NotException. + // Inner schema (minLength:5) passes → succeeded=1 → composition element is Valid. + try { + new $className(['filteredProperty' => '2024-01-01']); + $this->fail('Expected NotException for "2024-01-01"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed DateTime → input-space not skipped → passes. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space not runs POST-transform. + * + * Observable proof: "5" → filter → 5 → not { minimum: 0 }: 5 ≥ 0 → inner passes → not + * violated → NotException. If not ran pre-transform, minimum would not apply to the string + * "5" (is_int check fails), inner would fail, not would pass — no exception. + */ + public function testOutputSpaceNotCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionNotOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(true) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "-5": filter → -5 → not { minimum: 0 }: -5 < 0 → inner fails → not passes → -5. + $object = new $className(['filteredProperty' => '-5']); + $this->assertSame(-5, $object->getFilteredProperty()); + + // "5": filter → 5 → inner passes → not violated → NotException; inner succeeds → succeeded=1. + try { + new $className(['filteredProperty' => '5']); + $this->fail('Expected NotException for "5"'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + + // Already-transformed int -5 → filter skipped → not { minimum: 0 }: -5 < 0 → passes. + $object = new $className(['filteredProperty' => -5]); + $this->assertSame(-5, $object->getFilteredProperty()); + + // Already-transformed int 5 → filter skipped → not { minimum: 0 }: 5 ≥ 0 → not violated. + try { + new $className(['filteredProperty' => 5]); + $this->fail('Expected NotException for already-transformed int 5'); + } catch (ErrorRegistryException $exception) { + $errors = $exception->getErrors(); + $this->assertCount(1, $errors); + $this->assertContainsOnlyInstancesOf(NotException::class, $errors); + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertSame(1, $errors[0]->getSucceededCompositionElements()); + } + } + + // ------------------------------------------------------------------------- + // if/then/else: input-space, output-space, if/then only + // ------------------------------------------------------------------------- + + /** + * Input-space if/then/else runs PRE-transform. + * + * Observable proof: "" (0 chars) triggers ConditionalException pre-transform. + * Post-transform, DateTime is not a string so both minLength checks would skip, the + * if-branch would "pass", the then-branch would "pass", and no exception would be thrown. + */ + public function testInputSpaceIfThenElseCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenElseInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "20240101" (8 chars): if minLength:8 passes → then maxLength:20 passes → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "" (0 chars): if minLength:8 fails, else minLength:1 fails → ConditionalException. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected ConditionalException for ""'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNotNull($exception->getIfException()); + $this->assertNull($exception->getThenException()); + $this->assertNotNull($exception->getElseException()); + } + + // Already-transformed DateTime skips the input-space conditional. + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + /** + * Output-space if/then/else runs POST-transform. + * + * Observable proof: "200" → filter → 200 → if:{min:0} passes → then:{max:100} fails + * → ConditionalException. If the conditional ran PRE-transform, minimum/maximum would be + * no-ops on the string "200" and all branches would always pass vacuously — no exception. + */ + public function testOutputSpaceIfThenElseCompositionRunsPostTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenElseOutputSpace.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + + // "50": filter → 50 → if:{min:0} passes → then:{max:100} passes → success. + $object = new $className(['filteredProperty' => '50']); + $this->assertSame(50, $object->getFilteredProperty()); + + // "-5": filter → -5 → if:{min:0} fails → else:{min:-100} passes (-5 ≥ -100) → success. + $object = new $className(['filteredProperty' => '-5']); + $this->assertSame(-5, $object->getFilteredProperty()); + + // "200": filter → 200 → if passes → then:{max:100} fails → ConditionalException. + try { + new $className(['filteredProperty' => '200']); + $this->fail('Expected ConditionalException for "200"'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNull($exception->getIfException()); + $this->assertNotNull($exception->getThenException()); + $this->assertNull($exception->getElseException()); + } + + // "-200": filter → -200 → if fails → else:{min:-100} also fails → ConditionalException. + try { + new $className(['filteredProperty' => '-200']); + $this->fail('Expected ConditionalException for "-200"'); + } catch (ConditionalException $exception) { + $this->assertStringContainsString( + <<getMessage(), + ); + $this->assertNotNull($exception->getIfException()); + $this->assertNull($exception->getThenException()); + $this->assertNotNull($exception->getElseException()); + } + + // Already-transformed int 50 → filter skipped → output-space conditional still runs and passes. + $object = new $className(['filteredProperty' => 50]); + $this->assertSame(50, $object->getFilteredProperty()); + } + + /** + * if/then (no else) under a transforming filter: the conditional runs pre-transform. + * When the if-condition fails (short string), no else means the value passes through. + * When the if-condition passes but then fails, ConditionalException is thrown pre-transform. + */ + public function testInputSpaceIfThenOnlyCompositionRunsPreTransform(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionIfThenOnlyInputSpace.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + // "20240101" satisfies if (minLength:8) and then (maxLength:20) → filter runs → DateTime. + $object = new $className(['filteredProperty' => '20240101']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // "now" (3 chars) fails if (minLength:8) → no else → conditional passes → filter → DateTime. + $object = new $className(['filteredProperty' => 'now']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Overlong string: if passes, then fails → ConditionalException thrown pre-transform. + try { + new $className(['filteredProperty' => 'abcdefghijklmnopqrstuvwxyz']); + $this->fail('Expected ConditionalException for overlong string'); + } catch (ConditionalException $exception) { + $this->assertNull($exception->getIfException()); + $this->assertNotNull($exception->getThenException()); + $this->assertNull($exception->getElseException()); + $this->assertStringContainsString( + 'Value for filteredProperty must not be longer than 20', + $exception->getMessage(), + ); + } + + // Already-transformed DateTime skips the pre-transform conditional (pass-through). + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } + + // ------------------------------------------------------------------------- + // Root-level input-space constraint on filtered sub-property + // ------------------------------------------------------------------------- + + /** + * A root-level allOf with an input-space constraint targeting a filtered sub-property is + * allowed at generation time. At runtime the property validator runs first (including the + * filter), so the root-level constraint sees the already-transformed output value and is + * effectively inert against it. + */ + public function testRootLevelInputSpaceConstraintOnFilteredSubpropertyRunsSuccessfully(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json', + (new GeneratorConfiguration())->setCollectErrors(false)->setImmutable(false), + ); + + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + $dateTime = new DateTime('2024-06-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + } +} diff --git a/tests/Filter/FilterCompositionStaticTest.php b/tests/Filter/FilterCompositionStaticTest.php new file mode 100644 index 00000000..1b3877bd --- /dev/null +++ b/tests/Filter/FilterCompositionStaticTest.php @@ -0,0 +1,241 @@ + */ + public static function rejectedCompositionProvider(): array + { + return [ + // A single allOf branch spans both input-space and output-space keywords; it cannot + // be placed on either side of the filter boundary without losing one of the constraints. + 'allOf with Mixed branch' => [ + 'FilterCompositionAllOfMixedBranch.json', + '/Composition allOf under property filteredProperty' + . '.*branch #0 spans both input and output type-spaces/', + ], + // anyOf branches disagree on type-space (one input-space, one output-space); all + // branches of a non-allOf composition must be uniformly pre- or post-transform. + 'anyOf with cross-space branches' => [ + 'FilterCompositionAnyOfCrossSpace.json', + '/Composition anyOf under property filteredProperty' + . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + ], + // Same as anyOf: oneOf branches cannot span different type-spaces. + 'oneOf with cross-space branches' => [ + 'FilterCompositionOneOfCrossSpace.json', + '/Composition oneOf under property filteredProperty' + . '.*branch #0 constrains input type-space but branch #1 constrains output type-space/', + ], + // The not inner schema spans both spaces; the type-space classification is ambiguous. + 'not with Mixed inner schema' => [ + 'FilterCompositionNotMixed.json', + '/Composition not under property filteredProperty' + . '.*inner schema spans both input and output type-spaces/', + ], + // if/then/else sub-schemas span different type-spaces; all three sub-schemas must be + // uniformly classified so the whole conditional can be placed on one side of the filter. + 'if\/then with cross-space sub-schemas' => [ + 'FilterCompositionIfThenElseCrossSpace.json', + '/Composition if\/then\/else under property filteredProperty.*sub-schemas span different type-spaces/', + ], + // A filter keyword inside a composition branch cannot be correctly applied because the + // ComposedItem template resets $value to the original input after each branch evaluation. + 'filter inside allOf branch (with outer filter)' => [ + 'FilterCompositionFilterInBranch.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Same as above; the rejection applies regardless of whether the property itself also + // declares an outer filter. + 'filter inside allOf branch (no outer filter)' => [ + 'FilterCompositionFilterInBranchNoOuterFilter.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Root-level allOf constrains the filtered subproperty with output-type-space keywords. + 'root-level allOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootConstrainsFilteredSubproperty.json', + '/Composition allOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + // Same constraint applies to root-level anyOf. + 'root-level anyOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json', + '/Composition anyOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + // Same constraint applies to root-level oneOf. + 'root-level oneOf constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootOneOfConstrainsFilteredSubproperty.json', + '/Composition oneOf.*constrains filtered subproperty filteredProperty.*branch #0.*output-type-space/', + ], + // Same constraint applies to root-level not. + 'root-level not constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootNotConstrainsFilteredSubproperty.json', + '/Composition not.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + // Same constraint applies to root-level if/then/else. + 'root-level if constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootIfConstrainsFilteredSubproperty.json', + '/Composition if.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + // Filter inside a not branch: same $value-reset issue as for array composition keywords. + 'filter inside not branch' => [ + 'FilterCompositionFilterInNotBranch.json', + '/A filter keyword inside a not composition branch is not supported' + . ' for property filteredProperty/', + ], + // Filter inside an anyOf branch: same $value-reset issue. + 'filter inside anyOf branch' => [ + 'FilterCompositionFilterInAnyOfBranch.json', + '/A filter keyword inside a anyOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Filter inside a oneOf branch: same $value-reset issue. + 'filter inside oneOf branch' => [ + 'FilterCompositionFilterInOneOfBranch.json', + '/A filter keyword inside a oneOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // Filter inside an if/then/else sub-schema: same $value-reset issue. + 'filter inside if\/then\/else branch' => [ + 'FilterCompositionFilterInIfThenElseIfThenElseBranch.json', + '/A filter keyword inside an if\/then\/else composition branch is not supported' + . ' for property filteredProperty.*if sub-schema/', + ], + // Filter inside a deeply-nested allOf/anyOf branch: recursive scan must descend. + 'filter inside nested allOf\/anyOf branch' => [ + 'FilterCompositionFilterInNestedBranch.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], + // anyOf branch spanning both input and output type-spaces is ambiguous. + 'anyOf with single Mixed branch' => [ + 'FilterCompositionAnyOfMixedBranch.json', + '/Composition anyOf under property filteredProperty' + . '.*branch #0 spans both input and output type-spaces/', + ], + ]; + } + + #[DataProvider('rejectedCompositionProvider')] + public function testUnresolvableCompositionOnTransformingFilterPropertyThrowsSchemaException( + string $schemaFile, + string $expectedMessagePattern, + ): void { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches($expectedMessagePattern); + + $this->generateClassFromFile($schemaFile); + } + + /** @return array */ + public static function acceptedCompositionProvider(): array + { + return [ + 'allOf with input-only branches' => ['FilterCompositionAllOfInputOnly.json'], + 'anyOf with input-only branches' => ['FilterCompositionAnyOfInputOnly.json'], + 'oneOf with input-only branches' => ['FilterCompositionOneOfInputOnly.json'], + 'if/then/else input-only branches' => ['FilterCompositionIfThenElseInputOnly.json'], + 'if/then only (no else) input-only branches' => ['FilterCompositionIfThenOnlyInputSpace.json'], + 'if/else only (no then) input-only branches' => ['FilterCompositionIfElseOnlyInputSpace.json'], + 'allOf with empty {} branch' => ['FilterCompositionAllOfEmptyBranch.json'], + 'root-level allOf: input-space constraint on filtered subproperty' => + ['FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json'], + 'root-level allOf branch: filter in inherited-object branch property' => + ['FilterCompositionRootBranchWithFilterInProperty.json'], + ]; + } + + #[DataProvider('acceptedCompositionProvider')] + public function testCompatibleCompositionOnTransformingFilterPropertyGeneratesSuccessfully( + string $schemaFile, + ): void { + // Should not throw — generation must succeed for compatible compositions. + $this->generateClassFromFile($schemaFile); + $this->addToAssertionCount(1); + } + + /** + * An allOf branch whose 'type' constraint excludes the filter's accepted input types means + * the filter can never receive any value that passes validation — it is a dead filter. + */ + public function testDeadFilterViaAllOfTypeConstraintThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter stringToInt on property filteredProperty.*can never be executed' + . '.*allOf type constraints \(int\) exclude all input types accepted by the filter \(string\)/', + ); + + // allOf requires integer values but stringToInt only accepts strings; no value + // can pass both the allOf validation and reach the filter. + $this->generateClassFromFile( + 'FilterCompositionAllOfDeadFilter.json', + (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + } + + /** + * Contradictory allOf type constraints (no value can satisfy all branches simultaneously) + * produce an empty intersection. The property-level allOf intersection check fires and + * throws SchemaException for the type contradiction. The dead-filter check skips on empty + * intersection and is NOT the source of this exception. + */ + public function testContradictoryAllOfTypeConstraintsThrowSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'filteredProperty' is defined with conflicting types in allOf composition branches/", + ); + + // Contradictory branches: integer AND string simultaneously — impossible. The + // property-level allOf intersection detects the empty intersection and rejects the schema. + // The dead-filter check skips (empty intersection is handled by type-contradiction logic). + $this->generateClassFromFile( + 'FilterCompositionAllOfContradictoryTypes.json', + (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + } + + /** + * An allOf branch with {type: object} makes the dateTime filter unreachable: the filter only + * accepts strings, but the allOf requires an object — no value can satisfy both constraints. + */ + public function testObjectOutputTypeConstraintMakingFilterDeadThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter dateTime on property filteredProperty.*can never be executed' + . '.*allOf type constraints \(object\) exclude/', + ); + + $this->generateClassFromFile('FilterCompositionAllOfObjectBranchOutput.json'); + } +} diff --git a/tests/Filter/FilterConfigurationTest.php b/tests/Filter/FilterConfigurationTest.php new file mode 100644 index 00000000..32622c23 --- /dev/null +++ b/tests/Filter/FilterConfigurationTest.php @@ -0,0 +1,61 @@ +assertSame('trim', (new GeneratorConfiguration())->getFilter('trim')->getToken()); + } + + public function testGetFilterReturnsNullForNonExistingFilter(): void + { + $this->assertNull((new GeneratorConfiguration())->getFilter('somethingElse')); + } + + #[DataProvider('invalidCustomFilterDataProvider')] + public function testAddInvalidFilterThrowsAnException(array $customInvalidFilter): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessage('Invalid filter callback for filter customFilter'); + + (new GeneratorConfiguration())->addFilter($this->getCustomFilter($customInvalidFilter)); + } + + public static function invalidCustomFilterDataProvider(): array + { + return [ + 'empty array' => [[]], + 'one element array' => [[Trim::class]], + 'Invalid class' => [[123, 'filter']], + 'Invalid function' => [[Trim::class, 123]], + 'Non existing class' => [['NonExistingClass', 'filter']], + 'Non existing function' => [[Trim::class, 'nonExistingMethod']], + 'three array' => [[Trim::class, 'filter', 'abc']], + ]; + } + + public function testNonExistingFilterThrowsAnException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('Unsupported filter nonExistingFilter'); + + $this->generateClassFromFile('NonExistingFilter.json'); + } +} diff --git a/tests/Filter/FilterTypeCompatibilityTest.php b/tests/Filter/FilterTypeCompatibilityTest.php new file mode 100644 index 00000000..8370501c --- /dev/null +++ b/tests/Filter/FilterTypeCompatibilityTest.php @@ -0,0 +1,532 @@ +generateClassFromFile( + 'StringPropertyAcceptAllFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterAllTypes'], 'acceptAll'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + } + + public function testAddFilterWithMixedTypedCallableIsAllowed(): void + { + // A callable with (mixed $value) derives empty acceptedTypes — always compatible. + $config = (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ); + + $this->assertNotNull($config->getFilter('mixedFilter')); + } + + public function testMixedTypedCallableGeneratesNoRuntimeTypeCheck(): void + { + // A callable with (mixed $value) means "accept all types" — generation succeeds for both + // typed and untyped properties, and no runtime typeCheck guard is emitted. + + // typed string property — filter runs + $className = $this->generateClassFromFile( + 'StringPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + + // untyped property — filter runs + $className = $this->generateClassFromFile( + 'UntypedPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); + + // typed integer property — generation succeeds, filter runs (no typeCheck guard) + $className = $this->generateClassFromFile( + 'IntegerPropertyMixedFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'negateFilterMixed'], 'mixedFilter'), + ), + ); + $object = new $className(['property' => 5]); + $this->assertSame(-5, $object->getProperty()); + } + + // ------------------------------------------------------------------------- + // Zero-overlap rejection + // ------------------------------------------------------------------------- + + public function testZeroOverlapThrowsSchemaException(): void + { + // float has zero overlap with int — SchemaException at generation time. + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter numberFilter is not compatible with property type int for property property/', + ); + + $this->generateClassFromFile( + 'IntegerPropertyZeroOverlapFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterFloat'], 'numberFilter'), + ), + ); + } + + // ------------------------------------------------------------------------- + // Partial-overlap: filter applied only when runtime type matches + // ------------------------------------------------------------------------- + + public function testPartialOverlapStringFilterOnMultiTypeProperty(): void + { + // Filter callable has (string $value) — accepted type is string only. + // Filter applies for string values; integer is not accepted so the filter + // is skipped and the integer value passes through unchanged. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied + + $object = new $className(['property' => 5]); + $this->assertSame(5, $object->getProperty()); // filter skipped, value unchanged + } + + public function testPartialOverlapStringFilterSkipsNullOnNullableProperty(): void + { + // Filter callable has (string $value) — null is not accepted. + // Filter applies for string values; null passes through unchanged. + $className = $this->generateClassFromFile( + 'StringNullPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); // filter skipped, null unchanged + } + + public function testPartialOverlapNullFilterSkipsStringOnNullableProperty(): void + { + // Filter callable has (null $value) — only null is accepted. + // Filter runs for null (passes through); string is not accepted so skipped. + $className = $this->generateClassFromFile( + 'StringNullPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'nullPassthrough'], 'customFilter'), + ), + ); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); // filter ran, returned null + + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); // filter skipped, string unchanged + } + + public function testPartialOverlapFilterRunsWhenPropertyTypeIsInAcceptedTypes(): void + { + // Filter callable has (int $value) — overlap with integer property type, filter runs. + $className = $this->generateClassFromFile( + 'IntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'negateFilter'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 5]); + $this->assertSame(-5, $object->getProperty()); + } + + // ------------------------------------------------------------------------- + // Untyped properties + // ------------------------------------------------------------------------- + + public function testRestrictedFilterOnUntypedPropertyIsAllowed(): void + { + // 'trim' accepts string|null (from ?string type hint). An untyped property can hold any + // value, so the filter is applied only when the runtime type matches — generation must + // succeed without throwing a SchemaException. + $className = $this->generateClassFromFile('UntypedPropertyFilter.json'); + + $object = new $className(['property' => ' hello ']); + $this->assertSame('hello', $object->getProperty()); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); + } + + public function testMixedTypedCallableFilterOnUntypedProperty(): void + { + // callable with (mixed $value) derives empty acceptedTypes — no runtime typeCheck. + $className = $this->generateClassFromFile( + 'UntypedPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterMixed'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied for string + } + + public function testNarrowFilterOnUntypedPropertySkipsNonMatchingType(): void + { + // Callable with (string $value) on an untyped property — filter applies for string, + // integer is not accepted so the filter is skipped and the value passes through. + $className = $this->generateClassFromFile( + 'UntypedPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'uppercaseFilterStringOnly'], 'customFilter'), + ), + ); + + $object = new $className(['property' => 'hello']); + $this->assertSame('HELLO', $object->getProperty()); // filter applied for string + + $object = new $className(['property' => 5]); + $this->assertSame(5, $object->getProperty()); // filter skipped, integer unchanged + } + + // ------------------------------------------------------------------------- + // Union type hints on callables + // ------------------------------------------------------------------------- + + public function testFilterCallableWithUnionTypeHintAppliesFilterForBothAcceptedTypes(): void + { + // Both string and int are in the callable's union type hint — both pass the runtime guard. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyCustomFilter.json', + (new GeneratorConfiguration())->setCollectErrors(false)->addFilter( + $this->getCustomFilter([self::class, 'intOrStringFilter'], 'customFilter'), + ), + ); + + // string input: is_string passes → filter runs → result is string (unchanged) + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // int input: is_int passes → filter runs → result is string '42' + $object = new $className(['property' => 42]); + $this->assertSame('42', $object->getProperty()); + } + + // ------------------------------------------------------------------------- + // Transforming filter: bypass formula and union return types + // ------------------------------------------------------------------------- + + /** + * TransformingFilter (int→string via binary) on a string|integer property. + * The filter callable accepts only int, so string values bypass the filter unchanged. + * Verifies the bypass formula: bypass_names = base_names − accepted_non_null. + */ + public function testTransformingFilterWithBypassOnMultiTypeProperty(): void + { + // base type = string|int; filter accepts int only → string bypasses, int is transformed. + $className = $this->generateClassFromFile( + 'StringIntegerPropertyBinaryFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeBinaryToInt'], + [self::class, 'filterIntToBinary'], + 'binary', + ), + ), + ); + + // int input: filter applies (decbin), returns binary string + $object = new $className(['property' => 9]); + $this->assertSame('1001', $object->getProperty()); + + // string input: filter is skipped (string bypasses), value passes through unchanged + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // setter: int is re-transformed + $object->setProperty(5); + $this->assertSame('101', $object->getProperty()); + + // setter: string is preserved (bypass) + $object->setProperty('world'); + $this->assertSame('world', $object->getProperty()); + } + + /** + * TransformingFilter with a union return type (int|string) on a string property. + * The output type is widened to int|string; the setter must accept both int and string. + */ + public function testTransformingFilterWithUnionReturnType(): void + { + $className = $this->generateClassFromFile( + 'StringPropertyIntOrStringFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'stringToIntOrStringFilter'], + 'intOrString', + ), + ), + ); + + // numeric string → filter converts to int + $object = new $className(['property' => '42']); + $this->assertSame(42, $object->getProperty()); + + // non-numeric string → filter returns as-is (string) + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // setter accepts int (pass-through: already a transformed output type) + $object->setProperty(7); + $this->assertSame(7, $object->getProperty()); + + // setter accepts string (base type or output type string) + $object->setProperty('abc'); + $this->assertSame('abc', $object->getProperty()); + } + + /** + * TransformingFilter where both string and null are in its accepted types. + * Null is NOT a bypass type — the filter runs for null and converts it to string. + */ + public function testTransformingFilterNullConsumedByFilter(): void + { + $className = $this->generateClassFromFile( + 'StringNullPropertyStrOrNullFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'stringOrNullToStringFilter'], + 'strOrNull', + ), + ), + ); + + // string input: filter runs and returns string + $object = new $className(['property' => 'hello']); + $this->assertSame('hello', $object->getProperty()); + + // null input: filter runs (null IS accepted) and converts null → '' + $object = new $className(['property' => null]); + $this->assertSame('', $object->getProperty()); + } + + // ------------------------------------------------------------------------- + // Callable reflection rejections + // ------------------------------------------------------------------------- + + /** + * Filter callable whose first parameter has no type hint throws an InvalidFilterException + * at class-generation time (reflection cannot derive the accepted types). + */ + public function testFilterCallableWithNoTypeHintThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Filter noTypeHint must declare a type hint/'); + + $this->generateClassFromFile( + 'StringPropertyNoTypeHintFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomFilter([self::class, 'untypedFilter'], 'noTypeHint'), + ), + ); + } + + /** + * Transforming filter callable with no return type hint throws an InvalidFilterException + * at class-generation time (reflection cannot derive the output type). + */ + public function testTransformingFilterWithMissingReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter noReturnType must declare a return type/'); + + $this->generateClassFromFile( + 'StringPropertyNoReturnTypeFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithNoReturnType'], + 'noReturnType', + ), + ), + ); + } + + /** + * Transforming filter callable with a void return type throws an InvalidFilterException + * at class-generation time (void is not a valid output type for a transforming filter). + */ + public function testTransformingFilterWithVoidReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter voidReturn must not declare a void return type/'); + + $this->generateClassFromFile( + 'StringPropertyVoidReturnFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithVoidReturnType'], + 'voidReturn', + ), + ), + ); + } + + /** + * Transforming filter callable with a never return type throws an InvalidFilterException + * at class-generation time (never, like void, cannot produce a usable return value). + */ + public function testTransformingFilterWithNeverReturnTypeThrowsInvalidFilterException(): void + { + $this->expectException(InvalidFilterException::class); + $this->expectExceptionMessageMatches('/Transforming filter neverReturn must not declare a never return type/'); + + $this->generateClassFromFile( + 'StringPropertyNeverReturnFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'intOrStringSerializer'], + [self::class, 'filterWithNeverReturnType'], + 'neverReturn', + ), + ), + ); + } +} diff --git a/tests/Basic/SelfReturningFilterCallable.php b/tests/Filter/SelfReturningFilterCallable.php similarity index 72% rename from tests/Basic/SelfReturningFilterCallable.php rename to tests/Filter/SelfReturningFilterCallable.php index 9570e12d..96ed6d97 100644 --- a/tests/Basic/SelfReturningFilterCallable.php +++ b/tests/Filter/SelfReturningFilterCallable.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PHPModelGenerator\Tests\Basic; +namespace PHPModelGenerator\Tests\Filter; /** * Filter callable whose filter() declares a '?self' return type. * - * Used by FilterTest::testTransformingFilterWithSelfReturnType to verify that - * FilterReflection resolves 'self' to the declaring class FQCN before embedding - * it in generated type hints and pass-through guards. + * Used by TransformingFilterTest to verify that FilterReflection resolves 'self' + * to the declaring class FQCN before embedding it in generated type hints and + * pass-through guards. */ class SelfReturningFilterCallable { diff --git a/tests/Basic/StaticReturningFilterCallable.php b/tests/Filter/StaticReturningFilterCallable.php similarity index 71% rename from tests/Basic/StaticReturningFilterCallable.php rename to tests/Filter/StaticReturningFilterCallable.php index 23e61fa7..4795b02d 100644 --- a/tests/Basic/StaticReturningFilterCallable.php +++ b/tests/Filter/StaticReturningFilterCallable.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace PHPModelGenerator\Tests\Basic; +namespace PHPModelGenerator\Tests\Filter; /** * Filter callable whose filter() declares a '?static' return type. * - * Used by FilterTest::testTransformingFilterWithStaticReturnType to verify that - * FilterReflection resolves 'static' to the declaring class FQCN, the same as - * 'self' for a concrete callable that is not overridden in a subclass. + * Used by TransformingFilterTest to verify that FilterReflection resolves 'static' + * to the declaring class FQCN, the same as 'self' for a concrete callable that is + * not overridden in a subclass. */ class StaticReturningFilterCallable { diff --git a/tests/Filter/TransformingFilterTest.php b/tests/Filter/TransformingFilterTest.php new file mode 100644 index 00000000..ab24a991 --- /dev/null +++ b/tests/Filter/TransformingFilterTest.php @@ -0,0 +1,351 @@ +expectException(InvalidFilterException::class); + $this->expectExceptionMessage('Invalid serializer callback for filter customTransformingFilter'); + + (new GeneratorConfiguration())->addFilter($this->getCustomTransformingFilter($customInvalidFilter)); + } + + public static function invalidCustomFilterDataProvider(): array + { + return [ + 'empty array' => [[]], + 'one element array' => [[\PHPModelGenerator\Filter\Trim::class]], + 'Invalid class' => [[123, 'filter']], + 'Invalid function' => [[\PHPModelGenerator\Filter\Trim::class, 123]], + 'Non existing class' => [['NonExistingClass', 'filter']], + 'Non existing function' => [[\PHPModelGenerator\Filter\Trim::class, 'nonExistingMethod']], + 'three array' => [[\PHPModelGenerator\Filter\Trim::class, 'filter', 'abc']], + ]; + } + + // ------------------------------------------------------------------------- + // Built-in dateTime filter + // ------------------------------------------------------------------------- + + #[DataProvider('validDateTimeFilterDataProvider')] + public function testTransformingFilter(array $input, ?string $expected): void + { + $className = $this->generateClassFromFile( + 'TransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className($input); + + if ($expected === null) { + $this->assertNull($object->getCreated()); + } else { + $expectedDateTime = new DateTime($expected); + + $this->assertInstanceOf(DateTime::class, $object->getCreated()); + $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); + } + + // test if the setter accepts the raw model data + if (isset($input['created'])) { + $object->setCreated($input['created']); + + if ($expected === null) { + $this->assertNull($object->getCreated()); + } else { + $expectedDateTime = new DateTime($expected); + + $this->assertInstanceOf(DateTime::class, $object->getCreated()); + $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); + + // test if the setter accepts a DateTime object + $object->setCreated($expectedDateTime); + + $this->assertInstanceOf(DateTime::class, $object->getCreated()); + $this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); + } + } + + // test if the model can be serialized + $expectedSerialization = [ + 'created' => $expected !== null ? (new DateTime($expected))->format(DATE_ISO8601) : null, + 'name' => null, + ]; + + $this->assertSame($expectedSerialization, $object->toArray()); + $this->assertSame(json_encode($expectedSerialization), $object->toJSON()); + } + + public static function validDateTimeFilterDataProvider(): array + { + return [ + 'Optional Value not provided' => [[], null], + 'Null' => [['created' => null], null], + 'Empty string' => [['created' => ''], 'now'], + 'valid date' => [['created' => "12.12.2020 12:00"], '12.12.2020 12:00'], + 'valid DateTime constructor string' => [['created' => '+1 day'], '+1 day'], + ]; + } + + public function testFilterExceptionsAreCaught(): void + { + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage( + <<generateClassFromFile( + 'TransformingFilter.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + new $className(['created' => 'Hello', 'name' => 12]); + } + + #[DataProvider('additionalFilterOptionsDataProvider')] + public function testAdditionalFilterOptions(string $namespace, string $schemaFile): void + { + $className = $this->generateClassFromFile( + $schemaFile, + (new GeneratorConfiguration())->setSerialization(true)->setNamespacePrefix($namespace), + ); + + $fqcn = $namespace . $className; + $object = new $fqcn(['created' => '10122020']); + + $this->assertSame((new DateTime('2020-12-10'))->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM)); + + $expectedSerialization = ['created' => '20201210']; + $this->assertSame($expectedSerialization, $object->toArray()); + $this->assertSame(json_encode($expectedSerialization), $object->toJSON()); + } + + public static function additionalFilterOptionsDataProvider(): array + { + return self::combineDataProvider( + self::namespaceDataProvider(), + [ + 'Chain notation' => ['FilterOptionsChainNotation.json'], + 'Single filter notation' => ['FilterOptions.json'], + ], + ); + } + + // ------------------------------------------------------------------------- + // Unsupported scenarios + // ------------------------------------------------------------------------- + + public function testTransformingFilterAppliedToAnArrayPropertyThrowsAnException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Applying a transforming filter to the array property list is not supported', + ); + + $this->generateClassFromFile( + 'ArrayTransformingFilter.json', + (new GeneratorConfiguration())->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeBinaryToInt'], + [self::class, 'filterIntToBinary'], + 'customArrayTransformer', + ) + ), + ); + } + + public function testMultipleTransformingFiltersAppliedToOnePropertyThrowsAnException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessage( + 'Applying multiple transforming filters for property filteredProperty is not supported', + ); + + $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['["dateTime", "customTransformer"]'], + (new GeneratorConfiguration())->addFilter( + new class () extends \PHPModelGenerator\PropertyProcessor\Filter\DateTimeFilter { + public function getToken(): string + { + return 'customTransformer'; + } + }, + ), + false, + ); + } + + // ------------------------------------------------------------------------- + // Scalar output-type transform + // ------------------------------------------------------------------------- + + #[DataProvider('implicitNullNamespaceDataProvider')] + public function testTransformingToScalarType(bool $implicitNull, string $namespace): void + { + $className = $this->generateClassFromFile( + 'TransformingScalarFilter.json', + (new GeneratorConfiguration()) + ->setNamespacePrefix($namespace) + ->setSerialization(true) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [self::class, 'serializeBinaryToInt'], + [self::class, 'filterIntToBinary'], + 'binary', + ) + ), + false, + $implicitNull, + ); + + $fqcn = $namespace . $className; + $object = new $fqcn(['value' => 9]); + + $this->assertSame('1001', $object->getValue()); + $this->assertSame('1010', $object->setValue('1010')->getValue()); + $this->assertSame('1011', $object->setValue(11)->getValue()); + + $this->assertSame(['value' => 11], $object->toArray()); + $this->assertSame('{"value":11}', $object->toJSON()); + + if (!$implicitNull) { + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage('Invalid type for value. Requires [string, int], got NULL'); + new $fqcn(['value' => null]); + } + } + + // ------------------------------------------------------------------------- + // Enum interaction + // ------------------------------------------------------------------------- + + public function testEnumCheckWithTransformingFilterIsExecutedForNonTransformedValues(): void + { + $className = $this->generateClassFromFile('EnumBeforeFilter.json'); + + $object = new $className(['filteredProperty' => '2020-12-12']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame( + (new DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getFilteredProperty()->format(DATE_ATOM), + ); + + $this->expectException(\PHPModelGenerator\Exception\ValidationException::class); + $this->expectExceptionMessage('Invalid value for filteredProperty declined by enum constraint'); + + new $className(['filteredProperty' => '1999-12-12']); + } + + public function testEnumCheckWithTransformingFilterIsNotExecutedForTransformedValues(): void + { + $className = $this->generateClassFromFile('EnumBeforeFilter.json'); + $object = new $className(['filteredProperty' => new DateTime('1999-12-12')]); + + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + $this->assertSame( + (new DateTime('1999-12-12'))->format(DATE_ATOM), + $object->getFilteredProperty()->format(DATE_ATOM), + ); + } + + // ------------------------------------------------------------------------- + // Default value transformation + // ------------------------------------------------------------------------- + + #[DataProvider('implicitNullDataProvider')] + public function testDefaultValuesAreTransformed(bool $implicitNull): void + { + $className = $this->generateClassFromFile('DefaultValueFilter.json', null, false, $implicitNull); + $object = new $className(); + + $this->assertInstanceOf(DateTime::class, $object->getCreated()); + $this->assertSame( + (new DateTime('2020-12-12'))->format(DATE_ATOM), + $object->getCreated()->format(DATE_ATOM), + ); + } + + // ------------------------------------------------------------------------- + // self / static return types + // ------------------------------------------------------------------------- + + public static function selfStaticReturnTypeDataProvider(): array + { + return [ + 'self return type' => [SelfReturningFilterCallable::class, 'selfReturn', 'hello'], + 'static return type' => [StaticReturningFilterCallable::class, 'staticReturn', 'world'], + ]; + } + + /** + * A transforming filter callable that declares '?self' or '?static' as its return type. + * FilterReflection must resolve both to the declaring class FQCN so that the generated + * output type and pass-through type check use a valid class name. + */ + #[DataProvider('selfStaticReturnTypeDataProvider')] + public function testTransformingFilterWithSelfOrStaticReturnType( + string $callableClass, + string $token, + string $inputValue, + ): void { + $className = $this->generateClassFromFileTemplate( + 'FilterChain.json', + ['"' . $token . '"'], + (new GeneratorConfiguration()) + ->setImmutable(false) + ->addFilter( + $this->getCustomTransformingFilter( + [$callableClass, 'serialize'], + [$callableClass, 'filter'], + $token, + ), + ), + false, + ); + + // string input → filter wraps it in an instance of the declaring class + $object = new $className(['filteredProperty' => $inputValue]); + $this->assertInstanceOf($callableClass, $object->getFilteredProperty()); + $this->assertSame($inputValue, $object->getFilteredProperty()->getValue()); + + // null input → null stored + $object = new $className(['filteredProperty' => null]); + $this->assertNull($object->getFilteredProperty()); + + // pre-existing instance → passed through unchanged (setter accepts output type) + $existing = new $callableClass('existing'); + $object->setFilteredProperty($existing); + $this->assertSame($existing, $object->getFilteredProperty()); + } +} diff --git a/tests/Schema/FilterTest/TrimAsList.json b/tests/Schema/BuiltInFilterTest/TrimAsList.json similarity index 100% rename from tests/Schema/FilterTest/TrimAsList.json rename to tests/Schema/BuiltInFilterTest/TrimAsList.json diff --git a/tests/Schema/FilterTest/TrimAsString.json b/tests/Schema/BuiltInFilterTest/TrimAsString.json similarity index 100% rename from tests/Schema/FilterTest/TrimAsString.json rename to tests/Schema/BuiltInFilterTest/TrimAsString.json diff --git a/tests/Schema/FilterTest/TrimAsStringWithLengthValidation.json b/tests/Schema/BuiltInFilterTest/TrimAsStringWithLengthValidation.json similarity index 100% rename from tests/Schema/FilterTest/TrimAsStringWithLengthValidation.json rename to tests/Schema/BuiltInFilterTest/TrimAsStringWithLengthValidation.json diff --git a/tests/Schema/FilterTest/ArrayFilter.json b/tests/Schema/CustomFilterTest/ArrayFilter.json similarity index 100% rename from tests/Schema/FilterTest/ArrayFilter.json rename to tests/Schema/CustomFilterTest/ArrayFilter.json diff --git a/tests/Schema/FilterTest/Encode.json b/tests/Schema/CustomFilterTest/Encode.json similarity index 100% rename from tests/Schema/FilterTest/Encode.json rename to tests/Schema/CustomFilterTest/Encode.json diff --git a/tests/Schema/FilterTest/MultipleFilters.json b/tests/Schema/CustomFilterTest/MultipleFilters.json similarity index 100% rename from tests/Schema/FilterTest/MultipleFilters.json rename to tests/Schema/CustomFilterTest/MultipleFilters.json diff --git a/tests/Schema/FilterTest/Uppercase.json b/tests/Schema/CustomFilterTest/Uppercase.json similarity index 100% rename from tests/Schema/FilterTest/Uppercase.json rename to tests/Schema/CustomFilterTest/Uppercase.json diff --git a/tests/Schema/FilterTest/FilterChain.json b/tests/Schema/FilterChainTest/FilterChain.json similarity index 100% rename from tests/Schema/FilterTest/FilterChain.json rename to tests/Schema/FilterChainTest/FilterChain.json diff --git a/tests/Schema/FilterTest/FilterChainMultiType.json b/tests/Schema/FilterChainTest/FilterChainMultiType.json similarity index 100% rename from tests/Schema/FilterTest/FilterChainMultiType.json rename to tests/Schema/FilterChainTest/FilterChainMultiType.json diff --git a/tests/Schema/FilterTest/StringIntegerPropertyFilterChain.json b/tests/Schema/FilterChainTest/StringIntegerPropertyFilterChain.json similarity index 100% rename from tests/Schema/FilterTest/StringIntegerPropertyFilterChain.json rename to tests/Schema/FilterChainTest/StringIntegerPropertyFilterChain.json diff --git a/tests/Schema/FilterTest/UntypedPropertyFilterChain.json b/tests/Schema/FilterChainTest/UntypedPropertyFilterChain.json similarity index 100% rename from tests/Schema/FilterTest/UntypedPropertyFilterChain.json rename to tests/Schema/FilterChainTest/UntypedPropertyFilterChain.json diff --git a/tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/AllOfPropertyWithMixedAcceptTransformingFilter.json similarity index 100% rename from tests/Schema/FilterTest/AllOfPropertyWithMixedAcceptTransformingFilter.json rename to tests/Schema/FilterCompositionRuntimeTest/AllOfPropertyWithMixedAcceptTransformingFilter.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfEmptyBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfEmptyBranch.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfEmptyBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfInputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfMixedSpaces.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfMixedSpaces.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfMixedSpaces.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfOutputOnly.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfOutputOnly.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfOutputOnly.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfWithTrim.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfWithTrim.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfWithTrim.json diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfInputOnly.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAnyOfInputOnly.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfInputOnly.json diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfOutputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAnyOfOutputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfOutputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfThenElseInputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseOutputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfThenElseOutputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseOutputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenOnlyInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfThenOnlyInputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenOnlyInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionNotInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionNotInputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotOutputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionNotOutputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotOutputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionOneOfInputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfOutputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionOneOfOutputSpace.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfOutputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionRuntimeTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/MultiTypeFormatWithTransformingFilter.json similarity index 100% rename from tests/Schema/FilterTest/MultiTypeFormatWithTransformingFilter.json rename to tests/Schema/FilterCompositionRuntimeTest/MultiTypeFormatWithTransformingFilter.json diff --git a/tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/ValidatorPriorityWithTransformingFilter.json similarity index 100% rename from tests/Schema/FilterTest/ValidatorPriorityWithTransformingFilter.json rename to tests/Schema/FilterCompositionRuntimeTest/ValidatorPriorityWithTransformingFilter.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfContradictoryTypes.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfContradictoryTypes.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfContradictoryTypes.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfContradictoryTypes.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilter.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfDeadFilter.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilter.json diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfEmptyBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfEmptyBranch.json new file mode 100644 index 00000000..2a5bf70e --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfEmptyBranch.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + {} + ] + } + } +} diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfInputOnly.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfInputOnly.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfInputOnly.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfMixedBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfMixedBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfMixedBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfObjectBranchOutput.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAllOfObjectBranchOutput.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfObjectBranchOutput.json diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfCrossSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAnyOfCrossSpace.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfCrossSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfWithTrim.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json similarity index 59% rename from tests/Schema/FilterTest/FilterCompositionAnyOfWithTrim.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json index 76c6b693..869a15ab 100644 --- a/tests/Schema/FilterTest/FilterCompositionAnyOfWithTrim.json +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json @@ -2,14 +2,13 @@ "type": "object", "properties": { "filteredProperty": { - "type": "string", - "filter": "trim", + "filter": "dateTime", "anyOf": [ { - "minLength": 5 + "type": "string" }, { - "const": "hi" + "type": "integer" } ] } diff --git a/tests/Schema/FilterTest/FilterCompositionAnyOfMixedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfMixedBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionAnyOfMixedBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfMixedBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInAnyOfBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInAnyOfBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInAnyOfBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranchNoOuterFilter.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInBranchNoOuterFilter.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranchNoOuterFilter.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInNestedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInNestedBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNotBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInNotBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNotBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInOneOfBranch.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionFilterInOneOfBranch.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInOneOfBranch.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfElseOnlyInputSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfElseOnlyInputSpace.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionIfElseOnlyInputSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseCrossSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfThenElseCrossSpace.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseCrossSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseInputOnly.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionIfThenElseInputOnly.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseInputOnly.json diff --git a/tests/Schema/FilterTest/FilterCompositionIfThenWithTrim.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json similarity index 66% rename from tests/Schema/FilterTest/FilterCompositionIfThenWithTrim.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json index 61ab0723..4b4ac1c9 100644 --- a/tests/Schema/FilterTest/FilterCompositionIfThenWithTrim.json +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json @@ -3,12 +3,12 @@ "properties": { "filteredProperty": { "type": "string", - "filter": "trim", + "filter": "dateTime", "if": { - "minLength": 1 + "minLength": 8 }, "then": { - "pattern": "^A" + "maxLength": 20 } } } diff --git a/tests/Schema/FilterTest/FilterCompositionNotMixed.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionNotMixed.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionNotMixed.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionNotMixed.json diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfCrossSpace.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionOneOfCrossSpace.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfCrossSpace.json diff --git a/tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfInputOnly.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionOneOfInputOnly.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfInputOnly.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootBranchWithFilterInProperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootBranchWithFilterInProperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootBranchWithFilterInProperty.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..9c0488bf --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "minLength": 1 + } + } + } + ] +} diff --git a/tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json similarity index 100% rename from tests/Schema/FilterTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json rename to tests/Schema/FilterCompositionStaticTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json diff --git a/tests/Schema/FilterTest/NonExistingFilter.json b/tests/Schema/FilterConfigurationTest/NonExistingFilter.json similarity index 100% rename from tests/Schema/FilterTest/NonExistingFilter.json rename to tests/Schema/FilterConfigurationTest/NonExistingFilter.json diff --git a/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json b/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json deleted file mode 100644 index 948a3a15..00000000 --- a/tests/Schema/FilterTest/FilterCompositionFilterInIfThenElseBranch.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "type": "object", - "properties": { - "filteredProperty": { - "if": { - "filter": "trim" - }, - "then": { - "minLength": 5 - } - } - } -} diff --git a/tests/Schema/FilterTest/IntegerPropertyCustomFilter.json b/tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyCustomFilter.json similarity index 100% rename from tests/Schema/FilterTest/IntegerPropertyCustomFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyCustomFilter.json diff --git a/tests/Schema/FilterTest/IntegerPropertyMixedFilter.json b/tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyMixedFilter.json similarity index 100% rename from tests/Schema/FilterTest/IntegerPropertyMixedFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyMixedFilter.json diff --git a/tests/Schema/FilterTest/IntegerPropertyZeroOverlapFilter.json b/tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyZeroOverlapFilter.json similarity index 100% rename from tests/Schema/FilterTest/IntegerPropertyZeroOverlapFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/IntegerPropertyZeroOverlapFilter.json diff --git a/tests/Schema/FilterTest/StringIntegerPropertyBinaryFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringIntegerPropertyBinaryFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringIntegerPropertyBinaryFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringIntegerPropertyBinaryFilter.json diff --git a/tests/Schema/FilterTest/StringIntegerPropertyCustomFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringIntegerPropertyCustomFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringIntegerPropertyCustomFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringIntegerPropertyCustomFilter.json diff --git a/tests/Schema/FilterTest/StringNullPropertyCustomFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringNullPropertyCustomFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringNullPropertyCustomFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringNullPropertyCustomFilter.json diff --git a/tests/Schema/FilterTest/StringNullPropertyStrOrNullFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringNullPropertyStrOrNullFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringNullPropertyStrOrNullFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringNullPropertyStrOrNullFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyAcceptAllFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyAcceptAllFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyAcceptAllFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyAcceptAllFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyIntOrStringFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyIntOrStringFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyIntOrStringFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyIntOrStringFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyMixedFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyMixedFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyNeverReturnFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyNeverReturnFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyNeverReturnFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyNeverReturnFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyNoReturnTypeFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyNoReturnTypeFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyNoReturnTypeFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyNoReturnTypeFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyNoTypeHintFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyNoTypeHintFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyNoTypeHintFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyNoTypeHintFilter.json diff --git a/tests/Schema/FilterTest/StringPropertyVoidReturnFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyVoidReturnFilter.json similarity index 100% rename from tests/Schema/FilterTest/StringPropertyVoidReturnFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/StringPropertyVoidReturnFilter.json diff --git a/tests/Schema/FilterTest/UntypedPropertyCustomFilter.json b/tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyCustomFilter.json similarity index 100% rename from tests/Schema/FilterTest/UntypedPropertyCustomFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyCustomFilter.json diff --git a/tests/Schema/FilterTest/UntypedPropertyFilter.json b/tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyFilter.json similarity index 100% rename from tests/Schema/FilterTest/UntypedPropertyFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyFilter.json diff --git a/tests/Schema/FilterTest/UntypedPropertyMixedFilter.json b/tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyMixedFilter.json similarity index 100% rename from tests/Schema/FilterTest/UntypedPropertyMixedFilter.json rename to tests/Schema/FilterTypeCompatibilityTest/UntypedPropertyMixedFilter.json diff --git a/tests/Schema/FilterTest/ArrayTransformingFilter.json b/tests/Schema/TransformingFilterTest/ArrayTransformingFilter.json similarity index 100% rename from tests/Schema/FilterTest/ArrayTransformingFilter.json rename to tests/Schema/TransformingFilterTest/ArrayTransformingFilter.json diff --git a/tests/Schema/FilterTest/DefaultValueFilter.json b/tests/Schema/TransformingFilterTest/DefaultValueFilter.json similarity index 100% rename from tests/Schema/FilterTest/DefaultValueFilter.json rename to tests/Schema/TransformingFilterTest/DefaultValueFilter.json diff --git a/tests/Schema/FilterTest/EnumBeforeFilter.json b/tests/Schema/TransformingFilterTest/EnumBeforeFilter.json similarity index 100% rename from tests/Schema/FilterTest/EnumBeforeFilter.json rename to tests/Schema/TransformingFilterTest/EnumBeforeFilter.json diff --git a/tests/Schema/FilterTest/FilterCompositionNotWithTrim.json b/tests/Schema/TransformingFilterTest/FilterChain.json similarity index 59% rename from tests/Schema/FilterTest/FilterCompositionNotWithTrim.json rename to tests/Schema/TransformingFilterTest/FilterChain.json index cc3c72c8..e4e3b585 100644 --- a/tests/Schema/FilterTest/FilterCompositionNotWithTrim.json +++ b/tests/Schema/TransformingFilterTest/FilterChain.json @@ -3,10 +3,7 @@ "properties": { "filteredProperty": { "type": "string", - "filter": "trim", - "not": { - "const": "" - } + "filter": %s } } -} +} \ No newline at end of file diff --git a/tests/Schema/FilterTest/FilterOptions.json b/tests/Schema/TransformingFilterTest/FilterOptions.json similarity index 100% rename from tests/Schema/FilterTest/FilterOptions.json rename to tests/Schema/TransformingFilterTest/FilterOptions.json diff --git a/tests/Schema/FilterTest/FilterOptionsChainNotation.json b/tests/Schema/TransformingFilterTest/FilterOptionsChainNotation.json similarity index 100% rename from tests/Schema/FilterTest/FilterOptionsChainNotation.json rename to tests/Schema/TransformingFilterTest/FilterOptionsChainNotation.json diff --git a/tests/Schema/FilterTest/TransformingFilter.json b/tests/Schema/TransformingFilterTest/TransformingFilter.json similarity index 100% rename from tests/Schema/FilterTest/TransformingFilter.json rename to tests/Schema/TransformingFilterTest/TransformingFilter.json diff --git a/tests/Schema/FilterTest/TransformingScalarFilter.json b/tests/Schema/TransformingFilterTest/TransformingScalarFilter.json similarity index 100% rename from tests/Schema/FilterTest/TransformingScalarFilter.json rename to tests/Schema/TransformingFilterTest/TransformingScalarFilter.json From 1cea388cc81f192a99313b0fe0a1f4b94378da7c Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 20 May 2026 12:28:37 +0200 Subject: [PATCH 10/11] Fix if/then/else branch conflict detection for null-typed and per-branch checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IfValidatorFactory previously required both then and else branches to conflict with the parent before throwing, and stripped null from parent type names — so a null-typed branch against a non-null parent was silently accepted. Now each branch is checked independently and null from isNullable() is included in the parent set. getBranchTypeNames() distinguishes null-typed branches ({type: null}) from truly untyped branches via getTypeHint(), so null-typed branches correctly intersect (or conflict) with the parent type. CLAUDE.md extended with review-learning policy and line-number-in-docblock rule. New test coverage for all affected edge cases. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 27 ++++ .../Composition/IfValidatorFactory.php | 78 +++++++----- tests/ComposedValue/ComposedAllOfTest.php | 31 ++++- tests/ComposedValue/ComposedAnyOfTest.php | 37 +++++- tests/ComposedValue/ComposedIfTest.php | 55 +++++++- tests/Filter/FilterCompositionRuntimeTest.php | 117 ++++++++++++++++++ tests/Filter/FilterCompositionStaticTest.php | 57 +++++++++ tests/Filter/FilterTypeCompatibilityTest.php | 76 ++++++++++++ tests/Objects/ReferencePropertyTest.php | 21 ++++ .../AllOfNullableIntersectsToNullOnly.json | 21 ++++ .../AnyOfOnlyExplicitNullBranch.json | 12 ++ .../IfThenElseDeadThenBranchNonNull.json | 17 +++ .../IfThenElseNullTypedBranch.json | 17 +++ ...ThenElseNullableParentNullTypedBranch.json | 20 +++ ...positionDateTimeWithEmptyObjectBranch.json | 13 ++ ...nMixedReturnWithInputSpaceComposition.json | 14 +++ ...CompositionAllOfDeadFilterMultiBranch.json | 16 +++ ...terCompositionFilterInNestedNotBranch.json | 15 +++ ...lterCompositionNestedAllOfOutputSpace.json | 20 +++ ...rCompositionObjectBranchArrayTypeForm.json | 19 +++ ...pertyNullableStringTransformingFilter.json | 12 ++ ...gPropertyMixedInputTransformingFilter.json | 9 ++ .../AnnotatedDefinitionRef.json | 17 +++ 23 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 tests/Schema/ComposedAllOfTest/AllOfNullableIntersectsToNullOnly.json create mode 100644 tests/Schema/ComposedAnyOfTest/AnyOfOnlyExplicitNullBranch.json create mode 100644 tests/Schema/ComposedIfTest/IfThenElseDeadThenBranchNonNull.json create mode 100644 tests/Schema/ComposedIfTest/IfThenElseNullTypedBranch.json create mode 100644 tests/Schema/ComposedIfTest/IfThenElseNullableParentNullTypedBranch.json create mode 100644 tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithEmptyObjectBranch.json create mode 100644 tests/Schema/FilterCompositionRuntimeTest/FilterCompositionMixedReturnWithInputSpaceComposition.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilterMultiBranch.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedNotBranch.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionNestedAllOfOutputSpace.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionObjectBranchArrayTypeForm.json create mode 100644 tests/Schema/FilterTypeCompatibilityTest/NullableStringPropertyNullableStringTransformingFilter.json create mode 100644 tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedInputTransformingFilter.json create mode 100644 tests/Schema/ReferencePropertyTest/AnnotatedDefinitionRef.json diff --git a/CLAUDE.md b/CLAUDE.md index 790643f3..6dcaea41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,26 @@ # CLAUDE.md +## Learning from reviews + +After completing a task that involved responding to code review feedback, scan the reviewer's +corrections and confirmations for patterns not already captured in memory or in this file. For +each non-obvious pattern found, write or update a `feedback` memory file in the project memory +directory and add a pointer to `MEMORY.md`. + +What qualifies as worth saving: +- Any correction the reviewer had to make that I should have caught myself. +- Any expectation that surprised me or that I applied incorrectly. +- Any confirmation that a non-obvious approach was right (so it is not silently reversed later). + +What does not qualify: +- One-off fixes specific to a single schema or class. +- Anything already stated verbatim in this file. +- Trivially obvious mistakes with no generalizable lesson. + +Do this at the end of the session, not during — so it does not interrupt implementation work. + +--- + ## Clarification policy Before starting any non-trivial task — one that has more than one degree of freedom, including @@ -325,6 +346,12 @@ meaning. - ✅ `// Static rejection guarantees anyOf/oneOf have uniform spaces` - ❌ `* Covers FilterValidator::runCompatibilityCheck lines 130–158` - ✅ `* Validates the zero-overlap rejection path in FilterValidator` +- ❌ `* exercises FilterProcessor line 429 (else branch of classifyValidatorAdjustments)` +- ✅ `* exercises the else branch of classifyValidatorAdjustments` + +This rule applies equally to DocBlocks in test files: do not reference specific line numbers of +the code under test. Line numbers shift whenever the file is edited, making such references +misleading immediately after refactoring. Describe *what the code does or why* instead. Describe *what the code does or why* — not where it came from in a planning document. diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index 8e5901fc..b25ca4a7 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -232,43 +232,57 @@ private function detectIfThenElseConflict( CompositionPropertyDecorator $thenProperty, CompositionPropertyDecorator $elseProperty, ): void { - $parentNames = array_filter( - $originalParentType->getNames(), - static fn(string $typeName): bool => $typeName !== 'null', - ); - - if (empty($parentNames)) { - return; + // isNullable() encodes the null part of a multi-type (e.g. ["string","null"]) separately + // from getNames() — it is not present as a name string. Append 'null' so that a + // null-typed branch is not incorrectly flagged as conflicting with a nullable parent. + $parentNames = $originalParentType->isNullable() + ? [...$originalParentType->getNames(), 'null'] + : $originalParentType->getNames(); + + $thenTypes = $this->getBranchTypeNames($thenProperty); + $elseTypes = $this->getBranchTypeNames($elseProperty); + + // A null return means the branch is truly untyped (no type keyword) — it accepts + // any value and cannot conflict with the parent type. + $thenConflicts = $thenTypes !== null + && empty(TypeIntersection::compute($parentNames, $thenTypes)); + $elseConflicts = $elseTypes !== null + && empty(TypeIntersection::compute($parentNames, $elseTypes)); + + if ($thenConflicts || $elseConflicts) { + throw new SchemaException(sprintf( + "Property '%s' has an if/then/else composition branch with a type incompatible" + . " with the property's declared type (file %s)." + . ' No value can satisfy both constraints.', + $property->getName(), + $property->getJsonSchema()->getFile(), + )); } + } - $thenType = $thenProperty->getType(); - $elseType = $elseProperty->getType(); + /** + * Returns the full set of type names for a composition branch, or null when the branch + * is truly untyped (no type keyword — accepts any value). + * + * NullModifier sets getType() to PHP null but adds a TypeHintDecorator containing 'null', + * so we distinguish null-typed branches (effective types: ['null']) from truly untyped + * branches (getType() also null, but no 'null' in the type hint). + * + * @return string[]|null null when the branch has no type constraint + */ + private function getBranchTypeNames(CompositionPropertyDecorator $branch): ?array + { + $type = $branch->getType(); - if ($thenType === null || $elseType === null) { - // At least one branch is untyped — it accepts any value, so no total conflict. - return; + if ($type !== null) { + return $type->getNames(); } - $thenNonNull = array_filter( - $thenType->getNames(), - static fn(string $typeName): bool => $typeName !== 'null', - ); - $elseNonNull = array_filter( - $elseType->getNames(), - static fn(string $typeName): bool => $typeName !== 'null', - ); - - $thenConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($thenNonNull))); - $elseConflicts = empty(TypeIntersection::compute(array_values($parentNames), array_values($elseNonNull))); - - if ($thenConflicts && $elseConflicts) { - throw new SchemaException(sprintf( - "Property '%s' has a type that conflicts with all if/then/else composition branches" - . ' (file %s). No value can satisfy both the property type and the applicable' - . ' branch constraint, making this schema unsatisfiable.', - $property->getName(), - $property->getJsonSchema()->getFile(), - )); + // NullModifier-processed branch: getType() is PHP null but typeHint contains 'null'. + if (str_contains($branch->getTypeHint(), 'null')) { + return ['null']; } + + return null; } } diff --git a/tests/ComposedValue/ComposedAllOfTest.php b/tests/ComposedValue/ComposedAllOfTest.php index bbee4cb6..ec74d764 100644 --- a/tests/ComposedValue/ComposedAllOfTest.php +++ b/tests/ComposedValue/ComposedAllOfTest.php @@ -551,7 +551,7 @@ public static function validationInSetterDataProvider(): array * During SchemaProcessor::transferComposedPropertiesToSchema the base property's validator * list contains both a TypeCheckValidator (from TypeCheckModifier) and a MinProperties * validator — neither of which is an AbstractComposedPropertyValidator — so both are skipped - * via the `continue` guard (line 472) before the allOf composition validator is processed. + * before the allOf composition validator is processed. */ public function testObjectLevelAllOfWithAdditionalBaseValidatorTransfersProperties(): void { @@ -670,4 +670,33 @@ public function testPropertyLevelAllOfIntegerSubtypeOfNumberResolvesToInt(): voi $object = new $className(['property' => 42]); $this->assertSame(42, $object->getProperty()); } + + /** + * When two allOf branches both declare nullable types whose non-null names have an + * empty intersection (e.g. string|null AND integer|null), the property's effective + * non-null type set is empty. Both branches allow null, so no SchemaException is + * thrown. transferAllOfType returns early — the property carries no non-null type + * hint and accepts only null. + */ + public function testAllOfNullableTypesWithEmptyNonNullIntersectionReturnsEarlyWithoutType(): void + { + $className = $this->generateClassFromFile( + 'AllOfNullableIntersectsToNullOnly.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // No non-null type was imposed; the getter and setter carry no concrete type hint. + $this->assertSame('mixed', $this->getReturnType($className, 'getProperty')->getName()); + $this->assertSame('mixed', $this->getParameterType($className, 'setProperty')->getName()); + + // Null is accepted via constructor and via setter. + $object = new $className([]); + $this->assertNull($object->getProperty()); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); + + $object->setProperty(null); + $this->assertNull($object->getProperty()); + } } diff --git a/tests/ComposedValue/ComposedAnyOfTest.php b/tests/ComposedValue/ComposedAnyOfTest.php index 0d9ad13b..cfb5ef10 100644 --- a/tests/ComposedValue/ComposedAnyOfTest.php +++ b/tests/ComposedValue/ComposedAnyOfTest.php @@ -189,8 +189,14 @@ public function testAnyOfTypePropertyHasTypeAnnotation( ): void { $className = $this->generateClassFromFile($schema); - $this->assertMatchesRegularExpression($annotationPattern, $this->getPropertyTypeAnnotation($className, 'property')); - $this->assertMatchesRegularExpression($annotationPattern, $this->getReturnTypeAnnotation($className, 'getProperty')); + $this->assertMatchesRegularExpression( + $annotationPattern, + $this->getPropertyTypeAnnotation($className, 'property'), + ); + $this->assertMatchesRegularExpression( + $annotationPattern, + $this->getReturnTypeAnnotation($className, 'getProperty'), + ); $this->assertCount($generatedClasses, $this->getGeneratedFiles()); } @@ -651,4 +657,31 @@ public function testPropertyLevelAnyOfWithUntypedBranchProducesMixedType(): void $this->assertSame('mixed', $this->getReturnType($className, 'getProperty')->getName()); $this->assertSame('mixed', $this->getParameterType($className, 'setProperty')->getName()); } + + /** + * When the anyOf composition contains only explicit null-type branches ({type: null}), + * there are no typed (non-null) branches to build a union from. transferAnyOfOneOfType + * returns early — the property carries no non-null type hint and accepts only null. + */ + public function testAnyOfWithOnlyExplicitNullBranchReturnsEarlyWithoutType(): void + { + $className = $this->generateClassFromFile( + 'AnyOfOnlyExplicitNullBranch.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // No non-null type was imposed; the getter and setter carry no concrete type hint. + $this->assertSame('mixed', $this->getReturnType($className, 'getProperty')->getName()); + $this->assertSame('mixed', $this->getParameterType($className, 'setProperty')->getName()); + + // Null is accepted via constructor and via setter. + $object = new $className([]); + $this->assertNull($object->getProperty()); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); + + $object->setProperty(null); + $this->assertNull($object->getProperty()); + } } diff --git a/tests/ComposedValue/ComposedIfTest.php b/tests/ComposedValue/ComposedIfTest.php index cf228260..010d39d7 100644 --- a/tests/ComposedValue/ComposedIfTest.php +++ b/tests/ComposedValue/ComposedIfTest.php @@ -362,12 +362,65 @@ public function testPropertyLevelIfThenElseConflictingBranchTypesThrowsSchemaExc { $this->expectException(SchemaException::class); $this->expectExceptionMessageMatches( - "/Property 'property' has a type that conflicts with all if\/then\/else composition branches/", + "/Property 'property' has an if\/then\/else composition branch with a type incompatible/", ); $this->generateClassFromFile('PropertyLevelIfThenElseConflictingTypes.json'); } + /** + * When a then or else branch declares {type: null}, its non-null type intersection with a + * non-null parent (e.g. string) is empty — no value can be both a string and null. The + * conflict is detectable at generation time, so SchemaException is thrown immediately. + */ + public function testIfThenElseNullTypedBranchConflictsWithNonNullParentThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'property' has an if\/then\/else composition branch with a type incompatible/", + ); + + $this->generateClassFromFile('IfThenElseNullTypedBranch.json'); + } + + /** + * A property-level if/then/else where the parent declares type:string, then declares + * {type: integer}, and else declares {type: string}. The then branch has an empty + * intersection with the parent type — no string value can be an integer. This demonstrates + * that the conflict detector is not null-specific: any incompatible type triggers the error. + */ + public function testIfThenElseDeadThenBranchWithNonNullTypeConflictThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/Property 'property' has an if\/then\/else composition branch with a type incompatible/", + ); + + $this->generateClassFromFile('IfThenElseDeadThenBranchNonNull.json'); + } + + /** + * A property-level if/then/else where the parent declares type:["string","null"], then + * declares {type: null}, and else declares {type: string}. The then branch declares null, + * and null IS in the parent's type set — the intersection is non-empty, so this schema is + * valid and generation succeeds without throwing. + */ + public function testIfThenElseNullableParentWithNullTypedBranchGeneratesSuccessfully(): void + { + $className = $this->generateClassFromFile( + 'IfThenElseNullableParentNullTypedBranch.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // null is a valid value for the property (else branch: {type: string} accepts non-null, + // but the parent type permits null; if condition fires only on string values) + $object = new $className([]); + $this->assertNull($object->getProperty()); + + $object = new $className(['property' => null]); + $this->assertNull($object->getProperty()); + } + /** * A property-level if/then/else where the parent declares type:integer and the then/else * branches add only numeric constraints (no explicit type). The branches inherit the parent diff --git a/tests/Filter/FilterCompositionRuntimeTest.php b/tests/Filter/FilterCompositionRuntimeTest.php index 67d5b34c..05b87a87 100644 --- a/tests/Filter/FilterCompositionRuntimeTest.php +++ b/tests/Filter/FilterCompositionRuntimeTest.php @@ -16,6 +16,7 @@ use PHPModelGenerator\Exception\String\PatternException; use PHPModelGenerator\Format\FormatValidatorFromRegEx; use PHPModelGenerator\Model\GeneratorConfiguration; +use stdClass; /** * Tests for runtime ordering of composition validators around a transforming filter. @@ -45,6 +46,30 @@ public static function serializeMixedToDateTime(DateTime $value): string return $value->format(DATE_ATOM); } + /** Accepts an object and returns DateTime. Used for the empty-object-branch allOf coverage test. */ + public static function filterObjectToDateTime(object $value): DateTime + { + return $value instanceof DateTime ? $value : new DateTime(); + } + + /** Serializer for filterObjectToDateTime. */ + public static function serializeObjectToDateTime(DateTime $value): string + { + return $value->format(DATE_ATOM); + } + + /** Accepts a string and returns mixed. Used to exercise the empty-returnTypeNames code path. */ + public static function filterStringToMixed(string $value): mixed + { + return $value; + } + + /** Serializer for filterStringToMixed. */ + public static function serializeMixedToString(mixed $value): string + { + return (string) $value; + } + // ------------------------------------------------------------------------- // Validator-priority reassignment // ------------------------------------------------------------------------- @@ -1012,6 +1037,98 @@ public function testInputSpaceIfThenOnlyCompositionRunsPreTransform(): void $this->assertSame($dateTime, $object->getFilteredProperty()); } + // ------------------------------------------------------------------------- + // Empty object branch: addExtendedInstanceOfCheckForObjectBranches positive path + // ------------------------------------------------------------------------- + + /** + * When a transforming filter returns an object type (DateTime) and an allOf branch is + * an empty object schema ({type: object} with no declared properties), the strict instanceof + * check is removed from the composition branch and a property-level PropertyValidator is + * added instead. The pre-transform allOf is wrapped in a FilterPreTransformGuardValidator + * so that already-transformed DateTime values skip the object-type check; the guard is + * unwrapped to scan inner branches and detect the empty object schema. + */ + public function testExtendedInstanceOfCheckAddedForEmptyObjectBranchInAllOf(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeObjectToDateTime'], + [self::class, 'filterObjectToDateTime'], + 'objectToDateTime', + )); + + $className = $this->generateClassFromFile( + 'FilterCompositionDateTimeWithEmptyObjectBranch.json', + $configuration, + ); + + // Already-transformed DateTime: pre-transform allOf guard skips the allOf check; + // filter pass-through also skips; property-level instanceof check passes. + $dateTime = new DateTime('2024-01-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + + // Non-DateTime object: allOf({type:object}) passes pre-transform, filter converts to + // DateTime. The property-level instanceof check confirms the stored value is a DateTime. + $object = new $className(['filteredProperty' => new stdClass()]); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Non-object input (string): fails the pre-transform allOf {type:object} constraint. + try { + new $className(['filteredProperty' => 'not-an-object']); + $this->fail('Expected AllOfException for non-object input'); + } catch (AllOfException $allOfException) { + $this->assertStringContainsString('filteredProperty', $allOfException->getMessage()); + } + } + + // ------------------------------------------------------------------------- + // Mixed-return transforming filter: input-space composition moved without guard + // ------------------------------------------------------------------------- + + /** + * When the transforming filter has a mixed return type (returnTypeNames = []), input-space + * composition validators are moved to pre-filter priority directly — without wrapping in a + * FilterPreTransformGuardValidator — because there is no return type to build a skip + * condition from. + * + * Observable proof: minLength:1 runs PRE-filter. An empty string fails the anyOf check + * before the filter is ever invoked. + */ + public function testMixedReturnTransformingFilterMovesInputSpaceCompositionWithoutGuard(): void + { + $configuration = (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeMixedToString'], + [self::class, 'filterStringToMixed'], + 'stringToMixed', + )); + + $className = $this->generateClassFromFile( + 'FilterCompositionMixedReturnWithInputSpaceComposition.json', + $configuration, + ); + + // Non-empty string: anyOf (minLength:1) passes pre-filter, then filter runs. + $object = new $className(['filteredProperty' => 'hello']); + $this->assertSame('hello', $object->getFilteredProperty()); + + // Empty string: anyOf (minLength:1) fires pre-filter and rejects it. + // If the guard were absent, the filter would run first and minLength would never + // be evaluated against the raw input. + try { + new $className(['filteredProperty' => '']); + $this->fail('Expected AnyOfException for empty string'); + } catch (AnyOfException $anyOfException) { + $this->assertStringContainsString('filteredProperty', $anyOfException->getMessage()); + } + } + // ------------------------------------------------------------------------- // Root-level input-space constraint on filtered sub-property // ------------------------------------------------------------------------- diff --git a/tests/Filter/FilterCompositionStaticTest.php b/tests/Filter/FilterCompositionStaticTest.php index 1b3877bd..84f9443b 100644 --- a/tests/Filter/FilterCompositionStaticTest.php +++ b/tests/Filter/FilterCompositionStaticTest.php @@ -125,6 +125,13 @@ public static function rejectedCompositionProvider(): array '/A filter keyword inside a allOf composition branch is not supported' . ' for property filteredProperty.*branch #0/', ], + // Filter inside a not sub-schema within an allOf branch: the recursive scan for + // SINGLE_COMPOSITION_KEYWORDS descends into not and finds the filter keyword. + 'filter inside not sub-schema within allOf branch' => [ + 'FilterCompositionFilterInNestedNotBranch.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], // anyOf branch spanning both input and output type-spaces is ambiguous. 'anyOf with single Mixed branch' => [ 'FilterCompositionAnyOfMixedBranch.json', @@ -149,17 +156,41 @@ public function testUnresolvableCompositionOnTransformingFilterPropertyThrowsSch public static function acceptedCompositionProvider(): array { return [ + // All allOf branches constrain only the input type-space (e.g. minLength, pattern). + // These run pre-transform and do not conflict with the filter boundary. 'allOf with input-only branches' => ['FilterCompositionAllOfInputOnly.json'], + // All anyOf branches constrain only the input type-space. 'anyOf with input-only branches' => ['FilterCompositionAnyOfInputOnly.json'], + // All oneOf branches constrain only the input type-space. 'oneOf with input-only branches' => ['FilterCompositionOneOfInputOnly.json'], + // All if/then/else sub-schemas constrain only the input type-space. 'if/then/else input-only branches' => ['FilterCompositionIfThenElseInputOnly.json'], + // A conditional with only if+then (no else) where both sub-schemas are input-space. 'if/then only (no else) input-only branches' => ['FilterCompositionIfThenOnlyInputSpace.json'], + // A conditional with only if+else (no then) where both sub-schemas are input-space. 'if/else only (no then) input-only branches' => ['FilterCompositionIfElseOnlyInputSpace.json'], + // An allOf branch that is an empty object ({}) has no keywords and classifies as + // TypeSpace::Empty, which does not conflict with either type-space boundary. 'allOf with empty {} branch' => ['FilterCompositionAllOfEmptyBranch.json'], + // A root-level allOf constrains the filtered sub-property with an input-space keyword + // (minLength). This is accepted because input-space constraints target the raw value. 'root-level allOf: input-space constraint on filtered subproperty' => ['FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json'], + // A root-level allOf branch introduces an inherited-object property that itself + // declares a filter. The filter is on a nested property, not on the composition + // branch directly, so no filter-in-branch rejection fires. 'root-level allOf branch: filter in inherited-object branch property' => ['FilterCompositionRootBranchWithFilterInProperty.json'], + // anyOf branch typed as object via the array form (["object"]) is correctly + // identified as object-typed. Its properties are not scanned for filter keywords, + // so the inner trim filter does not trigger a filter-in-branch rejection. + 'anyOf with object branch using array-form type with inner filter in properties' => + ['FilterCompositionObjectBranchArrayTypeForm.json'], + // A branch that itself contains a nested allOf with all output-space constraints + // classifies the branch as TypeSpace::Output. Both branches here are output-space + // (object-typed, post-dateTime-filter), so the anyOf is accepted without error. + 'anyOf with nested all-output allOf branch (output-space composition)' => + ['FilterCompositionNestedAllOfOutputSpace.json'], ]; } @@ -197,6 +228,32 @@ public function testDeadFilterViaAllOfTypeConstraintThrowsSchemaException(): voi ); } + /** + * When multiple allOf branches all declare type constraints, the effective input type + * is the intersection across all branch type sets. The foreach in + * detectDeadFilterViaAllOfConstraints runs for every branch after the first. Two + * integer branches intersect to integer, which the string-accepting stringToInt filter + * cannot handle — dead-filter SchemaException is thrown. + */ + public function testDeadFilterViaMultiBranchAllOfTypeConstraintThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/Filter stringToInt on property filteredProperty.*can never be executed' + . '.*allOf type constraints \(int\) exclude all input types accepted by the filter \(string\)/', + ); + + $this->generateClassFromFile( + 'FilterCompositionAllOfDeadFilterMultiBranch.json', + (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'convertStringToInt'], + 'stringToInt', + )), + ); + } + /** * Contradictory allOf type constraints (no value can satisfy all branches simultaneously) * produce an empty intersection. The property-level allOf intersection check fires and diff --git a/tests/Filter/FilterTypeCompatibilityTest.php b/tests/Filter/FilterTypeCompatibilityTest.php index 8370501c..5397cbf5 100644 --- a/tests/Filter/FilterTypeCompatibilityTest.php +++ b/tests/Filter/FilterTypeCompatibilityTest.php @@ -95,6 +95,18 @@ public static function filterWithNoReturnType(string $value) return $value; } + /** Accepts mixed, returns int. Used by the mixed-input applyOutputType branch test. */ + public static function mixedInputToIntFilter(mixed $value): int + { + return (int) $value; + } + + /** Accepts nullable string, returns int. Used by the null-accepted applyOutputType branch test. */ + public static function nullableStringToIntFilter(?string $value): int + { + return (int) $value; + } + /** Void return type — used by the void-return-type InvalidFilterException test. */ public static function filterWithVoidReturnType(string $value): void { @@ -529,4 +541,68 @@ public function testTransformingFilterWithNeverReturnTypeThrowsInvalidFilterExce ), ); } + + /** + * When the transforming filter callable has a mixed parameter (acceptedTypes = []), + * the bypass type list is empty: a mixed-input filter consumes all input types and + * nothing is passed through unchanged. The getter returns only the filter output type + * (int), with no bypass union. + */ + public function testTransformingFilterWithMixedInputProducesEmptyBypassTypes(): void + { + $className = $this->generateClassFromFile( + 'StringPropertyMixedInputTransformingFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'mixedInputToIntFilter'], + 'mixedToInt', + )), + ); + + // No bypass: the getter returns only the filter's output type (int). + $this->assertEqualsCanonicalizing(['int'], $this->getReturnTypeNames($className, 'getFilteredProperty')); + + // String input: filter transforms to int. + $object = new $className(['filteredProperty' => '42']); + $this->assertSame(42, $object->getFilteredProperty()); + + // Setter: string is still transformed to int (no int-bypass because accepted types = mixed). + $object->setFilteredProperty('7'); + $this->assertSame(7, $object->getFilteredProperty()); + } + + /** + * When the transforming filter callable accepts nullable string (?string), null is in + * the accepted types, so bypassNullable is false — null is not a bypass type because + * the filter handles it. The getter returns only the filter output type (int), and + * null input is transformed by the filter rather than passed through unchanged. + */ + public function testTransformingFilterWithNullableInputConsumesNullWithoutBypass(): void + { + $className = $this->generateClassFromFile( + 'NullableStringPropertyNullableStringTransformingFilter.json', + (new GeneratorConfiguration()) + ->setCollectErrors(false) + ->setImmutable(false) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeIntToString'], + [self::class, 'nullableStringToIntFilter'], + 'nullableStringToInt', + )), + ); + + // No null bypass: the getter returns only the filter's output type (int), not int|null. + $this->assertEqualsCanonicalizing(['int'], $this->getReturnTypeNames($className, 'getFilteredProperty')); + + // String input: filter transforms to int. + $object = new $className(['filteredProperty' => '7']); + $this->assertSame(7, $object->getFilteredProperty()); + + // Null input: null is accepted by the filter (not a bypass), filter transforms to 0. + $object = new $className(['filteredProperty' => null]); + $this->assertSame(0, $object->getFilteredProperty()); + } } diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 84296a3a..ff55d65c 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -778,4 +778,25 @@ public static function invalidValuesForMultiplePropertiesWithIdenticalReferenceD ], ]; } + + /** + * When a property uses $ref to point to a definition that carries $comment and examples + * annotations, the PropertyProxy delegates getComment() and getExamples() to the + * underlying property. The template calls both methods on every rendered property, so + * generation successfully produces a class and the proxy delegation is exercised. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testRefPropertyWithCommentAndExamplesAnnotationsGeneratesSuccessfully(): void + { + $className = $this->generateClassFromFile('AnnotatedDefinitionRef.json'); + + $object = new $className([]); + $this->assertNull($object->getLabel()); + + $object = new $className(['label' => 'hello']); + $this->assertSame('hello', $object->getLabel()); + } } diff --git a/tests/Schema/ComposedAllOfTest/AllOfNullableIntersectsToNullOnly.json b/tests/Schema/ComposedAllOfTest/AllOfNullableIntersectsToNullOnly.json new file mode 100644 index 00000000..58d2a287 --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/AllOfNullableIntersectsToNullOnly.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "property": { + "allOf": [ + { + "type": [ + "string", + "null" + ] + }, + { + "type": [ + "integer", + "null" + ] + } + ] + } + } +} diff --git a/tests/Schema/ComposedAnyOfTest/AnyOfOnlyExplicitNullBranch.json b/tests/Schema/ComposedAnyOfTest/AnyOfOnlyExplicitNullBranch.json new file mode 100644 index 00000000..87178d18 --- /dev/null +++ b/tests/Schema/ComposedAnyOfTest/AnyOfOnlyExplicitNullBranch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "property": { + "anyOf": [ + { + "type": "null" + } + ] + } + } +} diff --git a/tests/Schema/ComposedIfTest/IfThenElseDeadThenBranchNonNull.json b/tests/Schema/ComposedIfTest/IfThenElseDeadThenBranchNonNull.json new file mode 100644 index 00000000..b57e6f88 --- /dev/null +++ b/tests/Schema/ComposedIfTest/IfThenElseDeadThenBranchNonNull.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "if": { + "minLength": 5 + }, + "then": { + "type": "integer" + }, + "else": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/ComposedIfTest/IfThenElseNullTypedBranch.json b/tests/Schema/ComposedIfTest/IfThenElseNullTypedBranch.json new file mode 100644 index 00000000..fd107695 --- /dev/null +++ b/tests/Schema/ComposedIfTest/IfThenElseNullTypedBranch.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "property": { + "type": "string", + "if": { + "minLength": 5 + }, + "then": { + "type": "null" + }, + "else": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/ComposedIfTest/IfThenElseNullableParentNullTypedBranch.json b/tests/Schema/ComposedIfTest/IfThenElseNullableParentNullTypedBranch.json new file mode 100644 index 00000000..b942cbe2 --- /dev/null +++ b/tests/Schema/ComposedIfTest/IfThenElseNullableParentNullTypedBranch.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "string", + "null" + ], + "if": { + "minLength": 5 + }, + "then": { + "type": "null" + }, + "else": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithEmptyObjectBranch.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithEmptyObjectBranch.json new file mode 100644 index 00000000..8bdc1657 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithEmptyObjectBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "objectToDateTime", + "allOf": [ + { + "type": "object" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionMixedReturnWithInputSpaceComposition.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionMixedReturnWithInputSpaceComposition.json new file mode 100644 index 00000000..f234bbcf --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionMixedReturnWithInputSpaceComposition.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "stringToMixed", + "anyOf": [ + { + "minLength": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilterMultiBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilterMultiBranch.json new file mode 100644 index 00000000..b25f77ed --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilterMultiBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "allOf": [ + { + "type": "integer" + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedNotBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedNotBranch.json new file mode 100644 index 00000000..0cc56003 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedNotBranch.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "not": { + "filter": "trim" + } + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionNestedAllOfOutputSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionNestedAllOfOutputSpace.json new file mode 100644 index 00000000..1d6ee4f0 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionNestedAllOfOutputSpace.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "object" + }, + { + "allOf": [ + { + "type": "object" + } + ] + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionObjectBranchArrayTypeForm.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionObjectBranchArrayTypeForm.json new file mode 100644 index 00000000..bb813339 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionObjectBranchArrayTypeForm.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "anyOf": [ + { + "type": [ + "object" + ], + "properties": { + "nested": { + "filter": "trim" + } + } + } + ] + } + } +} diff --git a/tests/Schema/FilterTypeCompatibilityTest/NullableStringPropertyNullableStringTransformingFilter.json b/tests/Schema/FilterTypeCompatibilityTest/NullableStringPropertyNullableStringTransformingFilter.json new file mode 100644 index 00000000..096b3629 --- /dev/null +++ b/tests/Schema/FilterTypeCompatibilityTest/NullableStringPropertyNullableStringTransformingFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "null" + ], + "filter": "nullableStringToInt" + } + } +} diff --git a/tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedInputTransformingFilter.json b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedInputTransformingFilter.json new file mode 100644 index 00000000..8e8da5e2 --- /dev/null +++ b/tests/Schema/FilterTypeCompatibilityTest/StringPropertyMixedInputTransformingFilter.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "mixedToInt" + } + } +} diff --git a/tests/Schema/ReferencePropertyTest/AnnotatedDefinitionRef.json b/tests/Schema/ReferencePropertyTest/AnnotatedDefinitionRef.json new file mode 100644 index 00000000..62717799 --- /dev/null +++ b/tests/Schema/ReferencePropertyTest/AnnotatedDefinitionRef.json @@ -0,0 +1,17 @@ +{ + "definitions": { + "annotatedString": { + "type": "string", + "$comment": "This is a note about the annotated string definition", + "examples": [ + "example value" + ] + } + }, + "type": "object", + "properties": { + "label": { + "$ref": "#/definitions/annotatedString" + } + } +} From 449ca58e2ed2b599c452eb70869f722c5dfe7749 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 20 May 2026 16:36:28 +0200 Subject: [PATCH 11/11] Close filter/composition coverage gaps: remove dead code, add targeted tests - Remove unreachable !is_array() guards in CompositionBranchClassifier: the not branch always receives an array (NotValidatorFactory wraps it), and non-array allOf/anyOf/oneOf values crash before the classifier runs. - Drop redundant testOutputTypeNotExtendedWhenReturnTypeAlreadyInBaseType: the applyOutputType early-return path is already exercised by the existing testOutputSpaceNotCompositionRunsPostTransform which uses the same type:["string","integer"] + stringToInt schema shape. - Extend testNoExtendedInstanceOfCheckWhenAllObjectBranchesAreNonEmpty with an unparseable-string assertion: 'Hello' passes the input-space anyOf (via type:string) but fails the dateTime filter, throwing InvalidFilterValueException. - Move testFilterOnObjectTypedPropertyWithNestedSchema to FilterTypeCompatibilityTest (no composition in that schema) and add a real runtime assertion: array input is instantiated to the inner class by ObjectInstantiationDecorator, then the filter converts it to DateTime. - Add static test cases for root-level then/else constraining a filtered subproperty with output-type-space keywords, a filter inside an if sub-schema within an allOf branch, and root-level not with an input-space keyword on a filtered subproperty. Co-Authored-By: Claude Sonnet 4.6 --- .../Filter/CompositionBranchClassifier.php | 7 +-- tests/Filter/FilterCompositionRuntimeTest.php | 36 +++++++++++++ tests/Filter/FilterCompositionStaticTest.php | 21 ++++++++ tests/Filter/FilterTypeCompatibilityTest.php | 54 ++++++++++++++++++- ...itionDateTimeWithNonEmptyObjectBranch.json | 16 ++++++ ...lterCompositionFilterInNestedIfBranch.json | 15 ++++++ ...RootElseConstrainsFilteredSubproperty.json | 15 ++++++ ...putSpaceConstrainsFilteredSubproperty.json | 15 ++++++ ...RootThenConstrainsFilteredSubproperty.json | 15 ++++++ ...jectPropertyWithNestedSchemaAndFilter.json | 14 +++++ 10 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithNonEmptyObjectBranch.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedIfBranch.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionRootElseConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotInputSpaceConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterCompositionStaticTest/FilterCompositionRootThenConstrainsFilteredSubproperty.json create mode 100644 tests/Schema/FilterTypeCompatibilityTest/ObjectPropertyWithNestedSchemaAndFilter.json diff --git a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php index ae7cfe57..e98980ce 100644 --- a/src/PropertyProcessor/Filter/CompositionBranchClassifier.php +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -154,12 +154,7 @@ private function classifyKeyword(string $keyword, mixed $value): TypeSpace private function classifyNestedComposition(string $keyword, mixed $value): TypeSpace { if ($keyword === 'not') { - return !is_array($value) ? TypeSpace::Empty : $this->classify($value); - } - - // allOf, anyOf, oneOf: value must be an array of branch schemas. - if (!is_array($value)) { - return TypeSpace::Empty; + return $this->classify($value); } $nonEmpty = array_values(array_filter( diff --git a/tests/Filter/FilterCompositionRuntimeTest.php b/tests/Filter/FilterCompositionRuntimeTest.php index 05b87a87..b7fbb487 100644 --- a/tests/Filter/FilterCompositionRuntimeTest.php +++ b/tests/Filter/FilterCompositionRuntimeTest.php @@ -11,6 +11,7 @@ use PHPModelGenerator\Exception\ComposedValue\NotException; use PHPModelGenerator\Exception\ComposedValue\OneOfException; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\Filter\InvalidFilterValueException; use PHPModelGenerator\Exception\String\FormatException; use PHPModelGenerator\Exception\Number\MinimumException; use PHPModelGenerator\Exception\String\PatternException; @@ -1153,4 +1154,39 @@ public function testRootLevelInputSpaceConstraintOnFilteredSubpropertyRunsSucces $object = new $className(['filteredProperty' => $dateTime]); $this->assertSame($dateTime, $object->getFilteredProperty()); } + + // ------------------------------------------------------------------------- + // addExtendedInstanceOfCheckForObjectBranches: early return for non-empty branches + // ------------------------------------------------------------------------- + + /** + * When a transforming filter (dateTime, returning non-primitive DateTime) is combined with + * anyOf branches that are all non-object-typed (type: string, minLength: 1), no branch loses + * its InstanceOfValidator. addExtendedInstanceOfCheckForObjectBranches finds no empty object + * branch and returns early without adding a property-level instanceof check. + * + * The anyOf branches are both input-space (string-space), so they run PRE-transform; a valid + * string passes and is converted to DateTime. An already-transformed DateTime bypasses the + * pre-transform validators and the filter entirely. An unparseable string passes the anyOf + * (via the type: string branch) but fails the filter, throwing InvalidFilterValueException. + */ + public function testNoExtendedInstanceOfCheckWhenAllObjectBranchesAreNonEmpty(): void + { + $className = $this->generateClassFromFile( + 'FilterCompositionDateTimeWithNonEmptyObjectBranch.json', + ); + + // String input satisfies both anyOf branches and is transformed to DateTime. + $object = new $className(['filteredProperty' => '2024-01-01']); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + + // Already-transformed DateTime bypasses the filter entirely. + $dateTime = new DateTime('2024-01-01'); + $object = new $className(['filteredProperty' => $dateTime]); + $this->assertSame($dateTime, $object->getFilteredProperty()); + + // A string that is not a valid datetime passes the anyOf but fails the filter. + $this->expectException(InvalidFilterValueException::class); + new $className(['filteredProperty' => 'Hello']); + } } diff --git a/tests/Filter/FilterCompositionStaticTest.php b/tests/Filter/FilterCompositionStaticTest.php index 84f9443b..ba910670 100644 --- a/tests/Filter/FilterCompositionStaticTest.php +++ b/tests/Filter/FilterCompositionStaticTest.php @@ -95,6 +95,23 @@ public static function rejectedCompositionProvider(): array 'FilterCompositionRootIfConstrainsFilteredSubproperty.json', '/Composition if.*constrains filtered subproperty filteredProperty.*output-type-space/', ], + // JSON Schema silently ignores then/else without a matching if, but the checker + // still parses them and must reject output-type-space constraints. + 'root-level then constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootThenConstrainsFilteredSubproperty.json', + '/Composition then.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + 'root-level else constrains filtered subproperty with output-type constraint' => [ + 'FilterCompositionRootElseConstrainsFilteredSubproperty.json', + '/Composition else.*constrains filtered subproperty filteredProperty.*output-type-space/', + ], + // Filter inside an if sub-schema within an allOf branch: the SINGLE_COMPOSITION_KEYWORDS + // loop in branchContainsFilter must descend into if and detect the filter keyword. + 'filter inside if sub-schema within allOf branch' => [ + 'FilterCompositionFilterInNestedIfBranch.json', + '/A filter keyword inside a allOf composition branch is not supported' + . ' for property filteredProperty.*branch #0/', + ], // Filter inside a not branch: same $value-reset issue as for array composition keywords. 'filter inside not branch' => [ 'FilterCompositionFilterInNotBranch.json', @@ -176,6 +193,10 @@ public static function acceptedCompositionProvider(): array // (minLength). This is accepted because input-space constraints target the raw value. 'root-level allOf: input-space constraint on filtered subproperty' => ['FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json'], + // Root-level not with an input-space keyword (minLength) is accepted. minLength targets + // the raw string input before transformation — no output-type-space conflict. + 'root-level not: input-space constraint on filtered subproperty' => + ['FilterCompositionRootNotInputSpaceConstrainsFilteredSubproperty.json'], // A root-level allOf branch introduces an inherited-object property that itself // declares a filter. The filter is on a nested property, not on the composition // branch directly, so no filter-in-branch rejection fires. diff --git a/tests/Filter/FilterTypeCompatibilityTest.php b/tests/Filter/FilterTypeCompatibilityTest.php index 5397cbf5..bb4c1944 100644 --- a/tests/Filter/FilterTypeCompatibilityTest.php +++ b/tests/Filter/FilterTypeCompatibilityTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Filter; +use DateTime; use RuntimeException; use PHPModelGenerator\Exception\InvalidFilterException; use PHPModelGenerator\Exception\SchemaException; @@ -16,8 +17,9 @@ * the runtime type matches), full-overlap (filter always runs), mixed type hints on callables * (empty accepted types, no runtime guard generated), untyped properties, union type hints on * callables, bypass formulas for transforming filters on multi-type properties, union return types, - * null consumed by a filter, and reflection-level rejections (no parameter type hint, void/never - * return types, missing return type on a transforming filter). + * null consumed by a filter, reflection-level rejections (no parameter type hint, void/never + * return types, missing return type on a transforming filter), and filters applied directly to + * object-typed properties with nested schemas. */ class FilterTypeCompatibilityTest extends AbstractFilterTestCase { @@ -107,6 +109,18 @@ public static function nullableStringToIntFilter(?string $value): int return (int) $value; } + /** Accepts any object and returns DateTime. Used by the nested-schema filter compatibility test. */ + public static function filterObjectToDateTime(object $value): DateTime + { + return $value instanceof DateTime ? $value : new DateTime(); + } + + /** Serializer for filterObjectToDateTime. */ + public static function serializeObjectToDateTime(DateTime $value): string + { + return $value->format(DATE_ATOM); + } + /** Void return type — used by the void-return-type InvalidFilterException test. */ public static function filterWithVoidReturnType(string $value): void { @@ -605,4 +619,40 @@ public function testTransformingFilterWithNullableInputConsumesNullWithoutBypass $object = new $className(['filteredProperty' => null]); $this->assertSame(0, $object->getFilteredProperty()); } + + // ------------------------------------------------------------------------- + // FilterValidator::runCompatibilityCheck — nested-schema property path + // ------------------------------------------------------------------------- + + /** + * When a filter is applied to an object-typed property that has a nested schema, + * runCompatibilityCheck takes the $nestedSchema !== null branch, derives typeNames as + * ['object'] and isNullable as false, and verifies overlap with the filter's accepted types. + * A filter accepting 'object' has full overlap; generation succeeds and the filter transforms + * the instantiated inner object to a DateTime. + * + * Array input is first passed through the ObjectInstantiationDecorator (array → inner class + * instance), then the transforming filter receives the inner class object and converts it to + * DateTime. There is no bypass for nested-schema properties because the type check for the + * generated inner class is not wrapped with a skip-check. + */ + public function testFilterOnObjectTypedPropertyWithNestedSchema(): void + { + $configuration = (new GeneratorConfiguration()) + ->addFilter($this->getCustomTransformingFilter( + [self::class, 'serializeObjectToDateTime'], + [self::class, 'filterObjectToDateTime'], + 'objectToDateTime', + )); + + $className = $this->generateClassFromFile( + 'ObjectPropertyWithNestedSchemaAndFilter.json', + $configuration, + ); + + // Array input: ObjectInstantiationDecorator constructs the inner class from the array, + // then filterObjectToDateTime receives the inner class instance and returns DateTime. + $object = new $className(['filteredProperty' => ['name' => 'Alice']]); + $this->assertInstanceOf(DateTime::class, $object->getFilteredProperty()); + } } diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithNonEmptyObjectBranch.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithNonEmptyObjectBranch.json new file mode 100644 index 00000000..c7c02088 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionDateTimeWithNonEmptyObjectBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "minLength": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedIfBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedIfBranch.json new file mode 100644 index 00000000..999bc429 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedIfBranch.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "if": { + "filter": "trim" + } + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootElseConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootElseConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..156a6c95 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootElseConstrainsFilteredSubproperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "else": { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotInputSpaceConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotInputSpaceConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..89417ebf --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotInputSpaceConstrainsFilteredSubproperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "not": { + "properties": { + "filteredProperty": { + "minLength": 1 + } + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootThenConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootThenConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..d301be07 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootThenConstrainsFilteredSubproperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "then": { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } +} diff --git a/tests/Schema/FilterTypeCompatibilityTest/ObjectPropertyWithNestedSchemaAndFilter.json b/tests/Schema/FilterTypeCompatibilityTest/ObjectPropertyWithNestedSchemaAndFilter.json new file mode 100644 index 00000000..de284495 --- /dev/null +++ b/tests/Schema/FilterTypeCompatibilityTest/ObjectPropertyWithNestedSchemaAndFilter.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "filter": "objectToDateTime" + } + } +}