GraphQLにはページネーションを表現する方法としてCursor Connectionsという仕様があります。
この機能を使うことでカーソルベースのページネーションを簡単に実装することができます。
仕様の詳細はGraphQLやRelayのページをご覧ください。
https://graphql.org/learn/pagination/
https://relay.dev/graphql/connections.htm
この機能はとても強力な機能なのですが、私はGraphQLを使い始めたころedgeやnodeの役割を正しく理解できておらず誤った使い方をしていました。
改めて公式ドキュメントを読んだり、その他の様々な情報に触れることで正しいと思われる使い方に気づいたので記事として残しておこうと思います。
※サンプルコードはgraphql-rubyを使ったRubyのコードで記載していますが内容はRubyに依存しないものになっていると思います。
どう間違えたのか
まずはどのように間違っていたのか紹介します。
端的にいうとGraphQLのtypeとデータベースのテーブルを1体1で紐づけて作成していました。
具体例があった方がわかりやすいので下記の例を使って説明します。
例
モデル
下記の2つのモデルがあるとします。
- ユーザー
- 複数の組織に所属できる
- 組織
- 複数のユーザーが所属している
ER図
ユーザーと組織は多対多なので中間テーブル(user_organizations)を使って表現しています。
最初の実装
例えばユーザー情報を返却するクエリーを定義するとします。
type Queryに定義しているuser(userId: Int!): User
がそれです。
当時の私は"type"="テーブル"で実装していたので、UserとUserOrganizationとOrganizationのtypeを作成していました。
connection関連のtypeも加えると下記のようになります。
type Query {
user(userId: Int!): User
}
type User {
id: ID!
name: String!
userOrganizations(
after: String
before: String
first: Int
last: Int
): UserOrganizationConnection!
}
type UserOrganizationConnection {
edges: [UserOrganizationEdge]
nodes: [UserOrganization!]!
pageInfo: PageInfo!
}
type UserOrganizationEdge {
cursor: String!
node: UserOrganization
}
type UserOrganization {
id: ID!
organization: Organization!
user: User!
}
type Organization {
id: ID!
name: String!
userOrganizations(
after: String
before: String
first: Int
last: Int
): UserOrganizationConnection!
}
このクエリーを実行すると下記のようになります。
UserOrganizationsConnectionのnodeはUserOrganization typeとなっており、その中のフィールドの1つとしてOrganizationが入っています。
query User($userId: Int!) {
user(userId: $userId) {
id
name
userOrganizations {
edges {
node {
organization {
name
}
}
}
}
}
}
userId=1のユーザーがorg1, org2, org3の3つの組織に所属している場合、下記のようなレスポンスになります。
{
"data": {
"user": {
"id": "1",
"name": "ham",
"userOrganizations": {
"edges": [
{
"node": {
"organization": {
"name": "org1"
}
}
},
{
"node": {
"organization": {
"name": "org2"
}
}
},
{
"node": {
"organization": {
"name": "org3"
}
}
}
]
}
}
}
}
これでも問題なく動作はするのですが、connectionで使われているnodeやedgeの意味を正しく理解してから改めて考えるとこの使い方は間違いだったと気づきました。
node, edgeとは
Connectionの用語はグラフ理論から来ています。
グラフ理論についての詳細はこの記事では省きますがnodeはグラフ『頂点』、edgeは頂点と頂点を繋ぐ『辺』を意味しています。
下記がグラフのイメージです。
先程の例をグラフで表現すると下記のようになります。
OrganizationがUserOrganizationの1つのfieldとして扱われていて歪な状態になっています。
グラフの意味を踏まえて考えるとUserOrgnizationはUserとOrganizationの紐付け情報なのでedgeとして表現するのがふさわしいということがわかります。
図で表すと下記の通り。先程のグラフよりしっくりくると思います。
これを踏まえて実装をやり直してみます。
やり直した実装
UserExaminationは明示的には定義せず、OrganizationEdgeとUserConnectionがUserとOrganizationを紐づける役割を持っています。
type Query {
user(userId: Int!): User
}
type User {
email: String!
id: ID!
name: String!
organizations(
after: String
before: String
first: Int
last: Int
): OrganizationConnection!
}
type OrganizationConnection {
edges: [OrganizationEdge]
nodes: [Organization!]!
pageInfo: PageInfo!
}
type OrganizationEdge {
cursor: String!
node: Organization
}
type Organization {
id: ID!
name: String!
users(
after: String
before: String
first: Int
last: Int
): UserConnection!
}
type UserConnection {
edges: [UserEdge]
nodes: [User!]!
pageInfo: PageInfo!
}
実行するクエリーは下記の通り。
OrganizationConnectionのnodeがOrganization typeになったため先ほどよりシンプルに書くことができます。
query User($userId: Int!) {
user(userId: $userId) {
id
name
organizations {
edges {
node {
name
}
}
}
}
}
先ほどと同じデータの場合、下記のレスポンスになります。
{
"data": {
"user": {
"id": "1",
"name": "ham",
"organizations": {
"edges": [
{
"node": {
"name": "org1"
}
},
{
"node": {
"name": "org2"
}
},
{
"node": {
"name": "org3"
}
}
]
}
}
}
}
今回の場合、最初の実装でも問題なく動作はしていましたが、正しく意図を理解した実装にすることでシンプルなクエリーにすることができました。
Railsの場合、モデル(テーブル)を中心に考えがちで、油断したら最初に実装したようにtype=モデル(テーブル)で実装してしまいそうになるので気をつけないと思いました。