Skip to content

Commit 85d5c98

Browse files
committed
feat: add filters support for relationships
1 parent 16c9ad5 commit 85d5c98

File tree

10 files changed

+377
-0
lines changed

10 files changed

+377
-0
lines changed

src/Descriptors/Relations/Relation.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace Ark4ne\JsonApi\Descriptors\Relations;
44

55
use Ark4ne\JsonApi\Descriptors\Describer;
6+
use Ark4ne\JsonApi\Filters\Filters;
67
use Ark4ne\JsonApi\Resources\Relationship;
78
use Ark4ne\JsonApi\Support\Includes;
89
use Ark4ne\JsonApi\Traits\HasRelationLoad;
910
use Closure;
11+
use Illuminate\Contracts\Auth\Access\Gate;
1012
use Illuminate\Database\Eloquent\Model;
1113
use Illuminate\Http\Request;
1214
use Illuminate\Http\Resources\MissingValue;
@@ -23,6 +25,7 @@ abstract class Relation extends Describer
2325
protected ?Closure $links = null;
2426
protected ?Closure $meta = null;
2527
protected ?bool $whenIncluded = null;
28+
protected ?Filters $filters = null;
2629

2730
/**
2831
* @param class-string<\Ark4ne\JsonApi\Resources\JsonApiResource|\Ark4ne\JsonApi\Resources\JsonApiCollection> $related
@@ -63,6 +66,37 @@ public function meta(Closure $meta): static
6366
return $this;
6467
}
6568

69+
/**
70+
* Set filters for the relationship
71+
*
72+
* @param Closure(Filters): Filters $filters Callback that receives (Filters $filters) and configures the filters
73+
* @return static
74+
*/
75+
public function filters(Closure $filters): static
76+
{
77+
$this->filters = $filters(new Filters);
78+
return $this;
79+
}
80+
81+
/**
82+
* @param iterable|string $abilities Abilities to check
83+
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
84+
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
85+
* @param string|null $guard Guard to use, defaults to the default guard
86+
* @return static
87+
*/
88+
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
89+
{
90+
return $this->when(fn(
91+
Request $request,
92+
Model $model,
93+
string $attribute
94+
) => app($gateClass)
95+
->forUser($request->user($guard))
96+
->allows($abilities, [$model, ...$arguments])
97+
);
98+
}
99+
66100
/**
67101
* @param bool|null $whenIncluded
68102
* @return static
@@ -139,6 +173,10 @@ public function resolveFor(Request $request, mixed $model, string $attribute): R
139173
$relation->whenIncluded($this->whenIncluded);
140174
}
141175

176+
if ($this->filters !== null) {
177+
$relation->withFilters($this->filters);
178+
}
179+
142180
return $relation;
143181
}
144182

src/Filters/CallbackFilterRule.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
8+
/**
9+
* @template Resource
10+
*
11+
* @implements FilterRule<Resource>
12+
*/
13+
class CallbackFilterRule implements FilterRule
14+
{
15+
/**
16+
* @param Closure(Request, Resource): bool $callback
17+
*/
18+
public function __construct(
19+
protected Closure $callback
20+
) {
21+
}
22+
23+
/**
24+
* @param Request $request
25+
* @param Resource $model
26+
* @return bool
27+
*/
28+
public function passes(Request $request, mixed $model): bool
29+
{
30+
return (bool) ($this->callback)($request, $model);
31+
}
32+
}

src/Filters/FilterRule.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Illuminate\Http\Request;
6+
7+
/**
8+
* @template Resource
9+
*/
10+
interface FilterRule
11+
{
12+
/**
13+
* Determine if the filter rule passes for the given model
14+
*
15+
* @param Request $request
16+
* @param Resource $model The model being filtered
17+
* @return bool
18+
*/
19+
public function passes(Request $request, mixed $model): bool;
20+
}

