🔥

Cloudflare D1 で ORM を使う (drizzle-orm)

2023/04/30に公開

tl;dr

  • 生産性を上げる & SQL インジェクションを防ぐために ORM を使うのがよいとされている(諸説あります)
  • cloudflare workers + d1 はウェブの破壊的イノベーション(諸説あります)
  • モダンフロントエンドで大切なのは TypeScript との親和性と言われている(諸説減ってきた)
  • 本当は理想の ORM を自作したいのけど、drizzle が現状一番自分のゴールに近いので、試したら良さそうだった

既存の問題と drizzle-orm

今までのあらすじ

https://zenn.dev/mizchi/articles/cloudflare-d1

というわけで d1 に全振りするのが今後の生存戦略として有効だと思っているんですが、d1 client は専用のAPIからクエリ文字列を送り込む形式なので、native driver を使ってる prisma や typeorm 等が使えません。

自分が Mongodb + たまに Rails ActiveRecord 育ちなので、生SQL だと生産性が爆下がりします。とはいえSQL書けないのはコンプレックスだったので、自分の勉強兼ねて ORM を自作しよう、と思って GW に自作に取り掛かりました。

が、調べた結果 drizzle-orm が自分がほしいものに近かったので、最初はこれを使い倒してみることにしました。

https://github.com/drizzle-team/drizzle-orm

d1 に関する他の注意点として、 2023年4月現在 の d1 は, まだ open alpha で read replica が有効になってないのと、ストレージサイズに 100MB の制限があり、これが結構しんどいんですが、 open alpha 終わる頃にはさすがに 1GB は使えるだろうという期待があります。(それ以上でかいデータを扱いたいときは d1 から定期的に逃した方がよさそう)

本当は prisma を使いたいんですが、ビルドが跳ねそうなのと、prisma 側が open alpha だから対応したくないオーラを出しています。が、cloudflare 側の contributer は乗り気っぽいので、いずれ動くかもしれないですね。

https://github.com/prisma/prisma/issues/13310
https://github.com/cloudflare/workers-sdk/issues/2701

前提: 自分の ORM への要求と既存の実装

というわけで、自分がほしい ORM を探す or 作ろうとして、要求はこうです。

  • prisma 風のAPIと型推論
  • 独自APIが少なくて素朴な query builder に近いもの
  • 宣言的スキーマと migration 機構

d1 で使えるORMとして、公式にリストがあるので、上からみていきます。

https://developers.cloudflare.com/d1/platform/community-projects/

D1-ORM

https://github.com/Interactions-as-a-Service/d1-orm

prisma 風のAPIですが、あんまり開発が Active ではなさそうです。
自作する場合、 TypeScript の型推論部分の実装が参考になりそう?

Drizzle ORM

https://github.com/drizzle-team/drizzle-orm

公式に d1 サポート。https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1

見た感じ自分の要件を満たしてそうです。

公式 example の例。sqlite 版をちょっといじって使うことになって、d1 だと返り値が全部 Promise 化されます。

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';

const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  fullName: text('full_name'),
})

const sqlite = new Database('sqlite.db');
const db = drizzle(sqlite);

const allUsers = db.select().from(users).all();

詳細は後述

d1-kysery

https://github.com/aidenwallis/kysely-d1

クエリビルダの kysery の d1 adapter。
既存の kysery ユーザー向けに見える。型とかいらん、クエリビルダだけくれ、という人はこれでよいかも。マイグレーション機構とかはなし。

公式サンプル

mport { D1Dialect } from 'kysely-d1';

export interface Env {
  DB: D1Database;
}

interface KvTable {
  key: string;
  value: string;
}

interface Database {
  kv: KvTable;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { searchParams } = new URL(request.url);
    const key = searchParams.get('key');
    if (!key) {
      return new Response('No key defined.', { status: 400 });
    }

    // Create Kysely instance with kysely-d1
    const db = new Kysely<Database>({ dialect: new D1Dialect({ database: env.DB }) });
    
    // Read row from D1 table
    const result = await db.selectFrom('kv').selectAll().where('key', '=', key).executeTakeFirst();
    if (!result) {
      return new Response('No value found', { status: 404 });
    }

    return new Response(result.value);
  },
};

型は自動導出されないので自分で型定義をメンテする必要があります。

workers-qb

https://github.com/G4brym/workers-qb

d1 用の簡易な query builder。 d1 で使うことだけ想定していて、軽量。

import { D1QB } from 'workers-qb'
const qb = new D1QB(env.DB)

