graphql-guardとポリシーオブジェクトをgraphql-ruby 1.8で使うための方法

これの続きです。

blog.kymmt.com

問題

graphql-guardはインラインでポリシーを書く方法の他に、ポリシーオブジェクトを定義して GraphQL::Guard.new に渡すという方法があります。使いかたはREADMEに書いてあるとおりです。

class GraphqlPolicy
  RULES = {
    Types::QueryType => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type, field)
  end
end

MySchema = GraphQL::Schema.define do
  query Types::QueryType

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :viewer, !Types::UserType do
    resolver ->(obj, args, ctx) { ctx[:current_user] }
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

で、graphql-ruby 1.8でclass-based APIを使ってスキーマを書き直すと、残念ながらこのポリシーオブジェクトは動かなくなります。

class GraphqlPolicy
  RULES = {
    Types::Query => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

class MySchema < GraphQL::Schema
  query Types::Query

  use GraphQL::Guard.new(policy_object: GraphqlPolicy)
end

class Types::Query < Types::BaseObject
  field :viewer, Types::User, null: false

  def viewer
    context[:current_user]
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされず、
# viewerのresolverの結果がnilになるので、次のエラーになってしまう
# {
#   "data": null,
#   "errors": [
#     {
#       "message": "Cannot return null for non-nullable field Query.viewer"
#     }
#   ]
# }

解決策

上の問題の原因はHash GraphqlPolicy::RULE のキーの等価比較にあります。

graphql-guardではフィールドのinstrumentationを使っており、GraphqlPolicy.guard に渡る type は、graphql-guardの差し込むinstrumentationで取得した、class-based API以前のオブジェクト型を表す GraphQL::ObjectType のインスタンスです。一方、RULE のキーはclass-based APIで導入された GraphQL::Schema::Object のインスタンスなので、クラスがそもそも一致していません。なので、これを GraphQL::ObjectType のような古いほうのクラスのインスタンスに変換するためのメソッド #to_graphql を呼ぶ必要があります。

また、GraphQL::ObjectType では eql? をオーバーライドしていないので、Hashのキーの比較にはオブジェクトIDが使われます。ここで、#to_graphql は内部で GraphQL::ObjectType.new しているので、これで作ったインスタンスと RULE のキーになっているインスタンスはオブジェクトIDが異なってしまいます。よって、キーが一致せず、RULE からguard Procを見つけられないので、望むような動作をしなくなっています。

GraphqlPolicy を次のように定義し直せば動きます。GraphQLの型をキーとするのは諦めて、その型の名前 name をキーとすることで、Hashのキー探索がうまくハマるようにしています。

class GraphqlPolicy
  RULES = {
    Types::Query.to_graphql.name => {
      viewer: ->(obj, args, ctx) { !ctx[:current_user].nil? }
    }
  }

  def self.guard(type, field)
    type.introspection? ? ->(_, _, _) { true } : RULES.dig(type.name, field)
  end
end

# 認証に失敗すると GraphQL::Guard::NotAuthorizedError がraiseされる

雑感

この件はgemのREADMEを改善したほうがよさそう。また、なにかもうちょっといい方法があれば教えてください。

graphql-guardをgraphql-ruby 1.8で使うためのある方法

問題

graphql-ruby 1.8ではclass-based APIが導入されました。フィールド定義は型を表すクラスのコンテキストでクラスメソッド field を呼ぶ形で定義します。

class Types::Query < Types::BaseObject
  field :viewer, Types::User, null: true

  def viewer
    context[:current_user]
  end
end

このときgraphql-guard(今回はv1.1.0)を次のように使おうとすると #<ArgumentError: unknown keyword: guard> になります。

class Types::Query < Types::BaseObject
  field :viewer, Types::User, guard ->(obj, args, ctx) { !ctx[:current_user].nil? }, null: true

  def viewer
    context[:current_user]
  end
end

class-based APIでフィールドを表すクラスが GraphQL::Field から GraphQL::Schema::Field にかわり、従来のように guard をキーとするProcを指定するだけでは、フィールドのメタデータに guard を設定することができなくなったためエラーになっています。

ある解決策

フィールドをカスタマイズして guard をメタデータとして持たせられるようにします。やりかたはこのドキュメントを参照。

GraphQL - Extending the GraphQL-Ruby Type Definition System

class Fields::Guardable < GraphQL::Schema::Field
  def initialize(*args, guard: nil, **kwargs, &block)
    @guard = guard
    super *args, **kwargs, &block
  end

  def to_graphql
    field_defn = super
    field_defn.tap { |d| d.metadata[:guard] = @guard }
  end
end

initialize はこのフィールドを定義するときに guard を設定できるようにしています。また、スキーマ定義が処理される過程で to_graphql が実行されるので、このときにフィールド定義を表す GraphQL::Schema のインスタンスが持つ属性 metadataguard を設定しています。

これで、class-based APIでもフィールド定義時に guard Procが設定できます。guard を定義できるフィールドにするために field_class を明示的に指定します。

class Types::Query < Types::BaseObject
  field_class Fields::Guardable

  field :viewer, Types::User, guard ->(obj, args, ctx) { !ctx[:current_user].nil? }, null: true

  def viewer
    context[:current_user]
  end
end

accepts_definitionを使う[2018-06-10 追記]

accepts_definition を利用すると1.8以前で使っていたオプションをclass-based APIで使うときにgraphql-ruby側でいい感じにハンドリングしてくれます。

GraphQL - Extending the GraphQL-Ruby Type Definition System

なので、Fields::Guardable に対して次のようにすればOKでした。

class Fields::Guardable < GraphQL::Schema::Field
  accepts_definition :guard
end

これを使えば従来どおり guard が使えます。

class Types::Query < Types::BaseObject
  field_class Fields::Guardable

  field :viewer, Types::User, null: true do
    guard ->(obj, args, ctx) { !ctx[:current_user].nil? }
  end

  def viewer
    context[:current_user]
  end
end

内部的には前節の説明に近いことをやっているようです。

移行時に補助的に使うメソッドのようにも見えるので、今後使い続けていいのかがちょっと微妙なところです。

雑感

  • Policyオブジェクトを使うときにどうやるか
  • Fields::Guardable のようなモジュールをgem側で提供できるようにしたほうがよさそうかも

アソシエーションのscopeを使いつつLIMITっぽいことをやる

細かすぎて伝わらないかもしれませんが、ハマったので共有します。

問題

ユーザーが免許を持つ次のようなモデルを考えます。has_one :licenseは免許更新を重ねてきたなかで最新の免許を取得したくて書いたと思ってください。

class User < ApplicationRecord
  has_many :licenses
  has_one :license, -> { order(created_at: :desc).limit(1) }
end

「Userが2件のLicenseを持つ」というセットを3件作って、それぞれのUserの最新のLicenseが取りたいので、licenseをeager loadしてlicenseをそれぞれのUserに対して呼んでみると、次のような結果になります。

User.includes(:license).map(&:license)
#=> [nil,
#    nil,
#    #<License:0x00007fb67bfbbbf0
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

それぞれのUserの最新のLicenseが取りたかったのですが、nilが入っており期待と違う結果になりました。このときにActive Recordが生成するクエリは次のような感じです。licensesテーブルの該当レコードをすべて取得したあとにLIMITしてしまうので、該当レコードすべての中の最新レコード1件しか取れていないようです。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["LIMIT", 1]]

一方、次のようにlicenses(複数形)をincludesすると取得結果は正しくなりますが、ORDER BY DESCを使うクエリとそうではないクエリに分かれてしまい、N+1問題が発生してしまいます。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]
SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 2], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 3], ["LIMIT", 1]]

ある解決策

licensesのscopeでソートしつつ、インスタンスメソッド化したUser#licenseのなかでActiveRecord::Associations::CollectionProxy#takeすると、期待どおり動くようです。

class User < ApplicationRecord
  has_many :licenses, -> { order(created_at: :desc) }

  def license
    licenses.take
  end
end

License取得時はlicenses(複数形)をeager loadします。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

licensesに対してORDER BY DESCするクエリを1回だけ発行して、Ruby側では取得済みレコードに対してtakeでデータを取るので、N+1問題を回避できています。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC  [["user_id", 1], ["user_id", 2], ["user_id", 3]]