graphql-rubyでrange fieldを実装する

はじめに

graphql-rubyを試していて created_ad が 2019-01-01 ~ 2019-05-01 のデータが欲しいとなった時にどう実装するか悩んだのでメモ。

graphql-ruby の使い方自体は特に説明しません。
尚、version 1.8 以降の Class-based API を使用しているので 1.7 以前とは互換性がありません。

テーブル定義

Novel table

column name type
id Integer
title String
release_date DateTime

型定義

module Types
  class NovelType < Types::BaseObject
    field :id, Integer, null: true
    field :title, String, null: true
    field :release_date, Types::DateTimeType, null: true
  end
end

やりたいこと

release_date が 2019-01-01 ~ 2019-05-01 のデータが欲しいケースでこんな感じの query を投げたい。

{
  novels(releaseDate: { from: "2019-01-01", to: "2019-05-01" }) {
    id
    title
    releaseDate
  }
}

実装

内部的には以下のような処理にしたかったので range オブジェクトを作成できる field を定義しました。

Model.where(release_date: (Time.zone.parse('2019-01-01')..Time.zone.parse('2019-05-01')))

Range を表現する InputObject を作成します。
GraphQL::Schema::InputObject はインスタンス変数として@arguments と@context を持っているので それを使って range を作成するメソッドも追加しておきます。

module Types
  class TimeRange < GraphQL::Schema::InputObject
    argument :from, Types::DateTimeType, required: true
    argument :to, Types::DateTimeType, required: true

    def range
      (@arguments['from']..@arguments['to'])
    end
  end
end

デフォルトでは DateTime 型が存在しないので自分で型を定義する必要がありますが、 公式のリポジトリにDateTime の定義があるのでそれを使います。

module Types
  class DateTimeType < GraphQL::Schema::Scalar
    def self.coerce_input(value, _context)
      Time.zone.parse(value)
    end

    def self.coerce_result(value, _context)
        value.utc.iso8601
    end
  end
end

使う側の定義は以下のようになります。

module Types
  class QueryType < GraphQL::Schema::Object
    field :novels, [Types::NovelType], null: true do
      argument :release_date,
               Types::TimeRange,
               required: false,
               prepare: -> (arg, _ctx) {
                 arg.range
               }
    end

    def novels(release_date:)
      Novel.where(release_date: release_date)
    end
  end
end

prepare 内で TimeRange クラスの range をコールして from、to を range オブジェクトに変換してます。
変換処理を prepare で実行する必要があるのが微妙なポイントです。
ドキュメントをもっと読めばいい方法があるかもしれないです…

とはいえ、これで当初目的だったクエリが実行できるようになりました。

まとめ

from のデフォルト値を極端な値(1950-01-01 とか)にしておけば使う側では(..to)みたいな range を表現できるんですがユースケースにもよるので今回は from、to を必須にしてます。

サンプルでは簡略化してますが、検索用の InputObject を定義して filter field でパラメーターを纏めていたり、pagination のためにCursor Connections を採用してるので最終的な query は以下のようになってます。

{
  novels(filter: { releaseDate: { from: "2019-01-01", to: "2019-05-01" } }) {
    pageInfo {
      hasPreviousPage
      hasNextPage
      startCursor
      endCursor
    }
    edges {
      node {
        id
        title
        releaseDate
      }
    }
    totalCount
  }
}

InputObject

module Types
  module InputObject
    class NovelFilter < Types::InputObject::BaseInputObject
      argument :OR, [self], required: false
      argument :id, Integer, required: false
      argument :title, String, required: false
      argument :release_date,
               Types::InputObject::TimeRange,
               required: false,
               prepare: -> (arg, _ctx) {
                 arg.range
               }
    end
  end
end

すごく余談だけど markdown で tabel 表記したらカラム数が少ないと微妙な見た目になってしまう…
調整する気力は無い。