対象読者
以下の技術スタックでGraphQLはじめたものの、割と古典的なデザインのページングを実装しなくちゃいけなくなって、でもバックエンドでDB検索するような事例がなかなか見つからなくて困っている人。
- Vue.js/Nuxt.js/TypeScript/Vuetify/Apollo
- Golang/Gorm/gqlgen
上記の個々の技術要素についてはある程度触ったことがあることも前提。
なので、個別技術要素についての説明や、GraphQLスキーマの中身についてもつどつどの説明はしない。
説明らしい説明は、なるべく実装時にソースコメントに書くことにした。
フロントエンド、バックエンドまとめて扱おうと思ったけど、バックエンドだけでけっこう長くなってきたので、まずはバックエンドだけ。
[2020/11/17 追記]
ちなみに、Gorm
の代わりにSQL Boiler
、そして、RDBがPostgreSQL
前提(※厳密には、ROWID, ROW_NUM, ROW_NUMBERといった「行番号」を取れるRDBなら何でもいけると思う。)でよければ、より簡潔な方式を以下でサンプル実装した。
「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
お題
GraphQLでは「ページング」に関して具体的な実装方式を指定していない。ただ、公式的な何かに則ろうと思うと、FacebookのRelayスタイルに準拠する形になると思う。
今回は、Relayスタイルでのページングを以下のような画面(仕様)に適用しようとするとどんなコードになるのかを実装する。
(VuetifyのData tablesをフロントエンドのデザインに用いる。)
機能
- Searchテキストボックスによる一覧表示レコードの絞り込み(部分一致)
- 「TODO」欄、「Done」欄、「CreatedAt」欄、「User」欄の昇降順ソート
- 一覧表示件数の切り替え(「5件」、「10件」、「15件」、「全件」)
- 前後ページへのページング
あくまで実践してみた結果を示すのが趣旨なので、そもそも「Relayスタイルって?」などは説明しない。
そもそも話に関しては下記参照。
https://facebook.github.io/relay/graphql/connections.htm#
ページングも含めたわかりやすい紹介記事としては下記かな。
https://qiita.com/gipcompany/items/ffee8cf0b1522a741e12
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
# フロントエンド
Nuxt.js
$ cat yarn.lock | grep "@nuxt/vue-app"
"@nuxt/vue-app" "2.11.0"
"@nuxt/vue-app@2.11.0":
resolved "https://registry.yarnpkg.com/@nuxt/vue-app/-/vue-app-2.11.0.tgz#05aa5fd7cc69bcf6a763b89c51df3bd27b58869e"
パッケージマネージャ - Yarn
$ yarn -v
1.19.2
IDE - WebStorm
WebStorm 2019.3
Build #WS-193.5233.80, built on November 25, 2019
# バックエンド
言語 - Go
$ go version
go version go1.13.3 linux/amd64
パッケージマネージャ - Go Modules
IDE - Goland
GoLand 2019.3.1
Build #GO-193.5662.65, built on December 23, 2019
実践
【ページング対応前】
■全ソース
■プロジェクト構成
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/src
$
$ tree -L 3
.
├── backend
│ ├── database
│ │ ├── todo.go
│ │ └── user.go
│ ├── Dockerfile
│ ├── generated.go
│ ├── go.mod
│ ├── go.sum
│ ├── gqlgen.yml
│ ├── models
│ │ └── models.go
│ ├── models_gen.go
│ ├── README.md
│ ├── _resolver.go
│ ├── resolver.go
│ ├── server
│ │ └── server.go
│ └── util
│ └── util.go
├── frontend
│ ├── apollo
│ │ └── queries
│ ├── assets
│ │ ├── README.md
│ │ └── variables.scss
│ ├── codegen.yml
│ ├── components
│ │ ├── Logo.vue
│ │ ├── README.md
│ │ ├── TodoCard.vue
│ │ ├── TodoTable.vue
│ │ └── VuetifyLogo.vue
│ ├── Dockerfile
│ ├── gql-types.d.ts
│ ├── layouts
│ │ ├── default.vue
│ │ ├── error.vue
│ │ └── README.md
│ ├── middleware
│ │ └── README.md
│ ├── node_modules
│ ├── nuxt.config.js
│ ├── package.json
│ ├── pages
│ │ ├── index.vue
│ │ ├── inspire.vue
│ │ └── README.md
│ ├── plugins
│ │ ├── apollo-error-handler.js
│ │ └── README.md
│ ├── README.md
│ ├── static
│ │ ├── favicon.ico
│ │ ├── README.md
│ │ └── v.png
│ ├── store
│ │ └── README.md
│ ├── tsconfig.json
│ ├── types
│ │ └── vuetify
│ ├── vue-shim.d.ts
│ └── yarn.lock
└── schema
└── schema.graphql
■解説
ソースを抜粋しての解説。ページング対応前のは参考程度なものなので、だいぶ端折りながら。
そもそもNuxtプロジェクトがどうなっててとかTypeScript対応するにはどうやってとかは今回の趣旨と外れるので当然説明なし。
TypeScript対応のくだりで言うと↓など見てもらえれば。
フロントエンド
ページ
ロジックらしいロジックはコンポーネントに書くので、pages
の下は薄く。
<template>
<div>
<TodoTable />
</div>
</template>
<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import TodoTable from '~/components/TodoTable.vue'
@Component({
components: { TodoTable }
})
export default class IndexPage extends Vue {}
</script>
コンポーネント
<template>
部分について
文字列検索フィルタぐらいは設けたかったので v-text-field
を用意。
あとは、一覧を表示しようと思ったら v-data-table
を使っておくと、ものすごく楽。
ApolloというGraphQLクライアントライブラリを使って、GraphQLサーバ(Golangで実装したやつ。このあと説明。)からTODO一覧を取得して todos
という変数に入れておく。
ちなみに、クエリは下記。
query todos {
todos {
id
text
done
createdAt
user {
id
name
}
}
}
v-data-table
を使うと、これだけのソースで「ページング」、「列ごとのソート」、「文字列検索フィルタ」、「表示件数切り替え」といったことが実現できる。とても便利。
<script>
部分について
TypeScriptなので極力、型を使うようにしてる。GraphQL関連の型については、起動中のGraphQLサーバにアクセスすることで自動生成できる型定義ファイルを使用。詳しくは下記参照。
<template>
<v-form>
<v-row>
<v-col col="5">
<v-card class="pa-4">
<v-text-field v-model="search" label="Search"></v-text-field>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col col="9">
<v-card>
<v-data-table
:headers="headers"
:items="todos"
:search="search"
:items-per-page="itemsPerPage"
:sort-by="sortBy"
:sort-desc="sortDesc"
fixed-header
>
</v-data-table>
</v-card>
</v-col>
</v-row>
</v-form>
</template>
<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import todos from '~/apollo/queries/todos.gql'
// eslint-disable-next-line no-unused-vars
import { Todo } from '~/gql-types'
// eslint-disable-next-line no-unused-vars
import { DataTableHeader } from '@/types/vuetify'
@Component({
apollo: {
todos: {
prefetch: true,
query: todos
}
}
})
export default class TodoCard extends Vue {
todos: Todo[] = []
search: string = ''
itemsPerPage: number = 10
sortBy: string = 'createdAt'
sortDesc: boolean = true
get headers(): DataTableHeader[] {
return [
{
sortable: false,
text: 'ID',
value: 'id'
},
{
sortable: true,
text: 'TODO',
value: 'text'
},
{
sortable: true,
text: 'Done',
value: 'done'
},
{
sortable: true,
text: 'CreatedAt(UnixTimestamp)',
value: 'createdAt'
},
{
sortable: true,
text: 'User',
value: 'user.name'
}
]
}
}
</script>
便利便利ともてはやしたい v-data-table
なんだけど、これ、サーバから(ページをまたいだ)全件を取得してきている前提なんだよね。
システムの規模や要件次第ではあるけど、全件が 1 万件くらいならいいけど、100 万件とかになったら重いよね、きっと?(何より、1ページに表示しない量を毎度取得するのは無駄。。。)
というわけで、通常はサーバからは(全件ではなく)1ページに表示すべき量だけを取得する。
のだけど、ページング対応後の実装を見る前に、GraphQLスキーマとバックエンド側の方の実装も軽く。
GraphQLスキーマ
別に凝ったことはまったくない代物。
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
type Todo {
id: ID!
text: String!
done: Boolean!
createdAt: Int!
user: User!
}
type User {
id: ID!
name: String!
todos: [Todo!]!
}
type Query {
todos: [Todo!]!
}
バックエンド
Resolver
GraphQLをgqlgenでといったらリゾルバー。ちなみに、import
文やmutation
に関する部分などページング実装前後で極端に変動しないところは省略している。
package backend
type Resolver struct {
DB *gorm.DB
}
func (r *Resolver) Query() QueryResolver {
return &queryResolver{r}
}
func (r *Resolver) Todo() TodoResolver {
return &todoResolver{r}
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*models.Todo, error) {
log.Println("[queryResolver.Todos]")
todos, err := database.NewTodoDao(r.DB).FindAll()
if err != nil {
return nil, err
}
var results []*models.Todo
for _, todo := range todos {
results = append(results, &models.Todo{
ID: todo.ID,
Text: todo.Text,
Done: todo.Done,
CreatedAt: todo.CreatedAt.Unix(),
})
}
return results, nil
}
type todoResolver struct{ *Resolver }
func (r *todoResolver) User(ctx context.Context, obj *models.Todo) (*models.User, error) {
log.Printf("[todoResolver.User] id: %#v", obj)
user, err := database.NewUserDao(r.DB).FindByTodoID(obj.ID)
if err != nil {
return nil, err
}
return &models.User{
ID: user.ID,
Name: user.Name,
}, nil
}
この実装、フロントエンドからTODO一覧表示のリクエストが投げられると、Todos()
関数が1回呼ばれた後、その結果返るTODOの1レコード毎に User()
関数が呼ばれる。
つまり、Todos()
関数の結果、10件のTODOレコードが返された場合、その1件毎にUser()
関数が呼ばれるので、全関数呼び出しは 11回となる。
何が問題かと言うと、どちらの関数もDBアクセスを伴うので、11回SQL発行することになるのだよね。
TODOとそのTODOを作ったユーザの情報が追加で欲しいだけだから、そもそもTODOとユーザをJOINして持ってくれば1回のSQLで済むのに。
こういうの、N+1問題と言うようで、例えばRailsとかではEager Loading(要するに関連テーブルのレコードは事前に読み込んでおくというやつ)という手法で解決するのだそうで。
で、Goというか今回使っているgqlgenではdataloaderというライブラリにならったdataloadenというのを使うらしい。
けど、今回はページング実装が趣旨なので、↑の実装は、また今度。
【2021/03/08 追記】
「Dataloadersを使ったN+1問題への対応」で試行。
DBアクセス
必要な箇所だけ抜粋。やっぱりGorm使うととても楽。
package database
import (
"time"
"github.com/jinzhu/gorm"
)
type Todo struct {
ID string `gorm:"column:id;primary_key"`
Text string `gorm:"column:text"`
Done bool `gorm:"column:done"`
CreatedAt time.Time `gorm:"column:created_at"`
UserID string `gorm:"column:user_id"`
}
func (u *Todo) TableName() string {
return "todo"
}
type TodoDao interface {
FindAll() ([]*Todo, error)
}
type todoDao struct {
db *gorm.DB
}
func NewTodoDao(db *gorm.DB) TodoDao {
return &todoDao{db: db}
}
func (d *todoDao) FindAll() ([]*Todo, error) {
var todos []*Todo
res := d.db.Find(&todos)
if err := res.Error; err != nil {
return nil, err
}
return todos, nil
}
【ページング対応後】
■全ソース(GraphQLスキーマ・バックエンドのみ完成版)
■プロジェクト構成(フロントエンド部分は省略)
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/src
$
$ tree -L 3
.
├── backend
│ ├── database
│ │ ├── database.go
│ │ ├── helper.go
│ │ ├── todo.go
│ │ └── user.go
│ ├── Dockerfile
│ ├── generated.go
│ ├── go.mod
│ ├── go.sum
│ ├── gqlgen.yml
│ ├── models
│ │ ├── connection.go
│ │ ├── order.go
│ │ ├── pagination.go
│ │ ├── text_filter.go
│ │ ├── todo.go
│ │ └── user.go
│ ├── models_gen.go
│ ├── _resolver.go
│ ├── resolver.go
│ ├── server
│ │ └── server.go
│ └── util
│ └── util.go
└── schema
├── connection.graphql
├── order.graphql
├── pagination.graphql
├── schema.graphql
├── text_filter.graphql
├── todo.graphql
└── user.graphql
■解説
ソースを抜粋しての解説。
GraphQLスキーマ
今回の対応前に用意したものから格段にファイルを増やした。
まあ、必ずしもこんなにファイルを分ける必要はないのだけど。
└── schema
├── connection.graphql
├── order.graphql
├── pagination.graphql
├── schema.graphql
├── text_filter.graphql
├── todo.graphql
TODO機能にクエリ「todoConnection
」追加
extend type Query {
todos: [Todo!]!
"Relay準拠ページング対応検索によるTODO一覧取得"
todoConnection(
"文字列フィルタ条件"
filterWord: TextFilterCondition
"ページング条件"
pageCondition: PageCondition
"並び替え条件"
edgeOrder: EdgeOrder
): TodoConnection
}
結果取得条件に該当する要素として下記を定義。(機能個別に追加したい条件があるなら別途要素の追加が必要)
- 文字列フィルタ条件 ・・・インクリメンタルサーチ用の文字列
- ページング条件 ・・・前後どちらに移動するか、表示件数、移動時の起点となる識別子(=カーソルと言う)等
- 並べ替え条件 ・・・どの項目で昇順or降順で並べ替えるか
ちなみに、どの条件も指定するか否かは任意(「!
」付けてない)
文字列フィルタ条件
"文字列フィルタ条件"
input TextFilterCondition {
"フィルタ文字列"
filterWord: String!
"マッチングパターン(※オプション。指定無しの場合は「部分一致」となる。)"
matchingPattern: MatchingPattern = PARTIAL_MATCH
}
"マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)"
enum MatchingPattern {
"部分一致"
PARTIAL_MATCH
"完全一致"
EXACT_MATCH
}
ページング条件
Relay方式ならではな単語に合わせると、ちょっと初見で意味がわかりづらくなるのが難点・・・。
"カーソル(1レコードをユニークに特定する識別子)"
scalar Cursor
"ページング条件"
input PageCondition {
"前ページ遷移条件"
backward: BackwardPagination
"次ページ遷移条件"
forward: ForwardPagination
"現在ページ番号(今回のページング実行前の時点のもの)"
nowPageNo: Int!
"1ページ表示件数"
initialLimit: Int
}
"前ページ遷移条件"
input BackwardPagination {
"取得件数"
last: Int!
"取得対象識別用カーソル(※前ページ遷移時にこのカーソルよりも前にあるレコードが取得対象)"
before: Cursor
}
"次ページ遷移条件"
input ForwardPagination {
"取得件数"
first: Int!
"取得対象識別用カーソル(※次ページ遷移時にこのカーソルよりも後ろにあるレコードが取得対象)"
after: Cursor
}
並べ替え条件
"並び替え条件"
input EdgeOrder {
"並べ替えキー項目"
key: OrderKey!
"ソート方向"
direction: OrderDirection!
}
"""
並べ替えのキー
汎用的な構造にしたいが以下はGraphQLの仕様として不可だった。
・enum・・・汎化機能がない。
・interface・・・inputには実装機能がない。
・union・・・inputでは要素に持てない。
とはいえ、並べ替えも共通の仕組みとして提供したく、結果として機能毎に enum フィールドを列挙
"""
input OrderKey {
"TODO一覧の並べ替えキー"
todoOrderKey: TodoOrderKey
}
"並べ替え方向"
enum OrderDirection {
"昇順"
ASC
"降順"
DESC
}
検索結果
↓の「TodoConnection
」の定義。
"Relay準拠ページング対応検索によるTODO一覧取得"
todoConnection(
〜〜〜〜
): TodoConnection
検索結果には、検索条件にしたがって取得した結果リストは当然のこと、
検索結果の全件数(フロントエンドで表示するため)やページ情報(これより前後にページがあるか否か等)の情報も返す。
"ページングを伴う結果返却用"
type TodoConnection implements Connection {
"ページ情報"
pageInfo: PageInfo!
"検索結果一覧(※カーソル情報を含む)"
edges: [TodoEdge!]!
"検索結果の全件数"
totalCount: Int!
}
"検索結果一覧(※カーソル情報を含む)"
type TodoEdge implements Edge {
node: Todo
cursor: Cursor!
}
type Todo implements Node {
"ID"
id: ID!
"TODO"
text: String!
"済みフラグ"
done: Boolean!
"作成日時"
createdAt: Int!
"ユーザー情報"
user: User!
}
もう1つ「ページ情報」も記載。
"ページングを伴う結果返却用"
interface Connection {
"ページ情報"
pageInfo: PageInfo!
"結果一覧(※カーソル情報を含む)"
edges: [Edge!]!
"検索結果の全件数"
totalCount: Int!
}
"ページ情報"
type PageInfo {
"次ページ有無"
hasNextPage: Boolean!
"前ページ有無"
hasPreviousPage: Boolean!
"当該ページの1レコード目"
startCursor: Cursor!
"当該ページの最終レコード"
endCursor: Cursor!
}
"検索結果一覧(※カーソル情報を含む)"
interface Edge {
"Nodeインタフェースを実装したtypeなら代入可能"
node: Node
cursor: Cursor!
}
パターン別検索結果
バックエンドの実装を説明する前に、上記GraphQLスキーマとバックエンドのGraphQLサーバを相手に、想定したGraphQLクエリーを発行して期待結果が返るかどうかをパターン別に検証した結果を載せる。
(これが想定結果を返さないなら、ソース紹介しても意味ないので・・・。)
DBの中身
todo
テーブルにレコードが18件登録されている状態。text
列にて、以下のように検索結果の検証がしやすいように名前を工夫している。
「通し連番
-todo登録user名
-user名ごとの通し番号
」
通し番号 01 から順に登録しているので、登録日時(created_at
)もその通りの順になっている。
【パターン 01】何も検索条件なし
クエリ
query Q01 {
todoConnection {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
id
text
done
createdAt
user {
id
name
}
}
}
totalCount
}
}
実行結果
何の絞り込みもしてないので、18件すべて取れる。
画面に一覧を表示した際に、「前ページ」遷移ボタン・「次ページ」遷移ボタンを活性化すべきかどうかの判定に使える pageInfo
の hasPreviousPage
や hasNextPage
の結果も取れている。
また、ページング利用時に必要となる、当該ページ表示時の1行目のレコードを特定するカーソル(startCursor
)と最終行のレコードを特定するカーソル(endCursor
)も取れている。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"endCursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZjQ5ZjI0MmVhZWE4NDViOWE5YmZiMzJhNTllODYwMTk",
"node": {
"id": "f49f242eaea845b9a9bfb32a59e86019",
"text": "17-hanako-06",
"done": false,
"createdAt": 1580649391,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
〜〜〜 省略 〜〜〜
{
"cursor": "dG9kbyMjIyMjNTRiNzgwZWZkOWE0NDUyYzhiZTE3NzIxOTk0NTdlNWM",
"node": {
"id": "54b780efd9a4452c8be1772199457e5c",
"text": "02-hanako-01",
"done": false,
"createdAt": 1580649253,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE",
"node": {
"id": "d091794b306d4370bba579568c059b0a",
"text": "01-taro-01",
"done": false,
"createdAt": 1580649237,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 02】文字列フィルタ
「taro
」を含む結果に絞り込む。
クエリ
query Q02 {
todoConnection(
filterWord: {
filterWord: "taro"
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"endCursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"node": {
"id": "8e09bd218b454b6c9f182a1c2eb51745",
"text": "16-taro-06",
"done": false,
"createdAt": 1580649389,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjMTMzZDY4M2M5MTI2NDE2Yjg0MWNiNTQyYjRkZDhkYjM",
"node": {
"id": "133d683c9126416b841cb542b4dd8db3",
"text": "13-taro-05",
"done": false,
"createdAt": 1580649360,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjNmViMzNiYTMxMzgwNDk4ODlhMTBhMzQzYmFmYzQ3ZTM",
"node": {
"id": "6eb33ba3138049889a10a343bafc47e3",
"text": "10-taro-04",
"done": false,
"createdAt": 1580649337,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjNTI4MjVkYzU1MzU4NDM1Nzg3NDZjOWViZGZjOGVkZDU",
"node": {
"id": "52825dc5535843578746c9ebdfc8edd5",
"text": "07-taro-03",
"done": false,
"createdAt": 1580649311,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjNDhmYzI5YTRkZjBmNDI0MWFhZGE3MmU1NjU0MWMyZGM",
"node": {
"id": "48fc29a4df0f4241aada72e56541c2dc",
"text": "04-taro-02",
"done": false,
"createdAt": 1580649288,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE",
"node": {
"id": "d091794b306d4370bba579568c059b0a",
"text": "01-taro-01",
"done": false,
"createdAt": 1580649237,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 6
}
}
}
【パターン 03】並べ替え
作成日時(created_at
)の降順で並べる。
クエリ
query Q03 {
todoConnection(
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: DESC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
18件が作成日時の降順で並んでいる。(※途中の情報を省略)
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"endCursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZjQ5ZjI0MmVhZWE4NDViOWE5YmZiMzJhNTllODYwMTk",
"node": {
"id": "f49f242eaea845b9a9bfb32a59e86019",
"text": "17-hanako-06",
"done": false,
"createdAt": 1580649391,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
〜〜〜 省略 〜〜〜
{
"cursor": "dG9kbyMjIyMjNTRiNzgwZWZkOWE0NDUyYzhiZTE3NzIxOTk0NTdlNWM",
"node": {
"id": "54b780efd9a4452c8be1772199457e5c",
"text": "02-hanako-01",
"done": false,
"createdAt": 1580649253,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE",
"node": {
"id": "d091794b306d4370bba579568c059b0a",
"text": "01-taro-01",
"done": false,
"createdAt": 1580649237,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 04】文字列フィルタと並べ替え
一応、ページングの確認の前に、文字列フィルタと並べ替えの合わせ技でもうまくいくことを確認。
クエリ
query Q04 {
todoConnection(
filterWord: {
filterWord: "jiro"
}
edgeOrder: {
key: {
todoOrderKey: TEXT
}
direction: DESC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
うん。「jiro
」を含むもので絞り込んだ結果の6件が text
の降順で並んでいる。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"endCursor": "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk",
"node": {
"id": "c8879db92adb4b8d9de01d05411feb99",
"text": "15-jiro-05",
"done": false,
"createdAt": 1580649364,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZDUzOGMxNzEwNDE1NGI4YmExNmM2MmFjMjE5NGM4MzY",
"node": {
"id": "d538c17104154b8ba16c62ac2194c836",
"text": "12-jiro-04",
"done": false,
"createdAt": 1580649341,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjMmVjMTQxZDJjMTM4NDcxNGIzMDRkODFkYWUyMDczMzQ",
"node": {
"id": "2ec141d2c1384714b304d81dae207334",
"text": "09-jiro-03",
"done": false,
"createdAt": 1580649315,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZmViMWM3YzBmZTcxNDljMmI4MDNiMzk0ZTc3N2VhZmU",
"node": {
"id": "feb1c7c0fe7149c2b803b394e777eafe",
"text": "06-jiro-02",
"done": false,
"createdAt": 1580649292,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU",
"node": {
"id": "1f30771849884c798e1967cdb08a5c05",
"text": "03-jiro-01",
"done": false,
"createdAt": 1580649256,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
}
],
"totalCount": 6
}
}
}
【パターン 05】表示件数の指定
さて、ページング。なんだけど、ページング前に単純に表示件数を指定してみる。
クエリ
並べ替えの指定もなく、単純に5件だけ取る。
query Q05 {
todoConnection(
pageCondition: {
nowPageNo: 1
initialLimit: 5
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
ランダムにと言いたいところだけど、今回は、並べ替え指定無しの場合に、あえてデフォルトで「作成日時の降順」になるような仕様にした。
なので、作成日時の降順で5件が取れた。
今までとの大きな変更点が、pageInfo
の hasNextPage
が true
であること。
全件数は18件あるので、そのうち5件を表示した結果、まだその次のページ分が残っている。なので、true
になる。
画面側では、この true
をもって、「次ページ」ボタンを活性化すればいい。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"endCursor": "dG9kbyMjIyMjN2E4ODc1OTI3ZTExNGI2OGI5YjMyMTViNDUwOGQwYzA"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZjQ5ZjI0MmVhZWE4NDViOWE5YmZiMzJhNTllODYwMTk",
"node": {
"id": "f49f242eaea845b9a9bfb32a59e86019",
"text": "17-hanako-06",
"done": false,
"createdAt": 1580649391,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"node": {
"id": "8e09bd218b454b6c9f182a1c2eb51745",
"text": "16-taro-06",
"done": false,
"createdAt": 1580649389,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk",
"node": {
"id": "c8879db92adb4b8d9de01d05411feb99",
"text": "15-jiro-05",
"done": false,
"createdAt": 1580649364,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjN2E4ODc1OTI3ZTExNGI2OGI5YjMyMTViNDUwOGQwYzA",
"node": {
"id": "7a8875927e114b68b9b3215b4508d0c0",
"text": "14-hanako-05",
"done": false,
"createdAt": 1580649362,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 06】ページング情報と並べ替えの組み合わせ
ここからは、基本的に3件ずつ取得するパターンで試していく。
クエリ
query Q06 {
todoConnection(
pageCondition: {
nowPageNo: 1
initialLimit: 3
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: ASC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE",
"endCursor": "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjZDA5MTc5NGIzMDZkNDM3MGJiYTU3OTU2OGMwNTliMGE",
"node": {
"id": "d091794b306d4370bba579568c059b0a",
"text": "01-taro-01",
"done": false,
"createdAt": 1580649237,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjNTRiNzgwZWZkOWE0NDUyYzhiZTE3NzIxOTk0NTdlNWM",
"node": {
"id": "54b780efd9a4452c8be1772199457e5c",
"text": "02-hanako-01",
"done": false,
"createdAt": 1580649253,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU",
"node": {
"id": "1f30771849884c798e1967cdb08a5c05",
"text": "03-jiro-01",
"done": false,
"createdAt": 1580649256,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 07】作成日時の昇順で2ページ目に遷移
パターン06の結果から、2ページ目に遷移する。
クエリ
パターン06の結果から、最終行のカーソルは「dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU
」だったので、その値を after
として指定する。
query Q07 {
todoConnection(
pageCondition: {
nowPageNo: 1
forward: {
first: 3
after: "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU"
}
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: ASC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
通し番号における 01 ~ 03 をパターン06では表示していたので、次ページに遷移したら、04 ~ 06 が返ることを期待。
結果、期待通り。
また、2ページ目に遷移したので、hasPreviousPage
は true
になる。
全18件でまだまだ先のページがあるので、当然、hasNextPage
も true
になる。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": true,
"startCursor": "dG9kbyMjIyMjNDhmYzI5YTRkZjBmNDI0MWFhZGE3MmU1NjU0MWMyZGM",
"endCursor": "dG9kbyMjIyMjZmViMWM3YzBmZTcxNDljMmI4MDNiMzk0ZTc3N2VhZmU"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNDhmYzI5YTRkZjBmNDI0MWFhZGE3MmU1NjU0MWMyZGM",
"node": {
"id": "48fc29a4df0f4241aada72e56541c2dc",
"text": "04-taro-02",
"done": false,
"createdAt": 1580649288,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZTFiYmFhYjUxYmZmNGI3ZDkxMDU5NjU5ZDg5YTViMDQ",
"node": {
"id": "e1bbaab51bff4b7d91059659d89a5b04",
"text": "05-hanako-02",
"done": false,
"createdAt": 1580649290,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjZmViMWM3YzBmZTcxNDljMmI4MDNiMzk0ZTc3N2VhZmU",
"node": {
"id": "feb1c7c0fe7149c2b803b394e777eafe",
"text": "06-jiro-02",
"done": false,
"createdAt": 1580649292,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 08】作成日時の昇順で最終ページを表示
パターン07から次ページへの遷移を繰り返していき、最終ページを表示するクエリを実行するパターン。
クエリ
query Q08 {
todoConnection(
pageCondition: {
nowPageNo: 5
forward: {
first: 3
after: "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk"
}
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: ASC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
全18件で、作成日時の昇順でのページ遷移を繰り返して現在5ページ目。ということは、3件ずつ表示しているので次が最終ページという状況下での検索実行結果。
最終ページを表示したので、hasNextPage
は false
になった。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": true,
"startCursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"endCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"node": {
"id": "8e09bd218b454b6c9f182a1c2eb51745",
"text": "16-taro-06",
"done": false,
"createdAt": 1580649389,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZjQ5ZjI0MmVhZWE4NDViOWE5YmZiMzJhNTllODYwMTk",
"node": {
"id": "f49f242eaea845b9a9bfb32a59e86019",
"text": "17-hanako-06",
"done": false,
"createdAt": 1580649391,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 09】作成日時の昇順で最終ページから1つ前のページに遷移
今度は前のページに戻っていくパターン。
クエリ
query Q09 {
todoConnection(
pageCondition: {
nowPageNo: 6
backward: {
last: 3
before: "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU"
}
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: ASC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
通し番号における 16 ~ 18 を最終ページで表示していたので、13 ~ 15 が結果となることを期待。
はい、期待通り。
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": true,
"startCursor": "dG9kbyMjIyMjMTMzZDY4M2M5MTI2NDE2Yjg0MWNiNTQyYjRkZDhkYjM",
"endCursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjMTMzZDY4M2M5MTI2NDE2Yjg0MWNiNTQyYjRkZDhkYjM",
"node": {
"id": "133d683c9126416b841cb542b4dd8db3",
"text": "13-taro-05",
"done": false,
"createdAt": 1580649360,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
},
{
"cursor": "dG9kbyMjIyMjN2E4ODc1OTI3ZTExNGI2OGI5YjMyMTViNDUwOGQwYzA",
"node": {
"id": "7a8875927e114b68b9b3215b4508d0c0",
"text": "14-hanako-05",
"done": false,
"createdAt": 1580649362,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk",
"node": {
"id": "c8879db92adb4b8d9de01d05411feb99",
"text": "15-jiro-05",
"done": false,
"createdAt": 1580649364,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 10】作成日時の降順で1ページ目を表示
今度は、”降順”でのページングパターン。
通し番号でいう 18, 17, 16 が返ることを期待。
クエリ
query Q10 {
todoConnection(
pageCondition: {
nowPageNo: 1
initialLimit: 3
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: DESC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"endCursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjNmEzMjBmMTgzMWE3NDIwZWI5ZDFjYmM2MzE3OTQ4ZjM",
"node": {
"id": "6a320f1831a7420eb9d1cbc6317948f3",
"text": "18-jiro-06",
"done": false,
"createdAt": 1580649394,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZjQ5ZjI0MmVhZWE4NDViOWE5YmZiMzJhNTllODYwMTk",
"node": {
"id": "f49f242eaea845b9a9bfb32a59e86019",
"text": "17-hanako-06",
"done": false,
"createdAt": 1580649391,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU",
"node": {
"id": "8e09bd218b454b6c9f182a1c2eb51745",
"text": "16-taro-06",
"done": false,
"createdAt": 1580649389,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 11】作成日時の降順で2ページ目に遷移
通し番号でいう 15, 14, 13 が返ることを期待。
クエリ
query Q11 {
todoConnection(
pageCondition: {
nowPageNo: 1
forward: {
first: 3
after: "dG9kbyMjIyMjOGUwOWJkMjE4YjQ1NGI2YzlmMTgyYTFjMmViNTE3NDU"
}
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: DESC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": true,
"startCursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk",
"endCursor": "dG9kbyMjIyMjMTMzZDY4M2M5MTI2NDE2Yjg0MWNiNTQyYjRkZDhkYjM"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjYzg4NzlkYjkyYWRiNGI4ZDlkZTAxZDA1NDExZmViOTk",
"node": {
"id": "c8879db92adb4b8d9de01d05411feb99",
"text": "15-jiro-05",
"done": false,
"createdAt": 1580649364,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjN2E4ODc1OTI3ZTExNGI2OGI5YjMyMTViNDUwOGQwYzA",
"node": {
"id": "7a8875927e114b68b9b3215b4508d0c0",
"text": "14-hanako-05",
"done": false,
"createdAt": 1580649362,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjMTMzZDY4M2M5MTI2NDE2Yjg0MWNiNTQyYjRkZDhkYjM",
"node": {
"id": "133d683c9126416b841cb542b4dd8db3",
"text": "13-taro-05",
"done": false,
"createdAt": 1580649360,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 18
}
}
}
【パターン 11】作成日時の降順で最終ページから1つ前のページに遷移
今度は前のページに戻っていくパターン。
クエリ
query Q12 {
todoConnection(
pageCondition: {
nowPageNo: 6
backward: {
last: 3
before: "dG9kbyMjIyMjMWYzMDc3MTg0OTg4NGM3OThlMTk2N2NkYjA4YTVjMDU"
}
}
edgeOrder: {
key: {
todoOrderKey: CREATED_AT
}
direction: DESC
}
) {
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
〜〜〜 パターン01と同じなので省略 〜〜〜
}
totalCount
}
}
実行結果
JSON
{
"data": {
"todoConnection": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": true,
"startCursor": "dG9kbyMjIyMjZmViMWM3YzBmZTcxNDljMmI4MDNiMzk0ZTc3N2VhZmU",
"endCursor": "dG9kbyMjIyMjNDhmYzI5YTRkZjBmNDI0MWFhZGE3MmU1NjU0MWMyZGM"
},
"edges": [
{
"cursor": "dG9kbyMjIyMjZmViMWM3YzBmZTcxNDljMmI4MDNiMzk0ZTc3N2VhZmU",
"node": {
"id": "feb1c7c0fe7149c2b803b394e777eafe",
"text": "06-jiro-02",
"done": false,
"createdAt": 1580649292,
"user": {
"id": "ddb85facd09e4677a9a381b118f89173",
"name": "Jiro"
}
}
},
{
"cursor": "dG9kbyMjIyMjZTFiYmFhYjUxYmZmNGI3ZDkxMDU5NjU5ZDg5YTViMDQ",
"node": {
"id": "e1bbaab51bff4b7d91059659d89a5b04",
"text": "05-hanako-02",
"done": false,
"createdAt": 1580649290,
"user": {
"id": "7cb28a6bd54d4d59a7b255a2296e6982",
"name": "Hanako"
}
}
},
{
"cursor": "dG9kbyMjIyMjNDhmYzI5YTRkZjBmNDI0MWFhZGE3MmU1NjU0MWMyZGM",
"node": {
"id": "48fc29a4df0f4241aada72e56541c2dc",
"text": "04-taro-02",
"done": false,
"createdAt": 1580649288,
"user": {
"id": "8e5ed45559c84e0185835ec4eab986ba",
"name": "Taro"
}
}
}
],
"totalCount": 18
}
}
}
バックエンド
さて、一通り(?)のパターンを動作確認したところで、バックエンドのソースを記載。
◆リゾルバー
全量は下記。
https://github.com/sky0621/study-graphql/blob/v0.7.0/src/backend/resolver.go
func (r *queryResolver) TodoConnection(ctx context.Context,
filterWord *models.TextFilterCondition,
pageCondition *models.PageCondition,
edgeOrder *models.EdgeOrder) (*models.TodoConnection, error) {
dao := database.NewTodoDao(r.DB)
/*
* 検索条件に合致する全件数を取得
*/
totalCount, err := dao.CountByTextFilter(ctx, filterWord)
if err != nil {
return nil, err
}
if totalCount == 0 {
return models.EmptyTodoConnection(), nil
}
↑まずは、文字列フィルタだけによる絞り込み結果件数を CountByTextFilter(~~)
により取得。
この結果件数を使って、以下のように「総ページ数」を算出する。
// 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
totalPage := pageCondition.TotalPage(totalCount)
// ページ情報を計算・収集しておく
pageInfo := &models.PageInfo{
HasNextPage: (totalPage - int64(pageCondition.MoveToPageNo())) >= 1, // 遷移後も、まだ先のページがあるか
HasPreviousPage: pageCondition.MoveToPageNo() > 1, // 遷移後も、まだ前のページがあるか
}
↑フロントエンドでのボタン活性制御等に必要な各種ページ情報を収集。
ちなみに、pageCondition.TotalPage(totalCount)
の実装は下記。
func (c *PageCondition) TotalPage(totalCount int64) int64 {
if c == nil {
return 0
}
targetCount := 0
if c.Backward == nil && c.Forward == nil {
if c.InitialLimit == nil {
return 0
} else {
targetCount = *c.InitialLimit
}
} else {
if c.Backward != nil {
targetCount = c.Backward.Last
}
if c.Forward != nil {
targetCount = c.Forward.First
}
}
return int64(math.Ceil(float64(totalCount) / float64(targetCount)))
}
また、pageCondition.MoveToPageNo()
の実装は下記。
func (c *PageCondition) MoveToPageNo() int {
if c == nil {
return 1 // 想定外のため初期ページ
}
if c.Backward == nil && c.Forward == nil {
return c.NowPageNo // 前にも後ろにも遷移しないので
}
if c.Backward != nil {
if c.NowPageNo <= 2 {
return 1
}
return c.NowPageNo - 1
}
if c.Forward != nil {
return c.NowPageNo + 1
}
return 1 // 想定外のため初期ページ
}
/*
* 検索条件、ページング条件、ソート条件に合致する結果を取得
*/
todos, err := dao.FindByCondition(ctx, filterWord, pageCondition, getOrder(edgeOrder))
if err != nil {
return nil, err
}
if todos == nil || len(todos) == 0 {
return models.EmptyTodoConnection(), nil
}
↑指定された条件を全て使って、検索実行。
var edges []*models.TodoEdge
for idx, todo := range todos {
// 当該レコードをユニークに特定するためのカーソルを計算
cursor := util.CreateCursor("todo", todo.ID)
// 検索結果をEdge形式に変換(カーソルの値も格納)
edges = append(edges, &models.TodoEdge{
Cursor: cursor,
Node: &models.Todo{
ID: todo.ID,
Text: todo.Text,
Done: todo.Done,
CreatedAt: todo.CreatedAt.Unix(),
User: &models.User{
ID: todo.User.ID,
Name: todo.User.Name,
},
},
})
if idx == 0 {
// 今回表示するページの1件目のレコードを特定するカーソルをセット
// (「前ページ」遷移時に「このカーソルよりも前のレコード」という検索条件に用いる)
pageInfo.StartCursor = cursor
}
if idx == len(todos)-1 {
// 今回表示するページの最後のレコードを特定するカーソルをセット
// (「次ページ」遷移時に「このカーソルよりも後のレコード」という検索条件に用いる)
pageInfo.EndCursor = cursor
}
}
return &models.TodoConnection{
PageInfo: pageInfo,
Edges: edges,
TotalCount: totalCount,
}, nil
}
↑あとは、よしなに返却用の形に詰め替えていくだけ。
ページング用に、結果の1レコード目と最終レコードのカーソルを個別に格納。
◆データベースアクセス
全量は下記。
https://github.com/sky0621/study-graphql/blob/v0.7.0/src/backend/database/todo.go
type Todo struct {
ID string `gorm:"column:id;primary_key"`
Text string `gorm:"column:text"`
Done bool `gorm:"column:done"`
CreatedAt time.Time `gorm:"column:created_at"`
User
}
func (t *Todo) Columns() string {
tn := t.TableName()
return fmt.Sprintf("%s.id, %s.text, %s.done, %s.created_at", tn, tn, tn, tn)
}
type TodoDao interface {
CountByTextFilter(ctx context.Context, filterWord *models.TextFilterCondition) (int64, error)
FindByCondition(ctx context.Context, filterWord *models.TextFilterCondition, pageCondition *models.PageCondition, edgeOrder *models.EdgeOrder) ([]*Todo, error)
}
type todoDao struct {
db *gorm.DB
}
func NewTodoDao(db *gorm.DB) TodoDao {
return &todoDao{db: db}
}
↑主に、todo
テーブルの定義を表す構造体とデータベースアクセスサービスのインタフェース。
func (d *todoDao) CountByTextFilter(ctx context.Context, filterWord *models.TextFilterCondition) (int64, error) {
// 絞り込み無しのパターン
if filterWord == nil || filterWord.FilterWord == "" {
var cnt int64
if err := d.db.Model(&Todo{}).Count(&cnt).Error; err != nil {
return 0, err
}
return cnt, nil
}
// デフォルトは部分一致
matchStr := "%" + filterWord.FilterWord + "%"
if filterWord.MatchingPattern != nil && *filterWord.MatchingPattern == models.MatchingPatternExactMatch {
matchStr = filterWord.FilterWord
}
todo := TableName(&Todo{})
user := TableName(&User{})
var cnt int64
// MEMO: ある程度複雑になったら頑張らずに db.Row() で生SQLを書く方が保守性は高いかもしれない。(メソッド使っても生SQL部分は存在するし)
res := d.db.
Table(todo).
Joins(InnerJoin(user) + On("%s.id = %s.user_id", user, todo)).
Where(Col(todo, "text").Like(matchStr)).
Or(Col(user, "name").Like(matchStr)).
Count(&cnt)
if res.Error != nil {
return 0, res.Error
}
return cnt, nil
}
↑件数を取得する方。Gorm
の使い方については、より洗練されたやり方があるように思う。
ヘルパー関数を作ることでなるべく簡略化。
https://github.com/sky0621/study-graphql/blob/v0.7.0/src/backend/database/helper.go
func (d *todoDao) FindByCondition(ctx context.Context, filterCondition *models.TextFilterCondition, pageCondition *models.PageCondition, edgeOrder *models.EdgeOrder) ([]*Todo, error) {
// todoテーブルのテーブル名
todo := TableName(&Todo{})
// userテーブルのテーブル名
user := TableName(&User{})
// 条件によらず固定の部分
base := d.db.Table(todo).
Joins(InnerJoin(user) + On("%s.id = %s.user_id", user, todo)).
Select((&Todo{}).Columns() + ", " + (&User{}).Columns())
/*
* 文字列フィルタ条件が指定されていた場合
*/
if filterCondition.ExistsFilter() {
// デフォルトは部分一致(例:「テスト」 -> 「%テスト%」)
matchStr := filterCondition.MatchString()
// todoテーブルのtextカラムかuserテーブルのnameカラムとLIKE検索
base = base.Where(Col(todo, "text").Like(matchStr)).Or(Col(user, "name").Like(matchStr))
}
/*
* ページング指定無しの初期ページビュー
*/
if pageCondition.IsInitialPageView() {
if pageCondition.HasInitialLimit() {
if edgeOrder.ExistsOrder() {
switch edgeOrder.Direction {
case models.OrderDirectionAsc:
base = base.Order(col_ASC(edgeOrder)).Limit(*pageCondition.InitialLimit)
case models.OrderDirectionDesc:
base = base.Order(col_DESC(edgeOrder)).Limit(*pageCondition.InitialLimit)
}
} else {
base = base.Limit(*pageCondition.InitialLimit)
}
}
}
/*
* ページング条件が指定されていた場合
* (※並べ替えのキー項目と昇順・降順の指定がないとページング不可のため、if文の判定に追加)
*/
if pageCondition.ExistsPaging() && edgeOrder.ExistsOrder() {
col, err := getColumnNameByOrderKey(*edgeOrder.Key.TodoOrderKey)
if err != nil {
return nil, err
}
/*
* どの項目で並べ替えをしているかによって、ページ遷移のために比較対象レコードのどのカラムと比較するかが決まる。
* また、比較対象レコードのカラムと比較する時、当該カラムの昇順で並んでいるか降順で並んでいるかによって「 > 」にするか「 < 」にするかが変わる。
*
* 【説明】
* 1 〜 17 までの数値(カラム名は「col」とする)が1ページ5件”昇順”で並んでいて、現在2ページ目を表示していたとする。
*/
switch edgeOrder.Direction {
/*
* ★ 7, 8, 9, 10, 11 の昇順で並んでいる場合
*/
case models.OrderDirectionAsc:
/*
* 次ページに遷移する場合、12, 13, 14, 15, 16 を取得する条件にする必要がある。
* pageCondition.Forward.Afterが、今表示している一覧の"最終行"を示すカーソルなので、そこから「 11 」という数値が取得できる。
* 結果、「col > 11」を5件取得する条件を追加すればいい。
*/
if pageCondition.Forward != nil {
// 「このレコードよりも後のレコードを取得」という条件に使うための比較対象レコードを取得
target, err := d.getCompareTarget(pageCondition.Forward.After)
if err != nil {
return nil, err
}
targetValue := getTargetValueByOrderKey(*edgeOrder.Key.TodoOrderKey, target)
if targetValue == nil {
return nil, errors.New("no target value")
}
base = base.Where(col.GreaterThan(targetValue)).Order(col_ASC(edgeOrder)).Limit(pageCondition.Forward.First)
}
/*
* 前ページに遷移する場合、2, 3, 4, 5, 6 を取得する条件にする必要がある。
* pageCondition.Backward.Beforeが、今表示している一覧の"1行目"を示すカーソルなので、そこから「 7 」という数値が取得できる。
* 結果、「num < 7」を5件取得する条件を追加すればいい。
* ※ただし、並べ替え順を”昇順”のままにすると、小さいものから5件取得する条件である都合上、意図に反して 1, 2, 3, 4, 5 が取得される。
* そのため、いったん”降順”で並べ替えて取得した後、再度、”昇順”で並べ替え直す必要がある。
* (再度の並べ替え直しは検索結果取得後にロジックでソートかけることで実現する。)
*/
if pageCondition.Backward != nil {
// 「このレコードよりも前のレコードを取得」という条件に使うための比較対象レコードを取得
target, err := d.getCompareTarget(pageCondition.Backward.Before)
if err != nil {
return nil, err
}
targetValue := getTargetValueByOrderKey(*edgeOrder.Key.TodoOrderKey, target)
if targetValue == nil {
return nil, errors.New("no target value")
}
base = base.Where(col.LessThan(targetValue)).Order(col_DESC(edgeOrder)).Limit(pageCondition.Backward.Last)
}
/*
* ★ 11, 10, 9, 8, 7 の降順で並んでいる場合
*/
case models.OrderDirectionDesc:
/*
* 次ページに遷移する場合、6, 5, 4, 3, 2 を取得する条件にする必要がある。
* pageCondition.Forward.Afterが、今表示している一覧の"最終行"を示すカーソルなので、そこから「 7 」という数値が取得できる。
* 結果、「num < 7」を5件取得する条件を追加すればいい。
*/
if pageCondition.Forward != nil {
// 「このレコードよりも後のレコードを取得」という条件に使うための比較対象レコードを取得
target, err := d.getCompareTarget(pageCondition.Forward.After)
if err != nil {
return nil, err
}
targetValue := getTargetValueByOrderKey(*edgeOrder.Key.TodoOrderKey, target)
if targetValue == nil {
return nil, errors.New("no target value")
}
base = base.Where(col.LessThan(targetValue)).Order(col_DESC(edgeOrder)).Limit(pageCondition.Forward.First)
}
/*
* 前ページに遷移する場合、16, 15, 14, 13, 12 を取得する条件にする必要がある。
* pageCondition.Backward.Beforeが、今表示している一覧の"1行目"を示すカーソルなので、そこから「 11 」という数値が取得できる。
* 結果、「num > 11」を5件取得する条件を追加すればいい。
* ※ただし、並べ替え順を”降順”のままにすると、大きいものから5件取得する条件である都合上、意図に反して 17, 16, 15, 14, 13 が取得される。
* そのため、いったん”昇順”で並べ替えて取得した後、再度、”降順”で並べ替え直す必要がある。
* (再度の並べ替え直しは検索結果取得後にロジックでソートかけることで実現する。)
*/
if pageCondition.Backward != nil {
// 「このレコードよりも前のレコードを取得」という条件に使うための比較対象レコードを取得
target, err := d.getCompareTarget(pageCondition.Backward.Before)
if err != nil {
return nil, err
}
targetValue := getTargetValueByOrderKey(*edgeOrder.Key.TodoOrderKey, target)
if targetValue == nil {
return nil, errors.New("no target value")
}
base = base.Where(col.GreaterThan(targetValue)).Order(col_ASC(edgeOrder)).Limit(pageCondition.Backward.Last)
}
}
}
var results []*Todo
if err := base.Find(&results).Error; err != nil {
return nil, err
}
/*
* 並べ替え条件が指定されていた場合
* (ページング条件指定時に、指定した並べ替え順とは逆の順序で取得する必要が生じる都合上、いったん検索結果取得後にロジックで並べ替えることにする)
*/
if edgeOrder.ExistsOrder() {
reOrder(results, edgeOrder)
}
return results, nil
}
func reOrder(results []*Todo, edgeOrder *models.EdgeOrder) {
if results == nil {
return
}
if len(results) == 0 {
return
}
if edgeOrder.Key.TodoOrderKey == nil {
return
}
switch *edgeOrder.Key.TodoOrderKey {
case models.TodoOrderKeyText:
if edgeOrder.Direction == models.OrderDirectionAsc {
sort.Slice(results, func(i int, j int) bool {
return results[i].Text < results[j].Text
})
}
if edgeOrder.Direction == models.OrderDirectionDesc {
sort.Slice(results, func(i int, j int) bool {
return results[i].Text > results[j].Text
})
}
case models.TodoOrderKeyDone:
if edgeOrder.Direction == models.OrderDirectionAsc {
sort.Slice(results, func(i int, j int) bool {
return boolToInt(results[i].Done) < boolToInt(results[j].Done)
})
}
if edgeOrder.Direction == models.OrderDirectionDesc {
sort.Slice(results, func(i int, j int) bool {
return boolToInt(results[i].Done) > boolToInt(results[j].Done)
})
}
case models.TodoOrderKeyCreatedAt:
if edgeOrder.Direction == models.OrderDirectionAsc {
sort.Slice(results, func(i int, j int) bool {
return results[i].CreatedAt.UnixNano() < results[j].CreatedAt.UnixNano()
})
}
if edgeOrder.Direction == models.OrderDirectionDesc {
sort.Slice(results, func(i int, j int) bool {
return results[i].CreatedAt.UnixNano() > results[j].CreatedAt.UnixNano()
})
}
case models.TodoOrderKeyUserName:
}
}
func (d *todoDao) getCompareTarget(cursor *string) (*Todo, error) {
if cursor == nil {
return nil, errors.New("cursor is nil")
}
_, todoID, err := util.DecodeCursor(*cursor)
if err != nil {
return nil, err
}
// 比較対象カーソルに該当するレコードを取得
var target Todo
if err := d.db.Where(&Todo{ID: todoID}).First(&target).Error; err != nil {
return nil, err
}
return &target, nil
}
func getColumnNameByOrderKey(todoOrderKey models.TodoOrderKey) (*c, error) {
// todoテーブルのテーブル名
todo := TableName(&Todo{})
// userテーブルのテーブル名
user := TableName(&User{})
switch todoOrderKey {
case models.TodoOrderKeyText:
return Col(todo, "text"), nil
case models.TodoOrderKeyDone:
return Col(todo, "done"), nil
case models.TodoOrderKeyCreatedAt:
return Col(todo, "created_at"), nil
case models.TodoOrderKeyUserName:
return Col(user, "name"), nil
default:
return nil, errors.New("not target orderKey")
}
}
func getTargetValueByOrderKey(todoOrderKey models.TodoOrderKey, todo *Todo) interface{} {
switch todoOrderKey {
case models.TodoOrderKeyText:
return todo.Text
case models.TodoOrderKeyDone:
return todo.Done
case models.TodoOrderKeyCreatedAt:
return todo.CreatedAt
case models.TodoOrderKeyUserName:
return todo.Name
default:
return nil
}
}
func col_ASC(o *models.EdgeOrder) string {
return fmt.Sprintf("%s %s", o.Key.TodoOrderKey.Val(), models.OrderDirectionAsc.String())
}
func col_DESC(o *models.EdgeOrder) string {
return fmt.Sprintf("%s %s", o.Key.TodoOrderKey.Val(), models.OrderDirectionDesc.String())
}
文字列フィルタ、ページング条件、並べ替え条件すべて指定しての検索実行。
解説は、なるべくソース中にコメント書いたので、そちら参照で。
◆ヘルパー
package database
import "fmt"
func TableName(t Table) string {
return t.TableName()
}
func InnerJoin(tableName string) string {
return fmt.Sprintf("INNER JOIN %s ", tableName)
}
func On(formatter string, args ...interface{}) string {
return "ON " + fmt.Sprintf(formatter, args...)
}
type c struct {
col string
}
func Col(table, col string) *c {
return &c{col: fmt.Sprintf("%s.%s", table, col)}
}
func (r *c) Val() string {
return r.col
}
func (r *c) Like(matchStr string) string {
return r.col + fmt.Sprintf(" LIKE '%s'", matchStr)
}
func (r *c) GreaterThan(target interface{}) string {
// TODO: time or string が前提になってしまっている。
return fmt.Sprintf("%s > '%v'", r.col, target)
}
func (r *c) LessThan(target interface{}) string {
// TODO: time or string が前提になってしまっている。
return fmt.Sprintf("%s < '%v'", r.col, target)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
◆ユーティリティ
package util
import (
"encoding/base64"
"fmt"
"strings"
"github.com/google/uuid"
)
func CreateUniqueID() string {
return strings.Replace(uuid.New().String(), "-", "", -1)
}
func CreateCursor(modelName, uniqueKey string) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s#####%s", modelName, uniqueKey)))
}
func DecodeCursor(cursor string) (string, string, error) {
byteArray, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return "", "", err
}
elements := strings.SplitN(string(byteArray), "#####", 2)
return elements[0], elements[1], nil
}
まとめ
バグがないところまで検証しきっていない点や仕様的に端折っている点、及び、冗長なロジック、Gormの特性ももう少し生かせるはずなど、いろいろ課題はあるものの、ひとまずお題に掲げた実装は完了。