前提
子オブジェクトという呼び方が正しいのか怪しいですが、今回は以下のようなオブジェクトがあった場合、postsをauthorの子オブジェクトと呼んでいます。
author {
id
posts {
id
}
}
このような構造にする場合、NestJSでは
- ObjectTypeデコレータを使用したクラスのpropertyとして定義する
- ResolveFieldデコレータを使用したメソッドをresolverに定義する
- 上記両方を併用する
という選択肢があります。この記事ではこれらにどのような違いがあるのか検証したいと思います。
https://docs.nestjs.com/graphql/quick-start にある設定は済ませてあり、schema.gql に GraphQL 用のschemaファイルが出力できるようになっている前提で進めます。
ObjectTypeでのオブジェクト定義
@ObjectType()
export class Author {
@Field(() => Int)
id: number;
@Field(() => [Post])
posts: Post[];
}
@ObjectType()
export class Post {
@Field(() => Int)
id: number;
@Field(() => Author)
auther: Author;
}
import { Query, Resolver } from "@nestjs/graphql";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly authorsService: AuthorsService) {}
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
}
この結果、schema.gql には以下のように出力されます。
type Author {
id: Int!
posts: [Post!]!
}
まあこれは公式のドキュメント通りにやっているので、別に特別なことはないです。
ただ、ここで注意しなければいけないのは、 posts
は必須フィールドなので、resolverの author
メソッドの this.authorsService.findOneById
の処理内で、author
に紐づく posts
も含めなければ、posts
を取得する際にエラーになる点です。
「いやいや…それならObject定義にそう書いてるんだから別に findOneById
メソッド内で join
でも何でもして author
に紐づく posts
も返せば良いじゃん」となるかもしれません。
そのような処理にした場合、将来他の処理で 「posts
は要らないけどidを基に単一の author
レコードを取得したいなあ」と なったときに、findOneById
で要らない posts
まで取得することになるので、無駄でしかありません。
もちろん、用途ごとに取得用のメソッドを定義するというのであれば回避できますが、実際の開発現場でそんな実装方針になることは殆どないと思います。というかなっていないと信じたい。
また、そもそもGraphQLなので、リクエスト時に posts
を指定しなければ通信に含めないことは可能です。ただ、この場合もこの書き方だと内部的には posts
のレコード取得自体は行われるので、無駄にDBからレコードを取得したことになります。
ReolveFieldでのオブジェクト定義
https://docs.nestjs.com/graphql/resolvers#code-first-resolver
次は ResolveField
というデコレータを使って posts
を取得してみたいと思います。
@ObjectType()
export class Author {
@Field(() => Int)
id: number;
//model から `posts` propertyを消します。
}
@ObjectType()
export class Post {
@Field(() => Int)
id: number;
//今回の説明に直接は関係ないですが、author propertyも消して問題ないです。
}
import { Parent, Query, Resolver、ResolveField } from "@nestjs/graphql";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly authorsService: AuthorsService) {}
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
// 本筋には関係ないですが、何も考えないでfindAllの処理を書くとN+1が起きるので注意
@ResolveField(() => [Post])
async posts(@Parent() { id }: Author) {
return this.postsService.findAll({ authorId: id });
}
}
この結果、schema.gql には以下のように出力されます。
type Author {
id: Int!
posts: [Post!]!
}
はい。schema.gqlの出力はObjectTypeの時と一緒です。
「おいおい、じゃあ何が変わったって言うんだい?勘弁してくれよboy」と誰もが感じると思います(?)
ResolveFieldの場合、Authorの子オブジェクトとして posts
を指定したら、resolverに定義した posts
メソッドが呼ばれます。
要するに、ObjectTypeの propertyとして posts
を定義していた場合とは異なり、 authors
メソッドのthis.authorsService.findOneById
の返り値に posts
を含めなくて良くなります。
これにより、データ取得時に posts
を指定した時のみDBから posts
レコードを取得することが可能になります。
※ちなみにQueryとして定義しているわけではないので、Authorを経由せずにposts
のみ取得しようと思ってもできません。
// OK
author {
posts {
id
}
}
// NG
posts {
id
}
ResolveFieldを使用してauthor一覧で関連するpostsも取得したいとなった場合、何も考えないで実装すると、authorごとにpostsメソッドが呼び出されN+1問題が発生します
お遊び
「ちょ待てよ!ObjectType でも ResolveFiled でもオブジェクトを定義できるのであれば、両方で定義したらどうなるんや兄ちゃん?」と思うことでしょう。
結論から言うと、どうやらshcema.gqlへの反映はObjectTypeが優先っぽいのと、型定義が一致している場合はちゃんとResolveFieldで定義したメソッドが呼び出されるようです。
propertyとResolveFieldの型が違う場合
ObjectTypeでの定義を適当にしてみる
@ObjectType()
export class Author {
@Field(() => Int)
id: number;
// 適当な型にしてみる
@Field(() => String)
posts: Post[];
}
import { Parent, Query, Resolver、ResolveField } from "@nestjs/graphql";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly authorsService: AuthorsService) {}
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField(() => [Post])
async posts(@Parent() { id }: Author) {
return this.postsService.findAll({ authorId: id });
}
}
type Author {
id: Int!
// 変わった
posts: String!
}
postsを指定するとエラー。レコードの取得処理は走る。
ResolveFieldでの定義を適当にしてみる
@ObjectType()
export class Author {
@Field(() => Int)
id: number;
@Field(() => [Post])
posts: Post[];
}
import { Parent, Query, Resolver、ResolveField } from "@nestjs/graphql";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly authorsService: AuthorsService) {}
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
// 適当な型にしてみる
@ResolveField(() => String)
async posts(@Parent() { id }: Author) {
return this.postsService.findAll({ authorId: id });
}
}
type Author {
id: Int!
// 変わらない
posts: [Post!]!
}
postsを指定してもデータの取得はできないが、エラーにもならず空配列が返る。レコードの取得処理は走る。
propertyとResolveFieldの型が同じ場合
@ObjectType()
export class Author {
@Field(() => Int)
id: number;
@Field(() => [Post])
posts: Post[];
}
import { Parent, Query, Resolver、ResolveField } from "@nestjs/graphql";
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly authorsService: AuthorsService) {}
@Query(() => Author)
async author(@Args('id', { type: () => Int }) id: number) {
return this.authorsService.findOneById(id);
}
@ResolveField(() => [Post])
async posts(@Parent() { id }: Author) {
return this.postsService.findAll({ authorId: id });
}
}
postsを指定してデータが取得できる
結論
基本的に子オブジェクトはResolveFiledで定義するのが良いと思います。実際に私は今プロジェクトでもResolveFieldで全部定義していて、今のところはObjectTypeのpropertyとして定義したことがないです。
NestJS楽だから流行ると良いですね(?)需要がありそうだったらもっと記事書こうかな。