🧬

Genericsを使いミスを防ぐSQL Builder「GenORM」

2022/03/15に公開約11,800字

Go 1.18がリリースされましたね。
Go 1.18の新機能の中で最も注目を集めている機能はやはりジェネリクスだと思います。
そのジェネリクスを使用してSQLに関するミスをできる限りコンパイルの段階で防ぐことを目指すSQL Builder「GenORM」を作ったので、この記事ではその紹介をしていきます。

リポジトリ: https://github.com/mazrean/genorm
ドキュメント: https://mazrean.github.io/genorm-docs/ja/

コード例

仕組みの説明の前に、GenORMのコード例を見ていただきたいと思います。
今回は以下のようなテーブルを使用します。

CREATE TABLE `users` (
  `id` char(36) NOT NULL,
  `name` varchar(64) NOT NULL,
  `password` char(60) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

CREATE TABLE `messages` (
  `id` char(36) NOT NULL,
  `user_id` char(36) NOT NULL,
  `content` text NOT NULL,
  `created_at` datetime NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  KEY `idx_user` (`user_id`),
  CONSTRAINT `idx_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

例1

GenORMでは以下のようなコードでユーザー名がnameのユーザー一覧を取得するSQLSELECT * FROM users WHERE name="name"に当たるSQL[1]を実行できます。

userValues, err := genorm.
	Select(orm.User()).
	Where(genorm.EqLit(user.NameExpr, genorm.Wrap("name"))).
	GetAll(db)

次に、誤って文字列の入っているnameカラムとint型の値1を比較しようとしてしまい、

userValues, err := genorm.
	Select(orm.User()).
	Where(genorm.EqLit(user.NameExpr, genorm.Wrap(1))).
	GetAll(db)

というコードを書いてしまったとします。
しかし、このコードはコンパイルエラーとなり、コンパイル段階でミスを防げます。
このように、GenORMを使用するとGo言語上で異なる型に対応する値をSQLでも比較できなくすることができます。

例2

GenORMでは以下のようなコードでidがuserIDのユーザー一覧を取得するSQLSELECT * FROM users WHERE id="{{id}}"に当たるSQL[1:1]を実行できます。

userValues, err := genorm.
	Select(orm.User()).
	Where(genorm.EqLit(user.IDExpr, userID)).
	GetAll(db)

次に、誤ってこのクエリでは使用できないmessagesテーブルのidカラムを使用してしまい

userValues, err := genorm.
	Select(orm.User()).
	Where(genorm.EqLit(message.IDExpr, uuid.New())).
	GetAll(db)

というコードを書いてしまったとします。
このコードもコンパイルエラーとなります。
このように、GenORMを使用すると本来使用できないテーブルのカラムを使用することを防止できます。

既存のツールとの違い

既存のGORMやentとの違いについて説明します。

GORM

GORMではinterface{}を利用して柔軟に引数を受け入れることで、直感的にクエリを組み上げられるようになっています。
反面、型による制約が非常に弱くなっています。
このため、コンパイル時に弾けるバグが少なく、バグが混入しやすくなります。
また、実際に実行されるSQLがわかりづらく、想定したのと異なる挙動をしやすい、という問題もあるように思います。

対して、GenORMは型による制約でコンパイル時にできる限り多くのバグを見つけることができます。
また、Goのコードから実行されるSQLがイメージしやすいようにすることも意識しており、ここまでの例のコードでもコードから実行するSQLがイメージしやすかったのではないかと思います。

このように、GenORMとGORMでは思想、機能ともに大きく異なります。

ent

entとの機能面での最も大きな違いは使用できるGo言語の型と考えています。
entではint、boolなどのプリミティブ型に対応する型にtime.TimeやUUIDなどを加えた、有限個の型のみが使用できます。
対して、GenORMではgenorm.ExprTypeinterfaceの条件を満たす任意の型[2]を使用し、その上で同一の型でのみ比較可能、などの制約が設定されています。
これにより、不要な型変換を行う必要がなく、また、Defined Typeを用いることでより強力な制約を設定できるようになっています[3]

例えば、以下のようにUserIDMessageIDを独自型として定義することで、「ユーザーのIDとメッセージのIDは比較できない」といった制約が指定できます。

type MessageID uuid.UUID

func (mid *MessageID) Scan(src any) error {
	return (*uuid.UUID)(mid).Scan(src)
}

func (mid MessageID) Value() (driver.Value, error) {
	return uuid.UUID(mid).Value()
}

type UserID uuid.UUID

func (uid *UserID) Scan(src any) error {
	return (*uuid.UUID)(uid).Scan(src)
}

func (uid UserID) Value() (driver.Value, error) {
	return uuid.UUID(uid).Value()
}

これにより、以下のようなコードはコンパイルエラーとなりミスを防げます。

// SELECT * FROM `messages` WHERE `messages`.`id`=`messages`.`user_id`
messageValues, err := genorm.
    Select(orm.Message()).
    Where(genorm.Eq(message.IDExpr, message.UserIDExpr)).
    GetAll(db)

また、GenORMではSQLに近いメソッドチェーンでクエリを構築できる点も重要です。
entは「entity framework」であるため、データベースの操作がentityの操作として抽象化されています。
これはメリットもありますが、GORMと同様に実行されるSQLがわかりづらくなるという側面もあります。
これは、意図せずパフォーマンスに問題のある処理を書いてしまう確率を上昇させてしまいます。
この点、GenORMでは実行されるSQLがわかりやすいため、このような問題は起こりづらいでしょう。

仕組み

コード例の動作の仕組みを解説します。
genorm.EqLitの定義は

func EqLit[T Table, S ExprType](
	expr TypedTableExpr[T, S],
	literal S,
) TypedTableExpr[T, WrappedPrimitive[bool]] {
	// 省略
}

のようになっています。
注目してほしいのがTypedTableExpr[T, S]の部分です。
[T Table, S ExprType]の部分からもわかるように、TはSQLのexpressionが使用しているテーブルを表す型、SはSQLのexpressionに対応するGo言語の型となっています。
このように、GenORMではSQLのexpressionに使用しているテーブルとGo言語の対応する型を型パラメーターとして持たせることで、使用可能なカラムや比較して良い値に制限をかけています。

このように、SQLのexpressionに型をつけているため、><ANDなどの演算子、COUNT()などの関数、さらに将来的にはデータベース独自の関数にも制限をかけてSQLを実行できます。

型パラメーターはどこ?

仕組みの説明で型パラメーターを利用して書けるSQLに強力な制限をかけていると書きました。
これに対して、ここまで例に挙げたコードで一度も型パラメーターを設定していないことに疑問を持った方もいると思います。
このようにコードの見た目に型パラメーターが現れていないのは、全ての型パラメーターを型推論で決定できるようにしているためです。
GenORMでは関数引数型推論で関数の引数を大半TypedTableExpr[T, S]型、一部カラム以外のexpressionが入ってはいけない箇所ではTypedTableColumns[T, S]型とし、各テーブルのカラムに対してこの2つの型の値を両方用意しておくことで、全ての関数で関数引数型推論により型パラメーターを決定できるようにしています。
ドキュメントのコード例を見るとuser.IDExprのように~Exprという値でカラムを使用している箇所と、user.IDのようにExprがついていない値でカラムを使用している箇所がありますが、これがそれぞれTypedTableExpr[T, S]型とTypedTableColumns[T, S]型に対応する値を格納した変数です。

このように全ての関数で型推論が効くようにしているのは可読性を上げるためです。
GenORMではSQLの演算子や関数を全てGoの関数で表現する関係で、使用すると関数呼び出しが非常に多くなります。
この度に型パラメーターが書かれていると最終的にどのようなSQLが実行されるかコードを見ただけではわかりづらくなり、可読性が低下するため、型パラメーターを一切指定せずに済むようにしています。

使い方

遅くなってしまったのですが、使い方です。
詳細は先頭に貼ったドキュメントで説明しているので、ここでは大まかな説明をしていきます。

コード生成

GenORMではテーブルに対応するstructをコード生成で作成します。
テーブルのカラムなどの情報はGoの構文に乗って書くようになっています。
例えば、

package main

import (
    "time"

    "github.com/google/uuid"
    "github.com/mazrean/genorm"
)

type User struct {
	ID        uuid.UUID `genorm:"id"`
	Name      string    `genorm:"name"`
	CreatedAt time.Time `genorm:"created_at"`
}

func (u *User) TableName() string {
    return "users"
}

のようなコードで、「usersテーブルはuuid.UUID型に対応するidカラム、string型に対応するnameカラム、time.Time型に対応するcreeated_atカラムを持つ」ということを表せます。

また、GenORMではJoin後のテーブルをJoin前のテーブルと別のstructで表すため、設定でJoinする可能性があるテーブルを指定する必要があります。
Joinする可能性があることはgenorm.Ref[T]型のフィールドで示します。
例えば、

// package,import省略

type User struct {
	// カラムを表すField省略
	// messagesテーブルとJoin可能であることを示すField
	Message   genorm.Ref[Message]
}

func (u *User) TableName() string {
	return "users"
}

type Message struct {
	// カラムを表すField省略
}

func (m *Message) TableName() string {
	return "messages"
}

のように書くと「usersテーブルとmessagesテーブルがJoin可能」ということを示せます。

設定を書いたら、

$ go install github.com/mazrean/genorm/cmd/genorm@v1.0.0

でinstallできるgenormコマンドでコード生成をします。
設定ファイルに//go:generateディレクティブで

//go:generate go run github.com/mazrean/genorm/cmd/genorm@v1.0.0 -source=$GOFILE ~

のように書いておくと、go generateでコード生成ができ、便利だと思います。

クエリ実行

生成されたコードとgithub.com/mazrean/genormを使ってSQLを実行します。

データベースへの接続

GenORMでは*sql.DB/*sql.Txを使用してSQLを実行します。
そのため、データベースへの接続はdatabase/sqlを使う場合と同様に以下のようにして行えば良いです。

import (
  "database/sql"
  _ "github.com/go-sql-driver/mysql"
)

db, err := sql.Open("mysql", "user:pass@tcp(host:port)/database?parseTime=true&loc=Asia%2FTokyo&charset=utf8mb4")

ただし、動作確認はMySQL/MariaDBでしか行っていないため、他のデータベースでは正常に動作しない可能性があります。

テーブルを表す値

CRUDのどのSQLを実行する際にもメソッドチェーンの開始時にテーブルの指定が必要になります。

Joinをしないテーブル

Joinをしないテーブルを表す値は、生成コード中にある設定ファイルの構造体名と同名の関数を呼び出すことで得られます。
例えば、コード生成の例で使った、usersテーブルであれば設定ファイルの構造体の名前はUserであるため、生成コードのUser関数を呼び出すことでテーブルに対応する値を得られます。つまり、コード生成時に指定したpackageフラッグの値がormであれば、orm.User()usersテーブルに対応する値となります。

Join後のテーブル

Joinを行い作られるテーブル、例えばusers INNER JOIN messages ON users.id = messages.user_idのようなテーブルは、Joinの元となるテーブル(users)を表す値(orm.User())のJoinするテーブルに対応するメソッドを呼び出し、次にJoinの種類に対応するメソッド(Join)を呼び出すことで作成できます。
また、Joinの種類に対応するメソッドは引数としてON句の条件を取ります。
つまり、users INNER JOIN messages ON users.id = messages.user_idに対応するテーブルは以下のようになります。

// Join後のテーブルとJoin前のテーブルは異なるため、カラムの型変換が必要
userID := orm.MessageUserParseExpr(user.ID)
messageUserID := orm.MessageUserParseExpr(message.UserID)

// Join後のテーブル
joinedTable := orm.User().
	Message().Join(genorm.Eq(userID, messageUserID))

INSERT文

INSERT文は以下のようにして実行できます。
メソッドチェーンの最後のDoの代わりにDoCtxを使用することで、contextも使用できます。

// INSERT INTO `users` (`id`, `name`, `created_at`) VALUES ({{uuid.New()}}, "name1", {{time.Now()}}), ({{uuid.New()}}, "name2", {{time.Now()}})
affectedRows, err := genorm.
    Insert(orm.User()).
    Values(&orm.UserTable{
        ID: uuid.New(),
        Name: genorm.Wrap("name1"),
        CreatedAt: genorm.Wrap(time.Now()),
    }, &orm.UserTable{
        ID: uuid.New(),
        Name: genorm.Wrap("name2"),
        CreatedAt: genorm.Wrap(time.Now()),
    }).
    Do(db)

SELECT文

SELECT文は以下のようにして実行できます。
Insertと同様に、Ctx付きの関数で終えるとcontextも使用できます。
Getは1レコードのみの場合、GetAllはレコード数の制限が1で無い場合、という使い分けになっています。
Pluckは1カラムのみのSELECTで使用する関数で、これを使うと結果がカラムに対応するGoの型で帰ってきます。
現在、Selectは結果がテーブルのカラムのみSELECT文しか対応できていませんが、PluckではCOUNTのような関数呼び出しなどを結果に含むSELECT文も実行できます。
ここには書いていませんが一部カラムのみのSELECT文、ORDER BYFOR UPDATE付きのSELECT文にも対応しています。

// SELECT `id`, `name`, `created_at` FROM `users`
// userValues: []orm.UserTable
userValues, err := genorm.
	Select(orm.User()).
	GetAll(db)

// SELECT `id`, `name`, `created_at` FROM `users` LIMIT 1
// userValue: orm.UserTable
userValue, err := genorm.
	Select(orm.User()).
	Get(db)

// SELECT `id` FROM `users`
// userIDs: []uuid.UUID
userIDs, err := genorm.
	Pluck(orm.User(), user.IDExpr).
	GetAll(db)

// SELECT COUNT(`id`) AS `result` FROM `users` LIMIT 1
// userNum: int64
userNum, err := genorm.
	Pluck(orm.User(), genorm.Count(user.IDExpr, false)).
	Get(db)

UPDATE文

UPDATE文は以下のようにして実行できます。
こちらも、Ctx付きの関数で終えるとcontextも使用できます。
ここには書いていませんがWHERELIMITORDER BY付きのUPDATE文にも対応しています。

// UPDATE `users` SET `name`="name"
affectedRows, err = genorm.
    Update(orm.User()).
    Set(
        genorm.AssignLit(user.Name, genorm.Wrap("name")),
    ).
    Do(db)

DELETE文

DELETE文は以下のようにして実行できます。
こちらも、Ctx付きの関数で終えるとcontextも使用できます。
ここには書いていませんがWHERELIMITORDER BY付きのUPDATE文にも対応しています。

// DELETE FROM `users`
affectedRows, err = genorm.
    Delete(orm.User()).
    Do(db)

Transaction

database/sqlと同様の方法で行えます。

tx, err := db.Begin()
if err != nil {
    log.Fatal(err)
}

_, err = genorm.
    Insert(orm.User()).
    Values(&orm.UserTable{
        ID: uuid.New(),
        Name: genorm.Wrap("name1"),
        CreatedAt: genorm.Wrap(time.Now()),
    }, &orm.UserTable{
        ID: uuid.New(),
        Name: genorm.Wrap("name2"),
        CreatedAt: genorm.Wrap(time.Now()),
    }).
    Do(db)
if err != nil {
    _ = tx.Rollback()
    log.Fatal(err)
}

err = tx.Commit()
if err != nil {
    log.Fatal(err)
}

今後の展望

現在サポートしきれていない以下のような機能も今後対応していきたいと考えています。

結果が複数かつ関数呼び出しを含むSELECT文

SELECT文の項目でも書いていたように、現在SELECT name, COUNT(id) FROM users GROUP BY nameのような複数の結果をもち、関数呼び出しや演算子を含むSQLの実行はGenORMではできません。
*sql.DB/*sql.Txを使用するため、sqlxなどとの併用もしやすくなっています。
しかし、やはりサポートできるSQLは多くしたいので、今後このようなSQLにも対応していく予定です。
方法としては、テーブルに加えてタプルを引数にとるメソッドチェーン開始の関数を新たに追加するのが適切かと考えています。

同じテーブルを2回JOINできるように

現在、~ JOIN users AS a ON ~ JOIN users AS b ON ~のような同じテーブルを2回JoinするようなSQLが実行できません。
このようなSQLも十分使用する可能性があるので、今後対応していきたいと考えています。

サブクエリ

現在、サブクエリを使用するSQLはRawExpr関数を用いて生でサブクエリ部分を書かなければ実行できません。
今後、サブクエリにも適切な型をつけてバグの発生を防止できる状態で使用できるようにしたいと考えています。

対応データベースの増加

現在、MySQL/MariaDBでしか動作確認ができておらず、その他のデータベースでは正常に動作しない可能性があります。
今後はPostgreSQLなど、他のデータベースでも動作するよう改善をしていきたいと考えています。

SQLに関する静的解析

当初の目的として考えていたものではないのですが、実際に使用してみてGenORMと静的解析の相性が非常に良いことに気がつきました。
GenORMではSQLの演算子・関数呼び出し、カラムの使用などが全てGoの関数として現れています。
このため、既存のORMやsqlxなどを使った場合以上に高精度に単純なGoのASTを用いての静的解析でN+1問題の発生などを検出できるのではないかと考えています。
現在は手が回らず静的解析ツールの整備まではできていないのですが、静的解析とGenORMを組み合わせることで、より高度なミスの防止が可能になるのではないかと考えています。

まとめ

今回はGo 1.18で入るジェネリクスを使い、コンパイル段階でミスを防止できるSQL Builder「GenORM」の紹介をしました。
まだまだ荒削りですが、既存のORMやSQL Builderなどと比較して十分メリットのあるツールになったのではないかと思います。

また、ORMやSQL Builder以外にもジェネリクスを使用することでこれまでinterface{}(any)が多用されていたツールでコンパイル時にエラーを出せるものがあるのではないかと思います。
今後、このようなツールが増え、ジェネリクスによりGoの開発体験がこれまで以上に良くなることを願っています。

脚注
  1. 実際に実行されるSQLはカラムにaliasが貼られたり、Prepared Statementが使用されたりして少し異なります ↩︎ ↩︎

  2. 詳細は https://mazrean.github.io/genorm-docs/ja/usage/value-type.html 参照 ↩︎

  3. 詳細は https://mazrean.github.io/genorm-docs/ja/advanced-usage/defined-type.html ↩︎

Discussion

ログインするとコメントできます