Repository: livewire/livewire (php)

Issue:
validateOnly validates all matching fields in a collection
### Description
When using validate only, I would expect that only the specific field would be validated against the rules. For example, if there is a collection of 3 `Reference` models, each with a `name` and `email` field, and the validation rules are:

```php
protected $rules = [
    'references.*.name' => ['required', 'distinct'],
    'references.*.email' => ['required', 'email', 'distinct'],
];
```

then when `$this->validateOnly('references.0.name')` is called, we should only validate the first reference's name. Instead, all 3 reference names will be validated, and an error added for each.

if `$this->validateOnly('references.*.name')` is called, the same behaviour applies, but this would be the expected result, to my mind.

### Exact steps to reproduce

I have made a [repo](https://github.com/acurrieclark/livewire-validation-tests) to reproduce the behaviour. Instructions in the readme:
`git clone git@github.com:acurrieclark/livewire-validation-tests.git` to take a look

**Context**
- Livewire version: 2.4
- Laravel version: 8.12
- Alpine version: n/a
- Browser: Brave/Safari/Firefox

Happy to put together a pull request for this if wanted.


Pull request:
validateOnly should only validate specific fields in a collection/array
This pull request builds on an initial "failing test only" pull request which I submitted some months ago to attempt to resolve #2591

The current behaviour for the `validateOnly` method (when handling nested arrays) is that if we ask to validate eg. `foo.1.bar` then `foo.2.bar`, `foo.3.bar` etc will also be validated.

In my mind, this should be the expected behaviour if `foo.*.bar` is requested. If `foo.1.bar` is validated, then only that one field should be returned.

This pull request now means that:

`foo.1.bar` will validate **only** `foo.1.bar`
`foo.*.bar` will validate every `bar` field within the `foo` array
`foo.1` will validate every field within `foo.1`, including any `foo.*` rules
`foo.*` validates every item in the foo array, including nested items

Tests have been updated where necessary and new tests added.

I look forward to your feedback.



Patch:
diff --git a/src/ComponentConcerns/ValidatesInput.php b/src/ComponentConcerns/ValidatesInput.php
index fdd2b7c9..4b8bd379 100644
--- a/src/ComponentConcerns/ValidatesInput.php
+++ b/src/ComponentConcerns/ValidatesInput.php
@@ -2,6 +2,9 @@
 
 namespace Livewire\ComponentConcerns;
 
+use function collect;
+use function count;
+use function explode;
 use function Livewire\str;
 use Livewire\ObjectPrybar;
 use Illuminate\Support\Collection;
@@ -164,10 +167,26 @@ trait ValidatesInput
     {
         [$rules, $messages, $attributes] = $this->providedOrGlobalRulesMessagesAndAttributes($rules, $messages, $attributes);
 
-        // If the field is "items.0.foo", validation rules for "items.*.foo", "items.*", etc. are applied.
-        $rulesForField = collect($rules)->filter(function ($rule, $fullFieldKey) use ($field) {
-            return str($field)->is($fullFieldKey);
-        })->toArray();
+        // If the field is "items.0.foo", validation rules for "items.*.foo" is applied.
+        // if the field is "items.0", validation rules for "items.*" and "items.*.foo" etc are applied.
+        $rulesForField = collect($rules)
+            ->filter(function ($rule, $fullFieldKey) use ($field) {
+                return str($field)->is($fullFieldKey);
+            })->keys()
+            ->flatMap(function($key) use ($rules) {
+                return collect($rules)->filter(function($rule, $ruleKey) use ($key) {
+                    return str($ruleKey)->is($key);
+                });
+            })->mapWithKeys(function ($value, $key) use ($field) {
+                $fieldArray = str($field)->explode('.');
+                $result = str($key)->explode('.');
+
+                $result->splice(0, $fieldArray->count(), $fieldArray->toArray());
+
+                return [
+                    $result->join('.') => $value,
+                ];
+            })->toArray();
 
         $ruleKeysForField = array_keys($rulesForField);
 


Test patch:
diff --git a/tests/Unit/EloquentModelValidationTest.php b/tests/Unit/EloquentModelValidationTest.php
index 98061699..869f6311 100644
--- a/tests/Unit/EloquentModelValidationTest.php
+++ b/tests/Unit/EloquentModelValidationTest.php
@@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Model;
 use Livewire\Component;
 use Livewire\Livewire;
 use Sushi\Sushi;
+use function collect;
+use function view;
 
 class EloquentModelValidationTest extends TestCase
 {
@@ -187,6 +189,32 @@ class EloquentModelValidationTest extends TestCase
     }
 
     /** @test */
+    public function collection_model_property_validation_only_includes_relevant_error()
+    {   $test = Livewire::test(ComponentForEloquentModelCollectionHydrationMiddleware::class, [
+        'foos' => collect()->pad(3, Foo::first())]);
+        $test  ->call('performValidateOnly', 'foos.0.bar_baz')
+            ->assertHasErrors('foos.0.bar_baz')
+            ->assertHasNoErrors('foos.1.bar_baz');
+    }
+
+    /** @test */
+    public function collection_model_property_validation_includes_all_errors_when_using_base_wildcard()
+    {   $test = Livewire::test(ComponentForEloquentModelCollectionHydrationMiddleware::class, [
+        'foos' => collect()->pad(3, Foo::first())]);
+        $test  ->call('performValidateOnly', 'foos.*')
+            ->assertHasErrors('foos.0.bar_baz')
+            ->assertHasErrors('foos.1.bar_baz');
+    }
+
+    /** @test */
+    public function collection_model_property_validation_only_includes_all_errors_when_using_wildcard()
+    {   $test = Livewire::test(ComponentForEloquentModelCollectionHydrationMiddleware::class, [
+        'foos' => collect()->pad(3, Foo::first())]);
+        $test  ->call('performValidateOnly', 'foos.*.bar_baz')
+            ->assertHasErrors('foos.0.bar_baz')
+            ->assertHasErrors('foos.1.bar_baz');
+    }
+
     public function array_with_deep_nested_model_relationship_validation()
     {
         Livewire::test(ComponentForEloquentModelNestedHydrationMiddleware::class, [
@@ -281,6 +309,28 @@ class ComponentForEloquentModelHydrationMiddleware extends Component
     }
 }
 
+class ComponentForEloquentModelCollectionHydrationMiddleware extends Component
+{
+    public $foos;
+
+    protected $rules = [
+        'foos' => 'required',
+        'foos.*' => 'max:20',
+        'foos.*.bar_baz' => 'required|min:10',
+        'foos.*.bar' => 'required|min:10',
+    ];
+
+    public function performValidateOnly($field)
+    {
+        $this->validateOnly($field);
+    }
+
+    public function render()
+    {
+        return view('dump-errors');
+    }
+}
+
 class Items extends Model
 {
     use Sushi;
diff --git a/tests/Unit/ValidationTest.php b/tests/Unit/ValidationTest.php
index 1896d52e..0017ecbe 100644
--- a/tests/Unit/ValidationTest.php
+++ b/tests/Unit/ValidationTest.php
@@ -215,10 +215,10 @@ class ValidationTest extends TestCase
         $component = Livewire::test(ForValidation::class);
 
         $component
-            ->runAction('runDeeplyNestedValidationOnly', 'items.0.baz')
+            ->runAction('runDeeplyNestedValidationOnly', 'items.1.baz')
             ->assertSee('items.1.baz field is required')
             ->set('items.1.baz', 'blab')
-            ->runAction('runDeeplyNestedValidationOnly', 'items.0.baz')
+            ->runAction('runDeeplyNestedValidationOnly', 'items.1.baz')
             ->assertDontSee('items.1.baz field is required');
     }
 
@@ -228,11 +228,11 @@ class ValidationTest extends TestCase
         $component = Livewire::test(ForValidation::class);
 
         $component
-            ->runAction('runDeeplyNestedValidationOnly', 'items.0.baz')
+            ->runAction('runDeeplyNestedValidationOnly', 'items.1.baz')
             ->assertSee('items.1.baz field is required')
             ->set('items.1.baz', 'blab')
             ->set('items.0.baz', '')
-            ->runAction('runDeeplyNestedValidationOnly', 'items.0.baz')
+            ->runAction('runDeeplyNestedValidationOnly', 'items.*.baz')
             ->assertDontSee('items.1.baz field is required')
             ->assertSee('items.0.baz field is required');
     }