From dc53b7544ccef68112a33ccc9e7669bf6c8aed7a Mon Sep 17 00:00:00 2001 From: Jon Erickson Date: Wed, 18 Jun 2025 09:04:23 -0700 Subject: [PATCH 1/2] fix(laravel): Allow `LinksHandler` to handle polymorphic relationships --- src/Laravel/Eloquent/State/LinksHandler.php | 8 ++++ src/Laravel/Tests/EloquentTest.php | 40 ++++++++++++++++ .../workbench/app/Models/CommentMorph.php | 32 ++++++++++++- .../database/factories/CommentFactory.php | 2 +- .../factories/CommentMorphFactory.php | 46 +++++++++++++++++++ .../database/factories/PostFactory.php | 2 +- .../factories/PostWithMorphManyFactory.php | 45 ++++++++++++++++++ 7 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/Laravel/workbench/database/factories/CommentMorphFactory.php create mode 100644 src/Laravel/workbench/database/factories/PostWithMorphManyFactory.php diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index b16f6d676c7..4291b541732 100644 --- a/src/Laravel/Eloquent/State/LinksHandler.php +++ b/src/Laravel/Eloquent/State/LinksHandler.php @@ -22,6 +22,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphOneOrMany; /** * @implements LinksHandlerInterface @@ -103,6 +104,13 @@ private function buildQuery(Builder $builder, Link $link, mixed $identifier): Bu if ($from = $link->getFromProperty()) { $relation = $this->application->make($link->getFromClass()); $relationQuery = $relation->{$from}(); + + if ($relationQuery instanceof MorphOneOrMany) { + return $builder + ->where($relationQuery->getForeignKeyName(), $identifier) + ->where($relationQuery->getMorphType(), $relationQuery->getMorphClass()); + } + if (!method_exists($relationQuery, 'getQualifiedForeignKeyName') && method_exists($relationQuery, 'getQualifiedForeignPivotKeyName')) { return $builder->getModel() ->join( diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index f9b79544b6e..9b03cdc6b42 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -19,9 +19,12 @@ use Illuminate\Support\Str; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use Workbench\App\Models\PostWithMorphMany; use Workbench\Database\Factories\AuthorFactory; use Workbench\Database\Factories\BookFactory; +use Workbench\Database\Factories\CommentMorphFactory; use Workbench\Database\Factories\GrandSonFactory; +use Workbench\Database\Factories\PostWithMorphManyFactory; use Workbench\Database\Factories\WithAccessorFactory; class EloquentTest extends TestCase @@ -538,4 +541,41 @@ public function testPostWithEmptyMorphMany(): void 'comments' => [['content' => 'hello']], ]); } + + public function testPostCommentsCollectionFromMorphMany(): void + { + PostWithMorphManyFactory::new()->create(); + + CommentMorphFactory::new()->count(5)->create([ + 'commentable_id' => 1, + 'commentable_type' => PostWithMorphMany::class, + ]); + + $response = $this->getJson('/api/post_with_morph_manies/1/comments', [ + 'accept' => 'application/ld+json', + ]); + $response->assertStatus(200); + $response->assertJsonCount(5, 'member'); + } + + public function testPostCommentItemFromMorphMany(): void + { + PostWithMorphManyFactory::new()->create(); + + CommentMorphFactory::new()->count(5)->create([ + 'commentable_id' => 1, + 'commentable_type' => PostWithMorphMany::class, + ])->first(); + + $response = $this->getJson('/api/post_with_morph_manies/1/comments/1', [ + 'accept' => 'application/ld+json', + ]); + $response->assertStatus(200); + $response->assertJson([ + '@context' => '/api/contexts/CommentMorph', + '@id' => '/api/post_with_morph_manies/1/comments/1', + '@type' => 'CommentMorph', + 'id' => 1, + ]); + } } diff --git a/src/Laravel/workbench/app/Models/CommentMorph.php b/src/Laravel/workbench/app/Models/CommentMorph.php index ad6cc5cdd6a..136ef98bfb3 100644 --- a/src/Laravel/workbench/app/Models/CommentMorph.php +++ b/src/Laravel/workbench/app/Models/CommentMorph.php @@ -14,12 +14,40 @@ namespace Workbench\App\Models; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Symfony\Component\Serializer\Attribute\Groups; -#[NotExposed] +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: '/post_with_morph_manies/{id}/comments', + uriVariables: [ + 'id' => new Link( + fromProperty: 'comments', + fromClass: PostWithMorphMany::class, + ), + ] + ), + new Get( + uriTemplate: '/post_with_morph_manies/{postId}/comments/{id}', + uriVariables: [ + 'postId' => new Link( + fromProperty: 'comments', + fromClass: PostWithMorphMany::class, + ), + 'id' => new Link( + fromClass: CommentMorph::class, + ), + ] + ), + ] +)] +#[ApiProperty(identifier: true, serialize: new Groups(['comments']), property: 'id')] #[ApiProperty(serialize: new Groups(['comments']), property: 'content')] class CommentMorph extends Model { diff --git a/src/Laravel/workbench/database/factories/CommentFactory.php b/src/Laravel/workbench/database/factories/CommentFactory.php index 1f62ac22143..7fced64f8f1 100644 --- a/src/Laravel/workbench/database/factories/CommentFactory.php +++ b/src/Laravel/workbench/database/factories/CommentFactory.php @@ -17,7 +17,7 @@ use Workbench\App\Models\Comment; /** - * @template TModel of \Workbench\App\Models\Author + * @template TModel of \Workbench\App\Models\Comment * * @extends \Illuminate\Database\Eloquent\Factories\Factory */ diff --git a/src/Laravel/workbench/database/factories/CommentMorphFactory.php b/src/Laravel/workbench/database/factories/CommentMorphFactory.php new file mode 100644 index 00000000000..f23f414de4f --- /dev/null +++ b/src/Laravel/workbench/database/factories/CommentMorphFactory.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Workbench\App\Models\CommentMorph; + +/** + * @template TModel of \Workbench\App\Models\CommentMorph + * + * @extends \Illuminate\Database\Eloquent\Factories\Factory + */ +class CommentMorphFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = CommentMorph::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'commentable_id' => PostWithMorphManyFactory::new(), + 'commentable_type' => PostWithMorphManyFactory::class, + 'content' => fake()->text(), + ]; + } +} diff --git a/src/Laravel/workbench/database/factories/PostFactory.php b/src/Laravel/workbench/database/factories/PostFactory.php index 9c754e8cfd3..0565c7db5f6 100644 --- a/src/Laravel/workbench/database/factories/PostFactory.php +++ b/src/Laravel/workbench/database/factories/PostFactory.php @@ -17,7 +17,7 @@ use Workbench\App\Models\Post; /** - * @template TModel of \Workbench\App\Models\Author + * @template TModel of \Workbench\App\Models\Post * * @extends \Illuminate\Database\Eloquent\Factories\Factory */ diff --git a/src/Laravel/workbench/database/factories/PostWithMorphManyFactory.php b/src/Laravel/workbench/database/factories/PostWithMorphManyFactory.php new file mode 100644 index 00000000000..f7d4b9911ae --- /dev/null +++ b/src/Laravel/workbench/database/factories/PostWithMorphManyFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Workbench\App\Models\PostWithMorphMany; + +/** + * @template TModel of \Workbench\App\Models\PostWithMorphMany + * + * @extends \Illuminate\Database\Eloquent\Factories\Factory + */ +class PostWithMorphManyFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = PostWithMorphMany::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'title' => fake()->unique()->sentence(10), + 'content' => fake()->sentences(10, true), + ]; + } +} From 997fec0887186c1bbc06839e2913031d0c9721d9 Mon Sep 17 00:00:00 2001 From: Jon Erickson Date: Wed, 18 Jun 2025 11:05:23 -0700 Subject: [PATCH 2/2] Use built in eloquent methods --- src/Laravel/Eloquent/State/LinksHandler.php | 58 ++++++++++++--------- src/Laravel/Tests/EloquentTest.php | 11 ++++ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index 4291b541732..771ee4d73fd 100644 --- a/src/Laravel/Eloquent/State/LinksHandler.php +++ b/src/Laravel/Eloquent/State/LinksHandler.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Laravel\Eloquent\State; use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\HttpOperation; @@ -22,7 +23,11 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\MorphOneOrMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasOneOrMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Relation; /** * @implements LinksHandlerInterface @@ -102,41 +107,44 @@ private function buildQuery(Builder $builder, Link $link, mixed $identifier): Bu } if ($from = $link->getFromProperty()) { - $relation = $this->application->make($link->getFromClass()); - $relationQuery = $relation->{$from}(); + /** @var Model $relatedInstance */ + $relatedInstance = $this->application->make($link->getFromClass()); + $relatedInstance->setAttribute($relatedInstance->getKeyName(), $identifier); + $relatedInstance->exists = true; - if ($relationQuery instanceof MorphOneOrMany) { - return $builder - ->where($relationQuery->getForeignKeyName(), $identifier) - ->where($relationQuery->getMorphType(), $relationQuery->getMorphClass()); + /** @var Relation $relation */ + $relation = $relatedInstance->{$from}(); + + if ($relation instanceof MorphTo) { + throw new RuntimeException('Cannot query directly from a MorphTo relationship.'); } - if (!method_exists($relationQuery, 'getQualifiedForeignKeyName') && method_exists($relationQuery, 'getQualifiedForeignPivotKeyName')) { + if ($relation instanceof BelongsTo) { return $builder->getModel() ->join( - $relationQuery->getTable(), // @phpstan-ignore-line - $relationQuery->getQualifiedRelatedPivotKeyName(), // @phpstan-ignore-line - $builder->getModel()->getQualifiedKeyName() - ) - ->where( - $relationQuery->getQualifiedForeignPivotKeyName(), // @phpstan-ignore-line + $relation->getParent()->getTable(), + $relation->getParent()->getQualifiedKeyName(), $identifier - ) - ->select($builder->getModel()->getTable().'.*'); + ); } - if (method_exists($relationQuery, 'dissociate')) { - return $builder->getModel() - ->join( - $relationQuery->getParent()->getTable(), // @phpstan-ignore-line - $relationQuery->getParent()->getQualifiedKeyName(), // @phpstan-ignore-line - $identifier - ); + if ($relation instanceof HasOneOrMany || $relation instanceof BelongsToMany) { + return $relation->getQuery(); + } + + if (method_exists($relation, 'getQualifiedForeignKeyName')) { + return $relation->getQuery()->where( + $relation->getQualifiedForeignKeyName(), + $identifier + ); } - return $builder->getModel()->where($relationQuery->getQualifiedForeignKeyName(), $identifier); + throw new RuntimeException(\sprintf('Unhandled or unknown relationship type: %s for property %s on %s', $relation::class, $from, $relatedInstance::class)); } - return $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier); + return $builder->where( + $builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), + $identifier + ); } } diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 9b03cdc6b42..38beef00b62 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -447,6 +447,17 @@ public function testBelongsTo(): void $this->assertEquals($json['sons'][0], '/api/grand_sons/1'); } + public function testHasMany(): void + { + GrandSonFactory::new()->count(1)->create(); + + $res = $this->get('/api/grand_fathers/1/grand_sons', ['Accept' => ['application/ld+json']]); + $json = $res->json(); + $this->assertEquals($json['@id'], '/api/grand_fathers/1/grand_sons'); + $this->assertEquals($json['totalItems'], 1); + $this->assertEquals($json['member'][0]['@id'], '/api/grand_sons/1'); + } + public function testRelationIsHandledOnCreateWithNestedData(): void { $cartData = [