8000 [Feature] Add Eloquent filtering using scopes · BrianLangevin/laravel-json-api@c4a6da8 · GitHub
[go: up one dir, main page]

Skip to content 8000

Commit c4a6da8

Browse files
committed
[Feature] Add Eloquent filtering using scopes
Adds a `filterWithScopes()` method to the Eloquent adapter, that can be used to map JSON API filters to Eloquent model local scopes. This is opt-in, as in the developer must call `filterWithScopes()` within their `filter()` method to use it. It has been added to the generated Eloquent adapter as a suggested default implementation.
1 parent e31a41f commit c4a6da8

File tree

10 files changed

+293
-42
lines changed

10 files changed

+293
-42
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
All notable changes to this project will be documented in this file. This project adheres to
33
[Semantic Versioning](http://semver.org/) and [this changelog format](http://keepachangelog.com/).
44

5+
## Unreleased
6+
7+
### Added
8+
- [#333](https://github.com/cloudcreativity/laravel-json-api/issues/333)
9+
Eloquent adapters now have a `filterWithScopes()` method, that maps JSON API filters to
10+
model scopes and the Eloquent `where*` method names. This is opt-in: i.e. to use, the
11+
developer must call `filterWithScopes()` within their adapter's `filter()` method.
12+
513
## [1.3.0] - 2019-07-24
614

715
Package now supports Laravel 5.9.

docs/fetching/filtering.md

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ highly coupled with the application's logic and choice of data storage.
1111
This package therefore provides the following capabilities:
1212

1313
- Validation of the `filter` parameter.
14-
- An easy hook in the Eloquent adapter to convert validated filter parameters to database queries.
14+
- An easy hook in the Eloquent adapter to convert validated filter parameters to database queries.
15+
- An opt-in implementation to map JSON API filters to model scopes and/or Eloquent's magic `where*` method.
1516

1617
## Example Requests
1718

@@ -72,37 +73,15 @@ class Validators extends AbstractValidators
7273

7374
## Validation
7475

75-
Filter parameters should always be validated to ensure that their use in database queries is valid. You can
76-
validate them in your [Validators](../basics/validators.md) query rules. For example:
76+
Filter parameters should always be validated to ensure that their use in database queries is valid.
77+
You can validate them in your [Validators](../basics/validators.md) query rules. For example:
7778

7879
```php
7980
class Validators extends AbstractValidators
8081
{
8182
// ...
8283

83-
protected function queryRules(): array
84-
{
85-
return [
86-
'filter.title' => 'filled|string',
87-
'filter.slug' => 'filled|string',
88-
'filter.authors' => 'array|min:1',
89-
'filter.authors.*' => 'integer',
90-
];
91-
}
92-
93-
}
94-
```
95-
96-
By default we allow a client to submit any filter parameters as we assume that you will validate the values
97-
of expected filters as in the example above. However, you can whitelist expected filter parameters by listing
98-
them on the `$allowedFilteringParameters` of your validators class. For example:
99-
100-
```php
101-
class Validators extends AbstractValidators
102-
{
103-
// ...
104-
105-
protected $allowedFilteringParameters = ['title', 'authors'];
84+
protected $allowedFilteringParameters = ['title', 'slug', 'authors'];
10685

10786
protected function queryRules(): array
10887
{
@@ -117,6 +96,9 @@ class Validators extends AbstractValidators
11796
}
11897
```
11998

99+
The above whitelists the allowed filter parameters, and then also validates the values that can be
100+
submitted for each.
101+
120102
Any requests that contain filter keys that are not in your allowed filtering parameters list will be rejected
121103
with a `400 Bad Request` response, for example:
122104

@@ -143,7 +125,75 @@ Content-Type: application/vnd.api+json
143125
The Eloquent adapter provides a `filter` method that allows you to implement your filtering logic.
144126
This method is provided with an Eloquent query builder and the filters provided by the client.
145127

146-
For example, our `posts` adapter filtering implementation could be:
128+
### Filter Scopes
129+
130+
A newly generated Eloquent adapter will use our `filterWithScopes()` implementation. For example:
131+
132+
```php
133+
class Adapter extends AbstractAdapter
134+
{
135+
136+
// ...
137+
138+
/**
139+
* Mapping of JSON API filter names to model scopes.
140+
*
141+
* @var array
142+
*/
143+
protected $filterScopes = [];
144+
145+
/**
146+
* @param Builder $query
147+
* @param Collection $filters
148+
* @return void
149+
*/
150+
protected function filter($query, Collection $filters)
151+
{
152+
$this->filterWithScopes($query, $filters);
153+
}
154+
}
155+
```
156+
157+
The `filterWithScopes` method will map JSON API filters to model scopes, and pass the filter value to that scope.
158+
For example, if the client has sent a `filter[slug]` query parameter, we expect either there to be a
159+
`scopeSlug` method on the model, or we will use Eloquent's magic `whereSlug` method.
160+
161+
If you need to map a filter parameter to a different scope name, then you can define it here.
162+
For example if `filter[slug]` needed to be passed to the `onlySlug` scope, it can be defined
163+
as follows:
164+
165+
```php
166+
protected $filterScopes = [
167+
'slug' => 'onlySlug'
168+
];
169+
```
170+
171+
If you want a filter parameter to not be mapped, define the mapping as `null`, for example:
172+
173+
```php
174+
protected $filterScopes = [
10000
175+
'slug' => null
176+
];
177+
```
178+
179+
Alternatively you could let some filters be applied using scopes, and then implement your own logic
180+
for others. For example:
181+
182+
```php
183+
protected function filter($query, Collection $filters)
184+
{
185+
$this->filterWithScopes($query, $filters->only('foo', 'bar', 'bat'));
186+
187+
if ($baz = $filters->get('baz')) {
188+
// filter logic for baz.
189+
}
190+
}
191+
```
192+
193+
### Custom Filter Logic
194+
195+
If you do not want to use our filter by scope implementation, then it is easy to implement your
196+
own logic. Remove the call to `filterWithScopes()` and insert your own logic. For example:
147197

148198
```php
149199
class Adapter extends AbstractAdapter
@@ -166,5 +216,7 @@ class Adapter extends AbstractAdapter
166216
}
167217
```
168218

169-
> As filters are also applied when filtering the resource through a relationship, it is good practice
219+
### Relationships
220+
221+
Filters are also applied when filtering the resource through a relationship. It is good practice
170222
to qualify any column names.

src/Eloquent/AbstractAdapter.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ abstract class AbstractAdapter extends AbstractResourceAdapter
4343

4444
use Concerns\DeserializesAttributes,
4545
Concerns\IncludesModels,
46-
Concerns\SortsModels;
46+
Concerns\SortsModels,
47+
Concerns\FiltersModels;
4748

4849
/**
4950
* @var Model
@@ -332,9 +333,13 @@ protected function readWithFilters($record, EncodingParametersInterface $paramet
332333
*/
333334
protected function applyFilters($query, Collection $filters)
334335
{
335-
/** By default we support the `id` filter. */
336+
/**
337+
* By default we support the `id` filter. If we use the filter,
338+
* we remove it so that it is not re-used by the `filter` method.
339+
*/
336340
if ($this->isFindMany($filters)) {
337341
$this->filterByIds($query, $filters);
342+
$filters->forget($this->getFindManyKey());
338343
}
339344

340345
/** Hook for custom filters. */
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
/**
3+
* Copyright 2019 Cloud Creativity Limited
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
namespace CloudCreativity\LaravelJsonApi\Eloquent\Concerns;
19+
20+
use CloudCreativity\LaravelJsonApi\Utils\Str;
21+
use Illuminate\Support\Collection;
22+
23+
trait FiltersModels
24+
{
25+
26+
/**
27+
* Mapping of filter keys to model query scopes.
28+
*
29+
* The `filterWithScopes` method will map JSON API filters to
30+
* model scopes, and pass the filter value to that scope.
31+
* For example, if the client has sent a `filter[slug]` query
32+
* parameter, we expect either there to be a `scopeSlug` method
33+
* on the model, or we will use Eloquent's magic `whereSlug` method.
34+
*
35+
* If you need to map a filter parameter to a different scope name,
36+
* then you can define it here. For example if `filter[slug]`
37+
* needed to be passed to the `onlySlug` scope, it can be defined
38+
* as follows:
39+
*
40+
* ```php
41+
* protected $filterScopes = [
42+
* 'slug' => 'onlySlug'
43+
* ];
44+
* ```
45+
*
46+
* If you want a filter parameter to not be mapped to a scope,
47+
* define the mapping as `null`, for example:
48+
*
49+
* ```php
50+
* protected $filterScopes = [
51+
* 'slug' => null
52+
* ];
53+
* ```
54+
*
55+
* @var array
56+
*/
57+
protected $filterScopes = [];
58+
59+
/**
60+
* @param $query
61+
* @param Collection $filters
62+
* @return void
63+
*/
64+
protected function filterWithScopes($query, Collection $filters): void
65+
{
66+
foreach ($filters as $name => $value) {
67+
if ($name === $this->getFindManyKey()) {
68+
continue;
69+
}
70+
71+
if ($scope = $this->modelScopeForFilter($name)) {
72+
$this->filterWithScope($query, $scope, $value);
73+
}
74+
}
75+
}
76+
77+
/**
78+
* @param $query
79+
* @param string $scope
80+
* @param $value
81+
* @return void
82+
*/
83+
protected function filterWithScope($query, string $scope, $value): void
84+
{
85+
$query->{$scope}($value);
86+
}
87+
88+
/**
89+
* @param string $name
90+
* the JSON API filter name.
91+
* @return string|null
92+
*/
93+
protected function modelScopeForFilter(string $name): ?string
94+
{
95+
/** If the developer has specified a scope for this filter, use that. */
96+
if (array_key_exists($name, $this->filterScopes)) {
97+
return $this->filterScopes[$name];
98+
}
99+
100+
return $this->guessScope($name);
101+
}
102+
103+
/**
104+
* Guess the scope to use for a named JSON API filter.
105+
*
106+
* @param string $name
107+
* @return string
108+
*/
109+
protected function guessScope(string $name): string
110+
{
111+
/** Use a scope that matches the JSON API filter name. */
112+
if ($this->doesScopeExist($name)) {
113+
return Str::camelize($name);
114+
}
115+
116+
$key = $this->modelKeyForField($name, $this->model);
117+
118+
/** Use a scope that matches the model key for the JSON API field name. */
119+
if ($this->doesScopeExist($key)) {
120+
return Str::camelize($key);
121+
}
122+
123+
/** Or use Eloquent's `where*` magic method */
124+
return 'where' . Str::classify($key);
125+
}
126+
127+
/**
128+
* Does the named scope exist on the model?
129+
*
130+
* @param string $name
131+
* @return bool
132+
*/
133+
private function doesScopeExist(string $name): bool
134+
{
135+
return method_exists($this->model, 'scope' . Str::classify($name));
136+
}
137+
}

src/Validation/AbstractValidators.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ abstract class AbstractValidators implements ValidatorFactoryInterface
121121
* Null = clients can specify any filtering fields they want.
122122
*
123123
* @var string[]|null
124+
* @todo 3.0.0 make this `[]` by default, as we now loop through filter parameters.
124125
*/
125126
protected $allowedFilteringParameters = null;
126127

stubs/eloquent/adapter.stub

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class DummyClass extends AbstractAdapter
1717
*/
1818
protected $attributes = [];
1919

20+
/**
21+
* Mapping of JSON API filter names to model scopes.
22+
*
23+
* @var array
24+
*/
25+
protected $filterScopes = [];
26+
2027
/**
2128
* Adapter constructor.
2229
*
@@ -34,7 +41,7 @@ class DummyClass extends AbstractAdapter
3441
*/
3542
protected function filter($query, Collection $filters)
3643
{
37-
// TODO
44+
$this->filterWithScopes($query, $filters);
3845
}
3946

4047
}

stubs/independent/validators.stub

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ class DummyClass extends AbstractValidators
2323
*/
2424
protected $allowedSortParameters = [];
2525

26+
/**
27+
* The filters a client is allowed send.
28+
*
29+
* @var string[]|null
30+
* the allowed filters, an empty array for none allowed, or null to allow all.
31+
*/
32+
protected $allowedFilteringParameters = [];
33+
2634
/**
2735
* Get resource validation rules.
2836
*

0 commit comments

Comments
 (0)
0