go-sql-driver/mysql の Scan の挙動にハマる

Go の MySQL ドライバーとして go-sql-driver/mysql を使用した際の挙動にハマったので、解決策を記しておきます。

クエリを発行し、取得した整数型の値をany型の値で受け取る際に予期しない結果となることがあります。

package main

import (
    "database/sql"
    "fmt"
    "os"

    "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", (&mysql.Config{
        User:                 "root",
        Passwd:               "password",
        DBName:               "rdb",
        Addr:                 "localhost:3000",
        AllowNativePasswords: true,
    }).FormatDSN())
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    var val any
    if err := db.QueryRow("SELECT 1").Scan(&val); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("type is %T\n", val)

    var val2 any
    if err := db.QueryRow("SELECT ?", 1).Scan(&val2); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("type is %T\n", val2)
}
go run main.go
type is []uint8
type is int64

2つのSELECT文は同じ値を返しているはずですが、プレースホルダを用いているか否かで、値の型が異なってしまいます。

期待するのは、どちらもint64になることです。

解決策

プレースホルダへのバインドが不要であってもプリペアドステートメントを使うよう実装することができます。

https://github.com/go-sql-driver/mysql/issues/407#issuecomment-172583652

下記が実装例です。

package main

import (
    "database/sql"
    "fmt"
    "os"

    "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", (&mysql.Config{
        User:                 "root",
        Passwd:               "password",
        DBName:               "rdb",
        Addr:                 "localhost:3000",
        AllowNativePasswords: true,
    }).FormatDSN())
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    var val any
    stmt, err := db.Prepare("SELECT 1")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer stmt.Close()
    if err := stmt.QueryRow().Scan(&val); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("type is %T\n", val)

    var val2 any
    if err := db.QueryRow("SELECT ?", 1).Scan(&val2); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("type is %T\n", val2)
}
go run main.go
type is int64
type is int64

解説

MySQL にはバイナリプロトコル、テキストプロトコルという2つのプロトコルがあります。

go-sql-driver/mysql は、プリペアドステートメントを使用する際にはバイナリプロトコルを使用し、そうでない場合はテキストプロトコルを使用します。

テキストプロトコルでデータを受信した場合には[]uint8で結果を取得し、バイナリプロトコルの場合にはカラムの型に応じた Go の型で結果を取得します。

上記を踏まえると、テキストプロトコルが使用されるケースでScan()any型の値を渡した際に[]uint8型の値がアサインされるのは自然です。

また、テキストプロトコルが使用されるケースであってもint64型の値を渡せばint64型の値がアサインされるので、型を強制したいのであればany型を渡すなよということです。

こちらの記事が詳しいです。

go-sql-driver/mysql の次期バージョンを待ちましょう

今回、解決策を示していますが、実は go-sql-driver/mysql 次期バージョンにおいて上記の問題は解決されます。

テキストプロトコルが使用されるケースであっても、整数、浮動小数点をint64float64にパースするPRがマージされています。

Parse numbers on text protocol too by methane · Pull Request #1452 · go-sql-driver/mysql · GitHub

v1.8 のリリースを待ちましょう。

参考