xormのNullableな(Bulk)Insertを内部実装から読み解く

ぺりー
18 min readDec 6, 2021

eurekaでGoのORMとしてxormを使用しているのですが、先日、Nullableな値を含むBulk Insertをしようとしたときにハマってしまったので、備忘録として残しておきます。

xormのリポジトリ(githubからgiteaに移行されてます)

公式ドキュメント

pkg.go.devのドキュメント

結論

xormでNullをBulk Insertするにはエンティティのフィールドをポインタにするしかないです
(DDLではNull許容にしているのが前提です。)

ここがハマりポイントです。
Bulk InsertとSingle InsertでNullになる挙動が変わります。

検証

もともとSingle Insertするときは、以下のようにエンティティをポインタにしなくてもNullを入れることができました。

type User struct {
ID int64 `xorm:"not null pk autoincr BIGINT(20)"`
Name string `xorm:"not null VARCHAR(190)"`
UID int64 `xorm:"BIGINT(20)"`
}
func Insert(ctx context.Context, ent *User) error {
engine, _ := xorm.NewEngine(driverName, datasourceName)
s := engine.NewSession()
s.Nullable("uid")
if _, err := s.Insert(ent); err != nil {
return err
}
return nil
}

もちろんポインタにしても入ります。

type User struct {
ID int64 `xorm:"not null pk autoincr BIGINT(20)"`
Name string `xorm:"not null VARCHAR(190)"`
UID *int64 `xorm:"BIGINT(20)"`
}
func Insert(ctx context.Context, ent *User) error {
engine, _ := xorm.NewEngine(driverName, datasourceName)
s := engine.NewSession()
s.Nullable("uid")
if _, err := s.Insert(ent); err != nil {
return err
}
return nil
}

ただこれが、Bulk Insertになるとポインタじゃなきゃ動かないんですよね。。

type User struct {
ID int64 `xorm:"not null pk autoincr BIGINT(20)"`
Name string `xorm:"not null VARCHAR(190)"`
UID *int64 `xorm:"BIGINT(20)"`
}
func BulkInsert(ctx context.Context, list []*User) error {
engine, _ := xorm.NewEngine(driverName, datasourceName)
s := engine.NewSession()
s.Nullable("uid")
if _, err := s.Insert(list); err != nil {
return err
}
return nil
}

ここではBulk Insert、Single Insertどちらも共通のfunc (session *Session) Insert(beans …interface{}) (int64, error)を使用していますが、他のメソッドも結果は変わりません。

例えばSingle Insertの際に、func (session *Session) InsertOne(bean interface{}) (int64, error)を使用しても内部で呼ばれるのはfunc (session *Session) insertStruct(bean interface{}) (int64, error)です。
同様に、Bulk Insertの際にfunc (session *Session) InsertMulti(rowsSlicePtr …interface{}) (int64, error)を使用しても内部で呼ばれているのは、func (session *Session) insertMultipleStruct(rowsSlicePtr …interface{}) (int64, error)です。

本筋の少し逸れてしまいましたが、ざっくりSingle InsertとBulk Insertで呼んでるメソッドが違ってその挙動が違うっぽいということがわかりました。

