Skip to content

Commit cf5350e

Browse files
authored
feat: implement a way of matching the fields to the jsonapi spec (#983)
* feat: implement a way of matching the fields to the jsonapi spec * feat: introduce tests to cover the new jsonapi functionality * feat: introduce an easy way of running tests locally * feat: implement a way of matching the fields to the jsonapi spec * feat: introduce tests to cover the new jsonapi functionality * feat: introduce an easy way of running tests locally * fix: allow usage of the main table filtering the fields
1 parent d064882 commit cf5350e

File tree

9 files changed

+409
-14
lines changed

9 files changed

+409
-14
lines changed

config/query-builder.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,23 @@
6060
* GET /users?fields[userOwner]=id,name
6161
*/
6262
'convert_relation_names_to_snake_case_plural' => true,
63+
64+
/*
65+
* By default, the package expects relationship names to be snake case plural when using fields[relationship].
66+
* For example, fetching the id and name for a userOwner relation would look like this:
67+
* GET /users?fields[user_owner]=id,name
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+
* GET /users?include=topOrders&fields[orders]=id,name
71+
*/
72+
'convert_relation_table_name_strategy' => false,
73+
74+
/*
75+
* By default, the package expects the field names to match the database names
76+
* For example, fetching the field named firstName would look like this:
77+
* GET /users?fields=firstName
78+
*
79+
* Set this to `true` if you want to convert the firstName into first_name for the underlying query
80+
*/
81+
'convert_field_names_to_snake_case' => false,
6382
];

database/factories/AppendModelFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
namespace Spatie\QueryBuilder\Database\Factories;
44

5-
use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;
65
use Illuminate\Database\Eloquent\Factories\Factory;
6+
use Spatie\QueryBuilder\Tests\TestClasses\Models\AppendModel;
77

88
class AppendModelFactory extends Factory
99
{

database/factories/TestModelFactory.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,3 @@ public function definition()
1616
];
1717
}
1818
}
19-

src/Concerns/AddsFieldsToQuery.php

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,16 @@ protected function addRequestedModelFieldsToQuery(): void
3838

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

41-
$modelFields = $fields->has($modelTableName) ? $fields->get($modelTableName) : $fields->get('_');
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+
}
4251

4352
if (empty($modelFields)) {
4453
return;
@@ -49,23 +58,46 @@ protected function addRequestedModelFieldsToQuery(): void
4958
$this->select($prependedFields);
5059
}
5160

52-
public function getRequestedFieldsForRelatedTable(string $relation): array
61+
public function getRequestedFieldsForRelatedTable(string $relation, ?string $tableName = null): array
5362
{
54-
$tableOrRelation = config('query-builder.convert_relation_names_to_snake_case_plural', true)
55-
? Str::plural(Str::snake($relation))
56-
: $relation;
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);
5784

5885
$fields = $this->request->fields()
59-
->mapWithKeys(fn ($fields, $table) => [$table => $fields])
60-
->get($tableOrRelation);
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();
6189

6290
if (! $fields) {
6391
return [];
6492
}
6593

66-
if (! $this->allowedFields instanceof Collection) {
67-
// We have requested fields but no allowed fields (yet?)
94+
$fields = $fields->toArray();
95+
96+
if ($tableName !== null) {
97+
$fields = $this->prependFieldsWithTableName($fields, $tableName);
98+
}
6899

100+
if (! $this->allowedFields instanceof Collection) {
69101
throw new UnknownIncludedFieldsQuery($fields);
70102
}
71103

src/Includes/IncludedRelationship.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Spatie\QueryBuilder\Includes;
44

55
use Closure;
6+
use Exception;
67
use Illuminate\Database\Eloquent\Builder;
78
use Illuminate\Support\Collection;
89

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

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

2223
if ($this->getRequestedFieldsForRelatedTable) {
23-
$fields = ($this->getRequestedFieldsForRelatedTable)($fullRelationName);
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);
2441
}
2542

