diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index b16f6d676c7..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,6 +23,11 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +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 @@ -101,34 +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}(); - if (!method_exists($relationQuery, 'getQualifiedForeignKeyName') && method_exists($relationQuery, 'getQualifiedForeignPivotKeyName')) { + /** @var Model $relatedInstance */ + $relatedInstance = $this->application->make($link->getFromClass()); + $relatedInstance->setAttribute($relatedInstance->getKeyName(), $identifier); + $relatedInstance->exists = true; + + /** @var Relation $relation */ + $relation = $relatedInstance->{$from}(); + + if ($relation instanceof MorphTo) { + throw new RuntimeException('Cannot query directly from a MorphTo relationship.'); + } + + 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 f9b79544b6e..38beef00b62 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 @@ -444,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 = [ @@ -538,4 +552,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), + ]; + } +}