GoのORMを作った
GoのORMを作ったkeroxp.icon2020/5/3 Gormがとんでもなくヤバい核地雷だと発覚したその後の数日で自前でORMを作ることを決断して実際に作った by keroxp.icon
まずORMを作るにあたりいくつか明確な方針を決めた
それは紹介文にも書いてあるとおり、
Safe: ユーザが意図しなデータの削除や更新を絶対に行わない
Strict: 設定が必要な箇所で、設定が間違っている場合は必ずエラーにする
Clear: クエリ生成をブラックボックス化しない
この3つに集約されます
要するに、「これを行ったらSQL的に何が起こるのか分からない」という状態に陥らせないように設計しました
ORM的特性
必要最小限のリフレクションは使う
ORMにおいてリフレクションは必要です
しかし最低限にとどめ、多義的な利用の仕方を抑制する必要があると思っている
タグとリフレクションによる行マップ
Goのsql/databaseは行データを変数に代入するインターフェースとしてsql.Rows.Scan()が用意されているが、これは現実の使用に即していない
code:go
rows, _ : = db.Query("select * from users limit 1")
var id int
var name string
rows.Scan(&id, &name)
ORMを使わないとこのようなコードを書くことになるが、これは大きな問題があり、
.Scan()にわたす引数の数はテーブルの列数と完全に一致しないといけないという制約がある
つまり実行される時点で予めテーブルの列数を把握していないといけないわけだが、これはつまり実際に動いているサーバーを止めずにテーブルに列を追加することができないということを意味する
デプロイの前にマイグレーションなどを行ってしまうと、動いているサーバーがエラーになってしまう
順番を逆にしても同様
つまりこの時点で現実的に使える選択肢にはならない
そこで使うのがリフレクションである
code:go
type Users struct {
Id int exql:"column:id;primary"
Name string exql:"column:name"
}
リフレクションはこのように構造体のフィールドにタグを書いておいて、実行時に動的に列データとフィールドをマッピングさせる方法
DBの列の名前とフィールドの名前をこのように対応させておくことで、構造体の方にない列が来たとしても無視して空の引受先に入れることで、列数を知らなくても部分的な列のマッピングが可能になる
たとえばユーザテーブルをこのように変えたとしても、
code:userdb
| id | age | name |
| &User.Id | - | &User.Name |
このように知らない列ageが来たら無視するようにできる
モデルジェネレータ内蔵
Exqlにはマイグレーションの機能はないが、モデルのジェネレータが内蔵してある
基本的には別の手段でテーブルの作成やマイグレーションを行い、そこから毎回モデルを生成し直すことになる
code:go
import (
"github.com/loilo-inc/exql"
"log"
)
func GenerateModels() {
gen := exql.NewGenerator(db.DB())
err := gen.Generate(&exql.GenerateOptions{
// Directory path for result. Default is model
OutDir: "dist",
// Package name for models. Default is model
Package: "dist",
// Exclude table names for generation. Default is []
Exclude: []string{
"internal",
},
})
if err != nil {
log.Fatalf(err.Error())
}
}
ちなみにCLIは用意していない
理由はgo install経由でのCLIのインストールはプロジェクトのVGOとバージョンが異なることがあるので
上記のようにプロジェクトで使っているExqlのバージョンのジェネレータを必ず使うようにするべき
ちなみにタグとして有意な記述は以下の2つだけである
code:model.go
type Users struct {
// カラム名: id, primary key
Id int exql:"column:id;primary"
// カラム名: name
Name string exql:"column:name"
}
他にもテーブルの設定によってint(11)や not nullやauto_incrementなども記述されるが、これはジェネレータがカラムの情報をできるだけコピペしているだけなので、特に意味はない
文法は、key(:val)?;...という感じ
columnはマップするフィールドに対して必須
primaryはプリマリキーに対して必須かつどれかのフィールドに必須
insert以外のクエリ生成は基本的に行わない
selectやupdate、deleteなどのアプリケーション依存のSQLクエリは案目的に生成しません
これは「どんなクエリが発行されるのか分からない」という状態を避けるためです
insertだけはモデルを自動生成する都合上、リフレクションで生成します
code:go
user := model.User{
Name: "goland"
}
// INSERT INTO users (name) VALUES (goland)
err := db.Insert(&user)
このクエリ生成の仕組みだけ解説します
なぜusersテーブルにインサートされるのか?
exqlジェネレータが生成したファイルには、デフォルトで以下のようなメソッドが追加されています。
code:go
func (u *Users) TableName() string {
return "users"
}
これはDBに作成されたテーブルの名前であり、Insertの前に構造体のこのメソッドをリフレクションから呼び出してテーブルの名前を特定しています。そのためTableName()が実装されていないモデルはInsertできません。
プライマリキーが除外されているのはなぜか?
ExqlはInsert文にプライマリキーのフィールドを含めません
これには色々な理由があるのですが簡潔に言うと、
構造体のその値が初期値なのか設定されたプライマリキーなのか判断できない
ということが挙げられます
通常RDBMSの主キーはDB側が設定する場合が殆どと思いますが、主キーをINSERT時に自分で設定することも可能です
通常はそういったセカンダリ移行のキーは別途インデックスを貼ることになると思うのですが、DBの仕様上主キーの管理を自分で行うことも可能です
しかしこれは仕様上の話であり、主キーしかないテーブルで自分で主キーを管理することは稀と判断し、primaryタグが付いたフィールドはInsert文から除外されます
どのフィールドがVALUESに入るのか?
主キーのフィールド以外で、exqlタグの付いたフィールド全てです
主キーを自分でINSERTしたい場合はどうするのか?
今のところできません
結合した行のマップ
Exqlの一番のウリとしてはJOINで結合した行を複数の構造体に順番にmapできるというものがあります
これはINNER JOINなどで複数のテーブルを結合した場合などに便利で、他のORMだとできなかったのでとても便利に使っています