この記事はなに
SangriaでGraphQLなAPIを実装するにあたって、公式のサンプルだけだと少し足りないためそれの補足というかtips的な記事。
Sangriaの導入にはこちらをどうぞ。
[Scala]Sangriaを使ってGraphQL APIを実装する - Qiita
複数のモデルにアクセスしたい
CtxとValについての補足にも少し書いたが、sangriaではCtxという型パラメータが頻出していてドキュメントでは
A typical example of such a context object is a service or repository object that is able to access a database
と書いてある。
つまりRepositoryやServiceのようなDBへのアクセス可能なオブジェクトが渡されることを期待している。
しかし、サンプルのようにRepositoryが1種類(CharacterRepo)しかないこともないはず。
val Query = ObjectType[CharacterRepo, Unit](
  "Query", fields[CharacterRepo, Unit](
    Field("hero", Character,
      arguments = EpisodeArg :: Nil,
      resolve = ctx ⇒ ctx.ctx.getHero(ctx.argOpt(EpisodeArg))),
    ...
  ))
  
val StarWarsSchema = Schema(Query)
StarWarsSchemaではCharacterRepoをCtxとして要求するので、実行するときには以下のようになる。
Executor.execute(
  SchemaDefinition.StarWarsSchema, 
  queryAst,
  new CharacterRepo,
  ...
)
GraphQLとしてQuery(Mutation)を実行する際にSchemaとnew CharacterRepoを渡していて、他のRepositoryを使えないようになってしまっている。
複数のモデルにアクセスするようなAPIを実装したいときにはこれでは困る。
どうやって複数のRepositoryやDAOを渡すか
手っ取り早い方法が、Ctxとして使いたいRepositoryやDAOを保持するオブジェクトを用意すればよい。
trait container {
  def userRepo: UserRepo
  def characterRepo: CharacterRepo
}
このcontainerをCtxとして使用すればUserRepoとcharacterRepoのどちらにもアクセス出来るようになる。
使うときにはcontainerとして使えばいいだけなので特に難しくない。
val Query = ObjectType[container, Unit](
  "Query", fields[container, Unit](
    Field("hero", Character,
      arguments = EpisodeArg :: Nil,
      resolve = ctx ⇒ ctx.ctx.characterRepo.getHero(ctx.argOpt(EpisodeArg))), // characterRepoを使用
    Field("user", User,
      arguments = UserArg :: Nil,
      resolve = ctx ⇒ ctx.ctx.userRepo.getUser(ctx.argOpt(UserArg))), // userRepoを使用
    ...
  ))
val StarWarsSchema = Schema(Query)
このようにして定義したSchemaに対してGraphQLを実行するには以下のように、containerの実装を注入すればよい。
object containerImpl extends container {
  override val userRepo = new UserRepoImpl()
  override val characterRepo = new CharacterRepoImpl()
}
Executor.execute(
  SchemaDefinition.StarWarsSchema, 
  queryAst,
  containerImpl,
  ...
)
CtxごとにExecutorを作成して合成できるとよいのだが...。
なお以降のサンプルコードではcontainerを使用している。
Fetcherを使った遅延取得
たとえばUserとTodoという2つのオブジェクトがある。
case class User(id: Id,
                name: String,
                email: String,
                createdAt: ZonedDateTime,
                updatedAt: ZonedDateTime)
case class Todo(id: Id,
                title: String,
                description: String,
                userId: Id,
                createdAt: ZonedDateTime,
                updatedAt: ZonedDateTime)
TodoにはuserIdがあり、これを使ってUserを取得したい。
普通に実装するとこのようになる。
lazy val TodoType: ObjectType[container, Todo] = 
  derive.deriveObjectType(
    derive.AddFields(
      Field("user", OptionType(UserType),  // UserTypeはObjectType[Unit, User]のインスタンス
        resolve = { ctx: Context[container, Todo] =>
          // ここでTodo#userIdを使ってDBからUserを取得する
          ctx.ctx.userDao.findById(ctx.value.userId)
        })
    )
  )
このような実装ではいわゆるN+1問題が生じてしまう。
N+1問題への対策はDeferred Value Reslutionで紹介されている。
Deferredを使えばいいらしい。
まずはSeq[Id]からSeq[User]を取得するためのFetcherを実装する。
Fetcher.cachingに(Ctx, Seq[Id]) => Future[Seq[User]]な関数を与えるだけでよい。
val userFetcher: Fetcher[container, User, User, Id] = Fetcher.caching {
  (ctx: container, ids: Seq[Id]) =>
    Future.apply {
      ctx.userDao.findAllByIds(ids)
    }
これを使って先程のTodoTypeを改めて実装するとこうなる。
lazy val TodoType: ObjectType[container, Todo] = 
  derive.deriveObjectType(
    derive.AddFields(
      Field("user", OptionType(UserType),
        resolve = { ctx: Context[container, Todo] =>
          // userFetcherを使ってuserIdからUserを取得する
          userFetcher.defer(ctx.value.userId)
        })
    )
  )
このTodoTypeを使ってQueryやMutationを実装すればよいが、これだけでExecutor.executeを実行すると、
sangria.execution.deferred.UnsupportedDeferError: Deferred resolver is not defined for deferred value ...
というエラーが出てしまう。Deferred resolverが定義されていないというエラー。
sangria/Executor.scalaを見るとDeferredResolver[Ctx]が必要らしいので用意する。
非常に簡単で、DeferredResolver.fetchersに実装したFetcherを渡すだけでよい。
val resolver: DeferredResolver[container] = DeferredResolver.fetchers(userFetcher)
このresolverをExecutor.executeに渡す。
Executor.execute(
  SchemaDefinition.StarWarsSchema, 
  queryAst,
  containerImpl,
  deferredResolver = resolver,
  ...
)
先程まではUserDao.findByIdでつどDBアクセスしていたのが、Fetcherを使うことによって遅延評価(?)されてまとめてUserDao.findAllByIdsでのアクセスとなり、N+1問題が回避出来る。