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 75a7465b..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 @@ -189,6 +210,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. @@ -224,6 +248,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 @@ -285,6 +317,59 @@ 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` +- ❌ `* 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. + +#### 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 @@ -300,6 +385,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.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/Draft/Draft_07.php b/src/Draft/Draft_07.php index 090deb51..107479a3 100644 --- a/src/Draft/Draft_07.php +++ b/src/Draft/Draft_07.php @@ -84,12 +84,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/Property/Property.php b/src/Model/Property/Property.php index 4c916bb7..b33d8fc2 100644 --- a/src/Model/Property/Property.php +++ b/src/Model/Property/Property.php @@ -197,8 +197,11 @@ public function setExamples(array $examples): PropertyInterface /** * @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; @@ -211,7 +214,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 bf8a34ae..78d68ef6 100644 --- a/src/Model/Property/PropertyInterface.php +++ b/src/Model/Property/PropertyInterface.php @@ -79,8 +79,17 @@ public function setExamples(array $examples): PropertyInterface; * 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 aa352fa6..ece3c88c 100644 --- a/src/Model/Property/PropertyProxy.php +++ b/src/Model/Property/PropertyProxy.php @@ -117,9 +117,12 @@ public function setExamples(array $examples): PropertyInterface /** * @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/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/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/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index 98535951..3b10cd41 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -14,11 +14,14 @@ 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; +use PHPModelGenerator\PropertyProcessor\Filter\CompositionCompatibilityChecker; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\TypeIntersection; abstract class AbstractCompositionValidatorFactory extends AbstractValidatorFactory { @@ -54,6 +57,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: 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. * @@ -92,10 +152,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())) { @@ -160,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). + * + * 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. * - * @param bool $isAllOf Whether allOf semantics apply (affects nullable detection). + * 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, @@ -177,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(), @@ -200,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/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..b25ca4a7 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -8,15 +8,18 @@ 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; 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; +use PHPModelGenerator\Utils\TypeIntersection; class IfValidatorFactory extends AbstractCompositionValidatorFactory @@ -35,9 +38,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 +48,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. $propertySchema = $this->inheritPropertyType($propertySchema); $json = $propertySchema->getJson(); + // Check for filter keywords in if/then/else sub-schemas after type inheritance. + // 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) { + 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) @@ -90,6 +115,8 @@ public function modify( $properties[$keyword] = $compositionProperty; } + $this->applyIfThenElseTypeSemantics($property, $properties); + $property->addValidator( new ConditionalPropertyValidator( $schemaProcessor->getGeneratorConfiguration(), @@ -109,4 +136,153 @@ 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 { + // 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(), + )); + } + } + + /** + * 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 ($type !== null) { + return $type->getNames(); + } + + // NullModifier-processed branch: getType() is PHP null but typeHint contains 'null'. + if (str_contains($branch->getTypeHint(), 'null')) { + return ['null']; + } + + return null; + } } 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/Model/Validator/FilterValidator.php b/src/Model/Validator/FilterValidator.php index 8f978566..271c8b33 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,15 @@ 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, + * 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. * * @throws SchemaException @@ -133,14 +143,37 @@ 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 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; - $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 (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(); + $isNullable = $property->getType()->isNullable() ?? false; + } + } $hasOverlap = !empty(array_intersect($typeNames, $acceptedTypes)) || ($isNullable && in_array('null', $acceptedTypes, true)); @@ -236,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/Model/Validator/FormatValidator.php b/src/Model/Validator/FormatValidator.php index 682d77ff..d582444e 100644 --- a/src/Model/Validator/FormatValidator.php +++ b/src/Model/Validator/FormatValidator.php @@ -31,12 +31,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/src/PropertyProcessor/Filter/CompositionBranchClassifier.php b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php new file mode 100644 index 00000000..e98980ce --- /dev/null +++ b/src/PropertyProcessor/Filter/CompositionBranchClassifier.php @@ -0,0 +1,277 @@ + 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. + * + * @param array $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. + * + * 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') { + return TypeSpace::Input; + } + + 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 $this->classify($value); + } + + $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. + * + * 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 + { + 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, + $inInput => TypeSpace::Input, + $inOutput => TypeSpace::Output, + default => TypeSpace::Empty, + }; + } + + /** + * 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 + { + 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/CompositionCompatibilityChecker.php b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php new file mode 100644 index 00000000..bddd1ed0 --- /dev/null +++ b/src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php @@ -0,0 +1,339 @@ + $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. + * + * 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: proper handling is deferred to a follow-up topic. + // Root-level composition branches cannot yet be split around the + // filter's transform boundary. + 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: 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; + } + + // 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 ( + !$isObjectTyped + && 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/FilterPreTransformGuardValidator.php b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php new file mode 100644 index 00000000..46a78c09 --- /dev/null +++ b/src/PropertyProcessor/Filter/FilterPreTransformGuardValidator.php @@ -0,0 +1,92 @@ +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 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 + { + 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 063c58f2..42fc62f8 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -5,6 +5,10 @@ namespace PHPModelGenerator\PropertyProcessor\Filter; use Exception; +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; @@ -13,10 +17,16 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator; -use PHPModelGenerator\Model\Validator\AbstractPropertyValidator; +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\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\FormatValidator; +use PHPModelGenerator\Model\Validator\InstanceOfValidator; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; @@ -39,7 +49,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]; @@ -49,12 +59,13 @@ public static function normalizeFilterList(mixed $filterList): array } /** + * @throws InvalidFilterException * @throws ReflectionException * @throws SchemaException */ public function process( PropertyInterface $property, - mixed $filterList, + string|array $filterList, GeneratorConfiguration $generatorConfiguration, Schema $schema, int $startPriority = 10, @@ -62,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()); @@ -124,14 +136,35 @@ 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) { $returnTypeNames = FilterReflection::getReturnTypeNames($filter, $property); + $inputTypeNames = FilterReflection::getAcceptedTypes($filter, $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()); + $checker->checkTransformingFilterRootCompositionConflicts($schema->getJsonSchema()->getJson()); + + $this->reassignValidatorPriorities( + $property, + $actualFilterPriority, + $classifier, + $returnTypeNames, + $generatorConfiguration, + ); + if (!empty($returnTypeNames)) { // Wire pass-through checks on pre-transforming FilterValidators/EnumValidators // so they are skipped when an already-transformed value is provided. @@ -140,6 +173,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). @@ -164,6 +209,436 @@ 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) && !($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: + * + * - 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 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 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) { + if ($validatorContainer->getPriority() < $filterPriority) { + continue; // Already scheduled before the filter; no adjustment needed. + } + + if (is_a($validatorContainer->getValidator(), FilterValidator::class)) { + continue; // Skip the filter validators themselves. + } + + if (is_a($validatorContainer->getValidator(), AbstractComposedPropertyValidator::class)) { + /** @var AbstractComposedPropertyValidator $composedValidator */ + $composedValidator = $validatorContainer->getValidator(); + + [$inputIndices, $outputIndices] = $this->classifyComposedValidatorBranches( + $composedValidator, + $classifier, + $property->getJsonSchema()->getJson(), + ); + + // 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) + && $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). Leave at its current position. + continue; + } + + $typeSpace = $classifier->classifySchemaKey($sourceKey); + + if ($typeSpace === TypeSpace::Output) { + 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. + * + * 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. + * + * @return array{int[], int[]} [inputIndices, outputIndices] + */ + private function classifyComposedValidatorBranches( + AbstractComposedPropertyValidator $validator, + CompositionBranchClassifier $classifier, + array $originalPropertyJson, + ): array { + $inputIndices = []; + $outputIndices = []; + + $originalBranchSchemas = $this->resolveOriginalBranchSchemas($validator, $originalPropertyJson); + + foreach ($validator->getComposedProperties() as $index => $compositionProperty) { + $branchSchema = ($originalBranchSchemas !== null && isset($originalBranchSchemas[$index])) + ? $originalBranchSchemas[$index] + : $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]; + } + + /** + * 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::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 (['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. * @@ -269,6 +744,26 @@ 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. Callers should cache the result rather + * than calling this method more than once per process() invocation. + */ + 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 @@ -288,54 +783,24 @@ public function addTransformedValuePassThrough( $validator->addTransformedCheck($filter, $property); } - if ($validator instanceof FormatValidator) { - $this->replaceValidatorWithGuardedCheck( - $property, - $validator, - FormatValidator::class, - sprintf('is_string($value) && %s', $validator->getCheck()), + if ($validator instanceof EnumValidator) { + $property->filterValidators( + static fn(Validator $candidate): bool => !$candidate->getValidator() instanceof EnumValidator, ); - } - if ($validator instanceof EnumValidator) { - $this->replaceValidatorWithGuardedCheck( - $property, - $validator, - EnumValidator::class, - sprintf('%s && %s', TypeCheck::buildNegatedCompound($returnTypeNames), $validator->getCheck()), + $exceptionParams = $validator->getExceptionParams(); + array_shift($exceptionParams); + + $property->addValidator( + new PropertyValidator( + $property, + sprintf('%s && %s', TypeCheck::buildNegatedCompound($returnTypeNames), $validator->getCheck()), + $validator->getExceptionClass(), + $exceptionParams, + ), + 3, ); } } } - - /** - * Remove all validators of the given class from the property and re-add the same validation - * logic wrapped in a new check expression, at priority 3. - * - * The property name is stripped from exceptionParams before re-adding because - * AbstractPropertyValidator::getExceptionParams() prepends it again automatically. - */ - private function replaceValidatorWithGuardedCheck( - PropertyInterface $property, - AbstractPropertyValidator $validator, - string $validatorClass, - string $guardedCheck, - ): void { - $property->filterValidators( - static fn(Validator $candidate): bool => !is_a($candidate->getValidator(), $validatorClass), - ); - - $exceptionParams = $validator->getExceptionParams(); - array_shift($exceptionParams); - - $property->addValidator( - new PropertyValidator( - $property, - $guardedCheck, - $validator->getExceptionClass(), - $exceptionParams, - ), - 3, - ); - } } 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 @@ +addValidator($validator, $validatorContainer->getPriority()); + $property->addValidator( + $validator, + $validatorContainer->getPriority(), + $validatorContainer->getSourceKey(), + ); } if ($subProperty->getDecorators()) { @@ -619,7 +624,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/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..2937e0e2 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 @@ -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 @@ + [ ['additional1' => ['name' => 12], 'additional2' => ['name' => 'AB', 'age' => '12']], << [ 6, << [ 0, << [ 1, << [ "4", <<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 Phase 4d 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'); - } - - // --- Phase 4d: output type formula, reflection, 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()); - } - - /** - * FC-M1: 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 - { - $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, - ); - } - - /** - * 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). - */ - 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. - $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()); - - // FC-M3: concrete-return transforming filter (dateTime → DateTime) + accept-all follow-up - // — no SchemaException. Line 206 (FilterValidator) covered. - $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()); - } - - /** - * 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). - */ - 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 for FC-M1 and FC-M2. - */ - public static function filterWithMixedReturn(string $value): mixed - { - return $value; - } - - /** - * Serializer for 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. - */ - public static function acceptAllFilter(mixed $value): mixed - { - return $value; - } - - /** - * Transforming filter callable that returns a non-nullable int. - * Used for FC-I1. - */ - public static function filterWithIntReturn(string $value): int - { - return (int) $value; - } - - /** - * Serializer for filterWithIntReturn. - */ - public static function serializeIntReturn(int $value): string - { - return (string) $value; - } - - // --- 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()); - } -} 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', ], <<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 { @@ -510,32 +516,30 @@ public static function validationInSetterDataProvider(): array 'Exception Collection' => [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), <<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()); + } + + /** + * 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 4f2092c0..cfb5ef10 100644 --- a/tests/ComposedValue/ComposedAnyOfTest.php +++ b/tests/ComposedValue/ComposedAnyOfTest.php @@ -35,10 +35,11 @@ public function testNullProvidedForEmptyOptionalAnyOfIsValid(): void public function testValueProvidedForEmptyOptionalAnyOfIsInvalid(string|int|array $propertyValue): void { $this->expectException(ValidationException::class); - $this->expectExceptionMessage(<<expectExceptionMessage( + <<generateClassFromFile('EmptyAnyOf.json'); @@ -188,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()); } @@ -617,21 +624,64 @@ public static function validationInSetterDataProvider(): array 'Exception Collection' => [ (new GeneratorConfiguration())->setCollectErrors(true), << [ (new GeneratorConfiguration())->setCollectErrors(false), <<generateClassFromFile( + 'PropertyLevelAnyOfUntypedBranch.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $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 80a199d2..010d39d7 100644 --- a/tests/ComposedValue/ComposedIfTest.php +++ b/tests/ComposedValue/ComposedIfTest.php @@ -77,31 +77,31 @@ public static function invalidConditionalPropertyDefinitionDataProvider(): array 'invalid negative' => [ -50, << [ 50, << [ 120, <<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 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 + * 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/ComposedValue/ComposedNotTest.php b/tests/ComposedValue/ComposedNotTest.php index d257b2ab..c401c22f 100644 --- a/tests/ComposedValue/ComposedNotTest.php +++ b/tests/ComposedValue/ComposedNotTest.php @@ -28,10 +28,11 @@ class ComposedNotTest extends AbstractPHPModelGeneratorTestCase public function testEmptyNotIsInvalid(mixed $propertyValue): void { $this->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), <<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/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..b7fbb487 --- /dev/null +++ b/tests/Filter/FilterCompositionRuntimeTest.php @@ -0,0 +1,1192 @@ +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 + // ------------------------------------------------------------------------- + + /** + * 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()); + } + + // ------------------------------------------------------------------------- + // 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 + // ------------------------------------------------------------------------- + + /** + * 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()); + } + + // ------------------------------------------------------------------------- + // 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 new file mode 100644 index 00000000..ba910670 --- /dev/null +++ b/tests/Filter/FilterCompositionStaticTest.php @@ -0,0 +1,319 @@ + */ + 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/', + ], + // 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', + '/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/', + ], + // 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', + '/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 [ + // 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'], + // 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. + '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'], + ]; + } + + #[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', + )), + ); + } + + /** + * 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 + * 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..bb4c1944 --- /dev/null +++ b/tests/Filter/FilterTypeCompatibilityTest.php @@ -0,0 +1,658 @@ +format(DATE_ATOM); + } + + /** Void return type — used by the void-return-type InvalidFilterException test. */ + public static function filterWithVoidReturnType(string $value): void + { + } + + /** Never return type — used by the never-return-type InvalidFilterException test. */ + public static function filterWithNeverReturnType(string $value): never + { + throw new RuntimeException('never'); + } + + // ------------------------------------------------------------------------- + // mixed type hint (accept-all, no runtime guard) + // ------------------------------------------------------------------------- + + 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 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', + ), + ), + ); + } + + /** + * 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()); + } + + // ------------------------------------------------------------------------- + // 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/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/Objects/ArrayPropertyTest.php b/tests/Objects/ArrayPropertyTest.php index e0f0aca4..b3fdf9a3 100644 --- a/tests/Objects/ArrayPropertyTest.php +++ b/tests/Objects/ArrayPropertyTest.php @@ -483,115 +483,115 @@ public static function invalidTypedArrayDataProvider(): array '"string"', ['a', 'b', 1], << [ '"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], <<generateClassFromFile('AnnotatedDefinitionRef.json'); + + $object = new $className([]); + $this->assertNull($object->getLabel()); + + $object = new $className(['label' => 'hello']); + $this->assertSame('hello', $object->getLabel()); + } } diff --git a/tests/Objects/TupleArrayPropertyTest.php b/tests/Objects/TupleArrayPropertyTest.php index e6deaa4d..cb01e38d 100644 --- a/tests/Objects/TupleArrayPropertyTest.php +++ b/tests/Objects/TupleArrayPropertyTest.php @@ -124,72 +124,72 @@ public static function invalidTupleArrayDataProvider(): array 'not all elements invalid type' => [ [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), <<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 testTypeBranchObjectClassifiesAsInput(): void + { + // 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']), + ); + } + + public function testTypeBranchStringAndObjectBothClassifyAsInput(): void + { + // `type` always returns Input — multi-value type arrays are also Input. + $this->assertSame( + TypeSpace::Input, + $this->classifierForStringToDateTime()->classify(['type' => ['string', 'object']]), + ); + } + + public function testTypeBranchOutsideBothSpacesDefaultsToInput(): void + { + // `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']), + ); + } + + public function testTypeBranchIntegerClassifiesAsInputForIntToStringFilter(): void + { + $this->assertSame( + TypeSpace::Input, + $this->classifierForIntegerToString()->classify(['type' => 'integer']), + ); + } + + 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::Input, + $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 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( + 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 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' => [ + ['minProperties' => 1], + ['maxProperties' => 10], + ], + ]), + ); + } + + public function testNestedAllOfWithTypeObjectBranchAndOutputKeywordClassifiesAsMixed(): void + { + // type:object → Input (type always validates raw input). + // 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 always validates raw input). + // Both branches are Input → allOf is Input. + $this->assertSame( + TypeSpace::Input, + $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 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 always validates raw input). + // minProperties → Output. Mix → Mixed. + $this->assertSame( + TypeSpace::Mixed, + $this->classifierForStringToDateTime()->classify([ + 'anyOf' => [ + ['type' => 'object'], + ['minProperties' => 1], + ], + ]), + ); + } + + public function testNestedCompositionCombinedWithOtherKeywordsContributes(): void + { + // allOf [{type: object}] → Input (type:object is Input; only branch → allOf is Input). + // minLength → Input. Both Input → branch is Input. + $this->assertSame( + TypeSpace::Input, + $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()); + } +} 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/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/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/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/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/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/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/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/FilterCompositionRuntimeTest/AllOfPropertyWithMixedAcceptTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/AllOfPropertyWithMixedAcceptTransformingFilter.json new file mode 100644 index 00000000..9a3f2047 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/AllOfPropertyWithMixedAcceptTransformingFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "mixedAcceptDateTimeFilter", + "allOf": [ + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfEmptyBranch.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfEmptyBranch.json new file mode 100644 index 00000000..2a5bf70e --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfEmptyBranch.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + {} + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfInputSpace.json new file mode 100644 index 00000000..984b2dc6 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfInputSpace.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "allOf": [ + { + "minLength": 5 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfMixedSpaces.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfMixedSpaces.json new file mode 100644 index 00000000..d3bfd4a9 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfMixedSpaces.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "allOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minimum": 0 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfOutputOnly.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfOutputOnly.json new file mode 100644 index 00000000..6ec0649a --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfOutputOnly.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "type": [ + "string", + "integer" + ], + "allOf": [ + { + "minimum": 0 + }, + { + "maximum": 100 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfWithTrim.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfWithTrim.json new file mode 100644 index 00000000..1a62cd62 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAllOfWithTrim.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "trim", + "allOf": [ + { + "minLength": 5 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfInputOnly.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfInputOnly.json new file mode 100644 index 00000000..869a15ab --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfOutputSpace.json new file mode 100644 index 00000000..3984c94b --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionAnyOfOutputSpace.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "anyOf": [ + { + "minimum": 0, + "maximum": 10 + }, + { + "minimum": 20, + "maximum": 30 + } + ] + } + } +} 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/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/FilterCompositionRuntimeTest/FilterCompositionIfThenElseInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseInputSpace.json new file mode 100644 index 00000000..3471115c --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/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/FilterCompositionRuntimeTest/FilterCompositionIfThenElseOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseOutputSpace.json new file mode 100644 index 00000000..165401ab --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenElseOutputSpace.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "if": { + "minimum": 0 + }, + "then": { + "maximum": 100 + }, + "else": { + "minimum": -100 + } + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenOnlyInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionIfThenOnlyInputSpace.json new file mode 100644 index 00000000..4b4ac1c9 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/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/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/FilterCompositionRuntimeTest/FilterCompositionNotInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotInputSpace.json new file mode 100644 index 00000000..ec70cf49 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotInputSpace.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "not": { + "minLength": 5 + } + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotOutputSpace.json new file mode 100644 index 00000000..a2daa143 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionNotOutputSpace.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "not": { + "minimum": 0 + } + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfInputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfInputSpace.json new file mode 100644 index 00000000..5f17c652 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfInputSpace.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime", + "oneOf": [ + { + "minLength": 5 + }, + { + "maxLength": 3 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfOutputSpace.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfOutputSpace.json new file mode 100644 index 00000000..a50b4615 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionOneOfOutputSpace.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "oneOf": [ + { + "minimum": 0, + "maximum": 10 + }, + { + "minimum": 20, + "maximum": 30 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..9c0488bf --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/FilterCompositionRootInputSpaceConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "minLength": 1 + } + } + } + ] +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/MultiTypeFormatWithTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/MultiTypeFormatWithTransformingFilter.json new file mode 100644 index 00000000..72451a28 --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/MultiTypeFormatWithTransformingFilter.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "value": { + "type": [ + "string", + "integer" + ], + "format": "onlyNumbers", + "filter": "stringToInt", + "minimum": 0 + } + } +} diff --git a/tests/Schema/FilterCompositionRuntimeTest/ValidatorPriorityWithTransformingFilter.json b/tests/Schema/FilterCompositionRuntimeTest/ValidatorPriorityWithTransformingFilter.json new file mode 100644 index 00000000..8120859d --- /dev/null +++ b/tests/Schema/FilterCompositionRuntimeTest/ValidatorPriorityWithTransformingFilter.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "value": { + "type": [ + "string", + "integer" + ], + "filter": "stringToInt", + "pattern": "^\\d+$", + "minimum": 0 + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfContradictoryTypes.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfContradictoryTypes.json new file mode 100644 index 00000000..b4840198 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfContradictoryTypes.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "allOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilter.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilter.json new file mode 100644 index 00000000..3e351d13 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfDeadFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "stringToInt", + "allOf": [ + { + "type": "integer" + } + ] + } + } +} 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/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/FilterCompositionStaticTest/FilterCompositionAllOfInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfInputOnly.json new file mode 100644 index 00000000..406aa517 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "minLength": 1 + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfMixedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfMixedBranch.json new file mode 100644 index 00000000..7fe948b1 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfMixedBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "minLength": 5, + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfObjectBranchOutput.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfObjectBranchOutput.json new file mode 100644 index 00000000..a906dd32 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAllOfObjectBranchOutput.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "type": "object" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfCrossSpace.json new file mode 100644 index 00000000..7fde7624 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfCrossSpace.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json new file mode 100644 index 00000000..869a15ab --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfMixedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfMixedBranch.json new file mode 100644 index 00000000..bad5b29c --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionAnyOfMixedBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "anyOf": [ + { + "minLength": 5, + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInAnyOfBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInAnyOfBranch.json new file mode 100644 index 00000000..2881e0f9 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInAnyOfBranch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "anyOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranch.json new file mode 100644 index 00000000..c9c3d5c5 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranch.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranchNoOuterFilter.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranchNoOuterFilter.json new file mode 100644 index 00000000..8241bf3d --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInBranchNoOuterFilter.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "allOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInIfThenElseIfThenElseBranch.json new file mode 100644 index 00000000..6fec336b --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/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/FilterCompositionStaticTest/FilterCompositionFilterInNestedBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedBranch.json new file mode 100644 index 00000000..1aa11501 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNestedBranch.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "allOf": [ + { + "anyOf": [ + { + "filter": "trim" + } + ] + } + ] + } + } +} 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/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/FilterCompositionFilterInNotBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNotBranch.json new file mode 100644 index 00000000..f76cd2b2 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInNotBranch.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "not": { + "filter": "trim" + } + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInOneOfBranch.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInOneOfBranch.json new file mode 100644 index 00000000..7bdb6897 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionFilterInOneOfBranch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "oneOf": [ + { + "filter": "trim" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfElseOnlyInputSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfElseOnlyInputSpace.json new file mode 100644 index 00000000..b924d92f --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/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/FilterCompositionStaticTest/FilterCompositionIfThenElseCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseCrossSpace.json new file mode 100644 index 00000000..6c6ef28c --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseCrossSpace.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "if": { + "type": "string" + }, + "then": { + "minProperties": 1 + } + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenElseInputOnly.json new file mode 100644 index 00000000..02790dc5 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/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/FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionIfThenOnlyInputSpace.json new file mode 100644 index 00000000..4b4ac1c9 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/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/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/FilterCompositionNotMixed.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionNotMixed.json new file mode 100644 index 00000000..e7892b20 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionNotMixed.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "not": { + "minLength": 5, + "minProperties": 1 + } + } + } +} 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/FilterCompositionStaticTest/FilterCompositionOneOfCrossSpace.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfCrossSpace.json new file mode 100644 index 00000000..55b7ac89 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfCrossSpace.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "oneOf": [ + { + "minLength": 1 + }, + { + "minProperties": 1 + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfInputOnly.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfInputOnly.json new file mode 100644 index 00000000..cb7a265f --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionOneOfInputOnly.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime", + "oneOf": [ + { + "minLength": 1 + }, + { + "type": "integer" + } + ] + } + } +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..c5430434 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootAnyOfConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "anyOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } + ] +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootBranchWithFilterInProperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootBranchWithFilterInProperty.json new file mode 100644 index 00000000..dd7399e0 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootBranchWithFilterInProperty.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "filter": "trim" + } + } + } + ] +} diff --git a/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..a6a7454e --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "allOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + } + ] +} 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/FilterCompositionRootIfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..9970a0d2 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootIfConstrainsFilteredSubproperty.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "if": { + "properties": { + "filteredProperty": { + "minProperties": 1 + } + } + }, + "then": {} +} 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/FilterCompositionStaticTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..b7763861 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootNotConstrainsFilteredSubproperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "not": { + "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/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json new file mode 100644 index 00000000..f34c8e81 --- /dev/null +++ b/tests/Schema/FilterCompositionStaticTest/FilterCompositionRootOneOfConstrainsFilteredSubproperty.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "filter": "dateTime" + } + }, + "oneOf": [ + { + "properties": { + "filteredProperty": { + "minProperties": 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/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/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/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/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" + } + } +} 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/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/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/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" + } + } +} 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/TransformingFilterTest/FilterChain.json b/tests/Schema/TransformingFilterTest/FilterChain.json new file mode 100644 index 00000000..e4e3b585 --- /dev/null +++ b/tests/Schema/TransformingFilterTest/FilterChain.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "filteredProperty": { + "type": "string", + "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