const fetched = await qb.fetchOne({
  tableName: 'employees',
  fields: 'count(*) as count',
  where: {
    conditions: 'active = ?1',
    params: [true],
  },
})

console.log(`Company has ${fetched.results.count} active employees`)

API のインターフェース的に TypeScript の型定義が付くものではなさそうです。ts-sql 的な template literal 推論系のライブラリと組み合わせたら使いやすいかもしれないですが、今回はパス。

https://github.com/codemix/ts-sql

knex が node 依存が激しくて cloudflare workers で動かすのが大変そうだったんですが、node サポート入った今なら動くかもしれません。が、推論効かないタイプのAPIなのでパス。

Rust 製のクエリビルダ(diesel等)を wasm ビルドするのも考えましたが、あえて rust で開発するのでない限り嬉しさはなさそうだったので、それは Rust を使う別の機会に考えます。

https://superflare.dev/ が Rails 的なオールインワン環境かと思って試してみましたが、初期生成コードが全然動く気配なかったので、参考程度にしかなりませんでした。

最初に: Cloudflare D1 自身の Migration 機構

じゃあここから 自分の要件を満たしていた drizzle の紹介... の前に、cloudflare d1 自体が簡易なマイグレーション機構を持ってるので、その使い方を抑えておく必要があります。

https://developers.cloudflare.com/d1/platform/migrations/

# マイグレーション一覧の確認
$ wrangler d1 migrations list <DATABASE_NAME> [OPTIONS]

# マイグレーションの生成
$ wrangler d1 migrations create <DATABASE_NAME> "<MIGRATION_NAME>"

migrations create ... を実行すると、migrations/ 以下にファイルが生成されます。

migrations/
  0001_init.sql

中身は空。これを編集して適用。

# 0001_init.sql を編集してから適用
$ wrangler d1 migrations apply <DATABASE_NAME>

この実行時、ジャーナルやメタ定義が生成されなくて、どうなってるか調べたらマイグレーションステップは d1 のテーブルとして書き込まれてます。

$ pnpm wrangler d1 execute <db-name> --command="select * from d1_migrations;"

DB自体にマイグレーション状態を書き込むのって、分散DB環境だったらDBごとに適用段階違うからぶっ壊れない?と思ったんですが、d1 って write は分散されずにプライマリDBにいくので、これで問題なさそう。

d1 migrations create はスキャフォルドする程度なので、他のフレームワークと組み合わせて使いたい場合、 migrations/ の下に時系列で [order_prefix]_[migration_name].sql を生成してくれれば、d1 migrations apply でそのまま使える、と言えそうです。(drizzle がこれを満たしています)

このd1のマイグレーション機構、未完成っぽくて次の機能が後で追加される予定と書いてあります。

These are some of the features we plan to add in the future:

Down migration: The same way that apply builds up the database, there will be a way to go down migrations or rollback changes.
Fake migrations: Mark a migration as already applied without changing the database.
Apply only a specific migration: Apply only a specific migration without going through the sequential order.

d1_migrations テーブルにマイグレーションステップが書き込まれていることを知っていれば、手動で Fake migrations や Apply only a specific migration はできそうですね。

rollback は...ORM 側でやるか公式機能でやるか、悩ましそう。何が実装されるか次第。


ここまでが drizzle を使うことにした自分の事前調査でした。ここから drizzle on cloudflare workers していきます。

完成形はここにありますが、要素を理解しないと怖くて使えないと思うので、自分が組み立てた手順を解説していきます。

https://github.com/mizchi/d1-drizzle-example

