- Notifications
You must be signed in to change notification settings - Fork11.7k
[12.x] IntroduceScopeAwareRule contract to provide relative contextual array item data to validation rules#58077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Draft
lucasacoutinho wants to merge1 commit intolaravel:12.xChoose a base branch fromlucasacoutinho:feat/scope-aware-rule
base:12.x
Could not load branches
Branch not found:{{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline, and old review comments may become outdated.
+321 −1
Draft
Changes fromall commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
14 changes: 14 additions & 0 deletionssrc/Illuminate/Contracts/Validation/ScopeAwareRule.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php | ||
| namespace Illuminate\Contracts\Validation; | ||
| interface ScopeAwareRule | ||
| { | ||
| /** | ||
| * Set the scoped data (the current array item being validated). | ||
| * | ||
| * @param array $scope | ||
| * @return $this | ||
| */ | ||
| public function setScope(array $scope); | ||
| } |
30 changes: 29 additions & 1 deletionsrc/Illuminate/Validation/InvokableValidationRule.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.Learn more about bidirectional Unicode characters
278 changes: 278 additions & 0 deletionstests/Validation/ValidationScopeAwareRuleTest.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| <?php | ||
| namespace Illuminate\Tests\Validation; | ||
| use Closure; | ||
| use Illuminate\Contracts\Validation\ScopeAwareRule; | ||
| use Illuminate\Contracts\Validation\ValidationRule; | ||
| use Illuminate\Translation\ArrayLoader; | ||
| use Illuminate\Translation\Translator; | ||
| use Illuminate\Validation\Validator; | ||
| use PHPUnit\Framework\TestCase; | ||
| class ValidationScopeAwareRuleTest extends TestCase | ||
| { | ||
| public function testScopeAwareRuleReceivesSiblingData() | ||
| { | ||
| $data = [ | ||
| 'products' => [ | ||
| ['price' => 100, 'discount' => 150], | ||
| ['price' => 200, 'discount' => 50], | ||
| ['price' => 300, 'discount' => null], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'products.*.price' => 'required|numeric', | ||
| 'products.*.discount' => ['nullable', 'numeric', new DiscountMustBeLessThanPrice()], | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'products.0.discount' => ['Discount cannot exceed the price.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| public function testScopeAwareRulePasses() | ||
| { | ||
| $data = [ | ||
| 'products' => [ | ||
| ['price' => 100, 'discount' => 50], | ||
| ['price' => 200, 'discount' => 100], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'products.*.price' => 'required|numeric', | ||
| 'products.*.discount' => ['nullable', 'numeric', new DiscountMustBeLessThanPrice()], | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertTrue($v->passes()); | ||
| } | ||
| public function testScopeAwareRuleWithDeeplyNestedWildcards() | ||
| { | ||
| $data = [ | ||
| 'orders' => [ | ||
| [ | ||
| 'items' => [ | ||
| ['price' => 50, 'quantity' => 2, 'max_quantity' => 1], | ||
| ['price' => 30, 'quantity' => 1, 'max_quantity' => 5], | ||
| ], | ||
| ], | ||
| [ | ||
| 'items' => [ | ||
| ['price' => 100, 'quantity' => 3, 'max_quantity' => 10], | ||
| ], | ||
| ], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'orders.*.items.*.quantity' => ['required', 'integer', new QuantityMustNotExceedMax()], | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'orders.0.items.0.quantity' => ['Quantity cannot exceed max quantity.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| public function testScopeAwareRuleWithConditionalLogic() | ||
| { | ||
| $data = [ | ||
| 'clients' => [ | ||
| ['name' => 'John', 'state' => 'CA', 'tax_id' => null], | ||
| ['name' => 'Jane', 'state' => 'NY', 'tax_id' => null], | ||
| ['name' => 'Bob', 'state' => 'CA', 'tax_id' => '123-456'], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'clients.*.name' => 'required|string', | ||
| 'clients.*.state' => 'required|string', | ||
| 'clients.*.tax_id' => new RequiredIfState('state', 'CA'), | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'clients.0.tax_id' => ['The clients.0.tax_id field is required when state is CA.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| public function testScopeAwareRuleCanAccessNestedSiblingData() | ||
| { | ||
| $data = [ | ||
| 'orders' => [ | ||
| [ | ||
| 'type' => 'physical', | ||
| 'shipping' => ['method' => 'express', 'address' => null], | ||
| ], | ||
| [ | ||
| 'type' => 'digital', | ||
| 'shipping' => ['method' => 'none', 'address' => null], | ||
| ], | ||
| [ | ||
| 'type' => 'physical', | ||
| 'shipping' => ['method' => 'standard', 'address' => '123 Main St'], | ||
| ], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'orders.*.type' => 'required|in:physical,digital', | ||
| 'orders.*.shipping.address' => new RequiredForPhysicalOrder(), | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'orders.0.shipping.address' => ['Shipping address is required for physical orders.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| public function testScopeAwareRuleWithMultipleRulesOnSameAttribute() | ||
| { | ||
| $data = [ | ||
| 'products' => [ | ||
| ['price' => 100, 'discount' => 150, 'type' => 'sale'], | ||
| ['price' => 200, 'discount' => 50, 'type' => 'sale'], | ||
| ['price' => 300, 'discount' => null, 'type' => 'regular'], | ||
| ], | ||
| ]; | ||
| $rules = [ | ||
| 'products.*.price' => 'required|numeric', | ||
| 'products.*.discount' => [ | ||
| 'nullable', | ||
| 'numeric', | ||
| new DiscountMustBeLessThanPrice(), | ||
| ], | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'products.0.discount' => ['Discount cannot exceed the price.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| public function testScopeAwareRuleWithoutWildcardReceivesFullData() | ||
| { | ||
| $data = [ | ||
| 'price' => 100, | ||
| 'discount' => 150, | ||
| ]; | ||
| $rules = [ | ||
| 'price' => 'required|numeric', | ||
| 'discount' => ['nullable', 'numeric', new DiscountMustBeLessThanPrice()], | ||
| ]; | ||
| $v = new Validator($this->getTranslator(), $data, $rules); | ||
| $this->assertFalse($v->passes()); | ||
| $this->assertEquals([ | ||
| 'discount' => ['Discount cannot exceed the price.'], | ||
| ], $v->getMessageBag()->toArray()); | ||
| } | ||
| protected function getTranslator() | ||
| { | ||
| return new Translator( | ||
| new ArrayLoader, 'en' | ||
| ); | ||
| } | ||
| } | ||
| class RequiredIfState implements ValidationRule, ScopeAwareRule | ||
| { | ||
| protected array $scope = []; | ||
| public function __construct( | ||
| protected string $field, | ||
| protected string $value | ||
| ) { | ||
| } | ||
| public function setScope(array $scope): static | ||
| { | ||
| $this->scope = $scope; | ||
| return $this; | ||
| } | ||
| public function validate(string $attribute, mixed $value, Closure $fail): void | ||
| { | ||
| if (($this->scope[$this->field] ?? null) === $this->value && empty($value)) { | ||
| $fail("The {$attribute} field is required when {$this->field} is {$this->value}."); | ||
| } | ||
| } | ||
| } | ||
| class RequiredForPhysicalOrder implements ValidationRule, ScopeAwareRule | ||
| { | ||
| protected array $scope = []; | ||
| public function setScope(array $scope): static | ||
| { | ||
| $this->scope = $scope; | ||
| return $this; | ||
| } | ||
| public function validate(string $attribute, mixed $value, Closure $fail): void | ||
| { | ||
| if (($this->scope['type'] ?? null) === 'physical' && empty($value)) { | ||
| $fail('Shipping address is required for physical orders.'); | ||
| } | ||
| } | ||
| } | ||
| class DiscountMustBeLessThanPrice implements ValidationRule, ScopeAwareRule | ||
| { | ||
| protected array $scope = []; | ||
| public function setScope(array $scope): static | ||
| { | ||
| $this->scope = $scope; | ||
| return $this; | ||
| } | ||
| public function validate(string $attribute, mixed $value, Closure $fail): void | ||
| { | ||
| if ($value !== null && $value > ($this->scope['price'] ?? 0)) { | ||
| $fail('Discount cannot exceed the price.'); | ||
| } | ||
| } | ||
| } | ||
| class QuantityMustNotExceedMax implements ValidationRule, ScopeAwareRule | ||
| { | ||
| protected array $scope = []; | ||
| public function setScope(array $scope): static | ||
| { | ||
| $this->scope = $scope; | ||
| return $this; | ||
| } | ||
| public function validate(string $attribute, mixed $value, Closure $fail): void | ||
| { | ||
| if ($value > ($this->scope['max_quantity'] ?? PHP_INT_MAX)) { | ||
| $fail('Quantity cannot exceed max quantity.'); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Uh oh!
There was an error while loading.Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.