Skip to content

Commit

Permalink
New Filter belongs to (#975)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
gpibarra and gpibarra authored Dec 23, 2024
1 parent db9fb45 commit 0348ab0
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 0 deletions.
49 changes: 49 additions & 0 deletions docs/features/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,55 @@ QueryBuilder::for(User::class)
->allowedFilters(AllowedFilter::exact('posts.title', null, $addRelationConstraint));
```

## BelongsTo filters

In Model:
```php
class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
```

```php
QueryBuilder::for(Comment::class)
->allowedFilters([
AllowedFilter::belongsTo('post'),
])
->get();
```

Alias
```php
QueryBuilder::for(Comment::class)
->allowedFilters([
AllowedFilter::belongsTo('post_id', 'post'),
])
->get();
```

Nested
```php
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
```

```php
QueryBuilder::for(Comment::class)
->allowedFilters([
AllowedFilter::belongsTo('author_post_id', 'post.author'),
])
->get();
```

## Scope filters

Sometimes more advanced filtering options are necessary. This is where scope filters, callback filters and custom filters come in handy.
Expand Down
8 changes: 8 additions & 0 deletions src/AllowedFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\Filters\Filter;
use Spatie\QueryBuilder\Filters\FiltersBeginsWithStrict;
use Spatie\QueryBuilder\Filters\FiltersBelongsTo;
use Spatie\QueryBuilder\Filters\FiltersCallback;
use Spatie\QueryBuilder\Filters\FiltersEndsWithStrict;
use Spatie\QueryBuilder\Filters\FiltersExact;
Expand Down Expand Up @@ -82,6 +83,13 @@ public static function endsWithStrict(string $name, $internalName = null, bool $
return new static($name, new FiltersEndsWithStrict($addRelationConstraint), $internalName);
}

public static function belongsTo(string $name, $internalName = null, string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);

return new static($name, new FiltersBelongsTo(), $internalName);
}

public static function scope(string $name, $internalName = null, string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
Expand Down
83 changes: 83 additions & 0 deletions src/Filters/FiltersBelongsTo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Spatie\QueryBuilder\Filters;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\RelationNotFoundException;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;

/**
* @template TModelClass of \Illuminate\Database\Eloquent\Model
* @template-implements \Spatie\QueryBuilder\Filters\Filter<TModelClass>
*/
class FiltersBelongsTo implements Filter
{
/** {@inheritdoc} */
public function __invoke(Builder $query, $value, string $property)
{
$values = array_values(Arr::wrap($value));

$propertyParts = collect(explode('.', $property));
$relation = $propertyParts->pop();
$relationParent = $propertyParts->implode('.');
$relatedModel = $this->getRelatedModel($query->getModel(), $relation, $relationParent);

$relatedCollection = $relatedModel->newCollection();
array_walk($values, fn ($v) => $relatedCollection->add(
tap($relatedModel->newInstance(), fn ($m) => $m->setAttribute($m->getKeyName(), $v))
));

if ($relatedCollection->isEmpty()) {
return $query;
}

if ($relationParent) {
$query->whereHas($relationParent, fn (Builder $q) => $q->whereBelongsTo($relatedCollection, $relation));
} else {
$query->whereBelongsTo($relatedCollection, $relation);
}
}

protected function getRelatedModel(Model $modelQuery, string $relationName, string $relationParent): Model
{
if ($relationParent) {
$modelParent = $this->getModelFromRelation($modelQuery, $relationParent);
} else {
$modelParent = $modelQuery;
}

$relatedModel = $this->getRelatedModelFromRelation($modelParent, $relationName);

return $relatedModel;
}

protected function getRelatedModelFromRelation(Model $model, string $relationName): ?Model
{
$relationObject = $model->$relationName();
if (! is_subclass_of($relationObject, Relation::class)) {
throw RelationNotFoundException::make($model, $relationName);
}

$relatedModel = $relationObject->getRelated();

return $relatedModel;
}

protected function getModelFromRelation(Model $model, string $relation, int $level = 0): ?Model
{
$relationParts = explode('.', $relation);
if (count($relationParts) == 1) {
return $this->getRelatedModelFromRelation($model, $relation);
} else {
$firstRelation = $relationParts[0];
$firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation);
if (! $firstRelatedModel) {
return null;
}

return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1);
}
}
}
83 changes: 83 additions & 0 deletions tests/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Spatie\QueryBuilder\Filters\Filter as FilterInterface;
use Spatie\QueryBuilder\Filters\FiltersExact;
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\Tests\TestClasses\Models\NestedRelatedModel;
use Spatie\QueryBuilder\Tests\TestClasses\Models\RelatedModel;
use Spatie\QueryBuilder\Tests\TestClasses\Models\TestModel;

beforeEach(function () {
Expand Down Expand Up @@ -283,6 +285,87 @@
expect($modelsResult)->toHaveCount(0);
});

it('can filter results by belongs to', function () {
$relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]);
$nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id]);

$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
->get();

expect($modelsResult)->toHaveCount(1);
});

it('can filter results by belongs to no match', function () {
$relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]);
$nestedModel = NestedRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id + 1]);

$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel->id], NestedRelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
->get();

expect($modelsResult)->toHaveCount(0);
});

it('can filter results by belongs multiple', function () {
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]);
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]);
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);

$modelsResult = createQueryFromFilterRequest(['relatedModel' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('relatedModel'))
->get();

expect($modelsResult)->toHaveCount(2);
});

it('can filter results by belongs multiple with different internal name', function () {
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => 0]);
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => 0]);
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);

$modelsResult = createQueryFromFilterRequest(['testFilter' => $relatedModel1->id.','.$relatedModel2->id], NestedRelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('testFilter', 'relatedModel'))
->get();

expect($modelsResult)->toHaveCount(2);
});

it('can filter results by belongs multiple with different internal name and nested model', function () {
$testModel1 = TestModel::create(['name' => 'John Test Doe 1']);
$relatedModel1 = RelatedModel::create(['name' => 'John Related Doe 1', 'test_model_id' => $testModel1->id]);
$nestedModel1 = NestedRelatedModel::create(['name' => 'John Nested Doe 1', 'related_model_id' => $relatedModel1->id]);
$testModel2 = TestModel::create(['name' => 'John Test Doe 2']);
$relatedModel2 = RelatedModel::create(['name' => 'John Related Doe 2', 'test_model_id' => $testModel2->id]);
$nestedModel2 = NestedRelatedModel::create(['name' => 'John Nested Doe 2', 'related_model_id' => $relatedModel2->id]);

$modelsResult = createQueryFromFilterRequest(['test_filter' => $testModel1->id.','.$testModel2->id], NestedRelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('test_filter', 'relatedModel.testModel'))
->get();

expect($modelsResult)->toHaveCount(2);
});

it('throws an exception when trying to filter by belongs to with an inexistent relation', function ($relationName, $exceptionClass) {
$this->expectException($exceptionClass);

$modelsResult = createQueryFromFilterRequest(['test_filter' => 1], RelatedModel::class)
->allowedFilters(AllowedFilter::belongsTo('test_filter', $relationName))
->get();

})->with([
['inexistentRelation', \BadMethodCallException::class],
['testModel.inexistentRelation', \BadMethodCallException::class], // existing 'testModel' belongsTo relation
['inexistentRelation.inexistentRelation', \BadMethodCallException::class],
['getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class],
['testModel.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation
['getTable.getTable', \Illuminate\Database\Eloquent\RelationNotFoundException::class],
['nestedRelatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'nestedRelatedModels' relation but not a belongsTo relation
['testModel.relatedModels', \Illuminate\Database\Eloquent\RelationNotFoundException::class], // existing 'testModel' belongsTo relation and existing 'relatedModels' relation but not a belongsTo relation
]);

it('can filter results by scope', function () {
$testModel = TestModel::create(['name' => 'John Testing Doe']);

Expand Down

0 comments on commit 0348ab0

Please sign in to comment.