2643
if (empty($fields)) {

tests/FieldsTest.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@
8383
expect($query)->toEqual($expected);
8484
});
8585

86+
it('can fetch specific string columns jsonApi Format', function () {
87+
config(['query-builder.convert_field_names_to_snake_case' => true]);
88+
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
89+
90+
$query = createQueryFromFieldRequest('firstName,id')
91+
->allowedFields(['firstName', 'id'])
92+
->toSql();
93+
94+
$expected = TestModel::query()
95+
->select("{$this->modelTableName}.first_name", "{$this->modelTableName}.id")
96+
->toSql();
97+
98+
expect($query)->toEqual($expected);
99+
});
100+
86101
it('wont fetch a specific array column if its not allowed', function () {
87102
$query = createQueryFromFieldRequest(['test_models' => 'random-column'])->toSql();
88103

@@ -222,6 +237,81 @@
222237
$this->assertQueryLogContains('select `related_through_pivot_models`.`id`, `related_through_pivot_models`.`name`, `pivot_models`.`test_model_id` as `pivot_test_model_id`, `pivot_models`.`related_through_pivot_model_id` as `pivot_related_through_pivot_model_id` from `related_through_pivot_models` inner join `pivot_models` on `related_through_pivot_models`.`id` = `pivot_models`.`related_through_pivot_model_id` where `pivot_models`.`test_model_id` in (');
223238
});
224239

