diff --git a/docs/features/filtering.md b/docs/features/filtering.md index 178bb8cd..c843b36d 100644 --- a/docs/features/filtering.md +++ b/docs/features/filtering.md @@ -100,6 +100,25 @@ QueryBuilder::for(User::class) ->allowedFilters(AllowedFilter::exact('posts.title', null, $addRelationConstraint)); ``` +## Grouping allowed relation filters. + +By default, multiple filters on relation properties will apply multiple `where exists` clauses in the underlying query. + +To improve query performance (only one `where exists` clause) you can use `AllowedRelationshipFilter::group()`. + +```php +QueryBuilder::for(User::class)->allowedFilters([ + AllowedRelationshipFilter::group('posts', ...[ + AllowedFilter::exact('posts.id', 'id'), + AllowedFilter::exact('posts.name', 'title'), + AllowedRelationshipFilter::group('comments', ...[ + AllowedFilter::exact('posts.comments.id', 'id'), + AllowedFilter::partial('posts.comments.content', 'content'), + ]), + ]), +]); +``` + ## Scope filters Sometimes more advanced filtering options are necessary. This is where scope filters, callback filters and custom filters come in handy. diff --git a/src/AllowedFilter.php b/src/AllowedFilter.php index a9ba5bb0..e81fbb5a 100644 --- a/src/AllowedFilter.php +++ b/src/AllowedFilter.php @@ -3,6 +3,7 @@ namespace Spatie\QueryBuilder; use Illuminate\Support\Collection; +use Spatie\QueryBuilder\Contracts\AllowedFilterContract; use Spatie\QueryBuilder\Filters\Filter; use Spatie\QueryBuilder\Filters\FiltersBeginsWithStrict; use Spatie\QueryBuilder\Filters\FiltersCallback; @@ -12,7 +13,7 @@ use Spatie\QueryBuilder\Filters\FiltersScope; use Spatie\QueryBuilder\Filters\FiltersTrashed; -class AllowedFilter +class AllowedFilter implements AllowedFilterContract { protected string $internalName; @@ -116,9 +117,9 @@ public function getName(): string return $this->name; } - public function isForFilter(string $filterName): bool + public function getNames(): array { - return $this->name === $filterName; + return [$this->getName()]; } public function ignore(...$values): self @@ -152,7 +153,7 @@ public function default($value): self return $this; } - public function getDefault() + public function getDefault(): mixed { return $this->default; } @@ -187,4 +188,19 @@ protected function resolveValueForFiltering($value) return ! $this->ignored->contains($value) ? $value : null; } + + public function isRequested(QueryBuilderRequest $request): bool + { + return $request->filters()->has($this->getName()); + } + + public function getValueFromRequest(QueryBuilderRequest $request): mixed + { + return $request->filters()->get($this->getName()); + } + + public function getValueFromCollection(Collection $value): mixed + { + return $value->get($this->getName()); + } } diff --git a/src/AllowedRelationshipFilter.php b/src/AllowedRelationshipFilter.php new file mode 100644 index 00000000..51aa2e35 --- /dev/null +++ b/src/AllowedRelationshipFilter.php @@ -0,0 +1,67 @@ +allowedFilters = collect($allowedFilters); + } + + public static function group(string $relationship, AllowedFilterContract ...$allowedFilters): self + { + return new static($relationship, ...$allowedFilters); + } + + public function filter(QueryBuilder $query, $value): void + { + $query->whereHas($this->relationship, function ($query) use ($value) { + $this->allowedFilters->each( + function (AllowedFilterContract $allowedFilter) use ($query, $value) { + $allowedFilter->filter( + QueryBuilder::for($query), + $allowedFilter->getValueFromCollection($value) + ); + } + ); + }); + } + + public function getNames(): array + { + return $this->allowedFilters->map( + fn (AllowedFilterContract $allowedFilter) => $allowedFilter->getNames() + )->flatten()->toArray(); + } + + public function isRequested(QueryBuilderRequest $request): bool + { + return $request->filters()->hasAny($this->getNames()); + } + + public function getValueFromRequest(QueryBuilderRequest $request): Collection + { + return $request->filters()->only($this->getNames()); + } + + public function getValueFromCollection(Collection $value): Collection + { + return $value->only($this->getNames()); + } + + public function hasDefault(): bool + { + return false; + } + + public function getDefault(): null + { + return null; + } +} diff --git a/src/Concerns/FiltersQuery.php b/src/Concerns/FiltersQuery.php index 9e932c23..a9b3795a 100644 --- a/src/Concerns/FiltersQuery.php +++ b/src/Concerns/FiltersQuery.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use Spatie\QueryBuilder\AllowedFilter; +use Spatie\QueryBuilder\Contracts\AllowedFilterContract; use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery; trait FiltersQuery @@ -15,7 +16,7 @@ public function allowedFilters($filters): static $filters = is_array($filters) ? $filters : func_get_args(); $this->allowedFilters = collect($filters)->map(function ($filter) { - if ($filter instanceof AllowedFilter) { + if ($filter instanceof AllowedFilterContract) { return $filter; } @@ -31,9 +32,9 @@ public function allowedFilters($filters): static protected function addFiltersToQuery(): void { - $this->allowedFilters->each(function (AllowedFilter $filter) { - if ($this->isFilterRequested($filter)) { - $value = $this->request->filters()->get($filter->getName()); + $this->allowedFilters->each(function (AllowedFilterContract $filter) { + if ($filter->isRequested($this->request)) { + $value = $filter->getValueFromRequest($this->request); $filter->filter($this, $value); return; @@ -45,19 +46,6 @@ protected function addFiltersToQuery(): void }); } - protected function findFilter(string $property): ?AllowedFilter - { - return $this->allowedFilters - ->first(function (AllowedFilter $filter) use ($property) { - return $filter->isForFilter($property); - }); - } - - protected function isFilterRequested(AllowedFilter $allowedFilter): bool - { - return $this->request->filters()->has($allowedFilter->getName()); - } - protected function ensureAllFiltersExist(): void { if (config('query-builder.disable_invalid_filter_query_exception', false)) { @@ -66,9 +54,9 @@ protected function ensureAllFiltersExist(): void $filterNames = $this->request->filters()->keys(); - $allowedFilterNames = $this->allowedFilters->map(function (AllowedFilter $allowedFilter) { - return $allowedFilter->getName(); - }); + $allowedFilterNames = $this->allowedFilters->map(function (AllowedFilterContract $allowedFilter) { + return $allowedFilter->getNames(); + })->flatten(); $diff = $filterNames->diff($allowedFilterNames); diff --git a/src/Contracts/AllowedFilterContract.php b/src/Contracts/AllowedFilterContract.php new file mode 100644 index 00000000..6b1048f8 --- /dev/null +++ b/src/Contracts/AllowedFilterContract.php @@ -0,0 +1,24 @@ +toContain('LOWER(`relatedModels`.`name`) LIKE ?'); }); + +it('defaults to separate exist clauses for each relationship allowed filter', function () { + $modelToFind = $this->models->first(); + + $relatedModelToFind = $modelToFind->relatedModels->first(); + $relatedModelToFind->name = 'asdf'; + $relatedModelToFind->save(); + + $nestedRelatedModelToFind = $relatedModelToFind->nestedRelatedModels->first(); + $nestedRelatedModelToFind->name = 'ghjk'; + $nestedRelatedModelToFind->save(); + + $query = createQueryFromFilterRequest([ + 'relatedModels.id' => $relatedModelToFind->id, + 'relatedModels.name' => 'asdf', + 'relatedModels.nestedRelatedModels.id' => $nestedRelatedModelToFind->id, + 'relatedModels.nestedRelatedModels.name' => 'ghjk', + ])->allowedFilters([ + AllowedFilter::exact('relatedModels.id'), + AllowedFilter::exact('relatedModels.name'), + AllowedFilter::exact('relatedModels.nestedRelatedModels.id'), + AllowedFilter::exact('relatedModels.nestedRelatedModels.name'), + ]); + + $models = $query->get(); + $rawSql = $query->toRawSql(); + + expect($rawSql)->toBe("select * from `test_models` where exists (select * from `related_models` where `test_models`.`id` = `related_models`.`test_model_id` and `related_models`.`id` = 1) and exists (select * from `related_models` where `test_models`.`id` = `related_models`.`test_model_id` and `related_models`.`name` = 'asdf') and exists (select * from `related_models` where `test_models`.`id` = `related_models`.`test_model_id` and exists (select * from `nested_related_models` where `related_models`.`id` = `nested_related_models`.`related_model_id` and `nested_related_models`.`id` = 1)) and exists (select * from `related_models` where `test_models`.`id` = `related_models`.`test_model_id` and exists (select * from `nested_related_models` where `related_models`.`id` = `nested_related_models`.`related_model_id` and `nested_related_models`.`name` = 'ghjk'))"); + expect($models)->toHaveCount(1); + expect($models->first()->id)->toBe($modelToFind->id); +}); + +it('does not add exists statement when no filters provided', function () { + $query = createQueryFromFilterRequest([ + // intentionally empty + ])->allowedFilters([ + AllowedRelationshipFilter::group('relatedModels', ...[ + AllowedFilter::exact('relatedModels.id', 'id'), + AllowedFilter::exact('relatedModels.name', 'name'), + AllowedRelationshipFilter::group('nestedRelatedModels', ...[ + AllowedFilter::exact('relatedModels.nestedRelatedModels.id', 'id'), + AllowedFilter::exact('relatedModels.nestedRelatedModels.name', 'name'), + ]), + ]), + ]); + + $models = $query->get(); + $rawSql = $query->toRawSql(); + + expect($rawSql)->toBe("select * from `test_models`"); + expect($models)->toHaveCount(5); +}); + +it('can group filters in same exist clause', function () { + $modelToFind = $this->models->first(); + + $relatedModelToFind = $modelToFind->relatedModels->first(); + $relatedModelToFind->name = 'asdf'; + $relatedModelToFind->save(); + + $nestedRelatedModelToFind = $relatedModelToFind->nestedRelatedModels->first(); + $nestedRelatedModelToFind->name = 'ghjk'; + $nestedRelatedModelToFind->save(); + + $query = createQueryFromFilterRequest([ + 'relatedModels.id' => $relatedModelToFind->id, + 'relatedModels.name' => 'asdf', + 'relatedModels.nestedRelatedModels.id' => $nestedRelatedModelToFind->id, + 'relatedModels.nestedRelatedModels.name' => 'ghjk', + ])->allowedFilters([ + AllowedRelationshipFilter::group('relatedModels', ...[ + AllowedFilter::exact('relatedModels.id', 'id'), + AllowedFilter::exact('relatedModels.name', 'name'), + AllowedRelationshipFilter::group('nestedRelatedModels', ...[ + AllowedFilter::exact('relatedModels.nestedRelatedModels.id', 'id'), + AllowedFilter::exact('relatedModels.nestedRelatedModels.name', 'name'), + ]), + ]), + ]); + + $models = $query->get(); + $rawSql = $query->toRawSql(); + + expect($rawSql)->toBe("select * from `test_models` where exists (select * from `related_models` where `test_models`.`id` = `related_models`.`test_model_id` and `related_models`.`id` = 1 and `related_models`.`name` = 'asdf' and exists (select * from `nested_related_models` where `related_models`.`id` = `nested_related_models`.`related_model_id` and `nested_related_models`.`id` = 1 and `nested_related_models`.`name` = 'ghjk'))"); + expect($models)->toHaveCount(1); + expect($models->first()->id)->toBe($modelToFind->id); +});