Skip to content

New Filter belongs to #975

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is $level still being used? I can see it's passed on recursively but never used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a habit, whenever I make a recursive function I send the level parameter, sometimes it is necessary to determine the first iteration of the rest. In this case it was not necessary and I forgot to delete it. You can delete it safely.

{
$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