Skip to content

Commit 08c0505

Browse files
authored
Merge pull request #163 from GautierDele/main
feat: ✨ nested filters
2 parents cda9980 + f0ef0e2 commit 08c0505

File tree

6 files changed

+206
-34
lines changed

6 files changed

+206
-34
lines changed

config/orion.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,19 @@
3434
],
3535
'search' => [
3636
'case_sensitive' => true, // TODO: set to "false" by default in 3.0 release
37-
]
37+
/*
38+
|--------------------------------------------------------------------------
39+
| Max Nested Depth
40+
|--------------------------------------------------------------------------
41+
|
42+
| This value is the maximum depth of nested filters
43+
| you will most likely need this to be maximum at 1 but
44+
| you can increase this number if necessary. Please
45+
| be aware that the depth generate dynamic rules and can slow
46+
| your application if someone sends a request with thousands of nested
47+
| filters.
48+
|
49+
*/
50+
'max_nested_depth' => 1
51+
],
3852
];

src/Drivers/Standard/ParamsValidator.php

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,56 @@ public function validateScopes(Request $request): void
4747

4848
public function validateFilters(Request $request): void
4949
{
50+
$max_depth = floor($this->getArrayDepth($request->all()['filters']) / 2);
51+
$config_max_nested_depth = config('orion.search.max_nested_depth', 1);
52+
53+
abort_if($max_depth > $config_max_nested_depth, 422, __('Max nested depth :depth is exceeded', ['depth' => $config_max_nested_depth]));
54+
5055
Validator::make(
5156
$request->all(),
52-
[
57+
array_merge([
5358
'filters' => ['sometimes', 'array'],
54-
'filters.*.type' => ['sometimes', 'in:and,or'],
55-
'filters.*.field' => [
56-
'required_with:filters',
57-
'regex:/^[\w.\_\-\>]+$/',
58-
new WhitelistedField($this->filterableBy),
59-
],
60-
'filters.*.operator' => [
61-
'sometimes',
62-
'in:<,<=,>,>=,=,!=,like,not like,ilike,not ilike,in,not in,all in,any in',
63-
],
64-
'filters.*.value' => ['present', 'nullable'],
65-
]
59+
], $this->getNestedRules('filters', $max_depth))
6660
)->validate();
6761
}
6862

