SQL中のtypoを何としてもコンパイラにチェックしてもらいたかった

github.com

以前に頭の中で考えたりしていたことだったのだけど。真面目に手を動かしてみることにしてみた。まだ作り途中。

考えていたこと

一番気にしたかったのは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 stringargs ...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の実装でところどころサポートされている文法は違うけれど。そこまでまじめに調べる段階にはないので。

動く実装

実際に動く実装の例はこのあたりにある。

あと、これは少し記事の話題とは離れた話題ではあるけれど、特定のライブラリによらずtrace logを出力させたいときに、sqldb-loggerが便利だった。

github.com

理想の話

理想的にはやむにやまれぬ事情でmongodbと付き合う必要があるので、これにもいい感じに対応したかったりする。ネストした属性が存在する点とstringではなくbson.Mが必要になる点等が異なる。