Go言語を使ってDBテーブルのNULL許可のカラムを扱う
こんにちは。こんばんは。
データベースに含まれている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型)を使います。
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一覧を取得するクエリーを書きます。
type User struct {
ID int
Name string
Nickname string
}
func main() {
// 省略
userQuery(db)
}
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に変更しました。
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構造体として定義されています。
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型について見てきます。
type Time struct {
ID int
Name string
Time sql.NullTime
}
func main() {
// 省略
userQuery(db)
}
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
に変更します。
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に変換して出力してくれるからです。
下記のように、sql.NullString
に、書き直しても期待した出力になります。
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以上は下記を使います。詳しくは、公式ドキュメントをご覧ください。
Goのコードを生成します。最初に、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
としてローカルのフォルダを参照します。
module study_sqlboiler
package main
import (
"study_sqlboiler/models"
)
userQuery関数とtimeQuery関数の中身をsqlboiler用に書き換えます。
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が何に変換されたのか見てみます。
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エンジンごとに書かれています。
Timeが、null.String
になっていたのは、DBTypeにcaseにtime
がないため、デフォルトのTypeになっていたからでした。
null.String
さて、このnull.Stringとは何でしょう?
この、null.Stringはgithub.com/volatiletech/nullの、string.goに定義されていました。
この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/null
の null.String
を使っていました。奥深いですね。GoでDBのNULLを扱うときには、どの型に変換されるかはライブラリのコードを読むのをおすすめします。sqlboilerを使っておけばマッピングする型を意識することは少なくなりますが、MySQLのTime型が文字列型として扱われているということを知っておくことがとても大事です。
文字列と時刻以外にもいろいろありますので、下記のコードを比較して見るのがおすすめです。
Discussion