├── docs ├── features │ ├── _index.md │ ├── selecting-fields.md │ ├── sorting.md │ ├── including-relationships.md │ └── filtering.md ├── advanced-usage │ ├── _index.md │ ├── extending-query-builder.md │ ├── pagination.md │ ├── front-end-implementation.md │ └── multi-value-delimiter.md ├── _index.md ├── changelog.md ├── requirements.md ├── questions-issues.md ├── about-us.md ├── support-us.md ├── introduction.md └── installation-setup.md ├── src ├── Enums │ ├── SortDirection.php │ └── FilterOperator.php ├── Exceptions │ ├── InvalidQuery.php │ ├── InvalidFilterValue.php │ ├── InvalidDirection.php │ ├── AllowedFieldsMustBeCalledBeforeAllowedIncludes.php │ ├── UnknownIncludedFieldsQuery.php │ ├── InvalidSortQuery.php │ ├── InvalidFieldQuery.php │ ├── InvalidFilterQuery.php │ ├── InvalidAppendQuery.php │ └── InvalidIncludeQuery.php ├── Sorts │ ├── Sort.php │ ├── SortsField.php │ └── SortsCallback.php ├── Filters │ ├── Filter.php │ ├── FiltersBeginsWithStrict.php │ ├── FiltersEndsWithStrict.php │ ├── FiltersTrashed.php │ ├── FiltersCallback.php │ ├── FiltersOperator.php │ ├── FiltersExact.php │ ├── FiltersPartial.php │ ├── FiltersBelongsTo.php │ └── FiltersScope.php ├── Includes │ ├── IncludeInterface.php │ ├── IncludedCallback.php │ ├── IncludedCount.php │ ├── IncludedExists.php │ └── IncludedRelationship.php ├── QueryBuilderServiceProvider.php ├── Concerns │ ├── FiltersQuery.php │ ├── SortsQuery.php │ ├── AddsIncludesToQuery.php │ └── AddsFieldsToQuery.php ├── AllowedSort.php ├── QueryBuilder.php ├── AllowedInclude.php ├── AllowedFilter.php └── QueryBuilderRequest.php ├── database └── factories │ ├── TestModelFactory.php │ ├── SoftDeleteModelFactory.php │ └── AppendModelFactory.php ├── phpstan.neon.dist ├── types └── query-builder.php ├── LICENSE.md ├── phpstan-baseline.neon ├── composer.json ├── config └── query-builder.php ├── UPGRADING.md ├── README.md └── CHANGELOG.md /docs/features/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Features 3 | weight: 2 4 | --- 5 | -------------------------------------------------------------------------------- /docs/advanced-usage/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Advanced usage 3 | weight: 3 4 | --- 5 | -------------------------------------------------------------------------------- /docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v6 3 | slogan: Easily build Eloquent queries from API requests. 4 | githubUrl: https://github.com/spatie/laravel-query-builder 5 | branch: main 6 | --- 7 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | weight: 6 4 | --- 5 | 6 | All notable changes to laravel-query-builder are documented [on GitHub](https://github.com/spatie/laravel-query-builder/blob/master/CHANGELOG.md) 7 | -------------------------------------------------------------------------------- /src/Enums/SortDirection.php: -------------------------------------------------------------------------------- 1 | orderBy($property, $descending ? 'desc' : 'asc'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidDirection.php: -------------------------------------------------------------------------------- 1 | '; 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 | } 20 | -------------------------------------------------------------------------------- /src/Filters/Filter.php: -------------------------------------------------------------------------------- 1 | $query 14 | * 15 | * @return mixed 16 | */ 17 | public function __invoke(Builder $query, mixed $value, string $property); 18 | } 19 | -------------------------------------------------------------------------------- /src/Includes/IncludeInterface.php: -------------------------------------------------------------------------------- 1 | $query 14 | * 15 | * @return mixed 16 | */ 17 | public function __invoke(Builder $query, string $include); 18 | } 19 | -------------------------------------------------------------------------------- /database/factories/TestModelFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/questions-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Questions and issues 3 | weight: 5 4 | --- 5 | 6 | Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the Laravel query builder? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-query-builder/issues), we'll try to address it as soon as possible. 7 | 8 | If you've found a bug regarding security please mail [freek@spatie.be](mailto:freek@spatie.be) instead of using the issue tracker. 9 | -------------------------------------------------------------------------------- /src/Includes/IncludedCallback.php: -------------------------------------------------------------------------------- 1 | with([ 17 | $relation => $this->callback, 18 | ]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /database/factories/SoftDeleteModelFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/about-us.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About us 3 | weight: 4 4 | --- 5 | 6 | [Spatie](https://spatie.be) is a webdesign agency based in Antwerp, Belgium. 7 | 8 | Open source software is used in all projects we deliver. Laravel, Nginx, Ubuntu are just a few 9 | of the free pieces of software we use every single day. For this, we are very grateful. 10 | When we feel we have solved a problem in a way that can help other developers, 11 | we release our code as open source software [on GitHub](https://spatie.be/opensource). 12 | -------------------------------------------------------------------------------- /src/Includes/IncludedCount.php: -------------------------------------------------------------------------------- 1 | withCount($relation); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /database/factories/AppendModelFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->firstName, 16 | 'lastname' => $this->faker->lastName, 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/advanced-usage/extending-query-builder.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Extending query builder 3 | weight: 1 4 | --- 5 | 6 | As the `QueryBuilder` extends Laravel's default Eloquent query builder you can use any method or macro you like. You can also specify a base query instead of the model FQCN: 7 | 8 | ```php 9 | QueryBuilder::for(User::where('id', 42)) // base query instead of model 10 | ->allowedIncludes(['posts']) 11 | ->where('activated', true) // chain on any of Laravel's query methods 12 | ->first(); // we only need one specific user 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/support-us.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Support us 3 | weight: 4 4 | --- 5 | 6 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 7 | 8 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 9 | -------------------------------------------------------------------------------- /src/Includes/IncludedExists.php: -------------------------------------------------------------------------------- 1 | withExists($exists) 16 | ->withCasts([ 17 | "{$exists}_exists" => 'boolean', 18 | ]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Filters/FiltersBeginsWithStrict.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class FiltersBeginsWithStrict extends FiltersPartial implements Filter 10 | { 11 | protected function getWhereRawParameters($value, string $property, string $driver): array 12 | { 13 | return [ 14 | "{$property} LIKE ?".static::maybeSpecifyEscapeChar($driver), 15 | [static::escapeLike($value).'%'], 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Filters/FiltersEndsWithStrict.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class FiltersEndsWithStrict extends FiltersPartial implements Filter 10 | { 11 | protected function getWhereRawParameters($value, string $property, string $driver): array 12 | { 13 | 14 | return [ 15 | "{$property} LIKE ?".static::maybeSpecifyEscapeChar($driver), 16 | ['%'.static::escapeLike($value)], 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - phpstan-baseline.neon 4 | 5 | parameters: 6 | 7 | paths: 8 | - src/ 9 | - config/ 10 | - database/ 11 | - types/ 12 | 13 | # Level 9 is the highest level 14 | level: 5 15 | 16 | checkModelProperties: true 17 | checkOctaneCompatibility: true 18 | reportUnmatchedIgnoredErrors: false 19 | noUnnecessaryCollectionCall: true 20 | checkNullables: true 21 | treatPhpDocTypesAsCertain: false 22 | 23 | ignoreErrors: 24 | - '#Unsafe usage of new static#' 25 | - '#PHPDoc tag @var#' 26 | 27 | # excludePaths: 28 | -------------------------------------------------------------------------------- /src/Sorts/SortsCallback.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 18 | } 19 | 20 | /** {@inheritdoc} */ 21 | public function __invoke(Builder $query, bool $descending, string $property) 22 | { 23 | return call_user_func($this->callback, $query, $descending, $property); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Filters/FiltersTrashed.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class FiltersTrashed implements Filter 12 | { 13 | /** {@inheritdoc} */ 14 | public function __invoke(Builder $query, $value, string $property) 15 | { 16 | if ($value === 'with') { 17 | $query->withTrashed(); 18 | 19 | return; 20 | } 21 | 22 | if ($value === 'only') { 23 | $query->onlyTrashed(); 24 | 25 | return; 26 | } 27 | 28 | $query->withoutTrashed(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /types/query-builder.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function author(): BelongsTo 13 | { 14 | return $this->belongsTo(Author::class); 15 | } 16 | } 17 | 18 | class Author extends Model {} 19 | 20 | assertType('Spatie\QueryBuilder\QueryBuilder', QueryBuilder::for(Book::class)); 21 | assertType('Spatie\QueryBuilder\QueryBuilder', QueryBuilder::for(Book::query())); 22 | assertType('Spatie\QueryBuilder\QueryBuilder', QueryBuilder::for((new Book)->author())); 23 | -------------------------------------------------------------------------------- /src/Exceptions/UnknownIncludedFieldsQuery.php: -------------------------------------------------------------------------------- 1 | unknownFields = collect($unknownFields); 15 | 16 | $unknownFields = $this->unknownFields->implode(', '); 17 | 18 | $message = "Requested field(s) `{$unknownFields}` are not allowed (yet). "; 19 | $message .= "If you want to allow these fields, please make sure to call the QueryBuilder's `allowedFields` method before the `allowedIncludes` method."; 20 | 21 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/QueryBuilderServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-query-builder') 14 | ->hasConfigFile(); 15 | } 16 | 17 | public function registeringPackage(): void 18 | { 19 | $this->app->bind(QueryBuilderRequest::class, function ($app) { 20 | return QueryBuilderRequest::fromRequest($app['request']); 21 | }); 22 | } 23 | 24 | public function provides(): array 25 | { 26 | return [ 27 | QueryBuilderRequest::class, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidSortQuery.php: -------------------------------------------------------------------------------- 1 | implode(', '); 15 | $unknownSorts = $unknownSorts->implode(', '); 16 | 17 | $message = "Requested sort(s) `{$unknownSorts}` is not allowed. Allowed sort(s) are `{$allowedSorts}`."; 18 | 19 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 20 | } 21 | 22 | public static function sortsNotAllowed(Collection $unknownSorts, Collection $allowedSorts): static 23 | { 24 | return new static(...func_get_args()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidFieldQuery.php: -------------------------------------------------------------------------------- 1 | implode(', '); 15 | $allowedFields = $allowedFields->implode(', '); 16 | $message = "Requested field(s) `{$unknownFields}` are not allowed. Allowed field(s) are `{$allowedFields}`."; 17 | 18 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 19 | } 20 | 21 | public static function fieldsNotAllowed(Collection $unknownFields, Collection $allowedFields): static 22 | { 23 | return new static(...func_get_args()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Filters/FiltersCallback.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class FiltersCallback implements Filter 12 | { 13 | /** 14 | * @var callable a PHP callback of the following signature: 15 | * `function (\Illuminate\Database\Eloquent\Builder $builder, mixed $value, string $property)` 16 | */ 17 | private $callback; 18 | 19 | public function __construct($callback) 20 | { 21 | $this->callback = $callback; 22 | } 23 | 24 | /** {@inheritdoc} */ 25 | public function __invoke(Builder $query, $value, string $property) 26 | { 27 | return call_user_func($this->callback, $query, $value, $property); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidFilterQuery.php: -------------------------------------------------------------------------------- 1 | unknownFilters->implode(', '); 15 | $allowedFilters = $this->allowedFilters->implode(', '); 16 | $message = "Requested filter(s) `{$unknownFilters}` are not allowed. Allowed filter(s) are `{$allowedFilters}`."; 17 | 18 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 19 | } 20 | 21 | public static function filtersNotAllowed(Collection $unknownFilters, Collection $allowedFilters): static 22 | { 23 | return new static(...func_get_args()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidAppendQuery.php: -------------------------------------------------------------------------------- 1 | implode(', '); 15 | $allowedAppends = $allowedAppends->implode(', '); 16 | $message = "Requested append(s) `{$appendsNotAllowed}` are not allowed. Allowed append(s) are `{$allowedAppends}`."; 17 | 18 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 19 | } 20 | 21 | public static function appendsNotAllowed(Collection $appendsNotAllowed, Collection $allowedAppends): static 22 | { 23 | return new static(...func_get_args()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/advanced-usage/pagination.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pagination 3 | weight: 2 4 | --- 5 | 6 | This package doesn't provide any methods to help you paginate responses. However as documented above you can use Laravel's default [`paginate()` method](https://laravel.com/docs/12.x/pagination). 7 | 8 | If you want to completely adhere to the JSON API specification you can also use our own [spatie/json-api-paginate](https://github.com/spatie/laravel-json-api-paginate)! 9 | 10 | ## Adding Parameters to Pagination 11 | 12 | By default the query parameters wont be added to the pagination json. You can append the request query to the pagination json by using the `appends` method available on the [LengthAwarePaginator](https://laravel.com/api/6.x/Illuminate/Contracts/Pagination/LengthAwarePaginator.html#method_appends). 13 | 14 | ```php 15 | $users = QueryBuilder::for(User::class) 16 | ->allowedFilters(['name', 'email']) 17 | ->paginate() 18 | ->appends(request()->query()); 19 | ``` 20 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidIncludeQuery.php: -------------------------------------------------------------------------------- 1 | implode(', '); 15 | 16 | $message = "Requested include(s) `{$unknownIncludes}` are not allowed. "; 17 | 18 | if ($allowedIncludes->count()) { 19 | $allowedIncludes = $allowedIncludes->implode(', '); 20 | $message .= "Allowed include(s) are `{$allowedIncludes}`."; 21 | } else { 22 | $message .= 'There are no allowed includes.'; 23 | } 24 | 25 | parent::__construct(Response::HTTP_BAD_REQUEST, $message); 26 | } 27 | 28 | public static function includesNotAllowed(Collection $unknownIncludes, Collection $allowedIncludes): static 29 | { 30 | return new static(...func_get_args()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/advanced-usage/front-end-implementation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Front-end implementation 3 | weight: 6 4 | --- 5 | 6 | If you're interested in building query urls on the front-end to match this package, you could use one of the below: 7 | 8 | - Standalone: [elodo package](https://www.npmjs.com/package/elodo) by [Maxim Vanhove](https://github.com/MaximVanhove). 9 | - Vue: [vue-api-query package](https://github.com/robsontenorio/vue-api-query) by [Robson Tenório](https://github.com/robsontenorio). 10 | - Vue + Inertia.js: [inertiajs-tables-laravel-query-builder](https://github.com/protonemedia/inertiajs-tables-laravel-query-builder) by [ 11 | Pascal Baljet](https://github.com/pascalbaljet). 12 | - React: [cogent-js package](https://www.npmjs.com/package/cogent-js) by [Joel Male](https://github.com/joelwmale). 13 | - Typescript: [query-builder-ts package](https://www.npmjs.com/package/@vortechron/query-builder-ts) by [Amirul Adli](https://www.npmjs.com/~vortechron) 14 | - Typescript + React [react-query-builder](https://www.npmjs.com/package/@cgarciagarcia/react-query-builder) by [Carlos Garcia](https://github.com/cgarciagarcia) 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Parameter \\#1 \\$keys of method Illuminate\\\\Support\\\\Collection\\\\:\\:except\\(\\) expects array\\\\|Illuminate\\\\Support\\\\Enumerable\\<\\(int\\|string\\), int\\>\\|string, int\\<\\-1, max\\> given\\.$#" 5 | count: 1 6 | path: src/Filters/FiltersExact.php 7 | 8 | - 9 | message: "#^Call to an undefined method ReflectionType\\:\\:getName\\(\\)\\.$#" 10 | count: 2 11 | path: src/Filters/FiltersScope.php 12 | 13 | - 14 | message: "#^Call to an undefined method ReflectionType\\:\\:isBuiltin\\(\\)\\.$#" 15 | count: 1 16 | path: src/Filters/FiltersScope.php 17 | 18 | - 19 | message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:onlyTrashed\\(\\)\\.$#" 20 | count: 1 21 | path: src/Filters/FiltersTrashed.php 22 | 23 | - 24 | message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:withTrashed\\(\\)\\.$#" 25 | count: 1 26 | path: src/Filters/FiltersTrashed.php 27 | 28 | - 29 | message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:withoutTrashed\\(\\)\\.$#" 30 | count: 1 31 | path: src/Filters/FiltersTrashed.php 32 | -------------------------------------------------------------------------------- /docs/advanced-usage/multi-value-delimiter.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Multi value delimiter 3 | weight: 4 4 | --- 5 | 6 | Sometimes values to filter for could include commas. This is why you can specify the delimiter symbol using the `QueryBuilderRequest` to overwrite the default behaviour. 7 | 8 | ```php 9 | // GET /api/endpoint?filter=12,4V|4,7V|2,1V 10 | 11 | QueryBuilderRequest::setArrayValueDelimiter('|'); 12 | 13 | QueryBuilder::for(Model::class) 14 | ->allowedFilters(AllowedFilter::exact('voltage')) 15 | ->get(); 16 | 17 | // filters: [ 'voltage' => [ '12,4V', '4,7V', '2,1V' ]] 18 | ``` 19 | 20 | __Note that this applies to ALL values for filters, includes and sorts__ 21 | 22 | ## Usage 23 | 24 | There are multiple opportunities where the delimiter can be set. 25 | 26 | You can define it in a `ServiceProvider` to apply it globally, or define a middleware that can be applied only on certain `Controllers`. 27 | ```php 28 | // YourServiceProvider.php 29 | public function boot() { 30 | QueryBuilderRequest::setArrayValueDelimiter(';'); 31 | } 32 | 33 | // ApplySemicolonDelimiterMiddleware.php 34 | public function handle($request, $next) { 35 | QueryBuilderRequest::setArrayValueDelimiter(';'); 36 | return $next($request); 37 | } 38 | ``` 39 | 40 | You can also set the delimiter for each feature individually: 41 | ```php 42 | QueryBuilderRequest::setIncludesArrayValueDelimiter(';'); // Includes 43 | QueryBuilderRequest::setAppendsArrayValueDelimiter(';'); // Appends 44 | QueryBuilderRequest::setFieldsArrayValueDelimiter(';'); // Fields 45 | QueryBuilderRequest::setSortsArrayValueDelimiter(';'); // Sorts 46 | QueryBuilderRequest::setFilterArrayValueDelimiter(';'); // Filter 47 | ``` 48 | 49 | You can override the default delimiter for single filters: 50 | ```php 51 | // GET /api/endpoint?filter[id]=h4S4MG3(+>azv4z/I,>XZII/Q1On 52 | AllowedFilter::exact('id', 'ref_id', true, ';'); 53 | ``` 54 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-query-builder", 3 | "description": "Easily build Eloquent queries from API requests", 4 | "keywords": [ 5 | "spatie", 6 | "laravel-query-builder" 7 | ], 8 | "homepage": "https://github.com/spatie/laravel-query-builder", 9 | "license": "MIT", 10 | "support": { 11 | "issues": "https://github.com/spatie/laravel-query-builder/issues", 12 | "source": "https://github.com/spatie/laravel-query-builder" 13 | }, 14 | "authors": [ 15 | { 16 | "name": "Alex Vanderbist", 17 | "email": "alex@spatie.be", 18 | "homepage": "https://spatie.be", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.2", 24 | "illuminate/database": "^10.0|^11.0|^12.0", 25 | "illuminate/http": "^10.0|^11.0|^12.0", 26 | "illuminate/support": "^10.0|^11.0|^12.0", 27 | "spatie/laravel-package-tools": "^1.11" 28 | }, 29 | "require-dev": { 30 | "ext-json": "*", 31 | "larastan/larastan": "^2.7 || ^3.3", 32 | "mockery/mockery": "^1.4", 33 | "orchestra/testbench": "^7.0|^8.0|^10.0", 34 | "pestphp/pest": "^2.0|^3.7|^4.0", 35 | "phpunit/phpunit": "^10.0|^11.5.3|^12.0", 36 | "spatie/invade": "^2.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Spatie\\QueryBuilder\\": "src", 41 | "Spatie\\QueryBuilder\\Database\\Factories\\": "database/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Spatie\\QueryBuilder\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "test": "vendor/bin/pest", 51 | "test-coverage": "phpunit --coverage-html coverage", 52 | "analyse": "vendor/bin/phpstan analyse --ansi --memory-limit=4G", 53 | "baseline": "vendor/bin/phpstan analyse --generate-baseline --memory-limit=4G" 54 | }, 55 | "config": { 56 | "sort-packages": true, 57 | "allow-plugins": { 58 | "pestphp/pest-plugin": true 59 | } 60 | }, 61 | "extra": { 62 | "laravel": { 63 | "providers": [ 64 | "Spatie\\QueryBuilder\\QueryBuilderServiceProvider" 65 | ] 66 | } 67 | }, 68 | "minimum-stability": "dev", 69 | "prefer-stable": true 70 | } 71 | -------------------------------------------------------------------------------- /src/Filters/FiltersOperator.php: -------------------------------------------------------------------------------- 1 | 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 | } elseif ($this->filterOperator->isDynamic()) { 40 | $filterOperator = $this->getDynamicFilterOperator($value); 41 | $this->removeDynamicFilterOperatorFromValue($value, $filterOperator); 42 | } 43 | 44 | $query->where($query->qualifyColumn($property), $filterOperator->value, $value, $this->boolean); 45 | } 46 | 47 | protected function getDynamicFilterOperator(string $value): FilterOperator 48 | { 49 | $filterOperator = FilterOperator::EQUAL; 50 | 51 | foreach (FilterOperator::cases() as $filterOperatorCase) { 52 | if (str_starts_with($value, $filterOperatorCase->value) && ! $filterOperatorCase->isDynamic()) { 53 | $filterOperator = $filterOperatorCase; 54 | } 55 | } 56 | 57 | return $filterOperator; 58 | } 59 | 60 | protected function removeDynamicFilterOperatorFromValue(string &$value, FilterOperator $filterOperator) 61 | { 62 | if (str_contains($value, $filterOperator->value)) { 63 | $value = substr_replace($value, '', 0, strlen($filterOperator->value)); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/features/selecting-fields.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Selecting fields 3 | weight: 4 4 | --- 5 | 6 | Sometimes you'll want to fetch only a couple fields to reduce the overall size of your SQL query. This can be done by specifying some fields using the `allowedFields` method and using the `fields` request query parameter. 7 | 8 | ## Basic usage 9 | 10 | The following example fetches only the users' `id` and `name`: 11 | 12 | ```php 13 | // GET /users?fields[users]=id,name 14 | 15 | $users = QueryBuilder::for(User::class) 16 | ->allowedFields(['id', 'name']) 17 | ->toSql(); 18 | ``` 19 | 20 | The SQL query will look like this: 21 | 22 | ```sql 23 | SELECT "id", "name" FROM "users" 24 | ``` 25 | 26 | When not allowing any fields explicitly, Eloquent's default behaviour of selecting all fields will be used. 27 | 28 | ## Disallowed fields/selects 29 | 30 | When trying to select a column that's not specified in `allowedFields()` an `InvalidFieldQuery` exception will be thrown: 31 | 32 | ```php 33 | $users = QueryBuilder::for(User::class) 34 | ->allowedFields('name') 35 | ->get(); 36 | 37 | // GET /users?fields[users]=email will throw an `InvalidFieldQuery` exception as `email` is not an allowed field. 38 | ``` 39 | 40 | ## Selecting fields for included relations 41 | 42 | Selecting fields for included models works the same way. This is especially useful when you only need a couple of columns from an included relationship. Consider the following example: 43 | 44 | ```php 45 | GET /posts?include=author&fields[authors]=id,name 46 | 47 | QueryBuilder::for(Post::class) 48 | ->allowedFields('authors.id', 'authors.name') 49 | ->allowedIncludes('author'); 50 | 51 | // All posts will be fetched including _only_ the name of the author. 52 | ``` 53 | ⚠️ **Note:** In `allowedFields`, you must always use the _snake case plural_ of your relation name. If you want to change this behavior, you can change the settings in the [configuration file](https://spatie.be/docs/laravel-query-builder/v6/installation-setup) 54 | 55 | ⚠️ 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. 56 | 57 | ⚠️ `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. 58 | 59 | -------------------------------------------------------------------------------- /src/Concerns/FiltersQuery.php: -------------------------------------------------------------------------------- 1 | allowedFilters = collect($filters)->flatten(1)->map(function ($filter) { 18 | if ($filter instanceof AllowedFilter) { 19 | return $filter; 20 | } 21 | 22 | return AllowedFilter::partial($filter); 23 | }); 24 | 25 | $this->ensureAllFiltersExist(); 26 | 27 | $this->addFiltersToQuery(); 28 | 29 | return $this; 30 | } 31 | 32 | protected function addFiltersToQuery(): void 33 | { 34 | $this->allowedFilters->each(function (AllowedFilter $filter) { 35 | if ($this->isFilterRequested($filter)) { 36 | $value = $this->request->filters()->get($filter->getName()); 37 | $filter->filter($this, $value); 38 | 39 | return; 40 | } 41 | 42 | if ($filter->hasDefault()) { 43 | $filter->filter($this, $filter->getDefault()); 44 | } 45 | }); 46 | } 47 | 48 | protected function findFilter(string $property): ?AllowedFilter 49 | { 50 | return $this->allowedFilters 51 | ->first(function (AllowedFilter $filter) use ($property) { 52 | return $filter->isForFilter($property); 53 | }); 54 | } 55 | 56 | protected function isFilterRequested(AllowedFilter $allowedFilter): bool 57 | { 58 | return $this->request->filters()->has($allowedFilter->getName()); 59 | } 60 | 61 | protected function ensureAllFiltersExist(): void 62 | { 63 | if (config('query-builder.disable_invalid_filter_query_exception', false)) { 64 | return; 65 | } 66 | 67 | $filterNames = $this->request->filters()->keys(); 68 | 69 | $allowedFilterNames = $this->allowedFilters->map(function (AllowedFilter $allowedFilter) { 70 | return $allowedFilter->getName(); 71 | }); 72 | 73 | $diff = $filterNames->diff($allowedFilterNames); 74 | 75 | if ($diff->count()) { 76 | throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Filters/FiltersExact.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class FiltersExact implements Filter 15 | { 16 | protected array $relationConstraints = []; 17 | 18 | public function __construct(protected bool $addRelationConstraint = true) 19 | { 20 | } 21 | 22 | /** {@inheritdoc} */ 23 | public function __invoke(Builder $query, $value, string $property) 24 | { 25 | if ($this->addRelationConstraint) { 26 | if ($this->isRelationProperty($query, $property)) { 27 | $this->withRelationConstraint($query, $value, $property); 28 | 29 | return; 30 | } 31 | } 32 | 33 | if (is_array($value)) { 34 | $query->whereIn($query->qualifyColumn($property), $value); 35 | 36 | return; 37 | } 38 | 39 | $query->where($query->qualifyColumn($property), '=', $value); 40 | } 41 | 42 | protected function isRelationProperty(Builder $query, string $property): bool 43 | { 44 | if (! Str::contains($property, '.')) { 45 | return false; 46 | } 47 | 48 | if (in_array($property, $this->relationConstraints)) { 49 | return false; 50 | } 51 | 52 | $firstRelationship = explode('.', $property)[0]; 53 | 54 | if (! method_exists($query->getModel(), $firstRelationship)) { 55 | return false; 56 | } 57 | 58 | return is_a($query->getModel()->{$firstRelationship}(), Relation::class); 59 | } 60 | 61 | protected function withRelationConstraint(Builder $query, mixed $value, string $property): void 62 | { 63 | [$relation, $property] = collect(explode('.', $property)) 64 | ->pipe(fn (Collection $parts) => [ 65 | $parts->except(count($parts) - 1)->implode('.'), 66 | $parts->last(), 67 | ]); 68 | 69 | $query->whereHas($relation, function (Builder $query) use ($property, $value) { 70 | /** @var Builder $query */ 71 | $this->relationConstraints[] = $property = $query->qualifyColumn($property); 72 | 73 | $this->__invoke($query, $value, $property); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/AllowedSort.php: -------------------------------------------------------------------------------- 1 | name = ltrim($name, '-'); 20 | 21 | $this->defaultDirection = static::parseSortDirection($name); 22 | 23 | $this->internalName = $internalName ?? $this->name; 24 | } 25 | 26 | public static function parseSortDirection(string $name): string 27 | { 28 | return str_starts_with($name, '-') ? SortDirection::DESCENDING : SortDirection::ASCENDING; 29 | } 30 | 31 | public function sort(QueryBuilder $query, ?bool $descending = null): void 32 | { 33 | $descending = $descending ?? ($this->defaultDirection === SortDirection::DESCENDING); 34 | 35 | ($this->sortClass)($query->getEloquentBuilder(), $descending, $this->internalName); 36 | } 37 | 38 | public static function field(string $name, ?string $internalName = null): self 39 | { 40 | return new static($name, new SortsField(), $internalName); 41 | } 42 | 43 | public static function custom(string $name, Sort $sortClass, ?string $internalName = null): self 44 | { 45 | return new static($name, $sortClass, $internalName); 46 | } 47 | 48 | public static function callback(string $name, $callback, ?string $internalName = null): self 49 | { 50 | return new static($name, new SortsCallback($callback), $internalName); 51 | } 52 | 53 | public function getName(): string 54 | { 55 | return $this->name; 56 | } 57 | 58 | public function isSort(string $sortName): bool 59 | { 60 | return $this->name === $sortName; 61 | } 62 | 63 | public function getInternalName(): string 64 | { 65 | return $this->internalName; 66 | } 67 | 68 | public function defaultDirection(string $defaultDirection): static 69 | { 70 | if (! in_array($defaultDirection, [ 71 | SortDirection::ASCENDING, 72 | SortDirection::DESCENDING, 73 | ])) { 74 | throw InvalidDirection::make($defaultDirection); 75 | } 76 | 77 | $this->defaultDirection = $defaultDirection; 78 | 79 | return $this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Includes/IncludedRelationship.php: -------------------------------------------------------------------------------- 1 | mapWithKeys(function ($table, $key) use ($relatedTables, $query) { 21 | $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.'); 22 | 23 | if ($this->getRequestedFieldsForRelatedTable) { 24 | 25 | $tableName = null; 26 | $strategy = config('query-builder.convert_relation_table_name_strategy', false); 27 | 28 | if ($strategy !== false) { 29 | // Try to resolve the related model's table name 30 | try { 31 | // Use the current query's model to resolve the relationship 32 | $relatedModel = $query->getModel()->{$fullRelationName}()->getRelated(); 33 | $tableName = $relatedModel->getTable(); 34 | } catch (Exception $e) { 35 | // If we can not figure out the table don't do anything 36 | $tableName = null; 37 | } 38 | } 39 | 40 | $fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName, $tableName); 41 | } 42 | 43 | if (empty($fields)) { 44 | return [$fullRelationName]; 45 | } 46 | 47 | return [$fullRelationName => function ($query) use ($fields) { 48 | $query->select($query->qualifyColumns($fields)); 49 | }]; 50 | }) 51 | ->toArray(); 52 | 53 | $query->with($withs); 54 | } 55 | 56 | public static function getIndividualRelationshipPathsFromInclude(string $include): Collection 57 | { 58 | return collect(explode('.', $include)) 59 | ->reduce(function (Collection $includes, string $relationship) { 60 | if ($includes->isEmpty()) { 61 | return $includes->push($relationship); 62 | } 63 | 64 | return $includes->push("{$includes->last()}.{$relationship}"); 65 | }, collect()); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Filters/FiltersPartial.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class FiltersPartial extends FiltersExact implements Filter 12 | { 13 | /** {@inheritdoc} */ 14 | public function __invoke(Builder $query, $value, string $property) 15 | { 16 | if ($this->addRelationConstraint) { 17 | if ($this->isRelationProperty($query, $property)) { 18 | $this->withRelationConstraint($query, $value, $property); 19 | 20 | return; 21 | } 22 | } 23 | 24 | $wrappedProperty = $query->getQuery()->getGrammar()->wrap($query->qualifyColumn($property)); 25 | $databaseDriver = $this->getDatabaseDriver($query); 26 | 27 | if (is_array($value)) { 28 | if (count(array_filter($value, fn ($item) => $item != '')) === 0) { 29 | return $query; 30 | } 31 | 32 | $query->where(function (Builder $query) use ($value, $wrappedProperty, $databaseDriver) { 33 | foreach (array_filter($value, fn ($item) => $item != '') as $partialValue) { 34 | [$sql, $bindings] = $this->getWhereRawParameters($partialValue, $wrappedProperty, $databaseDriver); 35 | $query->orWhereRaw($sql, $bindings); 36 | } 37 | }); 38 | 39 | return; 40 | } 41 | 42 | [$sql, $bindings] = $this->getWhereRawParameters($value, $wrappedProperty, $databaseDriver); 43 | $query->whereRaw($sql, $bindings); 44 | } 45 | 46 | protected function getDatabaseDriver(Builder $query): string 47 | { 48 | return $query->getConnection()->getDriverName(); /** @phpstan-ignore-line */ 49 | } 50 | 51 | 52 | protected function getWhereRawParameters(mixed $value, string $property, string $driver): array 53 | { 54 | $value = mb_strtolower((string) $value, 'UTF8'); 55 | 56 | return [ 57 | "LOWER({$property}) LIKE ?".self::maybeSpecifyEscapeChar($driver), 58 | ['%'.self::escapeLike($value).'%'], 59 | ]; 60 | } 61 | 62 | protected static function escapeLike(string $value): string 63 | { 64 | return str_replace( 65 | ['\\', '_', '%'], 66 | ['\\\\', '\\_', '\\%'], 67 | $value, 68 | ); 69 | } 70 | 71 | /** 72 | * @param 'sqlite'|'pgsql'|'sqlsrc'|'mysql'|'mariadb' $driver 73 | * @return string 74 | */ 75 | protected static function maybeSpecifyEscapeChar(string $driver): string 76 | { 77 | if (! in_array($driver, ['sqlite','sqlsrv'])) { 78 | return ''; 79 | } 80 | 81 | return " ESCAPE '\'"; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Concerns/SortsQuery.php: -------------------------------------------------------------------------------- 1 | allowedSorts = collect($sorts)->map(function ($sort) { 18 | if ($sort instanceof AllowedSort) { 19 | return $sort; 20 | } 21 | 22 | return AllowedSort::field(ltrim($sort, '-')); 23 | }); 24 | 25 | $this->ensureAllSortsExist(); 26 | 27 | $this->addRequestedSortsToQuery(); // allowed is known & request is known, add what we can, if there is no request, -wait 28 | 29 | return $this; 30 | } 31 | 32 | public function defaultSort(AllowedSort|array|string $sorts): static 33 | { 34 | $sorts = is_array($sorts) ? $sorts : func_get_args(); 35 | 36 | return $this->defaultSorts($sorts); 37 | } 38 | 39 | public function defaultSorts(AllowedSort|array|string $sorts): static 40 | { 41 | if ($this->request->sorts()->isNotEmpty()) { 42 | // We've got requested sorts. No need to parse defaults. 43 | 44 | return $this; 45 | } 46 | 47 | $sorts = is_array($sorts) ? $sorts : func_get_args(); 48 | 49 | collect($sorts) 50 | ->map(function ($sort) { 51 | if ($sort instanceof AllowedSort) { 52 | return $sort; 53 | } 54 | 55 | return AllowedSort::field($sort); 56 | }) 57 | ->each(fn (AllowedSort $sort) => $sort->sort($this)); 58 | 59 | return $this; 60 | } 61 | 62 | protected function addRequestedSortsToQuery(): void 63 | { 64 | $this->request->sorts() 65 | ->each(function (string $property) { 66 | $descending = $property[0] === '-'; 67 | 68 | $key = ltrim($property, '-'); 69 | 70 | $sort = $this->findSort($key); 71 | 72 | $sort?->sort($this, $descending); 73 | }); 74 | } 75 | 76 | protected function findSort(string $property): ?AllowedSort 77 | { 78 | return $this->allowedSorts 79 | ->first(fn (AllowedSort $sort) => $sort->isSort($property)); 80 | } 81 | 82 | protected function ensureAllSortsExist(): void 83 | { 84 | if (config('query-builder.disable_invalid_sort_query_exception', false)) { 85 | return; 86 | } 87 | 88 | $requestedSortNames = $this->request->sorts()->map(fn (string $sort) => ltrim($sort, '-')); 89 | 90 | $allowedSortNames = $this->allowedSorts->map(fn (AllowedSort $sort) => $sort->getName()); 91 | 92 | $unknownSorts = $requestedSortNames->diff($allowedSortNames); 93 | 94 | if ($unknownSorts->isNotEmpty()) { 95 | throw InvalidSortQuery::sortsNotAllowed($unknownSorts, $allowedSortNames); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Filters/FiltersBelongsTo.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class FiltersBelongsTo implements Filter 16 | { 17 | /** {@inheritdoc} */ 18 | public function __invoke(Builder $query, $value, string $property) 19 | { 20 | $values = array_values(Arr::wrap($value)); 21 | 22 | $propertyParts = collect(explode('.', $property)); 23 | $relation = $propertyParts->pop(); 24 | $relationParent = $propertyParts->implode('.'); 25 | $relatedModel = $this->getRelatedModel($query->getModel(), $relation, $relationParent); 26 | 27 | $relatedCollection = $relatedModel->newCollection(); 28 | array_walk($values, fn ($v) => $relatedCollection->add( 29 | tap($relatedModel->newInstance(), fn ($m) => $m->setAttribute($m->getKeyName(), $v)) 30 | )); 31 | 32 | if ($relatedCollection->isEmpty()) { 33 | return $query; 34 | } 35 | 36 | if ($relationParent) { 37 | $query->whereHas($relationParent, fn (Builder $q) => $q->whereBelongsTo($relatedCollection, $relation)); 38 | } else { 39 | $query->whereBelongsTo($relatedCollection, $relation); 40 | } 41 | } 42 | 43 | protected function getRelatedModel(Model $modelQuery, string $relationName, string $relationParent): Model 44 | { 45 | if ($relationParent) { 46 | $modelParent = $this->getModelFromRelation($modelQuery, $relationParent); 47 | } else { 48 | $modelParent = $modelQuery; 49 | } 50 | 51 | $relatedModel = $this->getRelatedModelFromRelation($modelParent, $relationName); 52 | 53 | return $relatedModel; 54 | } 55 | 56 | protected function getRelatedModelFromRelation(Model $model, string $relationName): ?Model 57 | { 58 | $relationObject = $model->$relationName(); 59 | if (! is_subclass_of($relationObject, Relation::class)) { 60 | throw RelationNotFoundException::make($model, $relationName); 61 | } 62 | 63 | $relatedModel = $relationObject->getRelated(); 64 | 65 | return $relatedModel; 66 | } 67 | 68 | protected function getModelFromRelation(Model $model, string $relation, int $level = 0): ?Model 69 | { 70 | $relationParts = explode('.', $relation); 71 | if (count($relationParts) == 1) { 72 | return $this->getRelatedModelFromRelation($model, $relation); 73 | } 74 | 75 | $firstRelation = $relationParts[0]; 76 | $firstRelatedModel = $this->getRelatedModelFromRelation($model, $firstRelation); 77 | if (! $firstRelatedModel) { 78 | return null; 79 | } 80 | 81 | return $this->getModelFromRelation($firstRelatedModel, implode('.', array_slice($relationParts, 1)), $level + 1); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Filters/FiltersScope.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class FiltersScope implements Filter 21 | { 22 | /** {@inheritdoc} */ 23 | public function __invoke(Builder $query, mixed $values, string $property): Builder 24 | { 25 | $propertyParts = collect(explode('.', $property)); 26 | 27 | $scope = Str::camel($propertyParts->pop()); // TODO: Make this configurable? 28 | 29 | $values = array_values(Arr::wrap($values)); 30 | $values = $this->resolveParameters($query, $values, $scope); 31 | 32 | $relation = $propertyParts->implode('.'); 33 | 34 | if ($relation) { 35 | return $query->whereHas($relation, function (Builder $query) use ( 36 | $scope, 37 | $values 38 | ) { 39 | return $query->$scope(...$values); 40 | }); 41 | } 42 | 43 | return $query->$scope(...$values); 44 | } 45 | 46 | protected function resolveParameters(Builder $query, $values, string $scope): array 47 | { 48 | try { 49 | $parameters = (new ReflectionObject($query->getModel())) 50 | ->getMethod('scope' . ucfirst($scope)) 51 | ->getParameters(); 52 | } catch (ReflectionException) { 53 | return $values; 54 | } 55 | 56 | foreach ($parameters as $parameter) { 57 | if (! $this->getClass($parameter)?->isSubclassOf(Model::class)) { 58 | continue; 59 | } 60 | 61 | /** @var TModelClass $model */ 62 | $model = $this->getClass($parameter)->newInstance(); 63 | $index = $parameter->getPosition() - 1; 64 | $value = $values[$index]; 65 | 66 | $result = $model->resolveRouteBinding($value); 67 | 68 | if ($result === null) { 69 | throw InvalidFilterValue::make($value); 70 | } 71 | 72 | $values[$index] = $result; 73 | } 74 | 75 | return $values; 76 | } 77 | 78 | protected function getClass(ReflectionParameter $parameter): ?ReflectionClass 79 | { 80 | $type = $parameter->getType(); 81 | 82 | if (is_null($type)) { 83 | return null; 84 | } 85 | 86 | if ($type instanceof ReflectionUnionType) { 87 | return null; 88 | } 89 | 90 | if ($type->isBuiltin()) { 91 | return null; 92 | } 93 | 94 | if ($type->getName() === 'self') { 95 | return $parameter->getDeclaringClass(); 96 | } 97 | 98 | return new ReflectionClass($type->getName()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config/query-builder.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'include' => 'include', 13 | 14 | 'filter' => 'filter', 15 | 16 | 'sort' => 'sort', 17 | 18 | 'fields' => 'fields', 19 | 20 | 'append' => 'append', 21 | ], 22 | 23 | /* 24 | * Related model counts are included using the relationship name suffixed with this string. 25 | * For example: GET /users?include=postsCount 26 | */ 27 | 'count_suffix' => 'Count', 28 | 29 | /* 30 | * Related model exists are included using the relationship name suffixed with this string. 31 | * For example: GET /users?include=postsExists 32 | */ 33 | 'exists_suffix' => 'Exists', 34 | 35 | /* 36 | * By default the package will throw an `InvalidFilterQuery` exception when a filter in the 37 | * URL is not allowed in the `allowedFilters()` method. 38 | */ 39 | 'disable_invalid_filter_query_exception' => false, 40 | 41 | /* 42 | * By default the package will throw an `InvalidSortQuery` exception when a sort in the 43 | * URL is not allowed in the `allowedSorts()` method. 44 | */ 45 | 'disable_invalid_sort_query_exception' => false, 46 | 47 | /* 48 | * By default the package will throw an `InvalidIncludeQuery` exception when an include in the 49 | * URL is not allowed in the `allowedIncludes()` method. 50 | */ 51 | 'disable_invalid_includes_query_exception' => false, 52 | 53 | /* 54 | * By default, the package expects relationship names to be snake case plural when using fields[relationship]. 55 | * For example, fetching the id and name for a userOwner relation would look like this: 56 | * GET /users?include=userOwner&fields[user_owners]=id,name 57 | * 58 | * Set this to `false` if you don't want that and keep the requested relationship names as-is and allows you to 59 | * request the fields using a camelCase relationship name: 60 | * GET /users?include=userOwner&fields[userOwner]=id,name 61 | */ 62 | 'convert_relation_names_to_snake_case_plural' => true, 63 | 64 | /* 65 | * This is an alternative to the previous option if you don't want to use default snake case plural for fields[relationship]. 66 | * It resolves the table name for the related model using the Laravel model class and, based on your chosen strategy, 67 | * matches it with the fields[relationship] provided in the request. 68 | * 69 | * 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. 70 | * `snake_case` => Matches table names like 'topOrders' to `fields[top_orders]` 71 | * `camelCase` => Matches table names like 'top_orders' to 'fields[topOrders]' 72 | * `none` => Uses the exact table name 73 | */ 74 | 'convert_relation_table_name_strategy' => false, 75 | 76 | /* 77 | * By default, the package expects the field names to match the database names 78 | * For example, fetching the field named firstName would look like this: 79 | * GET /users?fields=firstName 80 | * 81 | * Set this to `true` if you want to convert the firstName into first_name for the underlying query 82 | */ 83 | 'convert_field_names_to_snake_case' => false, 84 | ]; 85 | -------------------------------------------------------------------------------- /src/Concerns/AddsIncludesToQuery.php: -------------------------------------------------------------------------------- 1 | allowedIncludes = collect($includes) 20 | ->reject(function ($include) { 21 | return empty($include); 22 | }) 23 | ->flatMap(function ($include): Collection { 24 | if ($include instanceof Collection) { 25 | return $include; 26 | } 27 | 28 | if ($include instanceof IncludeInterface) { 29 | return collect([$include]); 30 | } 31 | 32 | if (Str::endsWith($include, config('query-builder.count_suffix', 'Count'))) { 33 | return AllowedInclude::count($include); 34 | } 35 | 36 | if (Str::endsWith($include, config('query-builder.exists_suffix', 'Exists'))) { 37 | return AllowedInclude::exists($include); 38 | } 39 | 40 | return AllowedInclude::relationship($include); 41 | }) 42 | ->unique(function (AllowedInclude $allowedInclude) { 43 | return $allowedInclude->getName(); 44 | }); 45 | 46 | $this->ensureAllIncludesExist(); 47 | 48 | $includes = $this->filterNonExistingIncludes($this->request->includes()); 49 | 50 | $this->addIncludesToQuery($includes); 51 | 52 | return $this; 53 | } 54 | 55 | protected function addIncludesToQuery(Collection $includes): void 56 | { 57 | $includes->each(function ($include) { 58 | $include = $this->findInclude($include); 59 | 60 | $include?->include($this); 61 | }); 62 | } 63 | 64 | protected function findInclude(string $include): ?AllowedInclude 65 | { 66 | return $this->allowedIncludes 67 | ->first(fn (AllowedInclude $included) => $included->isForInclude($include)); 68 | } 69 | 70 | protected function ensureAllIncludesExist(): void 71 | { 72 | if (config('query-builder.disable_invalid_includes_query_exception', false)) { 73 | return; 74 | } 75 | 76 | $includes = $this->request->includes(); 77 | 78 | $allowedIncludeNames = $this->allowedIncludes?->map(fn (AllowedInclude $allowedInclude) => $allowedInclude->getName()); 79 | 80 | $diff = $includes->diff($allowedIncludeNames); 81 | 82 | if ($diff->count()) { 83 | throw InvalidIncludeQuery::includesNotAllowed($diff, $allowedIncludeNames); 84 | } 85 | 86 | // TODO: Check for non-existing relationships? 87 | } 88 | 89 | /** 90 | * @param Collection $includes 91 | */ 92 | protected function filterNonExistingIncludes(Collection $includes): Collection 93 | { 94 | if (! config('query-builder.disable_invalid_includes_query_exception', false)) { 95 | return $includes; 96 | } 97 | 98 | return $includes->filter(fn ($include) => ! is_null($this->findInclude($include))); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class QueryBuilder implements ArrayAccess 21 | { 22 | use FiltersQuery; 23 | use SortsQuery; 24 | use AddsIncludesToQuery; 25 | use AddsFieldsToQuery; 26 | use ForwardsCalls; 27 | 28 | protected QueryBuilderRequest $request; 29 | 30 | public function __construct( 31 | protected EloquentBuilder|Relation $subject, 32 | ?Request $request = null 33 | ) { 34 | $this->request = $request 35 | ? QueryBuilderRequest::fromRequest($request) 36 | : app(QueryBuilderRequest::class); 37 | } 38 | 39 | public function getEloquentBuilder(): EloquentBuilder 40 | { 41 | if ($this->subject instanceof EloquentBuilder) { 42 | return $this->subject; 43 | } 44 | 45 | return $this->subject->getQuery(); 46 | } 47 | 48 | public function getSubject(): Relation|EloquentBuilder 49 | { 50 | return $this->subject; 51 | } 52 | 53 | /** 54 | * @template T of Model 55 | * 56 | * @param EloquentBuilder|Relation|class-string $subject 57 | * @return static 58 | */ 59 | public static function for( 60 | EloquentBuilder|Relation|string $subject, 61 | ?Request $request = null 62 | ): static { 63 | if (is_subclass_of($subject, Model::class)) { 64 | $subject = $subject::query(); 65 | } 66 | 67 | /** @var static $queryBuilder */ 68 | $queryBuilder = new static($subject, $request); 69 | 70 | return $queryBuilder; 71 | } 72 | 73 | public function __call($name, $arguments) 74 | { 75 | $result = $this->forwardCallTo($this->subject, $name, $arguments); 76 | 77 | /* 78 | * If the forwarded method call is part of a chain we can return $this 79 | * instead of the actual $result to keep the chain going. 80 | */ 81 | if ($result === $this->subject) { 82 | return $this; 83 | } 84 | 85 | return $result; 86 | } 87 | 88 | public function clone(): static 89 | { 90 | return clone $this; 91 | } 92 | 93 | public function __clone() 94 | { 95 | $this->subject = clone $this->subject; 96 | } 97 | 98 | public function __get($name) 99 | { 100 | return $this->subject->{$name}; 101 | } 102 | 103 | public function __set($name, $value) 104 | { 105 | $this->subject->{$name} = $value; 106 | } 107 | 108 | public function offsetExists($offset): bool 109 | { 110 | return isset($this->subject[$offset]); 111 | } 112 | 113 | public function offsetGet($offset): bool 114 | { 115 | return $this->subject[$offset]; 116 | } 117 | 118 | public function offsetSet($offset, $value): void 119 | { 120 | $this->subject[$offset] = $value; 121 | } 122 | 123 | public function offsetUnset($offset): void 124 | { 125 | unset($this->subject[$offset]); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | weight: 1 4 | --- 5 | 6 | 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. 7 | 8 | Here's how we use the package ourselves in [Mailcoach](https://mailcoach.app). 9 | 10 | 11 | 12 | ## Basic usage 13 | 14 | ### Filter a query based on a request: `/users?filter[name]=John`: 15 | 16 | ```php 17 | use Spatie\QueryBuilder\QueryBuilder; 18 | 19 | $users = QueryBuilder::for(User::class) 20 | ->allowedFilters('name') 21 | ->get(); 22 | 23 | // all `User`s that contain the string "John" in their name 24 | ``` 25 | 26 | [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/) 27 | 28 | ### Including relations based on a request: `/users?include=posts`: 29 | 30 | ```php 31 | $users = QueryBuilder::for(User::class) 32 | ->allowedIncludes('posts') 33 | ->get(); 34 | 35 | // all `User`s with their `posts` loaded 36 | ``` 37 | 38 | [Read more about include features like: including nested relationships, including relationship count, ...](https://spatie.be/docs/laravel-query-builder/v6/features/including-relationships/) 39 | 40 | ### Sorting a query based on a request: `/users?sort=id`: 41 | 42 | ```php 43 | $users = QueryBuilder::for(User::class) 44 | ->allowedSorts('id') 45 | ->get(); 46 | 47 | // all `User`s sorted by ascending id 48 | ``` 49 | 50 | [Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v6/features/sorting/) 51 | 52 | ### Works together nicely with existing queries: 53 | 54 | ```php 55 | $query = User::where('active', true); 56 | 57 | $userQuery = QueryBuilder::for($query) // start from an existing Builder instance 58 | ->withTrashed() // use your existing scopes 59 | ->allowedIncludes('posts', 'permissions') 60 | ->where('score', '>', 42); // chain on any of Laravel's query builder methods 61 | ``` 62 | 63 | ### Selecting fields for a query: `/users?fields=id,email` 64 | 65 | ```php 66 | $users = QueryBuilder::for(User::class) 67 | ->allowedFields(['id', 'email']) 68 | ->get(); 69 | 70 | // the fetched `User`s will only have their id & email set 71 | ``` 72 | 73 | [Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v6/features/selecting-fields/) 74 | 75 | ## We have badges! 76 | 77 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) 78 | [![Build Status](https://img.shields.io/circleci/project/github/spatie/laravel-query-builder/master.svg?style=flat-square)](https://circleci.com/gh/spatie/laravel-query-builder) 79 | [![StyleCI](https://styleci.io/repos/117567334/shield?branch=master)](https://styleci.io/repos/117567334) 80 | [![Quality Score](https://img.shields.io/scrutinizer/g/spatie/laravel-query-builder.svg?style=flat-square)](https://scrutinizer-ci.com/g/spatie/laravel-query-builder) 81 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) 82 | 83 | ![Look at all those badges](https://i.imgflip.com/36x6d6.jpg) 84 | -------------------------------------------------------------------------------- /src/AllowedInclude.php: -------------------------------------------------------------------------------- 1 | internalName = $internalName ?? $this->name; 24 | } 25 | 26 | public static function relationship(string $name, ?string $internalName = null): Collection 27 | { 28 | $internalName = $internalName ?? $name; 29 | 30 | return IncludedRelationship::getIndividualRelationshipPathsFromInclude($internalName) 31 | ->zip(IncludedRelationship::getIndividualRelationshipPathsFromInclude($name)) 32 | ->flatMap(function ($args): Collection { 33 | [$relationship, $alias] = $args; 34 | 35 | $includes = collect([ 36 | new self($alias, new IncludedRelationship(), $relationship), 37 | ]); 38 | 39 | if (! Str::contains($relationship, '.')) { 40 | $countSuffix = config('query-builder.count_suffix', 'Count'); 41 | $existsSuffix = config('query-builder.exists_suffix', 'Exists'); 42 | 43 | $includes = $includes 44 | ->merge(self::count( 45 | $alias.$countSuffix, 46 | $relationship.$countSuffix 47 | )) 48 | ->merge(self::exists( 49 | $alias.$existsSuffix, 50 | $relationship.$existsSuffix 51 | )); 52 | } 53 | 54 | return $includes; 55 | }); 56 | } 57 | 58 | public static function count(string $name, ?string $internalName = null): Collection 59 | { 60 | return collect([ 61 | new static($name, new IncludedCount(), $internalName), 62 | ]); 63 | } 64 | 65 | public static function exists(string $name, ?string $internalName = null): Collection 66 | { 67 | return collect([ 68 | new static($name, new IncludedExists(), $internalName), 69 | ]); 70 | } 71 | 72 | public static function callback(string $name, Closure $callback, ?string $internalName = null): Collection 73 | { 74 | return collect([ 75 | new static($name, new IncludedCallback($callback), $internalName), 76 | ]); 77 | } 78 | 79 | public static function custom(string $name, IncludeInterface $includeClass, ?string $internalName = null): Collection 80 | { 81 | return collect([ 82 | new static($name, $includeClass, $internalName), 83 | ]); 84 | } 85 | 86 | public function include(QueryBuilder $query): void 87 | { 88 | if (property_exists($this->includeClass, 'getRequestedFieldsForRelatedTable')) { 89 | $this->includeClass->getRequestedFieldsForRelatedTable = function (...$args) use ($query) { 90 | return $query->getRequestedFieldsForRelatedTable(...$args); 91 | }; 92 | } 93 | 94 | ($this->includeClass)($query->getEloquentBuilder(), $this->internalName); 95 | } 96 | 97 | public function getName(): string 98 | { 99 | return $this->name; 100 | } 101 | 102 | public function isForInclude(string $includeName): bool 103 | { 104 | return $this->name === $includeName; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /docs/installation-setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation & setup 3 | weight: 4 4 | --- 5 | 6 | You can install the package via composer: 7 | 8 | ```bash 9 | composer require spatie/laravel-query-builder 10 | ``` 11 | 12 | The package will automatically register its service provider. 13 | 14 | You can optionally publish the config file with: 15 | 16 | ```bash 17 | php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider" --tag="query-builder-config" 18 | ``` 19 | 20 | These are the contents of the default config file that will be published: 21 | 22 | ```php 23 | return [ 24 | 25 | /* 26 | * By default the package will use the `include`, `filter`, `sort` 27 | * and `fields` query parameters as described in the readme. 28 | * 29 | * You can customize these query string parameters here. 30 | */ 31 | 'parameters' => [ 32 | 'include' => 'include', 33 | 34 | 'filter' => 'filter', 35 | 36 | 'sort' => 'sort', 37 | 38 | 'fields' => 'fields', 39 | 40 | 'append' => 'append', 41 | ], 42 | 43 | /* 44 | * Related model counts are included using the relationship name suffixed with this string. 45 | * For example: GET /users?include=postsCount 46 | */ 47 | 'count_suffix' => 'Count', 48 | 49 | /* 50 | * Related model exists are included using the relationship name suffixed with this string. 51 | * For example: GET /users?include=postsExists 52 | */ 53 | 'exists_suffix' => 'Exists', 54 | 55 | /* 56 | * By default the package will throw an `InvalidFilterQuery` exception when a filter in the 57 | * URL is not allowed in the `allowedFilters()` method. 58 | */ 59 | 'disable_invalid_filter_query_exception' => false, 60 | 61 | /* 62 | * By default the package will throw an `InvalidSortQuery` exception when a sort in the 63 | * URL is not allowed in the `allowedSorts()` method. 64 | */ 65 | 'disable_invalid_sort_query_exception' => false, 66 | 67 | /* 68 | * By default the package will throw an `InvalidIncludeQuery` exception when an include in the 69 | * URL is not allowed in the `allowedIncludes()` method. 70 | */ 71 | 'disable_invalid_includes_query_exception' => false, 72 | 73 | /* 74 | * By default, the package expects relationship names to be snake case plural when using fields[relationship]. 75 | * For example, fetching the id and name for a userOwner relation would look like this: 76 | * GET /users?include=userOwner&fields[user_owners]=id,name 77 | * 78 | * Set this to `false` if you don't want that and keep the requested relationship names as-is and allows you to 79 | * request the fields using a camelCase relationship name: 80 | * GET /users?include=userOwner&fields[userOwner]=id,name 81 | */ 82 | 'convert_relation_names_to_snake_case_plural' => true, 83 | 84 | /* 85 | * This is an alternative to the previous option if you don't want to use default snake case plural for fields[relationship]. 86 | * It resolves the table name for the related model using the Laravel model class and, based on your chosen strategy, 87 | * matches it with the fields[relationship] provided in the request. 88 | * 89 | * 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. 90 | * `snake_case` => Matches table names like 'topOrders' to `fields[top_orders]` 91 | * `camelCase` => Matches table names like 'top_orders' to 'fields[topOrders]' 92 | * `none` => Uses the exact table name 93 | */ 94 | 'convert_relation_table_name_strategy' => false, 95 | 96 | /* 97 | * By default, the package expects the field names to match the database names 98 | * For example, fetching the field named firstName would look like this: 99 | * GET /users?fields=firstName 100 | * 101 | * Set this to `true` if you want to convert the firstName into first_name for the underlying query 102 | */ 103 | 'convert_field_names_to_snake_case' => false, 104 | ]; 105 | ``` 106 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | ## From v5 to v6 4 | 5 | A lot of the query builder classes now have typed properties and method parameters. If you have any custom sorts, includes, or filters, you will need to specify the property and parameter types used. 6 | 7 | ## Notice when upgrading to 5.6.0 8 | 9 | The changes to the `default()` method break backwards compatibility when setting the default value to `null` (`default(null)`). This is pretty much an edge case, but if you're trying to unset the default value, you can use the `unsetDefault()` method instead. 10 | 11 | ## From v4 to v5 12 | 13 | This version adds support for Laravel 9 and drops support for all older version. 14 | 15 | Appending attributes to a query was removed to make package maintenance easier. The rest of the public API was not changed, so you'll be able to upgrade without making any changes. 16 | 17 | ## From v3 to v4 18 | 19 | The biggest change in v4 is the way requested filters, includes and fields are processed. In previous versions we would automatically camel-case relationship names for includes and nested filters. Requested (nested) fields would also be transformed to their plural snake-case form, regardless of what was actually requested. 20 | 21 | In v4 we've removed this behaviour and will instead always pass the requested filter, include or field from the request URL to the query. 22 | 23 | When following Laravel's convention of camelcase relationship names, a request will look like this: 24 | 25 | ``` 26 | GET /api/users 27 | ?include=latestPosts,friendRequests 28 | &filter[homeAddress.city]=Antwerp 29 | &fields[related_models.test_models]=id,name 30 | ``` 31 | 32 | A minimal `QueryBuilder` for the above request looks like this: 33 | 34 | ```php 35 | use Spatie\QueryBuilder\QueryBuilder; 36 | 37 | QueryBuilder::for(User::class) 38 | ->allowedIncludes(['latestPosts', 'friendRequests']) 39 | ->allowedFilters(['homeAddress.city']) 40 | ->allowedFields(['related_models.test_models.id', 'related_models.test_models.name']); 41 | ``` 42 | 43 | There is no automated upgrade path available at this time. 44 | 45 | ## From v2 to v3 46 | 47 | Possible changes in this version due to internal changes. 48 | 49 | The package's `Spatie\QueryBuilder\QueryBuilder` class no longer extends Laravel's `Illuminate\Database\Eloquent\Builder`. This means you can no longer pass a `QueryBuilder` instance where a `Illuminate\Database\Eloquent\Builder` instance is expected. However, all Eloquent method calls get forwarded to the internal `Illuminate\Database\Eloquent\Builder`. 50 | 51 | Using `$queryBuilder->getEloquentBuilder()` you can access the internal `Illuminate\Database\Eloquent\Builder`. 52 | 53 | ## From v1 to v2 54 | 55 | There are a lot of renamed methods and classes in this release. An advanced IDE like PhpStorm is recommended to rename these methods and classes in your code base. Use the refactor -> rename functionality instead of find & replace. 56 | 57 | - rename `Spatie\QueryBuilder\Sort` to `Spatie\QueryBuilder\AllowedSort` 58 | - rename `Spatie\QueryBuilder\Included` to `Spatie\QueryBuilder\AllowedInclude` 59 | - rename `Spatie\QueryBuilder\Filter` to `Spatie\QueryBuilder\AllowedFilter` 60 | - replace request macro's like `request()->filters()`, `request()->includes()`, etc... with their related methods on the `QueryBuilderRequest` class. This class needs to be instantiated with a request object, (more info here: https://github.com/spatie/laravel-query-builder/issues/328): 61 | * `request()->includes()` -> `QueryBuilderRequest::fromRequest($request)->includes()` 62 | * `request()->filters()` -> `QueryBuilderRequest::fromRequest($request)->filters()` 63 | * `request()->sorts()` -> `QueryBuilderRequest::fromRequest($request)->sorts()` 64 | * `request()->fields()` -> `QueryBuilderRequest::fromRequest($request)->fields()` 65 | * `request()->appends()` -> `QueryBuilderRequest::fromRequest($request)->appends()` 66 | - please note that the above methods on `QueryBuilderRequest` do not take any arguments. You can use the `contains` to check for a certain filter/include/sort/... 67 | - make sure the second argument for `AllowedSort::custom()` is an instance of a sort class, not a classname 68 | * `AllowedSort::custom('name', MySort::class)` -> `AllowedSort::custom('name', new MySort())` 69 | - make sure the second argument for `AllowedFilter::custom()` is an instance of a filter class, not a classname 70 | * `AllowedFilter::custom('name', MyFilter::class)` -> `AllowedFilter::custom('name', new MyFilter())` 71 | - make sure all required sorts are allowed using `allowedSorts()` 72 | - make sure all required field selects are allowed using `allowedFields()` 73 | - make sure `allowedFields()` is always called before `allowedIncludes()` 74 | -------------------------------------------------------------------------------- /docs/features/sorting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sorting 3 | weight: 2 4 | --- 5 | 6 | The `sort` query parameter is used to determine by which property the results collection will be ordered. Sorting is ascending by default and can be reversed by adding a hyphen (`-`) to the start of the property name. 7 | 8 | All sorts have to be explicitly allowed by passing an array to the `allowedSorts()` method. The `allowedSorts` method takes an array of column names as strings or instances of `AllowedSorts`. 9 | 10 | For more advanced use cases, [custom sorts](#content-custom-sorts) can be used. 11 | 12 | ## Basic usage 13 | 14 | ```php 15 | // GET /users?sort=-name 16 | 17 | $users = QueryBuilder::for(User::class) 18 | ->allowedSorts('name') 19 | ->get(); 20 | 21 | // $users will be sorted by name and descending (Z -> A) 22 | ``` 23 | 24 | To define a default sort parameter that should be applied without explicitly adding it to the request, you can use the `defaultSort` method. 25 | 26 | ```php 27 | // GET /users 28 | $users = QueryBuilder::for(User::class) 29 | ->defaultSort('name') 30 | ->allowedSorts('name', 'street') 31 | ->get(); 32 | 33 | // Will retrieve the users sorted by name 34 | ``` 35 | 36 | You can use `-` if you want to have the default order sorted descendingly. 37 | 38 | ```php 39 | // GET /users 40 | $users = QueryBuilder::for(User::class) 41 | ->defaultSort('-name') 42 | ->allowedSorts('name', 'street') 43 | ->get(); 44 | 45 | // Will retrieve the users sorted descendingly by name 46 | ``` 47 | 48 | You can define multiple default sorts 49 | 50 | ```php 51 | // GET /users 52 | $users = QueryBuilder::for(User::class) 53 | ->defaultSort('-street', 'name') 54 | ->allowedSorts('name', 'street') 55 | ->get(); 56 | 57 | // Will retrieve the users sorted descendingly by street than in ascending order by name 58 | ``` 59 | 60 | You can sort by multiple properties by separating them with a comma: 61 | 62 | ```php 63 | // GET /users?sort=name,-street 64 | 65 | $users = QueryBuilder::for(User::class) 66 | ->allowedSorts(['name', 'street']) 67 | ->get(); 68 | 69 | // $users will be sorted by name in ascending order with a secondary sort on street in descending order. 70 | ``` 71 | 72 | ## Disallowed sorts 73 | 74 | When trying to sort by a property that's not specified in `allowedSorts()` an `InvalidSortQuery` exception will be thrown. 75 | 76 | ```php 77 | // GET /users?sort=password 78 | $users = QueryBuilder::for(User::class) 79 | ->allowedSorts(['name']) 80 | ->get(); 81 | 82 | // Will throw an `InvalidSortQuery` exception as `password` is not an allowed sorting property 83 | ``` 84 | 85 | ## Custom sorts 86 | 87 | You can specify custom sorting methods using the `AllowedSort::custom()` method. Custom sorts are instances of invokable classes that implement the `\Spatie\QueryBuilder\Sorts\Sort` interface. The `__invoke` method will receive the current query builder instance, the direction to sort in and the sort's name. This way you can build any sorting query your heart desires. 88 | 89 | For example sorting by string column length: 90 | 91 | ```php 92 | class StringLengthSort implements \Spatie\QueryBuilder\Sorts\Sort 93 | { 94 | public function __invoke(Builder $query, bool $descending, string $property) 95 | { 96 | $direction = $descending ? 'DESC' : 'ASC'; 97 | 98 | $query->orderByRaw("LENGTH(`{$property}`) {$direction}"); 99 | } 100 | } 101 | ``` 102 | 103 | The custom `StringLengthSort` sort class can then be used like this to sort by the length of the `users.name` column: 104 | 105 | ```php 106 | // GET /users?sort=name-length 107 | 108 | $users = QueryBuilder::for(User::class) 109 | ->allowedSorts([ 110 | AllowedSort::custom('name-length', new StringLengthSort(), 'name'), 111 | ]) 112 | ->get(); 113 | 114 | // The requested `name-length` sort alias will invoke `StringLengthSort` with the `name` column name. 115 | ``` 116 | 117 | To change the default direction of the a sort you can use `defaultDirection` : 118 | 119 | ```php 120 | $customSort = AllowedSort::custom('custom-sort', new SentSort())->defaultDirection(SortDirection::DESCENDING); 121 | 122 | $users = QueryBuilder::for(User::class) 123 | ->allowedSorts($customSort) 124 | ->defaultSort($customSort) 125 | ->get(); 126 | ``` 127 | 128 | ## Using an alias for sorting 129 | 130 | There may be occasions where it is not appropriate to expose the column name to the user. 131 | 132 | Similar to using an alias when filtering, you can do this for sorts as well. 133 | 134 | The column name can be passed as optional parameter and defaults to the property string. 135 | 136 | ```php 137 | // GET /users?sort=-street 138 | $users = QueryBuilder::for(User::class) 139 | ->allowedSorts([ 140 | AllowedSort::field('street', 'actual_column_street'), 141 | ]) 142 | ->get(); 143 | ``` 144 | -------------------------------------------------------------------------------- /src/Concerns/AddsFieldsToQuery.php: -------------------------------------------------------------------------------- 1 | allowedIncludes instanceof Collection) { 18 | throw new AllowedFieldsMustBeCalledBeforeAllowedIncludes(); 19 | } 20 | 21 | $fields = is_array($fields) ? $fields : func_get_args(); 22 | 23 | $this->allowedFields = collect($fields) 24 | ->map(function (string $fieldName) { 25 | return $this->prependField($fieldName); 26 | }); 27 | 28 | $this->ensureAllFieldsExist(); 29 | 30 | $this->addRequestedModelFieldsToQuery(); 31 | 32 | return $this; 33 | } 34 | 35 | protected function addRequestedModelFieldsToQuery(): void 36 | { 37 | $modelTableName = $this->getModel()->getTable(); 38 | 39 | $fields = $this->request->fields(); 40 | 41 | if (! $fields->isEmpty() && config('query-builder.convert_field_names_to_snake_case', false)) { 42 | $fields = $fields->mapWithKeys(fn ($fields, $table) => [$table => collect($fields)->map(fn ($field) => Str::snake($field))->toArray()]); 43 | } 44 | 45 | // Apply additional table name conversion based on strategy 46 | if (config('query-builder.convert_relation_table_name_strategy', false) === 'camelCase') { 47 | $modelFields = $fields->has(Str::camel($modelTableName)) ? $fields->get(Str::camel($modelTableName)) : $fields->get('_'); 48 | } else { 49 | $modelFields = $fields->has($modelTableName) ? $fields->get($modelTableName) : $fields->get('_'); 50 | } 51 | 52 | if (empty($modelFields)) { 53 | return; 54 | } 55 | 56 | $prependedFields = $this->prependFieldsWithTableName($modelFields, $modelTableName); 57 | 58 | $this->select($prependedFields); 59 | } 60 | 61 | public function getRequestedFieldsForRelatedTable(string $relation, ?string $tableName = null): array 62 | { 63 | // Possible table names to check 64 | $possibleRelatedNames = [ 65 | // Preserve existing relation name conversion logic 66 | config('query-builder.convert_relation_names_to_snake_case_plural', true) 67 | ? Str::plural(Str::snake($relation)) 68 | : $relation, 69 | ]; 70 | 71 | $strategy = config('query-builder.convert_relation_table_name_strategy', false); 72 | 73 | // Apply additional table name conversion based on strategy 74 | if ($strategy === 'snake_case' && $tableName) { 75 | $possibleRelatedNames[] = Str::snake($tableName); 76 | } elseif ($strategy === 'camelCase' && $tableName) { 77 | $possibleRelatedNames[] = Str::camel($tableName); 78 | } elseif ($strategy === 'none') { 79 | $possibleRelatedNames[] = $tableName; 80 | } 81 | 82 | // Remove any null values 83 | $possibleRelatedNames = array_filter($possibleRelatedNames); 84 | 85 | $fields = $this->request->fields() 86 | ->mapWithKeys(fn ($fields, $table) => [$table => collect($fields)->map(fn ($field) => config('query-builder.convert_field_names_to_snake_case', false) ? Str::snake($field) : $field)]) 87 | ->filter(fn ($value, $table) => in_array($table, $possibleRelatedNames)) 88 | ->first(); 89 | 90 | if (! $fields) { 91 | return []; 92 | } 93 | 94 | $fields = $fields->toArray(); 95 | 96 | if ($tableName !== null) { 97 | $fields = $this->prependFieldsWithTableName($fields, $tableName); 98 | } 99 | 100 | if (! $this->allowedFields instanceof Collection) { 101 | throw new UnknownIncludedFieldsQuery($fields); 102 | } 103 | 104 | return $fields; 105 | } 106 | 107 | protected function ensureAllFieldsExist(): void 108 | { 109 | $modelTable = $this->getModel()->getTable(); 110 | 111 | $requestedFields = $this->request->fields() 112 | ->map(function ($fields, $model) use ($modelTable) { 113 | $tableName = $model; 114 | 115 | return $this->prependFieldsWithTableName($fields, $model === '_' ? $modelTable : $tableName); 116 | }) 117 | ->flatten() 118 | ->unique(); 119 | 120 | $unknownFields = $requestedFields->diff($this->allowedFields); 121 | 122 | if ($unknownFields->isNotEmpty()) { 123 | throw InvalidFieldQuery::fieldsNotAllowed($unknownFields, $this->allowedFields); 124 | } 125 | } 126 | 127 | protected function prependFieldsWithTableName(array $fields, string $tableName): array 128 | { 129 | return array_map(function ($field) use ($tableName) { 130 | return $this->prependField($field, $tableName); 131 | }, $fields); 132 | } 133 | 134 | protected function prependField(string $field, ?string $table = null): string 135 | { 136 | if (! $table) { 137 | $table = $this->getModel()->getTable(); 138 | } 139 | 140 | if (Str::contains($field, '.')) { 141 | // Already prepended 142 | 143 | return $field; 144 | } 145 | 146 | return "{$table}.{$field}"; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-query-builder 6 | 7 | 8 | 9 |

