Skip to content
Closed
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
57 changes: 54 additions & 3 deletions src/Concerns/FiltersQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function allowedFilters($filters): static

protected function addFiltersToQuery(): void
{
// Apply regular filters (AND logic by default)
$this->allowedFilters->each(function (AllowedFilter $filter) {
if ($this->isFilterRequested($filter)) {
$value = $this->request->filters()->get($filter->getName());
Expand All @@ -43,6 +44,43 @@ protected function addFiltersToQuery(): void
$filter->filter($this, $filter->getDefault());
}
});

// Apply AND filter groups (explicit AND)
$this->request->andFilters()->each(function ($value, $filterName) {
$filter = $this->findFilter($filterName);
if ($filter) {
$filter->filter($this, $value);
}
});

// Apply OR filter groups (OR logic)
$orFilters = $this->request->orFilters();
if ($orFilters->isNotEmpty()) {
$this->getEloquentBuilder()->where(function ($query) use ($orFilters) {
$first = true;
$orFilters->each(function ($value, $filterName) use ($query, &$first) {
$filter = $this->findFilter($filterName);
if ($filter) {
// Create QueryBuilder wrapper for the OR query
$orQueryBuilder = \Spatie\QueryBuilder\QueryBuilder::for($query, $this->request);
$orQueryBuilder->allowedFilters = $this->allowedFilters;

if ($first) {
// First filter in OR group uses where
$filter->filter($orQueryBuilder, $value);
$first = false;
} else {
// Subsequent filters use orWhere
$query->orWhere(function ($orQuery) use ($filter, $value) {
$orQueryBuilder = \Spatie\QueryBuilder\QueryBuilder::for($orQuery, $this->request);
$orQueryBuilder->allowedFilters = $this->allowedFilters;
$filter->filter($orQueryBuilder, $value);
});
}
}
});
});
}
}

protected function findFilter(string $property): ?AllowedFilter
Expand All @@ -64,16 +102,29 @@ protected function ensureAllFiltersExist(): void
return;
}

$filterNames = $this->request->filters()->keys();

$allowedFilterNames = $this->allowedFilters->map(function (AllowedFilter $allowedFilter) {
return $allowedFilter->getName();
});

// Check regular filters
$filterNames = $this->request->filters()->keys();
$diff = $filterNames->diff($allowedFilterNames);

if ($diff->count()) {
throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
}

// Check AND filter groups
$andFilterNames = $this->request->andFilters()->keys();
$andDiff = $andFilterNames->diff($allowedFilterNames);
if ($andDiff->count()) {
throw InvalidFilterQuery::filtersNotAllowed($andDiff, $allowedFilterNames);
}

// Check OR filter groups
$orFilterNames = $this->request->orFilters()->keys();
$orDiff = $orFilterNames->diff($allowedFilterNames);
if ($orDiff->count()) {
throw InvalidFilterQuery::filtersNotAllowed($orDiff, $allowedFilterNames);
}
}
}
45 changes: 45 additions & 0 deletions src/Filters/FiltersExact.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ public function __invoke(Builder $query, $value, string $property)
}
}

// Check if this is a JSON column filter (contains ->)
if ($this->isJsonColumn($property)) {
$this->applyJsonColumnFilter($query, $value, $property);

return;
}

if (is_array($value)) {
$query->whereIn($query->qualifyColumn($property), $value);

Expand All @@ -39,6 +46,44 @@ public function __invoke(Builder $query, $value, string $property)
$query->where($query->qualifyColumn($property), '=', $value);
}

protected function isJsonColumn(string $property): bool
{
return Str::contains($property, '->');
}

protected function applyJsonColumnFilter(Builder $query, mixed $value, string $property): void
{
// For JSON columns, Laravel's where() method handles the -> syntax automatically
// For arrays, use whereJsonContains
if (is_array($value)) {
$query->where(function (Builder $query) use ($value, $property) {
foreach ($value as $item) {
// Check if property ends with array index (e.g., tags->0)
if (preg_match('/->\d+$/', $property)) {
// Extract the base path (e.g., tags->0 becomes tags)
$basePath = preg_replace('/->\d+$/', '', $property);
$query->orWhereJsonContains($query->qualifyColumn($basePath), $item);
} else {
// For JSON object paths, use where with -> syntax
$query->orWhere($query->qualifyColumn($property), '=', $item);
}
}
});

return;
}

// Check if property ends with array index (e.g., tags->0)
if (preg_match('/->\d+$/', $property)) {
// Extract the base path (e.g., tags->0 becomes tags)
$basePath = preg_replace('/->\d+$/', '', $property);
$query->whereJsonContains($query->qualifyColumn($basePath), $value);
} else {
// For JSON object paths, use where with -> syntax
$query->where($query->qualifyColumn($property), '=', $value);
}
}

