Skip to content

Commit cdda414

Browse files
authored
fix(laravel): Allow LinksHandler to handle polymorphic relationships (#7231)
1 parent bee5cec commit cdda414

File tree

7 files changed

+210
-24
lines changed

7 files changed

+210
-24
lines changed

src/Laravel/Eloquent/State/LinksHandler.php

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Laravel\Eloquent\State;
1515

1616
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
17+
use ApiPlatform\Metadata\Exception\RuntimeException;
1718
use ApiPlatform\Metadata\GraphQl\Operation;
1819
use ApiPlatform\Metadata\GraphQl\Query;
1920
use ApiPlatform\Metadata\HttpOperation;
@@ -22,6 +23,11 @@
2223
use Illuminate\Contracts\Foundation\Application;
2324
use Illuminate\Database\Eloquent\Builder;
2425
use Illuminate\Database\Eloquent\Model;
26+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
27+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
28+
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
29+
use Illuminate\Database\Eloquent\Relations\MorphTo;
30+
use Illuminate\Database\Eloquent\Relations\Relation;
2531

2632
/**
2733
* @implements LinksHandlerInterface<Model>
@@ -101,34 +107,44 @@ private function buildQuery(Builder $builder, Link $link, mixed $identifier): Bu
101107
}
102108

103109
if ($from = $link->getFromProperty()) {
104-
$relation = $this->application->make($link->getFromClass());
105-
$relationQuery = $relation->{$from}();
106-
if (!method_exists($relationQuery, 'getQualifiedForeignKeyName') && method_exists($relationQuery, 'getQualifiedForeignPivotKeyName')) {
110+
/** @var Model $relatedInstance */
111+
$relatedInstance = $this->application->make($link->getFromClass());
112+
$relatedInstance->setAttribute($relatedInstance->getKeyName(), $identifier);
113+
$relatedInstance->exists = true;
114+
115+
/** @var Relation<Model, Model, mixed> $relation */
116+
$relation = $relatedInstance->{$from}();
117+
118+
if ($relation instanceof MorphTo) {
119+
throw new RuntimeException('Cannot query directly from a MorphTo relationship.');
120+
}
121+
122+
if ($relation instanceof BelongsTo) {
107123
return $builder->getModel()
108124
->join(
109-
$relationQuery->getTable(), // @phpstan-ignore-line
110-
$relationQuery->getQualifiedRelatedPivotKeyName(), // @phpstan-ignore-line
111-
$builder->getModel()->getQualifiedKeyName()
112-
)
113-
->where(
114-
$relationQuery->getQualifiedForeignPivotKeyName(), // @phpstan-ignore-line
125+
$relation->getParent()->getTable(),
126+
$relation->getParent()->getQualifiedKeyName(),
115127
$identifier
116-
)
117-
->select($builder->getModel()->getTable().'.*');
128+
);
118129
}
119130

120-
if (method_exists($relationQuery, 'dissociate')) {
121-
return $builder->getModel()
122-
->join(
123-
$relationQuery->getParent()->getTable(), // @phpstan-ignore-line
124-
$relationQuery->getParent()->getQualifiedKeyName(), // @phpstan-ignore-line
125-
$identifier
126-
);
131+
if ($relation instanceof HasOneOrMany || $relation instanceof BelongsToMany) {
132+
return $relation->getQuery();
133+
}
134+
135+
if (method_exists($relation, 'getQualifiedForeignKeyName')) {
136+
return $relation->getQuery()->where(
137+
$relation->getQualifiedForeignKeyName(),
138+
$identifier
139+
);
127140
}
128141

129-
return $builder->getModel()->where($relationQuery->getQualifiedForeignKeyName(), $identifier);
142+
throw new RuntimeException(\sprintf('Unhandled or unknown relationship type: %s for property %s on %s', $relation::class, $from, $relatedInstance::class));
130143
}
131144

132-
return $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier);
145+
return $builder->where(
146+
$builder->getModel()->qualifyColumn($link->getIdentifiers()[0]),
147+
$identifier
148+
);
133149
}
134150
}

src/Laravel/Tests/EloquentTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
use Illuminate\Support\Str;
2020
use Orchestra\Testbench\Concerns\WithWorkbench;
2121
use Orchestra\Testbench\TestCase;
22+
use Workbench\App\Models\PostWithMorphMany;
2223
use Workbench\Database\Factories\AuthorFactory;
2324
use Workbench\Database\Factories\BookFactory;
25+
use Workbench\Database\Factories\CommentMorphFactory;
2426
use Workbench\Database\Factories\GrandSonFactory;
27+
use Workbench\Database\Factories\PostWithMorphManyFactory;
2528
use Workbench\Database\Factories\WithAccessorFactory;
2629

2730
class EloquentTest extends TestCase
@@ -444,6 +447,17 @@ public function testBelongsTo(): void
444447
$this->assertEquals($json['sons'][0], '/api/grand_sons/1');
445448
}
446449

