🫥

GraphQL Argumentsで「引数を渡さない」と「nullable」の挙動の違いを理解する

2023/03/23に公開

結論

GraphQL Arguments の必須指定には以下3種類がある(GraphQL-Ruby を例に)。

  1. arguments の指定は必須かつ null 不許可(required: :true
  2. arguments の指定は optional(required: :false
  3. arguments の指定は必須だけど null を渡せる(required: :nullable

用途に応じて適切に使い分けましょう。

概要

GraphQL の argument を定義する時に、特に Mutation だと特定のフィールドをオプショナルにしたい時があります。具体的なケースとしては以下のような場合です。

  • Mutation で投げたフィールドだけ更新したい
  • 後方互換性を保ちながらargumentにフィールドを追加したい

これを行うための引数の定義方法が「2. arguments の指定は optional(required: :false)」です。

また一方で、特定のフィールドを必須にはしておきたいが null で更新できるようにしておきたい場合もあります( メモ みたいに空で更新できるようなフィールド)。
これが「3. arguments の指定は必須だけど null を渡せる(required: :nullable)」です。
これは GraphQL の仕様にも Null Value として定義されており、argument そのものを渡さないことと null を渡すことは明確に区別されると書いてあります。

「1. arguments の指定は必須かつ null 不許可(required: :true)」については明確ですね。可能な限りこれで定義したいところです。

2, 3 について、どのような挙動になるのかを GraphQL-Ruby を例に詳しく見てみます。

required: false と required: :nullable の違い

TODOアプリを例に説明します。
このTODOアプリは以下の仕様です。

  • title は記入が必須
  • memo は書いても書かなくても良い

この時、作成時/更新時の mutation と argument を以下のように定義することができます。
ぞれぞれの Input 定義の違いに着目してください。

TODO作成時

title, memo は引数としては必須、だが memo は nullable な形で実装してみます。

CreateTodoInput
module Types
  module InputObject
    class CreateTodoInput < Types::BaseInputObject
      graphql_name 'createTodo'

      # title は必須なので必ず文字列を投げないといけない
      argument :title, String, required: true
      # memo はパラメーターとしては投げないといけないが、中身はnullでもOK
      argument :memo, String, required: :nullable
    end
  end
end
CreateTodo
module Mutations
  class CreateTodo < Mutations::BaseMutation
    argument :create_todo_input, Types::InputObject::CreateTodoInput, required: true

    field :todo, Types::Object::TodoType

    def resolve(create_todo_input:)
      todo = Todo.new
      # create_todo_input には必ず title, memo が入ってくる
      todo.assign_attributes(create_todo_input.to_h)
      if todo.save
        { todo: todo }
      else
        raise GraphQL::ExecutionError, todo.errors.full_messages.join(", ")
      end
    end
  end
end

この時は以下のような引数でリクエストできます。memo は引数としては必須ですが、null で投げられます。

{
  "input": {
    "createTodo": {
      "title": "タイトル",
      "memo": null
    }
  }
}

memo は引数として渡しているので、resolver の input にキーが含まれます。 Mutations::CreateTodo#resolve に binding.pry を仕込んで create_todo_input の値を見てみると、memo がキーとして渡ってきてるのが見れます。

create_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
        title: "新しいタイトル",
        memo: nil,
      }

create_todo_input.has_key?(:memo) # => true

memo は引数として必須なので、以下のような引数で CreateTodo を投げることはできません。

これはエラー
{
  "input": {
    "createTodo": {
      "title": "タイトル",
    }
  }
}

TODO更新時

更新の時は「引数として投げられた field のみを更新する」ことを想定して実装してみます。

UpdateTodoInput
module Types
  module InputObject
    class UpdateTodoInput < Types::BaseInputObject
      graphql_name 'updateTodo'

      argument :id, ID, required: true
      # title も memo もオプショナルなので、引数に含めなくてもOK
      argument :title, String, required: false
      argument :memo, String, required: false
    end
  end
end
UpdateTodo
module Mutations
  class UpdateTodo < Mutations::BaseMutation
    argument :update_todo_input, Types::InputObject::UpdateTodoInput, required: true

    field :todo, Types::Object::TodoType

    def resolve(update_todo_input:)
      todo = Todo.find(update_todo_input.id)

      # update_todo_input に更新したいキーがある場合のみ更新する
      # title は Todo モデルでバリデーションする想定
      todo.title = update_todo_input.title if update_todo_input.key?(:title)
      todo.memo = update_todo_input.memo if update_todo_input.key?(:memo)

      if todo.update
        { todo: todo }
      else
        raise GraphQL::ExecutionError, todo.errors.full_messages.join(", ")
      end
    end
  end
end

この時は、ざっくり以下のパターンで引数を投げることができます。
update_todo_input.to_h の結果も一緒に確認してください。

title, memo 両方を更新する
{
  "input": {
    "createTodo": {
      "id": "c3624f8d-435c-42a5-80e5-74a50d533d5b",
      "title": "新しいタイトル",
      "memo": "メモメモメモ"
    }
  }
}

update_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
        title: "新しいタイトル",
        memo: "メモメモメモ",
      }

create_todo_input.has_key?(:title) # => true
create_todo_input.has_key?(:memo) # => true
title のみを更新する
{
  "input": {
    "createTodo": {
      "id": "c3624f8d-435c-42a5-80e5-74a50d533d5b"
      "title": "新しいタイトル",
    }
  }
}

update_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
        title: "新しいタイトル",
      }

