diff --git a/src/Filters/FiltersBelongsTo.php b/src/Filters/FiltersBelongsTo.php index 059924b9..07d6ba23 100644 --- a/src/Filters/FiltersBelongsTo.php +++ b/src/Filters/FiltersBelongsTo.php @@ -5,7 +5,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\RelationNotFoundException; -use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Arr; /** @@ -17,67 +17,84 @@ class FiltersBelongsTo implements Filter /** {@inheritdoc} */ public function __invoke(Builder $query, $value, string $property) { - $values = array_values(Arr::wrap($value)); + $values = $this->prepareValues($value); + $valuesWithoutNulls = $this->filterNullValues($values); + $withWhereNull = count($values) !== count($valuesWithoutNulls); - $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()) { + if (empty($valuesWithoutNulls) && ! $withWhereNull) { return $query; } + $propertyParts = collect(explode('.', $property)); + $relation = $propertyParts->pop(); + $relationParent = $propertyParts->implode('.'); if ($relationParent) { - $query->whereHas($relationParent, fn (Builder $q) => $q->whereBelongsTo($relatedCollection, $relation)); + $relationObject = $this->getRelationModelFromRelationName( + $this->getModelFromRelationName($query->getModel(), $relationParent), + $relation + ); + $query->whereHas($relationParent, function (Builder $q) use ($relationObject, $withWhereNull, $valuesWithoutNulls) { + $this->applyLastLevelWhere($q, $relationObject, $withWhereNull, $valuesWithoutNulls); + }); } else { - $query->whereBelongsTo($relatedCollection, $relation); + $relationObject = $this->getRelationModelFromRelationName($query->getModel(), $relation); + $this->applyLastLevelWhere($query, $relationObject, $withWhereNull, $valuesWithoutNulls); } } - protected function getRelatedModel(Model $modelQuery, string $relationName, string $relationParent): Model + protected function prepareValues($values): array { - if ($relationParent) { - $modelParent = $this->getModelFromRelation($modelQuery, $relationParent); - } else { - $modelParent = $modelQuery; - } - - $relatedModel = $this->getRelatedModelFromRelation($modelParent, $relationName); - - return $relatedModel; + return array_values(Arr::wrap($values)); } - protected function getRelatedModelFromRelation(Model $model, string $relationName): ?Model + protected function filterNullValues(array $values): array { - $relationObject = $model->$relationName(); - if (! is_subclass_of($relationObject, Relation::class)) { - throw RelationNotFoundException::make($model, $relationName); - } - - $relatedModel = $relationObject->getRelated(); + return array_filter( + $values, + fn ($v) => ! in_array($v, [null, 0, 'null', '0', ''], true) + ); + } - return $relatedModel; + protected function applyLastLevelWhere(Builder $query, BelongsTo $relation, bool $withWhereNull, array $values) + { + $relationColumn = $relation->getQualifiedForeignKeyName(); + $query->where(function (Builder $q) use ($relationColumn, $withWhereNull, $values) { + if ($withWhereNull) { + $q->orWhereNull($relationColumn); + } + if (! empty($values)) { + $q->orWhereIn($relationColumn, $values); + } + }); } - protected function getModelFromRelation(Model $model, string $relation, int $level = 0): ?Model + protected function getModelFromRelationName(Model $model, string $relation, int $level = 0): Model { $relationParts = explode('.', $relation); if (count($relationParts) == 1) { - return $this->getRelatedModelFromRelation($model, $relation); + $relationObject = $this->getRelationModelFromRelationName($model, $relation); + + return $relationObject->getRelated(); } $firstRelation = $relationParts[0]; - $firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation); - if (! $firstRelatedModel) { - return null; + $firstRelationObject = $this->getRelationModelFromRelationName($model, $firstRelation); + + // recursion + return $this->getModelFromRelationName( + $firstRelationObject->getRelated(), + implode('.', array_slice($relationParts, 1)), + $level + 1 + ); + } + + protected function getRelationModelFromRelationName(Model $model, string $relationName): BelongsTo + { + $relationObject = $model->$relationName(); + if (! $relationObject instanceof BelongsTo) { + throw RelationNotFoundException::make($model, $relationName); } - return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1); + return $relationObject; } } diff --git a/tests/FilterTest.php b/tests/FilterTest.php index 75d5042a..c22fcac9 100644 --- a/tests/FilterTest.php +++ b/tests/FilterTest.php @@ -15,6 +15,7 @@ use Spatie\QueryBuilder\Filters\Filter as FilterInterface; use Spatie\QueryBuilder\Filters\FiltersExact; use Spatie\QueryBuilder\QueryBuilder; +use Spatie\QueryBuilder\Tests\TestClasses\Models\NestedNullableRelatedModel; use Spatie\QueryBuilder\Tests\TestClasses\Models\NestedRelatedModel; use Spatie\QueryBuilder\Tests\TestClasses\Models\RelatedModel; use Spatie\QueryBuilder\Tests\TestClasses\Models\TestModel; @@ -296,6 +297,31 @@ expect($modelsResult)->toHaveCount(1); }); +it('can filter results by belongs to with null', function () { + $relatedModel = RelatedModel::create(['name' => 'John Related Doe', 'test_model_id' => 0]); + $nestedModel = NestedNullableRelatedModel::create(['name' => 'John Nested Doe', 'related_model_id' => $relatedModel->id]); + $nestedNullModel = NestedNullableRelatedModel::create(['name' => 'John Nested Doe']); + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => [null]], NestedNullableRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(1); + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => [0]], NestedNullableRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(1); + + + $modelsResult = createQueryFromFilterRequest(['relatedModel' => [$relatedModel->id, null]], NestedNullableRelatedModel::class) + ->allowedFilters(AllowedFilter::belongsTo('relatedModel')) + ->get(); + + expect($modelsResult)->toHaveCount(2); +}); + 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]); diff --git a/tests/TestCase.php b/tests/TestCase.php index 591c22b6..c4cd90b7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -71,6 +71,11 @@ protected function setUpDatabase(Application $app) $table->integer('related_model_id'); $table->string('name'); }); + $app['db']->connection()->getSchemaBuilder()->create('nested_nullable_related_models', function (Blueprint $table) { + $table->increments('id'); + $table->integer('related_model_id')->nullable(); + $table->string('name'); + }); $app['db']->connection()->getSchemaBuilder()->create('pivot_models', function (Blueprint $table) { $table->increments('id'); diff --git a/tests/TestClasses/Models/NestedNullableRelatedModel.php b/tests/TestClasses/Models/NestedNullableRelatedModel.php new file mode 100644 index 00000000..bdcfd896 --- /dev/null +++ b/tests/TestClasses/Models/NestedNullableRelatedModel.php @@ -0,0 +1,18 @@ +belongsTo(RelatedModel::class); + } +}