SQL中のtypoを何としてもコンパイラにチェックしてもらいたかった
以前に頭の中で考えたりしていたことだったのだけど。真面目に手を動かしてみることにしてみた。まだ作り途中。
考えていたこと
一番気にしたかったのはSELECT文の実行。SQLをそのまま書くのは、実際のqueryを把握しやすくて良いのだけれど、生のSQL部分を丁寧に取り扱わないとだめなのが辛かった。例えばフィールドの変更時などに。
あと、これはSQLではなくmongodbでのことなのだけど、誤った条件を渡したときに無反応で空の結果が返ってくるというような状況が辛かった。
そんなわけでなんとかコンパイラにチェックしてもらおうということで考えてみた。
フルのSELECT文の実行
外部のDBへの問い合わせということを考えてみると、フルのSELECT文の実行は概ね以下のような関数のバリエーションで考えることができる。
func Select(ob interface{}, stmt string, args ...interface{}) error
ここで、名前と値の束縛部分を、つまりコンパイラにチェックして欲しいマッピング部分を考えてみると、以下の2箇所の部分に分けられる。
- クエリーにわたす条件部分での値の束縛
- クエリーの結果を値として束縛する部分
前者はwhere、後者はselect、と雑に考えられそう。先程あげた関数の例で言えば、前者はob interface{}
とstmt string
の部分。後者はstmt string
とargs ...interface{}
の部分ということになる。
今回どうにかできそうなのは主に後者の部分。
プレースホルダーの利用
基本的にはクエリー文字列を全部文字列で渡すことは推奨されていない。SQLインジェクションなどが起こせるので、prepared statementなどを使って値を埋め込む形にする。アプリケーション側のコード的にはfmt.Sprintfなどでの文字列生成と概ね同じ。
例えば、sqlxでは以下の様な形で使われる(https://jmoiron.github.io/sqlx/)。
// this will pull places with telcode > 50 into the slice pp err = db.Select(&pp, "SELECT * FROM place WHERE telcode > ?", 50)
細かな話をすれば、プレースホルダー部分が$1, $2, ...
だったり?
だったり:name, :age
とそれぞれのdbによって異なる可能性は出てくるが、そこは単に差異を吸収する変換層があれば良いだけなので、今回は全部?
で進める。
今回、このSELECT * FROM place WHERE telcode > ?
というqueryで気にするのは、FROM部分とWHERE部分。特にWHERE部分。telcodeはおそらくフィールド。ここが例えばtellcodeなどとタイポしていたりするのが嫌。あるいはこのtelcodeをcodeなどに変えるときに丁寧に確認していく必要があるのが嫌。整合性がコンパイル時にチェックされて欲しい。
条件のペアを型付きで表したい
生の文字列を使ったSELECT文の例と、条件部分でのプレースホルダーの利用を考えると、条件部分を設定するときには注入される値とのペアで管理ができて、クエリーの実行時には文字列として利用できるような形になると嬉しい。
例えば、以下の様な形で。
type Query struct { ... } // あとで func (q *Query) Stmt() string {} func (q *Query) Values() []interface{}
このQueryのような値があると先程の SELECT * FROM place WHERE telcode > ?
は以下の様なコードとして表わせる。
// Placeという値も有ることにする。これは place tableと紐付いている q := Query( From(Place), Where(Place.Telcode.Compare("> ?", 50)), Select("*"), // このselectはdefaultということにしても良いかもしれない ) err = db.Select(&pp, q.Stmt(), q.Values()...)
Where
部分が良い感じに表せられると一歩前進できる。どうなってほしいか?
- 特定のテーブルが持つカラム名だけが利用できる
- 特定のカラムに対応する型の値だけが設定できる
この様になっていると嬉しい。前者は、Placeという値(table)が持つTelcode(colum)というフィールドのCompare()というメソッドを利用するという取り決めである程度制限ができる。もちろん他のテーブルのものを無理やり使うことはできるがそこには目をつぶる。
また、このCompare()というメソッドは func(string, int) expr
のような型になって欲しい。もちろん、int以外のフィールド部分では、func(string, <T>) expr
のような型になって欲しい。
個人的にはEQ(),GT(),LT()
のようなメソッドは、どちらに対しての大小関係なのかがわからなくなってしまう。そんなわけで個人的には苦手だったりする。そんなわけで直接指定したほうが嬉しい(一方で?
という値が無いとエラーになるのと、"? <"は許されていないというのはlinterのような物が必要かもしれない)。
条件は木で表す
例えば、ANDやORが複雑になって行くことを考えると、flatなリストではちょっと困る。まぁそのあたりは木にしてDFSっぽい感じで集めていけば良い。
例えば、
And("x", Or("y", "z"))
というような条件は、以下の様にして扱われる。
(x AND (y OR z))
ここでValues()とStmt()で値を取り出すと、以下の様な形で取り出されるイメージ。
Stmt :: "x=? AND (y=? OR z=?)" Values :: [x, y, z]
method chainではなくfunctional optionsで表したい
先程のSELECT *
はデフォルトの振る舞いになって欲しい。あれこれ省略するためにfunctional optionsにしたい。method chainが嫌な理由はself reference typeなどがないため。
ここでそのまま実行したくなる。
err := Query( Where(Place.Telcode.Compare("> ?", 50)), ).Do(db.Select, &ob)
バリエーションを考えると、戻り値も存在する以下の様なメソッドも用意しておけば良さそう。
_, err := Query( Where(Place.Telcode.Compare("> ?", 50)), ).DoWithValue(db.Select1, &ob) _, err := Query( Where(Place.Telcode.Compare("> ?", 50)), ).DoWithValues(db.Select2, &ob)
このようにしてあげると、利用するライブラリが以下のどれであっても気にすることなくコードが書ける
まぁ、文字列を渡す形式でも同様なのだけど。
(このDo(),DoWithValue(),...の関数でのwrapは、なにか実行前にhookを入れたくなる可能性を考えての対応ではあるけれど、不要かもしれない。)。
SELECT部分の指定
当初は、fieldがCompare()を持たずに直接関数だったのだけれど、select部分の指定もこれでやりたいと考えたときに困った。As()というメソッドも持つようにしてあげるとある程度のSELECT部分に対応できる。
type row struct { ID string `db:"id"` code int64 `db:"code"` } var ob row err := Query( Select( Place.ID, Place.Telcode.As("code"), ), Where(Place.Telcode.Compare("> ?", 50)), ).Do(db.Select, &ob)
一方で、冒頭で話したとおり、この「クエリーの結果を値として束縛する部分」は文字列指定にならざるおえず、コンパイラの恩恵を受けることができない。
literalf
SELECT部での複雑なQueryのためにLiteralf()のような関数を用意しておくと良いかもしれない。フィールドを扱う部分は全部型が存在する形で指定しておけると嬉しい。
/* type Person struct { ID int64 MotherID int64 // いない場合には0 FatherID int64 // いない場合には0 Name string } */ Select( Person.ID, Person.Name, Literalf( "case when %s then 1 else 0 end", And( Person.MotherID.Compare("=", 0), Person.FatherID.Compare("=", 0), ), ).As("origin"), ),
JOIN
JOINも似たような形で取り扱いたい。少しめんどくさいのは、SELECT部分の指定にjoinで指定したテーブルがprefixとしてつく点。例えば先程のPersonという型のテーブルを自己結合して母と父の名前を一緒に取って見る。
SELECT p.id, p.name, f.name, m.name FROM people as p JOIN people as f ON p.father_id = f.id JOIN people as m ON p.mother_id = m.id
めんどくさいのはJoinされたTableはinterface{}
のような型ではあってほしくないという点。このためだけにtableはメソッドを持つ必要がある。
type view struct { ID int64 `db:"id"` Name string `db:"name"` FatherName string `db:"father_name"` MotherName string `db:"mother_name"` } p := Person.As("p") father := Person.As("father") mother := Person.As("mother") var rows []view err := Person.Query( From( p. Join(father, On(p.FatherID, father.ID)). Join(mother, On(p.MotherID, mother.ID)), ), Select( p.ID, p.Name, father.Name.As("father_name"), mother.Name.As("mother_name"), ), ).Do(db.Select, &rows)
これを動かすためにreflectを使ってしまっているのだけれど、LRUなどでcacheしても良いかもしれない。
実際の実装
実際これをまともに動かすには2つの定義が必要になる。mapされる値と条件を指定するための定義。
mapされる値
package model type Person struct { ID int64 `db:"id,primarykey,autoincrement"` FatherID int64 `db:"father_id"` // todo nullable MotherID int64 `db:"mother_id"` // todo nullable Name string `db:"name"` }
条件を指定するための定義
package q type PersonDefinition struct { typoless.Table ID typoless.Int64Field FatherID typoless.Int64Field MotherID typoless.Int64Field Name typoless.StringField } func (d *PersonDefinition) As(name string) *PersonDefinition { new := *d typoless.Alias(&new, d, name) return &new } var Person = PersonDefinition{ Table: typoless.Table("people"), ID: typoless.Int64Field("id"), FatherID: typoless.Int64Field("father_id"), MotherID: typoless.Int64Field("mother_id"), Name: typoless.StringField("name"), }
通常は、global varの方を使う。As()の実装のせいもあってわざわざ型を作る必要がある。この辺の実装はめんどくさいので実際に使うときにはコード生成して使うようにするとは思う。
いろいろ考えるために
いろいろ考えるためにsqliteのこのページが便利だった。railroad diagramがいい感じでみれる。個別のDBの実装でところどころサポートされている文法は違うけれど。そこまでまじめに調べる段階にはないので。
動く実装
実際に動く実装の例はこのあたりにある。
- sqlxの例 https://github.com/podhmo/typoless/blob/master/_examples/00sqlx/main00.go
- gorpの例 https://github.com/podhmo/typoless/blob/master/_examples/01gorp/main01.go
あと、これは少し記事の話題とは離れた話題ではあるけれど、特定のライブラリによらずtrace logを出力させたいときに、sqldb-loggerが便利だった。
理想の話
理想的にはやむにやまれぬ事情でmongodbと付き合う必要があるので、これにもいい感じに対応したかったりする。ネストした属性が存在する点とstringではなくbson.Mが必要になる点等が異なる。