Cloudflare D1をtype-safeでO/Rマッパーぽく使用できるようにする
昨年にalpha版が公開されたCloudflare D1(以下: D1)ですが、alphaというだけあって痒いところに手が届いてない感じです。トランザクションなどDBとしての機能としてもそうですが、プログラムとしての書き味ももう少しってところです。(実際SQL書いちゃってるていうのが大きいですが)
そこで今回のこのD1の書き味、特にSQLやら型をなんとかしてみるというのがこの記事の本題です。
【前提条件】
- 今回記事内に使用するプログラムの完成形はココにあります。
- D1の他にCloudflare Pagesも使用しています。
- CloudflareへデプロイするプログラムはRemixを使用しています。
- D1に関する説明とそれに関するプログラム周りは説明しますが、すべてを詳細については説明しません。
まとめ
- kysely kysely-codegen kysely-d1 を使うことでSQLビルダーを使いつつD1へのアクセスが可能になる。
- 標準の
DB.prepare(<SQL>).all()
を使うより型が自動的に入るので更にD1が使いやすくなる。
Cloudflare D1とは
Unlike other databases on the market, Cloudflare D1 will use Cloudflare’s global network to optimize a businesses’ database by locating it as close as possible to their customers, providing the fastest possible experience to users.
市場にある他のデータベースとは異なり、Cloudflare D1はCloudflareのグローバルネットワークを利用して、企業のデータベースを顧客のできるだけ近くに配置します。そうすることで最適化し、ユーザーに最速のエクスペリエンスを提供します。
すごく平たくいうとエッジ上(CDNなど)にDBを構築して、エッジ上で動くアプリケーションから素早くアクセスできますよってことです。エッジコンピューティングはクライアント(アクセス元)の近くで動くという利点はあるのですが、DBアクセスする場合は結局エッジ上で動くプログラムから後ろに構えているDBにアクセスします。そうなるとDBアクセスが必要になることで結局クライアントの近くで動くという利点は薄くなります。なのでエッジコンピューティングでプログラムが動くならDBもそのエッジ上にほしいと思うのは必然です。それを解決するのがこのD1なわけです。
D1へのアクセスプログラム
標準版
まずはD1へのアクセスするためのプログラムを以下に示します。
const data = await env.DB.prepare('SELECT * FROM User').all()
console.log('data.results', data.results)
// #=> [
// { id: <number>, email: <string>, name: <string | null> },
// { id: <number>, email: <string>, name: <string | null> },
// ] 型は unkown[] | unkown となる
どうでしょうか?言いたいことはこんな感じでしょうか
- SQLを記載する必要がある
- 型をジェネリクスで指定する必要がある
「SQLを記載する必要がある」というのは例えばPrismaやTypeORMなどといったO/Rマッパーを使用してるとちょっと面倒だなと思ってきたりもします。ただ、仮にそれを許すとしてもTypeScriptでコードを書いて困るのは返り値( data.results
)に型が付かないことです。要は prepare
で実行するSQLを解釈しないと型はつけれません。なので上記のコードでいうと all
関数にジェネリクスで返り値の型を自分で定義する必要があります。
const data = await env.DB.prepare('SELECT * FROM User').all<{
id: number,
email: string,
name: string | null
}>()
現在はAlpha版なんで改善される可能性はありますが、それでもSQLと型の2重に書くのは嫌です。
Kysely版
KyselyというTypeScriptのSQLビルダーが存在します。
KyselyはPostgreSQL, MySQL, SQLiteを標準でサポートしています。ただ、このKyselyもこれ単体で使う分には型をジェネリクスで指定する必要があります。
このexampleを見れば分かるとおり、データベースのテーブル一覧からカラムまでを自分で定義しています。SQLは書かなくていいかもしれませんが、できればこのジェネリクス指定もなんとか省きたい。
テーブルおよびカラムの型を自動生成
そこでKysely-codegenを使います。
Kysely-codegenはその名の通り、Kysely用の型をデータベースからジェネレートしてくれます。例えばD1を使ったローカル開発の場合、SQLiteのデータベースファイルは以下の場所にあります。
<project root>/.wrangler/state/d1/<データベースBinding名>.sqlite3
なのでこんな感じでkysely-codegenを使ってやると
$ DATABASE_URL=./.wrangler/state/d1/DB.sqlite3 npx kysely-codegen
型を生成してくれるので、その型を使ってKyselyを使うと非常に楽になります。
import type { DB } from 'kysely-codegen'
import { Kysely, SqliteDialect } from 'kysely'
import Database from 'better-sqlite3'
const db = new Kysely<DB>({
dialect: new SqliteDialect({
database: new Database('./.wrangler/state/d1/DB.sqlite3')
})
})
D1でKyselyを使う
テーブルとカラムの型は自動生成できましたが、肝心のKyselyをD1で使う方法は別途必要です。そこで今度は Kysely-D1 を使います。
これはKyselyがD1を扱うことができるようになるアダプターです。ちなみにKyselyのアダプターはいくつかあります。
Kysely-D1を使うことでKyselyがD1を扱えるようになり、こんなコードになります。
//const data = await env.DB.prepare('SELECT * FROM User').all<{
// id: number,
// email: string,
// name: string | null
//}>()
import type { DB } from 'kysely-codegen'
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
const client = new Kysely<DB>({ dialect: new D1Dialect({ database: env.DB }) })
const users = await client.selectFrom('User').selectAll().execute()
console.log('users', users)
// #=> [
// { id: <number>, email: <string>, name: <string | null> },
// { id: <number>, email: <string>, name: <string | null> },
// ]
どうですか?これでだいぶ書き味がO/Rマッパーぽくなってきてないでしょうか?型も自動補完されるので使いやくなったと思います。
さいごに
いかがだったでしょうか?D1を使う上でSQLを書くのはちょっと面倒だなって思ってる人には悪くない案ではないでしょうか。このKysely使おうが、Prisma使おうが、私の感覚ではSQLを書いている気分になるのでSQLの結果が自動的に型として定義されるのは楽になったなという感覚です。
もちろんD1はまだまだalpha版がいえにこういう点は修正してくる可能性があります。もしD1で遊びたいって方は参考にどうぞ。
ちなみに私の作ったリポジトリにはマイグレーションも出来るだけ楽にできるようにsqldefをマイグレーションツールとして使用するようなこともやってますので覗いてやってください。
Discussion