Skip to content

Commit f063f6d

Browse files
committed
feat: all in, any in filter operators
1 parent 8e4f970 commit f063f6d

File tree

7 files changed

+167
-14
lines changed

7 files changed

+167
-14
lines changed

src/Drivers/Standard/ParamsValidator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public function validateFilters(Request $request): void
5353
'filters' => ['sometimes', 'array'],
5454
'filters.*.type' => ['sometimes', 'in:and,or'],
5555
'filters.*.field' => ['required_with:filters', 'regex:/^[\w.\_\-\>]+$/', new WhitelistedField($this->filterableBy)],
56-
'filters.*.operator' => ['sometimes', 'in:<,<=,>,>=,=,!=,like,not like,ilike,not ilike,in,not in'],
56+
'filters.*.operator' => ['sometimes', 'in:<,<=,>,>=,=,!=,like,not like,ilike,not ilike,in,not in,all in,any in'],
5757
'filters.*.value' => ['present', 'nullable'],
5858
]
5959
)->validate();

src/Drivers/Standard/QueryBuilder.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Database\Eloquent\Relations\Relation;
1010
use Illuminate\Database\Eloquent\SoftDeletes;
1111
use Illuminate\Support\Arr;
12+
use JsonException;
1213
use Orion\Http\Requests\Request;
1314
use RuntimeException;
1415