結果だけ言うと、このリポジトリをクローンして GitHub Action Secrets に Cloudflare API Token を設定し、 release/* ブランチに push するたびに、 d1 がマイグレーションされて wrangler publish されます。

Cloudflare Worker プロジェクトを作る

npx wrangler generate <app-name> でサクッとボイラープレートを作ります。色々聞かれるけど適当に Y を押しつつ、最後に自分の好みで pnpm 化しました。

$ npx wrangler generate d1-drizzle
Using npm as package manager.
✨ Created d1-drizzle/wrangler.toml
✔ Would you like to use git to manage this Worker? … yes
✨ Initialized git repository at d1-drizzle
✔ No package.json found. Would you like to create one? … yes
✨ Created d1-drizzle/package.json
✔ Would you like to use TypeScript? … yes
✨ Created d1-drizzle/tsconfig.json
✔ Would you like to create a Worker at d1-drizzle/src/index.ts? › Fetch handler
✨ Created d1-drizzle/src/index.ts
✔ Would you like us to write your first test with Vitest? … yes
✨ Created d1-drizzle/src/index.test.ts
npm WARN deprecated rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 165 packages, and audited 166 packages in 25s

20 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✨ Installed @cloudflare/workers-types, typescript, and vitest into devDependencies

To start developing your Worker, run `cd d1-drizzle && npm start`
To start testing your Worker, run `npm test`
To publish your Worker to the Internet, run `npm run deploy`

確認

$ tree . -I node_modules
.
├── package-lock.json
├── package.json
├── src
│   ├── index.test.ts
│   └── index.ts
├── tsconfig.json
└── wrangler.toml

一旦 node_modules と lockfile をふっとばして、pnpm で初期化

$ rm -r node_modules package-lock.json
$ pnpm install

# 起動確認

$ pnpm wrangler dev

http://localhost:8787 で動作確認

ここまでで worker の setup 完了。

d1 database の初期化

d1 database を初期化します。今回は dzltest という名前でDBを作成しますが、適宜置き換えてください。

$ pnpm wrangler d1 create dzltest

✅ Successfully created DB 'dzltest'!

Add the following to your wrangler.toml to connect to it from a Worker:

[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "dzltest"
database_id = "<your-id>"

まだ、自分がちゃんと --preview を使いこなしてないんですが、本番とプレビュー環境のDBを分けたかったら、もう一個DBを作成しておいて切り替えられるようにしておくといいでしょう。(d1 open alpha のデータベース作成数の上限は10個です)

(ここで workers から素の d1 を叩くサンプルを書くか迷ったんですが、略。動作確認したければやったほうがいいと思います)

drizzle のセットアップ

最初に drizzle の CLI とライブラリ実体を追加しておきます。

$ pnpm add drizzle-kit drizzle-orm -D

スキーマ定義を置きます。

src/schema.ts
/*
  DO NOT RENAME THIS FILE FOR DRIZZLE-ORM TO WORK
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey().notNull(),
  name: text('name').notNull(),
});

drizzle-orm はスキーマ定義ファイルをTypeScriptとして静的解析してマイグレーションを生成するみたいです。ちょっとワイルドですね。なのでコメントでリネームしないように釘を刺しておきました。

drizzle-kit でこのスキーマに合わせた初回マイグレーションを生成します。

$ pnpm drizzle-kit generate:sqlite --out migrations --schema src/schema.ts
drizzle-kit: v0.17.6
drizzle-orm: v0.25.3

1 tables
users 2 columns 0 indexes 0 fks

[] Your SQL migration file ➜ migrations/0000_acoustic_doorman.sql

スクリプト名はなんか適当に生成されてるらしいです。(気に食わなかったら手動で変更すればよさそう)

$ tree migrations/
migrations/
├── 0000_acoustic_doorman.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

中身を見てみます。

migrations/0000_acoustic_doorman.sql
CREATE TABLE `users` (
	`id` integer PRIMARY KEY NOT NULL,
	`name` text NOT NULL
);

このマイグレーションを最初にローカル環境で適用して、次に本番に適用していきます。

--local

まずは手元で動かすために sqlite driver をインストールしておきます。(これをしないと migration の適用が失敗します)

$ pnpm add better-sqlite3 --dev

--local をつけて実行

$ wrangler d1 migrations apply dzltest --local
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
Migrations to be applied:
┌───────────────────────────┐
│ Name                      │
🌀 Mapping SQL input into an array of statements
🌀 Loading DB at .wrangler/state/d1/DB.sqlite3
┌───────────────────────────┬────────┐
│ Name                      │ Status │
├───────────────────────────┼────────┤
│ 0000_acoustic_doorman.sql │ ✅       │
└───────────────────────────┴────────┘

なんかCLIのテーブルが崩れてるけどおk。

worker から drizzle-orm を使ってクエリを実行するためにコードを書いていきましょう。

src/index.ts
import { drizzle } from 'drizzle-orm/d1';
import { users } from './schema';

export interface Env {
  DB: D1Database;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    const db = drizzle(env.DB);
    const result = await db.select().from(users).all();
    return Response.json(result);
  },
};

スキーマから型が推論されて、型がついているのが確認できます。これがやりたかった。

これで pnpm wrangler dev --local --persist でローカル実行。

http://localhost:8787[] が帰ってきたら成功(まだデータを入れてないので空)

Sqlite の DB 実体は .wrangler の下にあります。

$ tree .wrangler/
.wrangler/
└── state
    └── d1
        └── DB.sqlite3

本番環境

今のを --local 外して実行します。

$ pnpm wrangler d1 migrations apply dzltest

🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 1.704942001029849ms
🚣 Executed 1 command in 10.565411001443865ms
🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 1.6205519996583462ms
Migrations to be applied:
┌───────────────────────────┐
│ Name                      │
🌀 Mapping SQL input into an array of statements
🌀 Parsing 2 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 0.4101349990814924ms
🚣 Executed 1 command in 7.989365998655558ms
┌───────────────────────────┬────────┐
│ Name                      │ Status │
├───────────────────────────┼────────┤
│ 0000_acoustic_doorman.sql │ ✅       │
└───────────────────────────┴────────┘

--local 外して開発サーバーを建てます。src/index.ts のコードは手元のだけど、DB自体はcloudflareにトンネリングされて本番に飛んでるはず。

$ pnpm wrangler dev

先ほどと同じ http://localhost:8787 で確認。これはローカルの worker から本番DBに繋いでるイメージ。

ここでちゃんと migration が適用されてるか確認。

$ pnpm wrangler d1 execute dzltest --command="select * from d1_migrations;"

🌀 Mapping SQL input into an array of statements
🌀 Parsing 1 statements
🌀 Executing on dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506):
🚣 Executed 1 command in 86.91357399895787ms
┌────┬───────────────────────────┬─────────────────────┐
│ id │ name                      │ applied_at          │
├────┼───────────────────────────┼─────────────────────┤
│ 1  │ 0000_acoustic_doorman.sql │ 2023-04-29 08:43:11 │
└────┴───────────────────────────┴─────────────────────┘

Workers Release

手元からマイグレーションできてるので、あとはそのまま wrangler publish すればよいです。

$ pnpm wrangler publish
Your worker has access to the following bindings:
- D1 Databases:
  - DB: dzltest (ab8b338d-2bdb-4c24-9f8e-1095dc814506)
Total Upload: 6.47 KiB / gzip: 2.08 KiB
Uploaded d1-drizzle (1.77 sec)
Published d1-drizzle (3.74 sec)
  https://d1-drizzle.mizchi.workers.dev
Current Deployment ID: b7994ea4-6f54-4d33-b87e-60de1c6a02dd

これで今回はこの https://d1-drizzle.mizchi.workers.dev/ を開いて、動いてることを確認。(あとで消すと思います)

というわけで実装完了。

以降のマイグレーションとリリース手順

src/schema.ts を書き換えたら、 そのたびに migration を行います。

# 動作確認
$ pnpm drizzle-kit generate:sqlite --out migrations --schema src/schema.ts
$ pnpm wrangler d1 migrations apply dzltest --local
$ pnpm wrangler dev --local --persist

# 本番適用とリリース
$ wrangler d1 migrations apply dzltest
$ wrangler publish

GitHub Actions からリリース

おまけ。GitHub Actions で release/* ブランチが push されたら CI でリリースさせるようにする例です。

# 雑に手元から実行する例。本来ならPRのMergeから。
$ git push origin main:release/$(date +%s)

GitHub Actions Secrets に CLOUDFLARE_API_TOKEN を預けておいて、次の Workflow を実行します。(このトークンの生成は適当にググってください)

.github/workflows/release.yml
name: Release

on:
  push:
    branches: [ release/* ]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js 18
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: corepack enable pnpm
      - run: pnpm install --frozen-lockfile
      # setup cloudflare wrangler
      - run: pnpm wrangler d1 migrations apply dzltest
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      - run: pnpm wrangler publish
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

これで release/* がマージされるたびに cloudflare workers がリリースされるようになります。

この例では corepack 経由で pnpm を使ってるので、package.json に "packageManager" を追記しておく必要があります。

  "packageManager": "pnpm@8.3.1",

もうちょっと真面目にやるなら、ステージングやプレビュー用の wrangler.toml を分けて -f で指定したり、--preview を使う、といった派生が考えられますが、これ以上は実際の運用環境でバリエーションが多すぎるので、この程度に留めておきます。

おわり

というわけで最小例で動くところ+最低限のワークフローができました。プロダクションで使うには定期バックアップとか色々必要そうですが(GitHub Actions の cron job でよさそう)、一旦これでよさそうです。

まだ自分が drizzle を使い込めてないので、これから複雑なテーブルやクエリとか試してみて、破綻しないか実験していきます。簡単な wiki でも作るかも。

そもそも open alpha なので作ったり試したりしながら正式リリースを待ちます。

どうでもいいけど ChatGPT に SQL を解説させるのがめちゃくちゃ生産性あがりますね。

Discussion