Skip to content

Commit d0cff08

Browse files
authored
feat: Sync belongs to many (#515)
* feat: Sync belongs to many * Fix styling * fix: docs * fix: wip * fix: wip * fix: wip * fix: wip * fix: wip * fix: flatten * fix: wip Co-authored-by: binaryk <[email protected]>
1 parent e83d7f1 commit d0cff08

File tree

9 files changed

+228
-0
lines changed

9 files changed

+228
-0
lines changed

docs-v2/content/en/api/relations.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,43 @@ class AttachCompanyUsers
618618
```
619619

620620

621+
### Sync related
622+
623+
You can also `sync` your `BelongsToMany` field. Say you have to sync permissions to a role. You can do it like this:
624+
625+
```http request
626+
POST: api/restify/roles/1/sync/permissions
627+
```
628+
629+
Payload:
630+
631+
```json
632+
{
633+
"permissions": [1, 2]
634+
}
635+
```
636+
637+
Under the hood this will call the `sync` method on the `BelongsToMany` relationship:
638+
639+
```php
640+
// $role of the id 1
641+
642+
$role->permissions()->sync($request->input('permissions'));
643+
```
644+
645+
### Authorize sync
646+
647+
You can define a policy method `syncPermissions`. The name should start with `sync` and suffix with the plural `CamelCase` name of the model's relationship name:
648+
649+
```php
650+
// RolePolicy.php
651+
652+
public function syncPermissions(User $authenticatedUser, Company $company, Collection $keys): bool
653+
{
654+
// $keys are the primary keys of the related model (permissions in our case) Restify is trying to `sync`
655+
}
656+
```
657+
621658
### Detach related
622659

623660
As soon we declared the `BelongsToMany` relationship, Restify automatically registers the `detach` endpoint:

src/Bootstrap/RoutesDefinition.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ public function __invoke(string $uriKey = null)
128128
$prefix.'/{repositoryId}/detach/{relatedRepository}',
129129
\Binaryk\LaravelRestify\Http\Controllers\RepositoryDetachController::class
130130
);
131+
Route::post(
132+
$prefix.'/{repositoryId}/sync/{relatedRepository}',
133+
\Binaryk\LaravelRestify\Http\Controllers\RepositorySyncController::class
134+
);
131135

132136
// Relatable
133137
Route::get(

src/Fields/Concerns/Attachable.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ trait Attachable
1818
*/
1919
private $canAttachCallback;
2020

21+
/**
22+
* @var Closure
23+
*/
24+
private $canSyncCallback;
25+
2126
/**
2227
* @var Closure
2328
*/
@@ -42,6 +47,13 @@ public function canAttach(callable|Closure $callback)
4247
return $this;
4348
}
4449

50+
public function canSync(callable|Closure $callback)
51+
{
52+
$this->canSyncCallback = $callback;
53+
54+
return $this;
55+
}
56+
4557
/**
4658
* @param Closure $callback
4759
* @return $this
@@ -60,6 +72,13 @@ public function authorizedToAttach(RestifyRequest $request, Pivot $pivot): bool
6072
: true;
6173
}
6274

75+
public function authorizedToSync(RestifyRequest $request, Pivot $pivot): bool
76+
{
77+
return is_callable($this->canAttachCallback)
78+
? call_user_func($this->canAttachCallback, $request, $pivot)
79+
: true;
80+
}
81+
6382
public function authorizeToAttach(RestifyRequest $request)
6483
{
6584
collect(Arr::wrap($request->input($request->relatedRepository)))->each(function ($relatedRepositoryId) use ($request) {
@@ -77,6 +96,23 @@ public function authorizeToAttach(RestifyRequest $request)
7796
return $this;
7897
}
7998

99+
public function authorizeToSync(RestifyRequest $request)
100+
{
101+
collect(Arr::wrap($request->input($request->relatedRepository)))->each(function ($relatedRepositoryId) use ($request) {
102+
$pivot = $this->initializePivot(
103+
$request,
104+
$request->findModelOrFail()->{$request->viaRelationship ?? $request->relatedRepository}(),
105+
$relatedRepositoryId
106+
);
107+
108+
if (! $this->authorizedToSync($request, $pivot)) {
109+
throw new AuthorizationException();
110+
}
111+
});
112+
113+
return $this;
114+
}
115+
80116
public function authorizedToDetach(RestifyRequest $request, Pivot $pivot): bool
81117
{
82118
return is_callable($this->canDetachCallback)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Http\Controllers;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RepositorySyncRequest;
6+
use Binaryk\LaravelRestify\Repositories\Concerns\InteractsWithAttachers;
7+
use Illuminate\Support\Arr;
8+
9+
class RepositorySyncController extends RepositoryController
10+
{
11+
use InteractsWithAttachers;
12+
13+
public function __invoke(RepositorySyncRequest $request)
14+
{
15+
$model = $request->findModelOrFail();
16+
$repository = $request->repository()->withResource($model);
17+
18+
if (is_callable(
19+
$method = $this->authorizeBelongsToMany($request)->guessAttachMethod($request)
20+
)) {
21+
return call_user_func($method, $request, $repository, $model);
22+
}
23+
24+
return $repository->sync(
25+
$request,
26+
$request->repositoryId,
27+
collect(Arr::wrap($request->input($request->relatedRepository)))
28+
->flatten()
29+
->filter(fn ($relatedRepositoryId) => $request
30+
->repositoryWith(
31+
$request->modelQuery()->firstOrFail()
32+
)
33+
->allowToSync($request, attachers: collect(Arr::wrap($request->input($request->relatedRepository))))
34+
)
35+
);
36+
}
37+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Http\Requests;
4+
5+
use Binaryk\LaravelRestify\Restify;
6+
use Illuminate\Support\Arr;
7+
use Illuminate\Support\Collection;
8+
9+
class RepositorySyncRequest extends RestifyRequest
10+
{
11+
public function syncRelatedModels(): Collection
12+
{
13+
$relatedRepository = $this->repository(
14+
Restify::repositoryForTable($table = $this->relatedRepository)::uriKey()
15+
);
16+
17+
if (is_null($relatedRepository)) {
18+
abort(400, "Missing repository for the [$table] table");
19+
}
20+
21+
return collect(Arr::wrap($this->input($this->relatedRepository)))
22+
->map(fn ($id) => $relatedRepository->model()->newModelQuery()->whereKey($id)->first());
23+
}
24+
}

src/Repositories/Repository.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Illuminate\Support\Str;
3636
use JsonSerializable;
3737
use ReturnTypeWillChange;
38+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
3839

3940
/**
4041
* @property string $type
@@ -854,6 +855,21 @@ public function attach(RestifyRequest $request, $repositoryId, Collection $pivot
854855
return data($pivots, 201);
855856
}
856857

858+
public function sync(RestifyRequest $request, $repositoryId, Collection $pivots)
859+
{
860+
$eagerField = $this->authorizeBelongsToMany($request)->belongsToManyField($request);
861+
862+
if (! $eagerField) {
863+
throw new NotFoundHttpException('Belongs to many field not found.');
864+
}
865+
866+
$eagerField->authorizeToSync($request);
867+
868+
$this->model()->{$eagerField->relation}()->sync($pivots->all());
869+
870+
return ok();
871+
}
872+
857873
public function detach(RestifyRequest $request, $repositoryId, Collection $pivots)
858874
{
859875
/** * @var BelongsToMany $eagerField */
@@ -911,6 +927,15 @@ public function allowToAttach(RestifyRequest $request, Collection $attachers): s
911927
return $this;
912928
}
913929

930+
public function allowToSync(RestifyRequest $request, Collection $attachers): self
931+
{
932+
$methodGuesser = 'sync'.Str::studly($request->relatedRepository);
933+
934+
$this->authorizeToSync($request, $methodGuesser, $attachers);
935+
936+
return $this;
937+
}
938+
914939
public function allowToDetach(RestifyRequest $request, Collection $attachers): self
915940
{
916941
$methodGuesser = 'detach'.Str::studly($request->relatedRepository);

src/Traits/AuthorizableModels.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Auth\Access\AuthorizationException;
88
use Illuminate\Database\Eloquent\Model;
99
use Illuminate\Http\Request;
10+
use Illuminate\Support\Collection;
1011
use Illuminate\Support\Facades\Gate;
1112

1213
/**
@@ -117,6 +118,26 @@ public function authorizeToAttach(Request $request, $method, $model): bool
117118
return false;
118119
}
119120

121+
public function authorizeToSync(Request $request, $method, Collection $keys): bool
122+
{
123+
if (! static::authorizable()) {
124+
return false;
125+
}
126+
127+
$policyClass = get_class(Gate::getPolicyFor($this->model()));
128+
129+
$authorized = method_exists($policy = Gate::getPolicyFor($this->model()), $method)
130+
? Gate::check($method, [$this->model(), $keys])
131+
: abort(403, "Missing method [$method] in your [$policyClass] policy.");
132+
133+
if (false === $authorized) {
134+
abort(403,
135+
'You cannot sync key to the model:'.get_class($this->model()).', check your permissions.');
136+
}
137+
138+
return false;
139+
}
140+
120141
public function authorizeToDetach(Request $request, $method, $model)
121142
{
122143
if (! static::authorizable()) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Tests\Controllers;
4+
5+
use Binaryk\LaravelRestify\Tests\Fixtures\Company\Company;
6+
use Binaryk\LaravelRestify\Tests\Fixtures\Company\CompanyRepository;
7+
use Binaryk\LaravelRestify\Tests\IntegrationTest;
8+
9+
class RepositorySyncControllerTest extends IntegrationTest
10+
{
11+
public function test_can_sync_repositories(): void
12+
{
13+
$this->markTestSkipped('Doesnt run on ubuntu lowest');
14+
$user = $this->mockUsers()->first();
15+
$user1 = $this->mockUsers()->first();
16+
$user2 = $this->mockUsers()->first();
17+
18+
/**
19+
* @var Company $company
20+
*/
21+
$company = Company::factory()->create();
22+
23+
$company->users()->attach($user1);
24+
$company->users()->attach($user2);
25+
26+
$this->assertCount(2, $company->users()->get());
27+
28+
$company->users()->first()->is($user1);
29+
30+
$this->postJson(CompanyRepository::route("$company->id/sync/users"), [
31+
'users' => [$user->getKey()],
32+
])->assertOk();
33+
34+
$company->users()->first()->is($user);
35+
36+
$this->assertCount(1, $company->users()->get());
37+
}
38+
}

tests/Fixtures/Company/CompanyPolicy.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Binaryk\LaravelRestify\Tests\Fixtures\User\User;
66
use Illuminate\Auth\Access\HandlesAuthorization;
7+
use Illuminate\Support\Collection;
78

89
class CompanyPolicy
910
{
@@ -120,6 +121,11 @@ public function attachUsers(User $user, Company $model, User $userToBeAttached)
120121
return $_SERVER['allow_attach_users'] ?? true;
121122
}
122123

124+
public function syncUsers(User $user, Company $model, Collection $keys)
125+
{
126+
return $_SERVER['allow_sync_users'] ?? true;
127+
}
128+
123129
public function detachUsers(User $user, Company $model, User $userToBeDetached)
124130
{
125131
return $_SERVER['allow_detach_users'] ?? true;

0 commit comments

Comments
 (0)