stin's Blog

Drizzle ORM を使った書き捨てスクリプトから Cloudflare D1 にアクセスしたい


Cloudflare D1 (以下 D1) は Cloudflare のリレーショナルデータベースサービスです。実態はリモートにある SQLite です。

Drizzle ORM は TypeScript で各種データベースに接続するデータベースクライアントで ORM です。テーブルスキーマ管理と型安全性に優れています。

やりたいこと

D1 は Cloudflare Workers 内から利用するデータベースですが、Cloudflare Workers の実行コンテキスト以外から D1 にアクセスしたいこともあります。例えば、以下のようなケース

  • サービス開始時に初期データを登録する書き捨てスクリプトから D1 にアクセスする
  • GitHub Actions で定期実行しているスクリプトからアクセスする
  • データの不具合調査をローカル端末でするためにアクセスする

つまり、単に node scripts/index.js とか npx tsx script/index.ts を打ち込んで実行するようなケースです。

しかしやり方を調べても、Cloudflare Workers の実行コンテキストから呼び出すコードサンプルしか見つけられませんでした。

また、Drizzle の D1 アダプターも同様に Cloudflare Workers での利用を想定されたインターフェイスをしています。drizzle-orm/d1 モジュールは Cloudflare Workers の実行コンテキストで受け取れるデータベースバインドを受け取って利用するデザインになっています。

Cloudflare Workers アプリをローカル開発中に wrangler CLI が生成する D1 にアクセスするだけなら、実態が .sqlite ファイルなので単に drizzle-orm/libsql などを使えばよいですが、リモートにあるプロダクションの D1 にアクセスしたいのです。

色々試していたところ、drizzle-orm/d1 以外のモジュールを組み合わせて書き捨てスクリプトでも Drizzle を使って型安全に D1 にアクセスする方法を生み出したので紹介します。

先に完成形サンプルコード

このリポジトリの src/script.ts が書き捨てスクリプトのイメージです。npx tsx src/script.ts で実行可能です(試す場合 Cloudflare D1 はご自身でご用意ください)。

src/drizzle-remote-d1.ts に、リモートにある D1 に直接アクセス可能な Drizzle クライアントを生成する関数 createRemoteD1Drizzle を実装しています。

実現方法

Cloudflare D1 には HTTP API で SQL を POST して実行してもらう API があります。SQL の実行にはこれを使います (API トークンの発行が必要です)。

D1 を含む Cloudflare の各種 HTTP API を実行してくれる公式パッケージがあります。コード中の API 呼び出しはこれを使用します。

依存パッケージを増やしたくない場合は次の Cloudflare API 仕様書を参考に、単に fetch で SQL を載せた HTTP リクエストを投げつけるだけでもいいです。

SQL を生成する側は Drizzle Proxy を使用します。D1 は SQLite なのでモジュールは drizzle-orm/sqlite-proxy です。

Drizzle Proxy は、他の Drizzle クライアントと使い勝手は全く同じですが、クエリの送信部分を自由に差し替えることができるものです。

以下は Drizzle のドキュメントからの引用です。

┌───────────────────────────┐                 ┌─────────────────────────────┐
│       Drizzle ORM         │                 │  HTTP Server with Database  │
└─┬─────────────────────────┘                 └─────────────────────────┬───┘
  │                                                ^                    │
  │-- 1. Build query         2. Send built query --│                    │
  │                                                │                    │
  │              ┌───────────────────────────┐     │                    │
  └─────────────>│                           │─────┘                    │
                 │      HTTP Proxy Driver    │                          │
  ┌──────────────│                           │<─────────────┬───────────┘
  │              └───────────────────────────┘              │
  │                                                  3. Execute a query + send raw results back
  │-- 4. Map data and return

  v

この送信部分 (HTTP Server with Database) を Cloudflare D1 API に向けるイメージです。

実装

ラッパー関数の型定義

Drizzle クライアントを生成する関数を実装します。drizzle-orm/sqlite-proxy から取得できる drizzle 関数のラッパーです。

ラッパー関数の名前、引数、戻り値の型は次のようにします。

import { drizzle, SqliteRemoteDatabase } from "drizzle-orm/sqlite-proxy";

type Params<Schema> = {
  credentials: {
    accountId: string;
    databaseId: string;
    apiToken: string;
  };
  schema: Schema;
};

export const createRemoteD1Drizzle: <Schema extends Record<string, unknown>>(
  params: Params<Schema>,
) => SqliteRemoteDatabase<Schema>;

関数名は createRemoteD1Drizzle としました。

