🈳

Go言語を使ってDBテーブルのNULL許可のカラムを扱う

2023/06/29に公開

こんにちは。こんばんは。
データベースに含まれているNULL値をGo言語でどの型に変換するか困ったことはないでしょうか?
この記事では、Goの標準ライブラリのdatabase/sql と、ORMのvolatiletech/sqlboilerの2つを比較して、STRING型とTIME型にNULLが入っていた場合の対象方法について解説します。

サンプルデータ

データベース

CREATE DATABASE study_sqlboiler;

テーブル

解説に使うテーブルは下記の通りです。DBにはMySQLを使います。

2つのテーブルを作成します。

1つ目は、ユーザーテーブルです。
これは、String型を見るために作りました。nickenameをNULLにしています。ただそれだけです。

CREATE TABLE users (
  id INT NOT NULL AUTO_INCREMENT,
  name VARCHAR(255) NOT NULL,
  nickname VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (id)
);

2つ目は、タイムテーブルです。TIME型を見るために作りました。
TIME型は、'hh:mm:ss'形式で、'-838:59:59' から '838:59:59'の値が入ります。
使い道がなかなかみない型ですが、1日の中で決めらた時間を設定したいときに使いました。
1時間ごとに設定するとしたら、01:00:00, 02:00:00 のように格納されます。
この記事もMySQLのTIME型をGoのORMでマッピングしようとして、time.Time型に変換したらエラーになってハマったので書きました。

CREATE TABLE times (
  id INT NOT NULL AUTO_INCREMENT,
  user_id INT NOT NULL,
  time TIME DEFAULT NULL,
  PRIMARY KEY (id)
);

データ

データを2つのレコードにインサートします。値が入っているデータと、NULLが入っているデータです。

users

INSERT INTO users(name) values("tanaka");
INSERT INTO users(name, nickname) values("tanaka", "t5");

times

INSERT INTO times(user_id) values(1);
INSERT INTO times(user_id, time) values(2, "23:59:59");

サンプルコード

データベースに接続するコードです。
database/sql も、volatiletech/sqlboiler もどちらも使い方は同じです。
openDB関数から取得したdb(*sql.DB型)を使います。

main.go
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"study_sqlboiler/models"

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

func main() {
	db, err := openDB()
	if err != nil {
		panic(err)
	}
	defer db.Close()
}

func openDB() (*sql.DB, error) {
	c := mysql.Config{
		DBName:               "study_sqlboiler",
		User:                 "test",
		Passwd:               "test",
		Addr:                 "127.0.0.1:3306",
		Net:                  "tcp",
		Collation:            "utf8mb4_unicode_ci",
		AllowNativePasswords: true,
	}

	db, err := sql.Open("mysql", c.FormatDSN())
	if err != nil {
		log.Fatalf("main sql.Open error err:%v", err)
		return db, err
	}
	return db, nil
}

期待する出力結果

期待する出力結果は下記の通りです。NULLが格納されているデータは空の文字列として表示します。

id:1, name:tanaka, nickname:
id:2, name:tanaka, nickname:t5
id:3, time:
id:4, time:23:59:59

DBライブラリ比較

database/sql

初めに、Goの標準ライブラリにあるdatabase/sql を使った方法について解説します。

String

user一覧を取得するクエリーを書きます。

main.go
type User struct {
	ID       int
	Name     string
	Nickname string
}

func main() {
    // 省略
	userQuery(db)
}
main.go
func userQuery(db *sql.DB) {
	rows, err := db.Query("SELECT `id`, `name`, `nickname` FROM `users`")
	if err != nil {
		log.Fatalf("db.Query error err:%v", err)
	}
	defer rows.Close()

	for rows.Next() {
		u := &User{}
		if err := rows.Scan(&u.ID, &u.Name, &u.Nickname); err != nil {
			log.Fatalf("rows.Scan error err:%v", err)
		}

		fmt.Printf("id:%d, name:%s, nickname:%s \n", u.ID, u.Name, u.Nickname)
	}
}

これを実行すると、下記のエラーがでます。

