From 663719b9aab2ffccd74fcaaa77c9c9b48af1d61f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 25 Sep 2026 11:32:32 +0300 Subject: [PATCH 1/3] Fix #822: Validate rules inside `StopOnError`, `Composite` and `AnyRule` in the current scope --- CHANGELOG.md | 7 +- docs/guide/en/creating-custom-rules.md | 9 +- src/Rule/AnyRuleHandler.php | 2 +- src/Rule/CompositeHandler.php | 2 +- src/Rule/StopOnErrorHandler.php | 2 +- src/ValidationContext.php | 46 ++++++ src/Validator.php | 61 +++++++- tests/Rule/AnyRuleTest.php | 121 ++++++++++++++++ tests/Rule/CompositeTest.php | 117 ++++++++++++++++ tests/Rule/StopOnErrorTest.php | 118 ++++++++++++++++ .../Data/PostValidationHookCounter.php | 18 +++ tests/ValidationContextTest.php | 132 +++++++++++++++++- 12 files changed, 620 insertions(+), 15 deletions(-) create mode 100644 tests/Support/Data/PostValidationHookCounter.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e666262b..758c42a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Yii Validator Change Log -## 2.6.2 under development +## 2.7.0 under development -- no changes in this release. +- New #822: Add `ValidationContext::validateInCurrentScope()` method to validate a value in the current scope (@vjik) +- Bug #822: Validate rules inside `StopOnError`, `Composite` and `AnyRule` in the current scope, same as without these + wrappers: keep the current data set and property in validation context, don't iterate over public properties of + an object value and don't call its `PostValidationHookInterface::processValidationResult()` (@vjik) ## 2.6.1 September 22, 2026 diff --git a/docs/guide/en/creating-custom-rules.md b/docs/guide/en/creating-custom-rules.md index e7f284a12..082974ee5 100644 --- a/docs/guide/en/creating-custom-rules.md +++ b/docs/guide/en/creating-custom-rules.md @@ -417,14 +417,19 @@ final class OnHandler implements RuleHandlerInterface return $this->isSatisfied($rule, $scenario) // With active scenario, perform the validation. - ? $context->validate($value, $rule->getRules()) + ? $context->validateInCurrentScope($value, $rule->getRules()) // With all other scenarios, skip the validation. : new Result(); } } ``` -This code snippet is taken from [Yii Validator Scenarios] extension by [Sergei Predvoditelev]. Read more in [Scenarios] +Note that `$context->validateInCurrentScope()` is used here instead of `$context->validate()`. Use +`validateInCurrentScope()` for rules grouping other rules, when the inner rules must be applied to the same property +as the wrapping rule: the data set, the property and other context data are kept as is. Use `validate()` for +validating other data (for example, a nested object or array) as a separate data set. + +This code snippet is based on [Yii Validator Scenarios] extension by [Sergei Predvoditelev]. Read more in [Scenarios] section. ## Making an extension diff --git a/src/Rule/AnyRuleHandler.php b/src/Rule/AnyRuleHandler.php index 287ced806..c85004549 100644 --- a/src/Rule/AnyRuleHandler.php +++ b/src/Rule/AnyRuleHandler.php @@ -23,7 +23,7 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c } foreach ($rule->getRules() as $relatedRule) { - $result = $context->validate($value, $relatedRule); + $result = $context->validateInCurrentScope($value, $relatedRule); if ($result->isValid()) { return $result; } diff --git a/src/Rule/CompositeHandler.php b/src/Rule/CompositeHandler.php index cd04a5119..4069e1179 100644 --- a/src/Rule/CompositeHandler.php +++ b/src/Rule/CompositeHandler.php @@ -21,6 +21,6 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c throw new UnexpectedRuleException(Composite::class, $rule); } - return $context->validate($value, $rule->getRules()); + return $context->validateInCurrentScope($value, $rule->getRules()); } } diff --git a/src/Rule/StopOnErrorHandler.php b/src/Rule/StopOnErrorHandler.php index d9227c336..1520f72bb 100644 --- a/src/Rule/StopOnErrorHandler.php +++ b/src/Rule/StopOnErrorHandler.php @@ -23,7 +23,7 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c } foreach ($rule->getRules() as $relatedRule) { - $result = $context->validate($value, $relatedRule); + $result = $context->validateInCurrentScope($value, $relatedRule); if (!$result->isValid()) { return $result; } diff --git a/src/ValidationContext.php b/src/ValidationContext.php index c3e1200e1..4c9402907 100644 --- a/src/ValidationContext.php +++ b/src/ValidationContext.php @@ -4,6 +4,7 @@ namespace Yiisoft\Validator; +use Closure; use RuntimeException; use Yiisoft\Arrays\ArrayHelper; use Yiisoft\Strings\StringHelper; @@ -38,6 +39,14 @@ final class ValidationContext */ private ?ValidatorInterface $validator = null; + /** + * @var Closure|null A callback validating a value according to a rule or a list of rules without changing the + * current scope of the context. `null` means context data was not set with {@see setContextDataOnce()} yet. + * + * @psalm-var (Closure(mixed, callable|iterable|RuleInterface, ValidationContext): Result)|null + */ + private ?Closure $currentScopeValidator = null; + /** * @var mixed The raw validated data. `null` means context data was not set with {@see setContextDataOnce()} yet. */ @@ -90,6 +99,10 @@ public function __construct( * is specified via {@see setPropertyTranslator()}, it will be used instead. * @param mixed $rawData The raw validated data. * @param DataSetInterface $dataSet Global data set ({@see $globalDataSet}). + * @param Closure $currentScopeValidator A callback validating a value according to a rule or a list of rules + * without changing the current scope of the context ({@see validateInCurrentScope()}). + * + * @psalm-param Closure(mixed, callable|iterable|RuleInterface, ValidationContext): Result $currentScopeValidator * * @internal * @@ -100,12 +113,14 @@ public function setContextDataOnce( PropertyTranslatorInterface $propertyTranslator, mixed $rawData, DataSetInterface $dataSet, + Closure $currentScopeValidator, ): self { if ($this->validator !== null) { return $this; } $this->validator = $validator; + $this->currentScopeValidator = $currentScopeValidator; $this->defaultPropertyTranslator = $propertyTranslator; $this->rawData = $rawData; $this->globalDataSet = $dataSet; @@ -173,6 +188,36 @@ public function validate(mixed $data, callable|iterable|object|string|null $rule return $result; } + /** + * Validate a value according to a rule or a list of rules in the current scope: the data set, the property and + * other context data are kept as is. Useful for rules grouping other rules, such as {@see StopOnError}. + * + * The value is usually the one currently validated, but it could be another one as well (for example, a modified + * current value). In the latter case {@see PARAMETER_VALUE_AS_ARRAY} is not used for the value. Note that + * the context still describes the current property, for example, {@see isPropertyMissing()} checks the current + * property, not the passed value. + * + * @param mixed $value The validated value. + * @param callable|iterable|RuleInterface $rules A single rule or a list of rules to apply. Keys of the list are + * ignored: all rules are applied to the passed value. + * + * @psalm-param callable|RuleInterface|iterable $rules + * + * @throws RuntimeException If validator is not set in validation context. + * + * @return Result Validation result. + */ + public function validateInCurrentScope(mixed $value, callable|iterable|RuleInterface $rules): Result + { + $this->requireValidator(); + + $currentParameters = $this->parameters; + $result = ($this->currentScopeValidator)($value, $rules, $this); + $this->parameters = $currentParameters; + + return $result; + } + /** * Get the raw validated data. * @@ -331,6 +376,7 @@ public function isPropertyMissing(): bool * Ensure that validator is set in validation context. * * @psalm-assert ValidatorInterface $this->validator + * @psalm-assert Closure $this->currentScopeValidator * @psalm-assert DataSetInterface $this->globalDataSet * * @throws RuntimeException If validator is not set in validation context. diff --git a/src/Validator.php b/src/Validator.php index c33a28e6c..906be08e1 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -16,11 +16,13 @@ use Yiisoft\Validator\Helper\DataSetNormalizer; use Yiisoft\Validator\Helper\MessageProcessor; use Yiisoft\Validator\Helper\RulesNormalizer; +use Yiisoft\Validator\Helper\RulesNormalizerIterator; use Yiisoft\Validator\Helper\SkipOnEmptyNormalizer; use Yiisoft\Validator\RuleHandlerResolver\SimpleRuleHandlerContainer; use function extension_loaded; use function is_int; +use function is_iterable; use function is_string; /** @@ -123,7 +125,7 @@ public function validate( ?ValidationContext $context = null, ): Result { $dataSet = DataSetNormalizer::normalize($data); - $originalData = $dataSet instanceof DataWrapperInterface ? $dataSet->getSource() : $data; + $originalData = $this->getOriginalData($dataSet); $rules = RulesNormalizer::normalize( $rules, @@ -137,7 +139,13 @@ public function validate( $context ??= new ValidationContext(); $context - ->setContextDataOnce($this, $defaultPropertyTranslator, $data, $dataSet) + ->setContextDataOnce( + $this, + $defaultPropertyTranslator, + $data, + $dataSet, + $this->validateInCurrentScope(...), + ) ->setDataSet($dataSet); $result = new Result(); @@ -167,7 +175,7 @@ public function validate( $result->addErrorWithoutPostProcessing( $this->messageProcessor->process($error), $error->getParameters(), - $error->getValuePath(), + is_string($property) ? [$property, ...$error->getValuePath()] : $error->getValuePath(), ); } } @@ -214,9 +222,6 @@ private function validateInternal(mixed $value, iterable $rules, ValidationConte foreach ($ruleResult->getErrors() as $error) { $valuePath = $error->getValuePath(); - if ($context->getProperty() !== null) { - $valuePath = [$context->getProperty(), ...$valuePath]; - } match ($error->getMessageProcessing()) { Error::MESSAGE_TRANSLATE => $compoundResult->addError($error->getMessage(), $error->getParameters(), $valuePath), Error::MESSAGE_FORMAT => $compoundResult->addErrorWithFormatOnly( @@ -235,6 +240,50 @@ private function validateInternal(mixed $value, iterable $rules, ValidationConte return $compoundResult; } + /** + * Validates a value according to a rule or a list of rules without changing the current scope of the validation + * context ({@see ValidationContext::validateInCurrentScope()}). + * + * @param mixed $value The validated value of any type. + * @param callable|iterable|RuleInterface $rules A single rule or a list of rules to apply. + * @param ValidationContext $context Validation context. + * + * @return Result The result of validation. + */ + private function validateInCurrentScope( + mixed $value, + callable|iterable|RuleInterface $rules, + ValidationContext $context, + ): Result { + if ( + $context->getProperty() === null + && $value !== $this->getOriginalData($context->getDataSet()) + ) { + $context->setParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY, null); + } + return $this->validateInternal( + $value, + new RulesNormalizerIterator( + is_iterable($rules) ? $rules : [$rules], + $this->defaultSkipOnEmptyCondition, + ), + $context, + ); + } + + /** + * Gets the original data from a data set. + * + * @param DataSetInterface $dataSet A data set to get the original data from. + * + * @return mixed The original data wrapped by the data set ({@see DataWrapperInterface}), or the data set itself + * when it's not a wrapper. + */ + private function getOriginalData(DataSetInterface $dataSet): mixed + { + return $dataSet instanceof DataWrapperInterface ? $dataSet->getSource() : $dataSet; + } + /** * Acts like a pre-validation phase allowing to skip validation for specific rule within a set if any of these * conditions are met: diff --git a/tests/Rule/AnyRuleTest.php b/tests/Rule/AnyRuleTest.php index 3f6c49a86..857854709 100644 --- a/tests/Rule/AnyRuleTest.php +++ b/tests/Rule/AnyRuleTest.php @@ -5,8 +5,13 @@ namespace Yiisoft\Validator\Tests\Rule; use stdClass; +use Yiisoft\Validator\Result; use Yiisoft\Validator\Rule\AnyRule; use Yiisoft\Validator\Rule\AnyRuleHandler; +use Yiisoft\Validator\Rule\Callback; +use Yiisoft\Validator\Rule\Each; +use Yiisoft\Validator\Rule\Nested; +use Yiisoft\Validator\Rule\Number; use Yiisoft\Validator\Rule\Type\FloatType; use Yiisoft\Validator\Rule\Type\IntegerType; use Yiisoft\Validator\Tests\Rule\Base\DifferentRuleInHandlerTestTrait; @@ -15,6 +20,9 @@ use Yiisoft\Validator\Tests\Rule\Base\SkipOnErrorTestTrait; use Yiisoft\Validator\Tests\Rule\Base\WhenTestTrait; use Yiisoft\Validator\Tests\Support\Rule\StubRule\StubRuleWithAfterInit; +use Yiisoft\Validator\Tests\Support\Data\PostValidationHookCounter; +use Yiisoft\Validator\ValidationContext; +use Yiisoft\Validator\Validator; final class AnyRuleTest extends RuleTestCase { @@ -153,6 +161,119 @@ public function testAfterInitAttribute(): void $this->assertSame($object, $innerRule2->getObject()); } + public function testDataSetAndPropertyInInnerRules(): void + { + $data = ['a' => 'x', 'b' => 1]; + $innerData = null; + $innerProperty = null; + + (new Validator())->validate($data, [ + 'a' => new AnyRule([ + new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$innerData, + &$innerProperty, + ): Result { + $innerData = $context->getDataSet()->getData(); + $innerProperty = $context->getProperty(); + return new Result(); + }, + ), + ]), + ]); + + $this->assertSame($data, $innerData); + $this->assertSame('a', $innerProperty); + } + + public function testDataSetInInnerRulesWithNestedEach(): void + { + $data = [ + 'groups' => [ + ['items' => [['a' => 1], ['a' => 2]]], + ['items' => [['a' => 3]]], + ], + ]; + + $createRules = static function (callable $wrap) use (&$innerData): array { + $callback = new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use (&$innerData): Result { + $innerData[] = [$context->getDataSet()->getData(), $context->getProperty()]; + return new Result(); + }, + ); + return [ + 'groups' => new Each( + new Nested([ + 'items' => new Each( + new Nested(['a' => $wrap($callback)]), + ), + ]), + ), + ]; + }; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): Callback => $callback), + ); + $expectedInnerData = $innerData; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): AnyRule => new AnyRule([$callback])), + ); + + $this->assertSame( + [ + [['a' => 1], 'a'], + [['a' => 2], 'a'], + [['a' => 3], 'a'], + ], + $expectedInnerData, + ); + $this->assertSame($expectedInnerData, $innerData); + } + + public function testObjectValueInInnerRules(): void + { + $result = (new Validator())->validate( + ['o' => (object) ['x' => 7]], + ['o' => new AnyRule([new Each([new Number(max: 10)])])], + ); + + $this->assertSame( + ['o' => ['At least one of the inner rules must pass the validation.']], + $result->getErrorMessagesIndexedByPath(), + ); + } + + public function testPostValidationHookOfDataInInnerRules(): void + { + $data = new PostValidationHookCounter(); + + (new Validator())->validate( + $data, + [new AnyRule([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(1, $data->hookCallsCount); + } + + public function testPostValidationHookOfPropertyValueInInnerRules(): void + { + $value = new PostValidationHookCounter(); + + (new Validator())->validate( + ['o' => $value], + ['o' => new AnyRule([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(0, $value->hookCallsCount); + } + protected function getDifferentRuleInHandlerItems(): array { return [AnyRule::class, AnyRuleHandler::class]; diff --git a/tests/Rule/CompositeTest.php b/tests/Rule/CompositeTest.php index 017f0525c..2a02691df 100644 --- a/tests/Rule/CompositeTest.php +++ b/tests/Rule/CompositeTest.php @@ -8,7 +8,9 @@ use Yiisoft\Validator\Rule\Callback; use Yiisoft\Validator\Rule\Composite; use Yiisoft\Validator\Rule\CompositeHandler; +use Yiisoft\Validator\Rule\Each; use Yiisoft\Validator\Rule\Equal; +use Yiisoft\Validator\Rule\Nested; use Yiisoft\Validator\Rule\Number; use Yiisoft\Validator\Rule\Required; use Yiisoft\Validator\Tests\Rule\Base\DifferentRuleInHandlerTestTrait; @@ -20,6 +22,8 @@ use Yiisoft\Validator\Tests\Support\Rule\CoordinatesRuleSet; use Yiisoft\Validator\Tests\Support\Rule\RuleWithoutOptions; use Yiisoft\Validator\Tests\Support\Data\CompositeWithCallbackAttribute; +use Yiisoft\Validator\Tests\Support\Data\PostValidationHookCounter; +use Yiisoft\Validator\ValidationContext; use Yiisoft\Validator\Validator; final class CompositeTest extends RuleTestCase @@ -349,6 +353,119 @@ public function testWhen(): void $this->testWhenInternal(new Composite([]), new Composite([], when: $when)); } + public function testDataSetAndPropertyInInnerRules(): void + { + $data = ['a' => 'x', 'b' => 1]; + $innerData = null; + $innerProperty = null; + + (new Validator())->validate($data, [ + 'a' => new Composite([ + new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$innerData, + &$innerProperty, + ): Result { + $innerData = $context->getDataSet()->getData(); + $innerProperty = $context->getProperty(); + return new Result(); + }, + ), + ]), + ]); + + $this->assertSame($data, $innerData); + $this->assertSame('a', $innerProperty); + } + + public function testDataSetInInnerRulesWithNestedEach(): void + { + $data = [ + 'groups' => [ + ['items' => [['a' => 1], ['a' => 2]]], + ['items' => [['a' => 3]]], + ], + ]; + + $createRules = static function (callable $wrap) use (&$innerData): array { + $callback = new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use (&$innerData): Result { + $innerData[] = [$context->getDataSet()->getData(), $context->getProperty()]; + return new Result(); + }, + ); + return [ + 'groups' => new Each( + new Nested([ + 'items' => new Each( + new Nested(['a' => $wrap($callback)]), + ), + ]), + ), + ]; + }; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): Callback => $callback), + ); + $expectedInnerData = $innerData; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): Composite => new Composite([$callback])), + ); + + $this->assertSame( + [ + [['a' => 1], 'a'], + [['a' => 2], 'a'], + [['a' => 3], 'a'], + ], + $expectedInnerData, + ); + $this->assertSame($expectedInnerData, $innerData); + } + + public function testObjectValueInInnerRules(): void + { + $result = (new Validator())->validate( + ['o' => (object) ['x' => 7]], + ['o' => new Composite([new Each([new Number(max: 10)])])], + ); + + $this->assertSame( + ['o' => ['O must be array or iterable. stdClass given.']], + $result->getErrorMessagesIndexedByPath(), + ); + } + + public function testPostValidationHookOfDataInInnerRules(): void + { + $data = new PostValidationHookCounter(); + + (new Validator())->validate( + $data, + [new Composite([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(1, $data->hookCallsCount); + } + + public function testPostValidationHookOfPropertyValueInInnerRules(): void + { + $value = new PostValidationHookCounter(); + + (new Validator())->validate( + ['o' => $value], + ['o' => new Composite([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(0, $value->hookCallsCount); + } + public function testWithCallbackAttribute(): void { $result = (new Validator())->validate(new CompositeWithCallbackAttribute()); diff --git a/tests/Rule/StopOnErrorTest.php b/tests/Rule/StopOnErrorTest.php index cf72e03ed..2556374a7 100644 --- a/tests/Rule/StopOnErrorTest.php +++ b/tests/Rule/StopOnErrorTest.php @@ -5,7 +5,10 @@ namespace Yiisoft\Validator\Tests\Rule; use Yiisoft\Validator\Result; +use Yiisoft\Validator\Rule\Callback; +use Yiisoft\Validator\Rule\Each; use Yiisoft\Validator\Rule\Length; +use Yiisoft\Validator\Rule\Nested; use Yiisoft\Validator\Rule\Number; use Yiisoft\Validator\Rule\Required; use Yiisoft\Validator\Rule\StopOnError; @@ -15,7 +18,9 @@ use Yiisoft\Validator\Tests\Rule\Base\RuleWithOptionsTestTrait; use Yiisoft\Validator\Tests\Rule\Base\RuleWithProvidedRulesTrait; use Yiisoft\Validator\Tests\Rule\Base\WhenTestTrait; +use Yiisoft\Validator\Tests\Support\Data\PostValidationHookCounter; use Yiisoft\Validator\Tests\Support\Data\StopOnErrorDto; +use Yiisoft\Validator\ValidationContext; use Yiisoft\Validator\Validator; final class StopOnErrorTest extends RuleTestCase @@ -294,6 +299,119 @@ public function testWhen(): void ); } + public function testDataSetAndPropertyInInnerRules(): void + { + $data = ['a' => 'x', 'b' => 1]; + $innerData = null; + $innerProperty = null; + + (new Validator())->validate($data, [ + 'a' => new StopOnError([ + new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$innerData, + &$innerProperty, + ): Result { + $innerData = $context->getDataSet()->getData(); + $innerProperty = $context->getProperty(); + return new Result(); + }, + ), + ]), + ]); + + $this->assertSame($data, $innerData); + $this->assertSame('a', $innerProperty); + } + + public function testDataSetInInnerRulesWithNestedEach(): void + { + $data = [ + 'groups' => [ + ['items' => [['a' => 1], ['a' => 2]]], + ['items' => [['a' => 3]]], + ], + ]; + + $createRules = static function (callable $wrap) use (&$innerData): array { + $callback = new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use (&$innerData): Result { + $innerData[] = [$context->getDataSet()->getData(), $context->getProperty()]; + return new Result(); + }, + ); + return [ + 'groups' => new Each( + new Nested([ + 'items' => new Each( + new Nested(['a' => $wrap($callback)]), + ), + ]), + ), + ]; + }; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): Callback => $callback), + ); + $expectedInnerData = $innerData; + + $innerData = []; + (new Validator())->validate( + $data, + $createRules(static fn(Callback $callback): StopOnError => new StopOnError([$callback])), + ); + + $this->assertSame( + [ + [['a' => 1], 'a'], + [['a' => 2], 'a'], + [['a' => 3], 'a'], + ], + $expectedInnerData, + ); + $this->assertSame($expectedInnerData, $innerData); + } + + public function testObjectValueInInnerRules(): void + { + $result = (new Validator())->validate( + ['o' => (object) ['x' => 7]], + ['o' => new StopOnError([new Each([new Number(max: 10)])])], + ); + + $this->assertSame( + ['o' => ['O must be array or iterable. stdClass given.']], + $result->getErrorMessagesIndexedByPath(), + ); + } + + public function testPostValidationHookOfDataInInnerRules(): void + { + $data = new PostValidationHookCounter(); + + (new Validator())->validate( + $data, + [new StopOnError([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(1, $data->hookCallsCount); + } + + public function testPostValidationHookOfPropertyValueInInnerRules(): void + { + $value = new PostValidationHookCounter(); + + (new Validator())->validate( + ['o' => $value], + ['o' => new StopOnError([new Callback(static fn(): Result => new Result())])], + ); + + $this->assertSame(0, $value->hookCallsCount); + } + public function testClassAttribute(): void { $result = (new Validator())->validate(new StopOnErrorDto()); diff --git a/tests/Support/Data/PostValidationHookCounter.php b/tests/Support/Data/PostValidationHookCounter.php new file mode 100644 index 000000000..891f6bb2b --- /dev/null +++ b/tests/Support/Data/PostValidationHookCounter.php @@ -0,0 +1,18 @@ +hookCallsCount++; + } +} diff --git a/tests/ValidationContextTest.php b/tests/ValidationContextTest.php index fc0f908dd..fa868b096 100644 --- a/tests/ValidationContextTest.php +++ b/tests/ValidationContextTest.php @@ -10,6 +10,10 @@ use Yiisoft\Validator\PropertyTranslator\ArrayPropertyTranslator; use Yiisoft\Validator\PropertyTranslator\NullPropertyTranslator; use Yiisoft\Validator\DataSet\ArrayDataSet; +use Yiisoft\Validator\Result; +use Yiisoft\Validator\Rule\Callback; +use Yiisoft\Validator\Rule\Each; +use Yiisoft\Validator\Rule\Number; use Yiisoft\Validator\ValidationContext; use Yiisoft\Validator\Validator; @@ -67,6 +71,127 @@ public function testValidateWithoutValidator(): void $context->validate(42); } + public function testValidateInCurrentScopeWithoutValidator(): void + { + $context = new ValidationContext(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Validator is not set in validation context.'); + $context->validateInCurrentScope(42, []); + } + + public function testValidateInCurrentScopeWithCallable(): void + { + $data = ['a' => 7, 'b' => 8]; + $innerValue = null; + $innerData = null; + $innerProperty = null; + + $result = (new Validator())->validate($data, [ + 'a' => new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$innerValue, + &$innerData, + &$innerProperty, + ): Result { + return $context->validateInCurrentScope( + $value, + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$innerValue, + &$innerData, + &$innerProperty, + ): Result { + $innerValue = $value; + $innerData = $context->getDataSet()->getData(); + $innerProperty = $context->getProperty(); + return (new Result())->addError('Error.'); + }, + ); + }, + ), + ]); + + $this->assertSame(7, $innerValue); + $this->assertSame($data, $innerData); + $this->assertSame('a', $innerProperty); + $this->assertSame(['a' => ['Error.']], $result->getErrorMessagesIndexedByPath()); + } + + public function testValidateInCurrentScopeWithCurrentValue(): void + { + $data = new class { + public int $a = 5; + public int $b = 7; + }; + + $result = (new Validator())->validate($data, [ + new Callback( + static fn(mixed $value, Callback $rule, ValidationContext $context): Result => $context + ->validateInCurrentScope($value, new Each([new Number(max: 6)])), + ), + ]); + + $this->assertSame(['b' => ['Value must be no greater than 6.']], $result->getErrorMessagesIndexedByPath()); + } + + public function testValidateInCurrentScopeWithAnotherValue(): void + { + $data = ['items' => [1, 20], 'c' => 100]; + + $result = (new Validator())->validate($data, [ + new Callback( + static fn(mixed $value, Callback $rule, ValidationContext $context): Result => $context + ->validateInCurrentScope($value['items'], new Each([new Number(max: 6)])), + ), + ]); + + $this->assertSame(['1' => ['Value must be no greater than 6.']], $result->getErrorMessagesIndexedByPath()); + } + + public function testValidateInCurrentScopeRestoresParameters(): void + { + $data = ['items' => [1, 20]]; + $parametersInside = null; + $parametersBefore = null; + $parametersAfter = null; + + (new Validator())->validate($data, [ + new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$parametersInside, + &$parametersBefore, + &$parametersAfter, + ): Result { + $parametersBefore = [ + $context->getParameter(ValidationContext::PARAMETER_PREVIOUS_RULES_ERRORED), + $context->getParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY), + ]; + $context->validateInCurrentScope($value['items'], [ + new Number(max: 6), + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$parametersInside, + ): Result { + $parametersInside = [ + $context->getParameter(ValidationContext::PARAMETER_PREVIOUS_RULES_ERRORED), + $context->getParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY), + ]; + return new Result(); + }, + ]); + $parametersAfter = [ + $context->getParameter(ValidationContext::PARAMETER_PREVIOUS_RULES_ERRORED), + $context->getParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY), + ]; + return new Result(); + }, + ), + ]); + + $this->assertSame([null, $data], $parametersBefore); + $this->assertSame([true, null], $parametersInside); + $this->assertSame($parametersBefore, $parametersAfter); + } + public function testGetRawDataWithoutRawData(): void { $context = new ValidationContext(); @@ -83,13 +208,16 @@ public function testSetContextDataOnce(): void $data2 = ['2']; $dataSet1 = new ArrayDataSet($data1); $dataSet2 = new ArrayDataSet($data2); + $currentScopeValidator1 = static fn(): Result => (new Result())->addError('Error 1.'); + $currentScopeValidator2 = static fn(): Result => (new Result())->addError('Error 2.'); $context = (new ValidationContext()) - ->setContextDataOnce($validator, new NullPropertyTranslator(), $data1, $dataSet1) - ->setContextDataOnce($validator, new NullPropertyTranslator(), $data2, $dataSet2); + ->setContextDataOnce($validator, new NullPropertyTranslator(), $data1, $dataSet1, $currentScopeValidator1) + ->setContextDataOnce($validator, new NullPropertyTranslator(), $data2, $dataSet2, $currentScopeValidator2); $this->assertSame($data1, $context->getRawData()); $this->assertSame($dataSet1, $context->getGlobalDataSet()); + $this->assertSame(['Error 1.'], $context->validateInCurrentScope(null, [])->getErrorMessages()); } public static function dataTranslatedPropertyWithoutTranslator(): array From 31cab92aea653095840ebea264eeafff02338752 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 25 Sep 2026 11:33:23 +0300 Subject: [PATCH 2/3] fix --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 758c42a2f..55e9565ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## 2.7.0 under development -- New #822: Add `ValidationContext::validateInCurrentScope()` method to validate a value in the current scope (@vjik) -- Bug #822: Validate rules inside `StopOnError`, `Composite` and `AnyRule` in the current scope, same as without these +- New #824: Add `ValidationContext::validateInCurrentScope()` method to validate a value in the current scope (@vjik) +- Bug #824: Validate rules inside `StopOnError`, `Composite` and `AnyRule` in the current scope, same as without these wrappers: keep the current data set and property in validation context, don't iterate over public properties of an object value and don't call its `PostValidationHookInterface::processValidationResult()` (@vjik) From aa3839bb7a05c88749f0d90a314e947eae87ccec Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 25 Sep 2026 11:40:53 +0300 Subject: [PATCH 3/3] fix --- src/ValidationContext.php | 8 +++++--- tests/ValidationContextTest.php | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/ValidationContext.php b/src/ValidationContext.php index 4c9402907..5d812771a 100644 --- a/src/ValidationContext.php +++ b/src/ValidationContext.php @@ -212,10 +212,12 @@ public function validateInCurrentScope(mixed $value, callable|iterable|RuleInter $this->requireValidator(); $currentParameters = $this->parameters; - $result = ($this->currentScopeValidator)($value, $rules, $this); - $this->parameters = $currentParameters; - return $result; + try { + return ($this->currentScopeValidator)($value, $rules, $this); + } finally { + $this->parameters = $currentParameters; + } } /** diff --git a/tests/ValidationContextTest.php b/tests/ValidationContextTest.php index fa868b096..1eb196d8e 100644 --- a/tests/ValidationContextTest.php +++ b/tests/ValidationContextTest.php @@ -192,6 +192,42 @@ static function (mixed $value, Callback $rule, ValidationContext $context) use ( $this->assertSame($parametersBefore, $parametersAfter); } + public function testValidateInCurrentScopeRestoresParametersOnException(): void + { + $data = ['items' => [1, 20]]; + $parametersBefore = null; + $parametersAfter = null; + + (new Validator())->validate($data, [ + new Callback( + static function (mixed $value, Callback $rule, ValidationContext $context) use ( + &$parametersBefore, + &$parametersAfter, + ): Result { + $parametersBefore = [ + $context->getParameter(ValidationContext::PARAMETER_PREVIOUS_RULES_ERRORED), + $context->getParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY), + ]; + try { + $context->validateInCurrentScope($value['items'], [ + new Number(max: 6), + static fn(): Result => throw new RuntimeException('Test.'), + ]); + } catch (RuntimeException) { + } + $parametersAfter = [ + $context->getParameter(ValidationContext::PARAMETER_PREVIOUS_RULES_ERRORED), + $context->getParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY), + ]; + return new Result(); + }, + ), + ]); + + $this->assertSame([null, $data], $parametersBefore); + $this->assertSame($parametersBefore, $parametersAfter); + } + public function testGetRawDataWithoutRawData(): void { $context = new ValidationContext();