diff --git a/src/Support/SelectFields.php b/src/Support/SelectFields.php index 661cb647..98a6dde4 100644 --- a/src/Support/SelectFields.php +++ b/src/Support/SelectFields.php @@ -207,14 +207,29 @@ protected static function handleFields( static::addAlwaysFields($fieldObject, $field, $parentTable, true); - $with[$relationsKey] = static::getSelectableFieldsAndRelations( - $queryArgs, - $field, - $newParentType, - $customQuery, - false, - $ctx - ); + // Check if this is a MorphTo relation + if (is_a($relation, MorphTo::class)) { + // For MorphTo relations, we need to handle them specially + // because they can have different models, and we need to eager load based on the query + static::handleMorphToRelation( + $queryArgs, + $field, + $with, + $ctx, + $fieldObject, + $key, + $customQuery + ); + } else { + $with[$relationsKey] = static::getSelectableFieldsAndRelations( + $queryArgs, + $field, + $newParentType, + $customQuery, + false, + $ctx + ); + } } elseif (is_a($parentTypeUnwrapped, \GraphQL\Type\Definition\InterfaceType::class)) { static::handleInterfaceFields( $queryArgs, @@ -518,6 +533,65 @@ function (GraphqlType $type) use ($query) { }; } + /** + * Handle MorphTo relations + * @param mixed $ctx + */ + protected static function handleMorphToRelation( + array $queryArgs, + array $field, + array &$with, + $ctx, + FieldDefinition $fieldObject, + string $key, + ?Closure $customQuery + ): void { + $relationsKey = Arr::get($fieldObject->config, 'alias', $key); + + /* @var GraphqlType $fieldType */ + $fieldType = $fieldObject->config['type']; + + if ($fieldType instanceof WrappingType) { + $fieldType = $fieldType->getInnermostType(); + } + + /** @var UnionType $union */ + $union = $fieldType; + + $relationNames = (isset($union->config['relationName']) && \is_callable($union->config['relationName'])) + ? $union->config['relationName']() + : null; + $with[$relationsKey] = function (MorphTo $relation) use ($queryArgs, $field, $union, $relationNames, $customQuery, $ctx): void { + $morphRelation = []; + + foreach ($union->getTypes() as $unionType) { + // Get the model class name for the morph type + if (isset($unionType->config['model'])) { + $modelClass = $unionType->config['model']; + } else { + // Fallback to type name if no model is configured + $modelClass = $relationNames[$unionType->name()] ?? $unionType->name(); + } + + /** @var callable $callable */ + $callable = static::getSelectableFieldsAndRelations( + $queryArgs, + $field, + $unionType, + $customQuery, + false, + $ctx + ); + + $morphRelation[$modelClass] = $callable; + } + + if (!empty($morphRelation)) { + $relation->constrain($morphRelation); + } + }; + } + public function getSelect(): array { return $this->select; diff --git a/src/Support/UnionType.php b/src/Support/UnionType.php index 7f13fe8b..955c1ab8 100644 --- a/src/Support/UnionType.php +++ b/src/Support/UnionType.php @@ -30,6 +30,10 @@ public function getAttributes(): array $attributes['resolveType'] = [$this, 'resolveType']; } + if (method_exists($this, 'relationName')) { + $attributes['relationName'] = [$this, 'relationName']; + } + return $attributes; } diff --git a/tests/Database/SelectFields/UnionMorphTests/CommentType.php b/tests/Database/SelectFields/UnionMorphTests/CommentType.php new file mode 100644 index 00000000..80f96698 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/CommentType.php @@ -0,0 +1,38 @@ + 'Comment', + 'model' => Comment::class, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + ], + 'title' => [ + 'type' => Type::nonNull(Type::string()), + ], + 'body' => [ + 'type' => Type::string(), + ], + 'file' => [ + 'type' => GraphQL::type('File'), + ], + 'commentable' => [ + 'type' => GraphQL::type('CommentableUnion'), + ], + ]; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/CommentableUnionType.php b/tests/Database/SelectFields/UnionMorphTests/CommentableUnionType.php new file mode 100644 index 00000000..ae3eb3c2 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/CommentableUnionType.php @@ -0,0 +1,49 @@ + 'CommentableUnion', + ]; + + public function types(): array + { + return [ + GraphQL::type('Post'), + GraphQL::type('Product'), + ]; + } + + public function relationName(): array + { + return [ + Post::class => 'post', + Product::class => 'product', + ]; + } + + /** + * @param object $value + */ + public function resolveType($value): ?GraphqlType + { + if ($value instanceof Post) { + return GraphQL::type('Post'); + } + + if ($value instanceof Product) { + return GraphQL::type('Product'); + } + + return null; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/CommentsQuery.php b/tests/Database/SelectFields/UnionMorphTests/CommentsQuery.php new file mode 100644 index 00000000..70c97520 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/CommentsQuery.php @@ -0,0 +1,38 @@ + 'comments', + ]; + + public function type(): Type + { + return Type::listOf(GraphQL::type('Comment')); + } + + public function resolve($root, $args, $context, ResolveInfo $info, Closure $getSelectFields) + { + /** @var SelectFields $selectFields */ + $selectFields = $getSelectFields(); + + /** @var Comment[] $comments */ + $comments = Comment::query() + ->select($selectFields->getSelect()) + ->with($selectFields->getRelations()) + ->get(); + + return $comments; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/FileType.php b/tests/Database/SelectFields/UnionMorphTests/FileType.php new file mode 100644 index 00000000..0cab9b86 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/FileType.php @@ -0,0 +1,35 @@ + 'File', + 'model' => File::class, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + ], + 'name' => [ + 'type' => Type::string(), + ], + 'path' => [ + 'type' => Type::string(), + ], + 'folder' => [ + 'type' => GraphQL::type('Folder'), + ], + ]; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/FolderType.php b/tests/Database/SelectFields/UnionMorphTests/FolderType.php new file mode 100644 index 00000000..aefdd7f3 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/FolderType.php @@ -0,0 +1,28 @@ + 'Folder', + 'model' => Folder::class, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + ], + 'name' => [ + 'type' => Type::string(), + ], + ]; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/PostType.php b/tests/Database/SelectFields/UnionMorphTests/PostType.php new file mode 100644 index 00000000..57df053d --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/PostType.php @@ -0,0 +1,32 @@ + 'Post', + 'model' => Post::class, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + ], + 'title' => [ + 'type' => Type::string(), + ], + 'file' => [ + 'type' => GraphQL::type('File'), + ], + ]; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/ProductType.php b/tests/Database/SelectFields/UnionMorphTests/ProductType.php new file mode 100644 index 00000000..d04bd336 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/ProductType.php @@ -0,0 +1,35 @@ + 'Product', + 'model' => Product::class, + ]; + + public function fields(): array + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + ], + 'name' => [ + 'type' => Type::string(), + ], + 'price' => [ + 'type' => Type::float(), + ], + 'file' => [ + 'type' => GraphQL::type('File'), + ], + ]; + } +} diff --git a/tests/Database/SelectFields/UnionMorphTests/UnionMorphTest.php b/tests/Database/SelectFields/UnionMorphTests/UnionMorphTest.php new file mode 100644 index 00000000..1f154ed4 --- /dev/null +++ b/tests/Database/SelectFields/UnionMorphTests/UnionMorphTest.php @@ -0,0 +1,236 @@ +set('graphql.schemas.default', [ + 'query' => [ + CommentsQuery::class, + ], + ]); + + $app['config']->set('graphql.types', [ + FileType::class, + FolderType::class, + PostType::class, + ProductType::class, + CommentableUnionType::class, + CommentType::class, + ]); + + Model::preventLazyLoading(); + } + + public function testUnionMorphEagerLoading(): void + { + // Create 2 products with different files and product folder + /** @var Folder $productFolder */ + $productFolder = Folder::factory()->create(['name' => 'product']); + + /** @var File $productFile1 */ + $productFile1 = File::factory()->create(['folder_id' => $productFolder->id, 'name' => 'product_file_1.pdf']); + + /** @var File $productFile2 */ + $productFile2 = File::factory()->create(['folder_id' => $productFolder->id, 'name' => 'product_file_2.jpg']); + + /** @var Product $product1 */ + $product1 = Product::factory()->create(['file_id' => $productFile1->id, 'name' => 'Product 1']); + + /** @var Product $product2 */ + $product2 = Product::factory()->create(['file_id' => $productFile2->id, 'name' => 'Product 2']); + + // Create 2 posts with different files and post folder + /** @var User $user */ + $user = User::factory()->create(); + + /** @var Folder $postFolder */ + $postFolder = Folder::factory()->create(['name' => 'post']); + + /** @var File $postFile1 */ + $postFile1 = File::factory()->create(['folder_id' => $postFolder->id, 'name' => 'post_file_1.docx']); + + /** @var File $postFile2 */ + $postFile2 = File::factory()->create(['folder_id' => $postFolder->id, 'name' => 'post_file_2.png']); + + /** @var Post $post1 */ + $post1 = Post::factory()->create(['user_id' => $user->id, 'file_id' => $postFile1->id, 'title' => 'Post 1']); + + /** @var Post $post2 */ + $post2 = Post::factory()->create(['user_id' => $user->id, 'file_id' => $postFile2->id, 'title' => 'Post 2']); + + // Create comments for all products and posts using morphable relationships + /** @var Comment $product1Comment */ + $product1Comment = Comment::factory()->create(); + $product1->commentableComments()->save($product1Comment); + + /** @var Comment $product2Comment */ + $product2Comment = Comment::factory()->create(); + $product2->commentableComments()->save($product2Comment); + + /** @var Comment $post1Comment */ + $post1Comment = Comment::factory()->create(); + $post1->commentableComments()->save($post1Comment); + + /** @var Comment $post2Comment */ + $post2Comment = Comment::factory()->create(); + $post2->commentableComments()->save($post2Comment); + + $query = +/** @lang GraphQL */ <<<'GRAQPHQL' +{ + comments { + id + title + body + commentable { + ... on Post { + id + title + file { + id + name + path + folder { + id + name + } + } + } + ... on Product { + id + name + price + file { + id + name + path + folder { + id + name + } + } + } + } + } +} +GRAQPHQL; + + $this->sqlCounterReset(); + + $result = $this->httpGraphql($query); + + $this->assertSqlQueries( + /** @lang SQL */ + <<<'SQL' +select "comments"."id", "comments"."title", "comments"."body", "comments"."commentable_id", "comments"."commentable_type" from "comments"; +select "products"."id", "products"."name", "products"."price", "products"."file_id" from "products" where "products"."id" in (?, ?); +select "files"."id", "files"."name", "files"."path", "files"."folder_id" from "files" where "files"."id" in (?, ?); +select "folders"."id", "folders"."name" from "folders" where "folders"."id" in (?); +select "posts"."id", "posts"."file_id", "posts"."title" from "posts" where "posts"."id" in (?, ?); +select "files"."id", "files"."name", "files"."path", "files"."folder_id" from "files" where "files"."id" in (?, ?); +select "folders"."id", "folders"."name" from "folders" where "folders"."id" in (?); +SQL + ); + + $expectedResult = [ + 'data' => [ + 'comments' => [ + [ + 'id' => (string) $product1Comment->id, + 'title' => $product1Comment->title, + 'body' => $product1Comment->body, + 'commentable' => [ + 'id' => (string) $product1->id, + 'name' => $product1->name, + 'price' => $product1->price, + 'file' => [ + 'id' => (string) $productFile1->id, + 'name' => $productFile1->name, + 'path' => $productFile1->path, + 'folder' => [ + 'id' => (string) $productFolder->id, + 'name' => $productFolder->name, + ], + ], + ], + ], + [ + 'id' => (string) $product2Comment->id, + 'title' => $product2Comment->title, + 'body' => $product2Comment->body, + 'commentable' => [ + 'id' => (string) $product2->id, + 'name' => $product2->name, + 'price' => $product2->price, + 'file' => [ + 'id' => (string) $productFile2->id, + 'name' => $productFile2->name, + 'path' => $productFile2->path, + 'folder' => [ + 'id' => (string) $productFolder->id, + 'name' => $productFolder->name, + ], + ], + ], + ], + [ + 'id' => (string) $post1Comment->id, + 'title' => $post1Comment->title, + 'body' => $post1Comment->body, + 'commentable' => [ + 'id' => (string) $post1->id, + 'title' => $post1->title, + 'file' => [ + 'id' => (string) $postFile1->id, + 'name' => $postFile1->name, + 'path' => $postFile1->path, + 'folder' => [ + 'id' => (string) $postFolder->id, + 'name' => $postFolder->name, + ], + ], + ], + ], + [ + 'id' => (string) $post2Comment->id, + 'title' => $post2Comment->title, + 'body' => $post2Comment->body, + 'commentable' => [ + 'id' => (string) $post2->id, + 'title' => $post2->title, + 'file' => [ + 'id' => (string) $postFile2->id, + 'name' => $postFile2->name, + 'path' => $postFile2->path, + 'folder' => [ + 'id' => (string) $postFolder->id, + 'name' => $postFolder->name, + ], + ], + ], + ], + ], + ], + ]; + + self::assertSame($expectedResult, $result); + } +} diff --git a/tests/Support/Models/Comment.php b/tests/Support/Models/Comment.php index ef66191e..ace15198 100644 --- a/tests/Support/Models/Comment.php +++ b/tests/Support/Models/Comment.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Rebing\GraphQL\Tests\Support\database\factories\CommentFactory; /** @@ -17,7 +18,7 @@ * @property string $title * @property string|null $body * @property bool $flag - * @property-read Post $post + * @property-read Post $post * @property-read Collection|Like[] $likes */ class Comment extends Model @@ -34,6 +35,11 @@ public function likes(): MorphMany return $this->morphMany(Like::class, 'likable'); } + public function commentable(): MorphTo + { + return $this->morphTo(); + } + protected static function newFactory(): Factory { return CommentFactory::new(); diff --git a/tests/Support/Models/File.php b/tests/Support/Models/File.php new file mode 100644 index 00000000..3642ed5f --- /dev/null +++ b/tests/Support/Models/File.php @@ -0,0 +1,36 @@ +belongsTo(Folder::class); + } +} diff --git a/tests/Support/Models/Folder.php b/tests/Support/Models/Folder.php new file mode 100644 index 00000000..37a00e75 --- /dev/null +++ b/tests/Support/Models/Folder.php @@ -0,0 +1,32 @@ +hasMany(File::class); + } + + protected static function newFactory(): Factory + { + return FolderFactory::new(); + } +} diff --git a/tests/Support/Models/Post.php b/tests/Support/Models/Post.php index 538823ba..f5e77b62 100644 --- a/tests/Support/Models/Post.php +++ b/tests/Support/Models/Post.php @@ -24,6 +24,7 @@ * @property bool $is_published * @property-read Collection|Comment[] $comments * @property-read Collection|Like[] $likes + * @property-read Collection|File[] $files */ class Post extends Model { @@ -49,6 +50,16 @@ public function comments(): HasMany return $this->hasMany(Comment::class)->orderBy('comments.id'); } + public function commentableComments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function file(): BelongsTo + { + return $this->belongsTo(File::class, 'file_id'); + } + public function likes(): MorphMany { return $this->morphMany(Like::class, 'likable'); diff --git a/tests/Support/Models/Product.php b/tests/Support/Models/Product.php new file mode 100644 index 00000000..9bdb52ec --- /dev/null +++ b/tests/Support/Models/Product.php @@ -0,0 +1,52 @@ +belongsTo(File::class, 'file_id'); + } + + public function commentableComments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable'); + } + + public function getIsPublishedAttribute(): bool + { + $publishedAt = $this->published_at; + + return null !== $publishedAt; + } + + protected static function newFactory(): Factory + { + return ProductFactory::new(); + } +} diff --git a/tests/Support/database/factories/FileFactory.php b/tests/Support/database/factories/FileFactory.php new file mode 100644 index 00000000..86a15851 --- /dev/null +++ b/tests/Support/database/factories/FileFactory.php @@ -0,0 +1,21 @@ + $this->faker->word() . '.txt', + 'path' => '/files/' . $this->faker->word(), + 'folder_id' => null, + ]; + } +} diff --git a/tests/Support/database/factories/FolderFactory.php b/tests/Support/database/factories/FolderFactory.php new file mode 100644 index 00000000..240c5488 --- /dev/null +++ b/tests/Support/database/factories/FolderFactory.php @@ -0,0 +1,19 @@ + $this->faker->word(), + ]; + } +} diff --git a/tests/Support/database/factories/ProductFactory.php b/tests/Support/database/factories/ProductFactory.php new file mode 100644 index 00000000..d70c88d6 --- /dev/null +++ b/tests/Support/database/factories/ProductFactory.php @@ -0,0 +1,23 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + return [ + 'name' => fake()->words(3, true), + 'price' => fake()->randomFloat(2, 10, 1000), + ]; + } +} diff --git a/tests/Support/database/migrations/____comments_table.php b/tests/Support/database/migrations/____comments_table.php index 959f35dc..b1b65c9c 100644 --- a/tests/Support/database/migrations/____comments_table.php +++ b/tests/Support/database/migrations/____comments_table.php @@ -12,7 +12,8 @@ public function up(): void { Schema::create('comments', function (Blueprint $table): void { $table->increments('id'); - $table->integer('post_id'); + $table->integer('post_id')->nullable(); + $table->nullableMorphs('commentable'); $table->string('title'); $table->string('body')->nullable(); $table->boolean('flag')->default('false'); diff --git a/tests/Support/database/migrations/____files_table.php b/tests/Support/database/migrations/____files_table.php new file mode 100644 index 00000000..84fa78e5 --- /dev/null +++ b/tests/Support/database/migrations/____files_table.php @@ -0,0 +1,22 @@ +increments('id'); + $table->unsignedInteger('folder_id')->nullable(); + $table->string('name'); + $table->string('path'); + $table->unsignedInteger('size')->default(0); + $table->timestamps(); + }); + } +} diff --git a/tests/Support/database/migrations/____folders_table.php b/tests/Support/database/migrations/____folders_table.php new file mode 100644 index 00000000..880c8d5e --- /dev/null +++ b/tests/Support/database/migrations/____folders_table.php @@ -0,0 +1,20 @@ +increments('id'); + $table->string(column: 'name'); + $table->unsignedInteger('parent_id')->nullable(); + $table->timestamps(); + }); + } +} diff --git a/tests/Support/database/migrations/____posts_table.php b/tests/Support/database/migrations/____posts_table.php index 8c5ad33d..9a57210c 100644 --- a/tests/Support/database/migrations/____posts_table.php +++ b/tests/Support/database/migrations/____posts_table.php @@ -17,6 +17,7 @@ public function up(): void $table->integer('user_id')->nullable(); $table->text('properties')->nullable(); $table->boolean('flag')->default('false'); + $table->unsignedInteger('file_id')->nullable(); $table->dateTime('published_at')->nullable(); $table->timestamps(); }); diff --git a/tests/Support/database/migrations/____products_table.php b/tests/Support/database/migrations/____products_table.php new file mode 100644 index 00000000..3a846328 --- /dev/null +++ b/tests/Support/database/migrations/____products_table.php @@ -0,0 +1,22 @@ +increments('id'); + $table->string('name'); + $table->decimal('price', 10, 2)->nullable(); + $table->unsignedInteger('file_id')->nullable(); + $table->dateTime(column: 'published_at')->nullable(); + $table->timestamps(); + }); + } +}