1. sky0621

    Posted

    sky0621
Changes in title
+GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)
Changes in tags
Changes in body
Source | HTML | Preview

お題

以前、以下の記事でRelayスタイルのお試し実装をした。

ただ、「前後ページへの移動」と「任意の項目での昇順・降順並べ替え」という要件の組み合わせが想像以上の実装の煩雑さを生み、かなり消化不良だった。
今回、使用するRDBをPostgreSQL前提とするアーキテクチャ上の縛りを入れることで、バックエンド側の実装を(前回よりは)簡略化できないか試してみた。

今回のサンプル実装で使った言語やライブラリ等

なお、これら個々の言語やライブラリ等についての説明はしません。

フロントエンド

前回のフロントエンドの記事と同じ。

バックエンド

その他

想定する仕様

何かしらの情報(今回はCustomer(顧客))を一覧表示するページで以下の機能を持つ。

  • 文字列検索フィルタ(部分一致検索)
  • 前ページ、次ページ遷移
  • 一覧表示要素での昇順、降順並べ替え
  • 一覧表示件数の変更

単純に初期ページ表示時に全件取得して、オンメモリでの前ページ、次ページ遷移ではなく、都度(1ページに必要な分だけ)検索。
以下を実行した時は、ページングの途中(例えば2ページ目を表示中)だったとしても、1ページ目の表示に戻る。

  • 一覧表示要素での昇順、降順並べ替え
  • 一覧表示件数の変更

画面イメージ

初期ページ表示時(デフォルトではIDの降順で並んでいる仕様)

screenshot-localhost_3000-2020.11.15-23_36_44.png

2ページ目に遷移時

screenshot-localhost_3000-2020.11.16-00_59_48.png

検索フィルタ使用時

screenshot-localhost_3000-2020.11.16-01_01_41.png
screenshot-localhost_3000-2020.11.16-01_02_42.png

名前の昇順で並べ替え

1ページ目
screenshot-localhost_3000-2020.11.16-01_03_40.png
2ページ目
screenshot-localhost_3000-2020.11.16-01_03_54.png
3ページ目
screenshot-localhost_3000-2020.11.16-01_04_06.png

一覧表示件数を 10件 に変更(+「Age」の降順)

1ページ目
screenshot-localhost_3000-2020.11.16-01_06_09.png

2ページ目
screenshot-localhost_3000-2020.11.16-01_06_21.png

関連記事索引

開発環境

# 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で動かしておく。(ローカルでしか使わないのでパスワード等、ベタ書き)

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

GraphQLスキーマ

Relayの部分だけなら実はそれほどややこしくないのだけど、今回は「文字列検索フィルタ」と「各要素での昇順・降順並べ替え」も組み合わせているので、ちょっと定義が入り組んでいる。

$ tree schema/
schema/
├── connection.graphql
├── customer.graphql
├── order.graphql
├── pagination.graphql
├── schema.graphql
└── text_filter.graphql

■schema.graphql

# Global Object Identification ... 全データを共通のIDでユニーク化
interface Node {
    id: ID!
}

schema {
    query: Query
}

type Query {
    node(id: ID!): Node
}

■customer.graphql

customerConnection クエリ

extend type Query {
  "Relay準拠ページング対応検索によるTODO一覧取得"
  customerConnection(
    "ページング条件"
    pageCondition: PageCondition
    "並び替え条件"
    edgeOrder: EdgeOrder
    "文字列フィルタ条件"
    filterWord: TextFilterCondition
  ): CustomerConnection
}

これが、今回、フロントエンドからコールされるクエリ。
それぞれの要素の説明は後述。
要件に即して、以下のフィールドを持つ。

  • 前ページ、次ページ遷移の条件を含む「ページング条件
  • 各要素の昇順、後述並べ替え条件を含む「並べ替え条件
  • 文字列検索フィルタ(部分一致)用の「文字列フィルタ条件

そして、クエリの返却値は、Relayに準拠したConnection形式(これも後述)になっている。

CustomerConnection

