メインコンテンツへスキップ

Go Proposal Weekly Digest

Go言語のproposal更新を毎週お届け

新機能

database/sql に Rows.Scan と同じ型変換ロジックを呼び出せる ConvertAssign 関数が追加され、ドライバが独自のスキャン実装から標準の変換ルールへフォールバックできるようになった。

database/sql

この項目の注釈は AI により生成されており、誤りを含む場合があります。
使用例のコンパイル検証: 検証済み

概要

database/sql パッケージに新しい関数 ConvertAssign が追加された。これは Rows.Scan が内部で使っている「driver.Value から呼び出し側が指定した型へ変換する」ロジックを、SQLドライバの実装からも呼び出せるようにするものだ。従来この変換処理は非公開の convertAssign 関数としてパッケージ内部に閉じており、ドライバが独自のスキャン処理を実装する際に標準の変換ルールへフォールバックする公式な手段がなかった。

導入経緯

発端は issue #67546 で、pgx ドライバの作者が「ドライバが Rows.Scan のスキャン処理を完全にオーバーライドできるようにしたい」と提案したことに遡る。当初案は ScanColumnErrSkip を返すことで標準の変換処理へフォールバックする設計だったが、議論の中で database/sql 内部の変換ロジックそのものを ConvertAssign として公開し、ドライバが直接それを呼び出す設計に置き換えられた。改訂版の提案 では「ScanColumnConvertAssign を使って標準の変換動作へフォールバックする」ことが明記されている。

この新設計は一度 Go 1.26 向けに accepted となったが、Rows.Next の契約(dest を必ず埋める)と非互換になる懸念からロールバックされた。その後 NextRow/ScanColumn を分離する再設計が進む過程で、*sql.Rows(カーソル)への代入を扱うために ConvertAssigndriver.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 ベースのフォールバック案は、ScanColumnConvertAssign を直接呼ぶ設計に変わったことで不要になった(該当コメント)。
  • *sql.RawBytes*sql.Rows(カーソル)への代入は宛先の型だけでは判別できない特殊ケースで、ConvertAssigndriver.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{} を渡してよい。また ConvertAssignsrcdriver.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.scanLockedrowsidriver.RowsColumnScanner を実装しているかを型アサーションで判定し(sql.go:3426)、実装していれば列ごとに ScanColumn を呼ぶ経路へ切り替わる。その際 *sql.Rows 自身を internal.NewScanContext でラップして scanCtx として渡しており(sql.go:3433)、これが *sql.RawBytes*sql.Rows(カーソル)への代入時に親の Rows を辿るための仕組みになっている。

関連リンク