Build Eloquent queries from API requests

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) 12 | ![Test Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-query-builder/run-tests.yml?label=tests&branch=main) 13 | ![Code Style Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-query-builder/php-cs-fixer.yml?label=code%20style&branch=main) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-query-builder.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-query-builder) 15 | 16 |
17 | 18 | ## Basic usage 19 | 20 | ### Filter a query based on a request: `/users?filter[name]=John`: 21 | 22 | ```php 23 | use Spatie\QueryBuilder\QueryBuilder; 24 | 25 | $users = QueryBuilder::for(User::class) 26 | ->allowedFilters('name') 27 | ->get(); 28 | 29 | // all `User`s that contain the string "John" in their name 30 | ``` 31 | 32 | [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/) 33 | 34 | ### Including relations based on a request: `/users?include=posts`: 35 | 36 | ```php 37 | $users = QueryBuilder::for(User::class) 38 | ->allowedIncludes('posts') 39 | ->get(); 40 | 41 | // all `User`s with their `posts` loaded 42 | ``` 43 | 44 | [Read more about include features like: including nested relationships, including relationship count, custom includes, ...](https://spatie.be/docs/laravel-query-builder/v5/features/including-relationships/) 45 | 46 | ### Sorting a query based on a request: `/users?sort=id`: 47 | 48 | ```php 49 | $users = QueryBuilder::for(User::class) 50 | ->allowedSorts('id') 51 | ->get(); 52 | 53 | // all `User`s sorted by ascending id 54 | ``` 55 | 56 | [Read more about sorting features like: custom sorts, sort direction, ...](https://spatie.be/docs/laravel-query-builder/v5/features/sorting/) 57 | 58 | ### Works together nicely with existing queries: 59 | 60 | ```php 61 | $query = User::where('active', true); 62 | 63 | $userQuery = QueryBuilder::for($query) // start from an existing Builder instance 64 | ->withTrashed() // use your existing scopes 65 | ->allowedIncludes('posts', 'permissions') 66 | ->where('score', '>', 42); // chain on any of Laravel's query builder methods 67 | ``` 68 | 69 | ### Selecting fields for a query: `/users?fields[users]=id,email` 70 | 71 | ```php 72 | $users = QueryBuilder::for(User::class) 73 | ->allowedFields(['id', 'email']) 74 | ->get(); 75 | 76 | // the fetched `User`s will only have their id & email set 77 | ``` 78 | 79 | [Read more about selecting fields.](https://spatie.be/docs/laravel-query-builder/v5/features/selecting-fields/) 80 | 81 | ## Support us 82 | 83 | [](https://spatie.be/github-ad-click/laravel-query-builder) 84 | 85 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 86 | 87 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 88 | 89 | ## Installation 90 | 91 | You can install the package via composer: 92 | 93 | ```bash 94 | composer require spatie/laravel-query-builder 95 | ``` 96 | 97 | Read the installation notes on the docs site: [https://spatie.be/docs/laravel-query-builder/v5/installation-setup](https://spatie.be/docs/laravel-query-builder/v5/installation-setup/). 98 | 99 | ## Documentation 100 | 101 | You can find the documentation on [https://spatie.be/docs/laravel-query-builder/v5](https://spatie.be/docs/laravel-query-builder/v5). 102 | 103 | Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the media library? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-query-builder/issues), we'll try to address it as soon as possible. 104 | 105 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 106 | 107 | ### Upgrading 108 | 109 | Please see [UPGRADING.md](UPGRADING.md) for details. 110 | 111 | ### Testing 112 | 113 | ```bash 114 | composer test 115 | ``` 116 | 117 | ### Changelog 118 | 119 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 120 | 121 | ## Contributing 122 | 123 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 124 | 125 | ### Security 126 | 127 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 128 | 129 | ## Credits 130 | 131 | - [Alex Vanderbist](https://github.com/AlexVanderbist) 132 | - [All Contributors](../../contributors) 133 | 134 | ## License 135 | 136 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 137 | -------------------------------------------------------------------------------- /src/AllowedFilter.php: -------------------------------------------------------------------------------- 1 | ignored = Collection::make(); 36 | 37 | $this->internalName = $internalName ?? $name; 38 | } 39 | 40 | public function filter(QueryBuilder $query, $value): void 41 | { 42 | $valueToFilter = $this->resolveValueForFiltering($value); 43 | 44 | if (! $this->nullable && is_null($valueToFilter)) { 45 | return; 46 | } 47 | 48 | ($this->filterClass)($query->getEloquentBuilder(), $valueToFilter, $this->internalName); 49 | } 50 | 51 | public static function setFilterArrayValueDelimiter(?string $delimiter = null): void 52 | { 53 | if (isset($delimiter)) { 54 | QueryBuilderRequest::setFilterArrayValueDelimiter($delimiter); 55 | } 56 | } 57 | 58 | public static function exact(string $name, ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static 59 | { 60 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 61 | 62 | return new static($name, new FiltersExact($addRelationConstraint), $internalName); 63 | } 64 | 65 | public static function partial(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static 66 | { 67 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 68 | 69 | return new static($name, new FiltersPartial($addRelationConstraint), $internalName); 70 | } 71 | 72 | public static function beginsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static 73 | { 74 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 75 | 76 | return new static($name, new FiltersBeginsWithStrict($addRelationConstraint), $internalName); 77 | } 78 | 79 | public static function endsWithStrict(string $name, $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): static 80 | { 81 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 82 | 83 | return new static($name, new FiltersEndsWithStrict($addRelationConstraint), $internalName); 84 | } 85 | 86 | public static function belongsTo(string $name, $internalName = null, ?string $arrayValueDelimiter = null): static 87 | { 88 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 89 | 90 | return new static($name, new FiltersBelongsTo(), $internalName); 91 | } 92 | 93 | public static function scope(string $name, $internalName = null, ?string $arrayValueDelimiter = null): static 94 | { 95 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 96 | 97 | return new static($name, new FiltersScope(), $internalName); 98 | } 99 | 100 | public static function callback(string $name, $callback, $internalName = null, ?string $arrayValueDelimiter = null): static 101 | { 102 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 103 | 104 | return new static($name, new FiltersCallback($callback), $internalName); 105 | } 106 | 107 | public static function trashed(string $name = 'trashed', $internalName = null): static 108 | { 109 | return new static($name, new FiltersTrashed(), $internalName); 110 | } 111 | 112 | public static function custom(string $name, Filter $filterClass, $internalName = null, ?string $arrayValueDelimiter = null): static 113 | { 114 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 115 | 116 | return new static($name, $filterClass, $internalName); 117 | } 118 | 119 | public static function operator(string $name, FilterOperator $filterOperator, string $boolean = 'and', ?string $internalName = null, bool $addRelationConstraint = true, ?string $arrayValueDelimiter = null): self 120 | { 121 | static::setFilterArrayValueDelimiter($arrayValueDelimiter); 122 | 123 | return new static($name, new FiltersOperator($addRelationConstraint, $filterOperator, $boolean), $internalName); 124 | } 125 | 126 | public function getFilterClass(): Filter 127 | { 128 | return $this->filterClass; 129 | } 130 | 131 | public function getName(): string 132 | { 133 | return $this->name; 134 | } 135 | 136 | public function isForFilter(string $filterName): bool 137 | { 138 | return $this->name === $filterName; 139 | } 140 | 141 | public function ignore(...$values): static 142 | { 143 | $this->ignored = $this->ignored 144 | ->merge($values) 145 | ->flatten(); 146 | 147 | return $this; 148 | } 149 | 150 | public function getIgnored(): array 151 | { 152 | return $this->ignored->toArray(); 153 | } 154 | 155 | public function getInternalName(): string 156 | { 157 | return $this->internalName; 158 | } 159 | 160 | public function default($value): static 161 | { 162 | $this->hasDefault = true; 163 | $this->default = $value; 164 | 165 | if (is_null($value)) { 166 | $this->nullable(true); 167 | } 168 | 169 | return $this; 170 | } 171 | 172 | public function getDefault() 173 | { 174 | return $this->default; 175 | } 176 | 177 | public function hasDefault(): bool 178 | { 179 | return $this->hasDefault; 180 | } 181 | 182 | public function nullable(bool $nullable = true): static 183 | { 184 | $this->nullable = $nullable; 185 | 186 | return $this; 187 | } 188 | 189 | public function unsetDefault(): static 190 | { 191 | $this->hasDefault = false; 192 | unset($this->default); 193 | 194 | return $this; 195 | } 196 | 197 | protected function resolveValueForFiltering($value) 198 | { 199 | if (is_array($value)) { 200 | $remainingProperties = array_map([$this, 'resolveValueForFiltering'], $value); 201 | 202 | return ! empty($remainingProperties) ? $remainingProperties : null; 203 | } 204 | 205 | return ! $this->ignored->contains($value) ? $value : null; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /docs/features/including-relationships.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Including relationships 3 | weight: 3 4 | --- 5 | 6 | The `include` query parameter will load any Eloquent relation or relation count on the resulting models. 7 | All includes must be explicitly allowed using `allowedIncludes()`. This method takes an array of relationship names or `AllowedInclude` instances. 8 | 9 | ## Basic usage 10 | 11 | ```php 12 | // GET /users?include=posts 13 | 14 | $users = QueryBuilder::for(User::class) 15 | ->allowedIncludes(['posts']) 16 | ->get(); 17 | 18 | // $users will have all their their `posts()` related models loaded 19 | ``` 20 | 21 | You can load multiple relationships by separating them with a comma: 22 | 23 | ```php 24 | // GET /users?include=posts,permissions 25 | $users = QueryBuilder::for(User::class) 26 | ->allowedIncludes(['posts', 'permissions']) 27 | ->get(); 28 | 29 | // $users will contain all users with their posts and permissions loaded 30 | ``` 31 | 32 | ## Default includes 33 | 34 | There is no way to include relationships by default in this package. Default relationships are built-in to Laravel itself using the `with()` method on a query: 35 | 36 | ```php 37 | $users = QueryBuilder::for(User::class) 38 | ->allowedIncludes(['friends']) 39 | ->with('posts') // posts will always by included, friends can be requested 40 | ->withCount('posts') 41 | ->withExists('posts') 42 | ->get(); 43 | ``` 44 | 45 | ## Disallowed includes 46 | 47 | When trying to include relationships that have not been allowed using `allowedIncludes()` an `InvalidIncludeQuery` exception will be thrown. Its exception message contains the allowed includes for reference. 48 | 49 | ## Nested relationships 50 | 51 | You can load nested relationships using the dot `.` notation: 52 | 53 | ```php 54 | // GET /users?include=posts.comments,permissions 55 | 56 | $users = QueryBuilder::for(User::class) 57 | ->allowedIncludes(['posts.comments', 'permissions']) 58 | ->get(); 59 | 60 | // $users will contain all users with their posts, comments on their posts and permissions loaded 61 | ``` 62 | 63 | ## Including related model count 64 | 65 | Every allowed include will automatically allow requesting its related model count using a `Count` suffix. On top of that it's also possible to specifically allow requesting and querying the related model count (and not include the entire relationship). 66 | 67 | Under the hood this uses Laravel's `withCount method`. [Read more about the `withCount` method here](https://laravel.com/docs/master/eloquent-relationships#counting-related-models). 68 | 69 | ```php 70 | // GET /users?include=postsCount,friendsCount 71 | 72 | $users = QueryBuilder::for(User::class) 73 | ->allowedIncludes([ 74 | 'posts', // allows including `posts` or `postsCount` or `postsExists` 75 | AllowedInclude::count('friendsCount'), // only allows include the number of `friends()` related models 76 | ]); 77 | // every user in $users will contain a `posts_count` and `friends_count` property 78 | ``` 79 | 80 | ## Including related model exists 81 | 82 | Every allowed include will automatically allow requesting its related model exists using a `Exists` suffix. On top of that it's also possible to specifically allow requesting and querying the related model exists (and not include the entire relationship). 83 | 84 | Under the hood this uses Laravel's `withExists method`. [Read more about the `withExists` method here](https://laravel.com/docs/master/eloquent-relationships#other-aggregate-functions). 85 | 86 | ```php 87 | // GET /users?include=postsExists,friendsExists 88 | 89 | $users = QueryBuilder::for(User::class) 90 | ->allowedIncludes([ 91 | 'posts', // allows including `posts` or `postsCount` or `postsExists` 92 | AllowedInclude::exists('friendsExists'), // only allows include the existence of `friends()` related models 93 | ]); 94 | // every user in $users will contain a `posts_exists` and `friends_exists` property 95 | ``` 96 | 97 | ## Include aliases 98 | 99 | It can be useful to specify an alias for an include to enable friendly relationship names. For example, your users table might have a `userProfile` relationship, which might be neater just specified as `profile`. Using aliases you can specify a new, shorter name for this include: 100 | 101 | ```php 102 | use Spatie\QueryBuilder\AllowedInclude; 103 | 104 | // GET /users?include=profile 105 | 106 | $users = QueryBuilder::for(User::class) 107 | ->allowedIncludes(AllowedInclude::relationship('profile', 'userProfile')) // will include the `userProfile` relationship 108 | ->get(); 109 | ``` 110 | 111 | ## Custom includes 112 | 113 | You can specify custom includes using the `AllowedInclude::custom()` method. Custom includes are instances of invokable classes that implement the `\Spatie\QueryBuilder\Includes\IncludeInterface` interface. The `__invoke` method will receive the current query builder instance and the include name. This way you can build any query your heart desires. 114 | 115 | For example: 116 | 117 | ```php 118 | use Spatie\QueryBuilder\Includes\IncludeInterface; 119 | use Illuminate\Database\Eloquent\Builder; 120 | use App\Models\Post; 121 | 122 | class AggregateInclude implements IncludeInterface 123 | { 124 | protected string $column; 125 | 126 | protected string $function; 127 | 128 | public function __construct(string $column, string $function) 129 | { 130 | $this->column = $column; 131 | 132 | $this->function = $function; 133 | } 134 | 135 | public function __invoke(Builder $query, string $relations) 136 | { 137 | $query->withAggregate($relations, $this->column, $this->function); 138 | } 139 | } 140 | 141 | // In your controller for the following request: 142 | // GET /posts?include=comments_sum_votes 143 | 144 | $posts = QueryBuilder::for(Post::class) 145 | ->allowedIncludes([ 146 | AllowedInclude::custom('comments_sum_votes', new AggregateInclude('votes', 'sum'), 'comments'), 147 | ]) 148 | ->get(); 149 | 150 | // every post in $posts will contain a `comments_sum_votes` property 151 | ``` 152 | 153 | ## Callback includes 154 | 155 | If you want to define a tiny custom include, you can use a callback include. Using `AllowedInclude::callback(string $name, Closure $callback, ?string $internalName = null)` you can specify a Closure that will be executed when the includes is requested. 156 | 157 | You can modify the `Builder` object to add your own query constraints. 158 | 159 | For example: 160 | 161 | ```php 162 | QueryBuilder::for(User::class) 163 | ->allowedIncludes([ 164 | AllowedInclude::callback('latest_post', function (Builder $query) { 165 | $query->latestOfMany(); 166 | }), 167 | ]); 168 | ``` 169 | 170 | ## Selecting included fields 171 | 172 | You can select only some fields to be included using the [`allowedFields` method on the query builder](https://spatie.be/docs/laravel-query-builder/v6/features/selecting-fields/). 173 | 174 | ⚠️ `allowedFields` must be called before `allowedIncludes`. Otherwise the query builder wont know what fields to include for the requested includes and an exception will be thrown. 175 | 176 | ## Include casing 177 | 178 | Relation/include names will be passed from request URL to the query directly. This means `/users?include=blog-posts` will try to load `blog-posts` relationship and `/users?include=blogPosts` will try to load the `blogPosts()` relationship. 179 | 180 | ## Eloquent API resources 181 | 182 | Once the relationships are included, we'd recommend including them in your response by using [Eloquent API resources and conditional relationships](https://laravel.com/docs/master/eloquent-resources#conditional-relationships). 183 | -------------------------------------------------------------------------------- /src/QueryBuilderRequest.php: -------------------------------------------------------------------------------- 1 | getRequestData($includeParameterName); 40 | 41 | if (is_string($includeParts)) { 42 | $includeParts = explode(static::getIncludesArrayValueDelimiter(), $includeParts); 43 | } 44 | 45 | return collect($includeParts)->filter(); 46 | } 47 | 48 | public function appends(): Collection 49 | { 50 | $appendParameterName = config('query-builder.parameters.append', 'append'); 51 | 52 | $appendParts = $this->getRequestData($appendParameterName); 53 | 54 | if (! is_array($appendParts) && ! is_null($appendParts)) { 55 | $appendParts = explode(static::getAppendsArrayValueDelimiter(), $appendParts); 56 | } 57 | 58 | return collect($appendParts)->filter(); 59 | } 60 | 61 | public function fields(): Collection 62 | { 63 | $fieldsParameterName = config('query-builder.parameters.fields', 'fields'); 64 | $fieldsData = $this->getRequestData($fieldsParameterName); 65 | 66 | $fieldsPerTable = collect(is_string($fieldsData) ? explode(static::getFieldsArrayValueDelimiter(), $fieldsData) : $fieldsData); 67 | 68 | if ($fieldsPerTable->isEmpty()) { 69 | return collect(); 70 | } 71 | 72 | $fields = []; 73 | 74 | $fieldsPerTable->each(function ($tableFields, $model) use (&$fields) { 75 | if (is_numeric($model)) { 76 | // If the field is in dot notation, we'll grab the table without the field. 77 | // If the field isn't in dot notation we want the base table. We'll use `_` and replace it later. 78 | $model = Str::contains($tableFields, '.') ? Str::beforeLast($tableFields, '.') : '_'; 79 | } 80 | 81 | if (! isset($fields[$model])) { 82 | $fields[$model] = []; 83 | } 84 | 85 | // If the field is in dot notation, we'll grab the field without the tables: 86 | $tableFields = array_map(function (string $field) { 87 | return Str::afterLast($field, '.'); 88 | }, explode(static::getFieldsArrayValueDelimiter(), $tableFields)); 89 | 90 | $fields[$model] = array_merge($fields[$model], $tableFields); 91 | }); 92 | 93 | return collect($fields); 94 | } 95 | 96 | public function sorts(): Collection 97 | { 98 | $sortParameterName = config('query-builder.parameters.sort', 'sort'); 99 | 100 | $sortParts = $this->getRequestData($sortParameterName); 101 | 102 | if (is_string($sortParts)) { 103 | $sortParts = explode(static::getSortsArrayValueDelimiter(), $sortParts); 104 | } 105 | 106 | return collect($sortParts)->filter(); 107 | } 108 | 109 | public function filters(): Collection 110 | { 111 | $filterParameterName = config('query-builder.parameters.filter', 'filter'); 112 | 113 | $filterParts = $this->getRequestData($filterParameterName, []); 114 | 115 | if (is_string($filterParts)) { 116 | return collect(); 117 | } 118 | 119 | $filters = collect($filterParts); 120 | 121 | return $filters->map(function ($value) { 122 | return $this->getFilterValue($value); 123 | }); 124 | } 125 | 126 | /** @return array|float|int|string|bool|null */ 127 | protected function getFilterValue(mixed $value): mixed 128 | { 129 | if (empty($value)) { 130 | return $value; 131 | } 132 | 133 | if (is_array($value)) { 134 | return collect($value)->map(function ($valueValue) { 135 | return $this->getFilterValue($valueValue); 136 | })->all(); 137 | } 138 | 139 | if (Str::contains($value, static::getFilterArrayValueDelimiter())) { 140 | return explode(static::getFilterArrayValueDelimiter(), $value); 141 | } 142 | 143 | if ($value === 'true') { 144 | return true; 145 | } 146 | 147 | if ($value === 'false') { 148 | return false; 149 | } 150 | 151 | return $value; 152 | } 153 | 154 | protected function getRequestData(?string $key = null, $default = null) 155 | { 156 | return $this->input($key, $default); 157 | } 158 | 159 | public static function setIncludesArrayValueDelimiter(string $includesArrayValueDelimiter): void 160 | { 161 | static::$includesArrayValueDelimiter = $includesArrayValueDelimiter; 162 | } 163 | 164 | public static function setAppendsArrayValueDelimiter(string $appendsArrayValueDelimiter): void 165 | { 166 | static::$appendsArrayValueDelimiter = $appendsArrayValueDelimiter; 167 | } 168 | 169 | public static function setFieldsArrayValueDelimiter(string $fieldsArrayValueDelimiter): void 170 | { 171 | static::$fieldsArrayValueDelimiter = $fieldsArrayValueDelimiter; 172 | } 173 | 174 | public static function setSortsArrayValueDelimiter(string $sortsArrayValueDelimiter): void 175 | { 176 | static::$sortsArrayValueDelimiter = $sortsArrayValueDelimiter; 177 | } 178 | 179 | public static function setFilterArrayValueDelimiter(string $filterArrayValueDelimiter): void 180 | { 181 | static::$filterArrayValueDelimiter = $filterArrayValueDelimiter; 182 | } 183 | 184 | public static function getIncludesArrayValueDelimiter(): string 185 | { 186 | return static::$includesArrayValueDelimiter; 187 | } 188 | 189 | public static function getAppendsArrayValueDelimiter(): string 190 | { 191 | return static::$appendsArrayValueDelimiter; 192 | } 193 | 194 | public static function getFieldsArrayValueDelimiter(): string 195 | { 196 | return static::$fieldsArrayValueDelimiter; 197 | } 198 | 199 | public static function getSortsArrayValueDelimiter(): string 200 | { 201 | return static::$sortsArrayValueDelimiter; 202 | } 203 | 204 | public static function getFilterArrayValueDelimiter(): string 205 | { 206 | return static::$filterArrayValueDelimiter; 207 | } 208 | 209 | public static function resetDelimiters(): void 210 | { 211 | self::$includesArrayValueDelimiter = ','; 212 | self::$appendsArrayValueDelimiter = ','; 213 | self::$fieldsArrayValueDelimiter = ','; 214 | self::$sortsArrayValueDelimiter = ','; 215 | self::$filterArrayValueDelimiter = ','; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /docs/features/filtering.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Filtering 3 | weight: 1 4 | --- 5 | 6 | The `filter` query parameters can be used to add `where` clauses to your Eloquent query. Out of the box we support filtering results by partial attribute value, exact attribute value or even if an attribute value exists in a given array of values. For anything more advanced, custom filters can be used. 7 | 8 | By default, all filters have to be explicitly allowed using `allowedFilters()`. This method takes an array of strings or `AllowedFilter` instances. An allowed filter can be partial, beginsWithStrict, endsWithStrict, exact, scope or custom. By default, any string values passed to `allowedFilters()` will automatically be converted to `AllowedFilter::partial()` filters. 9 | 10 | ## Basic usage 11 | 12 | ```php 13 | // GET /users?filter[name]=john&filter[email]=gmail 14 | 15 | $users = QueryBuilder::for(User::class) 16 | ->allowedFilters(['name', 'email']) 17 | ->get(); 18 | 19 | // $users will contain all users with "john" in their name AND "gmail" in their email address 20 | ``` 21 | 22 | You can specify multiple matching filter values by passing a comma separated list of values: 23 | 24 | ```php 25 | // GET /users?filter[name]=seb,freek 26 | 27 | $users = QueryBuilder::for(User::class) 28 | ->allowedFilters(['name']) 29 | ->get(); 30 | 31 | // $users will contain all users that contain "seb" OR "freek" in their name 32 | ``` 33 | 34 | By passing column name strings to `allowedFilters`, **partial** filters are automatically applied. 35 | 36 | ## Disallowed filters 37 | 38 | Finally, when trying to filter on properties that have not been allowed using `allowedFilters()` an `InvalidFilterQuery` exception will be thrown along with a list of allowed filters. 39 | 40 | 41 | ## Disable InvalidFilterQuery exception 42 | 43 | You can set in configuration file to not throw an InvalidFilterQuery exception when a filter is not set in allowedFilter method. This does **not** allow using any filter, it just disables the exception. 44 | 45 | ```php 46 | 'disable_invalid_filter_query_exception' => true 47 | ``` 48 | 49 | By default the option is set false. 50 | 51 | ## Partial, beginsWithStrict and endsWithStrict filters 52 | 53 | By default, all values passed to `allowedFilters` are converted to partial filters. The underlying query will be modified to use a `LIKE LOWER(%value%)` statement. Because this can cause missed indexes, it's often worth considering a `beginsWithStrict` filter for the beginning of the value, or an `endsWithStrict` filter for the end of the value. These filters will use a `LIKE value%` statement and a `LIKE %value` statement respectively, instead of the default partial filter. This can help optimize query performance and index utilization. 54 | 55 | ## Exact filters 56 | 57 | When filtering IDs, boolean values or a literal string, you'll want to use exact filters. This way `/users?filter[id]=1` won't match all users having the digit `1` in their ID. 58 | 59 | Exact filters can be added using `Spatie\QueryBuilder\AllowedFilter::exact('property_name')` in the `allowedFilters()` method. 60 | 61 | ```php 62 | use Spatie\QueryBuilder\AllowedFilter; 63 | 64 | // GET /users?filter[name]=John%20Doe 65 | $users = QueryBuilder::for(User::class) 66 | ->allowedFilters([AllowedFilter::exact('name')]) 67 | ->get(); 68 | 69 | // only users with the exact name "John Doe" 70 | ``` 71 | 72 | The query builder will automatically map `1`, `0`, `'true'`, and `'false'` as boolean values and a comma separated list of values as an array: 73 | 74 | ```php 75 | use Spatie\QueryBuilder\AllowedFilter; 76 | 77 | // GET /users?filter[id]=1,2,3,4,5&filter[admin]=true 78 | 79 | $users = QueryBuilder::for(User::class) 80 | ->allowedFilters([ 81 | AllowedFilter::exact('id'), 82 | AllowedFilter::exact('admin'), 83 | ]) 84 | ->get(); 85 | 86 | // $users will contain all admin users with id 1, 2, 3, 4 or 5 87 | ``` 88 | 89 | ## Operator filters 90 | 91 | 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. 92 | 93 | ```php 94 | use Spatie\QueryBuilder\AllowedFilter; 95 | use Spatie\QueryBuilder\Enums\FilterOperator; 96 | 97 | // GET /users?filter[salary]=3000 98 | $users = QueryBuilder::for(User::class) 99 | ->allowedFilters([ 100 | AllowedFilter::operator('salary', FilterOperator::GREATER_THAN), 101 | ]) 102 | ->get(); 103 | 104 | // $users will contain all users with a salary greater than 3000 105 | ``` 106 | 107 | You can also use dynamic operator filters, which allow you to specify the operator in the filter value: 108 | 109 | ```php 110 | use Spatie\QueryBuilder\AllowedFilter; 111 | use Spatie\QueryBuilder\Enums\FilterOperator; 112 | 113 | // GET /users?filter[salary]=>3000 114 | $users = QueryBuilder::for(User::class) 115 | ->allowedFilters([ 116 | AllowedFilter::operator('salary', FilterOperator::DYNAMIC), 117 | ]) 118 | ->get(); 119 | 120 | // $users will contain all users with a salary greater than 3000 121 | ``` 122 | 123 | ## Exact or partial filters for related properties 124 | 125 | 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. 126 | 127 | In some cases you'll want to disable this behaviour and just pass the raw filter-property value to the query. For example, when using a joined table's value for filtering. By passing `false` as the third parameter to `AllowedFilter::exact()` or `AllowedFilter::partial()` this behaviour can be disabled: 128 | 129 | ```php 130 | $addRelationConstraint = false; 131 | 132 | QueryBuilder::for(User::class) 133 | ->join('posts', 'posts.user_id', 'users.id') 134 | ->allowedFilters(AllowedFilter::exact('posts.title', null, $addRelationConstraint)); 135 | ``` 136 | 137 | ## BelongsTo filters 138 | 139 | In Model: 140 | ```php 141 | class Comment extends Model 142 | { 143 | public function post(): BelongsTo 144 | { 145 | return $this->belongsTo(Post::class); 146 | } 147 | } 148 | ``` 149 | 150 | ```php 151 | QueryBuilder::for(Comment::class) 152 | ->allowedFilters([ 153 | AllowedFilter::belongsTo('post'), 154 | ]) 155 | ->get(); 156 | ``` 157 | 158 | Alias 159 | ```php 160 | QueryBuilder::for(Comment::class) 161 | ->allowedFilters([ 162 | AllowedFilter::belongsTo('post_id', 'post'), 163 | ]) 164 | ->get(); 165 | ``` 166 | 167 | Nested 168 | ```php 169 | class Post extends Model 170 | { 171 | public function author(): BelongsTo 172 | { 173 | return $this->belongsTo(User::class); 174 | } 175 | } 176 | ``` 177 | 178 | ```php 179 | QueryBuilder::for(Comment::class) 180 | ->allowedFilters([ 181 | AllowedFilter::belongsTo('author_post_id', 'post.author'), 182 | ]) 183 | ->get(); 184 | ``` 185 | 186 | ## Scope filters 187 | 188 | Sometimes more advanced filtering options are necessary. This is where scope filters, callback filters and custom filters come in handy. 189 | 190 | Scope filters allow you to add [local scopes](https://laravel.com/docs/master/eloquent#local-scopes) to your query by adding filters to the URL. This works for scopes on the queried model or its relationships using dot-notation. 191 | 192 | Consider the following scope on your model: 193 | 194 | ```php 195 | public function scopeStartsBefore(Builder $query, $date): Builder 196 | { 197 | return $query->where('starts_at', '<=', Carbon::parse($date)); 198 | } 199 | ``` 200 | 201 | To filter based on the `startsBefore` scope, add it to the `allowedFilters` array on the query builder: 202 | 203 | ```php 204 | QueryBuilder::for(Event::class) 205 | ->allowedFilters([ 206 | AllowedFilter::scope('starts_before'), 207 | ]) 208 | ->get(); 209 | ``` 210 | 211 | The following filter will now add the `startsBefore` scope to the underlying query: 212 | 213 | ``` 214 | GET /events?filter[starts_before]=2018-01-01 215 | ``` 216 | 217 | You can even pass multiple parameters to the scope by passing a comma separated list to the filter and use dot-notation for querying scopes on a relationship: 218 | 219 | ``` 220 | GET /events?filter[schedule.starts_between]=2018-01-01,2018-12-31 221 | ``` 222 | 223 | When passing an array as a parameter you can access it, as an array, in the scope by using the spread operator. 224 | ```php 225 | public function scopeInvitedUsers(Builder $query, ...$users): Builder 226 | { 227 | return $query->whereIn('id', $users); 228 | } 229 | ``` 230 | 231 | 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: 232 | 233 | ```php 234 | public function scopeEvent(Builder $query, \App\Models\Event $event): Builder 235 | { 236 | return $query->where('id', $event->id); 237 | } 238 | 239 | // GET /events?filter[event]=1 - the event with ID 1 will automatically be resolved and passed to the scoped filter 240 | ``` 241 | 242 | 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 243 | 244 | ```php 245 | // GET /events?filter[event]=01j0rcpkx5517v0aqyez5vnwn - supposing we use a ULID column for route model binding. 246 | ``` 247 | 248 | Scopes are usually not named with query filters in mind. Use [filter aliases](#filter-aliases) to alias them to something more appropriate: 249 | 250 | ```php 251 | QueryBuilder::for(User::class) 252 | ->allowedFilters([ 253 | AllowedFilter::scope('unconfirmed', 'whereHasUnconfirmedEmail'), 254 | // `?filter[unconfirmed]=1` will now add the `scopeWhereHasUnconfirmedEmail` to your query 255 | ]); 256 | ``` 257 | 258 | ## Trashed filters 259 | 260 | When using Laravel's [soft delete feature](https://laravel.com/docs/master/eloquent#querying-soft-deleted-models) you can use the `AllowedFilter::trashed()` filter to query these models. 261 | 262 | The `FiltersTrashed` filter responds to particular values: 263 | 264 | - `with`: include soft-deleted records to the result set 265 | - `only`: return only 'trashed' records at the result set 266 | - any other value: return only records without that are not soft-deleted in the result set 267 | 268 | For example: 269 | 270 | ```php 271 | QueryBuilder::for(Booking::class) 272 | ->allowedFilters([ 273 | AllowedFilter::trashed(), 274 | ]); 275 | 276 | // GET /bookings?filter[trashed]=only will only return soft deleted models 277 | ``` 278 | 279 | ## Callback filters 280 | 281 | If you want to define a tiny custom filter, you can use a callback filter. Using `AllowedFilter::callback(string $name, callable $filter)` you can specify a callable that will be executed when the filter is requested. 282 | 283 | The filter callback will receive the following parameters: `Builder $query, mixed $value, string $name`. You can modify the `Builder` object to add your own query constraints. 284 | 285 | For example: 286 | 287 | ```php 288 | QueryBuilder::for(User::class) 289 | ->allowedFilters([ 290 | AllowedFilter::callback('has_posts', function (Builder $query, $value) { 291 | $query->whereHas('posts'); 292 | }), 293 | ]); 294 | ``` 295 | 296 | Using PHP 7.4 this example becomes a lot shorter: 297 | 298 | ```php 299 | QueryBuilder::for(User::class) 300 | ->allowedFilters([ 301 | AllowedFilter::callback('has_posts', fn (Builder $query) => $query->whereHas('posts')), 302 | ]); 303 | ``` 304 | 305 | ## Custom filters 306 | 307 | You can specify custom filters using the `AllowedFilter::custom()` method. Custom filters are instances of invokable classes that implement the `\Spatie\QueryBuilder\Filters\Filter` interface. The `__invoke` method will receive the current query builder instance and the filter name/value. This way you can build any query your heart desires. 308 | 309 | For example: 310 | 311 | ```php 312 | use Spatie\QueryBuilder\Filters\Filter; 313 | use Illuminate\Database\Eloquent\Builder; 314 | 315 | class FiltersUserPermission implements Filter 316 | { 317 | public function __invoke(Builder $query, $value, string $property) 318 | { 319 | $query->whereHas('permissions', function (Builder $query) use ($value) { 320 | $query->where('name', $value); 321 | }); 322 | } 323 | } 324 | 325 | // In your controller for the following request: 326 | // GET /users?filter[permission]=createPosts 327 | 328 | $users = QueryBuilder::for(User::class) 329 | ->allowedFilters([ 330 | AllowedFilter::custom('permission', new FiltersUserPermission), 331 | ]) 332 | ->get(); 333 | 334 | // $users will contain all users that have the `createPosts` permission 335 | ``` 336 | 337 | ## Filter aliases 338 | 339 | It can be useful to specify an alias for a filter to avoid exposing database column names. For example, your users table might have a `user_passport_full_name` column, which is a horrible name for a filter. Using aliases you can specify a new, shorter name for this filter: 340 | 341 | ```php 342 | use Spatie\QueryBuilder\AllowedFilter; 343 | 344 | // GET /users?filter[name]=John 345 | 346 | $users = QueryBuilder::for(User::class) 347 | ->allowedFilters(AllowedFilter::exact('name', 'user_passport_full_name')) // will filter by the `user_passport_full_name` column 348 | ->get(); 349 | ``` 350 | 351 | ## Ignored filters values 352 | 353 | You can specify a set of ignored values for every filter. This allows you to not apply a filter when these values are submitted. 354 | 355 | ```php 356 | QueryBuilder::for(User::class) 357 | ->allowedFilters([ 358 | AllowedFilter::exact('name')->ignore(null), 359 | ]) 360 | ->get(); 361 | ``` 362 | 363 | The `ignore()` method takes one or more values, where each may be an array of ignored values. Each of the following calls are valid: 364 | 365 | * `ignore('should_be_ignored')` 366 | * `ignore(null, '-1')` 367 | * `ignore([null, 'ignore_me', 'also_ignored'])` 368 | 369 | Given an array of values to filter for, only the subset of non-ignored values get passed to the filter. If all values are ignored, the filter does not get applied. 370 | 371 | ```php 372 | // GET /user?filter[name]=forbidden,John%20Doe 373 | 374 | QueryBuilder::for(User::class) 375 | ->allowedFilters([ 376 | AllowedFilter::exact('name')->ignore('forbidden'), 377 | ]) 378 | ->get(); 379 | // Returns only users where name matches 'John Doe' 380 | 381 | // GET /user?filter[name]=ignored,ignored_too 382 | 383 | QueryBuilder::for(User::class) 384 | ->allowedFilters([ 385 | AllowedFilter::exact('name')->ignore(['ignored', 'ignored_too']), 386 | ]) 387 | ->get(); 388 | // Filter does not get applied because all requested values are ignored. 389 | ``` 390 | 391 | ## Default Filter Values 392 | 393 | You can specify a default value for a filter if a value for the filter was not present on the request. This is especially useful for boolean filters. 394 | 395 | ```php 396 | QueryBuilder::for(User::class) 397 | ->allowedFilters([ 398 | AllowedFilter::exact('name')->default('Joe'), 399 | AllowedFilter::scope('deleted')->default(false), 400 | AllowedFilter::scope('permission')->default(null), 401 | ]) 402 | ->get(); 403 | ``` 404 | 405 | ## Nullable Filter 406 | 407 | You can mark a filter nullable if you want to retrieve entries whose filtered value is null. This way you can apply the filter with an empty value, as shown in the example. 408 | 409 | ```php 410 | // GET /user?filter[name]=&filter[permission]= 411 | 412 | QueryBuilder::for(User::class) 413 | ->allowedFilters([ 414 | AllowedFilter::exact('name')->nullable(), 415 | AllowedFilter::scope('permission')->nullable(), 416 | ]) 417 | ->get(); 418 | ``` 419 | 420 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-query-builder` will be documented in this file 4 | 5 | ## 6.3.6 - 2025-10-20 6 | 7 | ### What's Changed 8 | 9 | * Update reference to Laravel Pagination docs by @timmch in https://github.com/spatie/laravel-query-builder/pull/1015 10 | * Update documentation in the config by @alexkart in https://github.com/spatie/laravel-query-builder/pull/1017 11 | * Bump actions/checkout from 4 to 5 by @dependabot[bot] in https://github.com/spatie/laravel-query-builder/pull/1016 12 | * Update issue template by @AlexVanderbist in https://github.com/spatie/laravel-query-builder/pull/1018 13 | * Test against php 8.5 by @sergiy-petrov in https://github.com/spatie/laravel-query-builder/pull/1020 14 | * Fix using convert_relation_table_name_strategy of "none" by @wrurik in https://github.com/spatie/laravel-query-builder/pull/1023 15 | * Bump stefanzweifel/git-auto-commit-action from 6 to 7 by @dependabot[bot] in https://github.com/spatie/laravel-query-builder/pull/1022 16 | 17 | ### New Contributors 18 | 19 | * @timmch made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1015 20 | * @wrurik made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1023 21 | 22 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.5...6.3.6 23 | 24 | ## 6.3.5 - 2025-08-04 25 | 26 | ### What's Changed 27 | 28 | * Make nullable parameter types explicit to avoid deprecation warnings by @zigzagdev in https://github.com/spatie/laravel-query-builder/pull/1013 29 | 30 | ### New Contributors 31 | 32 | * @zigzagdev made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1013 33 | 34 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.4...6.3.5 35 | 36 | ## 6.3.4 - 2025-07-25 37 | 38 | ### What's Changed 39 | 40 | * Improve QueryBuilder Generic Support by @liamduckett in https://github.com/spatie/laravel-query-builder/pull/1012 41 | 42 | ### New Contributors 43 | 44 | * @liamduckett made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1012 45 | 46 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.3...6.3.4 47 | 48 | ## 6.3.3 - 2025-07-14 49 | 50 | ### What's Changed 51 | 52 | * update some links so point to v6 docs by @KnudH in https://github.com/spatie/laravel-query-builder/pull/1004 53 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot[bot] in https://github.com/spatie/laravel-query-builder/pull/1006 54 | * Docs: clarify selecting fields for included relations by @amyavari in https://github.com/spatie/laravel-query-builder/pull/1008 55 | * Add larastan and fix static analysis issue by @alexkart in https://github.com/spatie/laravel-query-builder/pull/1003 56 | * Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot[bot] in https://github.com/spatie/laravel-query-builder/pull/1009 57 | * Allow nested filters by @bambamboole in https://github.com/spatie/laravel-query-builder/pull/1010 58 | 59 | ### New Contributors 60 | 61 | * @KnudH made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1004 62 | * @amyavari made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1008 63 | * @bambamboole made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1010 64 | 65 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.2...6.3.3 66 | 67 | ## 6.3.2 - 2025-04-16 68 | 69 | ### What's Changed 70 | 71 | * Enhance QueryBuilder with generics support for better type inference by @alexkart in https://github.com/spatie/laravel-query-builder/pull/1002 72 | 73 | ### New Contributors 74 | 75 | * @alexkart made their first contribution in https://github.com/spatie/laravel-query-builder/pull/1002 76 | 77 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.1...6.3.2 78 | 79 | ## 6.3.1 - 2025-02-21 80 | 81 | ### What's Changed 82 | 83 | * General code health improvements by @xHeaven in https://github.com/spatie/laravel-query-builder/pull/988 84 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/992 85 | * Laravel 12.x Compatibility by @laravel-shift in https://github.com/spatie/laravel-query-builder/pull/994 86 | * Exclude `.github` folder and `.php_cs` from being included in composer installation by @stevebauman in https://github.com/spatie/laravel-query-builder/pull/993 87 | 88 | ### New Contributors 89 | 90 | * @xHeaven made their first contribution in https://github.com/spatie/laravel-query-builder/pull/988 91 | 92 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.3.0...6.3.1 93 | 94 | ## 6.3.0 - 2024-12-23 95 | 96 | ### What's Changed 97 | 98 | * Feature: Add "belongs to" filter by @gpibarra in https://github.com/spatie/laravel-query-builder/pull/975 99 | * Feature: Additional config options to better match the API spec by @CoolGoose in https://github.com/spatie/laravel-query-builder/pull/983 100 | 101 | ### New Contributors 102 | 103 | * @CoolGoose made their first contribution in https://github.com/spatie/laravel-query-builder/pull/983 104 | 105 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.3...6.3.0 106 | 107 | ## 6.2.3 - 2024-12-23 108 | 109 | ### What's Changed 110 | 111 | * Fix selecting fields on belongs to many relations by @rasmuscnielsen in https://github.com/spatie/laravel-query-builder/pull/986 112 | 113 | ### New Contributors 114 | 115 | * @rasmuscnielsen made their first contribution in https://github.com/spatie/laravel-query-builder/pull/986 116 | 117 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.2...6.2.3 118 | 119 | ## 6.2.2 - 2024-12-11 120 | 121 | ### What's Changed 122 | 123 | * Update filtering.md to clarify handling of array scope parameter by @g-gullstrand in https://github.com/spatie/laravel-query-builder/pull/976 124 | * Remove PHPUnit cache by @tarexme in https://github.com/spatie/laravel-query-builder/pull/982 125 | * Fix typo in filtering.md by @yngc0der in https://github.com/spatie/laravel-query-builder/pull/984 126 | * Fixed IncludedCount.php by @dash8x in https://github.com/spatie/laravel-query-builder/pull/978 127 | 128 | ### New Contributors 129 | 130 | * @g-gullstrand made their first contribution in https://github.com/spatie/laravel-query-builder/pull/976 131 | * @tarexme made their first contribution in https://github.com/spatie/laravel-query-builder/pull/982 132 | * @yngc0der made their first contribution in https://github.com/spatie/laravel-query-builder/pull/984 133 | * @dash8x made their first contribution in https://github.com/spatie/laravel-query-builder/pull/978 134 | 135 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.1...6.2.2 136 | 137 | ## 6.2.1 - 2024-10-03 138 | 139 | ### What's Changed 140 | 141 | * Removed explicit escaping for `pgsql` driver in `FiltersPartial` - Fixes #941 by @Talpx1 in https://github.com/spatie/laravel-query-builder/pull/968 142 | 143 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.2.0...6.2.1 144 | 145 | ## 6.2.0 - 2024-09-27 146 | 147 | ### What's Changed 148 | 149 | * [FEAT] add filter by operator by @AbdelrahmanBl in https://github.com/spatie/laravel-query-builder/pull/940 150 | * Add documentation for the operator filter by @AlexVanderbist in https://github.com/spatie/laravel-query-builder/pull/974 151 | 152 | ### New Contributors 153 | 154 | * @AbdelrahmanBl made their first contribution in https://github.com/spatie/laravel-query-builder/pull/940 155 | 156 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.1.0...6.2.0 157 | 158 | ## 6.1.0 - 2024-09-23 159 | 160 | ### What's Changed 161 | 162 | * Bump ramsey/composer-install from 2 to 3 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/939 163 | * Add issue #175 link in selecting-fields.md by @alipadron in https://github.com/spatie/laravel-query-builder/pull/951 164 | * Update filtering.md by @justinkekeocha in https://github.com/spatie/laravel-query-builder/pull/954 165 | * Bump dependabot/fetch-metadata from 2.1.0 to 2.2.0 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/955 166 | * [DOCS] Update Frontend implementation with a new one by @cgarciagarcia in https://github.com/spatie/laravel-query-builder/pull/961 167 | * Update Documentation for php markdown by @chengkangzai in https://github.com/spatie/laravel-query-builder/pull/969 168 | * AllowedFilter should return static rather than self by @kosarinin in https://github.com/spatie/laravel-query-builder/pull/964 169 | 170 | ### New Contributors 171 | 172 | * @alipadron made their first contribution in https://github.com/spatie/laravel-query-builder/pull/951 173 | * @cgarciagarcia made their first contribution in https://github.com/spatie/laravel-query-builder/pull/961 174 | * @chengkangzai made their first contribution in https://github.com/spatie/laravel-query-builder/pull/969 175 | * @kosarinin made their first contribution in https://github.com/spatie/laravel-query-builder/pull/964 176 | 177 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.0.1...6.1.0 178 | 179 | ## 6.0.1 - 2024-05-21 180 | 181 | ### What's Changed 182 | 183 | * Fix ability to filter models by an array as filter value by @inmula in https://github.com/spatie/laravel-query-builder/pull/943 184 | 185 | ### New Contributors 186 | 187 | * @inmula made their first contribution in https://github.com/spatie/laravel-query-builder/pull/943 188 | 189 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/6.0.0...6.0.1 190 | 191 | ## 6.0.0 - 2024-05-10 192 | 193 | ### What's Changed 194 | 195 | * Add additional types & Phpstan by @Nielsvanpach in https://github.com/spatie/laravel-query-builder/pull/910 196 | 197 | ### New Contributors 198 | 199 | * @Nielsvanpach made their first contribution in https://github.com/spatie/laravel-query-builder/pull/910 200 | 201 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.8.1...6.0.0 202 | 203 | ## 5.8.1 - 2024-05-10 204 | 205 | ### What's Changed 206 | 207 | * Fix typo by @justinkekeocha in https://github.com/spatie/laravel-query-builder/pull/926 208 | * List query-builder-ts front-end implementation package by @rogervila in https://github.com/spatie/laravel-query-builder/pull/925 209 | * Fix incorrect escape character in SQL for LIKE query in partial filter by @Talpx1 in https://github.com/spatie/laravel-query-builder/pull/927 210 | 211 | ### New Contributors 212 | 213 | * @justinkekeocha made their first contribution in https://github.com/spatie/laravel-query-builder/pull/926 214 | * @rogervila made their first contribution in https://github.com/spatie/laravel-query-builder/pull/925 215 | * @Talpx1 made their first contribution in https://github.com/spatie/laravel-query-builder/pull/927 216 | 217 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.8.0...5.8.1 218 | 219 | ## 5.8.0 - 2024-02-06 220 | 221 | ### What's Changed 222 | 223 | * [Docs] Update config file content by @shdehnavi in https://github.com/spatie/laravel-query-builder/pull/918 224 | * Bump: Deprecating Laravel 9 and PHP 8.1, adding Laravel 11 support by @JustSteveKing in https://github.com/spatie/laravel-query-builder/pull/922 225 | 226 | ### New Contributors 227 | 228 | * @shdehnavi made their first contribution in https://github.com/spatie/laravel-query-builder/pull/918 229 | * @JustSteveKing made their first contribution in https://github.com/spatie/laravel-query-builder/pull/922 230 | 231 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.7.0...5.8.0 232 | 233 | ## 5.7.0 - 2024-01-08 234 | 235 | ### What's Changed 236 | 237 | * Start testing against PHP 8.3 by @sergiy-petrov in https://github.com/spatie/laravel-query-builder/pull/899 238 | * Add the possibility to use the literal relation names in the `allowedFields`. by @carvemerson in https://github.com/spatie/laravel-query-builder/pull/917 239 | * Add `unsetDefault` as a replacement for `default(null)` which was removed in 5.6.0 by @patrickrobrecht in https://github.com/spatie/laravel-query-builder/pull/902 240 | * Allow passing an array to the `defaultSort` function as documented by @MajidMohammadian in https://github.com/spatie/laravel-query-builder/pull/904 241 | * Add `disable_invalid_includes_query_exception` config option by @dimzeta in https://github.com/spatie/laravel-query-builder/pull/906 242 | * Update `AllowedFilter.php` to include `getFilterClass` function by @justasSendrauskas in https://github.com/spatie/laravel-query-builder/pull/909 243 | 244 | ### New Contributors 245 | 246 | * @sergiy-petrov made their first contribution in https://github.com/spatie/laravel-query-builder/pull/899 247 | * @carvemerson made their first contribution in https://github.com/spatie/laravel-query-builder/pull/917 248 | * @patrickrobrecht made their first contribution in https://github.com/spatie/laravel-query-builder/pull/902 249 | * @MajidMohammadian made their first contribution in https://github.com/spatie/laravel-query-builder/pull/904 250 | * @dimzeta made their first contribution in https://github.com/spatie/laravel-query-builder/pull/906 251 | * @justasSendrauskas made their first contribution in https://github.com/spatie/laravel-query-builder/pull/909 252 | 253 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.6.0...5.7.0 254 | 255 | ## 5.6.0 - 2023-10-05 256 | 257 | ### What's Changed 258 | 259 | - Add support for defining includes by callback by @enricodelazzari in https://github.com/spatie/laravel-query-builder/pull/894 260 | - Add nullable filters by @enricodelazzari in https://github.com/spatie/laravel-query-builder/pull/895 261 | - Fix escaping control characters in partial filters by @GrahamCampbell in https://github.com/spatie/laravel-query-builder/pull/898 262 | 263 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.5.0...5.6.0 264 | 265 | ## 5.5.0 - 2023-09-12 266 | 267 | ### What's Changed 268 | 269 | - Add support for [`withExists`](https://laravel.com/docs/master/eloquent-relationships#other-aggregate-functions) via `IncludedExists` by @enricodelazzari in https://github.com/spatie/laravel-query-builder/pull/891 270 | - Use default values for all config keys (avoids issues when `QueryBuilder` is used as a dependency in a package) 271 | 272 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.4.0...5.5.0 273 | 274 | ## 5.4.0 - 2023-09-08 275 | 276 | ### What's Changed 277 | 278 | - Deprecate `request_data_source` config. The `QueryBuilder` will always look at both the query string and the request body when available now 279 | - Fix having `null` as the query parameter name for filters (see #889) 280 | - Bump actions/checkout from 3 to 4 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/890 281 | 282 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.3.0...5.4.0 283 | 284 | ## 5.3.0 - 2023-08-21 285 | 286 | ### What's Changed 287 | 288 | - Accepts string value for the `fields` query parameter by @ezra-obiwale in https://github.com/spatie/laravel-query-builder/pull/872 289 | - Add `FiltersEndsWithStrict` filter by @utsavsomaiya in https://github.com/spatie/laravel-query-builder/pull/885 290 | - Make sure the `allowedSorts` are always set (even when none are requested) @luilliarcec in https://github.com/spatie/laravel-query-builder/pull/865 291 | 292 | ### New Contributors 293 | 294 | - @ezra-obiwale made their first contribution in https://github.com/spatie/laravel-query-builder/pull/872 295 | - @utsavsomaiya made their first contribution in https://github.com/spatie/laravel-query-builder/pull/885 296 | 297 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.2.0...5.3.0 298 | 299 | ## 5.2.0 - 2023-02-24 300 | 301 | ### What's Changed 302 | 303 | - Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/843 304 | - Update sorting.md by @shukriYusof in https://github.com/spatie/laravel-query-builder/pull/846 305 | - Update custom sorts link in documentation by @turpoint in https://github.com/spatie/laravel-query-builder/pull/844 306 | - Add config to disable InvalidSortQuery exception by @bohemima in https://github.com/spatie/laravel-query-builder/pull/830 307 | 308 | ### New Contributors 309 | 310 | - @shukriYusof made their first contribution in https://github.com/spatie/laravel-query-builder/pull/846 311 | - @turpoint made their first contribution in https://github.com/spatie/laravel-query-builder/pull/844 312 | - @bohemima made their first contribution in https://github.com/spatie/laravel-query-builder/pull/830 313 | 314 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.1.2...5.2.0 315 | 316 | ## 5.1.2 - 2023-01-24 317 | 318 | ### What's Changed 319 | 320 | - Update including-relationships.md by @designvoid in https://github.com/spatie/laravel-query-builder/pull/837 321 | - Fix workflow badges in README by @nelson6e65 in https://github.com/spatie/laravel-query-builder/pull/841 322 | - Laravel 10.x Compatibility by @laravel-shift in https://github.com/spatie/laravel-query-builder/pull/842 323 | 324 | ### New Contributors 325 | 326 | - @designvoid made their first contribution in https://github.com/spatie/laravel-query-builder/pull/837 327 | - @nelson6e65 made their first contribution in https://github.com/spatie/laravel-query-builder/pull/841 328 | - @laravel-shift made their first contribution in https://github.com/spatie/laravel-query-builder/pull/842 329 | 330 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.1.1...5.1.2 331 | 332 | ## 5.1.1 - 2022-12-02 333 | 334 | ### What's Changed 335 | 336 | - Fix `array_diff_assoc` BC break in v5.1.0 by @stevebauman in https://github.com/spatie/laravel-query-builder/pull/827 337 | 338 | ### New Contributors 339 | 340 | - @stevebauman made their first contribution in https://github.com/spatie/laravel-query-builder/pull/827 341 | 342 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.1.0...5.1.1 343 | 344 | ## 4.0.4 - 2022-11-28 345 | 346 | ### What's Changed 347 | 348 | - bugfix: appending to `pluck`ed values (that are not a `Model`) is not possible 349 | - Add version number to installation command in V4 by @jamesbhatta in https://github.com/spatie/laravel-query-builder/pull/786 350 | 351 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/4.0.3...4.0.4 352 | 353 | ## 5.1.0 - 2022-11-28 354 | 355 | ### What's Changed 356 | 357 | - Feature: Update Generics in IncludeInterface by @sidigi in https://github.com/spatie/laravel-query-builder/pull/810 358 | - Feature: Add PHP 8.2 Support by @patinthehat in https://github.com/spatie/laravel-query-builder/pull/825 359 | - Feature: Add `beginsWithStrict` filter by @danilopinotti in https://github.com/spatie/laravel-query-builder/pull/821 360 | - Bugfix: ignore allowed filters by @davidjr82 in https://github.com/spatie/laravel-query-builder/pull/818 361 | - Bugfix: Change self to static when creating query builder by @olliescase in https://github.com/spatie/laravel-query-builder/pull/819 362 | - Docs: Update filtering.md by @Dion213 in https://github.com/spatie/laravel-query-builder/pull/801 363 | - Misc: Add Dependabot Automation by @patinthehat in https://github.com/spatie/laravel-query-builder/pull/823 364 | - Misc: Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/spatie/laravel-query-builder/pull/824 365 | 366 | ### New Contributors 367 | 368 | - @Dion213 made their first contribution in https://github.com/spatie/laravel-query-builder/pull/801 369 | - @sidigi made their first contribution in https://github.com/spatie/laravel-query-builder/pull/810 370 | - @patinthehat made their first contribution in https://github.com/spatie/laravel-query-builder/pull/823 371 | - @dependabot made their first contribution in https://github.com/spatie/laravel-query-builder/pull/824 372 | - @davidjr82 made their first contribution in https://github.com/spatie/laravel-query-builder/pull/818 373 | - @olliescase made their first contribution in https://github.com/spatie/laravel-query-builder/pull/819 374 | - @danilopinotti made their first contribution in https://github.com/spatie/laravel-query-builder/pull/821 375 | 376 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.0.3...5.1.0 377 | 378 | ## 5.0.3 - 2022-07-29 379 | 380 | ### What's Changed 381 | 382 | - Fixed: Some links in the documentation for v5 pointing to v2 pages by @hameedraha in https://github.com/spatie/laravel-query-builder/pull/757 383 | - static return type when returning $this by @lorenzolosa in https://github.com/spatie/laravel-query-builder/pull/775 384 | - [PHP 8.2] Fix `${var}` string interpolation deprecation by @Ayesh in https://github.com/spatie/laravel-query-builder/pull/779 385 | - Fix grammar by @clouder in https://github.com/spatie/laravel-query-builder/pull/784 386 | - Add Inertia.js Tables for Laravel Query Builder by @fabianpnke in https://github.com/spatie/laravel-query-builder/pull/790 387 | - Fix Laravel 9 PHPStan generic check for `__invoke()` method of Filter by @kayw-geek in https://github.com/spatie/laravel-query-builder/pull/781 388 | - Fix for Warning by @shaunluedeke in https://github.com/spatie/laravel-query-builder/pull/791 389 | 390 | ### New Contributors 391 | 392 | - @hameedraha made their first contribution in https://github.com/spatie/laravel-query-builder/pull/757 393 | - @lorenzolosa made their first contribution in https://github.com/spatie/laravel-query-builder/pull/775 394 | - @Ayesh made their first contribution in https://github.com/spatie/laravel-query-builder/pull/779 395 | - @clouder made their first contribution in https://github.com/spatie/laravel-query-builder/pull/784 396 | - @fabianpnke made their first contribution in https://github.com/spatie/laravel-query-builder/pull/790 397 | - @shaunluedeke made their first contribution in https://github.com/spatie/laravel-query-builder/pull/791 398 | 399 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.0.2...5.0.3 400 | 401 | ## 5.0.2 - 2022-04-25 402 | 403 | ## What's Changed 404 | 405 | - Fix Laravel 9 PHPStan generic check by @kayw-geek in https://github.com/spatie/laravel-query-builder/pull/749 406 | 407 | ## New Contributors 408 | 409 | - @kayw-geek made their first contribution in https://github.com/spatie/laravel-query-builder/pull/749 410 | 411 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.0.1...5.0.2 412 | 413 | ## 4.0.3 - 2022-03-23 414 | 415 | ## What's Changed 416 | 417 | - V4 - Add support for laravel > 7.30.4 by @luilliarcec in https://github.com/spatie/laravel-query-builder/pull/744 418 | 419 | ## New Contributors 420 | 421 | - @luilliarcec made their first contribution in https://github.com/spatie/laravel-query-builder/pull/744 422 | 423 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/4.0.2...4.0.3 424 | 425 | ## 5.0.1 - 2022-03-18 426 | 427 | ## What's Changed 428 | 429 | - Update README.md by @wayz9 in https://github.com/spatie/laravel-query-builder/pull/713 430 | - Fix release order in CHANGELOG by @medvinator in https://github.com/spatie/laravel-query-builder/pull/717 431 | - Fix include casing docs by @canvural in https://github.com/spatie/laravel-query-builder/pull/733 432 | - Adapt documentation for publishing package config by @dominikb in https://github.com/spatie/laravel-query-builder/pull/734 433 | - Fix warning from passing null to explode for includeParts by @steven-fox in https://github.com/spatie/laravel-query-builder/pull/742 434 | 435 | ## New Contributors 436 | 437 | - @wayz9 made their first contribution in https://github.com/spatie/laravel-query-builder/pull/713 438 | - @medvinator made their first contribution in https://github.com/spatie/laravel-query-builder/pull/717 439 | - @canvural made their first contribution in https://github.com/spatie/laravel-query-builder/pull/733 440 | - @steven-fox made their first contribution in https://github.com/spatie/laravel-query-builder/pull/742 441 | 442 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/5.0.0...5.0.1 443 | 444 | ## 5.0.0 - 2022-01-13 445 | 446 | - add support for Laravel 9 447 | - drop support for older versions 448 | 449 | ## 4.0.2 - 2021-12-26 450 | 451 | ## What's Changed 452 | 453 | - DOC: New sample with multiple default sorts by @williamxsp in https://github.com/spatie/laravel-query-builder/pull/694 454 | - PHP 8.1 Support by @Medalink in https://github.com/spatie/laravel-query-builder/pull/702 455 | 456 | ## New Contributors 457 | 458 | - @williamxsp made their first contribution in https://github.com/spatie/laravel-query-builder/pull/694 459 | - @Medalink made their first contribution in https://github.com/spatie/laravel-query-builder/pull/702 460 | 461 | **Full Changelog**: https://github.com/spatie/laravel-query-builder/compare/4.0.1...4.0.2 462 | 463 | ## 4.0.1 - 2021-10-27 464 | 465 | - revert deferred service provider (#677) 466 | 467 | ## 4.0.0 - 2021-10-20 468 | 469 | - nested filters will no longer be automatically camel-cased to match a relationship name 470 | - includes will no longer be automatically camel-cased to match a relationship name 471 | - fields will no longer be automatically snake-cased to match table or column names 472 | - switch to deferred service provider 473 | 474 | Take a look at the [upgrade guide](./UPGRADING.md) for a more detailed explanation. 475 | 476 | ## 3.6.0 - 2021-09-06 477 | 478 | - add callback sorts (#654) 479 | 480 | ## 3.5.0 - 2021-07-05 481 | 482 | - add support for cursor pagination 483 | 484 | ## 3.4.3 - 2021-07-05 485 | 486 | - fix unexpected lowercase appends (#637) 487 | 488 | ## 3.4.2 - 2021-07-05 489 | 490 | - no changes 491 | 492 | ## 3.4.1 - 2021-05-24 493 | 494 | - fix simple paginator append not working (#633) 495 | 496 | ## 3.4.0 - 2021-05-20 497 | 498 | - add support for custom includes (#623) 499 | - add support for getting request data from the request body (#589) 500 | - fix issues when cloning `QueryBuilder` (#621) 501 | 502 | ## 3.3.4 - 2020-11-26 503 | 504 | - prepend table name to `WHERE` clause for ambiguous partial filters (#567) 505 | - add PHP 8 support 506 | 507 | ## 3.3.3 - 2020-10-27 508 | 509 | - prepend table name to `WHERE` clause for ambiguous exact filters (#467) 510 | 511 | ## 3.3.2 - 2020-10-27 512 | 513 | - fix config key to disable `InvalidFilterQuery` exception 514 | 515 | ## 3.3.1 - 2020-10-11 516 | 517 | - make nested scope compatible with older Laravel (#542) 518 | 519 | ## 3.3.0 - 2020-10-05 520 | 521 | - add ability to filter by nested relationship scopes (#519) 522 | - add config key to disable `InvalidFilterQuery` exception (#525) 523 | 524 | ## 3.2.4 - 2020-10-01 525 | 526 | - update what defines an ignored filter value (#533) 527 | 528 | ## 3.2.3 - 2020-09-30 529 | 530 | - add LengthAwarePaginator to QueryBuilder (#532) 531 | 532 | ## 3.2.2 - 2020-09-09 533 | 534 | - Revert changes from v3.2.1 to `AllowedFilter::filter()` 535 | 536 | ## 3.2.1 - 2020-09-09 537 | 538 | - Fix filtering associative arrays (#488) 539 | - AllowedFilter::filter() takes a `Illuminate\Database\Eloquent\Builder` instead of a QueryBuilder instance 540 | 541 | ## 3.2.0 - 2020-09-08 542 | 543 | - add support for Laravel 8 544 | 545 | ## 3.1.0 - 2020-08-18 546 | 547 | - add individual array delimiters for includes, filters, appends and sorts 548 | - ensure relations queried using the exact filter are actual relations on the model 549 | 550 | ## 3.0.0 - 2020-08-18 551 | 552 | New major version. Please read the [UPGRADING](UPGRADING.md) guide *before* upgrading. 553 | 554 | - `Spatie\QueryBuilder\QueryBuilder` class no longer extends Laravel's `Illuminate\Database\Eloquent\Builder` 555 | 556 | ## 2.8.2 - 2020-05-25 557 | 558 | - fix scope filters that are added via macros (e.g. `onlyTrashed`) (#469) 559 | 560 | ## 2.8.1 - 2020-03-20 561 | 562 | - make service provider deferrable (#381) 563 | 564 | ## 2.8.0 - 2020-03-02 565 | 566 | - add support for Laravel 7 567 | 568 | ## 2.7.2 - 2020-02-26 569 | 570 | - small fix for lumen (#436) 571 | 572 | ## 2.7.1 - 2020-02-26 573 | 574 | - small fix for lumen in service provider 575 | 576 | ## 2.7.0 - 2020-02-12 577 | 578 | - add support for model binding in scope filter parameters (#415) 579 | 580 | ## 2.6.1 - 2020-02-11 581 | 582 | - fix alias for multiple allowed includes (#414) 583 | 584 | ## 2.6.0 - 2020-02-10 585 | 586 | - add `FiltersTrashed` for filtering soft-deleted models 587 | - add `FiltersCallback` for filtering using a callback 588 | 589 | ## 2.5.1 - 2020-01-22 590 | 591 | - fix dealing with empty or `null` includes (#395) 592 | - fix passing an associative array of scope filter values (#387) 593 | 594 | ## 2.5.0 - 2020-01-09 595 | 596 | - add `defaultDirection` 597 | 598 | ## 2.4.0 - 2020-01-04 599 | 600 | - add support for a custom filter delimiter (#369) 601 | 602 | ## 2.3.0 - 2019-10-08 603 | 604 | - resolve `QueryBuilderRequest` from service container 605 | 606 | ## 2.2.1 - 2019-10-03 607 | 608 | - fix issue when passing camel-cased includes (#336) 609 | 610 | ## 2.2.0 - 2019-09-24 611 | 612 | - add option to disable parsing relationship constraints when filtering related model properties in the exact and partial filters (#262) 613 | - fix selecting fields from included relationships that are multiple levels deep (#317) 614 | 615 | ## 2.1.0 - 2019-09-03 616 | 617 | - add support for Laravel 6 618 | 619 | ## 2.0.1 - 2019-08-12 620 | 621 | - update doc block for `QueryBuilder::for()` 622 | - add missing typehint in `SortsField` 623 | 624 | ## 2.0.0 - 2019-08-12 625 | 626 | - removed request macros 627 | - sorts and field selects are not allowed by default and need to be explicitly allowed 628 | - requesting an include suffixed with `Count` will add the related models' count using `$query->withCount()` 629 | - custom sorts and filters now need to be passed as instances 630 | - renamed `Spatie\QueryBuilder\Sort` to `Spatie\QueryBuilder\AllowedSort` 631 | - renamed `Spatie\QueryBuilder\Included` to `Spatie\QueryBuilder\AllowedInclude` 632 | - renamed `Spatie\QueryBuilder\Filter` to `Spatie\QueryBuilder\AllowedFilter` 633 | - `Filter`, `Include` and `Sort` interfaces no longer need to return the `Builder` instance 634 | - `allowedFields` should be called before `allowedIncludes` 635 | - filters can now have default values 636 | - includes will be converted to camelcase before being parsed 637 | 638 | ## 1.17.5 - 2019-07-08 639 | 640 | - bugfix: correctly parse sorts in `chunk`ed query (#299) 641 | - bugfix: don't parse empty values in arrays for partial filters (#285) 642 | 643 | ## 1.17.4 - 2019-06-03 644 | 645 | - bugfix: `orderByRaw` is no longer being rejected as a sorting option (#258) 646 | - bugfix: `addSelect` is no longer being replaced by the `?fields` parameter (#260) 647 | - bugfix: take leading dash into account when remembering generated sort columns (#272) 648 | - bugfix: `allowedIncludes` no longer adds duplicate includes for nested includes (#251) 649 | 650 | ## 1.17.3 - 2019-04-16 651 | 652 | - bugfix: remove duplicate parsing of (default) sort clauses 653 | 654 | ## 1.17.2 - 2019-04-12 655 | 656 | - bugfix: replace missing `sort()` method on `QueryBuilderRequest` 657 | - bugfix: don't escape `allowedSort`s and their aliases 658 | - bugfix: don't escape `allowedField`s 659 | 660 | ## 1.17.1 - 2019-04-09 661 | 662 | - security fixes 663 | 664 | ## 1.16.1 - 2019-04-09 665 | 666 | - security fixes 667 | 668 | ## 1.17.0 - 2019-03-11 669 | 670 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 671 | 672 | - moved features to traits 673 | - started using `QueryBuilderRequest` to read data from the current request 674 | - deprecated request macros (`Request::filters()`, `Request::includes()`, etc...) 675 | - raised minimum supported Laravel version to 5.6.34 676 | 677 | ## 1.16.0 - 2019-03-05 678 | 679 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 680 | 681 | - add support for multiple default sorts (#214) 682 | 683 | ## 1.15.2 - 2019-02-28 684 | 685 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 686 | 687 | - add support for Laravel 5.5 and up (again) 688 | - add support for PHP 7.1 and up (again) 689 | 690 | ## 1.15.1 - 2019-02-28 691 | 692 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 693 | 694 | - fix default sort not parsing correctly (#178) 695 | 696 | ## 1.15.0 - 2019-02-27 697 | 698 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 699 | 700 | - drop support for Laravel 5.7 and lower 701 | - drop support for PHP 7.1 and lower 702 | 703 | ## 1.14.0 - 2019-02-27 704 | 705 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 706 | 707 | - add aliased sorts (#164) 708 | 709 | ## 1.13.2 - 2019-02-27 710 | 711 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 712 | 713 | - add support for Laravel 5.8 714 | - use Str:: and Arr:: instead of helper methods 715 | 716 | ## 1.13.1 - 2019-01-18 717 | 718 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 719 | 720 | - fix detection of false-positives for ignored values (#154) 721 | - fix broken morphTo includes (#130) 722 | 723 | ## 1.13.0 - 2019-01-12 724 | 725 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 726 | 727 | - allow ignoring specific filter values using `$filter->ignore()` 728 | - allow filtering related model attributes `allowedFilters('related-model.name')` 729 | - fix for filtering by relation model properties 730 | - add custom sort classes 731 | 732 | ## 1.12.0 - 2018-11-27 733 | 734 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 735 | 736 | - allow differently named columns 737 | 738 | ## 1.11.2 - 2018-10-30 739 | 740 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 741 | 742 | - fix exception when using filters with nested arrays (#117) 743 | - fix overwritten fields when using `allowedIncludes` with many-to-many relationships (#118) 744 | 745 | ## 1.11.1 - 2018-10-09 746 | 747 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 748 | 749 | - fix exception when using `allowedFields()` but selecting none 750 | 751 | ## 1.11.0 - 2018-10-03 752 | 753 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 754 | 755 | - add `allowedFields` method 756 | - fix & cleanup `Request::fields()` macro 757 | - fix fields option (`SELECT * FROM table` instead of `SELECT table.* FROM table`) 758 | 759 | ## 1.10.4 - 2018-10-02 760 | 761 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 762 | 763 | - fix parsing empty filters from url 764 | 765 | ## 1.10.3 - 2018-09-17 766 | 767 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 768 | 769 | - improve compatibility with Lumen 770 | 771 | ## 1.10.2 - 2018-08-28 772 | 773 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 774 | 775 | - add support for Laravel 5.7 776 | - add framework/laravel as a dependency 777 | 778 | ## 1.10.1 - 2018-08-21 779 | 780 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 781 | 782 | - improve compatibility with Lumen by only publishing the config file in console mode 783 | 784 | ## 1.10.0 - 2018-06-12 785 | 786 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 787 | 788 | - add support for instantiated custom filter classes 789 | 790 | ## 1.9.6 - 2018-06-11 791 | 792 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 793 | 794 | - fix for using reserved SQL words as attributes in Postgres 795 | 796 | ## 1.9.5 - 2018-06-09 797 | 798 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 799 | 800 | - make sure filtering on string with special characters just works 801 | 802 | ## 1.9.4 - 2018-06-06 803 | 804 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 805 | 806 | - fix for using reserved SQL words as attributes 807 | 808 | ## 1.9.3 - 2018-06-05 809 | 810 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 811 | 812 | - resolved #14 813 | 814 | ## 1.9.2 - 2018-05-21 815 | 816 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 817 | 818 | - prevent double sorting statments 819 | 820 | ## 1.9.1 - 2018-05-15 821 | 822 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 823 | 824 | - improvements around field selection 825 | 826 | ## 1.9.0 - 2018-05-02 827 | 828 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 829 | 830 | - add `Filter::scope()` for querying scopes 831 | - explicitly defining parent includes in nested queries is no longer required 832 | 833 | ## 1.8.0 - 2018-03-28 834 | 835 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 836 | 837 | - add `allowedAppends()` 838 | 839 | ## 1.7.0 - 2018-03-23 840 | 841 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 842 | 843 | - add ability to customize query parameter names 844 | 845 | ## 1.6.0 - 2018-03-05 846 | 847 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 848 | 849 | - add support for selecting specific columns using `?fields[table]=field_name` 850 | 851 | ## 1.5.3 - 2018-02-09 852 | 853 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 854 | 855 | - allow arrays in filters 856 | 857 | ## 1.5.2 - 2018-02-08 858 | 859 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 860 | 861 | - add support for Laravel 5.6 862 | 863 | ## 1.5.1 - 2018-02-07 864 | 865 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 866 | 867 | - fix: initializing scopes, macro's, the onDelete callback and eager loads from base query on QueryBuilder 868 | 869 | ## 1.5.0 - 2018-02-06 870 | 871 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 872 | 873 | - use specific exceptions for every invalid query 874 | 875 | ## 1.4.0 - 2018-02-05 876 | 877 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 878 | 879 | - allow multiple sorts 880 | 881 | ## 1.3.0 - 2018-02-05 882 | 883 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 884 | 885 | - allow `allowedIncludes`, `allowedFilters` and `allowedSorts` to accept arrays 886 | 887 | ## 1.2.1 - 2018-02-03 888 | 889 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 890 | 891 | - remove auto registering facade from composer.json 892 | 893 | ## 1.2.0 - 2018-01-29 894 | 895 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 896 | 897 | - add support for global scopes and soft deletes 898 | 899 | ## 1.1.2 - 2018-01-23 900 | 901 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 902 | 903 | - bugfix: revert #11 (escaping `_` and `%` in LIKE queries) 904 | 905 | ## 1.1.1 - 2018-01-22 906 | 907 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 908 | 909 | - escape `_` and `%` in LIKE queries 910 | 911 | ## 1.1.0 - 2018-01-20 912 | 913 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 914 | 915 | - add ability to set a default sort attribute 916 | 917 | ## 1.0.1 - 2018-01-19 918 | 919 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 920 | 921 | - bugfix: using `allowedSorts` together with an empty sort query parameter no longer throws an exception 922 | 923 | ## 1.0.0 - 2018-01-17 924 | 925 | **DO NOT USE: THIS VERSION ALLOWS SQL INJECTION ATTACKS** 926 | 927 | - initial release! 🎉 928 | --------------------------------------------------------------------------------