Skip to content
Closed

V2 #1005

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@

This package allows you to filter, sort and include eloquent relations based on a request. The `QueryBuilder` used in this package extends Laravel's default Eloquent builder. This means all your favorite methods and macros are still available. Query parameter names follow the [JSON API specification](http://jsonapi.org/) as closely as possible.

## Caching foreign keys
Add this line to your composer.json file to cache foreign keys. This will allow the query builder to automatically detect foreign keys without having to make a database call.
These foreign keys will always be included in select statements, which will prevent a loss of potential relations.
```json
"scripts": {
"post-update-cmd": [
"@php artisan query-builder:cache-foreign-keys"
],
}
```

## Basic usage

### Filter a query based on a request: `/users?filter[name]=John`:
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"illuminate/database": "^10.0|^11.0",
"illuminate/http": "^10.0|^11.0",
"illuminate/support": "^10.0|^11.0",
"spatie/laravel-package-tools": "^1.11"
"spatie/laravel-package-tools": "^1.11",
"doctrine/dbal": "^3.5"
},
"require-dev": {
"ext-json": "*",
Expand Down
43 changes: 43 additions & 0 deletions src/AllowedField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace Spatie\QueryBuilder;

use Illuminate\Support\Collection;

class AllowedField
{
protected string $name;
protected Collection $internalNames;

public function __construct(string $name, string|array $internalName = null)
{
$this->name = $name;

$this->internalNames = collect($internalName);
}


public static function setFilterArrayValueDelimiter(string $delimiter = null): void
{
if (isset($delimiter)) {
QueryBuilderRequest::setFilterArrayValueDelimiter($delimiter);
}
}

public static function partial(string $name, $internalNames = null, bool $addRelationConstraint = true, string $arrayValueDelimiter = null): self
{
static::setFilterArrayValueDelimiter($arrayValueDelimiter);

return new static($name, $internalNames);
}

public function getName(): string
{
return $this->name;
}

public function getInternalNames(): Collection
{
return $this->internalNames;
}
}
71 changes: 71 additions & 0 deletions src/App/Console/Commands/CacheForeignKeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Spatie\QueryBuilder\App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use ReflectionClass;

class CacheForeignKeys extends Command
{
protected $signature = 'query-builder:cache-foreign-keys';

protected $description = 'Cache foreign keys for the QueryBuilder package.';

public function __invoke(): void
{
$modelsDirectory = app_path('Models');

$files = File::allFiles($modelsDirectory);

$foreignKeys = [];
$this->info('Fetching all models in App\Models...' . PHP_EOL);

foreach ($files as $file) {
// Build the full class name
$fullClassName = 'App\\Models' . '\\' . $file->getRelativePath() . '\\' . pathinfo($file->getRelativePathname(), PATHINFO_FILENAME);
$fullClassName = Str::replace('/', '\\', $fullClassName);

// Check if the class exists
if (class_exists($fullClassName) && is_subclass_of($fullClassName, Model::class)) {
// Create a ReflectionClass instance
$reflectionClass = new ReflectionClass($fullClassName);

// Check if the class is instantiable
if ($reflectionClass->isInstantiable()) {
// Instantiate the class
$instance = $reflectionClass->newInstance();
$table = $instance->getTable();

// Get all foreign keys for the table
$tableForeignKeys = Schema::getConnection()->getDoctrineSchemaManager()->listTableForeignKeys($table);

// Add the foreign keys to the array
$foreignKeys[$table] = array_reduce($tableForeignKeys, function ($carry, $foreignKey) {
return array_merge($carry, $foreignKey->getLocalColumns());
}, []);

// Add the primary key to the array
$foreignKeys[$table][] = $instance->getKeyName();
} else {
$this->error("The class $fullClassName is not instantiable.");
}
} else {
$this->warn("The class $fullClassName does not exist or does not extend " . Model::class . '.');
}
}
$this->info(PHP_EOL . 'Cached foreign keys for ' . count($foreignKeys) . ' tables.');

Cache::forever('QUERY_BUILDER_FKS', $foreignKeys);
}

public static function getForTable(string $table): array
{
return Cache::get('QUERY_BUILDER_FKS')[$table] ?? [];
}

}
65 changes: 44 additions & 21 deletions src/Concerns/AddsFieldsToQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\QueryBuilder\AllowedField;
use Spatie\QueryBuilder\Exceptions\AllowedFieldsMustBeCalledBeforeAllowedIncludes;
use Spatie\QueryBuilder\Exceptions\InvalidFieldQuery;
use Spatie\QueryBuilder\Exceptions\UnknownIncludedFieldsQuery;

trait AddsFieldsToQuery
{
protected ?Collection $allowedFields = null;
public ?Collection $allowedFields = null;

public function allowedFields($fields): static
{
Expand All @@ -20,10 +21,13 @@ public function allowedFields($fields): static

$fields = is_array($fields) ? $fields : func_get_args();

$this->allowedFields = collect($fields)
->map(function (string $fieldName) {
return $this->prependField($fieldName);
});
$this->allowedFields = collect($fields)->map(function ($field) {
if ($field instanceof AllowedField) {
return $field;
}

return AllowedField::partial($field);
});

$this->ensureAllFieldsExist();

Expand All @@ -36,14 +40,34 @@ protected function addRequestedModelFieldsToQuery(): void
{
$modelTableName = $this->getModel()->getTable();

$fields = $this->request->fields();

$modelFields = $fields->has($modelTableName) ? $fields->get($modelTableName) : $fields->get('_');
$requestFields = $this->request->fields()->map(function ($field) {
return $field->name;
});

$modelFields = $this->allowedFields->mapWithKeys(function (AllowedField $field) {
return [
$field->getName() => $field->getInternalNames()->toArray(),
];
});

if ($requestFields->count() > 0) {
// If fields are requested, only select those
$modelFields = $modelFields->filter(function ($internalName, $name) use ($requestFields) {
return $requestFields->contains($name);
})->toArray();
} else {
// If no fields are requested, select all allowed fields
$modelFields = $modelFields->toArray();
}

if (empty($modelFields)) {
return;
}

// Flatten array
$modelFields = array_unique(array_merge(...array_values($modelFields)));

// Prepend the fields with the table name
$prependedFields = $this->prependFieldsWithTableName($modelFields, $modelTableName);

$this->select($prependedFields);
Expand All @@ -65,7 +89,6 @@ public function getRequestedFieldsForRelatedTable(string $relation): array

if (! $this->allowedFields instanceof Collection) {
// We have requested fields but no allowed fields (yet?)

throw new UnknownIncludedFieldsQuery($fields);
}

Expand All @@ -74,21 +97,17 @@ public function getRequestedFieldsForRelatedTable(string $relation): array

protected function ensureAllFieldsExist(): void
{
$modelTable = $this->getModel()->getTable();
// Map fieldnames from object
$allowedFields = $this->allowedFields->map(function (AllowedField $field) {
return $field->getName();
});

$requestedFields = $this->request->fields()
->map(function ($fields, $model) use ($modelTable) {
$tableName = $model;
$requestedFields = $this->request->fields();

return $this->prependFieldsWithTableName($fields, $model === '_' ? $modelTable : $tableName);
})
->flatten()
->unique();

$unknownFields = $requestedFields->diff($this->allowedFields);
$unknownFields = $requestedFields->pluck('name')->diff($allowedFields);

if ($unknownFields->isNotEmpty()) {
throw InvalidFieldQuery::fieldsNotAllowed($unknownFields, $this->allowedFields);
throw InvalidFieldQuery::fieldsNotAllowed($unknownFields, $allowedFields);
}
}

Expand All @@ -107,10 +126,14 @@ protected function prependField(string $field, ?string $table = null): string

if (Str::contains($field, '.')) {
// Already prepended

return $field;
}

return "{$table}.{$field}";
}

public function getAllowedFields(): ?Collection
{
return $this->allowedFields;
}
}
5 changes: 5 additions & 0 deletions src/Concerns/FiltersQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,9 @@ protected function ensureAllFiltersExist(): void
throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
}
}