450+
public function testHasMany(): void
451+
{
452+
GrandSonFactory::new()->count(1)->create();
453+
454+
$res = $this->get('/api/grand_fathers/1/grand_sons', ['Accept' => ['application/ld+json']]);
455+
$json = $res->json();
456+
$this->assertEquals($json['@id'], '/api/grand_fathers/1/grand_sons');
457+
$this->assertEquals($json['totalItems'], 1);
458+
$this->assertEquals($json['member'][0]['@id'], '/api/grand_sons/1');
459+
}
460+
447461
public function testRelationIsHandledOnCreateWithNestedData(): void
448462
{
449463
$cartData = [
@@ -538,4 +552,41 @@ public function testPostWithEmptyMorphMany(): void
538552
'comments' => [['content' => 'hello']],
539553
]);
540554
}
555+
556+
public function testPostCommentsCollectionFromMorphMany(): void
557+
{
558+
PostWithMorphManyFactory::new()->create();
559+
560+
CommentMorphFactory::new()->count(5)->create([
561+
'commentable_id' => 1,
562+
'commentable_type' => PostWithMorphMany::class,
563+
]);
564+
565+
$response = $this->getJson('/api/post_with_morph_manies/1/comments', [
566+
'accept' => 'application/ld+json',
567+
]);
568+
$response->assertStatus(200);
569+
$response->assertJsonCount(5, 'member');
570+
}
571+
572+
public function testPostCommentItemFromMorphMany(): void
573+
{
574+
PostWithMorphManyFactory::new()->create();
575+
576+
CommentMorphFactory::new()->count(5)->create([
577+
'commentable_id' => 1,
578+
'commentable_type' => PostWithMorphMany::class,
579+
])->first();
580+
581+
$response = $this->getJson('/api/post_with_morph_manies/1/comments/1', [
582+
'accept' => 'application/ld+json',
583+
]);
584+
$response->assertStatus(200);
585+
$response->assertJson([
586+
'@context' => '/api/contexts/CommentMorph',
587+
'@id' => '/api/post_with_morph_manies/1/comments/1',
588+
'@type' => 'CommentMorph',
589+
'id' => 1,
590+
]);
591+
}
541592
}

src/Laravel/workbench/app/Models/CommentMorph.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,40 @@
1414
namespace Workbench\App\Models;
1515

1616
use ApiPlatform\Metadata\ApiProperty;
17-
use ApiPlatform\Metadata\NotExposed;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\Link;
1821
use Illuminate\Database\Eloquent\Model;
1922
use Illuminate\Database\Eloquent\Relations\MorphTo;
2023
use Symfony\Component\Serializer\Attribute\Groups;
2124

22-
#[NotExposed]
25+
#[ApiResource(
26+
operations: [
27+
new GetCollection(
28+
uriTemplate: '/post_with_morph_manies/{id}/comments',
29+
uriVariables: [
30+
'id' => new Link(
31+
fromProperty: 'comments',
32+
fromClass: PostWithMorphMany::class,
33+
),
34+
]
35+
),
36+
new Get(
37+
uriTemplate: '/post_with_morph_manies/{postId}/comments/{id}',
38+
uriVariables: [
39+
'postId' => new Link(
40+
fromProperty: 'comments',
41+
fromClass: PostWithMorphMany::class,
42+
),
43+
'id' => new Link(
44+
fromClass: CommentMorph::class,
45+
),
46+
]
47+
),
48+
]
49+
)]
50+
#[ApiProperty(identifier: true, serialize: new Groups(['comments']), property: 'id')]
2351
#[ApiProperty(serialize: new Groups(['comments']), property: 'content')]
2452
class CommentMorph extends Model
2553
{

src/Laravel/workbench/database/factories/CommentFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use Workbench\App\Models\Comment;
1818

1919
/**
20-
* @template TModel of \Workbench\App\Models\Author
20+
* @template TModel of \Workbench\App\Models\Comment
2121
*
2222
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
2323
*/
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\CommentMorph;
18+
19+
/**
20+
* @template TModel of \Workbench\App\Models\CommentMorph
21+
*
22+
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
23+
*/
24+
class CommentMorphFactory extends Factory
25+
{
26+
/**
27+
* The name of the factory's corresponding model.
28+
*
29+
* @var class-string<TModel>
30+
*/
31+
protected $model = CommentMorph::class;
32+
33+
/**
34+
* Define the model's default state.
35+
*
36+
* @return array<string, mixed>
37+
*/
38+
public function definition(): array
39+
{
40+
return [
41+
'commentable_id' => PostWithMorphManyFactory::new(),
42+
'commentable_type' => PostWithMorphManyFactory::class,
43+
'content' => fake()->text(),
44+
];
45+
}
46+
}

src/Laravel/workbench/database/factories/PostFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
use Workbench\App\Models\Post;
1818

1919
/**
20-
* @template TModel of \Workbench\App\Models\Author
20+
* @template TModel of \Workbench\App\Models\Post
2121
*
2222
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
2323
*/
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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\Database\Factories;
15+
16+
use Illuminate\Database\Eloquent\Factories\Factory;
17+
use Workbench\App\Models\PostWithMorphMany;
18+
19+
/**
20+
* @template TModel of \Workbench\App\Models\PostWithMorphMany
21+
*
22+
* @extends \Illuminate\Database\Eloquent\Factories\Factory<TModel>
23+
*/
24+
class PostWithMorphManyFactory extends Factory
25+
{
26+
/**
27+
* The name of the factory's corresponding model.
28+
*
29+
* @var class-string<TModel>
30+
*/
31+
protected $model = PostWithMorphMany::class;
32+
33+
/**
34+
* Define the model's default state.
35+
*
36+
* @return array<string, mixed>
37+
*/
38+
public function definition(): array
39+
{
40+
return [
41+
'title' => fake()->unique()->sentence(10),
42+
'content' => fake()->sentences(10, true),
43+
];
44+
}
45+
}

0 commit comments

Comments
 (0)