Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

Go の新機能 Range Over Func を使って database/sql の Query を楽にする

Go 1.22 から実験的機能として Range Over Func が実装された。 このブログでは Range Over Func とは何か、どういうものなのか、といった説明はしないので、知らない人は次のページを見てほしい。

go.dev

標準パッケージの database/sqlRDB を Query するのはボイラープレートまみれになる。

database/sql のドキュメントにあるサンプルコードは次のようになっている。

rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

names := make([]string, 0)
for rows.Next() {
    var name string
    if err := rows.Scan(&name); err != nil {
        log.Fatal(err)
    }
    names = append(names, name)
}
// Check for errors from iterating over rows.
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

https://pkg.go.dev/database/sql#example-Rows から引用。

このコードは長い。 error チェックを3回もしているのが大きな要因だ。さすがに SELECT 文の実行という定型的なことに毎回こんなコードを書きたくない。面倒だ。

だから、このサンプルコードのような (*DB).QueryContext(*Rows).Next(*Rows).Scan の一連の流れを、関数として独立して、いかなるクエリでもボイラープレートを書かずに済ましたい。以下このような関数の名前を QueryScan にする。

よし、 QueryScan を書こう!

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) ... {
    ...???
}

…って、あれ?この関数の実装ってそんな簡単じゃない?

そう、自明ではない問題がいくつかある。 もっとも大きな問題は (*Rows).Scan の引数がクエリごとに違うこと。型も引数の数もクエリごとに違う。これらの情報をどうやって渡すの?そして返り値の型はどうするの? reflection つかうの?あれ面倒くさいなこれ。となる。

普通の開発ではサードパーティSQL ライブラリを使うのがいいんだろうけど、今回それは置いておこう。また reflect パッケージで頑張ってつくるのも今回はしない。QueryScan 関数は画面に収まる程度にしたい。

このブログではそれらの問題を Range Over Func を使うことで、まあまあ解決した関数 QueryScan を提案する。それは (*DB).QueryContext(*Rows).Next の一連の流れを、関数としてまとめたものだ。

Range Over Func を使った実装

提案する関数は次のように定義される。

type scanfunc func(dest ...any)

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) iter.Seq2[scanfunc, error]

この関数の実装は後で紹介する。

そして QueryScan 関数を使うと、このブログで最初に提示したサンプルコードは次のようになる。

names := make([]string, 0)
for scan, err := range QueryScan(ctx, db, `SELECT name FROM users WHERE age=?`, age) {
    if err != nil {
        log.Fatal(err)
    }
    var name string
    scan(&name) // scan には返り値がない。返り値を無視しているわけではない。
    names = append(names, name)
}

うおおおおおお

error のチェックが3回から1回に減って、コード行数も16 行から 9行に減っている。 コードが短い分読みやすいのではないだろうか。

この関数のポイントとしては (*Rows).Scan 自体は結局手で書かないといけないところだ。エラーの返り値がないラップされた関数 scan を使っているので多少は楽になっているものの、この部分の面倒さは残る。しかしながら、 (*Rows).Scan の引数がクエリごとに違うという問題は解決している。

では QueryScan はどのような実装になっているのだろうか。中身を見てみよう。

type scanfunc func(dest ...any) // 再掲

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) iter.Seq2[scanfunc, error] {
    return func(yield func(scanfunc, error) bool) {
        rows, err := db.QueryContext(ctx, query, args...)
        if err != nil {
            yield(nil, err)
            return
        }
        defer rows.Close()

        for rows.Next() {
            var scanErr error
            if !yield(func(dest ...any) {
                scanErr = rows.Scan(dest...)
            }, nil) {
                return // ここは困る!scanErr が渡せないからだ。後述する。
            }

            if scanErr != nil {
                yield(nil, scanErr)
                return
            }
        }

        if err := rows.Err(); err != nil {
            yield(nil, err)
            return
        }
    }
}

QueryScan の実装は、最初に引用した pkg.go.dev にあるサンプルコードと似ていることに気がつくだろうか。むしろほとんど一緒である。違うのは (*Rows).Scan (をラップした関数)と error を yield に渡しているところだ。

yield 関数が呼ばれるたび、 range over func の for 文の中身が1回実行される。言い換えると、for のループが1回まわる。QueryScandb.QueryContext とそのエラー処理をしているところに注目してみよう。つぎの部分だ。

    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        yield(nil, err)
        return
    }

yield が1回だけ実行されるので、 QueryScan のユーザー(呼び出し側)から見ると、ループが1回だけまわって終了しているように見える。このループで渡される値は、(nil, err) だ。つまりエラーだけを渡している。

yield を呼び出している別のところも見てみよう。たとえば、 rows.Scan が失敗するとどうなるのか? あるループの中で、 scan が失敗すると、次にもう1回だけ scanErr を渡すためのループが実行される。yield(nil, scanErr)の部分だ。そしてループは終了する。

この QueryScan の問題点

提案しておいてなんだが、この QueryScan は面白いものの、Range Over Func のハックだなぁとは思う。オーバーエンジニアリングかもしれない。また問題点もある。

大きな問題点は、for で error が渡されたときだけ break すると仮定していることだ。 error が渡されたループで break するのは問題はない。(というか error が渡されたときは最後のループなので、 break してもしなくても同じだ。)しかしながら、正常系つまり nil error が渡された時に break されると困ることがある。なぜなら次のループで、 error が渡されるかもしれないが、それを無視することになるからだ。 前述した QueryScan の実装で「ここは困る!」とコメントした部分だ。

for scan, err := range QueryScan(...) {
    if err != nil {
        log.Print(err)
        break  // ここの break は問題がない。 return でもよい。
    }
    ...

    // もしかすると、次のループで error が渡ってくるかもしれない。
    // ここで break するとその error チェックができない。
    break
}

このような break をするケースはほとんどないだろうけど。

一応解決策はある。それは break ができてしまう range over func をやめること。この記事の前提を崩すように見えるが、そんなこともない。QueryScan の返り値を iter.Seq2 すなわち

func(yield func(scanfunc, error) bool)

ではなく、

func(yield func(scanfunc)) error

で書くということだ。 Range Over Func は yield の返り値の bool で break かどうか分岐していた。 yield の返り値がなくなったので、 break できなくなった。また error をかえす場所を変えた。後者をかえす QueryScan の実装は宿題とする。

for 文では書けなくなるが、考え方自体は変わっていない。たとえば次のように呼び出すことになる。

err := QueryScan(ctx, db, `SELECT name FROM users WHERE age=?`, age)(func(scan scanfunc) {
    var name string
    scan(&name)
    names = append(names, name)
})

こっちの方が好きな人がいるかもしれない。こちらはbreak の問題がなくなり、エラーの渡し方も標準的だ。僕もこっちの方が好きかもしれない。

まあこのブログは新機能の range over func をせっかくなら使ってみたという欲張りがあったので、最初は range over func を使った手法を紹介したのだった。