引数では、Cloudflare API を内部で利用するのに必要な credentials と、Drizzle クライアントに渡すための schema を受け取ります。

戻り値は drizzle-orm/sqlite-proxydrizzle 関数の戻り値と同じ SqliteRemoteDatabase<Schema> 型です。

drizzle-orm/sqlite-proxydrizzle 関数について

drizzle-orm/sqlite-proxydrizzle 関数では、実際に SQL とバインドパラメーターを受け取って送信する関数を2つ引数に取ります。片方は単一クエリの実行用、もう一方はバッチクエリの実行用です。

import { drizzle } from "drizzle-orm/sqlite-proxy";

const db = drizzle(remoteCallback, batchRemoteCallback, { schema });

上記の remoteCallbackbatchRemoteCallback を実際の送信処理として実装する必要があります。

バッチクエリとは、次のように複数のクエリをまとめて実行してくれる機能です。

await db.batch([
  db.insert(schema.user).values({ name: "Alice" }),
  db.update(schema.user).set({ name: "Bob" }),
  db.delete(schema.user).where(eq(schema.user.name, "Charlie")),
]);

特に D1 ではトランザクションのサポートがないため、複数のクエリをまとめて実行するにはバッチクエリを使うことになります。ひとつのバッチクエリの一部のクエリが失敗するとすべてのクエリが実行されなかったことになり、バッチ内の原子性が保証されます。トランザクションの代わりとして使うことになります。

remoteCallback, batchRemoteCallback 関数の実装

remoteCallbackbatchRemoteCallback が満たすべき関数の型は次です。

export type AsyncRemoteCallback = (
  sql: string,
  params: any[],
  method: "run" | "all" | "values" | "get",
) => Promise<{
  rows: any[];
}>;

export type AsyncBatchRemoteCallback = (
  batch: {
    sql: string;
    params: any[];
    method: "run" | "all" | "values" | "get";
  }[],
) => Promise<
  {
    rows: any[];
  }[]
>;

ここで sql にはバインド変数の ? が埋め込まれた状態の SQL が渡されます。params はバインド変数に指定する値の配列です。

method は Drizzle クライアント側でどのメソッドによって発火されたかを示す識別子です。Drizzle Proxy のドキュメントには次のように記載があります。

Drizzle always waits for {rows: string[][]} or {rows: string[]} for the return value.

When the method is get, you should return a value as {rows: string[]}. Otherwise, you should return {rows: string[][]}.

つまり、method"get" の場合は remoteCallback{ rows: any[] } を返し、その他の場合は { rows: any[][] } を返す必要があります。

引数や戻り値の型を見比べると、AsyncRemoteCallback の複数クエリ版が AsyncBatchRemoteCallback というのがわかりますね。

Cloudflare D1 API の呼び出し

remoteCallbackbatchRemoteCallback の中で Cloudflare D1 API を呼び出しますが、重要な注意点があります。

Cloudflare D1 API 自体もバッチクエリをサポートしており、複数の SQL をセミコロン(;) 区切りで送れば一度に実行してくれます。しかし、バッチクエリを送るときはバインド変数が使えない制約があるようです。

そのため、Drizzle が出力した SQL の ? に自分でパラメーターを埋め込んで、バインド変数を持たないプレーンな SQL に変換してから D1 API に送る必要があります。その変換をするために sqlstring というライブラリを導入しました。

pnpm install sqlstring

ただし sqlstring は MySQL 用のライブラリで、シングルクオートのエスケープが MySQL 方言になっていました。MySQL では '\' にエスケープするのに対し、SQLite では '' にエスケープする必要があります。そのため、次のように sqlstring を使いつつも追加でエスケープを置き換えています。

const sqlString = SqlString.format(sql, params).replaceAll("\\'", "''");

続いて、cloudflare パッケージを使って D1 APIを実行します。Cloudflare クラスのインスタンスを生成したら d1.database.raw メソッドを呼び出します。

import Cloudflare from "cloudflare";

const cf = new Cloudflare({ apiToken: credentials.apiToken });

const queryIterable = cf.d1.database.raw(credentials.databaseId, {
  account_id: credentials.accountId,
  sql: sqlString,
});

const results = await Array.fromAsync(queryIterable);

メソッドの戻り値にややクセがあり、AsyncIterable になっています。for await で回すか、上記のように Array.fromAsync で配列に変換してから使います。

results の中にクエリ結果が格納されます。SELECT や UPDATE 後のデータなど、バッチクエリの数だけ入っています。

createRemoteD1Drizzle の完成形

