Skip to content

Commit

Permalink
feat: implement a way of matching the fields to the jsonapi spec (#983)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
CoolGoose authored Dec 23, 2024
1 parent d064882 commit cf5350e
Show file tree
Hide file tree
Showing 9 changed files with 409 additions and 14 deletions.
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 @@ -222,6 +237,81 @@
$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 (');
});

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 @@ -246,6 +336,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

0 comments on commit cf5350e

Please sign in to comment.