github.com/go-sql-driver/mysql で date型のカラムをtime.Time型で扱うと日付がズレるのを回避する

以下の記事で示したように、github.com/go-sql-driver/mysqlparseTimeloc オプションを適切に設定することで、Go の time.Time型とMySQLdatetime 型をタイムゾーンを考慮して適切に相互変換できる。

mrk21.hatenablog.com

しかし、同様にMySQLdate型をtime.Time型と相互変換しようとすると日付がズレる(ここではMySQL側のタイムゾーンJSTとする)。

const RFC3339Date = "2006-01-02"
...
db.Exec("create table test_date(dt date)")
...
date1 := time.Date(2022, 11, 2, 0, 0, 0, 0, time.UTC)
db.Exec("insert into test_date(dt) values (?)", date1)
rows, err := db.Query("select dt from test_date")
var date2 time.Time
for rows.Next() {
    rows.Scan(&date2)
    break
}
// date1: 2022-11-02(2022-11-02T00:00:00Z), date2: 2022-11-01(2022-11-01T15:00:00Z)
fmt.Printf("date1: %s(%s), date2: %s(%s)\n",
    date1.UTC().Format(RFC3339Date),
    date1.UTC().Format(time.RFC3339),
    date2.UTC().Format(RFC3339Date),
    date2.UTC().Format(time.RFC3339),
)

これは、Goでは日付をtime.Time型(時刻、タイムゾーンあり)で扱うので、Go => MySQL/MySQL => Goでのタイムゾーンの変換と、時刻/タイムゾーンの情報が欠落してしまうことに起因する。具体的には以下のようになる。

  1. 2022-11-02タイムゾーンUTCtime.Time型に格納: 2022-11-02T00:00:00Z
  2. MySQLタイムゾーンJSTに変換: 2022-11-02T09:00:00+09:00
  3. MySQLdate型に格納: 2022-11-02
  4. タイムゾーンJSTtime.Time型に格納: 2022-11-02T00:00:00+09:00
  5. タイムゾーンUTCに変換: 2022-11-01T15:00:00Z
  6. 日付を抽出: 2022-11-01

ここで 4. のようになるのは、github.com/go-sql-driver/mysql ではMySQLdate型からtime.Time型の値を生成するときは、以下のように時刻が00:00:00タイムゾーンlocオプションで示したものとなるからである。

func parseDateTime(b []byte, loc *time.Location) (time.Time, error) {
  ...
        if len(b) == 10 {
            return time.Date(year, month, day, 0, 0, 0, 0, loc), nil
        }

https://github.com/go-sql-driver/mysql/blob/fa1e4ed592daa59bcd70003263b5fc72e3de0137/utils.go#L139

この問題を防ぐには、date型を文字列でやり取りすればいいのだが、いちいちtime.Time型に変換するのは面倒である。そのため、タイムゾーンなしの日付型を新たにつくり、database/sqlで直接扱えるようにする。これには対象の型にdriver.Valuerおよびsql.Scannerインタフェースを満たすようにValue() (driver.Value, error)/Scan(value interface{}) errorメソッドを定義する。

type Date struct {
    year  int
    month time.Month
    day   int
}

func NewDate(year int, month time.Month, day int) *Date {
    t := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
    return &Date{
        year:  t.Year(),
        month: t.Month(),
        day:   t.Day(),
    }
}

func (d *Date) Year() int {
    return d.year
}

func (d *Date) Month() time.Month {
    return d.month
}

func (d *Date) Day() int {
    return d.day
}

func (d *Date) Format(layout string) string {
    return d.Time(nil).Format(layout)
}

func (d *Date) Time(loc *time.Location) time.Time {
    if loc == nil {
        loc = time.Local
    }
    return time.Date(d.year, d.month, d.day, 0, 0, 0, 0, loc)
}

// Go => MySQL
func (d Date) Value() (driver.Value, error) {
    return driver.Value(d.Format(RFC3339Date)), nil
}

// MySQL => Go
func (d *Date) Scan(value interface{}) error {
    var t mysql.NullTime
    err := t.Scan(value)
    if err != nil {
        return err
    }
    d.day = t.Time.Day()
    d.month = t.Time.Month()
    d.year = t.Time.Year()
    return nil
}

// interface check
var _ driver.Valuer = (*Date)(nil)
var _ sql.Scanner = (*Date)(nil)

そうすると、以下のようにdatabase/sqlで直接扱え、日付がズレることもなくなる。

date1 := NewDate(2022, 11, 2)

_, err = db.Exec("insert into test_date(dt) values (?)", date1)
if err != nil {
    log.Fatal(err)
}

rows, err := db.Query("select dt from test_date")
if err != nil {
    log.Fatal(err)
}

var date2 Date
for rows.Next() {
    rows.Scan(&date2)
    break
}
// date1: 2022-11-02, date2: 2022-11-02
fmt.Printf("date1: %s, date2: %s\n",
    date1.Format(RFC3339Date),
    date2.Format(RFC3339Date),
)

なおここで示したサンプルコードは以下にある。

github.com

環境

参考