ここまでを踏まえると、createRemoteD1Drizzle の全実装は次のようになります。

import Cloudflare from "cloudflare";
import { drizzle, SqliteRemoteDatabase } from "drizzle-orm/sqlite-proxy";
import SqlString from "sqlstring";

type Params<Schema> = {
  credentials: {
    accountId: string;
    databaseId: string;
    apiToken: string;
  };
  schema: Schema;
};

export const createRemoteD1Drizzle = <Schema extends Record<string, unknown>>({
  credentials,
  schema,
}: Params<Schema>) => {
  const cf = new Cloudflare({ apiToken: credentials.apiToken });

  return drizzle<Schema>(
    async (sql, params, method) => {
      const sqlString = SqlString.format(sql, params).replaceAll("\\'", "''");

      const queryIterable = cf.d1.database.raw(credentials.databaseId, {
        account_id: credentials.accountId,
        sql: sqlString,
      });

      const results = await Array.fromAsync(queryIterable);

      if (method === "get") {
        return { rows: results[0]?.results?.rows?.flat() ?? [] };
      } else {
        return { rows: results[0]?.results?.rows ?? [] };
      }
    },

    async (queries) => {
      const batchedSQL = queries
        .map(({ sql, params }) => SqlString.format(sql, params).replaceAll("\\'", "''"))
        .join(";\n");

      const queryIterable = cf.d1.database.raw(credentials.databaseId, {
        account_id: credentials.accountId,
        sql: batchedSQL,
      });

      const allResult = await Array.fromAsync(queryIterable);

      return allResult.map(({ results }, index) => {
        if (queries[index]?.method === "get") {
          return { rows: results?.rows?.flat() ?? [] };
        } else {
          return { rows: results?.rows ?? [] };
        }
      });
    },

    { schema: schema },
  );
};

実装のポイントは以下の通り。

  • sqlstring でバインド変数を埋め込んだリテラルな SQL を生成する(SQLite に合わせてエスケープを調整)
  • Cloudflare.d1.database.raw メソッドで D1 API を呼び出す(params は渡す必要なし)
  • AsyncIterableArray.fromAsync で配列に変換する
  • Drizzle ドキュメントに従い、method によって戻り値を切り替える
  • バッチクエリの場合は SQL をセミコロンで連結する

あとは通常の drizzle 関数と同じように schemacredentials を渡して呼び出せば、型安全な Drizzle クライアントが得られます。

import "dotenv/config";
import { createRemoteD1Drizzle } from "./drizzle-remote-d1.js";
import * as schema from "./db/schema.js";

const db = createRemoteD1Drizzle({
  schema,
  credentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_D1_DB_ID!,
    apiToken: process.env.CLOUDFLARE_API_TOKEN!,
  },
});

// 単一のクエリも実行できる!
await db.insert(schema.user).values({ name: "Alice" });

// バッチクエリも実行できる!
await db.batch([
  db.insert(schema.user).values({ name: "Alice" }),
  db.update(schema.user).set({ name: "Bob" }),
  db.delete(schema.user).where(eq(schema.user.name, "Charlie")),
]);

自分が書き捨てスクリプトで必要な分のクエリしか動作確認できていないので、参考にされる場合はちゃんと意図したクエリが発行できているか、戻り値が正しく整形されているかをご確認ください。

余談

Drizzle のクエリには実行せずに SQL だけを吐き出す toSQL() メソッドがあるのを知りました。これと sqlstring を組み合わせれば、Drizzle を使って SQL をファイルに書き出すスクリプトとかも作れます。いきなりデータベースに反映せずに SQL を確認したい場合に使えます。

const db = drizzle({ schema });

const { sql, params } = db
  .insert(schema.user)
  .values({ id: 99, name: "username" })
  .toSQL();

const sqlString = SqlString.format(sql, params).replaceAll("\\'", "''");

await fs.writeFile("./query.sql", sqlString);

まとめ

Cloudflare Workers の実行コンテキストの外から Cloudflare D1 に Drizzle で型安全にアクセスする方法を紹介しました。

Cloudflare API と Drizzle Proxy の組み合わせで実現できました。ただし Cloudflare D1 API はバッチクエリにバインド変数が使えないので、sqlstring で自分で埋め込む必要があります。

自分は書き捨てのスクリプトからリモートの D1 にアクセスする方法を探ってたどり着いたけど、もしかしたらこれによって Cloudflare Workers 以外の普通のバックエンド環境(VPS とか Vercel とか)から D1 を RDB として使えるかも?速度は全然出ないかもしれんけど(推測)