Repository: Lomkit/laravel-rest-api (php)

Issue:
Implement Sync/Toggle operation in mutate endpoint
### Description

> **Features**

These could be good features to implement. In particular, sync to avoid having to detach and then attach the new values. And also to avoid SQL errors when attaching the same value.
Update existing pivot fields
### Description

Hello,

I was wondering how one can update a field of an already existing row in a pivot table using mutate.

I was using this body, but my request was considered as a new attach and it threw an error.

```json
{
    "mutate": [
        {
            "operation": "update",
            "key": 1,
            "relations": {
                "posts": [
                    {
                        "operation": "update",
                        "key": 5,
                        "pivot": { "number": 20 }
                    },
                ]
            }
        }
    ]
}
```
Is there anything that can be done to achieve this pivot field update?

Thanks in advance :)

Pull request description:
:sparkles: mutate sync /toggle
closes #71 

Basically here the create / update operations now does a "syncWithoutDetaching"

closes #68 

Patch:
diff --git a/src/Query/Traits/PerformMutation.php b/src/Query/Traits/PerformMutation.php
index 62e5e92..f1c33f4 100644
--- a/src/Query/Traits/PerformMutation.php
+++ b/src/Query/Traits/PerformMutation.php
@@ -64,7 +64,7 @@ trait PerformMutation
             );
         }
 
-        if ($mutation['operation'] === 'update') {
+        if (in_array($mutation['operation'], ['update', 'touch', 'sync'])) {
             $model = $this->resource::newModel()::findOrFail($mutation['key']);
 
             $this->resource->authorizeTo('update', $model);
diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php
index 2f7bc17..41351b3 100644
--- a/src/Relations/BelongsToMany.php
+++ b/src/Relations/BelongsToMany.php
@@ -54,7 +54,7 @@ class BelongsToMany extends Relation implements RelationResource
                             ->applyMutation($mutationRelation)
                             ->getKey()
                     );
-            } else {
+            } elseif ($mutationRelation['operation'] === 'attach') {
                 $model
                     ->{$relation->relation}()
                     ->attach(
@@ -64,6 +64,37 @@ class BelongsToMany extends Relation implements RelationResource
                                 ->getKey() => $mutationRelation['pivot'] ?? [],
                         ]
                     );
+            } elseif ($mutationRelation['operation'] === 'toggle') {
+                $model
+                    ->{$relation->relation}()
+                    ->toggle(
+                        [
+                            app()->make(QueryBuilder::class, ['resource' => $relation->resource()])
+                                ->applyMutation($mutationRelation)
+                                ->getKey() => $mutationRelation['pivot'] ?? [],
+                        ]
+                    );
+            } elseif ($mutationRelation['operation'] === 'sync') {
+                $model
+                    ->{$relation->relation}()
+                    ->sync(
+                        [
+                            app()->make(QueryBuilder::class, ['resource' => $relation->resource()])
+                                ->applyMutation($mutationRelation)
+                                ->getKey() => $mutationRelation['pivot'] ?? [],
+                        ],
+                        !isset($mutationRelation['without_detaching']) || !$mutationRelation['without_detaching']
+                    );
+            } elseif (in_array($mutationRelation['operation'], ['create', 'update'])) {
+                $model
+                    ->{$relation->relation}()
+                    ->syncWithoutDetaching(
+                        [
+                            app()->make(QueryBuilder::class, ['resource' => $relation->resource()])
+                                ->applyMutation($mutationRelation)
+                                ->getKey() => $mutationRelation['pivot'] ?? [],
+                        ]
+                    );
             }
         }
     }
