最近、新しくgraphql-ruby(v1.12.3)を使ったRailsアプリケーションを作ったら、下記のようなMutationのBaseクラスが生成されるようになっていました。
module Types
class BaseMutation < GraphQL::Schema::RelayClassicMutation
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
end
end
私が初めてgraphql-rubyを使った時(v1.9系)はBaseMutationが生成されなかったため、GraphQL::Schema::Mutation
を継承してMutationクラスを作っていたのですが、どうやら今はGraphQL::Schema::RelayClassicMutation
を継承するようです。
(この記事を書くために過去のコードを辿ってみましたが、私が気づいていなかっただけでv1.9の頃からGraphQL::Schema::RelayClassicMutation
はあったようです)
ドキュメントにも下記のように書かれれており、どうやらGraphQL::Schema::RelayClassicMutation
を使った方が良さそうな感じです。
GraphQL::Schema::Mutation, a bare-bones base class
GraphQL::Schema::RelayClassicMutation, a base class with a set of nice conventions that also supports the Relay Classic mutation specification.
GraphQL::Schema::RelayClassicMutation
を使ったらInputObjectが生成されるようになった
GraphQL::Schema::RelayClassicMutation
を使ってみたところ、InputObjectが勝手に生成されるようになりました。
文章で書いてもわかりづらいと思うので、実際にGraphQL::Schema::RelayClassicMutation
を継承したMutationと、GraphQL::Schema::Mutation
を継承したMutationでパラメーターがどう変わるのか確認してみます。
両方ともinput / outputや処理内容は一緒です。
-
GraphQL::Schema::RelayClassicMutation
を継承したMutation
# app/graphql/mutations/base_new_mutation.rb
module Mutations
class BaseNewMutation < GraphQL::Schema::RelayClassicMutation
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
end
end
# app/graphql/mutations/create_new_user.rb
module Mutations
class CreateNewUser < BaseNewMutation
null false
argument :name, String, required: true
argument :email, String, required: true
field :user, Types::UserType, null: false
def resolve(name:, email:)
{ user: User.create!( name: name, email: email) }
end
end
end
-
GraphQL::Schema::Mutation
を継承したMutation
# app/graphql/mutations/base_mutation.rb
module Mutations
class BaseMutation < GraphQL::Schema::Mutation
end
end
# app/graphql/mutations/create_old_user.rb
module Mutations
class CreateOldUser < BaseMutation
null false
argument :name, String, required: true
argument :email, String, required: true
field :user, Types::UserType, null: false
def resolve(name:, email:)
{ user: User.create!( name: name, email: email) }
end
end
end
それぞれのschemaを確認してみます。
createOldUserのパラメーターはCreateOldUserクラスにargumentで定義した通りemailとnameを受け取るようになっていますが、createNewUserのパラメーターはCreateNewUserInputという明示的には定義していないInputObjectになっています。
CreateNewUserInputの項目としてemailとnameを受け取れるようになっています。
(clientMutationIdという項目も追加されていますが、今回の本題とズレるため説明は省きます)
type Mutation {
createNewUser(
"""
Parameters for CreateNewUser
"""
input: CreateNewUserInput!
): CreateNewUserPayload!
createOldUser(email: String!, name: String!): CreateOldUserPayload!
}
"""
Autogenerated input type of CreateNewUser
"""
input CreateNewUserInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
email: String!
name: String!
}
type CreateNewUserPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
user: User!
}
type User {
email: String!
id: ID!
name: String!
}
考察
最初この挙動に気づいた時は、勝手にInputObjectを生成されてしまうことや1階層パラメーターが深くなってしまうことに拒否反応を起こしていたのですが、テンプレートの継承元がGraphQL::Schema::RelayClassicMutation
になっており、こちらを使うのがデファクトスタンダードなのかと思い色々調べてみました。
色々調べた結果、一番参考になったのは下記のサイトでした。
https://www.apollographql.com/blog/designing-graphql-mutations-e09de826ed97/
詳細はURL先を参照していただくとして、要約すると「input objectを使った方がクライアントサイドが使いやすい。ネストは長所だ。」と記載されています(なかりざっくりした要約ですみません。とても良い記事なので気になる方は読んでみてください)
また、graphql.orgのドキュメントでもinput
というパラメーターが使われており、input typesを使用することを勧めています。
(こちらは共通化の観点で書かれているので上記と論点は違いますが)
https://graphql.org/graphql-js/mutations-and-input-types/
A common example is that creating an object in a database and updating an object in a database often take the same parameters.
To make your schema simpler, you can use “input types” for this, by using the input keyword instead of the type keyword.
具体的に2つのcreateUserを使用するときのクエリーを比較してみます。
上記例ではパラメーターがnameとemailの2つしかありませんでしたが、違いがわかりやすいように追加で5項目(col1〜col5)あると仮定します。
- createNewUserの場合
mutation CreateUser($CreateNewUserInput:CreateNewUserInput!) {
createNewUser(input: $CreateNewUserInput) {
user {
id
name
email
}
}
}
{
"CreateNewUserInput": {
"name": "name1",
"email": "email1@example.com",
"col1": "col1",
"col2": "col2",
"col3": "col3",
"col4": "col4",
"col5": "col5"
}
}
- createOldUserの場合
mutation CreateUser($name: String!, $email: String!, $col1: String!, $col2: String!, $col3: String!, $col4: String!, $col5: String!) {
createOldUser(name: $name, email: $email, col1: $col1, col2: $col2, col3: $col3, col4: $col4, col5: $col5) {
user {
id
name
email
}
}
}
{
"name": "name2",
"email": "email2@example.com"
"col1": "col1",
"col2": "col2",
"col3": "col3",
"col4": "col4",
"col5": "col5"
}
variablesはどちらでもほぼ同じですが、クエリーは前者の方がシンプルに書けることがわかると思います。
項目が増えれば増えるほどこの差は顕著になります。
この違いは実際にフロント側(GraphQLを呼び出す側)を実装しないと気づきづらい点だと思います。
今回の件、私もバックエンドの視点で見ていたときは記事に書いた通り予期せぬInputObjectが生成されて煩わしいと感じました。
実際にバックエンド側はどちらで書いてもメリットはほとんどありません。
ただ、フロントエンドの視点で実際に使ってみると、使い勝手に大きな違いがあると気づきました。
近年、フロントエンドとバックエンドの分業が進んでいますが、インターフェースのような両者の結節点は双方の観点で見る必要があると再認識するよい機会になりました。