Skip to content

Commit 4d66f5e

Browse files
authored
fix(laravel): persist embeded relations with groups
1 parent 329acf2 commit 4d66f5e

File tree

11 files changed

+366
-28
lines changed

11 files changed

+366
-28
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -256,30 +256,26 @@ public function register(): void
256256
});
257257

258258
$this->app->singleton(PropertyMetadataFactoryInterface::class, function (Application $app) {
259-
return new PropertyInfoPropertyMetadataFactory(
260-
$app->make(PropertyInfoExtractorInterface::class),
261-
new EloquentPropertyMetadataFactory(
262-
$app->make(ModelMetadata::class),
263-
)
264-
);
265-
});
266-
267-
$this->app->extend(PropertyMetadataFactoryInterface::class, function (PropertyInfoPropertyMetadataFactory $inner, Application $app) {
268259
/** @var ConfigRepository $config */
269260
$config = $app['config'];
270261

271262
return new CachePropertyMetadataFactory(
272263
new SchemaPropertyMetadataFactory(
273264
$app->make(ResourceClassResolverInterface::class),
274-
new SerializerPropertyMetadataFactory(
275-
$app->make(SerializerClassMetadataFactory::class),
276-
new AttributePropertyMetadataFactory(
277-
new EloquentAttributePropertyMetadataFactory(
278-
$inner,
279-
)
265+
new PropertyInfoPropertyMetadataFactory(
266+
$app->make(PropertyInfoExtractorInterface::class),
267+
new SerializerPropertyMetadataFactory(
268+
$app->make(SerializerClassMetadataFactory::class),
269+
new AttributePropertyMetadataFactory(
270+
new EloquentAttributePropertyMetadataFactory(
271+
new EloquentPropertyMetadataFactory(
272+
$app->make(ModelMetadata::class),
273+
),
274+
)
275+
),
276+
$app->make(ResourceClassResolverInterface::class)
280277
),
281-
$app->make(ResourceClassResolverInterface::class)
282-
),
278+
)
283279
),
284280
true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file')
285281
);

src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,21 @@ public function create(string $resourceClass, string $property, array $options =
8686
default => new Type(\in_array($builtinType, Type::$builtinTypes, true) ? $builtinType : Type::BUILTIN_TYPE_STRING, $p['nullable'] ?? true),
8787
};
8888

89-
return $propertyMetadata
90-
->withBuiltinTypes([$type])
91-
->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable'])
92-
->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']);
89+
$propertyMetadata = $propertyMetadata
90+
->withBuiltinTypes([$type]);
91+
92+
// If these are set let the SerializerPropertyMetadataFactory do the work
93+
if (!isset($options['denormalization_groups'])) {
94+
$propertyMetadata = $propertyMetadata
95+
->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable']);
96+
}
97+
98+
if (!isset($options['normalization_groups'])) {
99+
$propertyMetadata = $propertyMetadata
100+
->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']);
101+
}
102+
103+
return $propertyMetadata;
93104
}
94105

95106
foreach ($this->modelMetadata->getRelations($model) as $relation) {
@@ -110,8 +121,6 @@ public function create(string $resourceClass, string $property, array $options =
110121

111122
return $propertyMetadata
112123
->withBuiltinTypes([$type])
113-
->withWritable($propertyMetadata->isWritable() ?? true)
114-
->withReadable($propertyMetadata->isReadable() ?? true)
115124
->withExtraProperties(['eloquent_relation' => $relation] + $propertyMetadata->getExtraProperties());
116125
}
117126

src/Laravel/Eloquent/Metadata/ModelMetadata.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Laravel\Eloquent\Metadata;
1515

16+
use ApiPlatform\Metadata\Util\CamelCaseToSnakeCaseNameConverter;
1617
use Illuminate\Database\Eloquent\Model;
1718
use Illuminate\Database\Eloquent\Relations\Relation;
1819
use Illuminate\Support\Collection;
@@ -25,6 +26,8 @@
2526
*/
2627
final class ModelMetadata
2728
{
29+
private CamelCaseToSnakeCaseNameConverter $relationNameConverter;
30+
2831
/**
2932
* @var array<class-string, Collection<string, mixed>>
3033
*/
@@ -54,6 +57,11 @@ final class ModelMetadata
5457
'morphedByMany',
5558
];
5659

60+
public function __construct()
61+
{
62+
$this->relationNameConverter = new CamelCaseToSnakeCaseNameConverter();
63+
}
64+
5765
/**
5866
* Gets the column attributes for the given model.
5967
*
@@ -172,8 +180,10 @@ public function getRelations(Model $model): Collection
172180
|| $this->attributeIsHidden($method->getName(), $model)
173181
)
174182
->filter(function (\ReflectionMethod $method) {
175-
if ($method->getReturnType() instanceof \ReflectionNamedType
176-
&& is_subclass_of($method->getReturnType()->getName(), Relation::class)) {
183+
if (
184+
$method->getReturnType() instanceof \ReflectionNamedType
185+
&& is_subclass_of($method->getReturnType()->getName(), Relation::class)
186+
) {
177187
return true;
178188
}
179189

@@ -204,7 +214,8 @@ public function getRelations(Model $model): Collection
204214
}
205215

206216
return [
207-
'name' => $method->getName(),
217+
'name' => $this->relationNameConverter->normalize($method->getName()),
218+
'method_name' => $method->getName(),
208219
'type' => $relation::class,
209220
'related' => \get_class($relation->getRelated()),
210221
'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null,

src/Laravel/Eloquent/State/PersistProcessor.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,59 @@
1414
namespace ApiPlatform\Laravel\Eloquent\State;
1515

1616
use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
17+
use ApiPlatform\Metadata\Exception\RuntimeException;
1718
use ApiPlatform\Metadata\HttpOperation;
1819
use ApiPlatform\Metadata\Operation;
1920
use ApiPlatform\State\ProcessorInterface;
2021
use Illuminate\Database\Eloquent\Relations\BelongsTo;
22+
use Illuminate\Database\Eloquent\Relations\HasMany;
2123

2224
/**
2325
* @implements ProcessorInterface<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model>
2426
*/
2527
final class PersistProcessor implements ProcessorInterface
2628
{
29+
/**
30+
* @var array<string, string>
31+
*/
32+
private array $relations;
33+
2734
public function __construct(
2835
private readonly ModelMetadata $modelMetadata,
2936
) {
3037
}
3138

3239
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
3340
{
41+
$toMany = [];
42+
3443
foreach ($this->modelMetadata->getRelations($data) as $relation) {
3544
if (!isset($data->{$relation['name']})) {
3645
continue;
3746
}
3847

3948
if (BelongsTo::class === $relation['type']) {
40-
$data->{$relation['name']}()->associate($data->{$relation['name']});
49+
$rel = $data->{$relation['name']};
50+
51+
if (!$rel->exists) {
52+
$rel->save();
53+
}
54+
55+
$data->{$relation['method_name']}()->associate($data->{$relation['name']});
56+
unset($data->{$relation['name']});
57+
$this->relations[$relation['method_name']] = $relation['name'];
58+
}
59+
60+
if (HasMany::class === $relation['type']) {
61+
$rel = $data->{$relation['name']};
62+
63+
if (!\is_array($rel)) {
64+
throw new RuntimeException('To-Many relationship is not a collection.');
65+
}
66+
67+
$toMany[$relation['method_name']] = $rel;
4168
unset($data->{$relation['name']});
69+
$this->relations[$relation['method_name']] = $relation['name'];
4270
}
4371
}
4472

@@ -54,6 +82,18 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
5482
$data->saveOrFail();
5583
$data->refresh();
5684

85+
foreach ($data->getRelations() as $methodName => $obj) {
86+
if (isset($this->relations[$methodName])) {
87+
$data->{$this->relations[$methodName]} = $obj;
88+
}
89+
}
90+
91+
foreach ($toMany as $methodName => $relations) {
92+
$data->{$methodName}()->saveMany($relations);
93+
$data->{$this->relations[$methodName]} = $relations;
94+
unset($toMany[$methodName]);
95+
}
96+
5797
return $data;
5898
}
5999
}

src/Laravel/Tests/EloquentTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
1717
use ApiPlatform\Laravel\workbench\app\Enums\BookStatus;
1818
use Illuminate\Foundation\Testing\RefreshDatabase;
19+
use Illuminate\Support\Str;
1920
use Orchestra\Testbench\Concerns\WithWorkbench;
2021
use Orchestra\Testbench\TestCase;
2122
use Workbench\Database\Factories\AuthorFactory;
@@ -442,4 +443,84 @@ public function testBelongsTo(): void
442443
$this->assertEquals($json['@id'], '/api/grand_sons/1/grand_father');
443444
$this->assertEquals($json['sons'][0], '/api/grand_sons/1');
444445
}
446+
447+
public function testRelationIsHandledOnCreateWithNestedData(): void
448+
{
449+
$cartData = [
450+
'productSku' => 'SKU_TEST_001',
451+
'quantity' => 2,
452+
'priceAtAddition' => '19.99',
453+
'shoppingCart' => [
454+
'userIdentifier' => 'user-'.Str::uuid()->toString(),
455+
'status' => 'active',
456+
],
457+
];
458+
459+
$response = $this->postJson('/api/cart_items', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
460+
$response->assertStatus(201);
461+
462+
$response
463+
->assertJson([
464+
'@context' => '/api/contexts/CartItem',
465+
'@id' => '/api/cart_items/1',
466+
'@type' => 'CartItem',
467+
'id' => 1,
468+
'productSku' => 'SKU_TEST_001',
469+
'quantity' => 2,
470+
'priceAtAddition' => 19.99,
471+
'shoppingCart' => [
472+
'@id' => '/api/shopping_carts/1',
473+
'@type' => 'ShoppingCart',
474+
'userIdentifier' => $cartData['shoppingCart']['userIdentifier'],
475+
'status' => 'active',
476+
],
477+
]);
478+
}
479+
480+
public function testRelationIsHandledOnCreateWithNestedDataToMany(): void
481+
{
482+
$cartData = [
483+
'userIdentifier' => 'user-'.Str::uuid()->toString(),
484+
'status' => 'active',
485+
'cartItems' => [
486+
[
487+
'productSku' => 'SKU_TEST_001',
488+
'quantity' => 2,
489+
'priceAtAddition' => '19.99',
490+
],
491+
[
492+
'productSku' => 'SKU_TEST_002',
493+
'quantity' => 1,
494+
'priceAtAddition' => '25.50',
495+
],
496+
],
497+
];
498+
499+
$response = $this->postJson('/api/shopping_carts', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']);
500+
$response->assertStatus(201);
501+
$response->assertJson([
502+
'@context' => '/api/contexts/ShoppingCart',
503+
'@id' => '/api/shopping_carts/1',
504+
'@type' => 'ShoppingCart',
505+
'id' => 1,
506+
'userIdentifier' => $cartData['userIdentifier'],
507+
'status' => 'active',
508+
'cartItems' => [
509+
[
510+
'@id' => '/api/cart_items/1',
511+
'@type' => 'CartItem',
512+
'productSku' => 'SKU_TEST_001',
513+
'quantity' => 2,
514+
'priceAtAddition' => '19.99',
515+
],
516+
[
517+
'@id' => '/api/cart_items/2',
518+
'@type' => 'CartItem',
519+
'productSku' => 'SKU_TEST_002',
520+
'quantity' => 1,
521+
'priceAtAddition' => '25.50',
522+
],
523+
],
524+
]);
525+
}
445526
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use Illuminate\Database\Eloquent\Factories\HasFactory;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
21+
use Symfony\Component\Serializer\Attribute\Groups;
22+
23+
// #[ApiResource(denormalizationContext: ['groups' => ['cart_item.write']], normalizationContext: ['groups' => ['cart_item.write']])]
24+
#[ApiResource(denormalizationContext: ['groups' => ['cart_item.write']], normalizationContext: ['groups' => ['cart_item.write']])]
25+
#[Groups('cart_item.write')]
26+
#[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'product_sku')]
27+
#[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'price_at_addition')]
28+
#[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'quantity')]
29+
class CartItem extends Model
30+
{
31+
use HasFactory;
32+
33+
protected $fillable = [
34+
'product_sku',
35+
'quantity',
36+
'price_at_addition',
37+
];
38+
39+
public function shoppingCart(): BelongsTo
40+
{
41+
return $this->belongsTo(ShoppingCart::class);
42+
}
43+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Workbench\App\Models;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use Illuminate\Database\Eloquent\Factories\HasFactory;
19+
use Illuminate\Database\Eloquent\Model;
20+
use Illuminate\Database\Eloquent\Relations\HasMany;
21+
use Symfony\Component\Serializer\Attribute\Groups;
22+
23+
#[ApiResource(denormalizationContext: ['groups' => ['shopping_cart.write']], normalizationContext: ['groups' => ['shopping_cart.write']])]
24+
#[Groups(['shopping_cart.write'])]
25+
// We do not want to set the group on `cartItems` because it will lead to a circular reference
26+
#[ApiProperty(serialize: new Groups(['cart_item.write']), property: 'user_identifier')]
27+
#[ApiProperty(serialize: new Groups(['cart_item.write']), property: 'status')]
28+
class ShoppingCart extends Model
29+
{
30+
use HasFactory;
31+
32+
protected $fillable = [
33+
'user_identifier',
34+
'status',
35+
];
36+
37+
public function cartItems(): HasMany
38+
{
39+
return $this->hasMany(CartItem::class);
40+
}
41+
}

0 commit comments

Comments
 (0)