LoginSignup
1
0

GraphQLのスキーマと型を学び直した

Last updated at Posted at 2024-04-27

はじめに

既存のシステムでGraphQLを触っているのですが、スキーマを一から作成した経験がないことと、スキーマと型システムの基礎的な部分の理解が浅いなぁと感じていたので学び直しました。

学習方法としては、具体的な題材を元にスキーマを作っていくと知識が定着しやすかったので、その過程も含め、スキーマと型システムの基礎的な内容を共有しようと思います。

本記事の題材と作成するもの

本記事では、ピザとパスタの2種類の商品を提供するオンラインデリバリーサービスを題材にし、2つの商品情報をCRUDするためのGraphQL APIの仕様(スキーマ)を作っていきます。

image.png

前提情報

最初にGraphQLのスキーマと型システムについてざっくり説明します。

GraphQLのスキーマとは?

前述の通り、Graphql APIの仕様を定義するものです。
具体的には、GraphQL APIがどのようなデータを持っており、クライアントがどうやってそのデータを操作できるのかを明確にするためのルールを定めた設計書のようなイメージです。

スキーマは以下のような構造となっており、スキーマ定義言語(SDL)を使って記述します。

type Pizza implements Product {
    id: ID!
    name: String!
    price: Float!
    size: String
}

type Pizza {
    id: ID!
    name: String!
    price: Int!
    size: String
    hasGlutenFreeCrust: Boolean
}

type Query {
    products: [Product!]
}

GraphQLの型システムとは?

GraphQLの型システムは、APIを通じてやりとりされるデータの種類や形式を定義するものです。
この型システムによってGraphQL APIのデータは厳密に型付けされ、エラーの少ないデータ交換が可能になります。

詳細は後述しますが、型システムとしては基本的なスカラー型(String, Int, Boolean など)から複雑なオブジェクト型まで、様々なデータ型が存在します。

題材を元にスキーマを作っていく

スキーマと型システムの概要がざっくり理解できたところで、ここからは各型システムの説明を交えつつ、ピザとパスタの商品情報を管理するための具体的なスキーマの作成を進めていきます。

オブジェクト型で具体的なエンティティを定義する

オブジェクト型は、GraphQLで扱う主要なデータ構造の1つであり、データベースに例えると「テーブル」に近しい概念です。

オブジェクト型は「ユーザー」や「商品」などの具体的なエンティティを表すために使用します。

オブジェクト型を定義するには、typeキーワードに続いてオブジェクトの名前を指定し、波括弧で囲みます。

今回は、具体的なエンティティ(オブジェクト)としてピザとパスタがあるのでそれぞれ以下のように定義します。

type Pizza {
}

type Pasta {
}

スカラー型でオブジェクトが持つフィールドのデータ型を定義する

上記で作成したオブジェクトは基本的に属性(フィールド)を持ちます。
データベースに例えると「カラム」に相当するものです。

データベースのカラムにはデータ型を定義することで、どのような種類のデータが格納されるのかを制限できますよね。
例えば、整数型(INT)、文字列型(VARCHAR)、日付型(DATE)などがあります。

GraphQLでは、これに相当するものとして「スカラー型」と呼ばれるものがあります。