protected function isRelationProperty(Builder $query, string $property): bool
{
if (! Str::contains($property, '.')) {
Expand Down
77 changes: 77 additions & 0 deletions src/Filters/FiltersPartial.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ public function __invoke(Builder $query, $value, string $property)
}
}

// Check if this is a JSON column filter (contains ->)
if ($this->isJsonColumn($property)) {
$this->applyJsonColumnFilter($query, $value, $property);

return;
}

$wrappedProperty = $query->getQuery()->getGrammar()->wrap($query->qualifyColumn($property));
$databaseDriver = $this->getDatabaseDriver($query);

Expand All @@ -43,6 +50,76 @@ public function __invoke(Builder $query, $value, string $property)
$query->whereRaw($sql, $bindings);
}

protected function isJsonColumn(string $property): bool
{
return \Illuminate\Support\Str::contains($property, '->');
}

protected function applyJsonColumnFilter(Builder $query, mixed $value, string $property): void
{
$wrappedProperty = $query->getQuery()->getGrammar()->wrap($query->qualifyColumn($property));
$databaseDriver = $this->getDatabaseDriver($query);

// For JSON columns with partial matching, we need to use JSON extraction
// Laravel supports JSON_EXTRACT or JSON_UNQUOTE(JSON_EXTRACT) depending on the driver
if (is_array($value)) {
if (count(array_filter($value, fn ($item) => $item != '')) === 0) {
return;
}

$query->where(function (Builder $query) use ($value, $wrappedProperty, $databaseDriver, $property) {
foreach (array_filter($value, fn ($item) => $item != '') as $partialValue) {
// Use JSON extraction for partial matching on JSON columns
$jsonPath = $this->convertJsonPathToSql($query, $property, $databaseDriver);
[$sql, $bindings] = $this->getWhereRawParameters($partialValue, $jsonPath, $databaseDriver);
$query->orWhereRaw($sql, $bindings);
}
});

return;
}

// For single value, use JSON extraction with LIKE
$jsonPath = $this->convertJsonPathToSql($query, $property, $databaseDriver);
[$sql, $bindings] = $this->getWhereRawParameters($value, $jsonPath, $databaseDriver);
$query->whereRaw($sql, $bindings);
}

protected function convertJsonPathToSql(Builder $query, string $property, string $driver): string
{
// Convert metadata->key to JSON_EXTRACT(column, '$.key') or equivalent
[$column, $path] = explode('->', $property, 2);
$qualifiedColumn = $query->getQuery()->getGrammar()->wrap($query->qualifyColumn($column));

// Build JSON path: convert key->nested->0 to $.key.nested[0]
$jsonPath = '$';
$parts = explode('->', $path);
foreach ($parts as $part) {
if (is_numeric($part)) {
$jsonPath .= '[' . $part . ']';
} else {
$jsonPath .= '.' . $part;
}
}

// Database-specific JSON extraction
switch ($driver) {
case 'mysql':
case 'mariadb':
return "LOWER(JSON_UNQUOTE(JSON_EXTRACT({$qualifiedColumn}, '{$jsonPath}')))";
case 'pgsql':
// PostgreSQL uses -> operator for JSON
// Convert path parts to PostgreSQL JSON path syntax
$pgPath = str_replace('->', '->>', $path);
return "LOWER(({$qualifiedColumn}->>'{$pgPath}')::text)";
case 'sqlite':
return "LOWER(json_extract({$qualifiedColumn}, '{$jsonPath}'))";
default:
// Fallback: use Laravel's JSON column syntax which should work
return $query->getQuery()->getGrammar()->wrap($query->qualifyColumn($property));
}
}