"ページングを伴う結果返却用"
type CustomerConnection implements Connection {
  "ページ情報"
  pageInfo: PageInfo!
  "検索結果一覧(※カーソル情報を含む)"
  edges: [CustomerEdge!]!
  "検索結果の全件数"
  totalCount: Int64!
}

customerConnection クエリの実行結果格納用。
Relay仕様に準拠(というレベルではないかも。参考レベル。)。

汎用的に扱えるようにConnectionインタフェース(後述)を実装している。
ページ情報(PageInfo)については後述。

CustomerEdge

"検索結果(※カーソル情報を含む)"
type CustomerEdge implements Edge {
  node: Customer!
  cursor: Cursor!
}

汎用的に扱えるようにEdgeインタフェース(後述)を実装している。
1件分の検索結果をあらわす。データ特定用の「カーソル」という情報を持つ。
Cursorタイプについては後述。

Customer

type Customer implements Node {
  "ID"
  id: ID!
  "名前"
  name: String!
  "年齢"
  age: Int!
}

顧客1件分をあらわす。

■pagination.graphql

PageCondition

クエリに渡す「ページング条件」をあらわす型。

"ページング条件"
input PageCondition {
    "前ページ遷移条件"
    backward: BackwardPagination
    "次ページ遷移条件"
    forward: ForwardPagination
    "現在ページ番号(今回のページング実行前の時点のもの)"
    nowPageNo: Int64!
    "1ページ表示件数"
    initialLimit: Int64!
}

BackwardPagination

「前ページ」遷移時に渡されるページング条件。

"前ページ遷移条件"
input BackwardPagination {
    "取得件数"
    last: Int64!
    "取得対象識別用カーソル(※前ページ遷移時にこのカーソルよりも前にあるレコードが取得対象)"
    before: Cursor!
}

ForwardPagination

「次ページ」遷移時に渡されるページング条件。

"次ページ遷移条件"
input ForwardPagination {
    "取得件数"
    first: Int64!
    "取得対象識別用カーソル(※次ページ遷移時にこのカーソルよりも後ろにあるレコードが取得対象)"
    after: Cursor!
}

Cursor

カーソルには、DB検索時に振るROW_NUMBERをテーブル名と組み合わせたあとURLエンコードした値を格納する。
後述。

"カーソル(1レコードをユニークに特定する識別子)"
scalar Cursor

■order.graphql

EdgeOrder

クエリに渡す「並べ替え条件」をあらわす型。

"並び替え条件"
input EdgeOrder {
    "並べ替えキー項目"
    key: OrderKey!
    "ソート方向"
    direction: OrderDirection!
}

OrderKey

"""
並べ替えのキー

【検討経緯】
汎用的な構造、かつ、タイプセーフにしたく、interface で定義の上、機能毎に input ないし enum で実装しようとした。
しかし、input は interface を実装できない仕様だったので諦めた。
enum に継承機能があればよかったが、それもなかった。
union で CustomerOrderKey や(増えたら)他の機能の並べ替えのキーも | でつなぐ方法も考えたが、
union を input に要素として持たせることはできない仕様だったので、これも諦めた。
とはいえ、並べ替えも共通の仕組みとして提供したく、結果として機能毎の enum フィールドを共通の input 内に列挙していく形にした。
"""
input OrderKey {
    "ユーザー一覧の並べ替えキー"
    customerOrderKey: CustomerOrderKey
}

OrderDirection

"並べ替え方向"
enum OrderDirection {
    "昇順"
    ASC
    "降順"
    DESC
}

■text_filter.graphql

TextFilterCondition

クエリに渡す「文字列フィルタ条件」をあらわす型。

"文字列フィルタ条件"
input TextFilterCondition {
    "フィルタ文字列"
    filterWord: String!
    "マッチングパターン"
    matchingPattern: MatchingPattern!
}

MatchingPattern

"マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)"
enum MatchingPattern {
    "部分一致"
    PARTIAL_MATCH
    "完全一致"
    EXACT_MATCH
}

■connection.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関数