diff --git a/src/Rules/MutateRules.php b/src/Rules/MutateRules.php
index 3114ec8..0e77ef4 100644
--- a/src/Rules/MutateRules.php
+++ b/src/Rules/MutateRules.php
@@ -86,7 +86,7 @@ class MutateRules implements ValidationRule, ValidatorAwareRule
                 [
                     $attribute.'.operation' => [
                         'required_with:'.$attribute,
-                        Rule::in('create', 'update', ...($this->isRootValidation ? [] : ['attach', 'detach'])),
+                        Rule::in('create', 'update', ...($this->isRootValidation ? [] : ['attach', 'detach', 'toggle', 'sync'])),
                     ],
                     $attribute.'.attributes' => [
                         'prohibited_if:'.$attribute.'.operation,attach',
@@ -97,10 +97,15 @@ class MutateRules implements ValidationRule, ValidatorAwareRule
                         'required_if:'.$attribute.'.operation,update',
                         'required_if:'.$attribute.'.operation,attach',
                         'required_if:'.$attribute.'.operation,detach',
+                        'required_if:'.$attribute.'.operation,toggle',
+                        'required_if:'.$attribute.'.operation,sync',
                         'prohibited_if:'.$attribute.'.operation,create',
                         'exists:'.$this->resource::newModel()->getTable().','.$this->resource::newModel()->getKeyName(),
                     ],
-
+                    $attribute.'.without_detaching' => [
+                        'boolean',
+                        'prohibited_unless:'.$attribute.'.operation,sync',
+                    ],
                     $attribute.'.relations.*' => [
                         new MutateItemRelations($this->resource, $this->request),
                     ],


Test patch:
diff --git a/tests/Feature/Controllers/MutateUpdateOperationsTest.php b/tests/Feature/Controllers/MutateUpdateOperationsTest.php
index e3efb64..2235c35 100644
--- a/tests/Feature/Controllers/MutateUpdateOperationsTest.php
+++ b/tests/Feature/Controllers/MutateUpdateOperationsTest.php
@@ -1182,6 +1182,447 @@ class MutateUpdateOperationsTest extends TestCase
         );
     }
 
