Skip to content

feat: implement a way of matching the fields to the jsonapi spec #983

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 23, 2024
19 changes: 19 additions & 0 deletions config/query-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,23 @@
* GET /users?fields[userOwner]=id,name
*/
'convert_relation_names_to_snake_case_plural' => true,

/*
* By default, the package expects relationship names to be snake case plural when using fields[relationship].
* For example, fetching the id and name for a userOwner relation would look like this:
* GET /users?fields[user_owner]=id,name
*
* Set this to one of `snake_case`, `camelCase` or `none` if you want to enable table name resolution in addition to the relation name resolution
* GET /users?include=topOrders&fields[orders]=id,name
*/
'convert_relation_table_name_strategy' => false,

/*
* By default, the package expects the field names to match the database names
* For example, fetching the field named firstName would look like this:
* GET /users?fields=firstName
*
* Set this to `true` if you want to convert the firstName into first_name for the underlying query
*/
'convert_field_names_to_snake_case' => false,
];
2 changes: 1 addition & 1 deletion database/factories/AppendModelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

namespace Spatie\QueryBuilder\Database\Factories;

use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;
use Illuminate\Database\Eloquent\Factories\Factory;
use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;

class AppendModelFactory extends Factory
{
Expand Down
1 change: 0 additions & 1 deletion database/factories/TestModelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ public function definition()
];
}
}

50 changes: 41 additions & 9 deletions src/Concerns/AddsFieldsToQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ protected function addRequestedModelFieldsToQuery(): void

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

$modelFields = $fields->has($modelTableName) ? $fields->get($modelTableName) : $fields->get('_');
if (! $fields->isEmpty() && config('query-builder.convert_field_names_to_snake_case', false)) {
$fields = $fields->mapWithKeys(fn ($fields, $table) => [$table => collect($fields)->map(fn ($field) => Str::snake($field))->toArray()]);
}

// Apply additional table name conversion based on strategy
if (config('query-builder.convert_relation_table_name_strategy', false) === 'camelCase') {
$modelFields = $fields->has(Str::camel($modelTableName)) ? $fields->get(Str::camel($modelTableName)) : $fields->get('_');
} else {
$modelFields = $fields->has($modelTableName) ? $fields->get($modelTableName) : $fields->get('_');
}

if (empty($modelFields)) {
return;
Expand All @@ -49,23 +58,46 @@ protected function addRequestedModelFieldsToQuery(): void
$this->select($prependedFields);
}

public function getRequestedFieldsForRelatedTable(string $relation): array
public function getRequestedFieldsForRelatedTable(string $relation, ?string $tableName = null): array
{
$tableOrRelation = config('query-builder.convert_relation_names_to_snake_case_plural', true)
? Str::plural(Str::snake($relation))
: $relation;
// Possible table names to check
$possibleRelatedNames = [
// Preserve existing relation name conversion logic
config('query-builder.convert_relation_names_to_snake_case_plural', true)
? Str::plural(Str::snake($relation))
: $relation,
];

$strategy = config('query-builder.convert_relation_table_name_strategy', false);

// Apply additional table name conversion based on strategy
if ($strategy === 'snake_case' && $tableName) {
$possibleRelatedNames[] = Str::snake($tableName);
} elseif ($strategy === 'camelCase' && $tableName) {
$possibleRelatedNames[] = Str::camel($tableName);
} elseif ($strategy === 'none') {
$possibleRelatedNames = $tableName;
}

// Remove any null values
$possibleRelatedNames = array_filter($possibleRelatedNames);

$fields = $this->request->fields()
->mapWithKeys(fn ($fields, $table) => [$table => $fields])
->get($tableOrRelation);
->mapWithKeys(fn ($fields, $table) => [$table => collect($fields)->map(fn ($field) => config('query-builder.convert_field_names_to_snake_case', false) ? Str::snake($field) : $field)])
->filter(fn ($value, $table) => in_array($table, $possibleRelatedNames))
->first();

if (! $fields) {
return [];
}

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

if ($tableName !== null) {
$fields = $this->prependFieldsWithTableName($fields, $tableName);
}

if (! $this->allowedFields instanceof Collection) {
throw new UnknownIncludedFieldsQuery($fields);
}

Expand Down
21 changes: 19 additions & 2 deletions src/Includes/IncludedRelationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Spatie\QueryBuilder\Includes;

use Closure;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;

Expand All @@ -16,11 +17,27 @@ public function __invoke(Builder $query, string $relationship)
$relatedTables = collect(explode('.', $relationship));

$withs = $relatedTables
->mapWithKeys(function ($table, $key) use ($relatedTables) {
->mapWithKeys(function ($table, $key) use ($relatedTables, $query) {
$fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');

if ($this->getRequestedFieldsForRelatedTable) {
$fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName);

$tableName = null;
$strategy = config('query-builder.convert_relation_table_name_strategy', false);

if ($strategy !== false) {
// Try to resolve the related model's table name
try {
// Use the current query's model to resolve the relationship
$relatedModel = $query->getModel()->{$fullRelationName}()->getRelated();
$tableName = $relatedModel->getTable();
} catch (Exception $e) {
// If we can not figure out the table don't do anything
$tableName = null;
}
}

$fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName, $tableName);
}

if (empty($fields)) {
Expand Down
120 changes: 120 additions & 0 deletions tests/FieldsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@
expect($query)->toEqual($expected);
});

it('can fetch specific string columns jsonApi Format', function () {
config(['query-builder.convert_field_names_to_snake_case' => true]);
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);

$query = createQueryFromFieldRequest('firstName,id')
->allowedFields(['firstName', 'id'])
->toSql();

$expected = TestModel::query()
->select("{$this->modelTableName}.first_name", "{$this->modelTableName}.id")
->toSql();

expect($query)->toEqual($expected);
});

it('wont fetch a specific array column if its not allowed', function () {
$query = createQueryFromFieldRequest(['test_models' => 'random-column'])->toSql();

Expand Down Expand Up @@ -200,6 +215,81 @@
$this->assertQueryLogContains('select `name` from `related_models`');
});

it('can fetch only requested string columns from an included model jsonApi format', function () {
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
RelatedModel::create([
'test_model_id' => $this->model->id,
'name' => 'related',
]);

$request = new Request([
'fields' => 'id,relatedModels.name',
'include' => ['relatedModels'],
]);

$queryBuilder = QueryBuilder::for(TestModel::class, $request)
->allowedFields('relatedModels.name', 'id')
->allowedIncludes('relatedModels');

DB::enableQueryLog();

$queryBuilder->first()->relatedModels;

$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
$this->assertQueryLogContains('select `related_models`.`name` from `related_models`');
});

it('can fetch only requested string columns from an included model jsonApi format with field conversion', function () {
config(['query-builder.convert_field_names_to_snake_case' => true]);
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);

RelatedModel::create([
'test_model_id' => $this->model->id,
'name' => 'related',
]);

$request = new Request([
'fields' => 'id,relatedModels.fullName',
'include' => ['relatedModels'],
]);

$queryBuilder = QueryBuilder::for(TestModel::class, $request)
->allowedFields('relatedModels.fullName', 'id')
->allowedIncludes('relatedModels');

DB::enableQueryLog();

$queryBuilder->first()->relatedModels;

$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
$this->assertQueryLogContains('select `related_models`.`full_name` from `related_models`');
});