@@ -62,6 +63,7 @@ public function __construct(
6263
* @param Builder|Relation $query
6364
* @param Request $request
6465
* @return Builder|Relation
66+
* @throws JsonException
6567
*/
6668
public function buildQuery($query, Request $request)
6769
{
@@ -102,6 +104,7 @@ public function applyScopesToQuery($query, Request $request): void
102104
* @param Builder|Relation $query
103105
* @param Request $request
104106
* @param array $filterDescriptors
107+
* @throws JsonException
105108
*/
106109
public function applyFiltersToQuery($query, Request $request, array $filterDescriptors = []): void
107110
{
@@ -146,6 +149,7 @@ function ($relationQuery) use ($relationField, $filterDescriptor) {
146149
* @param Builder|Relation $query
147150
* @param bool $or
148151
* @return Builder|Relation
152+
* @throws JsonException
149153
*/
150154
protected function buildFilterQueryWhereClause(string $field, array $filterDescriptor, $query, bool $or = false)
151155
{
@@ -168,6 +172,7 @@ protected function buildFilterQueryWhereClause(string $field, array $filterDescr
168172
* @param Builder|Relation $query
169173
* @param bool $or
170174
* @return Builder|Relation
175+
* @throws JsonException
171176
*/
172177
protected function buildFilterNestedQueryWhereClause(
173178
string $field,
@@ -180,16 +185,36 @@ protected function buildFilterNestedQueryWhereClause(
180185

181186
if ($treatAsDateField && Carbon::parse($filterDescriptor['value'])->toTimeString() === '00:00:00') {
182187
$constraint = 'whereDate';
188+
} elseif (in_array(Arr::get($filterDescriptor, 'operator'), ['all in', 'any in'])) {
189+
$constraint = 'whereJsonContains';
183190
} else {
184191
$constraint = 'where';
185192
}
186193

187-
if (!is_array($filterDescriptor['value']) || $constraint === 'whereDate') {
188-
$query->{$or ? 'or' . ucfirst($constraint) : $constraint}(
194+
if ($constraint !== 'whereJsonContains' && (!is_array(
195+
$filterDescriptor['value']
196+
) || $constraint === 'whereDate')) {
197+
$query->{$or ? 'or'.ucfirst($constraint) : $constraint}(
189198
$field,
190199
$filterDescriptor['operator'] ?? '=',
191200
$filterDescriptor['value']
192201
);
202+
} elseif ($constraint === 'whereJsonContains') {
203+
if (!is_array($filterDescriptor['value'])) {
204+
$query->{$or ? 'orWhereJsonContains' : 'whereJsonContains'}(
205+
$field,
206+
$filterDescriptor['value']
207+
);
208+
} else {
209+
$query->{$or ? 'orWhere' : 'where'}(function ($nestedQuery) use ($filterDescriptor, $field) {
210+
foreach ($filterDescriptor['value'] as $value) {
211+
$nestedQuery->{$filterDescriptor['operator'] === 'any in' ? 'orWhereJsonContains' : 'whereJsonContains'}(
212+
$field,
213+
$value
214+
);
215+
}
216+
});
217+
}
193218
} else {
194219
$query->{$or ? 'orWhereIn' : 'whereIn'}(
195220
$field,
@@ -335,14 +360,14 @@ function ($relationQuery) use ($relationField, $requestedSearchString, $caseSens
335360
if (!$caseSensitive) {
336361
return $relationQuery->whereRaw(
337362
"lower({$relationField}) like lower(?)",
338-
['%' . $requestedSearchString . '%']
363+
['%'.$requestedSearchString.'%']
339364
);
340365
}
341366

342367
return $relationQuery->where(
343368
$relationField,
344369
'like',
345-
'%' . $requestedSearchString . '%'
370+
'%'.$requestedSearchString.'%'
346371
);
347372
}
348373
);
@@ -352,13 +377,13 @@ function ($relationQuery) use ($relationField, $requestedSearchString, $caseSens
352377
if (!$caseSensitive) {
353378
$whereQuery->orWhereRaw(
354379
"lower({$qualifiedFieldName}) like lower(?)",
355-
['%' . $requestedSearchString . '%']
380+
['%'.$requestedSearchString.'%']
356381
);
357382
} else {
358383
$whereQuery->orWhere(
359384
$qualifiedFieldName,
360385
'like',
361-
'%' . $requestedSearchString . '%'
386+
'%'.$requestedSearchString.'%'
362387
);
363388
}
364389
}

src/Specs/Builders/Builder.php

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

55
namespace Orion\Specs\Builders;
66

7+
use Illuminate\Contracts\Container\BindingResolutionException;
8+
79
class Builder
810
{
911
/** @var InfoBuilder */
@@ -35,6 +37,10 @@ public function __construct(
3537
$this->tagsBuilder = $tagsBuilder;
3638
}
3739

40+
/**
41+
* @return array
42+
* @throws BindingResolutionException
43+
*/
3844
public function build(): array
3945
{
4046
return [

tests/Feature/StandardIndexFilteringOperationsTest.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,4 +533,116 @@ public function getting_a_list_of_resources_filtered_by_model_datetime_field():
533533
$this->makePaginator([$matchingPost], 'posts/search')
534534
);
535535
}
536+
537+
/** @test */
538+
public function getting_a_list_of_resources_filtered_by_jsonb_array_field_inclusive(): void
539+
{
540+
if (config('database.default') === 'sqlite'){
541+
$this->markTestSkipped('Not supported with SQLite');
542+
}
543+
544+
$matchingPost = factory(Post::class)
545+
->create(['options' => ['a', 'b', 'c']])->fresh();
546+
factory(Post::class)->create(['publish_at' => Carbon::now()])->fresh();
547+
548+
Gate::policy(Post::class, GreenPolicy::class);
549+
550+
$response = $this->post(
551+
'/api/posts/search',
552+
[
553+
'filters' => [
554+
['field' => 'options', 'operator' => 'all in', 'value' => ['a', 'b']],
555+
],
556+
]
557+
);
558+
559+
$this->assertResourcesPaginated(
560+
$response,
561+
$this->makePaginator([$matchingPost], 'posts/search')
562+
);
563+
}
564+
565+
/** @test */
566+
public function getting_a_list_of_resources_filtered_by_nested_jsonb_array_field_inclusive(): void
567+
{
568+
if (config('database.default') === 'sqlite'){
569+
$this->markTestSkipped('Not supported with SQLite');
570+
}
571+
572+
$matchingPost = factory(Post::class)
573+
->create(['options' => ['nested_field' => ['a', 'b', 'c']]])->fresh();
574+
factory(Post::class)->create(['publish_at' => Carbon::now()])->fresh();
575+
576+
Gate::policy(Post::class, GreenPolicy::class);
577+
578+
$response = $this->post(
579+
'/api/posts/search',
580+
[
581+
'filters' => [
582+
['field' => 'options->nested_field', 'operator' => 'all in', 'value' => ['a', 'b']],
583+
],
584+
]
585+
);
586+
587+
$this->assertResourcesPaginated(
588+
$response,
589+
$this->makePaginator([$matchingPost], 'posts/search')
590+
);
591+
}
592+
593+
/** @test */
594+
public function getting_a_list_of_resources_filtered_by_jsonb_array_field_exclusive(): void
595+
{
596+
if (config('database.default') === 'sqlite'){
597+
$this->markTestSkipped('Not supported with SQLite');
598+
}
599+
600+
$matchingPost = factory(Post::class)
601+
->create(['options' => ['a', 'd']])->fresh();
602+
factory(Post::class)->create(['publish_at' => Carbon::now()])->fresh();
603+
604+
Gate::policy(Post::class, GreenPolicy::class);
605+
606+
$response = $this->post(
607+
'/api/posts/search',
608+
[
609+
'filters' => [
610+
['field' => 'options', 'operator' => 'any in', 'value' => ['a', 'b']],
611+
],
612+
]
613+
);
614+
615+
$this->assertResourcesPaginated(
616+
$response,
617+
$this->makePaginator([$matchingPost], 'posts/search')
618+
);
619+
}
620+
621+
/** @test */
622+
public function getting_a_list_of_resources_filtered_by_nested_jsonb_array_field_exclusive(): void
623+
{
624+
if (config('database.default') === 'sqlite'){
625+
$this->markTestSkipped('Not supported with SQLite');
626+
}
627+
628+
$matchingPost = factory(Post::class)
629+
->create(['options' => ['nested_field' => ['a', 'd']]])->fresh();
630+
factory(Post::class)->create(['publish_at' => Carbon::now()])->fresh();
631+
632+
Gate::policy(Post::class, GreenPolicy::class);
633+
634+
$response = $this->post(
635+
'/api/posts/search',
636+
[
637+
'filters' => [
638+
['field' => 'options->nested_field', 'operator' => 'any in', 'value' => ['a', 'b']],
639+
],
640+
]
641+
);
642+
643+
$this->assertResourcesPaginated(
644+
$response,
645+
$this->makePaginator([$matchingPost], 'posts/search')
646+
);
647+
}
536648
}

tests/Fixtures/app/Http/Controllers/PostsController.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,38 @@ protected function beforeSave(Request $request, $entity)
2525
}
2626
}
2727

28-
public function sortableBy() : array
28+
public function sortableBy(): array
2929
{
3030
return ['title', 'user.name', 'meta->nested_field'];
3131
}
3232

33-
public function filterableBy() : array
33+
public function filterableBy(): array
3434
{
35-
return ['title', 'position', 'publish_at', 'user.name', 'meta->nested_field'];
35+
return [
36+
'title',
37+
'position',
38+
'publish_at',
39+
'user.name',
40+
'meta->nested_field',
41+
'options',
42+
'options->nested_field',
43+
];
3644
}
3745

38-
public function searchableBy() : array
46+
public function searchableBy(): array
3947
{
4048
return ['title', 'user.name'];
4149
}
4250

43-
public function exposedScopes() : array
51+
public function exposedScopes(): array
4452
{
4553
return ['published', 'publishedAt'];
4654
}
4755

4856
/**
4957
* @return array
5058
*/
51-
public function includes() : array
59+
public function includes(): array
5260
{
5361
return ['user'];
5462
}

tests/Fixtures/app/Models/Post.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class Post extends Model
2727
* @var array
2828
*/
2929
protected $casts = [
30-
'meta' => 'array'
30+
'meta' => 'array',
31+
'options' => 'array',
3132
];
3233

3334
/**

tests/Fixtures/database/migrations/2019_01_06_113445_create_posts_table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public function up()
2020
$table->text('body');
2121
$table->string('tracking_id')->nullable();
2222
$table->jsonb('meta')->nullable();
23+
$table->jsonb('options')->nullable();
2324
$table->unsignedInteger('position')->default(0);
2425

2526
$table->unsignedBigInteger('user_id')->nullable();

0 commit comments

Comments
 (0)