🐚

TypeScriptのSQLビルダー Kysely をさわってみた

2022/02/04に公開約5,100字

はじめに

最近リリースされた KyselyというTypeScriptのSQLビルダーがよさそうですが、まだ情報がほぼない状態です。なので軽くさわった程度ですが記事にしようと思いました

Kysely概要

  • TypeScriptで型安全にSQLを組み立て、発行できる
  • カラム名などに補完が効く
  • PostgreSQL, MySQL, SQLiteをサポートしている
  • node.js, deno, ブラウザで動く

使い方

以下はバージョン0.16.9を使っています。

デーブルの型を定義

interface Database {
    user: {
        id: Generated<number>,
        name: string,
        age: number
    },
    product: {
        id: Generated<number>,
        name: string,
        price: number
    },
    purchase_history: {
        id: Generated<number>,
        user_id: number,
        product_id: number,
        pay_method_id: number
    }
}
  • keyにテーブル名、valueにカラムの型を記述した型を定義します。
  • 自動採番されるカラムはGenerated<number>と書きます。
    • insert等ではこのカラムは入力不要で、selectの戻り値にはnumberがつきます

DBに接続

const db = new Kysely<Database>({
    dialect: new SqliteDialect({
        databasePath: ":memory:"
    }),
    log(event) {
        if (event.level === 'query') {
            console.log(event.query.sql)
            console.log(event.query.parameters)
        }
    }
})
  • 今回はSQLite用のSqliteDialectを使用します。PostgreSQLの場合は公式のサンプルを見てください
  • logメソッドはデバッグ用に生成されたSQLをプリントしています。オプションです。

テーブル作成

    await db.schema
        .createTable('purchase_history')
        .addColumn('id', 'integer', col => col.primaryKey().autoIncrement())
        .addColumn('user_id', 'integer', col => col.references("user.id"))
        .addColumn('product_id', 'integer', col => col.references("product.id"))
        .execute()
  • 上記のようにテーブルを作成できます。見たままなので詳細は割愛します
  • テーブル作成では型チェックが効いていません。最初に定義したDatabase型と不整合がないように気を付けましょう。
  • executeの部分をcompileに変えるとコンパイル結果を取得できます。デバッグに使用できます。

insert

    const { id } = await db.insertInto("user").
        values({ name: 'i_am_30_years_old', age: 30 })
        .returning('id as id')
        .executeTakeFirstOrThrow()
  • これも見たままなので詳細は割愛します
  • インサートは型チェックが効います。例えばageを文字列にするとエラーとなります。

Select

    const result = await db
        .selectFrom("user")
        .selectAll()
        .where("age", ">", 10)
        .where("age", "<", 30)
        .execute()
  • 先にFROMのテーブルを書きます。これでselectなどに補完が効くようになります
  • select * に相当するのがselectAllです。
    • 特定のカラムのみ取り出すにはselectAllの変わりに .select(['age', 'name as n']) のように書きます
  • and 条件は 上記のようにwhereを2回書くと作れます
    • orには専用のorWhereが用意されています
    • whereの3項目は値として扱われます。カラム名にしたい場合はwhereRefを使用します
  • 生成されたSQL
select * from "user" where "age" > ? and "age" < ?
  • もちろん型チェック&補完が効いています
  • sum等の関数はdb.fn.sum<number>で利用できます

update

    await db.updateTable("user")
        .set({ name: '!!!!!!!!' })
        .where('id', '=', 1)
        .execute()
  • これも見たままなので詳細は割愛します

情報源

*ドキュメント

https://koskimas.github.io/kysely/
の上部の検索ボタンから自分の作りたいSQLのキーワードで検索をかけるとインターフェースがヒットします。(分かりづらい)。インターフェースの解説にサンプルが載ってたりします

まだ未成熟な点

サポートされないSQL

  • betweenやwindow関数など、サポートされていないものも多くあります。直接SQLを書くrawというメソッドはあるので、それを使うことはできます

特定のDBでサポートしてない構文で型エラーが出ない

SQLiteはDateやFullJoinをサポートしませんが、それに対して型エラーが出たりするわけではなく、実行時にエラーとなります

バグ?

  • SQLiteのバグでid as idとエイリアスが必要とドキュメントに書いてあります
    • しかし、エイリアスをつけても戻り値を取得できていません。

型エラーの読みづらさ

  • 型推論とジェネリクスを多用しているため、型エラーが非常に複雑です。適度に型アノテーションを手で書くなど、ノウハウが今後待たれます

Tips

型推論が固まる & ts(2589)エラー の回避

あまりにも長いSQLを書くとエラーが起きます。

型のインスタンス化は非常に深く、無限である可能性があります。ts(2589)

型推論による計算があまりに大きく、TypeScriptが悲鳴を上げている状態です。

長いSQLを書かないのが一番良いのですが、どうしても書かないといけない場合は次の方法で回避できることがあります。

dbに型アノテーションを追加する

型アノテーションを手動でつけるとそこの型推論が不要になり、エラーがなくなります。
しかし、kyselyが自動でつける型は非常に複雑でなかなか手で書くのは厳しいです。
ただ、db => となっている部分は比較的楽に手で型をつけることができます。
QueryCreator<Database & 一時テーブルの型>という型をつけてやればよいです

  • エラーとなるwith句抜粋
await db.with("age_10_30", db => db
        .selectFrom("user")
        .selectAll()
        .where("age", ">", 10)
        .where("age", "<", 30)
    ).with("cheep_product", db => db
        .selectFrom("product")
        .selectAll()
        .where("price", "<", 1000)
  • 型アノテーションを追加(age_10_30はuserテーブルと同じ型なのでDatabase["user"]としている)
await db.with("age_10_30", (db: QueryCreator<Database>) => db
        .selectFrom("user")
        .selectAll()
        .where("age", ">", 10)
        .where("age", "<", 30)
    ).with("cheep_product", (db: QueryCreator<Database & { age_10_30: Database["user"] }>) => db
        .selectFrom("product")
        .selectAll()
        .where("price", "<", 1000)

おまけポエム:なぜORMでなくクエリビルダーに期待するのか

ORMも便利は便利なのですが、次のトレードオフがあり、必ずしもベストとは思っていません

  • いつSQLが発行されるのか分かりにくい
  • どんなSQLが発行されるか分かりにくい
  • ORMのバージョンによって挙動がかわりうる。それにより、バージョンアップで動かなくなるリスクがある(経験談)

クエリービルダーであればSQLの内容も発行タイミングも明示できるため、上記の点については回避できます。個人によるところはありますが、私はORMよりもクエリービルダーを推します。

Discussion

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