2023/06/29 20:14:27 rows.Scan error err:sql: Scan error on column index 2, name "nickname": converting NULL to string is unsupported

これは、nickenameがstring型ですが、DBの値はNULLが含まれているため、MySQLのNULLをGoのString型に変更できなかったためです。

これを下記のように変更します。stringを、sql.NullStringに変更しました。

main.go
type User struct {
	ID       int
	Name     string
	Nickname sql.NullString
}

sql.NullString は null かもしれない文字列を表すのに使えます。
結果は、下記のとおりになります。

id:1, name:tanaka, nickname:{ %!s(bool=false)}
id:2, name:tanaka, nickname:{t5 %!s(bool=true)}
id:3, time:
id:4, time:23:59:59

nicknameの値が期待した通りになっていませんね。u.Nicknameは、sqlパッケージに含まれるNullString構造体として定義されています。

main.go
type NullString struct {
	String string
	Valid  bool // Valid is true if String is not NULL
}

そのため、文字列として表示するためには、u.Nickname.String に変える必要があります。
また、Validは、NULLでなければ真を返します。もし、NULLかどうか判定したい場合には、Validを見る必要があります。

fmt.Printfを下記のように書き換えます。fmt.Printfであれば、NULLの場合は、文字列は空になります。

fmt.Printf("id:%d, name:%s, nickname:%s \n", u.ID, u.Name, u.Nickname.String)

Time

次ぐに、Time型について見てきます。

main.go
type Time struct {
	ID   int
	Name string
	Time sql.NullTime
}

func main() {
    // 省略
	userQuery(db)
}
main.go
func timeQuery(db *sql.DB) {
	rows, err := db.Query("SELECT `id`, `time` FROM `times`")
	if err != nil {
		log.Fatalf("db.Query error err:%v", err)
	}
	defer rows.Close()

	for rows.Next() {
		t := &Time{}
		if err := rows.Scan(&t.ID, &t.Time); err != nil {
			log.Fatalf("rows.Scan error err:%v", err)
		}

		fmt.Printf("id:%d, time:%s \n", t.ID, t.Time)
	}
}

データベースTime型なので最初に思いつくのは、sql.NullTime ですね。下記を実行してみます。

下記のエラーがでました。

2023/06/29 20:37:30 rows.Scan error err:sql: Scan error on column index 1, name "time": unsupported Scan, storing driver.Value type []uint8 into type *time.Time

[]uint8 を使う必要があることがわかります。sql.NullTime[]uint8 に変更します。

main.go
type Time struct {
	ID   int
	Name string
	Time []uint8
}

出力結果は下記の通りで、期待通りになりました。

id:3, time:
id:4, time:23:59:59

なぜ、[]uint8 がうまくいくかというと、uint8は、byteの別名で、fmt.Printfで指定した%sフォーマットは、[]byte をstringに変換して出力してくれるからです。
https://pkg.go.dev/fmt#hdr-Printing

下記のように、sql.NullStringに、書き直しても期待した出力になります。

main.go
type Time struct {
	ID   int
	Name string
	Time sql.NullString
}
fmt.Printf("id:%d, time:%s \n", t.ID, t.Time.String)

volatiletech/sqlboiler

次に、Goの標準ライブラリ以外のライブラリを使います。ORMとして、volatiletech/sqlboilerを選択しました。このORMライブラリを選んだ理由は、データベースの内容を読み込んでGoのコードを自動生成してくれるからです。 database/sql では、自分でUserとTimeの構造体を定義する必要がありましたが、sqlboilerでは生成されたコードを使います。生成されたコードを読むことで、どの型にマッピングされたのか知ることができます。

まず最初に、sqlboilerをダウンロードします。Go1.16以上は下記を使います。詳しくは、公式ドキュメントをご覧ください。

https://github.com/volatiletech/sqlboiler/blob/master/README.md?plain=1#L274-L307

Goのコードを生成します。最初に、sqlboiler.tomlを作成します。環境に合わせて書き換えてください。

sqlboiler.toml
[mysql]
  dbname="study_sqlboiler"
  host="localhost"
  port=3306
  user="test"
  pass="test"
  sslmode="false"