63+
protected function getNestedRules($prefix, $max_depth, $rules = [], $current_depth = 1) {
64+
$rules = array_merge($rules, [
65+
$prefix.'.*.type' => ['sometimes', 'in:and,or'],
66+
$prefix.'.*.field' => [
67+
"required_without:{$prefix}.*.nested",
68+
'regex:/^[\w.\_\-\>]+$/',
69+
new WhitelistedField($this->filterableBy),
70+
],
71+
$prefix.'.*.operator' => [
72+
'sometimes',
73+
'in:<,<=,>,>=,=,!=,like,not like,ilike,not ilike,in,not in,all in,any in',
74+
],
75+
$prefix.'.*.value' => ['nullable'],
76+
$prefix.'.*.nested' => ['sometimes', 'array', "prohibits:{$prefix}.*.operator,{$prefix}.*.value,{$prefix}.*.field"],
77+
]);
78+
79+
if ($max_depth >= $current_depth) {
80+
$rules = array_merge($rules, $this->getNestedRules("{$prefix}.*.nested", $max_depth, $rules, ++$current_depth));
81+
}
82+
83+
return $rules;
84+
}
85+
86+
protected function getArrayDepth($array) {
87+
$max_depth = 0;
88+
89+
foreach ($array as $value) {
90+
if (is_array($value)) {
91+
$depth = $this->getArrayDepth($value) + 1;
92+
93+
$max_depth = max($depth, $max_depth);
94+
}
95+
}
96+
97+
return $max_depth;
98+
}
99+
69100
public function validateSort(Request $request): void
70101
{
71102
Validator::make(

src/Drivers/Standard/QueryBuilder.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ public function applyFiltersToQuery($query, Request $request, array $filterDescr
116116
foreach ($filterDescriptors as $filterDescriptor) {
117117
$or = Arr::get($filterDescriptor, 'type', 'and') === 'or';
118118

119-
if (strpos($filterDescriptor['field'], '.') !== false) {
119+
if (is_array($childrenDescriptors = Arr::get($filterDescriptor, 'nested'))) {
120+
$query->{$or ? 'orWhere' : 'where'}(function ($query) use ($request, $childrenDescriptors) { $this->applyFiltersToQuery($query, $request, $childrenDescriptors); });
121+
} elseif (strpos($filterDescriptor['field'], '.') !== false) {
120122
$relation = $this->relationsResolver->relationFromParamConstraint($filterDescriptor['field']);
121123
$relationField = $this->relationsResolver->relationFieldFromParamConstraint($filterDescriptor['field']);
122124

src/Specs/Builders/Partials/RequestBody/Search/FiltersBuilder.php

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,37 @@ public function build(): ?array
1414
return null;
1515
}
1616

17+
$filters = [
18+
'type' => [
19+
'type' => 'string',
20+
'enum' => ['and', 'or'],
21+
],
22+
'field' => [
23+
'type' => 'string',
24+
'enum' => $this->controller->filterableBy(),
25+
],
26+
'operator' => [
27+
'type' => 'string',
28+
'enum' => ['<','<=','>','>=','=','!=','like','not like','ilike','not ilike','in','not in', 'all in', 'any in'],
29+
],
30+
'value' => [
31+
'type' => 'string',
32+
]
33+
];
34+
1735
return [
1836
'type' => 'array',
1937
'items' => [
2038
'type' => 'object',
21-
'properties' => [
22-
'type' => [
23-
'type' => 'string',
24-
'enum' => ['and', 'or'],
25-
],
26-
'field' => [
27-
'type' => 'string',
28-
'enum' => $this->controller->filterableBy(),
29-
],
30-
'operator' => [
31-
'type' => 'string',
32-
'enum' => ['<','<=','>','>=','=','!=','like','not like','ilike','not ilike','in','not in', 'all in', 'any in'],
33-
],
34-
'value' => [
35-
'type' => 'string',
39+
'properties' => array_merge($filters, [
40+
'nested' => [
41+
'type' => 'array',
42+
'items' => [
43+
'type' => 'object',
44+
'properties' => $filters
45+
]
3646
]
37-
],
38-
'required' => [
39-
'field', 'value'
40-
]
47+
])
4148
]
4249
];
4350
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace Orion\Tests\Feature;
4+
5+
use Carbon\Carbon;
6+
use Illuminate\Support\Facades\Gate;
7+
use Orion\Tests\Fixtures\App\Models\Company;
8+
use Orion\Tests\Fixtures\App\Models\Post;
9+
use Orion\Tests\Fixtures\App\Models\Team;
10+
use Orion\Tests\Fixtures\App\Models\User;
11+
use Orion\Tests\Fixtures\App\Policies\GreenPolicy;
12+
13+
class StandardIndexNestedFilteringOperationsTest extends TestCase
14+
{
15+
/** @test */
16+
public function getting_a_list_of_resources_nested_filtered_by_model_field_using_default_operator(): void
17+
{
18+
$matchingPost = factory(Post::class)->create(['title' => 'match'])->fresh();
19+
factory(Post::class)->create(['title' => 'not match'])->fresh();
20+
21+
Gate::policy(Post::class, GreenPolicy::class);
22+
23+
$response = $this->post(
24+
'/api/posts/search',
25+
[
26+
'filters' => [
27+
['field' => 'title', 'operator' => 'in' ,'value' => ['match', 'not_match']],
28+
['nested' => [
29+
['field' => 'title', 'value' => 'match'],
30+
['field' => 'title', 'operator' => '!=', 'value' => 'not match']
31+
]],
32+
],
33+
]
34+
);
35+
36+
$this->assertResourcesPaginated(
37+
$response,
38+
$this->makePaginator([$matchingPost], 'posts/search')
39+
);
40+
}
41+
42+
/** @test */
43+
public function getting_a_list_of_resources_nested_filtered_by_model_field_using_equal_operator_and_or_type(): void
44+
{
45+
$matchingPost = factory(Post::class)->create(['title' => 'match'])->fresh();
46+
$anotherMatchingPost = factory(Post::class)->create(['position' => 3])->fresh();
47+
factory(Post::class)->create(['title' => 'not match'])->fresh();
48+
49+
Gate::policy(Post::class, GreenPolicy::class);
50+
51+
$response = $this->post(
52+
'/api/posts/search',
53+
[
54+
'filters' => [
55+
['field' => 'title', 'operator' => '=', 'value' => 'match'],
56+
['type' => 'or', 'nested' => [
57+
['field' => 'position', 'operator' => '=', 'value' => 3],
58+
]],
59+
],
60+
]
61+
);
62+
63+
$this->assertResourcesPaginated(
64+
$response,
65+
$this->makePaginator([$matchingPost, $anotherMatchingPost], 'posts/search')
66+
);
67+
}
68+
69+
/** @test */
70+
public function getting_a_list_of_resources_nested_filtered_by_model_field_using_not_equal_operator(): void
71+
{
72+
$matchingPost = factory(Post::class)->create(['position' => 4])->fresh();
73+
factory(Post::class)->create(['position' => 5])->fresh();
74+
75+
Gate::policy(Post::class, GreenPolicy::class);
76+
77+
$response = $this->post(
78+
'/api/posts/search',
79+
[
80+
'filters' => [
81+
['nested' => [
82+
['field' => 'position', 'operator' => '!=', 'value' => 5]
83+
]],
84+
],
85+
]
86+
);
87+
88+
$this->assertResourcesPaginated(
89+
$response,
90+
$this->makePaginator([$matchingPost], 'posts/search')
91+
);
92+
}
93+
94+
/** @test */
95+
public function getting_a_list_of_resources_nested_filtered_by_not_whitelisted_field(): void
96+
{
97+
factory(Post::class)->create(['body' => 'match'])->fresh();
98+
factory(Post::class)->create(['body' => 'not match'])->fresh();
99+
100+
Gate::policy(Post::class, GreenPolicy::class);
101+
102+
$response = $this->post(
103+
'/api/posts/search',
104+
[
105+
'filters' => [
106+
['nested' => [
107+
['field' => 'body', 'operator' => '=', 'value' => 'match']
108+
]],
109+
],
110+
]
111+
);
112+
113+
$response->assertStatus(422);
114+
$response->assertJsonStructure(['message', 'errors' => ['filters.0.nested.0.field']]);
115+
}
116+
}

tests/Fixtures/app/Providers/OrionServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ public function boot()
4545
{
4646
app()->make(Kernel::class)->pushMiddleware(EnforceExpectsJson::class);
4747

48+
$this->mergeConfigFrom(__DIR__ . '/../../../../config/orion.php', 'orion');
49+
4850
$this->loadRoutesFrom(__DIR__.'/../../routes/api.php');
4951
}
5052
}

0 commit comments

Comments
 (0)