xormでマイクロ秒の日付保存でハマった話

公開:2020/10/23
更新:2021/02/05
4 min読了の目安(約3900字TECH技術記事

始め

GoのxormというORMMySQLを仕事で使っていますが、
マイクロ秒の日付を保存できないという謎の動きをしていたので、調査と対処をしたというお話です。

結論

2020/10/23時点xormMicrosoft SQL Server以外のRDBにマイクロ秒の日付を保存できません。

旅の始まり

仕事で次のカラムにtime.Timeのデータを保存しようとしたら、マイクロ秒のところが0のままなっていました。

mysql> desc t;
+-------+-------------+------+-----+---------+-------+
| Field | Type        | Null | Key | Default | Extra |
+-------+-------------+------+-----+---------+-------+
| date  | datetime(6) | NO   |     | NULL    |       |
+-------+-------------+------+-----+---------+-------+

mysql> select * from t;
+----------------------------+
| date                       |
+----------------------------+
| 2020-10-23 23:00:47.000000 |
+----------------------------+

ためしに普通にSQLを発行してみたところ、問題なくマイクロ秒が挿入されていたのでこれはコードもしくはxormが原因かなと疑い始めました。

mysql> insert into t (date) values(now(6));
Query OK, 1 row affected (0.00 sec)

mysql> select * from t;
+----------------------------+
| date                       |
+----------------------------+
| 2020-10-23 23:00:47.000000 |
| 2020-10-23 23:02:21.793967 |
+----------------------------+

コードが悪いのか、それともxormが悪いのか

DBは問題ないなら自分が悪いのかなと疑いはじめて、
次の小さなサンプルコードを動かしてみたところ、マイクロ秒が入りませんでした。

engine, err := xorm.NewEngine("mysql", "gorilla:gorilla@/gorilla?charset=utf8")
if err != nil {
	log.Fatal(err)
}

t := struct {
	Date time.Time
}{
	Date: time.Now(),
}

if _, err := engine.Table("t").Insert(&t); err != nil {
	log.Println(err)
}

xorm

こうなるとxormだなと思ってxormコードを読み始めました。
xormtime.Timeを変換している箇所があるはずですので、Insertメソッドからたどれば良さそうと思って潜ったら次の処理を見つけました。

internal/statements/values.go
case reflect.Struct:
	if fieldType.ConvertibleTo(schemas.TimeType) {
		t := fieldValue.Convert(schemas.TimeType).Interface().(time.Time)
		tf := dialects.FormatColumnTime(statement.dialect, statement.defaultTimeZone, col, t)
		return tf, nil

dialects.FormatColumnTimeってメソッドがいかにもそれっぽいなと思って中を覗いたらビンゴでした。
FormatTime関数が日付のフォーマットを生成していて、しかもschemas.DateTimeの場合は秒までのフォーマットになっていました。

dialects/time.go
// FormatTime format time as column type
func FormatTime(dialect Dialect, sqlTypeName string, t time.Time) (v interface{}) {
	switch sqlTypeName {
	case schemas.Time:
		s := t.Format("2006-01-02 15:04:05") // time.RFC3339
		v = s[11:19]
	case schemas.Date:
		v = t.Format("2006-01-02")
	case schemas.DateTime, schemas.TimeStamp, schemas.Varchar: // !DarthPestilane! format time when sqlTypeName is schemas.Varchar.
		v = t.Format("2006-01-02 15:04:05")
	case schemas.TimeStampz:
		if dialect.URI().DBType == schemas.MSSQL {
			v = t.Format("2006-01-02T15:04:05.9999999Z07:00")
		} else {
			v = t.Format(time.RFC3339Nano)
		}
	case schemas.BigInt, schemas.Int:
		v = t.Unix()
	default:
		v = t
	}
	return
}

そんなことあるのかとびっくりしましたが、現実は残酷なものです。受け入れて対処しなければいけません。

パッチを書く

このままだとお仕事困ってしまうので、パッチを書くしかないかなと悩んでいました。
ちょうど同じ現象に遭遇して、すでにPRを出していたKoRoNさんのコードがあったので、
少し拝借してテストも書いてPRを投げました。

対処方法はシンプルで、PostgreSQLとMySQLでTimeStampDateTime型のときは日付フォーマットをマイクロ秒までにするだけです。

-	case schemas.DateTime, schemas.TimeStamp, schemas.Varchar: // !DarthPestilane! format time when sqlTypeName is schemas.Varchar.
+	case schemas.Varchar: // !DarthPestilane! format time when sqlTypeName is schemas.Varchar.
 		v = t.Format("2006-01-02 15:04:05")
+	case schemas.TimeStamp, schemas.DateTime:
+		dbType := dialect.URI().DBType
+		if dbType == schemas.POSTGRES || dbType == schemas.MYSQL {
+			v = t.Format("2006-01-02T15:04:05.999999")
+		} else {
+			v = t.Format("2006-01-02 15:04:05")
+		}
 	case schemas.TimeStampz:
 		if dialect.URI().DBType == schemas.MSSQL {
 			v = t.Format("2006-01-02T15:04:05.9999999Z07:00")

動かしてみる

まだ本体へマージされていないのですが、上記修正で無事動きました。めでたい。

最後に

やれることはやったので、後は神頼みするしかないです。
はやくマージしてほしい。