Skip to content

Commit 0348ab0

Browse files
authored
New Filter belongs to (#975)
* new filter BelongsTo * test new filter BelongsTo * wip doc new filter BelongsTo * Fix styling * change RelationNotFoundException instead of InvalidFilterProperty, add tests --------- Co-authored-by: gpibarra <[email protected]>
1 parent db9fb45 commit 0348ab0

File tree

4 files changed

+223
-0
lines changed

4 files changed

+223
-0
lines changed

docs/features/filtering.md

+49
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,55 @@ QueryBuilder::for(User::class)
134134
->allowedFilters(AllowedFilter::exact('posts.title', null, $addRelationConstraint));
135135
```
136136

137+
## BelongsTo filters
138+
139+
In Model:
140+
```php
141+
class Comment extends Model
142+
{
143+
public function post(): BelongsTo
144+
{
145+
return $this->belongsTo(Post::class);
146+
}
147+
}
148+
```
149+
150+
```php
151+
QueryBuilder::for(Comment::class)
152+
->allowedFilters([
153+
AllowedFilter::belongsTo('post'),
154+
])
155+
->get();
156+
```
157+
158+
Alias
159+
```php
160+
QueryBuilder::for(Comment::class)
161+
->allowedFilters([
162+
AllowedFilter::belongsTo('post_id', 'post'),
163+
])
164+
->get();
165+
```
166+
167+
Nested
168+
```php
169+
class Post extends Model
170+
{
171+
public function author(): BelongsTo
172+
{
173+
return $this->belongsTo(User::class);
174+
}
175+
}
176+
```
177+
178+
```php
179+
QueryBuilder::for(Comment::class)
180+
->allowedFilters([
181+
AllowedFilter::belongsTo('author_post_id', 'post.author'),
182+
])
183+
->get();
184+
```
185+
137186
## Scope filters
138187

139188
Sometimes more advanced filtering options are necessary. This is where scope filters, callback filters and custom filters come in handy.

src/AllowedFilter.php

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Spatie\QueryBuilder\Enums\FilterOperator;
77
use Spatie\QueryBuilder\Filters\Filter;
88
use Spatie\QueryBuilder\Filters\FiltersBeginsWithStrict;
9+
use Spatie\QueryBuilder\Filters\FiltersBelongsTo;
910
use Spatie\QueryBuilder\Filters\FiltersCallback;
1011
use Spatie\QueryBuilder\Filters\FiltersEndsWithStrict;
1112
use Spatie\QueryBuilder\Filters\FiltersExact;
@@ -82,6 +83,13 @@ public static function endsWithStrict(string $name, $internalName = null, bool $
8283
return new static($name, new FiltersEndsWithStrict($addRelationConstraint), $internalName);
8384
}
8485

86+
public static function belongsTo(string $name, $internalName = null, string $arrayValueDelimiter = null): static
87+
{
88+
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
89+
90+
return new static($name, new FiltersBelongsTo(), $internalName);
91+
}
92+
8593
public static function scope(string $name, $internalName = null, string $arrayValueDelimiter = null): static
8694
{
8795
static::setFilterArrayValueDelimiter($arrayValueDelimiter);

src/Filters/FiltersBelongsTo.php

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace Spatie\QueryBuilder\Filters;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\RelationNotFoundException;
8+
use Illuminate\Database\Eloquent\Relations\Relation;
9+
use Illuminate\Support\Arr;
10+
11+
/**
12+
* @template TModelClass of \Illuminate\Database\Eloquent\Model
13+
* @template-implements \Spatie\QueryBuilder\Filters\Filter<TModelClass>
14+
*/
15+
class FiltersBelongsTo implements Filter
16+
{
17+
/** {@inheritdoc} */
18+
public function __invoke(Builder $query, $value, string $property)
19+
{
20+
$values = array_values(Arr::wrap($value));
21+
22+
$propertyParts = collect(explode('.', $property));
23+
$relation = $propertyParts->pop();
24+
$relationParent = $propertyParts->implode('.');
25+
$relatedModel = $this->getRelatedModel($query->getModel(), $relation, $relationParent);
26+
27+
$relatedCollection = $relatedModel->newCollection();
28+
array_walk($values, fn ($v) => $relatedCollection->add(
29+
tap($relatedModel->newInstance(), fn ($m) => $m->setAttribute($m->getKeyName(), $v))
30+
));
31+
32+
if ($relatedCollection->isEmpty()) {
33+
return $query;
34+
}
35+
36+
if ($relationParent) {
37+
$query->whereHas($relationParent, fn (Builder $q) => $q->whereBelongsTo($relatedCollection, $relation));
38+
} else {
39+
$query->whereBelongsTo($relatedCollection, $relation);
40+
}
41+
}
42+
43+
protected function getRelatedModel(Model $modelQuery, string $relationName, string $relationParent): Model
44+
{
45+
if ($relationParent) {
46+
$modelParent = $this->getModelFromRelation($modelQuery, $relationParent);
47+
} else {
48+
$modelParent = $modelQuery;
49+
}
50+
51+
$relatedModel = $this->getRelatedModelFromRelation($modelParent, $relationName);
52+
53+
return $relatedModel;
54+
}
55+
56+
protected function getRelatedModelFromRelation(Model $model, string $relationName): ?Model
57+
{
58+
$relationObject = $model->$relationName();
59+
if (! is_subclass_of($relationObject, Relation::class)) {
60+
throw RelationNotFoundException::make($model, $relationName);
61+
}
62+
63+
$relatedModel = $relationObject->getRelated();
64+
65+
return $relatedModel;
66+
}
67+
68+
protected function getModelFromRelation(Model $model, string $relation, int $level = 0): ?Model
69+
{
70+
$relationParts = explode('.', $relation);
71+
if (count($relationParts) == 1) {
72+
return $this->getRelatedModelFromRelation($model, $relation);
73+
} else {
74+
$firstRelation = $relationParts[0];
75+
$firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation);
76+
if (! $firstRelatedModel) {
77+
return null;
78+
}
79+
80+
return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1);
81+
}
82+
}
83+
}

tests/FilterTest.php

+83
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Spatie\QueryBuilder\Filters\Filter as FilterInterface;
1616
use Spatie\QueryBuilder\Filters\FiltersExact;
1717
use Spatie\QueryBuilder\QueryBuilder;
18+
use Spatie\QueryBuilder\Tests\TestClasses\Models\NestedRelatedModel;
19+
use Spatie\QueryBuilder\Tests\TestClasses\Models\RelatedModel;
1820
use Spatie\QueryBuilder\Tests\TestClasses\Models\TestModel;
1921

2022
beforeEach(function () {
@@ -283,6 +285,87 @@
283285
expect($modelsResult)->toHaveCount(0);
284286
});
285287

288+
it('can filter results by belongs to', function () {
289+
$relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]);
290+
$nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id]);
291+
292+
$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class)
293+
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
294+
->get();
295+
296+
expect($modelsResult)->toHaveCount(1);
297+
});
298+
299+
it('can filter results by belongs to no match', function () {
300+
$relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]);
301+
$nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id + 1]);
302+
303+
$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class)
304+
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
305+
->get();
306+
307+
expect($modelsResult)->toHaveCount(0);
308+
});
309+
310+
it('can filter results by belongs multiple', function () {
311+
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]);
312+
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
313+
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]);
314+
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);
315+
316+
$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class)
317+
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
318+
->get();
319+
320+
expect($modelsResult)->toHaveCount(2);
321+
});
322+
323+
it('can filter results by belongs multiple with different internal name', function () {
324+
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]);
325+
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
326+
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]);
327+
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);
328+
329+
$modelsResult = createQueryFromFilterRequest(['testFilter' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class)
330+
->allowedFilters(AllowedFilter::belongsTo('testFilter', 'relatedModel'))
331+
->get();
332+
333+
expect($modelsResult)->toHaveCount(2);
334+
});
335+
336+
it('can filter results by belongs multiple with different internal name and nested model', function () {
337+
$testModel1 = TestModel::create(['name' => 'John Test Doe 1']);
338+
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => $testModel1->id]);
339+
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
340+
$testModel2 = TestModel::create(['name' => 'John Test Doe 2']);
341+
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => $testModel2->id]);
342+
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);
343+
344+
$modelsResult = createQueryFromFilterRequest(['test_filter' => $testModel1->id.','.$testModel2->id], NestedRelatedModel::class)
345+
->allowedFilters(AllowedFilter::belongsTo('test_filter', 'relatedModel.testModel'))
346+
->get();
347+
348+
expect($modelsResult)->toHaveCount(2);
349+
});
350+
351+
it('throws an exception when trying to filter by belongs to with an inexistent relation', function ($relationName, $exceptionClass) {
352+
$this->expectException($exceptionClass);
353+
354+
$modelsResult = createQueryFromFilterRequest(['test_filter' => 1], RelatedModel::class)
355+
->allowedFilters(AllowedFilter::belongsTo('test_filter', $relationName))
356+
->get();
357+
358+
})->with([
359+
['inexistentRelation', \BadMethodCallException::class],
360+
['testModel.inexistentRelation', \BadMethodCallException::class], // existing 'testModel' belongsTo relation
361+
['inexistentRelation.inexistentRelation', \BadMethodCallException::class],
362+
['getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class],
363+
['testModel.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation
364+
['getTable.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class],
365+
['nestedRelatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'nestedRelatedModels' relation but not a belongsTo relation
366+
['testModel.relatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation and existing 'relatedModels' relation but not a belongsTo relation
367+
]);
368+
286369
it('can filter results by scope', function () {
287370
$testModel = TestModel::create(['name' => 'John Testing Doe']);
288371

0 commit comments

Comments
 (0)