diff --git a/src/BaseInputFilter.php b/src/BaseInputFilter.php index 3cf1665d..28848897 100644 --- a/src/BaseInputFilter.php +++ b/src/BaseInputFilter.php @@ -22,6 +22,7 @@ use function is_int; use function is_iterable; use function is_string; +use function iterator_to_array; use function sprintf; /** @@ -218,6 +219,37 @@ public function isValid(array|null $context = null): bool return $this->validateInputs($inputs, $this->data, $context); } + public function validate(iterable|null $data, array|null $context = null): InputFilterValidationResult + { + $data = iterator_to_array($data ?? []); + $context ??= $data; + $inputs = $this->validationGroup ?? array_keys($this->inputs); + $results = []; + foreach ($inputs as $name) { + $input = $this->inputs[$name]; + /** @psalm-var mixed $value */ + $value = $data[$name] ?? null; + + if ($input instanceof InputFilterInterface) { + $value = is_iterable($value) ? iterator_to_array($value) : []; + + $result = $input->validate($value, $context); + assert($result instanceof InputFilterValidationResult); + $results[$name] = $result; + + continue; + } + + $result = $input->validate($value, $context); + $results[$name] = $result; + if (! $result->valid() && $input->breakOnFailure()) { + break; + } + } + + return new InputFilterValidationResult($results); + } + /** * Validate a set of inputs against the current data * diff --git a/src/Input.php b/src/Input.php index c1ee1646..9c56441f 100644 --- a/src/Input.php +++ b/src/Input.php @@ -12,6 +12,7 @@ use Laminas\Validator\ValidatorChain; use Laminas\Validator\ValidatorChainInterface; +use function array_merge; use function assert; use function class_exists; use function get_debug_type; @@ -277,6 +278,62 @@ public function merge(InputInterface $input): static return $this; } + public function validate(mixed $value, array $context): InputValidationResult + { + $isEmpty = $value === '' || $value === null || $value === []; + /** @psalm-var mixed $resolvedValue */ + $resolvedValue = $isEmpty && $this->hasFallback ? $this->fallbackValue : $value; + /** + * Behaviour Change: The fallback value is filtered where previously it was returned verbatim + * + * @psalm-var mixed $filteredValue + */ + $filteredValue = $this->filterChain->filter($resolvedValue); + + if ( + // We have a valid result when a value is empty, but a fallback is present + ($isEmpty && $this->hasFallback) + || + // Empty values are valid when they are not required and validation should not continue for empty values + ($isEmpty && ! $this->required && ! $this->continueIfEmpty) + || + // Empty is valid when allowEmpty is true and continue if empty is false + ($isEmpty && $this->allowEmpty && ! $this->continueIfEmpty) + ) { + return InputValidationResult::pass($this->name, $value, $filteredValue); + } + + $isValid = $this->validatorChain->isValid($filteredValue, $context); + $messages = $this->validatorChain->getMessages(); + + /** + * An empty value should not be considered valid in this situation, regardless + * of what the validator chain says. + * Instead of mutating the chain, fail validation with a validation failure message that advises the user to + * customise the validation chain with a NotEmpty validator. + */ + if ($isValid && $isEmpty) { + $isValid = false; + $messages = array_merge([ + InputInterface::EMPTY_FAILURE_VALIDATION_KEY => sprintf( + 'The value for "%s" was empty, but its configuration prohibits an empty value. ' + . 'Prepend a "NotEmpty" validator to this input’s chain in order to customise ' + . 'this validation failure message', + $this->name, + ), + ], $messages); + } + + return $isValid + ? InputValidationResult::pass($this->name, $value, $filteredValue) + : InputValidationResult::fail( + $this->name, + $value, + $filteredValue, + new ErrorMessages($messages), + ); + } + /** @inheritDoc */ public function isValid(array|null $context = null): bool { @@ -340,8 +397,7 @@ public function getMessages(): ErrorMessages return new ErrorMessages([]); } - $validator = $this->getValidatorChain(); - return new ErrorMessages($validator->getMessages()); + return new ErrorMessages($this->validatorChain->getMessages()); } protected function injectNotEmptyValidator(): void diff --git a/src/InputFilterInterface.php b/src/InputFilterInterface.php index 035509db..edb373ae 100644 --- a/src/InputFilterInterface.php +++ b/src/InputFilterInterface.php @@ -87,6 +87,14 @@ public function setData(iterable|null $data): static; */ public function isValid(array|null $context = null): bool; + /** + * Is the data set valid? + * + * @param iterable|null $data + * @param array|null $context + */ + public function validate(iterable|null $data, array|null $context = null): InputFilterValidationResult; + /** * Provide a list of one or more elements indicating the complete set to validate * diff --git a/src/InputFilterValidationResult.php b/src/InputFilterValidationResult.php new file mode 100644 index 00000000..82510762 --- /dev/null +++ b/src/InputFilterValidationResult.php @@ -0,0 +1,82 @@ + $results */ + public function __construct( + public array $results, + ) { + } + + public function valid(): bool + { + foreach ($this->results as $result) { + if ($result->valid()) { + continue; + } + + return false; + } + + return true; + } + + public function getMessages(): ErrorMessages + { + $messages = []; + foreach ($this->results as $key => $result) { + $name = $this->keyName($key, $result); + $messages[$name] = $result->getMessages(); + } + + return new ErrorMessages($messages); + } + + /** @psalm-suppress MixedAssignment */ + public function rawValue(): array + { + $value = []; + foreach ($this->results as $key => $result) { + $name = $this->keyName($key, $result); + $value[$name] = $result->rawValue(); + } + + return $value; + } + + /** @psalm-suppress MixedAssignment */ + public function value(): array + { + $value = []; + foreach ($this->results as $key => $result) { + $name = $this->keyName($key, $result); + $value[$name] = $result->value(); + } + + return $value; + } + + /** @throws InputNotFoundException */ + public function resultFor(string|int $key): ValidationResultInterface + { + $result = $this->results[$key] ?? null; + if (! $result instanceof ValidationResultInterface) { + throw InputNotFoundException::forKey($key); + } + + return $result; + } + + private function keyName(string|int $key, ValidationResultInterface $result): string|int + { + return $result instanceof InputValidationResult + ? $result->name() + : $key; + } +} diff --git a/src/InputInterface.php b/src/InputInterface.php index 7a4d6056..f68cac2a 100644 --- a/src/InputInterface.php +++ b/src/InputInterface.php @@ -9,6 +9,9 @@ interface InputInterface { + /** @internal */ + public const EMPTY_FAILURE_VALIDATION_KEY = '__inputEmptyValueFailure'; + public function setValue(mixed $value): static; public function allowEmpty(): bool; @@ -33,6 +36,9 @@ public function getValue(): mixed; /** @param array|null $context */ public function isValid(array|null $context = null): bool; + /** @param array $context */ + public function validate(mixed $value, array $context): InputValidationResult; + public function getMessages(): ErrorMessages; public function continueIfEmpty(): bool; diff --git a/src/InputValidationResult.php b/src/InputValidationResult.php new file mode 100644 index 00000000..993d21ac --- /dev/null +++ b/src/InputValidationResult.php @@ -0,0 +1,59 @@ +name; + } + + public function valid(): bool + { + return $this->valid; + } + + public function getMessages(): ErrorMessages + { + return $this->errorMessages; + } + + public function rawValue(): mixed + { + return $this->rawValue; + } + + public function value(): mixed + { + return $this->value; + } +} diff --git a/src/ValidationResultInterface.php b/src/ValidationResultInterface.php new file mode 100644 index 00000000..e4eb158a --- /dev/null +++ b/src/ValidationResultInterface.php @@ -0,0 +1,16 @@ +> */ + public static function specList(): array + { + return [ + BasicFilterAndValidate::class, + ]; + } + + /** @return iterable */ + public static function specDataProvider(): iterable + { + foreach (self::specList() as $class) { + $parts = explode('\\', $class); + $name = array_pop($parts); + + $spec = new $class(); + + foreach ($spec->expectations() as $key => $expectation) { + yield sprintf('%s - %s', $name, $key) => [ + $spec->spec(), + $expectation, + ]; + } + } + } + + /** @param InputFilterSpecification $spec */ + #[DataProvider('specDataProvider')] + public function testValidate(array $spec, Expectation $expectation): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $inputFilter = $factory->createInputFilter($spec); + + $result = $inputFilter->validate($expectation->input); + + self::assertSame($expectation->valid, $result->valid()); + self::assertSame($expectation->expect, $result->value()); + self::assertValidEntry($result, $expectation->validKeys); + self::assertInvalidEntry($result, $expectation->invalidKeys); + } + + /** @param list $validKeys */ + private static function assertValidEntry(ValidationResultInterface $result, array $validKeys): void + { + foreach ($validKeys as $keys) { + $itemResult = self::fetchResultFromDotSeparatedKey($keys, $result); + self::assertTrue( + $itemResult->valid(), + sprintf( + 'The input `%s` was expected to be valid, but it was invalid', + $keys, + ), + ); + } + } + + /** @param list $invalidKeys */ + private static function assertInvalidEntry(ValidationResultInterface $result, array $invalidKeys): void + { + foreach ($invalidKeys as $keys) { + $itemResult = self::fetchResultFromDotSeparatedKey($keys, $result); + self::assertFalse( + $itemResult->valid(), + sprintf( + 'The input `%s` was expected to be invalid, but it was valid', + $keys, + ), + ); + } + } + + private static function fetchResultFromDotSeparatedKey( + string $key, + ValidationResultInterface $result, + ): ValidationResultInterface { + $return = $result; + foreach (explode('.', $key) as $itemKey) { + self::assertInstanceOf(InputFilterValidationResult::class, $return); + $return = $return->resultFor($itemKey); + } + + return $return; + } +} diff --git a/test/InputTest.php b/test/InputTest.php index adc8e672..095bd37f 100644 --- a/test/InputTest.php +++ b/test/InputTest.php @@ -32,9 +32,6 @@ use const JSON_THROW_ON_ERROR; -/** - * @psalm-suppress DeprecatedMethod - */ final class InputTest extends TestCase { private const EMPTY_ERROR_MESSAGE_KEY = 'isEmpty'; @@ -403,7 +400,7 @@ public function testValidationOperatesOnFilteredValue(): void $input = $this->createInput( 'foo', TestHelper::createFilterChainFixture($valueRaw, $valueFiltered), - TestHelper::createValidatorChain($valueFiltered, true) + TestHelper::createValidatorChain($valueFiltered, true), ); $input->setValue($valueRaw); diff --git a/test/InputValidateMethodTest.php b/test/InputValidateMethodTest.php new file mode 100644 index 00000000..3cd2c0d9 --- /dev/null +++ b/test/InputValidateMethodTest.php @@ -0,0 +1,357 @@ + */ + public static function validateDataProviderWithEmptyValues(): iterable + { + foreach (['Empty String' => '', 'null' => null, 'Empty Array' => []] as $valueKey => $value) { + yield from [ + 'Req: T, Allow F, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => false, + ], + $value, + false, + ], + 'Req: T, Allow T, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => true, + 'continue_if_empty' => false, + ], + $value, + true, + ], + 'Req: T, Allow T, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => true, + 'continue_if_empty' => true, + ], + $value, + false, + ], + 'Req: F, Allow T, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => true, + 'continue_if_empty' => true, + ], + $value, + false, + ], + 'Req: F, Allow F, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => false, + 'continue_if_empty' => true, + ], + $value, + false, + ], + 'Req: F, Allow F, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => false, + 'continue_if_empty' => false, + ], + $value, + true, + ], + ]; + } + } + + /** @return iterable */ + public static function validateDataProviderWithNonEmptyValues(): iterable + { + $nonEmptyValues = [ + 'Non-Empty String' => 'Kermit', + 'Int 0' => 0, + 'Int 1' => 1, + 'Float 0.0' => 0.0, + 'Float 1.0' => 1.0, + 'Zero String' => '0', + 'Non Empty Array' => ['Miss', 'Piggy'], + ]; + + foreach ($nonEmptyValues as $valueKey => $value) { + yield from [ + 'Req: T, Allow F, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => false, + ], + $value, + true, + ], + 'Req: T, Allow T, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => true, + 'continue_if_empty' => false, + ], + $value, + true, + ], + 'Req: T, Allow T, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => true, + 'continue_if_empty' => true, + ], + $value, + true, + ], + 'Req: F, Allow T, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => true, + 'continue_if_empty' => true, + ], + $value, + true, + ], + 'Req: F, Allow F, Cont: T: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => false, + 'continue_if_empty' => true, + ], + $value, + true, + ], + 'Req: F, Allow F, Cont: F: (' . $valueKey . ')' => [ + [ + 'name' => 'foo', + 'required' => false, + 'allow_empty' => false, + 'continue_if_empty' => false, + ], + $value, + true, + ], + ]; + } + } + + /** @param InputSpecification $spec */ + #[DataProvider('validateDataProviderWithEmptyValues')] + #[DataProvider('validateDataProviderWithNonEmptyValues')] + public function testValidateWithEmptyValues(array $spec, mixed $inputData, bool $valid): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput($spec); + + $name = $spec['name'] ?? null; + self::assertIsString($name); + $result = $input->validate($inputData, [$name => $inputData]); + + self::assertSame($valid, $result->valid()); + self::assertSame($inputData, $result->rawValue(), 'Raw value should be identical to the input'); + self::assertSame($inputData, $result->value(), 'Filtered value should be identical to the input'); + $expectedErrorCount = $valid ? 0 : 1; + self::assertCount($expectedErrorCount, $result->getMessages()); + self::assertSame($name, $result->name()); + } + + public function testEmptyValidationFailureMessageIsPresentWhenEmptyValuesPassValidation(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'Gonzo', + 'required' => false, + 'allow_empty' => true, + 'continue_if_empty' => true, + ]); + + $result = $input->validate('', ['Gonzo' => '']); + self::assertFalse($result->valid()); + self::assertCount(1, $result->getMessages()); + self::assertArrayHasKey(InputInterface::EMPTY_FAILURE_VALIDATION_KEY, $result->getMessages()->toArray()); + self::assertSame( + 'The value for "Gonzo" was empty, but its configuration prohibits an empty value. ' + . 'Prepend a "NotEmpty" validator to this input’s chain in order to customise ' + . 'this validation failure message', + $result->getMessages()[InputInterface::EMPTY_FAILURE_VALIDATION_KEY], + ); + } + + public function testEmptyValidationFailureMessageIsNotPresentWhenNotEmptyValidatorIsPresent(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'Gonzo', + 'required' => false, + 'allow_empty' => true, + 'continue_if_empty' => true, + 'validators' => [ + ['name' => NotEmpty::class], + ], + ]); + + $result = $input->validate('', ['Gonzo' => '']); + self::assertFalse($result->valid()); + self::assertCount(1, $result->getMessages()); + self::assertArrayHasKey(NotEmpty::IS_EMPTY, $result->getMessages()->toArray()); + self::assertArrayNotHasKey(InputInterface::EMPTY_FAILURE_VALIDATION_KEY, $result->getMessages()->toArray()); + } + + public function testFiltersAreAppliedToTheInput(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => false, + 'filters' => [ + ['name' => StringTrim::class], + ], + ]); + + $result = $input->validate(' fred ', ['foo' => ' fred ']); + self::assertTrue($result->valid()); + self::assertSame(' fred ', $result->rawValue()); + self::assertSame('fred', $result->value()); + self::assertCount(0, $result->getMessages()); + } + + public function testFallbackValueIsFilteredWhenGiven(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => true, + 'fallback_value' => ' Marge ', + 'filters' => [ + ['name' => StringTrim::class], + ], + ]); + + $result = $input->validate('', ['foo' => '']); + self::assertTrue($result->valid()); + self::assertSame('', $result->rawValue()); + self::assertSame('Marge', $result->value()); + self::assertCount(0, $result->getMessages()); + } + + public function testFallbackValueIsNotValidatedWhenGiven(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => true, + 'fallback_value' => 'Marge', + 'validators' => [ + [ + 'name' => Regex::class, + 'options' => [ + 'pattern' => '/^[0-9]+$/', + ], + ], + ], + ]); + + $result = $input->validate('', ['foo' => '']); + self::assertTrue($result->valid()); + self::assertSame('', $result->rawValue()); + self::assertSame('Marge', $result->value()); + self::assertCount(0, $result->getMessages()); + } + + public function testAnyFailingValidationDoesNotTriggerBuiltInEmptyChecks(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => true, + 'validators' => [ + [ + 'name' => Regex::class, + 'options' => [ + 'pattern' => '/^[0-9]+$/', + ], + ], + ], + ]); + + $result = $input->validate('foo', ['foo' => 'foo']); + self::assertFalse($result->valid()); + self::assertSame('foo', $result->rawValue()); + self::assertSame('foo', $result->value()); + self::assertCount(1, $result->getMessages()); + self::assertArrayHasKey(Regex::NOT_MATCH, $result->getMessages()->toArray()); + } + + public function testValidateMethodDoesNotAffectInternals(): void + { + $factory = TestHelper::getContainer()->get(Factory::class); + $input = $factory->createInput([ + 'name' => 'foo', + 'required' => true, + 'allow_empty' => false, + 'continue_if_empty' => true, + 'validators' => [ + [ + 'name' => Regex::class, + 'options' => [ + 'pattern' => '/^[0-9]+$/', + ], + ], + ], + ]); + self::assertInstanceOf(Input::class, $input); + + $result = $input->validate('foo', ['foo' => 'foo']); + self::assertFalse($result->valid()); + $this->assertInternalsAreNotMutated($input); + + $result = $input->validate('123', ['foo' => '123']); + self::assertTrue($result->valid()); + $this->assertInternalsAreNotMutated($input); + } + + private function assertInternalsAreNotMutated(Input $input): void + { + self::assertNull($input->getValue()); + self::assertNull($input->getRawValue()); + self::assertFalse($input->hasValue()); + } +} diff --git a/test/Spec/BasicFilterAndValidate.php b/test/Spec/BasicFilterAndValidate.php new file mode 100644 index 00000000..ce031b14 --- /dev/null +++ b/test/Spec/BasicFilterAndValidate.php @@ -0,0 +1,138 @@ + InputFilter::class, + 'a' => [ + 'name' => 'a', + 'required' => true, + 'filters' => [ + ['name' => StringTrim::class], + ['name' => ToNull::class], + ], + 'validators' => [ + ['name' => NotEmpty::class], + ['name' => Ip::class], + ], + ], + 'b' => [ + 'type' => InputFilter::class, + 'c' => [ + 'name' => 'c', + 'required' => true, + 'filters' => [ + ['name' => StringTrim::class], + ['name' => ToNull::class], + ], + 'validators' => [ + ['name' => NotEmpty::class], + ['name' => Ip::class], + ], + ], + 'd' => [ + 'name' => 'd', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + ['name' => StringTrim::class], + ['name' => ToNull::class], + ], + 'validators' => [], + ], + ], + ]; + } + + /** @inheritDoc */ + public function expectations(): array + { + return [ + 'Valid Payload' => new Expectation( + [ + 'a' => ' 123.123.123.123 ', + 'b' => [ + 'c' => '1.1.1.1', + 'd' => '', + ], + ], + true, + [], + [ + 'a', + 'b.c', + 'b.d', + ], + [ + 'a' => '123.123.123.123', + 'b' => [ + 'c' => '1.1.1.1', + 'd' => null, + ], + ], + ), + 'Empty Payload' => new Expectation( + [], + false, + [ + 'a', + 'b.c', + ], + [ + 'b.d', + ], + [ + 'a' => null, + 'b' => [ + 'c' => null, + 'd' => null, + ], + ], + ), + 'Non-empty, Invalid' => new Expectation( + [ + 'a' => 'fred', + 'b' => [ + 'c' => 'wilma', + 'd' => 'barney', + ], + ], + false, + [ + 'a', + 'b.c', + ], + [ + 'b.d', + ], + [ + 'a' => 'fred', + 'b' => [ + 'c' => 'wilma', + 'd' => 'barney', + ], + ], + ), + ]; + } +} diff --git a/test/Spec/Expectation.php b/test/Spec/Expectation.php new file mode 100644 index 00000000..817f0326 --- /dev/null +++ b/test/Spec/Expectation.php @@ -0,0 +1,23 @@ + $input + * @param list $invalidKeys + * @param list $validKeys + * @param array $expect + */ + public function __construct( + public iterable $input, + public bool $valid, + public array $invalidKeys, + public array $validKeys, + public array $expect, + ) { + } +} diff --git a/test/Spec/InputFilterTestSpecInterface.php b/test/Spec/InputFilterTestSpecInterface.php new file mode 100644 index 00000000..bc3db569 --- /dev/null +++ b/test/Spec/InputFilterTestSpecInterface.php @@ -0,0 +1,27 @@ + */ + public function expectations(): array; +} diff --git a/test/TestAsset/InputInterfaceImplementation.php b/test/TestAsset/InputInterfaceImplementation.php index cdff9160..6d23595b 100644 --- a/test/TestAsset/InputInterfaceImplementation.php +++ b/test/TestAsset/InputInterfaceImplementation.php @@ -4,10 +4,12 @@ namespace LaminasTest\InputFilter\TestAsset; +use Exception; use Laminas\Filter\FilterChainInterface; use Laminas\InputFilter\ErrorMessages; use Laminas\InputFilter\InputFilterInterface; use Laminas\InputFilter\InputInterface; +use Laminas\InputFilter\InputValidationResult; use Laminas\Validator\ValidatorChainInterface; /** @psalm-import-type InputSpecification from InputFilterInterface */ @@ -116,4 +118,9 @@ public function hasFallback(): bool { return $this->hasFallback; } + + public function validate(mixed $value, array $context): InputValidationResult + { + throw new Exception('Not implemented'); + } } diff --git a/test/TestAsset/InputInterfaceStub.php b/test/TestAsset/InputInterfaceStub.php index 0b7d8b89..eb7c91c1 100644 --- a/test/TestAsset/InputInterfaceStub.php +++ b/test/TestAsset/InputInterfaceStub.php @@ -7,6 +7,7 @@ use Exception; use Laminas\InputFilter\ErrorMessages; use Laminas\InputFilter\InputInterface; +use Laminas\InputFilter\InputValidationResult; use function func_get_arg; use function func_num_args; @@ -115,4 +116,9 @@ public function hasFallback(): bool { return false; } + + public function validate(mixed $value, array $context): InputValidationResult + { + throw new Exception('Not implemented'); + } }