このへんは今回の主題ではないのでソースの提示だけ。

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形式に変換して返却

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文構築に必要なパラメータの構造体

    params := searchParam{
        // 情報取得先のテーブル名
        tableName: boiled.TableNames.Customer,

        // 並び順のデフォルトはIDの降順
        orderKey:       boiled.CustomerColumns.ID,
        orderDirection: model.OrderDirectionDesc.String(),
    }

上記の実体は、下記。基本的にGraphQLクライアントから渡された条件で上書きしていくけど、未指定時にデフォルトが必要なものについては冒頭で初期化。
searchParamを渡してSQL文を構築する関数の中でも、実は初期化してたりするのだけど)

search.go
type searchParam struct {
    orderKey       string
    orderDirection string
    tableName      string
    baseCondition  string
    rowNumFrom     int64
    rowNumTo       int64
}

検索文字列フィルタ設定

    /*
     * 検索文字列フィルタ設定
     * TODO: 複数カラムにフィルタを適用したい場合など、ここで AND でつなぐか buildSearchQueryMod() を拡張するか検討が必要
     */
    filter := filterWord.MatchString()
    if filter != "" {
        params.baseCondition = fmt.Sprintf("%s LIKE '%s'", boiled.CustomerColumns.Name, filter)
    }

検索用の文字列は以下の関数で構築。

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
}

マッチングパターンは、とりあえず完全一致と部分一致だけ用意してるけど、必要に応じて前方一致や後方一致も増やせばいい。

model/models_gen.go
// マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)
type MatchingPattern string

const (
    // 部分一致
    MatchingPatternPartialMatch MatchingPattern = "PARTIAL_MATCH"
    // 完全一致
    MatchingPatternExactMatch MatchingPattern = "EXACT_MATCH"
)

ページング設定

初期ページ表示時(要するに、画面を最初に開いた時や、並び替えの項目を変えたり、一覧表示件数を変えたりしたタイミングを想定)の時は下記。

    if pageCondition.IsInitialPageView() {
        // ページング指定無しの初期ページビュー
        params.rowNumFrom = 1
        params.rowNumTo = pageCondition.InitialLimit
    } else {
        〜〜〜
    }

初期ページかどうかは以下のようにして判断。

model/expansion.go
func (c *PageCondition) IsInitialPageView() bool {
    if c == nil {
        return true
    }
    return c.Backward == nil && c.Forward == nil
}

続いて、前ページや次ページへの遷移時の動線は下記。

        〜〜〜
    } 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は、検索の内容(絞り込みだろうと並びが昇順でも降順でも)がどうであれ、その結果に対して連番が振られる想定の番号である前提。

decodeCustomerCursor(~~~~)

以下のようにしてデコード。

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(~~~~)の定義は下記。

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

並び順の指定

    if edgeOrder.CustomerOrderKeyExists() {
        params.orderKey = edgeOrder.Key.CustomerOrderKey.String()
        params.orderDirection = edgeOrder.Direction.String()
    }

CustomerOrderKeyExists()の定義は下記。

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()
}

「顧客」情報に関する並べ替えのキー候補は下記。

model/modege_gen.go
type CustomerOrderKey string

const (
    // ID
    CustomerOrderKeyID CustomerOrderKey = "ID"
    // ユーザー名
    CustomerOrderKeyName CustomerOrderKey = "NAME"
    // 年齢
    CustomerOrderKeyAge CustomerOrderKey = "AGE"
)

検索実行

    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がDBのテーブル定義から自動生成する構造体。
これをラップして、SQL文で row_num として受け取る ROW_NUMBER を RowNum という名前で保持。
こうすることで、SQL文の実行結果を受け取る時に、テーブル定義に即した構造体をいちいち作る必要もなく、追加したい要素だけよしなに追加できる。

graph/customer.go
type CustomerWithRowNum struct {
    RowNum          int64 `boil:"row_num"`
    boiled.Customer `boil:",bind"`
}