it('can fetch only requested string columns from an included model through pivot jsonApi format', function () {
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);

$this->model->relatedThroughPivotModels()->create([
'id' => $this->model->id + 1,
'name' => 'Test',
]);

$request = new Request([
'fields' => 'id,relatedThroughPivotModels.name',
'include' => ['relatedThroughPivotModels'],
]);

$queryBuilder = QueryBuilder::for(TestModel::class, $request)
->allowedFields('relatedThroughPivotModels.name', 'id')
->allowedIncludes('relatedThroughPivotModels');

DB::enableQueryLog();

$queryBuilder->first()->relatedThroughPivotModels;

$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
$this->assertQueryLogContains('select `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models`');
});

it('can fetch requested array columns from included models up to two levels deep', function () {
RelatedModel::create([
'test_model_id' => $this->model->id,
Expand All @@ -224,6 +314,36 @@
expect($result->relatedModels->first()->testModel->toArray())->toEqual(['id' => $this->model->id]);
});

it('can fetch requested array columns from included models up to two levels deep jsonApi mapper', function () {
config(['query-builder.convert_field_names_to_snake_case' => true]);
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);

$relatedModel = RelatedModel::create([
'test_model_id' => $this->model->id,
'name' => 'related',
]);

$relatedModel->nestedRelatedModels()->create([
'name' => 'nested related',
]);

$request = new Request([
'fields' => 'id,name,relatedModels.id,relatedModels.name,nestedRelatedModels.id,nestedRelatedModels.name',
'include' => ['nestedRelatedModels', 'relatedModels'],
]);


$queryBuilder = QueryBuilder::for(TestModel::class, $request)
->allowedFields('id', 'name', 'relatedModels.id', 'relatedModels.name', 'nestedRelatedModels.id', 'nestedRelatedModels.name')
->allowedIncludes('relatedModels', 'nestedRelatedModels');

DB::enableQueryLog();
$queryBuilder->first();

$this->assertQueryLogContains('select `test_models`.`id`, `test_models`.`name` from `test_models`');
$this->assertQueryLogContains('select `nested_related_models`.`id`, `nested_related_models`.`name`, `related_models`.`test_model_id` as `laravel_through_key` from `nested_related_models`');
});

it('can fetch requested string columns from included models up to two levels deep', function () {
RelatedModel::create([
'test_model_id' => $this->model->id,
Expand Down
4 changes: 3 additions & 1 deletion tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ protected function setUpDatabase(Application $app)
$table->increments('id');
$table->timestamps();
$table->string('name')->nullable();
$table->string('full_name')->nullable();
$table->double('salary')->nullable();
$table->boolean('is_visible')->default(true);
});
Expand All @@ -62,6 +63,7 @@ protected function setUpDatabase(Application $app)
$table->increments('id');
$table->integer('test_model_id');
$table->string('name');
$table->string('full_name')->nullable();
});

$app['db']->connection()->getSchemaBuilder()->create('nested_related_models', function (Blueprint $table) {
Expand Down Expand Up @@ -92,7 +94,7 @@ protected function setUpDatabase(Application $app)
protected function getPackageProviders($app)
{
return [
RayServiceProvider::class,
// RayServiceProvider::class,
QueryBuilderServiceProvider::class,
];
}
Expand Down
13 changes: 13 additions & 0 deletions tests/TestClasses/Models/TestModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Carbon;

Expand All @@ -27,6 +28,18 @@ public function relatedModel(): BelongsTo
return $this->belongsTo(RelatedModel::class);
}

public function nestedRelatedModels(): HasManyThrough
{
return $this->hasManyThrough(
NestedRelatedModel::class, // Target model
RelatedModel::class, // Intermediate model
'test_model_id', // Foreign key on RelatedModel
'related_model_id', // Foreign key on NestedRelatedModel
'id', // Local key on TestModel
'id' // Local key on RelatedModel
);
}

public function otherRelatedModels(): HasMany
{
return $this->hasMany(RelatedModel::class);
Expand Down
Loading