protected function getDatabaseDriver(Builder $query): string
{
return $query->getConnection()->getDriverName(); /** @phpstan-ignore-line */
Expand Down
53 changes: 52 additions & 1 deletion src/QueryBuilderRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,58 @@ public function filters(): Collection

$filters = collect($filterParts);

return $filters->map(function ($value) {
// Extract regular filters (not in 'or' or 'and' groups)
$regularFilters = $filters->except(['or', 'and']);

return $regularFilters->map(function ($value) {
return $this->getFilterValue($value);
});
}

/**
* Get OR filter groups
* Returns filters grouped by OR logic: ['name' => 'John', 'email' => '[email protected]']
*/
public function orFilters(): Collection
{
$filterParameterName = config('query-builder.parameters.filter', 'filter');
$filterParts = $this->getRequestData($filterParameterName, []);

if (is_string($filterParts) || ! is_array($filterParts)) {
return collect();
}

$orFilters = $filterParts['or'] ?? [];

if (! is_array($orFilters)) {
return collect();
}

return collect($orFilters)->map(function ($value) {
return $this->getFilterValue($value);
});
}

/**
* Get AND filter groups
* Returns filters grouped by AND logic: ['status' => 'active']
*/
public function andFilters(): Collection
{
$filterParameterName = config('query-builder.parameters.filter', 'filter');
$filterParts = $this->getRequestData($filterParameterName, []);

if (is_string($filterParts) || ! is_array($filterParts)) {
return collect();
}

$andFilters = $filterParts['and'] ?? [];

if (! is_array($andFilters)) {
return collect();
}

return collect($andFilters)->map(function ($value) {
return $this->getFilterValue($value);
});
}
Expand Down
100 changes: 100 additions & 0 deletions tests/FilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -908,3 +908,103 @@ public function __invoke(Builder $query, $value, string $property): Builder

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

it('can filter by JSON column using exact filter', function () {
TestModel::create(['name' => 'John', 'metadata' => json_encode(['theme' => 'dark', 'language' => 'en'])]);
TestModel::create(['name' => 'Jane', 'metadata' => json_encode(['theme' => 'light', 'language' => 'fr'])]);

$results = createQueryFromFilterRequest([
'metadata->theme' => 'dark',
])
->allowedFilters(AllowedFilter::exact('metadata->theme'))
->get();

expect($results)->toHaveCount(1);
expect($results->first()->name)->toBe('John');
});

it('can filter by JSON column using partial filter', function () {
TestModel::create(['name' => 'John', 'metadata' => json_encode(['description' => 'Software developer'])]);
TestModel::create(['name' => 'Jane', 'metadata' => json_encode(['description' => 'Designer'])]);
TestModel::create(['name' => 'Bob', 'metadata' => json_encode(['description' => 'Manager'])]);

$results = createQueryFromFilterRequest([
'metadata->description' => 'developer',
])
->allowedFilters(AllowedFilter::partial('metadata->description'))
->get();

expect($results)->toHaveCount(1);
expect($results->first()->name)->toBe('John');
});

it('can filter by JSON array using exact filter', function () {
TestModel::create(['name' => 'John', 'tags' => json_encode(['php', 'laravel'])]);
TestModel::create(['name' => 'Jane', 'tags' => json_encode(['javascript', 'react'])]);

$results = createQueryFromFilterRequest([
'tags->0' => 'php',
])
->allowedFilters(AllowedFilter::exact('tags->0'))
->get();

expect($results)->toHaveCount(1);
expect($results->first()->name)->toBe('John');
});

it('can filter with OR groups', function () {
TestModel::create(['name' => 'John', 'email' => '[email protected]']);
TestModel::create(['name' => 'Jane', 'email' => '[email protected]']);
TestModel::create(['name' => 'Bob', 'email' => '[email protected]']);

$results = createQueryFromFilterRequest([
'or' => [
'name' => 'John',
'email' => '[email protected]',
],
])
->allowedFilters('name', 'email')
->get();

expect($results)->toHaveCount(2);
expect($results->pluck('name')->toArray())->toContain('John', 'Jane');
});

it('can filter with AND groups', function () {
TestModel::create(['name' => 'John', 'status' => 'active']);
TestModel::create(['name' => 'Jane', 'status' => 'active']);
TestModel::create(['name' => 'Bob', 'status' => 'inactive']);

$results = createQueryFromFilterRequest([
'and' => [
'status' => 'active',
],
'name' => 'John',
])
->allowedFilters('name', 'status')
->get();

expect($results)->toHaveCount(1);
expect($results->first()->name)->toBe('John');
});

it('can filter with mixed AND and OR groups', function () {
TestModel::create(['name' => 'John', 'status' => 'active', 'role' => 'admin']);
TestModel::create(['name' => 'Jane', 'status' => 'active', 'role' => 'moderator']);
TestModel::create(['name' => 'Bob', 'status' => 'inactive', 'role' => 'user']);

$results = createQueryFromFilterRequest([
'and' => [
'status' => 'active',
],
'or' => [
'role' => 'admin',
'role' => 'moderator',
],
])
->allowedFilters('name', 'status', 'role')
->get();

expect($results)->toHaveCount(2);
expect($results->pluck('name')->toArray())->toContain('John', 'Jane');
});
Loading