240+
it('can fetch only requested string columns from an included model jsonApi format', function () {
241+
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
242+
RelatedModel::create([
243+
'test_model_id' => $this->model->id,
244+
'name' => 'related',
245+
]);
246+
247+
$request = new Request([
248+
'fields' => 'id,relatedModels.name',
249+
'include' => ['relatedModels'],
250+
]);
251+
252+
$queryBuilder = QueryBuilder::for(TestModel::class, $request)
253+
->allowedFields('relatedModels.name', 'id')
254+
->allowedIncludes('relatedModels');
255+
256+
DB::enableQueryLog();
257+
258+
$queryBuilder->first()->relatedModels;
259+
260+
$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
261+
$this->assertQueryLogContains('select `related_models`.`name` from `related_models`');
262+
});
263+
264+
it('can fetch only requested string columns from an included model jsonApi format with field conversion', function () {
265+
config(['query-builder.convert_field_names_to_snake_case' => true]);
266+
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
267+
268+
RelatedModel::create([
269+
'test_model_id' => $this->model->id,
270+
'name' => 'related',
271+
]);
272+
273+
$request = new Request([
274+
'fields' => 'id,relatedModels.fullName',
275+
'include' => ['relatedModels'],
276+
]);
277+
278+
$queryBuilder = QueryBuilder::for(TestModel::class, $request)
279+
->allowedFields('relatedModels.fullName', 'id')
280+
->allowedIncludes('relatedModels');
281+
282+
DB::enableQueryLog();
283+
284+
$queryBuilder->first()->relatedModels;
285+
286+
$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
287+
$this->assertQueryLogContains('select `related_models`.`full_name` from `related_models`');
288+
});
289+
290+
it('can fetch only requested string columns from an included model through pivot jsonApi format', function () {
291+
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
292+
293+
$this->model->relatedThroughPivotModels()->create([
294+
'id' => $this->model->id + 1,
295+
'name' => 'Test',
296+
]);
297+
298+
$request = new Request([
299+
'fields' => 'id,relatedThroughPivotModels.name',
300+
'include' => ['relatedThroughPivotModels'],
301+
]);
302+
303+
$queryBuilder = QueryBuilder::for(TestModel::class, $request)
304+
->allowedFields('relatedThroughPivotModels.name', 'id')
305+
->allowedIncludes('relatedThroughPivotModels');
306+
307+
DB::enableQueryLog();
308+
309+
$queryBuilder->first()->relatedThroughPivotModels;
310+
311+
$this->assertQueryLogContains('select `test_models`.`id` from `test_models`');
312+
$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`');
313+
});
314+
225315
it('can fetch requested array columns from included models up to two levels deep', function () {
226316
RelatedModel::create([
227317
'test_model_id' => $this->model->id,
@@ -246,6 +336,36 @@
246336
expect($result->relatedModels->first()->testModel->toArray())->toEqual(['id' => $this->model->id]);
247337
});
248338

339+
it('can fetch requested array columns from included models up to two levels deep jsonApi mapper', function () {
340+
config(['query-builder.convert_field_names_to_snake_case' => true]);
341+
config(['query-builder.convert_relation_table_name_strategy' => 'camelCase']);
342+
343+
$relatedModel = RelatedModel::create([
344+
'test_model_id' => $this->model->id,
345+
'name' => 'related',
346+
]);
347+
348+
$relatedModel->nestedRelatedModels()->create([
349+
'name' => 'nested related',
350+
]);
351+
352+
$request = new Request([
353+
'fields' => 'id,name,relatedModels.id,relatedModels.name,nestedRelatedModels.id,nestedRelatedModels.name',
354+
'include' => ['nestedRelatedModels', 'relatedModels'],
355+
]);
356+
357+
358+
$queryBuilder = QueryBuilder::for(TestModel::class, $request)
359+
->allowedFields('id', 'name', 'relatedModels.id', 'relatedModels.name', 'nestedRelatedModels.id', 'nestedRelatedModels.name')
360+
->allowedIncludes('relatedModels', 'nestedRelatedModels');
361+
362+
DB::enableQueryLog();
363+
$queryBuilder->first();
364+
365+
$this->assertQueryLogContains('select `test_models`.`id`, `test_models`.`name` from `test_models`');
366+
$this->assertQueryLogContains('select `nested_related_models`.`id`, `nested_related_models`.`name`, `related_models`.`test_model_id` as `laravel_through_key` from `nested_related_models`');
367+
});
368+
249369
it('can fetch requested string columns from included models up to two levels deep', function () {
250370
RelatedModel::create([
251371
'test_model_id' => $this->model->id,

tests/TestCase.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ protected function setUpDatabase(Application $app)
3737
$table->increments('id');
3838
$table->timestamps();
3939
$table->string('name')->nullable();
40+
$table->string('full_name')->nullable();
4041
$table->double('salary')->nullable();
4142
$table->boolean('is_visible')->default(true);
4243
});
@@ -62,6 +63,7 @@ protected function setUpDatabase(Application $app)
6263
$table->increments('id');
6364
$table->integer('test_model_id');
6465
$table->string('name');
66+
$table->string('full_name')->nullable();
6567
});
6668

6769
$app['db']->connection()->getSchemaBuilder()->create('nested_related_models', function (Blueprint $table) {
@@ -92,7 +94,7 @@ protected function setUpDatabase(Application $app)
9294
protected function getPackageProviders($app)
9395
{
9496
return [
95-
RayServiceProvider::class,
97+
// RayServiceProvider::class,
9698
QueryBuilderServiceProvider::class,
9799
];
98100
}

tests/TestClasses/Models/TestModel.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Database\Eloquent\Relations\BelongsTo;
99
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
1010
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
1112
use Illuminate\Database\Eloquent\Relations\MorphMany;
1213
use Illuminate\Support\Carbon;
1314

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

31+
public function nestedRelatedModels(): HasManyThrough
32+
{
33+
return $this->hasManyThrough(
34+
NestedRelatedModel::class, // Target model
35+
RelatedModel::class, // Intermediate model
36+
'test_model_id', // Foreign key on RelatedModel
37+
'related_model_id', // Foreign key on NestedRelatedModel
38+
'id', // Local key on TestModel
39+
'id' // Local key on RelatedModel
40+
);
41+
}
42+
3043
public function otherRelatedModels(): HasMany
3144
{
3245
return $this->hasMany(RelatedModel::class);

0 commit comments

Comments
 (0)