sliceValue := reflect.Indirect(reflect.ValueOf(bean))
if sliceValue.Kind() == reflect.Slice {
cnt, err = session.insertMultipleStruct(bean)
} else {
cnt, err = session.insertStruct(bean)
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L52-L57

解説

Single Insertの場合

上記で示した通りfunc (session *Session) insertStruct(bean interface{}) (int64, error)が呼ばれます。
メソッド内では前処理、テーブル名取得、カラム名取得、クエリ組み立て、後処理という感じで進んでいくようです。
クエリが組み立てられるのはfunc (statement *Statement) GenInsertSQL(colNames []string, args []interface{}) (string, []interface{}, error)でその第一引数にカラム名が入っています。

colNames, args, err := session.genInsertColumns(bean)
if err != nil {
return 0, err
}

sqlStr, args, err := session.statement.GenInsertSQL(colNames, args)
if err != nil {
return 0, err
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L271-L279

カラムが作成されるfunc (session *Session) genInsertColumns(bean interface{}) ([]string, interface{}, error)では、カラム名と挿入する値がそれぞれスライスに追加されるようになっています。

colNames := make([]string, 0, len(table.ColumnsSeq()))
args := make([]interface{}, 0, len(table.ColumnsSeq()))
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L427-L428

テーブルのカラム数分繰り返し、条件によって、例えばカラムが存在するか、オートインクリメントのタグがあるかなど、適切な値をスライスに追加しているようです。

*xorm.io/xorm/schemas.Column {
Name: "uid",
TableName: "",
FieldName: "UID",
FieldIndex: []int len: 1, cap: 1, [4],
SQLType: xorm.io/xorm/schemas.SQLType {Name: "BIGINT", DefaultLength: 0, DefaultLength2: 0},
IsJSON: false,
Length: 20,
Length2: 0,
Nullable: true,
Default: "",
Indexes: map[string]int [],
IsPrimaryKey: false,
IsAutoIncrement: false,
MapType: 1,
IsCreated: false,
IsUpdated: false,
IsDeleted: false,
IsCascade: false,
IsVersion: false,
DefaultIsEmpty: true,
EnumOptions: map[string]int nil,
SetOptions: map[string]int nil,
DisableTimeZone: false,
TimeZone: *time.Location nil,
Comment: "",
}

検証していたコードを思い出してみると、func (engine *Engine) Nullable(columns …string) *Sessionで設定したフィールドの型はint64と*int64でした。

fieldValuePtr, err := col.ValueOf(bean)
if err != nil {
return nil, nil, err
}
fieldValue := *fieldValuePtr
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L458-L462

fieldValue.Kind()をデバッグしてみるとint64(6)が出力されたのでこの時点ではまだint64の初期値がカラムの挿入値として使われそうです。
以下がfieldValueの値です。

reflect.Value {
typ: *reflect.rtype {size: 8, ptrdata: 0, hash: 2520751103, tflag: tflagUncommon|tflagExtraStar|tflagNamed|tflagRegularMemory (15), align: 8, fieldAlign: 8, kind: 6, equal: runtime.memequal64, gcdata: *0, str: 11608, ptrToThis: 270976},
ptr: unsafe.Pointer(0xc000612130),
flag: 390,
}

この後にint64の初期値0が入らないようにfieldValueが書き換えられるはずです。

if _, ok := getFlagForColumn(session.statement.NullableMap, col); ok {
if col.Nullable && utils.IsValueZero(fieldValue) {
var nilValue *int
fieldValue = reflect.ValueOf(nilValue)
}
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L469-L474

ありました。ここでカラムにNullableを設定していて、かつ初期値だった場合にfieldValueがnilに書き換えられています。

func getFlagForColumn(m map[string]bool, col *schemas.Column) (val bool, has bool) {
if len(m) == 0 {
return false, false
}

n := len(col.Name)

for mk := range m {
if len(mk) != n {
continue
}
if strings.EqualFold(mk, col.Name) {
return m[mk], true
}
}

return false, false
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_cols.go#L47-L64

そのあとは[]interface{}型にキャストしてスライスに追加するだけです。
以下のコードのelse以下のfunc (statement *Statement) Value2Interface(col []schemas.Column, fieldValue reflect.Value) (interface{}, error)の部分が該当箇所です。

if (col.IsCreated || col.IsUpdated) && session.statement.UseAutoTime /*&& isZero(fieldValue.Interface())*/ {
// if time is non-empty, then set to auto time
val, t, err := session.engine.nowTime(col)
if err != nil {
return nil, nil, err
}
args = append(args, val)

var colName = col.Name
session.afterClosures = append(session.afterClosures, func(bean interface{}) {
col := table.GetColumn(colName)
setColumnTime(bean, col, t)
})
} else if col.IsVersion && session.statement.CheckVersion {
args = append(args, 1)
} else {
arg, err := session.statement.Value2Interface(col, fieldValue)
if err != nil {
return colNames, args, err
}
args = append(args, arg)
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L476-L497

こうして、Single Insertの場合はエンティティのフィールドがポインタであろうがなかろうが、NullとしてInsertすることができます。

Bulk Insertでポインタの場合

前述した通り、Single Insertの場合は、func (session *Session) insertStruct(bean interface{}) (int64, error)が呼ばれますが、Bulk Insertの場合は、func (session *Session) insertMultipleStruct(rowsSlicePtr …interface{}) (int64, error)が呼ばれます。

メソッド内で行っていることはSingle Insertと大して変わりませんが、Bulk Insertならではの処理がつけ加わったという感じです。

for _, col := range table.Columns() {
ptrFieldValue, err := col.ValueOfV(&vv)
if err != nil {
return 0, err
}
fieldValue := *ptrFieldValue
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L120-L124

ここでのfieldValueはSingle Insertの時と同じでfieldValue.Kind()はint64(6)です。

if (col.IsCreated || col.IsUpdated) && session.statement.UseAutoTime {
val, t, err := session.engine.nowTime(col)
if err != nil {
return 0, err
}
args = append(args, val)

var colName = col.Name
session.afterClosures = append(session.afterClosures, func(bean interface{}) {
col := table.GetColumn(colName)
setColumnTime(bean, col, t)
})
} else if col.IsVersion && session.statement.CheckVersion {
args = append(args, 1)
var colName = col.Name
session.afterClosures = append(session.afterClosures, func(bean interface{}) {
col := table.GetColumn(colName)
setColumnInt(bean, col, 1)
})
} else {
arg, err := session.statement.Value2Interface(col, fieldValue)
if err != nil {
return 0, err
}
args = append(args, arg)
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/session_insert.go#L140-L165

Single Insert時にあったfieldValueのnil値への書き換えはなく、このままfunc (statement *Statement) Value2Interface(col []schemas.Column, fieldValue reflect.Value) (interface{}, error)が呼ばれます。

fieldType := fieldValue.Type()
k := fieldType.Kind()
if k == reflect.Ptr {
if fieldValue.IsNil() {
return nil, nil
} else if !fieldValue.IsValid() {
return nil, nil
} else {
// !nashtsai! deference pointer type to instance type
fieldValue = fieldValue.Elem()
fieldType = fieldValue.Type()
k = fieldType.Kind()
}
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/internal/statements/values.go#L67-L80

期待する挙動は、k == reflect.Ptr(=22)になり、最初の分岐(fieldValue.IsNil())でnilを返すことです。
しかし、fieldValueが書き換えられていないため、k(=int64(6) != reflect.Ptr(=22)となり、この後の処理に続いてしまいます。

switch k {
case reflect.Bool:
return fieldValue.Bool(), nil
// 中略
default:
return fieldValue.Interface(), nil
}
ref: https://gitea.com/xorm/xorm/src/tag/v1.2.5/internal/statements/values.go#L82-L170

このように最終的にはfieldValue.Interface()が呼ばれ、interface{} (int64) 0が返ってしまい、Nullを挿入したいカラムに0が挿入されてしまいます。

xormにpatchを送りました

--

--

ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.