1. sky0621

    Posted

    sky0621
Changes in title
+GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,1216 @@
+# お題
+以前、以下の記事で[Relayスタイル](https://facebook.github.io/relay/graphql/connections.htm#)のお試し実装をした。
+
+- [GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)](https://qiita.com/sky0621/items/1e8823200633f2c46013)
+- [GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)
+
+ただ、「前後ページへの移動」と「任意の項目での昇順・降順並べ替え」という要件の組み合わせが想像以上の実装の煩雑さを生み、かなり消化不良だった。
+今回、使用するRDBをPostgreSQL前提とするアーキテクチャ上の縛りを入れることで、バックエンド側の実装を(前回よりは)簡略化できないか試してみた。
+
+# 今回のサンプル実装で使った言語やライブラリ等
+なお、これら個々の言語やライブラリ等についての説明はしません。
+
+## フロントエンド
+
+[前回のフロントエンドの記事](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)と同じ。
+
+- [Vue.js](https://jp.vuejs.org/index.html)
+- [Nuxt.js](https://ja.nuxtjs.org/)
+- [TypeScript](https://www.typescriptlang.org/)
+- [Nuxt TypeScript](https://typescript.nuxtjs.org/ja/)
+- [Vuetify](https://vuetifyjs.com/en/)
+- [Apollo](https://www.apollographql.com/)
+- [graphql-code-generator](https://graphql-code-generator.com/)
+
+## バックエンド
+
+- [Go](https://golang.org/)
+- [gqlgen](https://gqlgen.com/)
+- [SQL Boiler](https://github.com/volatiletech/sqlboiler)
+- [PostgreSQL](https://www.postgresql.org/docs/13/index.html)
+
+## その他
+
+- [GraphQL](https://graphql.org/)
+- [Docker](https://www.docker.com/)
+- [Docker Compose](https://docs.docker.jp/compose/toc.html)
+
+# 想定する仕様
+
+何かしらの情報(今回は`Customer`(顧客))を一覧表示するページで以下の機能を持つ。
+
+- 文字列検索フィルタ(部分一致検索)
+- 前ページ、次ページ遷移
+- 一覧表示要素での昇順、降順並べ替え
+- 一覧表示件数の変更
+
+単純に初期ページ表示時に全件取得して、オンメモリでの前ページ、次ページ遷移ではなく、都度(1ページに必要な分だけ)検索。
+以下を実行した時は、ページングの途中(例えば2ページ目を表示中)だったとしても、1ページ目の表示に戻る。
+
+- 一覧表示要素での昇順、降順並べ替え
+- 一覧表示件数の変更
+
+## 画面イメージ
+
+### 初期ページ表示時(デフォルトでは`IDの降順`で並んでいる仕様)
+![screenshot-localhost_3000-2020.11.15-23_36_44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c7b7e3b2-5090-c145-d79e-cbcf3aef5a15.png)
+
+### 2ページ目に遷移時
+![screenshot-localhost_3000-2020.11.16-00_59_48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/d25a81d7-eb37-deb9-d031-9fbcc95fab8d.png)
+
+### 検索フィルタ使用時
+![screenshot-localhost_3000-2020.11.16-01_01_41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/069c6244-b34b-9430-a0b5-7fb483d3fdcf.png)
+![screenshot-localhost_3000-2020.11.16-01_02_42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/7a07dda7-977a-7c1d-822c-96f622afa185.png)
+
+### 名前の昇順で並べ替え
+1ページ目
+![screenshot-localhost_3000-2020.11.16-01_03_40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/b55b6815-3a7b-5330-f905-b5bb587c859b.png)
+2ページ目
+![screenshot-localhost_3000-2020.11.16-01_03_54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/24e32b2b-6ca3-fb68-ff95-b37023eb121c.png)
+3ページ目
+![screenshot-localhost_3000-2020.11.16-01_04_06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c0d5467a-7dd2-abc7-c013-154da769c960.png)
+
+### 一覧表示件数を 10件 に変更(+「Age」の降順)
+1ページ目
+![screenshot-localhost_3000-2020.11.16-01_06_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/2527bce2-2e91-14ff-f702-f14504dac134.png)
+
+2ページ目
+![screenshot-localhost_3000-2020.11.16-01_06_21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f4f4b0db-0fa9-e7f0-a0eb-799114d4a3a2.png)
+
+
+# 関連記事索引
+- 第11回「[Dataloadersを使ったN+1問題への対応](https://qiita.com/sky0621/items/621f075e4257270a9e02)」
+- 第10回「[GraphQL(gqlgen)エラーハンドリング](https://qiita.com/sky0621/items/affc833e770fee8b576e)」
+- 第9回「[GraphQLにおける認証認可事例(Auth0 RBAC仕立て)](https://qiita.com/sky0621/items/efbb8fa085094230d0fd)」
+- 第8回「[GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例](https://qiita.com/sky0621/items/5c2c565678c102d9d4ed)」
+- 第7回「[GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)」
+- 第6回「[GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)](https://qiita.com/sky0621/items/1e8823200633f2c46013)」
+- 第5回「[DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動](https://qiita.com/sky0621/items/58fa2bcfc0935f6186cb)」
+- 第4回「[graphql-codegenでフロントエンドをGraphQLスキーマファースト](https://qiita.com/sky0621/items/f03631f0309f5c375b1d)」
+- 第3回「[go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)](https://qiita.com/sky0621/items/32e3e26189589080e492)」
+- 第2回「[NuxtJS(with Apollo)のTypeScript対応](https://qiita.com/sky0621/items/0297f5a026be380a5929)」
+- 第1回「[frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る](https://qiita.com/sky0621/items/8abd445edba347e8f6f1)」
+
+# 開発環境
+## # OS - Linux(Ubuntu)
+```
+$ cat /etc/os-release
+NAME="Ubuntu"
+VERSION="20.04.1 LTS (Focal Fossa)"
+```
+
+## # バックエンド
+### # 言語 - Golang
+```
+$ go version
+go version go1.15.2 linux/amd64
+```
+
+### # gqlgen
+```
+v0.13.0
+```
+
+### IDE - Goland
+```
+GoLand 2020.2.3
+Build #GO-202.7319.61, built on September 16, 2020
+```
+
+# 今回の全ソース
+
+https://github.com/sky0621/study-graphql/tree/v0.10.0/try01
+
+# 実践
+## DB
+PostgreSQL v13 をDocker Compseで動かしておく。(ローカルでしか使わないのでパスワード等、ベタ書き)
+
+```yaml:docker-compose.yml
+version: '3'
+
+services:
+ db:
+ restart: always
+ image: postgres:13-alpine
+ container_name: study-graphql-postgres-container
+ ports:
+ - "25432:5432"
+ environment:
+ - DATABASE_HOST=localhost
+ - POSTGRES_DB=study-graphql-local-db
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=yuckyjuice
+ - PGPASSWORD=yuckyjuice
+ volumes:
+ - ./local/data:/docker-entrypoint-initdb.d/
+```
+
+上記DBに「`customer`」テーブルを作成。
+
+```
+CREATE TABLE customer (
+ id bigserial NOT NULL,
+ name varchar(64) NOT NULL,
+ age int NOT NULL,
+ PRIMARY KEY (id)
+);
+```
+
+`customer`テーブルのレコードは下記。
+※画面の初期表示時と合わせて、`ID`の降順で表示。
+![Screenshot at 2020-11-16 01-09-20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/4c8fd712-3599-c368-61ce-a343a158dbc6.png)
+
+
+## GraphQLスキーマ
+Relayの部分だけなら実はそれほどややこしくないのだけど、今回は「文字列検索フィルタ」と「各要素での昇順・降順並べ替え」も組み合わせているので、ちょっと定義が入り組んでいる。
+
+```
+$ tree schema/
+schema/
+├── connection.graphql
+├── customer.graphql
+├── order.graphql
+├── pagination.graphql
+├── schema.graphql
+└── text_filter.graphql
+```
+
+### ■schema.graphql
+
+```graphql
+# Global Object Identification ... 全データを共通のIDでユニーク化
+interface Node {
+ id: ID!
+}
+
+schema {
+ query: Query
+}
+
+type Query {
+ node(id: ID!): Node
+}
+```
+
+### ■customer.graphql
+
+#### customerConnection クエリ
+```graphql
+extend type Query {
+ "Relay準拠ページング対応検索によるTODO一覧取得"
+ customerConnection(
+ "ページング条件"
+ pageCondition: PageCondition
+ "並び替え条件"
+ edgeOrder: EdgeOrder
+ "文字列フィルタ条件"
+ filterWord: TextFilterCondition
+ ): CustomerConnection
+}
+```
+
+これが、今回、フロントエンドからコールされるクエリ。
+それぞれの要素の説明は後述。
+要件に即して、以下のフィールドを持つ。
+
+- 前ページ、次ページ遷移の条件を含む「`ページング条件`」
+- 各要素の昇順、後述並べ替え条件を含む「`並べ替え条件`」
+- 文字列検索フィルタ(部分一致)用の「`文字列フィルタ条件`」
+
+そして、クエリの返却値は、Relayに準拠したConnection形式(これも後述)になっている。
+
+#### CustomerConnection
+```graphql
+"ページングを伴う結果返却用"
+type CustomerConnection implements Connection {
+ "ページ情報"
+ pageInfo: PageInfo!
+ "検索結果一覧(※カーソル情報を含む)"
+ edges: [CustomerEdge!]!
+ "検索結果の全件数"
+ totalCount: Int64!
+}
+```
+
+customerConnection クエリの実行結果格納用。
+[Relay仕様](https://relay.dev/graphql/connections.htm#)に準拠(というレベルではないかも。参考レベル。)。
+
+汎用的に扱えるように`Connection`インタフェース(後述)を実装している。
+ページ情報(`PageInfo`)については後述。
+
+#### CustomerEdge
+```graphql
+"検索結果(※カーソル情報を含む)"
+type CustomerEdge implements Edge {
+ node: Customer!
+ cursor: Cursor!
+}
+```
+
+汎用的に扱えるように`Edge`インタフェース(後述)を実装している。
+1件分の検索結果をあらわす。データ特定用の「`カーソル`」という情報を持つ。
+`Cursor`タイプについては後述。
+
+#### Customer
+```graphql
+type Customer implements Node {
+ "ID"
+ id: ID!
+ "名前"
+ name: String!
+ "年齢"
+ age: Int!
+}
+```
+
+顧客1件分をあらわす。
+
+### ■pagination.graphql
+#### PageCondition
+クエリに渡す「ページング条件」をあらわす型。
+
+```graphql
+"ページング条件"
+input PageCondition {
+ "前ページ遷移条件"
+ backward: BackwardPagination
+ "次ページ遷移条件"
+ forward: ForwardPagination
+ "現在ページ番号(今回のページング実行前の時点のもの)"
+ nowPageNo: Int64!
+ "1ページ表示件数"
+ initialLimit: Int64!
+}
+```
+
+#### BackwardPagination
+「前ページ」遷移時に渡されるページング条件。
+
+```graphql
+"前ページ遷移条件"
+input BackwardPagination {
+ "取得件数"
+ last: Int64!
+ "取得対象識別用カーソル(※前ページ遷移時にこのカーソルよりも前にあるレコードが取得対象)"
+ before: Cursor!
+}
+```
+
+#### ForwardPagination
+「次ページ」遷移時に渡されるページング条件。
+
+```graphql
+"次ページ遷移条件"
+input ForwardPagination {
+ "取得件数"
+ first: Int64!
+ "取得対象識別用カーソル(※次ページ遷移時にこのカーソルよりも後ろにあるレコードが取得対象)"
+ after: Cursor!
+}
+```
+
+#### Cursor
+カーソルには、DB検索時に振る`ROW_NUMBER`をテーブル名と組み合わせたあとURLエンコードした値を格納する。
+後述。
+
+```graphql
+"カーソル(1レコードをユニークに特定する識別子)"
+scalar Cursor
+```
+
+### ■order.graphql
+#### EdgeOrder
+クエリに渡す「並べ替え条件」をあらわす型。
+
+```graphql
+"並び替え条件"
+input EdgeOrder {
+ "並べ替えキー項目"
+ key: OrderKey!
+ "ソート方向"
+ direction: OrderDirection!
+}
+```
+
+#### OrderKey
+
+```graphql
+"""
+並べ替えのキー
+
+【検討経緯】
+汎用的な構造、かつ、タイプセーフにしたく、interface で定義の上、機能毎に input ないし enum で実装しようとした。
+しかし、input は interface を実装できない仕様だったので諦めた。
+enum に継承機能があればよかったが、それもなかった。
+union で CustomerOrderKey や(増えたら)他の機能の並べ替えのキーも | でつなぐ方法も考えたが、
+union を input に要素として持たせることはできない仕様だったので、これも諦めた。
+とはいえ、並べ替えも共通の仕組みとして提供したく、結果として機能毎の enum フィールドを共通の input 内に列挙していく形にした。
+"""
+input OrderKey {
+ "ユーザー一覧の並べ替えキー"
+ customerOrderKey: CustomerOrderKey
+}
+```
+
+#### OrderDirection
+
+```graphql
+"並べ替え方向"
+enum OrderDirection {
+ "昇順"
+ ASC
+ "降順"
+ DESC
+}
+```
+
+### ■text_filter.graphql
+#### TextFilterCondition
+クエリに渡す「文字列フィルタ条件」をあらわす型。
+
+```graphql
+"文字列フィルタ条件"
+input TextFilterCondition {
+ "フィルタ文字列"
+ filterWord: String!
+ "マッチングパターン"
+ matchingPattern: MatchingPattern!
+}
+```
+
+#### MatchingPattern
+
+```graphql
+"マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)"
+enum MatchingPattern {
+ "部分一致"
+ PARTIAL_MATCH
+ "完全一致"
+ EXACT_MATCH
+}
+```
+
+### ■connection.graphql
+
+```graphql
+scalar Int64
+
+"ページングを伴う結果返却用"
+interface Connection {
+ "ページ情報"
+ pageInfo: PageInfo!
+ "結果一覧(※カーソル情報を含む)"
+ edges: [Edge!]!
+ "検索結果の全件数"
+ totalCount: Int64!
+}
+
+"ページ情報"
+type PageInfo {
+ "次ページ有無"
+ hasNextPage: Boolean!
+ "前ページ有無"
+ hasPreviousPage: Boolean!
+ "当該ページの1レコード目"
+ startCursor: Cursor!
+ "当該ページの最終レコード"
+ endCursor: Cursor!
+}
+
+"検索結果一覧(※カーソル情報を含む)"
+interface Edge {
+ "Nodeインタフェースを実装したtypeなら代入可能"
+ node: Node!
+ cursor: Cursor!
+}
+```
+
+## バックエンド
+
+### main関数
+このへんは今回の主題ではないのでソースの提示だけ。
+
+```go:server.go
+package main
+
+import (
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/99designs/gqlgen/graphql/handler"
+ "github.com/99designs/gqlgen/graphql/playground"
+ "github.com/go-chi/chi"
+ "github.com/jmoiron/sqlx"
+ _ "github.com/lib/pq"
+ "github.com/rs/cors"
+ "github.com/sky0621/study-graphql/try01/src/backend/graph"
+ "github.com/sky0621/study-graphql/try01/src/backend/graph/generated"
+ "github.com/volatiletech/sqlboiler/v4/boil"
+)
+
+func main() {
+ // MEMO: ローカルでしか使わないので、ベタ書き
+ dsn := "host=localhost port=25432 dbname=study-graphql-local-db user=postgres password=yuckyjuice sslmode=disable"
+ db, err := sqlx.Connect("postgres", dsn)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ boil.DebugMode = true
+
+ var loc *time.Location
+ loc, err = time.LoadLocation("Asia/Tokyo")
+ if err != nil {
+ log.Fatal(err)
+ }
+ boil.SetLocation(loc)
+
+ r := chi.NewRouter()
+ r.Use(corsHandlerFunc())
+ r.Handle("/", playground.Handler("GraphQL playground", "/query"))
+ r.Handle("/query",
+ handler.NewDefaultServer(
+ generated.NewExecutableSchema(
+ generated.Config{
+ Resolvers: &graph.Resolver{
+ DB: db,
+ },
+ },
+ ),
+ ),
+ )
+
+ if err := http.ListenAndServe(":8080", r); err != nil {
+ panic(err)
+ }
+}
+
+func corsHandlerFunc() func(h http.Handler) http.Handler {
+ return cors.New(cors.Options{
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
+ ExposedHeaders: []string{"Link"},
+ AllowCredentials: true,
+ MaxAge: 300, // Maximum value not ignored by any of major browsers
+ }).Handler
+}
+```
+
+### ページング対応した顧客一覧取得リゾルバー
+これが今回の主題を担うソース。
+大まかな流れで言うと、
+1.検索用SQL文構築に必要なパラメータの構造体を定義
+2.GraphQLクライアントから「検索文字列」の指定があったら、上記構造体に反映
+3.GraphQLクライアントから「ページング」の指定(要するに初期ページ表示なのか前ページへの移動なのか次ページへの移動なのか)があったら、上記構造体に反映
+4.GraphQLクライアントから「並び順」の指定があったら、上記構造体に反映
+5.検索用SQL実行
+6.検索結果をRelay形式に変換して返却
+
+```go:graph/customer.resolvers.go(抜粋)
+func (r *queryResolver) CustomerConnection(ctx context.Context, pageCondition *model.PageCondition, edgeOrder *model.EdgeOrder, filterWord *model.TextFilterCondition) (*model.CustomerConnection, error) {
+ /*
+ * SQL構築に必要な各種要素の保持用
+ */
+ params := searchParam{
+ // 情報取得先のテーブル名
+ tableName: boiled.TableNames.Customer,
+
+ // 並び順のデフォルトはIDの降順
+ orderKey: boiled.CustomerColumns.ID,
+ orderDirection: model.OrderDirectionDesc.String(),
+ }
+
+ /*
+ * 検索文字列フィルタ設定
+ * TODO: 複数カラムにフィルタを適用したい場合など、ここで AND でつなぐか buildSearchQueryMod() を拡張するか検討が必要
+ */
+ filter := filterWord.MatchString()
+ if filter != "" {
+ params.baseCondition = fmt.Sprintf("%s LIKE '%s'", boiled.CustomerColumns.Name, filter)
+ }
+
+ /*
+ * ページング設定
+ */
+ if pageCondition.IsInitialPageView() {
+ // ページング指定無しの初期ページビュー
+ params.rowNumFrom = 1
+ params.rowNumTo = pageCondition.InitialLimit
+ } else {
+ // 前ページへの遷移指示
+ if pageCondition.Backward != nil {
+ key, err := decodeCustomerCursor(pageCondition.Backward.Before)
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ params.rowNumFrom = key - pageCondition.Backward.Last
+ params.rowNumTo = key - 1
+ }
+ // 次ページへの遷移指示
+ if pageCondition.Forward != nil {
+ key, err := decodeCustomerCursor(pageCondition.Forward.After)
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ params.rowNumFrom = key + 1
+ params.rowNumTo = key + pageCondition.Forward.First
+ }
+ }
+
+ /*
+ * 並び順の指定
+ */
+ if edgeOrder.CustomerOrderKeyExists() {
+ params.orderKey = edgeOrder.Key.CustomerOrderKey.String()
+ params.orderDirection = edgeOrder.Direction.String()
+ }
+
+ /*
+ * 検索実行
+ */
+ var records []*CustomerWithRowNum
+ if err := boiled.Customers(buildSearchQueryMod(params)).Bind(ctx, r.DB, &records); err != nil {
+ log.Print(err)
+ return nil, err
+ }
+
+ /*
+ * ページング後の次ページ、前ページの存在有無判定のために必要な
+ * 検索文字列フィルタ適用後の結果件数保持用
+ */
+ var totalCount int64 = 0
+ {
+ var err error
+ if filter == "" {
+ totalCount, err = boiled.Customers().Count(ctx, r.DB)
+ } else {
+ totalCount, err = boiled.Customers(qm.Where(boiled.CustomerColumns.Name+" LIKE ?",
+ filterWord.MatchString())).Count(ctx, r.DB)
+ }
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ }
+
+ /*
+ * Relay返却形式
+ */
+ result := &model.CustomerConnection{
+ TotalCount: totalCount,
+ }
+
+ /*
+ * 検索結果をEdgeスライス形式に変換
+ */
+ var edges []*model.CustomerEdge
+ for _, record := range records {
+ edges = append(edges, &model.CustomerEdge{
+ Node: &model.Customer{
+ ID: strconv.Itoa(int(record.ID)),
+ Name: record.Name,
+ Age: record.Age,
+ },
+ Cursor: createCursor("customer", record.RowNum),
+ })
+ }
+ result.Edges = edges
+
+ // 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
+ totalPage := pageCondition.TotalPage(totalCount)
+
+ /*
+ * クライアント側での画面表示及び次回ページングに必要な情報
+ */
+ pageInfo := &model.PageInfo{
+ HasNextPage: (totalPage - pageCondition.MoveToPageNo()) >= 1, // 遷移後も、まだ先のページがあるか
+ HasPreviousPage: pageCondition.MoveToPageNo() > 1, // 遷移後も、まだ前のページがあるか
+ }
+ if len(edges) > 0 {
+ pageInfo.StartCursor = edges[0].Cursor
+ pageInfo.EndCursor = edges[len(edges)-1].Cursor
+ }
+ result.PageInfo = pageInfo
+
+ return result, nil
+}
+```
+
+#### 検索用SQL文構築に必要なパラメータの構造体
+
+```go
+ params := searchParam{
+ // 情報取得先のテーブル名
+ tableName: boiled.TableNames.Customer,
+
+ // 並び順のデフォルトはIDの降順
+ orderKey: boiled.CustomerColumns.ID,
+ orderDirection: model.OrderDirectionDesc.String(),
+ }
+```
+
+上記の実体は、下記。基本的にGraphQLクライアントから渡された条件で上書きしていくけど、未指定時にデフォルトが必要なものについては冒頭で初期化。
+(`searchParam`を渡してSQL文を構築する関数の中でも、実は初期化してたりするのだけど)
+
+```go:search.go
+type searchParam struct {
+ orderKey string
+ orderDirection string
+ tableName string
+ baseCondition string
+ rowNumFrom int64
+ rowNumTo int64
+}
+```
+
+#### 検索文字列フィルタ設定
+
+```go
+ /*
+ * 検索文字列フィルタ設定
+ * TODO: 複数カラムにフィルタを適用したい場合など、ここで AND でつなぐか buildSearchQueryMod() を拡張するか検討が必要
+ */
+ filter := filterWord.MatchString()
+ if filter != "" {
+ params.baseCondition = fmt.Sprintf("%s LIKE '%s'", boiled.CustomerColumns.Name, filter)
+ }
+```
+
+検索用の文字列は以下の関数で構築。
+
+```go:model/expansion.go
+func (c *TextFilterCondition) MatchString() string {
+ if c == nil {
+ return ""
+ }
+ if c.FilterWord == "" {
+ return ""
+ }
+ matchStr := "%" + c.FilterWord + "%"
+ if c.MatchingPattern == MatchingPatternExactMatch {
+ matchStr = c.FilterWord
+ }
+ return matchStr
+}
+```
+
+マッチングパターンは、とりあえず完全一致と部分一致だけ用意してるけど、必要に応じて前方一致や後方一致も増やせばいい。
+
+```go:model/models_gen.go
+// マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)
+type MatchingPattern string
+
+const (
+ // 部分一致
+ MatchingPatternPartialMatch MatchingPattern = "PARTIAL_MATCH"
+ // 完全一致
+ MatchingPatternExactMatch MatchingPattern = "EXACT_MATCH"
+)
+```
+
+#### ページング設定
+
+初期ページ表示時(要するに、画面を最初に開いた時や、並び替えの項目を変えたり、一覧表示件数を変えたりしたタイミングを想定)の時は下記。
+
+```go
+ if pageCondition.IsInitialPageView() {
+ // ページング指定無しの初期ページビュー
+ params.rowNumFrom = 1
+ params.rowNumTo = pageCondition.InitialLimit
+ } else {
+ 〜〜〜
+ }
+```
+
+初期ページかどうかは以下のようにして判断。
+
+```go:model/expansion.go
+func (c *PageCondition) IsInitialPageView() bool {
+ if c == nil {
+ return true
+ }
+ return c.Backward == nil && c.Forward == nil
+}
+```
+
+続いて、前ページや次ページへの遷移時の動線は下記。
+
+```go
+ 〜〜〜
+ } else {
+ // 前ページへの遷移指示
+ if pageCondition.Backward != nil {
+ key, err := decodeCustomerCursor(pageCondition.Backward.Before)
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ params.rowNumFrom = key - pageCondition.Backward.Last
+ params.rowNumTo = key - 1
+ }
+ // 次ページへの遷移指示
+ if pageCondition.Forward != nil {
+ key, err := decodeCustomerCursor(pageCondition.Forward.After)
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ params.rowNumFrom = key + 1
+ params.rowNumTo = key + pageCondition.Forward.First
+ }
+ }
+```
+
+ここで重要なのがカーソルのデコード。
+カーソルは「`customer`+ ROW_NUMBER」の形でURLエンコードされている。
+ROW_NUMBERは、検索の内容(絞り込みだろうと並びが昇順でも降順でも)がどうであれ、その結果に対して連番が振られる想定の番号である前提。
+
+```go
+decodeCustomerCursor(~~~~)
+```
+
+以下のようにしてデコード。
+
+```go:graph/customer.go
+func decodeCustomerCursor(cursor string) (int64, error) {
+ modelName, key, err := decodeCursor(cursor)
+ if err != nil {
+ return 0, err
+ }
+ if modelName != "customer" {
+ return 0, errors.New("not customer")
+ }
+ return key, nil
+}
+```
+
+`decodeCursor(~~~~)`の定義は下記。
+
+```go:graph/util.go
+const cursorSeps = "#####"
+
+func decodeCursor(cursor string) (string, int64, error) {
+ byteArray, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ return "", 0, err
+ }
+ elements := strings.SplitN(string(byteArray), cursorSeps, 2)
+ key, err := strconv.Atoi(elements[1])
+ if err != nil {
+ return "", 0, err
+ }
+ return elements[0], int64(key), nil
+}
+```
+
+上記ロジックでどのように今回表示するページ分のレコードを取得するかについては以下のイメージを参照。
+
+```
+現在、以下の状態とする。
+・1ページあたりの表示件数は、5件
+・IDの降順で並んだ状態
+・2ページ目を表示している状態
+
+          1ページ目     2ページ目      3ページ目
+ ROW_NUMBER: [1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]
+
+■「前ページ」に遷移する指示の場合
+ 1ページ目の 1 〜 5 のレコードが欲しい。
+ pageCondition.Backward.Before をデコードしたROW_NUMBERに(2ページ目の先頭レコードを示す)[6] が入っている。
+ また、pageCondition.Backward.Last には1ページあたりの表示件数 [5件] が入っている。
+ よって、以下の計算で取得したい範囲を決める。
+ From:6 - 5 = 1
+ To :6 - 1 = 5
+
+■「次ページ」に遷移する指示の場合
+ 3ページ目の 11 〜 15 のレコードが欲しい。
+ pageCondition.Forward.After をデコードしたROW_NUMBERに(2ページ目の末尾レコードを示す)[10] が入っている。
+ また、pageCondition.Forward.First には1ページあたりの表示件数 [5件] が入っている。
+ よって、以下の計算で取得したい範囲を決める。
+ From:10 + 1 = 11
+ To :10 + 5 = 15
+```
+
+#### 並び順の指定
+
+```go
+ if edgeOrder.CustomerOrderKeyExists() {
+ params.orderKey = edgeOrder.Key.CustomerOrderKey.String()
+ params.orderDirection = edgeOrder.Direction.String()
+ }
+```
+
+`CustomerOrderKeyExists()`の定義は下記。
+
+```go:model/expansion.go
+func (o *EdgeOrder) CustomerOrderKeyExists() bool {
+ if o == nil {
+ return false
+ }
+ if o.Key == nil {
+ return false
+ }
+ if o.Key.CustomerOrderKey == nil {
+ return false
+ }
+ return o.Key.CustomerOrderKey.IsValid()
+}
+```
+
+「顧客」情報に関する並べ替えのキー候補は下記。
+
+```go:model/modege_gen.go
+type CustomerOrderKey string
+
+const (
+ // ID
+ CustomerOrderKeyID CustomerOrderKey = "ID"
+ // ユーザー名
+ CustomerOrderKeyName CustomerOrderKey = "NAME"
+ // 年齢
+ CustomerOrderKeyAge CustomerOrderKey = "AGE"
+)
+```
+
+#### 検索実行
+
+```go
+ var records []*CustomerWithRowNum
+ if err := boiled.Customers(buildSearchQueryMod(params)).Bind(ctx, r.DB, &records); err != nil {
+ log.Print(err)
+ return nil, err
+ }
+```
+
+まず、`records` としてスライスの型としている `CustomerWithRowNum` の構造体は下記。
+`boiled.Customer` は、[SQL Boiler](https://github.com/volatiletech/sqlboiler)がDBのテーブル定義から自動生成する構造体。
+これをラップして、SQL文で `row_num` として受け取る ROW_NUMBER を `RowNum` という名前で保持。
+こうすることで、SQL文の実行結果を受け取る時に、テーブル定義に即した構造体をいちいち作る必要もなく、追加したい要素だけよしなに追加できる。
+
+```go:graph/customer.go
+type CustomerWithRowNum struct {
+ RowNum int64 `boil:"row_num"`
+ boiled.Customer `boil:",bind"`
+}
+```
+
+続いて、 `buildSearchQueryMod(params)` の定義は下記。
+
+```go:graph/search.go
+// TODO: とりあえず雑に作った。複数テーブルへの対応等、どこまで汎用性を持たせるかは要件次第。
+func buildSearchQueryMod(p searchParam) qm.QueryMod {
+ if p.baseCondition == "" {
+ p.baseCondition = "true"
+ }
+ q := `
+ SELECT row_num, * FROM (
+ SELECT ROW_NUMBER() OVER (ORDER BY %s %s) AS row_num, *
+ FROM %s
+ WHERE %s
+ ) AS tmp
+ WHERE row_num BETWEEN %d AND %d
+ `
+ sql := fmt.Sprintf(q,
+ p.orderKey, p.orderDirection,
+ p.tableName,
+ p.baseCondition,
+ p.rowNumFrom, p.rowNumTo,
+ )
+ return qm.SQL(sql)
+}
+```
+
+PostgreSQLのWindow関数(`ROW_NUMBER()`)を使って、指定の文字列検索フィルタと並べ替えを適用させた結果に連番を振る。
+その結果から、欲しい範囲のROW_NUMBERを抜き出す。
+これで、並べ替えの要素が何であろうと、昇順だろうと降順だろうと関係なく、ROW_NUMBERの範囲指定で「前ページ」でも「次ページ」でも同じ仕組みで取得できる。
+
+#### 検索結果をRelay形式に変換して返却
+
+##### 検索文字列フィルタ適用後の結果件数
+コメントにある通り、検索文字列フィルタによる絞り込み検索の結果件数を取得し、ページ遷移後、まだ前(次)のページが存在するかどうか(※この情報を返すことにより、フロントエンドではUIデザイン上、[Prev]ボタンや[Next]ボタンの活性・非活性を制御できる。
+
+```go
+ /*
+ * ページング後の次ページ、前ページの存在有無判定のために必要な
+ * 検索文字列フィルタ適用後の結果件数保持用
+ */
+ var totalCount int64 = 0
+ {
+ var err error
+ if filter == "" {
+ totalCount, err = boiled.Customers().Count(ctx, r.DB)
+ } else {
+ totalCount, err = boiled.Customers(qm.Where(boiled.CustomerColumns.Name+" LIKE ?",
+ filterWord.MatchString())).Count(ctx, r.DB)
+ }
+ if err != nil {
+ log.Print(err)
+ return nil, err
+ }
+ }
+```
+
+[SQL Boiler](https://github.com/volatiletech/sqlboiler)を使うと、自動生成されたソースを使って `boiled.Customers().Count(ctx, r.DB)` と書くだけで `customer` テーブルの全件数が取得できる。
+検索条件を追加したい場合は上記ソースの通り、 `boiled.Customers(xxxx)` の `xxxx` の部分にSQL Boilerが用意した記述方法で書けばいいだけ。
+
+##### Relay返却形式
+
+Relayが求める返却形式では、必要なのは「`edges`」と「`pageInfo`」だけなのだけど、UIデザイン上、件数も普通は欲しいよねということで `totalCount` も定義。
+https://relay.dev/graphql/connections.htm#sec-Connection-Types
+
+```go
+ /*
+ * Relay返却形式
+ */
+ result := &model.CustomerConnection{
+ TotalCount: totalCount,
+ }
+```
+
+##### edges
+
+カーソルのデコードは先述の通りだけど、エンコードの方は、ここで登場。
+検索結果1件1件に対して `ROW_NUMBER` からカーソルを生成。
+これをフロントエンドに返すことによりフロントエンド側では次回ページ遷移時に(特に取得範囲を指定したりすることなく)単にカーソルをパラメータに付与するだけでページングが実現できる。
+
+```go
+ /*
+ * 検索結果をEdgeスライス形式に変換
+ */
+ var edges []*model.CustomerEdge
+ for _, record := range records {
+ edges = append(edges, &model.CustomerEdge{
+ Node: &model.Customer{
+ ID: strconv.Itoa(int(record.ID)),
+ Name: record.Name,
+ Age: record.Age,
+ },
+ Cursor: createCursor("customer", record.RowNum),
+ })
+ }
+ result.Edges = edges
+```
+
+`CustomerEdge` は以下の構造。
+
+```go:model/models_gen.go
+// 検索結果一覧(※カーソル情報を含む)
+type CustomerEdge struct {
+ Node *Customer `json:"node"`
+ Cursor string `json:"cursor"`
+}
+```
+
+`createCursor(modelName, key)` の定義は下記。
+
+```go:graph/util.go
+const cursorSeps = "#####"
+
+func createCursor(modelName string, key int64) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s%s%d", modelName, cursorSeps, key)))
+}
+```
+
+##### pageInfo
+
+ここで計算して返却する情報があるから、フロントエンドでの処理が軽量化される。
+ページ情報として必要なのは下記。
+
+```go:model/models_gen.go
+// ページ情報
+type PageInfo struct {
+ // 次ページ有無
+ HasNextPage bool `json:"hasNextPage"`
+ // 前ページ有無
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ // 当該ページの1レコード目
+ StartCursor string `json:"startCursor"`
+ // 当該ページの最終レコード
+ EndCursor string `json:"endCursor"`
+}
+```
+
+まず、「次ページ有無」を判定するために「総ページ数」を算出。
+
+```go
+ // 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
+ totalPage := pageCondition.TotalPage(totalCount)
+```
+
+`TotalPage(~~)`の定義は下記。
+
+```go:model/expansion.go
+func (c *PageCondition) TotalPage(totalCount int64) int64 {
+ if c == nil {
+ return 0
+ }
+ var targetCount int64 = 0
+ if c.Backward == nil && c.Forward == nil {
+ 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)))
+}
+```
+
+上記を使って「次ページ有無」は以下のように判定できる。
+
+```go
+ /*
+ * クライアント側での画面表示及び次回ページングに必要な情報
+ */
+ pageInfo := &model.PageInfo{
+ HasNextPage: (totalPage - pageCondition.MoveToPageNo()) >= 1, // 遷移後も、まだ先のページがあるか
+ HasPreviousPage: pageCondition.MoveToPageNo() > 1, // 遷移後も、まだ前のページがあるか
+ }
+```
+
+上記の「前ページ有無」の判定でも使われている`MoveToPageNo()`の定義は下記。
+
+```go:model/expansion.go
+func (c *PageCondition) MoveToPageNo() int64 {
+ 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 // 想定外のため初期ページ
+}
+```
+
+あとは、今回の検索で表示するページのレコードから最初と最後のカーソルを別途抜き出す。
+
+```go
+ if len(edges) > 0 {
+ pageInfo.StartCursor = edges[0].Cursor
+ pageInfo.EndCursor = edges[len(edges)-1].Cursor
+ }
+ result.PageInfo = pageInfo
+```
+
+このカーソルは、フロントエンドで次回ページ遷移時に「前ページ」に遷移する場合は「`StartCursor`」が、「次ページ」に遷移する場合は「`EndCursor`」が使われることになる。
+
+```
+PageCondition
+ Backward
+ Before ・・・ StartCursor
+ Forward
+ After ・・・ EndCursor
+```
+
+## フロントエンド
+ソースは下記。
+https://github.com/sky0621/study-graphql/tree/v0.10.0/try01/src/frontend
+
+こちらは以前書いた記事と構造は同じなので説明は省略。
+以下を参考にしてもらえれば。
+[GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)
+
+## 動作確認
+
+### 初回ページ表示時(IDの降順)
+
+#### DBの状態
+![Screenshot at 2020-11-16 23-12-21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/d9efc0f6-a32b-2ea0-e70f-f15c2c19116c.png)
+
+#### 画面遷移結果
+##### 1ページ目
+![screenshot-localhost_3000-2020.11.16-23_17_37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/8b1cffad-d212-2ea3-61d1-5c310da00feb.png)
+
+##### 2ページ目
+![screenshot-localhost_3000-2020.11.16-23_17_55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/b2e24220-f146-8cda-5157-188b2e97fc68.png)
+
+##### 3ページ目
+![screenshot-localhost_3000-2020.11.16-23_18_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/0830be4c-82c1-008a-8eac-dcbd284bbfe8.png)
+
+3ページ目のGraphQLレスポンスデータ
+![Screenshot at 2020-11-16 23-21-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/b08993d7-5cee-670c-2166-ebead3dcbc4f.png)
+
+##### 2ページ目に戻る
+![screenshot-localhost_3000-2020.11.16-23_24_45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/dc8aae67-5aac-6a48-c977-7a81e8a0d9a9.png)
+
+
+### IDの昇順に変更
+
+#### DBの状態
+![Screenshot at 2020-11-16 23-25-51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/57e209f1-d859-2d92-ed88-13299bec4291.png)
+
+#### 画面遷移結果
+##### 1ページ目
+![screenshot-localhost_3000-2020.11.16-23_26_42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/8e90aa21-2643-b45d-ac77-44181c9291e8.png)
+
+##### 2ページ目
+![screenshot-localhost_3000-2020.11.16-23_26_54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/679db0f1-bc32-3ffd-1571-9893cf8e3a64.png)
+
+##### 3ページ目
+![screenshot-localhost_3000-2020.11.16-23_27_05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/5d94a530-6008-f72d-6a13-940ea0ac1ad9.png)
+
+##### 2ページ目に戻る
+![screenshot-localhost_3000-2020.11.16-23_27_18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f43fe20d-7f30-20fd-0c4d-242eaeaf1b37.png)
+
+### Nameの降順に変更
+
+#### DBの状態
+![Screenshot at 2020-11-16 23-29-36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c0b49c36-8054-6b72-7ade-aa223f34a2e1.png)
+
+#### 画面遷移結果
+##### 1ページ目
+![screenshot-localhost_3000-2020.11.16-23_31_17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/9a85adcf-7e3b-a6b7-e09e-7b8d6e4d15cd.png)
+
+##### 2ページ目
+![screenshot-localhost_3000-2020.11.16-23_31_29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/555bcb0a-20bf-5e59-3621-d57658551891.png)
+
+##### 3ページ目
+![screenshot-localhost_3000-2020.11.16-23_31_40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/a430b7e1-3884-9b86-c06d-edba68bb0b2f.png)
+
+##### 2ページ目に戻る
+![screenshot-localhost_3000-2020.11.16-23_32_22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/03cc949f-9ff6-26c6-3b9a-81c307e4134f.png)
+
+### Ageの昇順、及び、1ページ表示件数「10件」に変更
+
+#### DBの状態
+![Screenshot at 2020-11-16 23-33-46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/cc21c60b-5c23-963c-ffb5-33755206a230.png)
+
+#### 画面遷移結果
+##### 1ページ目
+![screenshot-localhost_3000-2020.11.16-23_35_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c2caaa0a-3889-c237-0fde-8cc98b043d1f.png)
+
+##### 2ページ目
+![screenshot-localhost_3000-2020.11.16-23_35_22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/7cb00d24-b8f4-2ff3-b375-dc7945cad33c.png)
+
+##### 1ページ目に戻る
+![screenshot-localhost_3000-2020.11.16-23_35_34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f7220d90-f421-0061-e595-ec74fcca45f3.png)
+
+### Nameの昇順、及び、「`k`」でフィルタに変更
+
+#### DBの状態
+![Screenshot at 2020-11-16 23-39-52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/ad97ad1f-3196-6afe-ccc2-013c35b8bc9f.png)
+
+#### 画面遷移結果
+![screenshot-localhost_3000-2020.11.16-23_40_35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/c5b11e7b-ee01-114c-7964-366924f19de4.png)
+
+# まとめ
+これでひとまず「顧客一覧」というページにおけるページング(及び要素別の並べ替えや文字列検索フィルタの組み合わせ)が実現できた。
+バックエンドの実装としても、[前回](https://qiita.com/sky0621/items/1e8823200633f2c46013)のように並べ替え要素の値をCursorに持つ(という暴挙)こともなく一律同じ形式でSQLが叩けるようになった。
+ただ、これを機能毎に量産していくのは、あまりにもボイラープレートが過ぎるので、実際に使う際はなるべくテンプレート化しておく必要がある。
+あと、ソース中にもコメントでTODO書いてたりするけど、いろいろ課題はある。