src/Filters/Filters.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Auth\Access\Gate;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Http\Resources\MissingValue;
9+
use Illuminate\Support\Collection;
10+
11+
/**
12+
* @template Resource
13+
*/
14+
class Filters
15+
{
16+
/** @var array<FilterRule<Resource>> */
17+
protected array $rules = [];
18+
19+
/**
20+
* Add a policy-based filter
21+
*
22+
* @param iterable<string>|string $abilities Abilities to check
23+
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
24+
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
25+
* @param string|null $guard Guard to use, defaults to the default guard
26+
* @return static
27+
*/
28+
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
29+
{
30+
$this->rules[] = new PolicyFilterRule($abilities, $arguments, $gateClass, $guard);
31+
return $this;
32+
}
33+
34+
/**
35+
* Add a custom filter rule
36+
*
37+
* @param Closure $callback Callback that receives (Request $request, Model $model) and returns bool
38+
* @return static
39+
*/
40+
public function when(Closure $callback): static
41+
{
42+
$this->rules[] = new CallbackFilterRule($callback);
43+
return $this;
44+
}
45+
46+
/**
47+
* Apply all filters to the given data
48+
*
49+
* @param mixed $data
50+
* @return mixed
51+
*/
52+
public function apply(Request $request, mixed $data): mixed
53+
{
54+
if ($data instanceof MissingValue || $data === null) {
55+
return $data;
56+
}
57+
58+
// If it's a collection/array, filter each item
59+
if (is_iterable($data)) {
60+
$filtered = [];
61+
foreach ($data as $key => $item) {
62+
if ($this->shouldInclude($request, $item)) {
63+
$filtered[$key] = $item;
64+
}
65+
}
66+
67+
// Preserve the original collection type
68+
if ($data instanceof Collection) {
69+
return new Collection($filtered);
70+
}
71+
72+
return $filtered;
73+
}
74+
75+
// Single model - check if it should be included
76+
return $this->shouldInclude($request, $data) ? $data : new MissingValue();
77+
}
78+
79+
/**
80+
* Check if a model should be included based on all filter rules
81+
*
82+
* @param mixed $model
83+
* @return bool
84+
*/
85+
protected function shouldInclude(Request $request, mixed $model): bool
86+
{
87+
// All rules must pass
88+
foreach ($this->rules as $rule) {
89+
if (!$rule->passes($request, $model)) {
90+
return false;
91+
}
92+
}
93+
94+
return true;
95+
}
96+
}

src/Filters/PolicyFilterRule.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Ark4ne\JsonApi\Filters;
4+
5+
use Illuminate\Contracts\Auth\Access\Gate;
6+
use Illuminate\Http\Request;
7+
8+
/**
9+
* @template Resource
10+
*
11+
* @implements FilterRule<Resource>
12+
*/
13+
class PolicyFilterRule implements FilterRule
14+
{
15+
/**
16+
* @param iterable<string>|string $abilities
17+
* @param array<mixed> $arguments
18+
*/
19+
public function __construct(
20+
protected iterable|string $abilities,
21+
protected array $arguments = [],
22+
protected string $gateClass = Gate::class,
23+
protected ?string $guard = null
24+
) {
25+
}
26+
27+
/**
28+
* @param Request $request
29+
* @param Resource $model
30+
* @return bool
31+
*/
32+
public function passes(Request $request, mixed $model): bool
33+
{
34+
return app($this->gateClass)
35+
->forUser($request->user($this->guard))
36+
->allows($this->abilities, [$model, ...$this->arguments]);
37+
}
38+
}

src/Resources/Relationship.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Ark4ne\JsonApi\Resources;
44

5+
use Ark4ne\JsonApi\Filters\Filters;
56
use Ark4ne\JsonApi\Support\Values;
67
use Ark4ne\JsonApi\Traits\HasRelationLoad;
78
use Closure;
@@ -25,6 +26,8 @@ class Relationship implements Resourceable
2526

2627
protected ?bool $whenIncluded = null;
2728

29+
protected ?Filters $filters = null;
30+
2831
/**
2932
* @param class-string<T> $resource
3033
* @param Closure $value
@@ -111,6 +114,19 @@ public function whenIncluded(null|bool $whenIncluded = null): static
111114
return $this;
112115
}
113116

117+
/**
118+
* Set filters for the relationship
119+
*
120+
* @param Filters $filters
121+
* @return $this
122+
*/
123+
public function withFilters(Filters $filters): static
124+
{
125+
$this->filters = $filters;
126+
127+
return $this;
128+
}
129+
114130
/**
115131
* Return class-string of resource
116132
*
@@ -148,6 +164,11 @@ public function toArray(mixed $request, bool $included = true): array
148164
: value($this->value);
149165
$value ??= new MissingValue;
150166

167+
// Apply filters if they are defined and we have data
168+
if ($this->filters !== null && !Values::isMissing($value)) {
169+
$value = $this->filters->apply($request, $value);
170+
}
171+
151172
if ($this->asCollection && !is_subclass_of($this->resource, ResourceCollection::class)) {
152173
$resource = $this->resource::collection($value);
153174
} else {

0 commit comments

Comments
 (0)