[](https://packagist.org/packages/spatie/laravel-query-builder)


[](https://packagist.org/packages/spatie/laravel-query-builder)
-
-This package allows you to filter, sort and include eloquent relations based on a request. The `QueryBuilder` used in this package extends Laravel's default Eloquent builder. This means all your favorite methods and macros are still available. Query parameter names follow the [JSON API specification](http://jsonapi.org/) as closely as possible.
+
+
## Caching foreign keys
Add this line to your composer.json file to cache foreign keys. This will allow the query builder to automatically detect foreign keys without having to make a database call.
diff --git a/composer.json b/composer.json
index e7ce390f..a28ea8b6 100644
--- a/composer.json
+++ b/composer.json
@@ -21,19 +21,18 @@
],
"require": {
"php": "^8.2",
- "illuminate/database": "^10.0|^11.0",
- "illuminate/http": "^10.0|^11.0",
- "illuminate/support": "^10.0|^11.0",
+ "illuminate/database": "^10.0|^11.0|^12.0",
+ "illuminate/http": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
"spatie/laravel-package-tools": "^1.11",
"doctrine/dbal": "^3.5"
},
"require-dev": {
"ext-json": "*",
"mockery/mockery": "^1.4",
- "nunomaduro/larastan": "^2.0",
- "orchestra/testbench": "^7.0|^8.0",
- "phpunit/phpunit": "^10.0",
- "pestphp/pest": "^2.0",
+ "orchestra/testbench": "^7.0|^8.0|^10.0",
+ "pestphp/pest": "^2.0|^3.7",
+ "phpunit/phpunit": "^10.0|^11.5.3",
"spatie/invade": "^2.0"
},
"autoload": {
diff --git a/config/query-builder.php b/config/query-builder.php
index 36d3d9f5..94fca196 100644
--- a/config/query-builder.php
+++ b/config/query-builder.php
@@ -60,4 +60,23 @@
* GET /users?fields[userOwner]=id,name
*/
'convert_relation_names_to_snake_case_plural' => true,
+
+ /*
+ * By default, the package expects relationship names to be snake case plural when using fields[relationship].
+ * For example, fetching the id and name for a userOwner relation would look like this:
+ * GET /users?fields[user_owner]=id,name
+ *
+ * Set this to one of `snake_case`, `camelCase` or `none` if you want to enable table name resolution in addition to the relation name resolution
+ * GET /users?include=topOrders&fields[orders]=id,name
+ */
+ 'convert_relation_table_name_strategy' => false,
+
+ /*
+ * By default, the package expects the field names to match the database names
+ * For example, fetching the field named firstName would look like this:
+ * GET /users?fields=firstName
+ *
+ * Set this to `true` if you want to convert the firstName into first_name for the underlying query
+ */
+ 'convert_field_names_to_snake_case' => false,
];
diff --git a/database/factories/AppendModelFactory.php b/database/factories/AppendModelFactory.php
index 3591b07e..26b38331 100644
--- a/database/factories/AppendModelFactory.php
+++ b/database/factories/AppendModelFactory.php
@@ -2,8 +2,8 @@
namespace Spatie\QueryBuilder\Database\Factories;
-use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;
use Illuminate\Database\Eloquent\Factories\Factory;
+use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;
class AppendModelFactory extends Factory
{
diff --git a/database/factories/TestModelFactory.php b/database/factories/TestModelFactory.php
index 8b1001ad..8965abfb 100644
--- a/database/factories/TestModelFactory.php
+++ b/database/factories/TestModelFactory.php
@@ -16,4 +16,3 @@ public function definition()
];
}
}
-
diff --git a/docs/_index.md b/docs/_index.md
index f17c1c36..2c3ffc19 100644
--- a/docs/_index.md
+++ b/docs/_index.md
@@ -1,5 +1,5 @@
---
-title: v5
+title: v6
slogan: Easily build Eloquent queries from API requests.
githubUrl: https://github.com/spatie/laravel-query-builder
branch: main
diff --git a/docs/advanced-usage/front-end-implementation.md b/docs/advanced-usage/front-end-implementation.md
index fe90478f..f532888a 100644
--- a/docs/advanced-usage/front-end-implementation.md
+++ b/docs/advanced-usage/front-end-implementation.md
@@ -1,6 +1,6 @@
---
title: Front-end implementation
-weight: 3
+weight: 6
---
If you're interested in building query urls on the front-end to match this package, you could use one of the below:
@@ -11,3 +11,4 @@ If you're interested in building query urls on the front-end to match this packa
Pascal Baljet](https://github.com/pascalbaljet).
- React: [cogent-js package](https://www.npmjs.com/package/cogent-js) by [Joel Male](https://github.com/joelwmale).
- Typescript: [query-builder-ts package](https://www.npmjs.com/package/@vortechron/query-builder-ts) by [Amirul Adli](https://www.npmjs.com/~vortechron)
+- Typescript + React [react-query-builder](https://www.npmjs.com/package/@cgarciagarcia/react-query-builder) by [Carlos Garcia](https://github.com/cgarciagarcia)
diff --git a/docs/features/filtering.md b/docs/features/filtering.md
index 178bb8cd..f2a7e0e5 100644
--- a/docs/features/filtering.md
+++ b/docs/features/filtering.md
@@ -86,6 +86,40 @@ $users = QueryBuilder::for(User::class)
// $users will contain all admin users with id 1, 2, 3, 4 or 5
```
+## Operator filters
+
+Operator filters allow you to filter results based on different operators such as EQUAL, NOT_EQUAL, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, and DYNAMIC. You can use the `AllowedFilter::operator` method to create operator filters.
+
+```php
+use Spatie\QueryBuilder\AllowedFilter;
+use Spatie\QueryBuilder\Enums\FilterOperator;
+
+// GET /users?filter[salary]=3000
+$users = QueryBuilder::for(User::class)
+ ->allowedFilters([
+ AllowedFilter::operator('salary', FilterOperator::GREATER_THAN),
+ ])
+ ->get();
+
+// $users will contain all users with a salary greater than 3000
+```
+
+You can also use dynamic operator filters, which allow you to specify the operator in the filter value:
+
+```php
+use Spatie\QueryBuilder\AllowedFilter;
+use Spatie\QueryBuilder\Enums\FilterOperator;
+
+// GET /users?filter[salary]=>3000
+$users = QueryBuilder::for(User::class)
+ ->allowedFilters([
+ AllowedFilter::operator('salary', FilterOperator::DYNAMIC),
+ ])
+ ->get();
+
+// $users will contain all users with a salary greater than 3000
+```
+
## Exact or partial filters for related properties
You can also add filters for a relationship property using the dot-notation: `AllowedFilter::exact('posts.title')`. This works for exact and partial filters. Under the hood we'll add a `whereHas` statement for the `posts` that filters for the given `title` property as well.
@@ -100,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.
@@ -137,6 +220,14 @@ You can even pass multiple parameters to the scope by passing a comma separated
GET /events?filter[schedule.starts_between]=2018-01-01,2018-12-31
```
+When passing an array as a parameter you can access it, as an array, in the scope by using the spread operator.
+```php
+public function scopeInvitedUsers(Builder $query, ...$users): Builder
+{
+ return $query->whereIn('id', $users);
+}
+```
+
When using scopes that require model instances in the parameters, we'll automatically try to inject the model instances into your scope. This works the same way as route model binding does for injecting Eloquent models into controllers. For example:
```php
@@ -148,6 +239,12 @@ public function scopeEvent(Builder $query, \App\Models\Event $event): Builder
// GET /events?filter[event]=1 - the event with ID 1 will automatically be resolved and passed to the scoped filter
```
+If you use any other column aside `id` column for route model binding (ULID,UUID). Remeber to specify the value of the column used in route model binding
+
+```php
+// GET /events?filter[event]=01j0rcpkx5517v0aqyez5vnwn - supposing we use a ULID column for route model binding.
+```
+
Scopes are usually not named with query filters in mind. Use [filter aliases](#filter-aliases) to alias them to something more appropriate:
```php
diff --git a/docs/features/selecting-fields.md b/docs/features/selecting-fields.md
index 2ab16d32..b613e719 100644
--- a/docs/features/selecting-fields.md
+++ b/docs/features/selecting-fields.md
@@ -9,8 +9,8 @@ Sometimes you'll want to fetch only a couple fields to reduce the overall size o
The following example fetches only the users' `id` and `name`:
-```
-GET /users?fields[users]=id,name
+```php
+// GET /users?fields[users]=id,name
$users = QueryBuilder::for(User::class)
->allowedFields(['id', 'name'])
@@ -51,7 +51,7 @@ QueryBuilder::for(Post::class)
// All posts will be fetched including _only_ the name of the author.
```
-⚠️ Keep in mind that the fields query will completely override the `SELECT` part of the query. This means that you'll need to manually specify any columns required for Eloquent relationships to work, in the above example `author.id`. See issue #175 as well.
+⚠️ Keep in mind that the fields query will completely override the `SELECT` part of the query. This means that you'll need to manually specify any columns required for Eloquent relationships to work, in the above example `author.id`. See issue [#175](https://github.com/spatie/laravel-query-builder/issues/175) as well.
⚠️ `allowedFields` must be called before `allowedIncludes`. Otherwise the query builder won't know what fields to include for the requested includes and an exception will be thrown.
diff --git a/docs/introduction.md b/docs/introduction.md
index 3baf681f..b7f5b797 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -23,7 +23,7 @@ $users = QueryBuilder::for(User::class)
// all `User`s that contain the string "John" in their name
```
-[Read more about filtering features like: partial filters, exact filters, scope filters, custom filters, ignored values, default filter values, ...](https://spatie.be/docs/laravel-query-builder/v5/features/filtering/)
+[Read more about filtering features like: partial filters, exact filters, scope filters, custom filters, ignored values, default filter values, ...](https://spatie.be/docs/laravel-query-builder/v6/features/filtering/)
### Including relations based on a request: `/users?include=posts`:
@@ -35,7 +35,7 @@ $users = QueryBuilder::for(User::class)
// all `User`s with their `posts` loaded
```
-[Read more about include features like: including nested relationships, including relationship count, ...](https://spatie.be/docs/laravel-query-builder/v5/features/including-relationships/)
+[Read more about include features like: including nested relationships, including relationship count, ...](https://spatie.be/docs/laravel-query-builder/v6/features/including-relationships/)
### Sorting a query based on a request: `/users?sort=id`:
@@ -47,7 +47,7 @@ $users = QueryBuilder::for(User::class)
// all `User`s sorted by ascending id
```
-[Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v5/features/sorting/)
+[Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v6/features/sorting/)
### Works together nicely with existing queries:
@@ -70,7 +70,7 @@ $users = QueryBuilder::for(User::class)
// the fetched `User`s will only have their id & email set
```
-[Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v5/features/selecting-fields/)
+[Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v6/features/selecting-fields/)
## We have badges!
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index c590d9e3..6dcbbdec 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -1,5 +1,5 @@
includes:
- - ./vendor/nunomaduro/larastan/extension.neon
+ - ./vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
@@ -14,11 +14,9 @@ parameters:
checkModelProperties: true
checkOctaneCompatibility: true
- checkMissingIterableValueType: false
reportUnmatchedIgnoredErrors: false
noUnnecessaryCollectionCall: true
checkNullables: true
- checkGenericClassInNonGenericObjectType: false
treatPhpDocTypesAsCertain: false
ignoreErrors:
diff --git a/src/AllowedField.php b/src/AllowedField.php
index 2be79d1d..6689a952 100644
--- a/src/AllowedField.php
+++ b/src/AllowedField.php
@@ -3,6 +3,7 @@
namespace Spatie\QueryBuilder;
use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
class AllowedField
{
@@ -36,8 +37,12 @@ public function getName(): string
return $this->name;
}
- public function getInternalNames(): Collection
+ public function getInternalNames(bool $snakeCase = false): Collection
{
+ if ($snakeCase) {
+ return $this->internalNames->map(fn ($name) => Str::snake($name));
+ }
+
return $this->internalNames;
}
}
diff --git a/src/AllowedFilter.php b/src/AllowedFilter.php
index a9ba5bb0..c4ea46ee 100644
--- a/src/AllowedFilter.php
+++ b/src/AllowedFilter.php
@@ -3,11 +3,14 @@
namespace Spatie\QueryBuilder;
use Illuminate\Support\Collection;
+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;
+use Spatie\QueryBuilder\Filters\FiltersOperator;
use Spatie\QueryBuilder\Filters\FiltersPartial;
use Spatie\QueryBuilder\Filters\FiltersScope;
use Spatie\QueryBuilder\Filters\FiltersTrashed;
@@ -45,67 +48,81 @@ public function filter(QueryBuilder $query, $value): void
($this->filterClass)($query->getEloquentBuilder(), $valueToFilter, $this->internalName);
}
- public static function setFilterArrayValueDelimiter(string $delimiter = null): void
+ public static function setFilterArrayValueDelimiter(?string $delimiter = null): void
{
if (isset($delimiter)) {
QueryBuilderRequest::setFilterArrayValueDelimiter($delimiter);
}
}
- public static function exact(string $name, ?string $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
+ public static function exact(string $name, ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, new FiltersExact($addRelationConstraint), $internalName);
}
- public static function partial(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
+ public static function partial(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, new FiltersPartial($addRelationConstraint), $internalName);
}
- public static function beginsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
+ public static function beginsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, new FiltersBeginsWithStrict($addRelationConstraint), $internalName);
}
- public static function endsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
+ public static function endsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, new FiltersEndsWithStrict($addRelationConstraint), $internalName);
}
- public static function scope(string $name, $internalName = null, string $arrayValueDelimiter = null): self
+ 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);
return new static($name, new FiltersScope(), $internalName);
}
- public static function callback(string $name, $callback, $internalName = null, string $arrayValueDelimiter = null): self
+ public static function callback(string $name, $callback, $internalName = null, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, new FiltersCallback($callback), $internalName);
}
- public static function trashed(string $name = 'trashed', $internalName = null): self
+ public static function trashed(string $name = 'trashed', $internalName = null): static
{
return new static($name, new FiltersTrashed(), $internalName);
}
- public static function custom(string $name, Filter $filterClass, $internalName = null, string $arrayValueDelimiter = null): self
+ public static function custom(string $name, Filter $filterClass, $internalName = null, ?string $arrayValueDelimiter = null): static
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);
return new static($name, $filterClass, $internalName);
}
+ public static function operator(string $name, FilterOperator $filterOperator, string $boolean = 'and', ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): self
+ {
+ static::setFilterArrayValueDelimiter($arrayValueDelimiter);
+
+ return new static($name, new FiltersOperator($addRelationConstraint, $filterOperator, $boolean), $internalName);
+ }
+
public function getFilterClass(): Filter
{
return $this->filterClass;
@@ -121,7 +138,7 @@ public function isForFilter(string $filterName): bool
return $this->name === $filterName;
}
- public function ignore(...$values): self
+ public function ignore(...$values): static
{
$this->ignored = $this->ignored
->merge($values)
@@ -140,7 +157,7 @@ public function getInternalName(): string
return $this->internalName;
}
- public function default($value): self
+ public function default($value): static
{
$this->hasDefault = true;
$this->default = $value;
@@ -162,14 +179,14 @@ public function hasDefault(): bool
return $this->hasDefault;
}
- public function nullable(bool $nullable = true): self
+ public function nullable(bool $nullable = true): static
{
$this->nullable = $nullable;
return $this;
}
- public function unsetDefault(): self
+ public function unsetDefault(): static
{
$this->hasDefault = false;
unset($this->default);
diff --git a/src/Concerns/AddsFieldsToQuery.php b/src/Concerns/AddsFieldsToQuery.php
index 726574d1..f276cd73 100644
--- a/src/Concerns/AddsFieldsToQuery.php
+++ b/src/Concerns/AddsFieldsToQuery.php
@@ -40,13 +40,21 @@ protected function addRequestedModelFieldsToQuery(): void
{
$modelTableName = $this->getModel()->getTable();
+ if (config('query-builder.convert_relation_table_name_strategy', false) === 'camelCase') {
+ $modelTableName = Str::camel($modelTableName);
+ }
+
+ if (config('query-builder.convert_relation_table_name_strategy', false) === 'snake_case') {
+ $modelTableName = Str::snake($modelTableName);
+ }
+
$requestFields = $this->request->fields()->map(function ($field) {
return $field->name;
});
$modelFields = $this->allowedFields->mapWithKeys(function (AllowedField $field) {
return [
- $field->getName() => $field->getInternalNames()->toArray(),
+ $field->getName() => $field->getInternalNames(config('query-builder.convert_field_names_to_snake_case', false))->toArray(),
];
});
@@ -73,26 +81,64 @@ protected function addRequestedModelFieldsToQuery(): void
$this->select($prependedFields);
}
- public function getRequestedFieldsForRelatedTable(string $relation): array
+ public function getRequestedFieldsForRelatedTable(string $relation, ?string $tableName = null): array
{
- $tableOrRelation = config('query-builder.convert_relation_names_to_snake_case_plural', true)
+ // Build list of possible table names to check
+ $possibleRelatedNames = [];
+
+ // Original table name conversion logic
+ $possibleRelatedNames[] = config('query-builder.convert_relation_names_to_snake_case_plural', true)
? Str::plural(Str::snake($relation))
: $relation;
- $fields = $this->request->fields()
- ->mapWithKeys(fn ($fields, $table) => [$table => $fields])
- ->get($tableOrRelation);
+ // New strategy-based conversions
+ $strategy = config('query-builder.convert_relation_table_name_strategy', false);
+ if ($tableName) {
+ if ($strategy === 'snake_case') {
+ $possibleRelatedNames[] = Str::snake($tableName);
+ } elseif ($strategy === 'camelCase') {
+ $possibleRelatedNames[] = Str::camel($tableName);
+ } elseif ($strategy === 'none') {
+ $possibleRelatedNames[] = $tableName;
+ }
+ }
- if (! $fields) {
+ // Get fields with potential snake_case conversion
+ $fields = $this->request->fields();
+
+ if (config('query-builder.convert_field_names_to_snake_case', false)) {
+ $fields = $fields->mapWithKeys(fn ($fields, $table) => [
+ $table => collect($fields)->map(fn ($field) => Str::snake($field)),
+ ]);
+ }
+
+ // Find fields for any of the possible table names
+ $matchedFields = null;
+ foreach ($possibleRelatedNames as $tableName) {
+ if ($fields->has($tableName)) {
+ $matchedFields = $fields->get($tableName);
+
+ break;
+ }
+ }
+
+ if (! $matchedFields) {
return [];
}
+ $matchedFields = $matchedFields->toArray();
+
+ // Validate against allowed fields as in original implementation
if (! $this->allowedFields instanceof Collection) {
- // We have requested fields but no allowed fields (yet?)
- throw new UnknownIncludedFieldsQuery($fields);
+ throw new UnknownIncludedFieldsQuery($matchedFields);
+ }
+
+ // Prepend table name if provided (from new implementation)
+ if ($tableName !== null) {
+ $matchedFields = $this->prependFieldsWithTableName($matchedFields, $tableName);
}
- return $fields;
+ return $matchedFields;
}
protected function ensureAllFieldsExist(): void
diff --git a/src/Enums/FilterOperator.php b/src/Enums/FilterOperator.php
new file mode 100644
index 00000000..451f37e7
--- /dev/null
+++ b/src/Enums/FilterOperator.php
@@ -0,0 +1,19 @@
+';
+ case LESS_THAN_OR_EQUAL = '<=';
+ case GREATER_THAN_OR_EQUAL = '>=';
+ case NOT_EQUAL = '<>';
+
+ public function isDynamic()
+ {
+ return self::DYNAMIC === $this;
+ }
+}
diff --git a/src/Filters/FiltersBelongsTo.php b/src/Filters/FiltersBelongsTo.php
new file mode 100644
index 00000000..059924b9
--- /dev/null
+++ b/src/Filters/FiltersBelongsTo.php
@@ -0,0 +1,83 @@
+
+ */
+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);
+ }
+
+ $firstRelation = $relationParts[0];
+ $firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation);
+ if (! $firstRelatedModel) {
+ return null;
+ }
+
+ return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1);
+ }
+}
diff --git a/src/Filters/FiltersExact.php b/src/Filters/FiltersExact.php
index 58c07818..b84ad991 100644
--- a/src/Filters/FiltersExact.php
+++ b/src/Filters/FiltersExact.php
@@ -66,7 +66,7 @@ protected function withRelationConstraint(Builder $query, mixed $value, string $
$parts->last(),
]);
- $query->whereHas($relation, function (Builder $query) use ($value, $property) {
+ $query->whereHas($relation, function (Builder $query) use ($property, $value) {
$this->relationConstraints[] = $property = $query->qualifyColumn($property);
$this->__invoke($query, $value, $property);
diff --git a/src/Filters/FiltersOperator.php b/src/Filters/FiltersOperator.php
new file mode 100644
index 00000000..157649e7
--- /dev/null
+++ b/src/Filters/FiltersOperator.php
@@ -0,0 +1,66 @@
+
+ */
+class FiltersOperator extends FiltersExact implements Filter
+{
+ public function __construct(protected bool $addRelationConstraint, protected FilterOperator $filterOperator, protected string $boolean)
+ {
+ }
+
+ /** {@inheritdoc} */
+ public function __invoke(Builder $query, $value, string $property)
+ {
+ $filterOperator = $this->filterOperator;
+
+ if ($this->addRelationConstraint) {
+ if ($this->isRelationProperty($query, $property)) {
+ $this->withRelationConstraint($query, $value, $property);
+
+ return;
+ }
+ }
+
+ if (is_array($value)) {
+ $query->where(function ($query) use ($value, $property) {
+ foreach ($value as $item) {
+ $this->__invoke($query, $item, $property);
+ }
+ });
+
+ return;
+ } elseif ($this->filterOperator->isDynamic()) {
+ $filterOperator = $this->getDynamicFilterOperator($value);
+ $this->removeDynamicFilterOperatorFromValue($value, $filterOperator);
+ }
+
+ $query->where($query->qualifyColumn($property), $filterOperator->value, $value, $this->boolean);
+ }
+
+ protected function getDynamicFilterOperator(string $value): FilterOperator
+ {
+ $filterOperator = FilterOperator::EQUAL;
+
+ foreach (FilterOperator::cases() as $filterOperatorCase) {
+ if (str_starts_with($value, $filterOperatorCase->value) && ! $filterOperatorCase->isDynamic()) {
+ $filterOperator = $filterOperatorCase;
+ }
+ }
+
+ return $filterOperator;
+ }
+
+ protected function removeDynamicFilterOperatorFromValue(string &$value, FilterOperator $filterOperator)
+ {
+ if (str_contains($value, $filterOperator->value)) {
+ $value = substr_replace($value, '', 0, strlen($filterOperator->value));
+ }
+ }
+}
diff --git a/src/Filters/FiltersPartial.php b/src/Filters/FiltersPartial.php
index 77db819c..dbea2f3c 100644
--- a/src/Filters/FiltersPartial.php
+++ b/src/Filters/FiltersPartial.php
@@ -74,7 +74,7 @@ protected static function escapeLike(string $value): string
*/
protected static function maybeSpecifyEscapeChar(string $driver): string
{
- if (! in_array($driver, ['sqlite','pgsql','sqlsrv'])) {
+ if (! in_array($driver, ['sqlite','sqlsrv'])) {
return '';
}
diff --git a/src/Filters/FiltersScope.php b/src/Filters/FiltersScope.php
index eca6829a..5be5d508 100644
--- a/src/Filters/FiltersScope.php
+++ b/src/Filters/FiltersScope.php
@@ -54,12 +54,12 @@ protected function resolveParameters(Builder $query, $values, string $scope): ar
}
foreach ($parameters as $parameter) {
- if (! optional($this->getClass($parameter))->isSubclassOf(Model::class)) {
+ if (! $this->getClass($parameter)?->isSubclassOf(Model::class)) {
continue;
}
/** @var TModelClass $model */
- $model = $this->getClass($parameter)?->newInstance();
+ $model = $this->getClass($parameter)->newInstance();
$index = $parameter->getPosition() - 1;
$value = $values[$index];
diff --git a/src/Includes/IncludedCount.php b/src/Includes/IncludedCount.php
index 4e2c05d6..67f3eb1a 100644
--- a/src/Includes/IncludedCount.php
+++ b/src/Includes/IncludedCount.php
@@ -9,6 +9,9 @@ class IncludedCount implements IncludeInterface
{
public function __invoke(Builder $query, string $count)
{
- $query->withCount(Str::before($count, config('query-builder.count_suffix', 'Count')));
+ $suffix = config('query-builder.count_suffix', 'Count');
+ $relation = Str::endsWith($count, $suffix) ? Str::beforeLast($count, $suffix) : $count;
+
+ $query->withCount($relation);
}
}
diff --git a/src/Includes/IncludedRelationship.php b/src/Includes/IncludedRelationship.php
index 0df9e279..9aca7d8d 100644
--- a/src/Includes/IncludedRelationship.php
+++ b/src/Includes/IncludedRelationship.php
@@ -3,6 +3,7 @@
namespace Spatie\QueryBuilder\Includes;
use Closure;
+use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@@ -16,11 +17,27 @@ public function __invoke(Builder $query, string $relationship)
$relatedTables = collect(explode('.', $relationship));
$withs = $relatedTables
- ->mapWithKeys(function ($table, $key) use ($relatedTables) {
+ ->mapWithKeys(function ($table, $key) use ($relatedTables, $query) {
$fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
if ($this->getRequestedFieldsForRelatedTable) {
- $fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName);
+
+ $tableName = null;
+ $strategy = config('query-builder.convert_relation_table_name_strategy', false);
+
+ if ($strategy !== false) {
+ // Try to resolve the related model's table name
+ try {
+ // Use the current query's model to resolve the relationship
+ $relatedModel = $query->getModel()->{$fullRelationName}()->getRelated();
+ $tableName = $relatedModel->getTable();
+ } catch (Exception $e) {
+ // If we can not figure out the table don't do anything
+ $tableName = null;
+ }
+ }
+
+ $fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName, $tableName);
}
if (empty($fields)) {
@@ -28,7 +45,7 @@ public function __invoke(Builder $query, string $relationship)
}
return [$fullRelationName => function ($query) use ($fields) {
- $query->select($fields);
+ $query->select($query->qualifyColumns($fields));
}];
})
->toArray();
diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php
index 80366e85..eb181915 100755
--- a/src/QueryBuilder.php
+++ b/src/QueryBuilder.php
@@ -14,7 +14,8 @@
use Spatie\QueryBuilder\Concerns\SortsQuery;
/**
- * @mixin EloquentBuilder
+ * @template TModel of Model
+ * @mixin EloquentBuilder
*/
class QueryBuilder implements ArrayAccess
{
@@ -57,7 +58,10 @@ public static function for(
$subject = $subject::query();
}
- return new static($subject, $request);
+ /** @var static $queryBuilder */
+ $queryBuilder = new static($subject, $request);
+
+ return $queryBuilder;
}
public function __call($name, $arguments)
diff --git a/tests/FieldsTest.php b/tests/FieldsTest.php
index af5c12f1..a8148b0a 100644
--- a/tests/FieldsTest.php
+++ b/tests/FieldsTest.php
@@ -83,6 +83,21 @@
expect($query)->toEqual($expected);
});
+it('can fetch specific string columns jsonApi Format', function () {
+ config(['query-builder.convert_field_names_to_snake_case' => true]);
+ config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
+
+ $query = createQueryFromFieldRequest('firstName,id')
+ ->allowedFields(['firstName', 'id'])
+ ->toSql();
+
+ $expected = TestModel::query()
+ ->select("{$this->modelTableName}.first_name", "{$this->modelTableName}.id")
+ ->toSql();
+
+ expect($query)->toEqual($expected);
+});
+
it('wont fetch a specific array column if its not allowed', function () {
$query = createQueryFromFieldRequest(['test_models' => 'random-column'])->toSql();
@@ -174,7 +189,7 @@
$queryBuilder->first()->relatedModels;
$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
- $this->assertQueryLogContains('select `name` from `related_models`');
+ $this->assertQueryLogContains('select `related_models`.`name` from `related_models`');
});
it('can fetch only requested string columns from an included model', function () {
@@ -197,7 +212,104 @@
$queryBuilder->first()->relatedModels;
$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
- $this->assertQueryLogContains('select `name` from `related_models`');
+ $this->assertQueryLogContains('select `related_models`.`name` from `related_models`');
+});
+
+it('can fetch only requested string columns from an included belongs to many model', function () {
+ TestModel::first()->relatedThroughPivotModels()->create([
+ 'name' => 'related',
+ ]);
+
+ $request = new Request([
+ 'fields' => 'id,related_through_pivot_models.id,related_through_pivot_models.name',
+ 'include' => ['relatedThroughPivotModels'],
+ ]);
+
+ $queryBuilder = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFields('id', 'related_through_pivot_models.id', 'related_through_pivot_models.name')
+ ->allowedIncludes('relatedThroughPivotModels');
+
+ DB::enableQueryLog();
+
+ $queryBuilder->first()->relatedThroughPivotModels;
+
+ $this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
+ $this->assertQueryLogContains('select `related_through_pivot_models`.`id`, `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models` inner join `pivot_models` on `related_through_pivot_models`.`id` = `pivot_models`.`related_through_pivot_model_id` where `pivot_models`.`test_model_id` in (');
+});
+
+it('can fetch only requested string columns from an included model jsonApi format', function () {
+ config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
+ RelatedModel::create([
+ 'test_model_id' => $this->model->id,
+ 'name' => 'related',
+ ]);
+
+ $request = new Request([
+ 'fields' => 'id,relatedModels.name',
+ 'include' => ['relatedModels'],
+ ]);
+
+ $queryBuilder = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFields('relatedModels.name', 'id')
+ ->allowedIncludes('relatedModels');
+
+ DB::enableQueryLog();
+
+ $queryBuilder->first()->relatedModels;
+
+ $this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
+ $this->assertQueryLogContains('select `related_models`.`name` from `related_models`');
+});
+
+it('can fetch only requested string columns from an included model jsonApi format with field conversion', function () {
+ config(['query-builder.convert_field_names_to_snake_case' => true]);
+ config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
+
+ RelatedModel::create([
+ 'test_model_id' => $this->model->id,
+ 'name' => 'related',
+ ]);
+
+ $request = new Request([
+ 'fields' => 'id,relatedModels.fullName',
+ 'include' => ['relatedModels'],
+ ]);
+
+ $queryBuilder = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFields('relatedModels.fullName', 'id')
+ ->allowedIncludes('relatedModels');
+
+ DB::enableQueryLog();
+
+ $queryBuilder->first()->relatedModels;
+
+ $this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
+ $this->assertQueryLogContains('select `related_models`.`full_name` from `related_models`');
+});
+
+it('can fetch only requested string columns from an included model through pivot jsonApi format', function () {
+ config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
+
+ $this->model->relatedThroughPivotModels()->create([
+ 'id' => $this->model->id + 1,
+ 'name' => 'Test',
+ ]);
+
+ $request = new Request([
+ 'fields' => 'id,relatedThroughPivotModels.name',
+ 'include' => ['relatedThroughPivotModels'],
+ ]);
+
+ $queryBuilder = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFields('relatedThroughPivotModels.name', 'id')
+ ->allowedIncludes('relatedThroughPivotModels');
+
+ DB::enableQueryLog();
+
+ $queryBuilder->first()->relatedThroughPivotModels;
+
+ $this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
+ $this->assertQueryLogContains('select `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models`');
});
it('can fetch requested array columns from included models up to two levels deep', function () {
@@ -224,6 +336,36 @@
expect($result->relatedModels->first()->testModel->toArray())->toEqual(['id' => $this->model->id]);
});
+it('can fetch requested array columns from included models up to two levels deep jsonApi mapper', function () {
+ config(['query-builder.convert_field_names_to_snake_case' => true]);
+ config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
+
+ $relatedModel = RelatedModel::create([
+ 'test_model_id' => $this->model->id,
+ 'name' => 'related',
+ ]);
+
+ $relatedModel->nestedRelatedModels()->create([
+ 'name' => 'nested related',
+ ]);
+
+ $request = new Request([
+ 'fields' => 'id,name,relatedModels.id,relatedModels.name,nestedRelatedModels.id,nestedRelatedModels.name',
+ 'include' => ['nestedRelatedModels', 'relatedModels'],
+ ]);
+
+
+ $queryBuilder = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFields('id', 'name', 'relatedModels.id', 'relatedModels.name', 'nestedRelatedModels.id', 'nestedRelatedModels.name')
+ ->allowedIncludes('relatedModels', 'nestedRelatedModels');
+
+ DB::enableQueryLog();
+ $queryBuilder->first();
+
+ $this->assertQueryLogContains('select `test_models`.`id`, `test_models`.`name` from `test_models`');
+ $this->assertQueryLogContains('select `nested_related_models`.`id`, `nested_related_models`.`name`, `related_models`.`test_model_id` as `laravel_through_key` from `nested_related_models`');
+});
+
it('can fetch requested string columns from included models up to two levels deep', function () {
RelatedModel::create([
'test_model_id' => $this->model->id,
@@ -299,7 +441,7 @@
$queryBuilder->first()->relatedModels;
$this->assertQueryLogContains('select * from `test_models`');
- $this->assertQueryLogContains('select `id`, `name` from `related_models`');
+ $this->assertQueryLogContains('select `related_models`.`id`, `related_models`.`name` from `related_models`');
});
it('wont use sketchy field requests', function () {
diff --git a/tests/FilterTest.php b/tests/FilterTest.php
index 650abbd9..75d5042a 100644
--- a/tests/FilterTest.php
+++ b/tests/FilterTest.php
@@ -9,11 +9,14 @@
use function PHPUnit\Framework\assertObjectHasProperty;
use Spatie\QueryBuilder\AllowedFilter;
+use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery;
use Spatie\QueryBuilder\Filters\Filter as CustomFilter;
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 () {
@@ -30,10 +33,38 @@
expect($models)->toHaveCount(1);
});
+it('can use a custom filter query string parameter', function () {
+ config(['query-builder.parameters.filter' => 'custom_filter']);
+
+ $request = new Request([
+ 'custom_filter' => ['name' => $this->models->first()->name],
+ ]);
+
+ $models = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFilters('name')
+ ->get();
+
+ expect($models)->toHaveCount(1);
+});
+
+it('can work without a general filter query string parameter configured', function () {
+ config(['query-builder.parameters.filter' => null]);
+
+ $request = new Request([
+ 'name' => $this->models->first()->name,
+ ]);
+
+ $models = QueryBuilder::for(TestModel::class, $request)
+ ->allowedFilters('name')
+ ->get();
+
+ expect($models)->toHaveCount(1);
+});
+
it('can filter models by an array as filter value', function () {
$models = createQueryFromFilterRequest([
- 'name' => ['first' => $this->models->first()->name],
- ])
+ 'name' => ['first' => $this->models->first()->name],
+ ])
->allowedFilters('name')
->get();
@@ -42,8 +73,8 @@
it('can filter partially and case insensitive', function () {
$models = createQueryFromFilterRequest([
- 'name' => strtoupper($this->models->first()->name),
- ])
+ 'name' => strtoupper($this->models->first()->name),
+ ])
->allowedFilters('name')
->get();
@@ -55,8 +86,8 @@
$model2 = TestModel::create(['name' => 'uvwxyz']);
$results = createQueryFromFilterRequest([
- 'name' => 'abc,xyz',
- ])
+ 'name' => 'abc,xyz',
+ ])
->allowedFilters('name')
->get();
@@ -66,8 +97,8 @@
it('can filter models and return an empty collection', function () {
$models = createQueryFromFilterRequest([
- 'name' => 'None existing first name',
- ])
+ 'name' => 'None existing first name',
+ ])
->allowedFilters('name')
->get();
@@ -91,6 +122,10 @@
});
it('specifies escape character in supported databases', function (string $dbDriver) {
+ if ($dbDriver === 'mariadb' && ! in_array('mariadb', DB::supportedDrivers())) {
+ $this->markTestSkipped('mariadb driver not supported in the installed version of illuminate/database dependency');
+ }
+
$fakeConnection = "test_{$dbDriver}";
DB::connectUsing($fakeConnection, [
@@ -99,6 +134,7 @@
]);
DB::usingConnection($fakeConnection, function () use ($dbDriver) {
+
$request = new Request([
'filter' => ['name' => 'to_find'],
]);
@@ -107,15 +143,19 @@
->allowedFilters('name', 'id')
->toSql();
- expect($queryBuilderSql)->when(in_array($dbDriver, ["sqlite","pgsql","sqlsrv"]), fn (Expectation $query) => $query->toContain("ESCAPE '\'"));
- expect($queryBuilderSql)->when($dbDriver === 'mysql', fn (Expectation $query) => $query->not->toContain("ESCAPE '\'"));
+ expect($queryBuilderSql)->when(in_array($dbDriver, ["sqlite", "sqlsrv"]), fn (
+ Expectation $query
+ ) => $query->toContain("ESCAPE '\'"));
+ expect($queryBuilderSql)->when(in_array($dbDriver, ["mysql", "mariadb", "pgsql"]), fn (
+ Expectation $query
+ ) => $query->not->toContain("ESCAPE '\'"));
});
-})->with(['sqlite', 'mysql', 'pgsql', 'sqlsrv']);
+})->with(['sqlite', 'mysql', 'pgsql', 'sqlsrv', 'mariadb']);
it('can filter results based on the existence of a property in an array', function () {
$results = createQueryFromFilterRequest([
- 'id' => '1,2',
- ])
+ 'id' => '1,2',
+ ])
->allowedFilters(AllowedFilter::exact('id'))
->get();
@@ -125,8 +165,8 @@
it('ignores empty values in an array partial filter', function () {
$results = createQueryFromFilterRequest([
- 'id' => '2,',
- ])
+ 'id' => '2,',
+ ])
->allowedFilters(AllowedFilter::partial('id'))
->get();
@@ -136,8 +176,8 @@
it('ignores an empty array partial filter', function () {
$results = createQueryFromFilterRequest([
- 'id' => ',,',
- ])
+ 'id' => ',,',
+ ])
->allowedFilters(AllowedFilter::partial('id'))
->get();
@@ -148,8 +188,8 @@
DB::enableQueryLog();
createQueryFromFilterRequest([
- 'id' => [0],
- ])
+ 'id' => [0],
+ ])
->allowedFilters(AllowedFilter::partial('id'))
->get();
@@ -160,8 +200,8 @@
DB::enableQueryLog();
createQueryFromFilterRequest([
- 'id' => [0],
- ])
+ 'id' => [0],
+ ])
->allowedFilters(AllowedFilter::beginsWithStrict('id'))
->get();
@@ -172,8 +212,8 @@
DB::enableQueryLog();
createQueryFromFilterRequest([
- 'id' => [0],
- ])
+ 'id' => [0],
+ ])
->allowedFilters(AllowedFilter::endsWithStrict('id'))
->get();
@@ -225,8 +265,8 @@
->get();
$modelsResult = createQueryFromFilterRequest([
- 'id' => $testModel->id,
- ])
+ 'id' => $testModel->id,
+ ])
->allowedFilters(AllowedFilter::exact('id'))
->get();
@@ -237,14 +277,95 @@
$testModel = TestModel::create(['name' => 'John Testing Doe']);
$modelsResult = createQueryFromFilterRequest([
- 'name' => ' Testing ',
- ])
+ 'name' => ' Testing ',
+ ])
->allowedFilters(AllowedFilter::exact('name'))
->get();
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']);
@@ -322,8 +443,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
};
$modelResult = createQueryFromFilterRequest([
- 'custom_name' => $testModel->name,
- ])
+ 'custom_name' => $testModel->name,
+ ])
->allowedFilters(AllowedFilter::custom('custom_name', $filterClass))
->first();
@@ -335,8 +456,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
$model2 = TestModel::create(['name' => 'abcdef']);
$results = createQueryFromFilterRequest([
- 'name' => 'abc',
- ])
+ 'name' => 'abc',
+ ])
->allowedFilters('name', AllowedFilter::exact('id'))
->get();
@@ -349,8 +470,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
$model2 = TestModel::create(['name' => 'abcdef']);
$results = createQueryFromFilterRequest([
- 'name' => 'abc',
- ])
+ 'name' => 'abc',
+ ])
->allowedFilters(['name', AllowedFilter::exact('id')])
->get();
@@ -363,9 +484,9 @@ public function __invoke(Builder $query, $value, string $property): Builder
$model2 = TestModel::create(['name' => 'abcdef']);
$results = createQueryFromFilterRequest([
- 'name' => 'abc',
- 'id' => "1,{$model1->id}",
- ])
+ 'name' => 'abc',
+ 'id' => "1,{$model1->id}",
+ ])
->allowedFilters('name', AllowedFilter::exact('id'))
->get();
@@ -408,8 +529,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'abcdef']);
$results = createQueryFromFilterRequest([
- '*' => '*',
- ])
+ '*' => '*',
+ ])
->allowedFilters('name', AllowedFilter::custom('*', $customFilter))
->get();
@@ -438,8 +559,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
it('should not apply a filter if the supplied value is ignored', function () {
$models = createQueryFromFilterRequest([
- 'name' => '-1',
- ])
+ 'name' => '-1',
+ ])
->allowedFilters(AllowedFilter::exact('name')->ignore('-1'))
->get();
@@ -451,8 +572,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'John Deer']);
$models = createQueryFromFilterRequest([
- 'name' => 'John Deer,John Doe',
- ])
+ 'name' => 'John Deer,John Doe',
+ ])
->allowedFilters(AllowedFilter::exact('name')->ignore('John Doe'))
->get();
@@ -464,8 +585,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['id' => 7, 'name' => 'John Deer']);
$models = createQueryFromFilterRequest([
- 'id' => [ 7, 6 ],
- ])
+ 'id' => [7, 6],
+ ])
->allowedFilters(AllowedFilter::exact('id')->ignore(6))
->get();
@@ -491,8 +612,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'abcdef']);
$models = createQueryFromFilterRequest([
- 'nickname' => 'abcdef',
- ])
+ 'nickname' => 'abcdef',
+ ])
->allowedFilters($filter)
->get();
@@ -527,8 +648,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'UniqueJohn Deer']);
$models = createQueryFromFilterRequest([
- 'name' => 'UniqueDoe',
- ])
+ 'name' => 'UniqueDoe',
+ ])
->allowedFilters(AllowedFilter::partial('name')->default('UniqueJohn'))
->get();
@@ -551,8 +672,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'UniqueJohn Deer']);
$models = createQueryFromFilterRequest([
- 'name' => 'UniqueJohn Deer',
- ])
+ 'name' => 'UniqueJohn Deer',
+ ])
->allowedFilters(AllowedFilter::exact('name')->default(null))
->get();
@@ -560,15 +681,18 @@ public function __invoke(Builder $query, $value, string $property): Builder
});
it('should apply a nullable filter when filter exists and is null', function () {
+ DB::enableQueryLog();
+
TestModel::create(['name' => null]);
TestModel::create(['name' => 'UniqueJohn Deer']);
$models = createQueryFromFilterRequest([
- 'name' => null,
- ])
+ 'name' => null,
+ ])
->allowedFilters(AllowedFilter::exact('name')->nullable())
->get();
+ $this->assertQueryLogContains("select * from `test_models` where `test_models`.`name` is null");
expect($models->count())->toEqual(1);
});
@@ -577,8 +701,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'UniqueJohn Deer']);
$models = createQueryFromFilterRequest([
- 'name' => 'UniqueJohn Deer',
- ])
+ 'name' => 'UniqueJohn Deer',
+ ])
->allowedFilters(AllowedFilter::exact('name')->nullable())
->get();
@@ -590,8 +714,8 @@ public function __invoke(Builder $query, $value, string $property): Builder
$filterWithDefault = AllowedFilter::exact('name')->default('some default value');
$models = createQueryFromFilterRequest([
- 'name' => 'John Doe',
- ])
+ 'name' => 'John Doe',
+ ])
->allowedFilters($filterWithDefault->unsetDefault())
->get();
@@ -611,10 +735,10 @@ public function __invoke(Builder $query, $value, string $property): Builder
TestModel::create(['name' => 'John Doe']);
$models = createQueryFromFilterRequest(['conditions' => [[
- 'attribute' => 'name',
- 'operator' => '=',
- 'value' => 'John Doe',
- ]]])
+ 'attribute' => 'name',
+ 'operator' => '=',
+ 'value' => 'John Doe',
+ ]]])
->allowedFilters(AllowedFilter::callback('conditions', function ($query, $conditions) {
foreach ($conditions as $condition) {
$query->where(
@@ -635,25 +759,138 @@ public function __invoke(Builder $query, $value, string $property): Builder
// First use default delimiter
$models = createQueryFromFilterRequest([
- 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On',
- ])
+ 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On',
+ ])
->allowedFilters(AllowedFilter::exact('ref_id', 'name', true))
->get();
expect($models->count())->toEqual(2);
// Custom delimiter
$models = createQueryFromFilterRequest([
- 'ref_id' => 'h4S4MG3(+>azv4z/I|>XZII/Q1On',
- ])
+ 'ref_id' => 'h4S4MG3(+>azv4z/I|>XZII/Q1On',
+ ])
->allowedFilters(AllowedFilter::exact('ref_id', 'name', true, '|'))
->get();
expect($models->count())->toEqual(2);
// Custom delimiter, but default in request
$models = createQueryFromFilterRequest([
- 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On',
- ])
+ 'ref_id' => 'h4S4MG3(+>azv4z/I,>XZII/Q1On',
+ ])
->allowedFilters(AllowedFilter::exact('ref_id', 'name', true, '|'))
->get();
expect($models->count())->toEqual(0);
});
+
+it('can filter name with equal operator filter', function () {
+ TestModel::create(['name' => 'John Doe']);
+
+ $results = createQueryFromFilterRequest([
+ 'name' => 'John Doe',
+ ])
+ ->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter name with not equal operator filter', function () {
+ TestModel::create(['name' => 'John Doe']);
+
+ $results = createQueryFromFilterRequest([
+ 'name' => 'John Doe',
+ ])
+ ->allowedFilters(AllowedFilter::operator('name', FilterOperator::NOT_EQUAL))
+ ->get();
+
+ expect($results)->toHaveCount(5);
+});
+
+it('can filter salary with greater than operator filter', function () {
+ TestModel::create(['salary' => 5000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => 3000,
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter salary with less than operator filter', function () {
+ TestModel::create(['salary' => 5000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => 7000,
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter salary with greater than or equal operator filter', function () {
+ TestModel::create(['salary' => 5000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => 3000,
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::GREATER_THAN_OR_EQUAL))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter salary with less than or equal operator filter', function () {
+ TestModel::create(['salary' => 5000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => 7000,
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::LESS_THAN_OR_EQUAL))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter array of names with equal operator filter', function () {
+ TestModel::create(['name' => 'John Doe']);
+ TestModel::create(['name' => 'Max Doe']);
+
+ $results = createQueryFromFilterRequest([
+ 'name' => 'John Doe,Max Doe',
+ ])
+ ->allowedFilters(AllowedFilter::operator('name', FilterOperator::EQUAL, 'or'))
+ ->get();
+
+ expect($results)->toHaveCount(2);
+});
+
+it('can filter salary with dynamic operator filter', function () {
+ TestModel::create(['salary' => 5000]);
+ TestModel::create(['salary' => 2000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => '>2000',
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC))
+ ->get();
+
+ expect($results)->toHaveCount(1);
+});
+
+it('can filter salary with dynamic array operator filter', function () {
+ TestModel::create(['salary' => 1000]);
+ TestModel::create(['salary' => 2000]);
+ TestModel::create(['salary' => 3000]);
+ TestModel::create(['salary' => 4000]);
+
+ $results = createQueryFromFilterRequest([
+ 'salary' => '>1000,<4000',
+ ])
+ ->allowedFilters(AllowedFilter::operator('salary', FilterOperator::DYNAMIC))
+ ->get();
+
+ expect($results)->toHaveCount(2);
+});
diff --git a/tests/RelationFilterTest.php b/tests/RelationFilterTest.php
index c6144482..31902883 100644
--- a/tests/RelationFilterTest.php
+++ b/tests/RelationFilterTest.php
@@ -1,6 +1,7 @@
toContain('LOWER(`relatedModels`.`name`) LIKE ?');
});
+
+it('can disable operator filtering based on related model properties', function () {
+ $addRelationConstraint = false;
+
+ $sql = createQueryFromFilterRequest([
+ 'relatedModels.name' => $this->models->first()->name,
+ ])
+ ->allowedFilters(AllowedFilter::operator('relatedModels.name', FilterOperator::EQUAL, 'and', null, $addRelationConstraint))
+ ->toSql();
+
+ expect($sql)->toContain('`relatedModels`.`name` = ?');
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 62427dc3..591c22b6 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -37,6 +37,8 @@ protected function setUpDatabase(Application $app)
$table->increments('id');
$table->timestamps();
$table->string('name')->nullable();
+ $table->string('full_name')->nullable();
+ $table->double('salary')->nullable();
$table->boolean('is_visible')->default(true);
});
@@ -61,6 +63,7 @@ protected function setUpDatabase(Application $app)
$table->increments('id');
$table->integer('test_model_id');
$table->string('name');
+ $table->string('full_name')->nullable();
});
$app['db']->connection()->getSchemaBuilder()->create('nested_related_models', function (Blueprint $table) {
@@ -91,7 +94,7 @@ protected function setUpDatabase(Application $app)
protected function getPackageProviders($app)
{
return [
- RayServiceProvider::class,
+ // RayServiceProvider::class,
QueryBuilderServiceProvider::class,
];
}
diff --git a/tests/TestClasses/Models/TestModel.php b/tests/TestClasses/Models/TestModel.php
index 16797919..e182b028 100644
--- a/tests/TestClasses/Models/TestModel.php
+++ b/tests/TestClasses/Models/TestModel.php
@@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Carbon;
@@ -27,6 +28,18 @@ public function relatedModel(): BelongsTo
return $this->belongsTo(RelatedModel::class);
}
+ public function nestedRelatedModels(): HasManyThrough
+ {
+ return $this->hasManyThrough(
+ NestedRelatedModel::class, // Target model
+ RelatedModel::class, // Intermediate model
+ 'test_model_id', // Foreign key on RelatedModel
+ 'related_model_id', // Foreign key on NestedRelatedModel
+ 'id', // Local key on TestModel
+ 'id' // Local key on RelatedModel
+ );
+ }
+
public function otherRelatedModels(): HasMany
{
return $this->hasMany(RelatedModel::class);