🐚
TypeScriptのSQLビルダー Kysely をさわってみた
はじめに
最近リリースされた 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
を使用します
- orには専用の
- 生成されたSQL
select * from "user" where "age" > ? and "age" < ?
- もちろん型チェック&補完が効いています
- sum等の関数は
db.fn.sum<number>
で利用できます
update
await db.updateTable("user")
.set({ name: '!!!!!!!!' })
.where('id', '=', 1)
.execute()
- これも見たままなので詳細は割愛します
情報源
*ドキュメント
の上部の検索ボタンから自分の作りたい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