次に、コードを生成します。

sqlboiler mysql --output models --pkgname models --wipe

コマンドを実行することで、MySQLに対応したコードがmodelsフォルダに生成されます。

出力結果

./models
├── boil_main_test.go
├── boil_queries.go
├── boil_queries_test.go
├── boil_suites_test.go
├── boil_table_names.go
├── boil_types.go
├── boil_view_names.go
├── mysql_main_test.go
├── mysql_suites_test.go
├── mysql_upsert.go
├── times.go
├── times_test.go
├── users.go
└── users_test.go

modelsを読み込みます。モジュールは適当にstudy_sqlboilerとして、study_sqlboiler/models としてローカルのフォルダを参照します。

go.mod
module study_sqlboiler
main.go
package main
import (
	"study_sqlboiler/models"
)

userQuery関数とtimeQuery関数の中身をsqlboiler用に書き換えます。

main.go
func userQuery(db *sql.DB) {
	ctx := context.Background()
	u, err := models.Users().All(ctx, db)

	if err != nil {
		panic(err)
	}

	for _, u := range u {
		fmt.Printf("id:%d, name:%s, nickname:%s \n", u.ID, u.Name, u.Nickname.String)
	}
}

func timeQuery(db *sql.DB) {
	ctx := context.Background()
	t, err := models.Times().All(ctx, db)

	if err != nil {
		panic(err)
	}

	for _, t := range t {
		fmt.Printf("id:%d, time:%s \n", t.ID, t.Time.String)
	}
}

これは、期待通りの結果になりました。さて、違いはどこにあるのでしょうか?それは、t.Time.String です。
Timeが何に変換されたのか見てみます。

models/times.go
type Time struct {
	ID     int         `boil:"id" json:"id" toml:"id" yaml:"id"`
	UserID int         `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"`
	Time   null.String `boil:"time" json:"time,omitempty" toml:"time" yaml:"time,omitempty"`

	R *timeR `boil:"-" json:"-" toml:"-" yaml:"-"`
	L timeL  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

Timeの型は、null.Stringに変換されてました。[]uint8 のほうがうまく動きそうなのに。
では、実際にsqlboilerが、MySQLの型をGoのどの型にマッピングしているのか見ます。

各DBエンジンごとに書かれています。
https://github.com/volatiletech/sqlboiler/blob/b77089f9b9a178dfd782fdf709d2d7609e33aa6c/drivers/sqlboiler-mysql/driver/mysql.go#L451-L511

Timeが、null.Stringになっていたのは、DBTypeにcaseにtime がないため、デフォルトのTypeになっていたからでした。

null.String

さて、このnull.Stringとは何でしょう?

この、null.Stringはgithub.com/volatiletech/nullの、string.goに定義されていました。
https://github.com/volatiletech/null/blob/v8/string.go#L13-L16

このgithub.com/volatiletech/nullは、github.com/guregu/null のフォークです。ただし、フォークしているので、そのまま使っておらず書き換えられていました。なお、guregu/nullの、null/string は、sql.NullString を使ってました。

まとめ

NULL許可していた場合、VARCHAR型とTime型どちらも、database/sqlを使った場合は、sql.NullStringを使う。volatiletech/sqlboilerを使う場合は、volatiletech/null のnull.Stringを使うとよいです。sqlboilerは自動生成されたコードを使うので意識しなくても良いですが、どんな型が使われているかは知っておきましょう。

最後に

sqlboilerでは、[]uint8 ではなく、sql.NullString でもなく、sqlboilerとは別のリポジトリで管理されている volatiletech/nullnull.String を使っていました。奥深いですね。GoでDBのNULLを扱うときには、どの型に変換されるかはライブラリのコードを読むのをおすすめします。sqlboilerを使っておけばマッピングする型を意識することは少なくなりますが、MySQLのTime型が文字列型として扱われているということを知っておくことがとても大事です。

文字列と時刻以外にもいろいろありますので、下記のコードを比較して見るのがおすすめです。

Voicyテックブログ

Discussion