Skip to content

Commit 09d9162

Browse files
Add operator filter (<, =, >, ...) (#940)
* [FEAT] add filter by operators * [TEST] add test cases for filter by operator * [FEAT] add dynamic operator to filer by operators * [TEST] test dynamic operator filter
1 parent 34f35ce commit 09d9162

File tree

6 files changed

+224
-0
lines changed

6 files changed

+224
-0
lines changed

src/AllowedFilter.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
namespace Spatie\QueryBuilder;
44

55
use Illuminate\Support\Collection;
6+
use Spatie\QueryBuilder\Enums\FilterOperator;
67
use Spatie\QueryBuilder\Filters\Filter;
78
use Spatie\QueryBuilder\Filters\FiltersBeginsWithStrict;
89
use Spatie\QueryBuilder\Filters\FiltersCallback;
910
use Spatie\QueryBuilder\Filters\FiltersEndsWithStrict;
1011
use Spatie\QueryBuilder\Filters\FiltersExact;
12+
use Spatie\QueryBuilder\Filters\FiltersOperator;
1113
use Spatie\QueryBuilder\Filters\FiltersPartial;
1214
use Spatie\QueryBuilder\Filters\FiltersScope;
1315
use Spatie\QueryBuilder\Filters\FiltersTrashed;
@@ -106,6 +108,13 @@ public static function custom(string $name, Filter $filterClass, $internalName =
106108
return new static($name, $filterClass, $internalName);
107109
}
108110

111+
public static function operator(string $name, FilterOperator $filterOperator, string $boolean = 'and', ?string $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
112+
{
113+
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
114+
115+
return new static($name, new FiltersOperator($addRelationConstraint, $filterOperator, $boolean), $internalName, $filterOperator);
116+
}
117+
109118
public function getFilterClass(): Filter
110119
{
111120
return $this->filterClass;

src/Enums/FilterOperator.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Spatie\QueryBuilder\Enums;
4+
5+
enum FilterOperator: string
6+
{
7+
case DYNAMIC = '';
8+
case EQUAL = '=';
9+
case LESS_THAN = '<';
10+
case GREATER_THAN = '>';
11+
case LESS_THAN_OR_EQUAL = '<=';
12+
case GREATER_THAN_OR_EQUAL = '>=';
13+
case NOT_EQUAL = '<>';
14+
15+
public function isDynamic()
16+
{
17+
return self::DYNAMIC === $this;
18+
}
19+
}

src/Filters/FiltersOperator.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace Spatie\QueryBuilder\Filters;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
use Spatie\QueryBuilder\Enums\FilterOperator;
7+
8+
/**
9+
* @template TModelClass of \Illuminate\Database\Eloquent\Model
10+
* @template-implements \Spatie\QueryBuilder\Filters\Filter<TModelClass>
11+
*/
12+
class FiltersOperator extends FiltersExact implements Filter
13+
{
14+
public function __construct(protected bool $addRelationConstraint, protected FilterOperator $filterOperator, protected string $boolean)
15+
{
16+
}
17+
18+
/** {@inheritdoc} */
19+
public function __invoke(Builder $query, $value, string $property)
20+
{
21+
$filterOperator = $this->filterOperator;
22+
23+
if ($this->addRelationConstraint) {
24+
if ($this->isRelationProperty($query, $property)) {
25+
$this->withRelationConstraint($query, $value, $property);
26+
27+
return;
28+
}
29+
}
30+
31+
if (is_array($value)) {
32+
$query->where(function ($query) use ($value, $property) {
33+
foreach($value as $item) {
34+
$this->__invoke($query, $item, $property);
35+
}
36+
});
37+
38+
return;
39+
}
40+
else if ($this->filterOperator->isDynamic()) {
41+
$filterOperator = $this->getDynamicFilterOperator($value, $this);
42+
$this->removeDynamicFilterOperatorFromValue($value, $filterOperator);
43+
}
44+
45+
$query->where($query->qualifyColumn($property), $filterOperator->value, $value, $this->boolean);
46+
}
47+
48+
protected function getDynamicFilterOperator(string $value): FilterOperator
49+
{
50+
$filterOperator = FilterOperator::EQUAL;
51+
52+
// match filter operators and assign the filter operator.
53+
foreach(FilterOperator::cases() as $filterOperatorCase) {
54+
if (str_starts_with($value, $filterOperatorCase->value) && ! $filterOperatorCase->isDynamic()) {
55+
$filterOperator = $filterOperatorCase;
56+
}
57+
}
58+
59+
return $filterOperator;
60+
}
61+
62+
protected function removeDynamicFilterOperatorFromValue(string &$value, FilterOperator $filterOperator)
63+
{
64+
if (str_contains($value, $filterOperator->value)) {
65+
$value = substr_replace($value, '', 0, strlen($filterOperator->value));
66+
}
67+
}
68+
}

tests/FilterTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use function PHPUnit\Framework\assertObjectHasProperty;
1010

1111
use Spatie\QueryBuilder\AllowedFilter;
12+
use Spatie\QueryBuilder\Enums\FilterOperator;
1213
use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery;
1314
use Spatie\QueryBuilder\Filters\Filter as CustomFilter;
1415
use Spatie\QueryBuilder\Filters\Filter as FilterInterface;
@@ -657,3 +658,116 @@ public function __invoke(Builder $query, $value, string $property): Builder
657658
->get();
658659
expect($models->count())->toEqual(0);
659660
});
661+
662+
it('can filter name with equal operator filter', function () {
663+
TestModel::create(['name' => 'John Doe']);
664+
665+
$results = createQueryFromFilterRequest([
666+
'name' => 'John Doe',
667+
])
668+
->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL))
669+
->get();
670+
671+
expect($results)->toHaveCount(1);
672+
});
673+
674+
it('can filter name with not equal operator filter', function () {
675+
TestModel::create(['name' => 'John Doe']);
676+
677+
$results = createQueryFromFilterRequest([
678+
'name' => 'John Doe',
679+
])
680+
->allowedFilters(AllowedFilter::operator('name', FilterOperator::NOT_EQUAL))
681+
->get();
682+
683+
expect($results)->toHaveCount(5);
684+
});
685+
686+
it('can filter salary with greater than operator filter', function () {
687+
TestModel::create(['salary' => 5000]);
688+
689+
$results = createQueryFromFilterRequest([
690+
'salary' => 3000,
691+
])
692+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN))
693+
->get();
694+
695+
expect($results)->toHaveCount(1);
696+
});
697+
698+
it('can filter salary with less than operator filter', function () {
699+
TestModel::create(['salary' => 5000]);
700+
701+
$results = createQueryFromFilterRequest([
702+
'salary' => 7000,
703+
])
704+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN))
705+
->get();
706+
707+
expect($results)->toHaveCount(1);
708+
});
709+
710+
it('can filter salary with greater than or equal operator filter', function () {
711+
TestModel::create(['salary' => 5000]);
712+
713+
$results = createQueryFromFilterRequest([
714+
'salary' => 3000,
715+
])
716+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN_OR_EQUAL))
717+
->get();
718+
719+
expect($results)->toHaveCount(1);
720+
});
721+
722+
it('can filter salary with less than or equal operator filter', function () {
723+
TestModel::create(['salary' => 5000]);
724+
725+
$results = createQueryFromFilterRequest([
726+
'salary' => 7000,
727+
])
728+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN_OR_EQUAL))
729+
->get();
730+
731+
expect($results)->toHaveCount(1);
732+
});
733+
734+
it('can filter array of names with equal operator filter', function () {
735+
TestModel::create(['name' => 'John Doe']);
736+
TestModel::create(['name' => 'Max Doe']);
737+
738+
$results = createQueryFromFilterRequest([
739+
'name' => 'John Doe,Max Doe',
740+
])
741+
->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL, 'or'))
742+
->get();
743+
744+
expect($results)->toHaveCount(2);
745+
});
746+
747+
it('can filter salary with dynamic operator filter', function () {
748+
TestModel::create(['salary' => 5000]);
749+
TestModel::create(['salary' => 2000]);
750+
751+
$results = createQueryFromFilterRequest([
752+
'salary' => '>2000',
753+
])
754+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC))
755+
->get();
756+
757+
expect($results)->toHaveCount(1);
758+
});
759+
760+
it('can filter salary with dynamic array operator filter', function () {
761+
TestModel::create(['salary' => 1000]);
762+
TestModel::create(['salary' => 2000]);
763+
TestModel::create(['salary' => 3000]);
764+
TestModel::create(['salary' => 4000]);
765+
766+
$results = createQueryFromFilterRequest([
767+
'salary' => '>1000,<4000',
768+
])
769+
->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC))
770+
->get();
771+
772+
expect($results)->toHaveCount(2);
773+
});

tests/RelationFilterTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use Spatie\QueryBuilder\AllowedFilter;
4+
use Spatie\QueryBuilder\Enums\FilterOperator;
45
use Spatie\QueryBuilder\Tests\TestClasses\Models\TestModel;
56

67
beforeEach(function () {
@@ -115,3 +116,15 @@
115116

116117
expect($sql)->toContain('LOWER(`relatedModels`.`name`) LIKE ?');
117118
});
119+
120+
it('can disable operator filtering based on related model properties', function () {
121+
$addRelationConstraint = false;
122+
123+
$sql = createQueryFromFilterRequest([
124+
'relatedModels.name' => $this->models->first()->name,
125+
])
126+
->allowedFilters(AllowedFilter::operator('relatedModels.name', FilterOperator::EQUAL, 'and', null, $addRelationConstraint))
127+
->toSql();
128+
129+
expect($sql)->toContain('`relatedModels`.`name` = ?');
130+
});

tests/TestCase.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ protected function setUpDatabase(Application $app)
3737
$table->increments('id');
3838
$table->timestamps();
3939
$table->string('name')->nullable();
40+
$table->double('salary')->nullable();
4041
$table->boolean('is_visible')->default(true);
4142
});
4243

0 commit comments

Comments
 (0)