+    public function test_updating_a_resource_with_toggling_belongs_to_many_relation(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManyToggled = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyNotToggled = BelongsToManyRelationFactory::new()->createOne();
+
+        $modelToUpdate->belongsToManyRelation()
+            ->attach($belongsToManyToggled);
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'  => 'toggle',
+                                    'key'        => $belongsToManyToggled->getKey(),
+                                ],
+                                [
+                                    'operation'  => 'toggle',
+                                    'key'        => $belongsToManyNotToggled->getKey(),
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            1
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->first()->getKey(),
+            $belongsToManyNotToggled->getKey()
+        );
+    }
+
+    public function test_updating_a_resource_with_toggling_belongs_to_many_relation_and_updating_attributes_and_pivot(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManyToggled = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyNotToggled = BelongsToManyRelationFactory::new()->createOne();
+
+        $modelToUpdate->belongsToManyRelation()
+            ->attach($belongsToManyToggled);
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'  => 'toggle',
+                                    'key'        => $belongsToManyToggled->getKey(),
+                                    'attributes' => ['number' => 5001], // 5001 because with factory it can't exceed 5000
+                                    'pivot'      => [
+                                        'number' => 20,
+                                    ],
+                                ],
+                                [
+                                    'operation'  => 'toggle',
+                                    'key'        => $belongsToManyNotToggled->getKey(),
+                                    'attributes' => ['number' => 5002], // 5002 because with factory it can't exceed 5000
+                                    'pivot'      => [
+                                        'number' => 21,
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            1
+        );
+
+        $this->assertDatabaseHas(
+            $belongsToManyToggled->getTable(),
+            ['number' => 5001]
+        );
+        $this->assertDatabaseHas(
+            $belongsToManyToggled->getTable(),
+            ['number' => 5002]
+        );
+
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 21]
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->first()->getKey(),
+            $belongsToManyNotToggled->getKey()
+        );
+    }
+
+    public function test_updating_a_resource_with_syncing_belongs_to_many_relation(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManySynced = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyNotSynced = BelongsToManyRelationFactory::new()->createOne();
+
+        $modelToUpdate->belongsToManyRelation()
+            ->attach($belongsToManySynced);
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'  => 'sync',
+                                    'key'        => $belongsToManyNotSynced->getKey(),
+                                    'attributes' => ['number' => 5001], // 5001 because with factory it can't exceed 5000
+                                    'pivot'      => [
+                                        'number' => 20,
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            1
+        );
+
+        $this->assertDatabaseHas(
+            $belongsToManySynced->getTable(),
+            ['number' => 5001]
+        );
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 20]
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->first()->getKey(),
+            $belongsToManyNotSynced->getKey()
+        );
+    }
+
+    public function test_updating_a_resource_with_syncing_belongs_to_many_relation_without_detaching(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManyToSync1 = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyToSync2 = BelongsToManyRelationFactory::new()->createOne();
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'  => 'sync',
+                                    'key'        => $belongsToManyToSync1->getKey(),
+                                    'attributes' => ['number' => 5001], // 5001 because with factory it can't exceed 5000
+                                    'pivot'      => [
+                                        'number' => 20,
+                                    ],
+                                ],
+                                [
+                                    'operation'         => 'sync',
+                                    'key'               => $belongsToManyToSync2->getKey(),
+                                    'without_detaching' => true,
+                                    'attributes'        => ['number' => 5002], // 5001 because with factory it can't exceed 5000
+                                    'pivot'             => [
+                                        'number' => 21,
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            2
+        );
+
+        $this->assertDatabaseHas(
+            $belongsToManyToSync1->getTable(),
+            ['number' => 5001]
+        );
+        $this->assertDatabaseHas(
+            $belongsToManyToSync2->getTable(),
+            ['number' => 5002]
+        );
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 20]
+        );
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 21]
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->pluck('id')->toArray(),
+            [
+                $belongsToManyToSync1->getKey(),
+                $belongsToManyToSync2->getKey(),
+            ]
+        );
+    }
+
+    public function test_updating_a_resource_with_syncing_belongs_to_many_relation_without_detaching_with_already_attached(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManySynced = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyNotSynced = BelongsToManyRelationFactory::new()->createOne();
+
+        $modelToUpdate->belongsToManyRelation()
+            ->attach($belongsToManySynced);
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'         => 'sync',
+                                    'key'               => $belongsToManyNotSynced->getKey(),
+                                    'without_detaching' => true,
+                                    'attributes'        => ['number' => 5002], // 5001 because with factory it can't exceed 5000
+                                    'pivot'             => [
+                                        'number' => 21,
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            2
+        );
+
+        $this->assertDatabaseHas(
+            $belongsToManyNotSynced->getTable(),
+            ['number' => 5002]
+        );
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 21]
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->pluck('id')->toArray(),
+            [
+                $belongsToManySynced->getKey(),
+                $belongsToManyNotSynced->getKey(),
+            ]
+        );
+    }
+
+    public function test_updating_a_resource_with_syncing_belongs_to_many_relation_with_detaching_with_already_attached(): void
+    {
+        $modelToUpdate = ModelFactory::new()->createOne();
+        $belongsToManySynced = BelongsToManyRelationFactory::new()->createOne();
+        $belongsToManyNotSynced = BelongsToManyRelationFactory::new()->createOne();
+
+        $modelToUpdate->belongsToManyRelation()
+            ->attach($belongsToManySynced);
+
+        Gate::policy(Model::class, GreenPolicy::class);
+        Gate::policy(BelongsToManyRelation::class, GreenPolicy::class);
+
+        $response = $this->post(
+            '/api/models/mutate',
+            [
+                'mutate' => [
+                    [
+                        'operation'  => 'update',
+                        'key'        => $modelToUpdate->getKey(),
+                        'attributes' => [
+                            'name'   => 'new name',
+                            'number' => 5001,
+                        ],
+                        'relations' => [
+                            'belongsToManyRelation' => [
+                                [
+                                    'operation'  => 'sync',
+                                    'key'        => $belongsToManyNotSynced->getKey(),
+                                    'attributes' => ['number' => 5002], // 5001 because with factory it can't exceed 5000
+                                    'pivot'      => [
+                                        'number' => 21,
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            ['Accept' => 'application/json']
+        );
+
+        $this->assertMutatedResponse(
+            $response,
+            [],
+            [$modelToUpdate],
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->count(),
+            1
+        );
+
+        $this->assertDatabaseHas(
+            $belongsToManyNotSynced->getTable(),
+            ['number' => 5002]
+        );
+        $this->assertDatabaseHas(
+            $modelToUpdate->belongsToManyRelation()->getTable(),
+            ['number' => 21]
+        );
+
+        // Here we test that the relation is correctly linked
+        $this->assertEquals(
+            Model::find($response->json('updated.0'))->belongsToManyRelation()->pluck('id')->toArray(),
+            [
+                $belongsToManyNotSynced->getKey(),
+            ]
+        );
+    }
+
     public function test_updating_a_resource_with_creating_belongs_to_many_relation_with_pivot_fields(): void
     {
         $modelToUpdate = ModelFactory::new()->createOne();