diff --git a/README.md b/README.md index 2e041f11..f065e80a 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/composer.json b/composer.json index b99787e1..e7ce390f 100644 --- a/composer.json +++ b/composer.json @@ -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": "*", diff --git a/src/AllowedField.php b/src/AllowedField.php new file mode 100644 index 00000000..2be79d1d --- /dev/null +++ b/src/AllowedField.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/src/App/Console/Commands/CacheForeignKeys.php b/src/App/Console/Commands/CacheForeignKeys.php new file mode 100644 index 00000000..45934492 --- /dev/null +++ b/src/App/Console/Commands/CacheForeignKeys.php @@ -0,0 +1,71 @@ +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] ?? []; + } + +} diff --git a/src/Concerns/AddsFieldsToQuery.php b/src/Concerns/AddsFieldsToQuery.php index 120c000e..726574d1 100644 --- a/src/Concerns/AddsFieldsToQuery.php +++ b/src/Concerns/AddsFieldsToQuery.php @@ -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 { @@ -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(); @@ -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); @@ -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); } @@ -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); } } @@ -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; + } } diff --git a/src/Concerns/FiltersQuery.php b/src/Concerns/FiltersQuery.php index 9e932c23..b3cfda2c 100644 --- a/src/Concerns/FiltersQuery.php +++ b/src/Concerns/FiltersQuery.php @@ -76,4 +76,9 @@ protected function ensureAllFiltersExist(): void throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames); } } + + public function getAllowedFilters(): ?Collection + { + return $this->allowedFilters; + } } diff --git a/src/Concerns/SortsQuery.php b/src/Concerns/SortsQuery.php index c61d216f..15dded43 100644 --- a/src/Concerns/SortsQuery.php +++ b/src/Concerns/SortsQuery.php @@ -95,4 +95,9 @@ protected function ensureAllSortsExist(): void throw InvalidSortQuery::sortsNotAllowed($unknownSorts, $allowedSortNames); } } + + public function getAllowedSorts(): ?Collection + { + return $this->allowedSorts; + } } diff --git a/src/Mappings/Column.php b/src/Mappings/Column.php new file mode 100644 index 00000000..3a582c11 --- /dev/null +++ b/src/Mappings/Column.php @@ -0,0 +1,18 @@ +name = $name; + } + +} diff --git a/src/QueryBuilderRequest.php b/src/QueryBuilderRequest.php index e2f30e14..ea500416 100644 --- a/src/QueryBuilderRequest.php +++ b/src/QueryBuilderRequest.php @@ -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 { @@ -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 diff --git a/src/QueryBuilderServiceProvider.php b/src/QueryBuilderServiceProvider.php index da8b9313..e68206a0 100644 --- a/src/QueryBuilderServiceProvider.php +++ b/src/QueryBuilderServiceProvider.php @@ -4,6 +4,7 @@ use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; +use Spatie\QueryBuilder\App\Console\Commands\CacheForeignKeys; class QueryBuilderServiceProvider extends PackageServiceProvider { @@ -11,6 +12,7 @@ public function configurePackage(Package $package): void { $package ->name('laravel-query-builder') + ->hasConsoleCommands(CacheForeignKeys::class) ->hasConfigFile(); }