続いて、 buildSearchQueryMod(params) の定義は下記。

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]ボタンの活性・非活性を制御できる。

    /*
     * ページング後の次ページ、前ページの存在有無判定のために必要な
     * 検索文字列フィルタ適用後の結果件数保持用
     */
    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を使うと、自動生成されたソースを使って 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

    /*
     * Relay返却形式
     */
    result := &model.CustomerConnection{
        TotalCount: totalCount,
    }
edges

カーソルのデコードは先述の通りだけど、エンコードの方は、ここで登場。
検索結果1件1件に対して ROW_NUMBER からカーソルを生成。
これをフロントエンドに返すことによりフロントエンド側では次回ページ遷移時に(特に取得範囲を指定したりすることなく)単にカーソルをパラメータに付与するだけでページングが実現できる。

    /*
     * 検索結果を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 は以下の構造。

model/models_gen.go
// 検索結果一覧(※カーソル情報を含む)
type CustomerEdge struct {
    Node   *Customer `json:"node"`
    Cursor string    `json:"cursor"`
}

createCursor(modelName, key) の定義は下記。

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

ここで計算して返却する情報があるから、フロントエンドでの処理が軽量化される。
ページ情報として必要なのは下記。

model/models_gen.go
// ページ情報
type PageInfo struct {
    // 次ページ有無
    HasNextPage bool `json:"hasNextPage"`
    // 前ページ有無
    HasPreviousPage bool `json:"hasPreviousPage"`
    // 当該ページの1レコード目
    StartCursor string `json:"startCursor"`
    // 当該ページの最終レコード
    EndCursor string `json:"endCursor"`
}

まず、「次ページ有無」を判定するために「総ページ数」を算出。

    // 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
    totalPage := pageCondition.TotalPage(totalCount)

TotalPage(~~)の定義は下記。

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)))
}

上記を使って「次ページ有無」は以下のように判定できる。

    /*
     * クライアント側での画面表示及び次回ページングに必要な情報
     */
    pageInfo := &model.PageInfo{
        HasNextPage:     (totalPage - pageCondition.MoveToPageNo()) >= 1, // 遷移後も、まだ先のページがあるか
        HasPreviousPage: pageCondition.MoveToPageNo() > 1,                // 遷移後も、まだ前のページがあるか
    }

上記の「前ページ有無」の判定でも使われているMoveToPageNo()の定義は下記。

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 // 想定外のため初期ページ
}

あとは、今回の検索で表示するページのレコードから最初と最後のカーソルを別途抜き出す。

    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スタイルによるページング実装(後編:フロントエンド)

動作確認

初回ページ表示時(IDの降順)

DBの状態

Screenshot at 2020-11-16 23-12-21.png

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_17_37.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_17_55.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_18_09.png

3ページ目のGraphQLレスポンスデータ
Screenshot at 2020-11-16 23-21-30.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_24_45.png

IDの昇順に変更

DBの状態

Screenshot at 2020-11-16 23-25-51.png

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_26_42.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_26_54.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_27_05.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_27_18.png

Nameの降順に変更

DBの状態

Screenshot at 2020-11-16 23-29-36.png

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_31_17.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_31_29.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_31_40.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_32_22.png

Ageの昇順、及び、1ページ表示件数「10件」に変更

DBの状態

Screenshot at 2020-11-16 23-33-46.png

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_35_09.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_35_22.png

1ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_35_34.png

Nameの昇順、及び、「k」でフィルタに変更

DBの状態

Screenshot at 2020-11-16 23-39-52.png

画面遷移結果

screenshot-localhost_3000-2020.11.16-23_40_35.png

まとめ

これでひとまず「顧客一覧」というページにおけるページング(及び要素別の並べ替えや文字列検索フィルタの組み合わせ)が実現できた。
バックエンドの実装としても、前回のように並べ替え要素の値をCursorに持つ(という暴挙)こともなく一律同じ形式でSQLが叩けるようになった。
ただ、これを機能毎に量産していくのは、あまりにもボイラープレートが過ぎるので、実際に使う際はなるべくテンプレート化しておく必要がある。
あと、ソース中にもコメントでTODO書いてたりするけど、いろいろ課題はある。