public function getAllowedFilters(): ?Collection
{
return $this->allowedFilters;
}
}
5 changes: 5 additions & 0 deletions src/Concerns/SortsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,9 @@ protected function ensureAllSortsExist(): void
throw InvalidSortQuery::sortsNotAllowed($unknownSorts, $allowedSortNames);
}
}

public function getAllowedSorts(): ?Collection
{
return $this->allowedSorts;
}
}
18 changes: 18 additions & 0 deletions src/Mappings/Column.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Spatie\QueryBuilder\Mappings;

class Column
{
/**
* Create a new currency instance.
*
* @param string $name
* @return void
*/
public function __construct(string $name)
{
$this->name = $name;
}

}
28 changes: 5 additions & 23 deletions src/QueryBuilderRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\QueryBuilder\Mappings\Column;

class QueryBuilderRequest extends Request
{
Expand Down Expand Up @@ -65,32 +66,13 @@ public function fields(): Collection

$fieldsPerTable = collect(is_string($fieldsData) ? explode(static::getFieldsArrayValueDelimiter(), $fieldsData) : $fieldsData);

if ($fieldsPerTable->isEmpty()) {
$data = $this->getRequestData($fieldsParameterName);

if (! $data) {
return collect();
}

$fields = [];

$fieldsPerTable->each(function ($tableFields, $model) use (&$fields) {
if (is_numeric($model)) {
// If the field is in dot notation, we'll grab the table without the field.
// If the field isn't in dot notation we want the base table. We'll use `_` and replace it later.
$model = Str::contains($tableFields, '.') ? Str::beforeLast($tableFields, '.') : '_';
}

if (! isset($fields[$model])) {
$fields[$model] = [];
}

// If the field is in dot notation, we'll grab the field without the tables:
$tableFields = array_map(function (string $field) {
return Str::afterLast($field, '.');
}, explode(static::getFieldsArrayValueDelimiter(), $tableFields));

$fields[$model] = array_merge($fields[$model], $tableFields);
});

return collect($fields);
return collect(explode(static::getFieldsArrayValueDelimiter(), $data))->mapInto(Column::class);
}

public function sorts(): Collection
Expand Down
2 changes: 2 additions & 0 deletions src/QueryBuilderServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
use Spatie\QueryBuilder\App\Console\Commands\CacheForeignKeys;

class QueryBuilderServiceProvider extends PackageServiceProvider
{
public function configurePackage(Package $package): void
{
$package
->name('laravel-query-builder')
->hasConsoleCommands(CacheForeignKeys::class)
->hasConfigFile();
}

Expand Down