はじめに
既存のシステムでGraphQLを触っているのですが、スキーマを一から作成した経験がないことと、スキーマと型システムの基礎的な部分の理解が浅いなぁと感じていたので学び直しました。
学習方法としては、具体的な題材を元にスキーマを作っていくと知識が定着しやすかったので、その過程も含め、スキーマと型システムの基礎的な内容を共有しようと思います。
本記事の題材と作成するもの
本記事では、ピザとパスタの2種類の商品を提供するオンラインデリバリーサービスを題材にし、2つの商品情報をCRUDするためのGraphQL APIの仕様(スキーマ)を作っていきます。
前提情報
最初に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が許容されないフィールドであることを意味します。
上記例だと、id
、name
、price
、available
がnullになることがなく、hasGlutenFreeCrust
とisVeganFriendly
は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のコネクションを作ることができます。
なお、接続の向きとしては上図の通りで、この状態だと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!]!
}
これにより、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!]!
}
インターフェース型
を使って共通のフィールドを定義する
インターフェース型は異なる型が共通のフィールドを持つことを保証するための抽象型です。
JavaやC#のインターフェースに似ており、インターフェースで定義されたフィールドは実装側で全て定義しなくてはなりません。
インターフェースの型はinterface
キーワードを使って宣言します。
今回は、Pizza
と Pasta
が同じ「商品」であり、共通するフィールドを持っているためインターフェースにまとめられそうです。
新たにProductInterface
を定義し、Pizza
と Pasta
に実装します。
+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
型を調べることで、Pizza
と Pasta
の両方で利用できるフィールドをすばやく特定することができます。
また、将来的に新しい商品が追加された場合は、その新しい商品でProductInterface
を実装することで共通するフィールドの定義漏れを防ぐことができます。
ユニオン型
で複数の型を1つにまとめる
インターフェースが様々な型に共通するフィールドをまとめるのに対し、ユニオンは様々な型を同じ1つの型にまとめるの役立ちます。
簡単に言うと、ユニオン型は複数の型のいずれか1つを返すことができる型であり、異なる型のオブジェクトを1つのオブジェクトとして扱いたい場合などに非常に便利です。
なお、ユニオンはインターフェースとは異なり、共通のフィールドを持つことを要求しません。
ユニオンの型はunion
キーワードを使って宣言し、パイプ(|
)を使って1つにまとめる型を記述します。
今回は、Pizza
とPasta
のオブジェクト型を同じ商品として取り扱えるようにするため、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!
}
※本来はIngredient
やSupplier
のクエリ/ミューテーションも必要ですが、含めていません。
さいごに
余力があれば、今回作ったスキーマを元にしたリゾルバの実装とクエリ/ミューテーションの具体的な動作について別記事にまとめたいと思います。
参考
-
クエリやミューテーションのスキーマはあくまで定義のみであり、実際のデータ操作は行いません。実際にデータ操作を実行するのがリゾルバというものになります。リゾルバはデータを返す関数であり、リゾルバ内にデータ操作の具体的な処理を別途実装する必要があります。 ↩