create_todo_input.has_key?(:title) # => true
create_todo_input.has_key?(:memo) # => false
memo のみを更新する
{
  "input": {
    "createTodo": {
      "id": "c3624f8d-435c-42a5-80e5-74a50d533d5b"
      "memo": "メモメモメモ"
    }
  }
}

update_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
        memo: "メモメモメモ",
      }

create_todo_input.has_key?(:title) # => false
create_todo_input.has_key?(:memo) # => true
title は文字列で、memo は null で更新する
{
  "input": {
    "createTodo": {
      "id": "c3624f8d-435c-42a5-80e5-74a50d533d5b"
      "title": "新しいタイトル",
      "memo": null
    }
  }
}

update_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
        title: "新しいタイトル",
        memo: nil,
      }

create_todo_input.has_key?(:title) # => true
create_todo_input.has_key?(:memo) # => true
何も更新しない(実際に使うことはないが、投げることはできる)
{
  "input": {
    "createTodo": {
      "id": "c3624f8d-435c-42a5-80e5-74a50d533d5b"
    }
  }
}

update_todo_input.to_h
# =>  {
        id: "c3624f8d-435c-42a5-80e5-74a50d533d5b",
      }

create_todo_input.has_key?(:title) # => false
create_todo_input.has_key?(:memo) # => false

挙動をまとめると、以下のようになります。

  • required: false では、引数として渡すかどうかもオプショナル
    • 引数として渡していない時は引数のキーそのものがない
  • required: :nullable では、引数として渡すことは必須だが null で渡せる
    • null を渡しても引数のキーは存在する

どう使い分けるか

引数は、必須である方が実装の条件分岐を減らせるので、可能な限り required: true または required: :nullable を使うと良いと思います。引数としては必須にしておきたいが null でも更新可能、という場合に required: :nullable にすると、CreateTodo の例のようにそのままモデルに引数を渡すだけなので便利です。

しかし、新しく field を追加する際に、後方互換性を保つ必要がある場合は required: false で追加することになります。例えばモバイルアプリで GraphQL を使っている場合、バックエンド側で必須の引数を追加してしまうと旧バージョンのアプリで使えなくなってしまいます。

また、上記 UpdateTodo でも示した通り、更新の Mutation では required: false がとても便利です。引数を渡してない or 引数にnullを渡してる が「引数のキーが存在するかで」判定できるので「引数が渡された field のみを更新する」が可能です。
更新の時は引数を必須にしたがゆえに意図しない値を渡して更新してしまった(デフォルト値とか)…が起こりうるので、オプショナルにしておくことで意図しない形での更新を防ぐことができます。

もちろん、いずれの場合でもテストは網羅的に書きましょう!

アルダグラム Tech Blog

Discussion