GraphQLのスカラー型は以下の5つがあります。

  • 文字列型(String
  • 整数型(Int
  • 浮動小数点数型(Float
  • Boolean型(Boolean
  • 一意な識別子(ID
    • 厳密にいうとIDは文字列ですが、GraphQLでは各オブジェクトのIDが一意であることを保証してくれます。

今回は、以下のフィールドおよび型を定義します。

type Pizza {
+    id: ID!
+    name: String!
+    price: Int!
+    # 在庫があるかどうか
+    available: Boolean!
+    # グルテンフリーの生地が提供されているかどうかを示す
+    hasGlutenFreeCrust: Boolean
}

type Pasta {
+    id: ID!
+    name: String!
+    price: Int!
+    # 在庫があるかどうか
+    available: Boolean!
+    # ビーガン対応の材料で作られているかどうかを示す
+    isVeganFriendly: Boolean
}

エクスクラメーションマーク(!マーク)について

上記のフィールドの型にはID!String!のように、!が付いています。
これはエクスクラメーションマークと呼ばれるものであり、!が付いているものはnullが許容されないフィールドであることを意味します。

上記例だと、idnamepriceavailableがnullになることがなく、hasGlutenFreeCrustisVeganFriendlyはnullになる可能性があります。

カスタムスカラー型で任意のデータ型を作る

多くの場合、GraphQLの組み込みのスカラー型で十分ですが、場合によってスカラー型のみだと対応できないケースがあります。
例えば、日付型やメールアドレス型、URL型などを表現できるようにしたいケースが考えられます。

この場合、「カスタムスカラー型」として任意の型を定義することができます。

カスタムスカラー型はscalarキーワードを使って宣言します。

今回は、オブジェクトが更新された日時を表現するためのDatetimeのカスタムスカラー型を定義し、各オブジェクトのlastupdatedフィールドに割り当てます。

+scalar Datetime

type Pizza {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    hasGlutenFreeCrust: Boolean
+   lastupdated: Datetime!
}

type Pasta {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    isVeganFriendly: Boolean
+   lastupdated: Datetime!
}

Enum型でフィールドが扱う値を事前に定義する

多くのプログラミング言語で使われている列挙をGraphQLでも定義することができます。

GraphQLでEnum型を宣言するには、enumキーワードに続いて列挙の名前を指定します。

今回は、ピザとパスタの注文サイズを定義するEnum型を作り、各オブジェクトのsizeフィールドに割り当てます。


+enum Size {
+    LARGE
+    MEDIUM
+    SMALL
+}

type Pizza {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
+   size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
}

type Pasta {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
+   size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
}

リストでフィールドのコレクションを表す

リストは型の配列です。
1つのフィールドに複数の値やオブジェクトを格納するために使用されます。

リストは型を角括弧[]で囲むことによって表現できます。

今回は、各商品の説明を複数要素格納できるよう、descriptionフィールドが扱う値をString型のリストにしてみます。

type Pizza {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
+   description: [String!]
}

type Pasta {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
+   description: [String!]
}

リストのエクスクラメーションマークについて

上記の例では[String!]としていますが、リストのエクスクラメーションマークはこれ以外にも合計3パターンの表現方法があります。

各パターンにおけるエクスクラメーションマークの組み合わせパターンと、パターンごとの挙動を以下に示します。

渡す値 [String] [String]! [String]! [String!]!
null 有効 有効 無効 無効
[] 有効 有効 有効 有効
["word"] 有効 有効 有効 有効
[null] 有効 無効 有効 無効
["word", null] 有効 無効 有効 無効

一例として[String!]を言葉で説明すると、リスト自体のnullは許容するが、リスト内の要素のnullは許容しないということです。

オブジェクト型の間でコネクションを構築する

GraphQLスキーマでは、異なるオブジェクト同士を接続することができ、これによってリレーションシップ(1対1、1対多など)を構築することができます。
具体的には、後述するエッジやスルー型を利用します。

エッジ

エッジはグラフ理論から来ている概念であり、2つのオブジェクト間の接続を表現するものです。

エッジを説明する上で、まずは新しいオブジェクトとして

  • Ingredient(ピザやパスタの原材料を扱うオブジェクト)
  • Supplier(原材料の生産者を扱うオブジェクト)

の2つをGraphQLスキーマに定義します。

Type Ingredient {
    id: ID!
    name: String!
}

Type Supplier {
    id: ID!
    name: String!
    address: String!
}

1対1のコネクションを作る

GraphQLでIngredientの情報を取得する際に、Ingredientに紐づくSupplierの情報も取得できるようにするにはどうすればいいでしょうか。

1つの原材料は、1人の生産者に紐づいていることを前提にした場合、
以下のようにIngredientオブジェクトのフィールドとしてSupplier型を指定します。

Type Ingredient {
    id: ID!
    name: String!
+   supplier: Supplier!
}

Type Supplier {
    id: ID!
    name: String!
    address: String!
}

これにより、1対1のコネクションを作ることができます。

エッジ.png

なお、接続の向きとしては上図の通りで、この状態だとIngredientからSupplierにアクセスすることはできますが、その逆はできません。

双方向(1対多)のコネクションを作る

逆のケースでSupplierの情報を取得する際、提供しているIngredientも取得できるようにするにはどうすればいいでしょうか。

Supplierは、複数のIngredientを提供していることを前提にした場合、
以下のようにSupplierオブジェクトのフィールドとしてIngredient型を要素に持つリストを指定すると1対多の接続ができます。

Type Ingredient {
    id: ID!
    name: String!
    supplier: Supplier!
}

Type Supplier {
    id: ID!
    name: String!
    address: String!
+   ingredients: [Ingredient!]!
}

エッジ-ページ2のコピー.drawio.png

これにより、Supplierが提供しているすべての原材料を取得することができます。

スルー型

スルー型は2つのオブジェクト間に中間的なデータ構造を設け、それぞれの関連に追加情報を付与することができるものです。

例えば、どのピザにどの原材料がどれくらいの量で使用されているのかを管理したい場合に利用できます。
これを実現するため、今回はスルー型としてRecipeを追加し、原材料とその量をフィールドとして持たせます。

+type Recipe {
+    ingredient: Ingredient!
+    quantity: Float!
+}

type Pizza {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
    description: [String!]
+   Ingredients: [IngredientRecipe!]!
}

type Pasta {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
    description: [String!]
+   Ingredients: [IngredientRecipe!]!
}

Type Ingredient {
    id: ID!
    name: String!
    supplier: Supplier!
}

Type Supplier {
    id: ID!
    name: String!
    address: String!
    ingredients: [Ingredient!]!
}

図にすると以下のようなイメージです。
スルー型.png

インターフェース型を使って共通のフィールドを定義する

インターフェース型は異なる型が共通のフィールドを持つことを保証するための抽象型です。
JavaやC#のインターフェースに似ており、インターフェースで定義されたフィールドは実装側で全て定義しなくてはなりません。

インターフェースの型はinterfaceキーワードを使って宣言します。

今回は、PizzaPasta が同じ「商品」であり、共通するフィールドを持っているためインターフェースにまとめられそうです。
新たにProductInterfaceを定義し、PizzaPasta に実装します。

+interface ProductInterface {
+    id: ID!
+   name: String!
+   price: Int!
+   size: Size!
+   lastupdated: Datetime!
+   description: [String!]
+   Ingredients: [IngredientRecipe!]!
+}

+type Pizza implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

+type Pasta implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

これにより、GraphQLの利用者はProductInterface型を調べることで、PizzaPasta の両方で利用できるフィールドをすばやく特定することができます。

また、将来的に新しい商品が追加された場合は、その新しい商品でProductInterfaceを実装することで共通するフィールドの定義漏れを防ぐことができます。

ユニオン型で複数の型を1つにまとめる

インターフェースが様々な型に共通するフィールドをまとめるのに対し、ユニオンは様々な型を同じ1つの型にまとめるの役立ちます。

簡単に言うと、ユニオン型は複数の型のいずれか1つを返すことができる型であり、異なる型のオブジェクトを1つのオブジェクトとして扱いたい場合などに非常に便利です。
なお、ユニオンはインターフェースとは異なり、共通のフィールドを持つことを要求しません。

ユニオンの型はunionキーワードを使って宣言し、パイプ(|)を使って1つにまとめる型を記述します。

今回は、PizzaPastaのオブジェクト型を同じ商品として取り扱えるようにするため、unionを使って以下のようにまとめます。

union Product = Pizaa | Pasta

さらに、Ingredientオブジェクトのスルー型フィールドとして、上記のユニオン型Productを定義します。

interface ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

type Pizza implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

type Pasta implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

+union Product = Pizaa | Pasta

Type Ingredient {
    id: ID!
    name: String!
    supplier: Supplier!
+   products: [Product!]!
}

上記のように定義することで、同じ原材料が使われている商品(Pizza, Pastaを意識することなく)をすべて取得することができるようになります。

ルート型

ルート型は今まで説明してきた他の型とは少し毛色が異なるものです。

ルート型はGraphQL APIのエントリーポイントとして機能するものであり、GraphQL APIに対して実行可能なオペレーションを定義する型となります。
以下の3つがあります。

  • query
    • GraphQL APIからデータを取得するオペレーション
  • mutation
    • GraphQL APIに対してデータの変更を行うオペレーション
  • subscription(本記事では説明しません)
    • GraphQL APIが保持するデータの変更が行われた際、クライアント側でその情報をリアルタイムに受け取るためのオペレーション

スキーマ内にクエリやミューテーションを定義することにより、GraphQL APIに対してデータ操作が行えるようになります。 1:

クエリでAPIからデータを取得できるようにする

クエリを定義するには、typeキーワードに続いてQueryを定義します。
波括弧内にクエリの名前とコロンの後に戻り値の型を指定します。

例えば、全商品を取得するための単純なクエリを作る場合は以下の通りです。

# products()クエリにリクエストを投げると、ユニオン型のProductsをリストで返す
type Query {
    products: [Products!]!
}

ただ、APIの利用者としては在庫状況や価格でフィルタリングしたい場合があると思います。
この場合は以下のようにクエリパラメータを()内に定義することができます。

type Query {
+    products(available: Boolean, maxPrice: Int, minPrice: Int): [Products!]!
}

input型で渡すパラメータを定義する

今は3つのクエリパラメータで済んでいますが、渡すパラメータが多くなると読みにくく管理しにくいクエリとなってしまいます。
この場合、input型を使ってパラメータを定義することで可読性が向上します。

+input ProductsFilter {
+   maxPrice: Int
+   minPrice: Int
+   available: Boolean = true  # デフォルト値がつけられる
+}

type Query {
+   products(input: ProductsFilter!): [Products!]!   # inputパラメータ型をProductFilterに設定
}

ミューテーションでデータを変更できるようにする

ミューテーションを定義するには、typeキーワードに続いてMutationを定義します。
波括弧内の書き方はクエリと同じです。

今回は、商品を追加・更新・削除するための以下のミューテーションを定義します。

  • addProduct()
  • updateProduct()
  • deleteProduct()

まず、新しい商品を追加するためのaddProduct()は以下のように書きます。

# 登録できる商品をenum型で制限する
enum ProductType {
    pizza
    pasta
}

# Recipeを登録する用のパラメータを定義
input RecipeInput {
    ingredient: ID!
    quantity: Float!
}

type Mutation {
    addProduct(
        name: String,
        price: Int,
        size: Size,
        hasGlutenFreeCrust: Boolean = False,
        isVeganFriendly: Boolean = False,
        description: [String!],
        type: ProductType!,
        Ingredients: [RecipeInput!]!
    ): Product!    # 戻り値
}

ただ、addProduct()が受け取るパラメータの数が多いので、ここでもinput型で渡す値をまとめます。

+input ProductInput {
+    name: String
+    price: Int,
+    size: Size,
+    hasGlutenFreeCrust: Boolean = False,
+    isVeganFriendly: Boolean = False,
+    description: [String!],
+    Ingredients: [RecipeInput!]
+}

type Mutation {
    addProduct(
        type: ProductType!,
+       input: ProductInput!
    ): Product!
}

input型でパラメータをまとめると、同じinput型を別のミューテーションで再利用することができます。これがinput型のもう一つの利点です。

ここでは、input型 ProductInput のパラメータを 更新用のミューテーションupdateProduct() でも利用することにします。

input ProductInput {
    name: String
    price: Int,
    size: Size,
    hasGlutenFreeCrust: Boolean = False,
    isVeganFriendly: Boolean = False,
    description: [String!],
    Ingredients: [RecipeInput!]
}

type Mutation {
    addProduct(
        type: ProductType!,
        input: AddProductInput!
    ): Product!
    
+    updateProduct(id: ID!, input: ProductInput!): Product!
}

削除用のミューテーション deleteProduct() はIDのみ受け取り、返却値としては削除の成功 or 失敗を示すBoolean値にします。

type Mutation {
    addProduct(
        type: ProductType!,
        input: AddProductInput!
    ): Product!
    
    updateProduct(id: ID!, input: ProductInput!): Product!
+   deleteProduct(id: ID!): Boolean!
     
}

本記事で作成したGraphQLスキーマ

schema.graphqls
interface ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

type Pizza implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    hasGlutenFreeCrust: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

type Pasta implements ProductInterface {
    id: ID!
    name: String!
    price: Int!
    available: Boolean!
    size: Size!
    isVeganFriendly: Boolean
    lastupdated: Datetime!
    description: [String!]
    Ingredients: [IngredientRecipe!]!
}

union Product = Pizaa | Pasta

Type Ingredient {
    id: ID!
    name: String!
    supplier: Supplier!
    products: [Product!]!
}

Type Supplier {
    id: ID!
    name: String!
    address: String!
    ingredients: [Ingredient!]!
}

input ProductsFilter {
   maxPrice: Int
   minPrice: Int
   available: Boolean = true
}

type Query {
   products(input: ProductsFilter!): [Products!]!
}

input ProductInput {
    name: String
    price: Int,
    size: Size,
    hasGlutenFreeCrust: Boolean = False,
    isVeganFriendly: Boolean = False,
    description: [String!],
    Ingredients: [RecipeInput!]
}

type Mutation {
    addProduct(
        type: ProductType!,
        input: AddProductInput!
    ): Product!
    
    updateProduct(id: ID!, input: ProductInput!): Product!
    deleteProduct(id: ID!): Boolean!
}

※本来はIngredientSupplierのクエリ/ミューテーションも必要ですが、含めていません。

さいごに

余力があれば、今回作ったスキーマを元にしたリゾルバの実装とクエリ/ミューテーションの具体的な動作について別記事にまとめたいと思います。

参考

  1. クエリやミューテーションのスキーマはあくまで定義のみであり、実際のデータ操作は行いません。実際にデータ操作を実行するのがリゾルバというものになります。リゾルバはデータを返す関数であり、リゾルバ内にデータ操作の具体的な処理を別途実装する必要があります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0