database/sql に Rows.Scan と同じ型変換ロジックを呼び出せる ConvertAssign 関数が追加され、ドライバが独自のスキャン実装から標準の変換ルールへフォールバックできるようになった。
database/sql
概要
database/sql パッケージに新しい関数 ConvertAssign が追加された。これは Rows.Scan が内部で使っている「driver.Value から呼び出し側が指定した型へ変換する」ロジックを、SQLドライバの実装からも呼び出せるようにするものだ。従来この変換処理は非公開の convertAssign 関数としてパッケージ内部に閉じており、ドライバが独自のスキャン処理を実装する際に標準の変換ルールへフォールバックする公式な手段がなかった。
導入経緯
発端は issue #67546 で、pgx ドライバの作者が「ドライバが Rows.Scan のスキャン処理を完全にオーバーライドできるようにしたい」と提案したことに遡る。当初案は ScanColumn が ErrSkip を返すことで標準の変換処理へフォールバックする設計だったが、議論の中で database/sql 内部の変換ロジックそのものを ConvertAssign として公開し、ドライバが直接それを呼び出す設計に置き換えられた。改訂版の提案 では「ScanColumn は ConvertAssign を使って標準の変換動作へフォールバックする」ことが明記されている。
この新設計は一度 Go 1.26 向けに accepted となったが、Rows.Next の契約(dest を必ず埋める)と非互換になる懸念からロールバックされた。その後 NextRow/ScanColumn を分離する再設計が進む過程で、*sql.Rows(カーソル)への代入を扱うために ConvertAssign へ driver.ScanContext を経由させる構成に落ち着き、プロポーザルレビューでも ScanContext をより型安全にすることが求められた。最終的にこの形で accepted となっている。
議論タイムライン
- 2024年7月:
driver.RowsColumnScannerの初回提案がレビューを経て accepted となる。 - 2025年12月: Go 1.26 向け実装が
Rows.Nextとの後方互換性問題によりロールバックされる。 - 2026年4月22日:
NextRow/ScanColumnの分離とConvertAssignの公開を含む再設計版が likely accept 判定を受ける(既存ダイジェスト)。 - 2026年4月29日: コンセンサスに変化なしとして正式に accepted となる(既存ダイジェスト)。
議論のハイライト
- 当初の
ErrSkipベースのフォールバック案は、ScanColumnがConvertAssignを直接呼ぶ設計に変わったことで不要になった(該当コメント)。 *sql.RawBytesや*sql.Rows(カーソル)への代入は宛先の型だけでは判別できない特殊ケースで、ConvertAssignにdriver.ScanContextを渡すことで親の*sql.Rowsを辿れるようにした(該当コメント)。- @aclements の指摘として、
ConvertAssignに渡すsrc(driver.Value)はヒープへエスケープするため、ConvertAssignを呼ぶだけでは真のゼロアロケーションは実現できない。ドライバー側で頻出する型をあらかじめ型スイッチで処理し、それ以外だけConvertAssignにフォールバックさせる必要がある(該当コメント)。
使用例
Before
package mydriver
import (
"database/sql/driver"
"fmt"
"time"
)
// Go 1.27より前は、Rows.Scanが使う型変換ロジック(旧convertAssign)を
// ドライバから再利用する公式な手段がなかった。そのためフォールバック用の
// 変換処理は、ドライバ側で部分的に自作するしかなかった。
func fallbackConvert(dest any, src driver.Value) error {
switch d := dest.(type) {
case *string:
switch s := src.(type) {
case string:
*d = s
return nil
case time.Time:
// database/sqlの標準ルールを見よう見まねで再現するしかなく、
// 挙動が食い違うリスクがあった。
*d = s.Format(time.RFC3339Nano)
return nil
}
case *int64:
if i, ok := src.(int64); ok {
*d = i
return nil
}
}
return fmt.Errorf("mydriver: unsupported conversion from %T to %T", src, dest)
}
After
package mydriver
import (
"database/sql"
"database/sql/driver"
"io"
)
// myRows implements driver.RowsColumnScanner.
type myRows struct {
cols []string
rows [][]driver.Value
pos int
}
func (r *myRows) Columns() []string { return r.cols }
func (r *myRows) Close() error { return nil }
// Next is kept only so myRows still satisfies driver.Rows on older Go
// versions that don't know about RowsColumnScanner.
func (r *myRows) Next(dest []driver.Value) error {
if r.pos >= len(r.rows) {
return io.EOF
}
copy(dest, r.rows[r.pos])
r.pos++
return nil
}
func (r *myRows) NextRow() error {
if r.pos >= len(r.rows) {
return io.EOF
}
r.pos++
return nil
}
func (r *myRows) ScanColumn(scanCtx driver.ScanContext, index int, dest any) error {
if v, ok := r.rows[r.pos-1][index].(int64); ok {
if d, ok := dest.(*int64); ok {
*d = v
return nil
}
}
// この型は自前で高速化する理由がないので、標準の変換ロジックへ
// そのままフォールバックする。scanCtxはScanColumnが受け取ったものを
// そのまま渡す。
return sql.ConvertAssign(scanCtx, dest, r.rows[r.pos-1][index])
}
var _ driver.RowsColumnScanner = (*myRows)(nil)
移行時の注意
ConvertAssign を呼ぶ際は dest にポインタ(または Scanner 実装値)を渡す必要がある。driver.RowsColumnScanner.ScanColumn の実装内で呼ぶ場合は、ScanColumn が受け取った scanCtx をそのまま渡すこと。それ以外の文脈(ScanColumn の外)で呼ぶ場合はゼロ値の driver.ScanContext{} を渡してよい。また ConvertAssign は src を driver.Value として受け取るため、呼び出しのたびに src がヒープへエスケープする点に注意。アロケーション削減が目的なら、頻出する型をドライバ側の型スイッチで先に処理し、それ以外だけ ConvertAssign にフォールバックさせる必要がある。
実装解説
ConvertAssign の実体は convert.go:237 にあり、内部の convertAssignRows(rows 引数は nil)を呼ぶだけの薄いラッパーになっている。すぐ上にある非公開の convertAssign 関数(convert.go:223)には、"convertAssign should be an internal detail, but widely used packages access it using linkname. Notable members of the hall of shame include: ariga.io/entcache." というコメントが残っており(convert.go:214)、go:linkname 経由で非公式に呼ばれてきた実績が公開API化の一因だったことがうかがえる。
呼び出し元となる driver.RowsColumnScanner(driver.go:458)の ScanColumn が受け取る scanCtx driver.ScanContext は、internal.ScanContext を包んだ型として定義されている(driver.go:449)。database/sql 側では Rows.scanLocked が rowsi が driver.RowsColumnScanner を実装しているかを型アサーションで判定し(sql.go:3426)、実装していれば列ごとに ScanColumn を呼ぶ経路へ切り替わる。その際 *sql.Rows 自身を internal.NewScanContext でラップして scanCtx として渡しており(sql.go:3433)、これが *sql.RawBytes や *sql.Rows(カーソル)への代入時に親の Rows を辿るための仕組みになっている。