Bun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた

去年、Blueskyのアカウントを作りました。今のところブログの更新通知しかしてませんが。。。
https://bsky.app/profile/thinkami.bsky.social

 
そんな中、Blueskyに自分が投稿したものを取得したくなったことから、ためしてみたときのメモを残します。

 
目次

 

環境

  • Bun 1.1.6
  • TypeScript 5.4.5
  • @atproto/api 0.12.8

 

調査

BlueskyのAPIを今まで使ったことがなかったため、事前に調査しました。

 

APIを使うときのクレデンシャルについて

@atproto/api のREADMEを読んでいたところ、APIを使うときには identifierpassword が必要そうでした。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#session-management

identifier として使えそうなのは

  • ハンドルネーム
  • DID

のようでした。
自分のDIDを知る方法 - Bluesky

 
一方、パスワードは自分のログインパスワードではなく、アプリパスワードを使うのが良さそうでした。
AT Protocol APIでBlueskyに投稿する - くらげになりたい。

 

自分の投稿を取得するAPIについて

Blueskyのドキュメントを読むと、多くのAPIが用意されていました。
HTTP Reference | Bluesky

APIのうち、今回の目的に合いそうなのは app.bsky.feed.getAuthorFeed でした。
https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

また、 @atproto/api でも agent.getAuthorFeed(params, opts) として実装されていました。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#api-calls

 

実装

Bunで環境構築

まずはBunで環境構築をします。

$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (atproto_api-example):
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 
続いて、 @atproto/api をインストールします。

$ bun add @atproto/api
bun add v1.1.6 (e58d67b4)

 installed @atproto/api@0.12.8

 

.envファイルに秘匿情報を記載

Bunの場合、 .env ファイルに秘匿情報を記入しておけば、パッケージを追加することなく環境変数に値が設定されるようです。
Environment variables – Runtime | Bun Docs

 
そこで、今回の秘匿情報を .env ファイルに用意します。

IDENTIFIER=***
APP_PASSWORD=***

 

プログラムを書く

あとは getAuthorFeed を使うプログラムを書きます。

なお、今後別のAPIを使うかもしれないので、ファイル名を get_author_feed.ts へと変更しておきます。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const agent = new BskyAgent({
  service: 'https://bsky.social',
})


const main = async () => {
  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER
  })

  const records = feed.map(f => f.post.record as Record)
  const items = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(items)
}

main()

 

動作確認

Bunで実行してみると、自分の投稿が取得できました。

$ bun run get_author_feed.ts 
[
  {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entr,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
...
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }

 

ページング処理について

2024/05/08 追記

記事を公開後、 id:kkotyy さんよりページング処理に関するコメントをいただきました。

ページング処理について詳しくなかったことから、合わせて調べてみることにしました。

 

パラメータ limit について

パラメータ limit について、 app.bsky.feed.getAuthorFeed のドキュメントには

limit

integer

Possible values: >= 1 and <= 100

Default value: 50

https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

とありました。

 
そこで、 limit =3 を設定し、挙動を確認してみます。

const {data: {feed}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3
})

const records = feed.map(f => f.post.record as Record)
const items = records.map(({text, createdAt}) => {
  return {
    text, createdAt
  }
})

console.log(items)

 
すると、最新から3件を取得する挙動へと変わりました。

$ bun run get_author_feed.ts
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 

パラメータ cursor について

パラメータ cursor について、 app.bsky.feed.getAuthorFeed のドキュメントではstring型の値を設定することしか記載されていませんでした。

続いて、 app.bsky.feed.getAuthorFeed の型定義を眺めてみたところ、リクエストパラメータの他、レスポンスパラメータにも cursor がありました。
https://github.com/bluesky-social/atproto/blob/%40atproto/api%400.12.8/lexicons/app/bsky/feed/getAuthorFeed.json#L39

 
そこで、レスポンスパラメータの cursor には何が含まれるのかを確認する get_author_feed_with_cursor.ts を作りました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed, cursor}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER,
    limit: 3,
  })

  console.log(cursor)

  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(feeds)
}

main()

 
実行してみたところ、取得した一番最後の createdAt の値が設定されているようでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-06T14:43:40.051Z
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 
続いて、この取得したcursorの値をリクエストパラメータに含めた場合はどうなるのか気になりました。

そこで getAuthorFeed() の引数に cursor に先ほど console.log で出力された値を設定して、挙動を確認してみます。

const {data: {feed, cursor}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3,
  cursor: '2024-05-06T14:43:40.051Z'  // 追加
})

 
実行すると、先ほどとは別の3件が取得できました。

また、 cursor に指定した createdAt を持つ投稿は取得できませんでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-01T13:39:58.662Z
[
  {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com,
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }
]

 
これより、リクエストパラメータ cursor を使うことで、指定した値よりも前の投稿を取得できることが分かりました。

 

ページング処理を実装する

ここまでより、 limitcursor を組み合わせれば、今までの投稿数が少ない場合であっても「ページングして投稿を取得する」が実現できそうでした。

そこで、ページングするような get_author_feed_with_paging.ts を作ってみました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const getFeed = async (agent: BskyAgent, cursor: string)  => {
  const params = {
    actor: process.env.IDENTIFIER,
    limit: 5,
  }
  if (cursor) {
    params['cursor'] = cursor
  }

  const {data: {feed, cursor: nextCursor}} = await agent.getAuthorFeed(params)
  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  return {feeds, nextCursor}
}

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  let cursor: string | undefined = ''
  while (cursor != undefined) {
    const {feeds, nextCursor} = await getFeed(agent, cursor)
    console.log(`============================\n${cursor}\n============================`)
    console.log(feeds)

    cursor = nextCursor
  }
}

main()

 
実行してみたところ、ページング処理は成功し、一番最初の投稿まで取得できました。

$ bun run get_author_feed_with_paging.ts
============================

============================
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }
]
============================
2024-05-02T11:40:29.21Z
============================
[
  {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }, {
...
============================
2023-09-30T22:45:47.063Z
============================
[
  {
    text: "はてなブログに投稿しました\nTanstack QueryのuseQueryにて、refetchIntervalとstaleTimeを組み合わせたときの動作を確認してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2023/1...",
    createdAt: "2023-09-30T22:44:44.988Z",
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }
]

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/atproto_api-example

ページング処理なしのプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/1

ページング処理のプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/2

JetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った

前回、自作のアプリを Cloudflare Pages + D1 に乗せてみました。
Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な

Cloudflare D1にあるテーブルの

で確認できます。

一方、アプリはWebStormなどのJetBrains IDEで開発しているため、JetBrains IDEでも Cloudflare に直接接続できると便利そうでした。

 
何かないか調べたところ、 d1-jdbc-driver がありました。READMEに従い設定してみたところ、JetBrains IDEのWebStormから接続できました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver

 
ただ、Known issueとして

Foreign keys are not currently shown in the introspection window, although they are still there and working as normal.

と、外部キーまわりのサポートがないようでした。

それがあると嬉しいと思いつつGithubのissueをながめていたところ、同じように外部キーまわりの機能を望んでいる方がいるようでした。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/issues/4

 
そこで、

  • 少なくとも、テーブルに foreign key だけでも表示できるといいなと思ったこと
  • JDBC driver の修正方法が気になること

から、修正してプルリクを出してみたところ、早速マージしていただきました。

 
ということで、今回の記事では、修正するために調べたことをメモとして残します。

 
目次

 

環境

 

調査

そもそもJavaJDBC driverに詳しくないので、基本的なところから調べ始めました。

 

KotlinでJDBC driverを書けるかについて

以前、JetBrains IDEプラグインを書いた時に Kotlin を使いました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

そのため、JavaではなくKotlinでJDBC driverを書けるといいなと思って調べたところ、「書ける」と書かれたstackoverflowの回答がありました。
Is it applicable to write a JDBC driver in Kotlin? - Stack Overflow

とはいえ、現在の d1-jdbc-driverJavaで実装されているため、Kotlinで書くのは別の機会にしました。

 

外部キーを表示するために必要な JDBC driver の修正について

現在の d1-jdbc-driver の実装では、いくつか SQLException("Not implemented: ***") となっているメソッドがありました。

このうち、外部キーに関係するメソッドを実装すれば、いい感じにJetBrains IDEで表示できるかもしれないと考えました。

 
次に、他のJDBC driverの実装を調べてみたところ、MicrosoftSQL Server 向け JDBC driver に詳しいドキュメントがありました。

ドキュメントを読むと

あたりで、 外部キー という記載がありました。

そのため、まずはこれら3つのメソッドを修正するところから始めることにしました。

 

SQLiteの外部キー情報を設定する方法について

修正対象のメソッドは分かりましたが、修正するためにはSQLiteの外部キー情報をどのように設定すればよいかが分かりません。

そこで、 d1-jdbc-driver の既存の実装を見たところ、主キーの情報を設定している箇所がありました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/blob/v1.1/src/main/java/org/isaacmcfadyen/D1DatabaseMetaData.java#L906-L962

 
これより、同じような感じで外部キー情報を設定し、

new D1ResultSet(ApiKey, AccountId, DatabaseUuid, columnNames, rows, columnSchema)

な感じで D1ResultSetインスタンスを生成すれば良さそうと分かりました。

 
また、設定する項目の値については、

を見比べる限り一致していました。

そのため、各メソッドでMicrosoftのドキュメントの項目に返すことで、何とかなりそうな気がしました。

 

UPDATE_RULEやDELETE_RULEの数値について

各メソッドでは、外部キーのActionを UPDATE_RULEDELETE_RULE として設定すれば良さそうと分かりました。

ただ、Microsoftのドキュメントによると、それらの値は数値であり、文字列ではありません。

Microsoftのドキュメントには数値も記載されていたものの、できれば数値のハードコーディングは避けたいです。

 
そこで調べてみたところ、Oracle Javaのドキュメントに記載がありました。

 
これより、既存の作りと同様

JSONObject ruleType = new JSONObject();
ruleType.put("NO ACTION", DatabaseMetaData.importedKeyNoAction);

とすれば、文字列を数値に変換できてハードコーディングしなくて済みそうとわかりました。

 

SQLiteの外部キー情報を取得する方法について

続いて、SQLiteの外部キー情報の取得方法を調べることにしました。

主キーでは queryDatabase("PRAGMA table_info(" + table + ")") のような感じでデータを取得していたため、同じような方法があるのかなと思って調べたところ、stackoverflowの回答がありました。
foreign keys - Output of the SQLite's foreign_key_list pragma - Stack Overflow

SQLitePRAGMAforeign_key_list() を使えば良さそうです。
https://www.sqlite.org/pragma.html#pragma_foreign_key_list

 

外部キー制約名の取得について

stackoverflowの回答 にあった foreign_key_list() で取得できる値には、外部キー制約名が含まれていませんでした。

どこで取得するんだろうと思って調べたところ、stackoverflowに回答がありました。
How to get the names of foreign key constraints in SQLite? - Stack Overflow

これより、「システムテーブルの SQL 列に CREATE TABLE したときのSQLが保存されているので、それをパース・取得する」くらいしか方法がないと分かりました。

 
ただ、

ということから、外部キー制約名を表示する優先度は高くなさそうと考えました。

 
そこで、今回はひとまず <table_name>_<id>_<seq> という形で対応することにしました。

 
ちなみに、外部キー名を編集するのではなく null や空文字を設定すると、 #FAKE みたいなprefixがついてしまいます。さすがに見栄えが良くないので編集することにしました。

 

JDBC driver の実装をデバッグする方法について

ここまでの調査で実装はできそうでした。

ただ、

  • 変数の中身の確認
  • うまく動いていないときの動作確認

をしたくなったことから、 JDBC driver の実装をデバッグする方法を探してみました。

すると以下の記事がありました。
JDBCドライバの作り方 #Java - Qiita

 
そこで、「テスト用の実行処理」を参考に、 main メソッドを持つ以下のようなクラスを作ってIntelliJ IDEAでデバッグ実行できるようにしました。

package org.isaacmcfadyen;

import java.sql.SQLException;

public class MyClass {
    public static void main(String[] args) throws SQLException {
        try (D1Connection con = new D1Connection("token", "account_id","database_id")) {
            var dbmd = con.getMetaData();
            var rs = dbmd.getCrossReference(null, null, null, null, null, "orders");
        }
    }
}

 
なお、 tokendatabase_id は、d1-jdbc-driver のREADMEに取得方法が記載されています。

一方、account_id は最初どこで見れるのか分かりませんでしたが、CloudflareのコンソールURLに含まれていると分かりました。

具体的には、 https://dash.cloudflare.com/<アカウントID>アカウント の値になります。

 

IntelliJ IDEAでビルドして jar ファイルを生成する方法について

今回、最終的には WebStormにJDBC driverの jar を設定したいのですが、そもそも jar を生成する方法がわかりません。

そこで方法を調べたところ、以下の記事がありました。

 
上記の記事を参考に、各種設定を行った後、Build ArtifactからReBuildを実行したところ、無事に jar ファイルができました。

 
なお、ビルドとリビルドの違いについては、IntelliJ IDEAのドキュメントに記載がありました。今回は規模が大きくないので、常時再ビルド(ReBuild)します。

https://pleiades.io/help/idea/working-with-artifacts.html#build-manually

 
ちなみに、WebStormで jar を追加のJDBC driverとして設定する方法は、 d1-jdbc-driver のREADMEに記載があります。
https://github.com/isaac-mcfadyen/d1-jdbc-driver

 

実装

以上で必要な調査が終わったので、あとは実装するだけです。

今回は

  • getImportedKeys
  • getExportedKeys
  • getCrossReference

を実装します。

 
なお、今回は最低限動くところがゴールなので、 getImportedKeysgetExportedKeys は同じ実装にしています。

もし JDBC driver の実装に詳しい方がいれば、上記2つのメソッドをより良く実装する方法を教えていただけるとありがたいです。

public class D1DatabaseMetaData extends D1Queryable implements DatabaseMetaData {
    // ...
    @Override
    public ResultSet getImportedKeys(String catalog, String schema, String table) throws SQLException {
        return getCrossReference(null, null, null, catalog, schema, table);
    }

    @Override
    public ResultSet getExportedKeys(String catalog, String schema, String table) throws SQLException {
        return getCrossReference(null, null, null, catalog, schema, table);
    }

    @Override
    public ResultSet getCrossReference(String parentCatalog, String parentSchema, String parentTable, String foreignCatalog, String foreignSchema, String foreignTable) throws SQLException {
        ArrayList<String> columnNames = new ArrayList<>();
        columnNames.add("PKTABLE_CAT");
        columnNames.add("PKTABLE_SCHEM");
        columnNames.add("PKTABLE_NAME");
        columnNames.add("PKCOLUMN_NAME");
        columnNames.add("FKTABLE_CAT");
        columnNames.add("FKTABLE_SCHEM");
        columnNames.add("FKTABLE_NAME");
        columnNames.add("FKCOLUMN_NAME");
        columnNames.add("KEY_SEQ");
        columnNames.add("UPDATE_RULE");
        columnNames.add("DELETE_RULE");
        columnNames.add("FK_NAME");
        columnNames.add("PK_NAME");

        JSONObject stringType = new JSONObject();
        stringType.put("type", "TEXT");
        JSONObject intType = new JSONObject();
        intType.put("type", "INTEGER");

        JSONArray columnSchema = new JSONArray();
        // PKTABLE_CAT
        columnSchema.put(stringType);
        // PKTABLE_SCHEM
        columnSchema.put(stringType);
        // PKTABLE_NAME
        columnSchema.put(stringType);
        // PKCOLUMN_NAME
        columnSchema.put(stringType);
        // FKTABLE_CAT
        columnSchema.put(stringType);
        // FKTABLE_SCHEM
        columnSchema.put(stringType);
        // FKTABLE_NAME
        columnSchema.put(stringType);
        // FKCOLUMN_NAME
        columnSchema.put(stringType);
        // KEY_SEQ
        columnSchema.put(intType);
        // UPDATE_RULE
        columnSchema.put(intType);
        // DELETE_RULE
        columnSchema.put(intType);
        // FK_NAME
        columnSchema.put(stringType);
        // PK_NAME
        columnSchema.put(stringType);

        JSONObject ruleType = new JSONObject();
        ruleType.put("NO ACTION", DatabaseMetaData.importedKeyNoAction);
        ruleType.put("CASCADE", DatabaseMetaData.importedKeyCascade);
        ruleType.put("SET NULL", DatabaseMetaData.importedKeySetNull);
        ruleType.put("SET DEFAULT", DatabaseMetaData.importedKeySetDefault);
        ruleType.put("RESTRICT", DatabaseMetaData.importedKeyRestrict);

        JSONObject results = queryDatabase("PRAGMA foreign_key_list(" + foreignTable + ")");
        JSONArray fkList = results.getJSONArray("results");
        ArrayList<ArrayList<Object>> rows = new ArrayList<>();

        for (int i = 0; i < fkList.length(); i++) {
            JSONObject fkItem = fkList.getJSONObject(i);

            ArrayList<Object> row = new ArrayList<>();
            row.add(null);
            row.add(null);
            row.add(fkItem.get("table"));
            row.add(fkItem.get("to"));
            row.add(null);
            row.add(null);
            row.add(foreignTable);
            row.add(fkItem.get("from"));
            row.add(fkItem.get("seq"));
            row.add(ruleType.get(fkItem.get("on_update").toString()));
            row.add(ruleType.get(fkItem.get("on_delete").toString()));

            // If null is set, #FAKE_<table>_<number> is set, so <foreignTable>_<id>_<seq> set
            row.add(foreignTable + "_" + fkItem.get("id").toString() + "_" + fkItem.get("seq").toString());
            row.add(null);

            rows.add(row);
        }

        return new D1ResultSet(ApiKey, AccountId, DatabaseUuid, columnNames, rows, columnSchema);
    }
// ...

 

動作確認

今回は

  • Wranglerでテスト用のD1を作る
  • Wranglerで各種外部キーを持つテーブルを作る
  • WebStormのDatabase toolsで表示する

という流れで動作確認をします。

 

Wranglerでテスト用のD1を作る

Wranglerのドキュメントに従い作成します。
https://developers.cloudflare.com/workers/wrangler/commands/#d1

$ npx wrangler login
$ npx wrangler d1 create d1-driver-test

 

Wranglerで各種外部キーを持つテーブルを作る

ON UPDATEON DELETE まわりをしっかり見たかったので、網羅するようなテーブル数と設定を行っています。

CREATE TABLE `shops` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `staffs` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `products` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `orders` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text,
  `product_id` integer,
  `shop_id` integer,
  `staff_id` integer,
  FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON UPDATE cascade ON DELETE set null,
  FOREIGN KEY (`shop_id`) REFERENCES `shops`(`id`) ON UPDATE set default ON DELETE restrict,  
  CONSTRAINT `fk__staff_who_ordered` FOREIGN KEY (`staff_id`) REFERENCES `staffs`(`id`) ON UPDATE no action ON DELETE no action
);

 
このSQLd1.sql みたいなファイルに保存し、

$ npx wrangler d1 execute d1-driver-test --remote --file=./d1.sql

とすることで、Cloudflare上のD1にテーブルができました。

 

WebStormのDatabase toolsで表示する

想定通りの表示となりました。

 

作ったプルリク

完成したのでプルリクを作りました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/pull/5

すると、早速マージしていただけました。ありがたい限りです。

 
なお、プルリクを作るためにforkしたリポジトリはこちらです。
https://github.com/thinkAmi/d1-jdbc-driver

 

その他資料

JDBC driver をゼロから作るときの資料

中身は詳しく見てないのですが、いつか役立つかもしれないので、リンクだけ置いておきます。

Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた

少し前から、Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORM あたりをさわってきました。

 
今まではローカルのみでアプリ開発していたため、次はどこかにデプロイしたくなりました。

そこで、以前から気になっていた Cloudflare Pages と D1にアプリを乗せてみたところ、色々ハマったことがあったため、メモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • React 18.3.1
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • Hono 4.3.1
  • TanStack Router 1.31.17
  • TanStack Query 5.34.1
  • Drizzle ORM 0.30.10
  • Drizzle Kit 0.20.17
  • Wrangler 3.47.0

 
なお、今回は Bun ではなく npm を使います。

ここまでの記事から問題なく動くような気もしますが、現在のCloudflareの公式ドキュメントでは Bun が控えめだったので、 npm にしておきました。

 

前置き

アプリの構成

今回のアプリは

  • Hono の上で React を動かす
  • Hono の中で Drizzle ORM を使い、D1からデータを取得する
  • フロントエンドのルーティングは TanStack Router を使う

という構成とします。このアプリを Cloudflare Pages にデプロイします。

 

Cloudflare Pages へのデプロイ方法

Cloudflareへアプリをデプロイする場合、

などがありました。

今回はCLIでさくっとデプロイしたいことから、 C3 もしくは Wrangler を使うことになりそうでした。

 
次に、C3 と Wrangler の違いを調べたところ、公式ドキュメントに

CLI

The Cloudflare Developer Platform ecosystem has two command-line interfaces (CLI):

  • C3: To create new projects.
  • Wrangler: To build and deploy your projects.

C3 & Wrangler · Build applications with Cloudflare Workers · Learning paths

との記載がありました。

 
「今回のように新しく作成アプリは C3 を使えばいいのかな」と思っていたところ、Honoの著者のyusukebeさんの記事にて

C3(Create Cloudflare CLI)コマンドでもHonoを選べますが今のところそれだとWorkersのテンプレートになるのでcreate honoで。

Honoの新しいCloudflare Pagesスターターについて

とありました。

 
2023年10月の記事なので、もしかしたら現在では C3 で良いのかもしれません。

ただ、Cloudflare に詳しくないこともあり、今回は以下の方法でアプリの作成とデプロイを行うことにしました。

  • npm create hono@latestcloudflare-pages テンプレートでアプリを作成
  • デプロイだけ Wrangler を使う

 
ちなみに、 C3 でHonoテンプレートを使った直後の状態は、以下のリポジトリに置いておきました。
https://github.com/thinkAmi-sandbox/hono_app_by_cloudflare_c3-example

Viteまわりの設定がないので、 npm create hono@latest の方が作りやすそうな印象です。

 

Cloudflare アカウントの作成

事前に作成しておきます。

また、2FAも設定しておきます。

 

アプリの作成

Hono + React + Chart.js + TanStack Router + TanStack Query なアプリを実装

まずは、以前の記事のアプリを作り、Cloudflare にデプロイするところまでやってみます。
Hono + React + Chart.js + TanStack Router + TanStack Query を使って、Hono製APIのレスポンスをPie chartとして表示してみた - メモ的な思考的な

 

Hono のセットアップ

今回は thinkami-react-hono-d1 というアプリ名でHonoをセットアップしました。

テンプレートは cloudflare-pages を選んでいます。

$ npm create hono@latest

Need to install the following packages:
create-hono@0.7.1
Ok to proceed? (y)
create-hono version 0.7.1
? Target directory thinkami-react-hono-d1
? Which template do you want to use? cloudflare-pages
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd thinkami-react-hono-d1

 

必要なライブラリをインストール

Reactまわりです。

$ npm i react react-dom
$ npm i -D @types/react @types/react-dom

 
Chart.jsまわりです。

$ npm i chart.js react-chartjs-2

 
TanStack Router まわりは以下の3つに関係するものを入れます。

なお、現在はバージョンが進んでいるので問題ないですが、バージョン 1.31.9 ではうまく動作しません。当初気づかず、時間を溶かしました。。。
A component suspended while responding to synchronous input in version 1.31.9 · Issue #1554 · TanStack/router

$ npm i @tanstack/react-router @tanstack/router-vite-plugin @tanstack/router-devtools @tanstack/router-cli

 
TanStack Router まわりです。

$ npm i @tanstack/react-query

 
以上で、ひとまずインストールは終わりです。

 

実装する上で悩んだこと

ここでの実装は、以前の記事の実装を移植しただけになります。

そのため、ここではコミットだけ置いておきます。
https://github.com/thinkAmi-sandbox/react_hono_with_cloudflare_pages_d1-example/commit/a90dc6a6002cac9b5bc300187485266dbc460d56

 
ただ、Cloudflareへデプロイするにあたり、自分が悩んだことをまとめておきます。

 

サーバ側のファイル名は src/index.tsx のままにする

今回、フロントエンドまわりのファイルは client ディレクトリの中に入れました。

そこで、バックエンドも server みたいなディレクトリに入れてもよいのかも...と考えて試してみたところ、

  • 522 エラーになる
  • 「Nothing is here yet. If the project exists, it may not be ready yet. Please check back later.」が出続ける

となってしまい、うまくいきませんでした。

そのため、サーバ側のファイルは src/index.tsx のままにしてあります。

 

TanStack Router で Code Splitting を使う場合は、ビルド後のファイルは assets へ出力しない

TanStack RouterでCode Splittingを使う場合、suffixを .lazy.tsx とすると容易に実現できます。
Code Splitting | TanStack Router React Docs

 
ただ、以前の記事の rollupOptions の設定だと、 .lazy.tsx のファイルはビルド後は assets ディレクトリへ出力されます。

そして、 assets ディレクトリに出力したままデプロイすると、動かした時に

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html

というエラーが出てしまい動作しません。

 
そこで、今回は rollupOptions に chunkFileNames を設定し、 static ディレクトリへ出力するようにしました。

  if (mode === 'client') {
    return {
      build: {
        rollupOptions: {
          input: './src/client/main.tsx',
          output: {
            entryFileNames: 'static/client.js',
            chunkFileNames: 'static/[name]-[hash].js',  // これ
          }
        }
      },
// ...
    }
  } else {

 

defineConfigのサーバ側の plugin には pages も入れる

最初、うっかり pluginpages() を入れ忘れてしまい、

error during build:
RollupError: Could not resolve entry module "index.html".

というエラーを出し続けてしまいました。

そこで hono-spa-react リポジトリと差分を見ていたところ、この場所に pages() が不足していると気づきました。
https://github.com/yusukebe/hono-spa-react/commit/1f71afde1aeaef4d998247577cddb98761b21a54#diff-6a3b01ba97829c9566ef2d8dc466ffcffb4bdac08706d3d6319e42e0aa6890ddR23

 

TanStack Routerの Devtools は development のときだけ使うようにする

Cloudflareにデプロイした後も TanStack Router の Devtools が表示されていたので、おや?となりました。

そこで、TanStack Routerの公式ドキュメントを見て、 developement のときだけ動くようにしました。
Only importing and using Devtools in Development | Devtools | TanStack Router React Docs

 

TanStack Router の routeTree.gen.ts は、初回は CLI で作る

TanStack Router のルーティングが動いていないので調べたところ、 routeTree.gen.ts が無いことに気づきました。

そのため、Router CLItsr generate を実行し、ファイルを生成しました。
Router CLI | File-Based Routes | TanStack Router React Docs

 

ローカルでの動作確認

npm run dev したところ、Pie chartが表示されました。

 

WranglerでCloudflare Pagesへデプロイ

続いて、vite build --mode client && vite build && wrangler pages deploy dist (package.jsonでは npm run deploy) にて Cloudflare Pagesへデプロイします。

デプロイする際、TanStack Queryのワーニングが出ますが、今回はいったん置いておきます。

$ npm run deploy

> deploy
> vite build --mode client && vite build && wrangler pages deploy dist


♻️  Generating routes...
✅ Processed routes in 144ms
vite v5.2.11 building for client...
node_modules/@tanstack/react-query/build/modern/useQueries.js (1:0): Module level directives cause errors when bundled, "use client" in "node_modules/@tanstack/react-query/build/modern/useQueries.js" was ignored.
...
✓ 125 modules transformed.
dist/static/index.lazy-PW47UpKj.js    0.25 kB │ gzip:  0.21 kB
dist/static/link-CzmguEkQ.js          2.90 kB │ gzip:  1.48 kB
dist/static/chart.lazy-BYg6oVDi.js  174.51 kB │ gzip: 60.61 kB
dist/static/client.js               212.83 kB │ gzip: 67.83 kB
✓ built in 1.17s
vite v5.2.11 building SSR bundle for production...
✓ 24 modules transformed.
dist/_worker.js  22.46 kB
✓ built in 160ms

 
初回デプロイなこともあり、Cloudflare Pages のプロジェクト作成が必要になったので、質問に答えていきます。

今回 production branch name は main にしました。

The project you specified does not exist: "thinkami-react-hono-d1". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'thinkami-react-hono-d1' project.
▲ [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


🌏  Uploading... (5/5)

✨ Success! Uploaded 5 files (2.53 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Deployment complete! Take a peek over at https://f21b42fb.thinkami-react-hono-d1.pages.dev

 

Cloudflare Pages で動作確認

払い出されたURLにアクセスしたところ、ローカルと同じように表示されました。

こちらは / へのアクセスしたときの表示です。TanStack RouterのDevtoolsは非表示になっています。

 
こちらは /chart の表示です。こちらも良さそうです。

 

Cloudflare D1のデータを表示するようアプリを修正

以前作ったアプリがCloudflare Pages上で動くようになったため、次はCloudflare D1のデータを表示できるようアプリを修正します。

 

WranglerでCloudflare D1をセットアップ

D1のドキュメントに従い、 wrangler d1 create で作成します。
https://developers.cloudflare.com/workers/wrangler/commands/#create

今回のD1は my-d1 という名前にします。

$ wrangler d1 create my-d1

 ⛅️ wrangler 3.53.1
-------------------
✅ Successfully created DB 'my-d1' in region APAC
Created your new D1 database.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-d1"
database_id = "73b700ec-6f6e-40f3-b01f-bb3fd5864b7c"

 
次に、Wranglerの実行ログに出たD1の情報を wrangler.toml の末尾に追記します。

name = "thinkami-react-hono-d1"
pages_build_output_dir = "./dist"

# 以下を追記
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-d1"
database_id = "73b700ec-6f6e-40f3-b01f-bb3fd5864b7c"

 

Drizzle ORMをインストール

Drizzle ORM の公式ドキュメントにある Cloudflare D1 の項目に従い、Drizzle ORMとKitをインストールします。
Cloudflare D1 | Drizzle ORM - SQLite

$ npm i drizzle-orm
$ npm i -D drizzle-kit

 

drizzle.config.ts の作成

Cloudflare D1では、マイグレーションはCloudflare D1が提供しているものを使います。
Migrations · Cloudflare D1 docs

そこで、Drizzle Kitを使って migrations ディレクトリにマイグレーションファイルを出力できれば良さそうです。

 
ただ、そのディレクトリはDrizzle Kitのデフォルトとは異なることから、 drizzle.config.ts にて、マイグレーションファイルの出力先 out を指定しておきます。
Migrations folder | Drizzle ORM - Configuration

なお、今回もテーブルごとのスキーマファイルにすることから、 schema も設定しておきます。
Schema files paths | Drizzle ORM - Configuration

import type {Config} from "drizzle-kit"

export default {
  schema: "./src/schema/*",
  out: "./migrations",
} satisfies Config

 

Drizzle ORM向けのスキーマを作成

今回は Pie chart のデータソースを D1 に入れておきます。そこで、 colorsapples の2つのテーブルを用意します。

まずは src/schema/colors.ts です。IDと色名だけ保持します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";

export const colors = sqliteTable("colors", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
続いて、 src/schema/apples.ts です。こちらでは colors テーブルへの外部キー制約を付けています。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {colors} from "./colors";

export const apples = sqliteTable("apples", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  colorId: integer("color_id").references(() => colors.id),
  quantity: integer("quantity")
})

 

Drizzle Kitでマイグレーションファイルを自動生成

D1はSQLiteであることから、Drizzle Kitの drizzle-kit generate:sqlite を使ってD1向けのマイグレーションファイルを生成します。
Generate migrations | Drizzle ORM - List of commands

$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.10

No config path provided, using default 'drizzle.config.ts'
Reading config file 'path/to/drizzle.config.ts'
1 tables
publishers 2 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ migrations/0000_tidy_phantom_reporter.sql 🚀

 
migrations ディレクトリの中に1つのマイグレーションファイルができました。

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

1 directory, 3 files

 
0000_tidy_phantom_reporter.sql を開くと、2つのスキーマに対するSQLが記載されています。

CREATE TABLE `apples` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `color_id` integer,
    `quantity` integer,
    FOREIGN KEY (`color_id`) REFERENCES `colors`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `colors` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 

マイグレーションの実行

前回の記事では Drizzle の migrate() を使ってマイグレーションしました。

一方、Cloudflare D1で マイグレーションをするには、Wranglerapply コマンドを使います。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

 

マイグレーション適用状況を確認

マイグレーションする前に、適用状況を確認します。Wranglerでは wrangler d1 migrations list を使えば良さそうです。
migrations list | Commands - Wrangler · Cloudflare Workers docs

 
まずはローカル環境から確認します。ローカルに対して実行する場合は --local を付与します。

$ wrangler d1 migrations list my-d1 --local
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 
続いて本番のD1を確認します。

ドキュメントには書いてありませんが、本番のD1を確認するには --remote オプションが必要です。

$ wrangler d1 migrations list my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 

ローカル環境に対し、マイグレーションを実行

続いてマイグレーションmigrations apply で実行します。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

ひとまずローカルに対してのみ実行しますので、 --local オプションを付けておきます。

$ wrangler d1 migrations apply my-d1 --local
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
┌────────────────────────────────┬────────┐
│ name                           │ status │
├────────────────────────────────┼────────┤
│ 0000_tidy_phantom_reporter.sql │ ✅       │
└────────────────────────────────┴────────┘

 
マイグレーションが成功し、ローカルの .wrangler ディレクトリの中にSQLiteのファイルが生成されました。

 
生成された SQLite ファイルを確認すると、 colorsapples の2テーブルがありました。

 
念のため、本番環境のD1のマイグレーション適用状況も確認すると、まだ未適用でした。

$ wrangler d1 migrations list my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 
今回は、ローカル環境での動作確認完了後、本番環境をマイグレーションすることにします。

 

ローカル環境に対し、seedデータを投入

前回の記事では、Drizzle ORMにて、自分で seed 投入用プログラムを作りました。

一方、D1の場合はseed用の .sql ファイルを用意することで Wrangler から投入できるようです。
Build a Staff Directory Application · Cloudflare D1 docs

 
そこで、ルートに seeds ディレクトリを作り、その中に seed.sql を作成します。

INSERT INTO colors (name) VALUES ('firebrick'), ('gold'), ('pink'), ('mediumseagreen');

INSERT INTO apples (name, color_id, quantity) VALUES ('奥州ロマン', 1, 1), ('シナノゴールド', 2, 5), ('ピンクレディ', 3, 3), ('ブラムリー', 4, 2);

 
続いて、ローカル環境のSQLiteへseedデータを投入します。

$ wrangler d1 execute my-d1 --local --file=./seeds/seed.sql

 ⛅️ wrangler 3.53.1
-------------------
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.

 
ローカル環境の各テーブルを見てみると、seedデータが存在していました。

colorsテーブル

 
applesテーブル

name 列のフォントが怪しいですが、今はこのままで進めます。

 

Honoアプリからローカル環境のD1へ接続できるよう vite.config.ts を修正

ローカル環境のD1へ接続する方法を調査

Cloudflare Pages では、Bindingsという機能を使ってPagesからD1へのアクセスが可能になるようです。
D1 databases | Bindings · Cloudflare Pages docs

また、ローカル開発時に Wrangler の開発サーバを使っていれば、いい感じにローカルのSQLiteへ接続できるようです。
Interact with your D1 databases locally | Bindings · Cloudflare Pages docs

 
ただ、今回のHonoアプリはViteの開発サーバで動作しています。

どうすればいいのだろうと思ったところ、yusukebeさんの記事に情報がありました。

「Bindingsはどうするの?」。実はローカルに限っては対応しています。というかPagesはwrangler --remoteできませんので、Pagesでできる範疇はすべてできます。

例えば、KVなんかこのようにvite.config.tsを編集すると使えるようになります。

(略)

D1もちょっと工夫すれば、ローカルのSQLiteを参照するようにして使えます。

デプロイ先で使いたければ、ダッシュボードからBindingsを有効にすればOKです。

 

Bindings | Honoの新しいCloudflare Pagesスターターについて

 
次に、ローカル環境のD1向けの設定を調べたところ、HonoXのissueに情報がありました。

Now, we can use the new API getPlatformProxy() in Wrangler. This will automatically read variables from wrangler.toml without having to write Bindings in vite.config.ts. The vite.config.ts can be written simply as follows:

 

https://github.com/honojs/honox/issues/39#issuecomment-1955716487

 
issueのコメントからリンクされていたCloudflare Workersのドキュメントを読むと、 getPlatformProxy() では D1 database bindings もサポートされていました。
getPlatformProxy | API · Cloudflare Workers docs

 
以上より、ローカル環境のD1へ接続するには getPlatformProxy() を使えば良さそうと分かりました。

 

vite.config.ts を修正

HonoXのissueのコメントに従い、 vite.config.ts のうち、サーバ側の設定の devServer に対し、

  • env
  • plugins

の設定を追加しました。

import pages from '@hono/vite-cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import {defineConfig} from 'vite'
import {TanStackRouterVite} from "@tanstack/router-vite-plugin"
import {getPlatformProxy} from "wrangler";

export default defineConfig(async ({ mode }) => {
  const { env, dispose } = await getPlatformProxy()

  if (mode === 'client') {
    // ...
  } else {
    return {
      // ...
      plugins: [
        // ...
        // env と plugins を設定
        devServer({
          entry: 'src/index.tsx',
          env: env,
          plugins: [
            {
              onServerClose: dispose
            }
          ]
        })
      ]
    }
  }
})

 

HonoでD1の中身を返すよう修正

今まではAPIのエンドポイント /api/apples にてハードコーディングした値を返しています。

そこで、ハードコーディングではなくD1の値を返すよう修正します。

 
今回は、 apples テーブルと colors テーブルを INNER JOINした結果を返すよう修正します。

ただ、現在のD1 + Drizzle ORMの組み合わせだとバグがあることからメモを残しておきます。

 

D1 + Drizzle ORMを使う場合、Join系にバグがあるので回避

apples テーブルと colors テーブルをINNER JOINしようと、Drizzle ORMの innerJoin() を使って書きました。
Joins [SQL] | Drizzle ORM - Joins

const results = await db.select({
  name: apples.name,
  color: colors.name,
  quantity: apples.quantity,
}).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

console.log(results)

 
INNER JOINした結果を確認したところ、「name 列に色名が設定されている」など意図しないデータができていました。

[
  { name: 'firebrick', color: 1, quantity: undefined },
  { name: 'gold', color: 5, quantity: undefined },
  { name: 'pink', color: 3, quantity: undefined },
  { name: 'mediumseagreen', color: 2, quantity: undefined }
]

 
SQLがおかしいのかなと思い、all() の代わりに toSQL() メソッドで発行されるSQLを確認してみます。
Printing SQL query | Drizzle ORM - Goodies

  const p = await db.select({
    name: apples.name,
    color: colors.name,
    quantity: apples.quantity,
  }).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).toSQL()
  console.log(p)

 
すると、コンソールには以下が出力されました。SQLは正しそうです。

{
  sql: 'select "apples"."name", "colors"."name", "apples"."quantity" from "apples" inner join "colors" on "apples"."color_id" = "colors"."id"',
  params: []
}

 
次にGithubのissueを見たところ、すでにissueが上がっていました。約1年前のissueですが、まだOpenなようです。

 
この問題のワークアラウンドについては、上記issueや以下の記事に書いてありました。今回は SELECT する時に別名を付ける方針でいくことにしました。
Cloudflare D1とDrizzleの組み合わせてで困ったこと

 
Drizzle ORMで列に別名を付けるためには、 as<T>() が使えるのでためしてみます。
sql``.as() | Drizzle ORM - Magic sql`` operator

const results = await db.select({
  name: apples.name,
  color: sql`${colors.name}`.as('colorName'),  // 別名を付ける
  quantity: apples.quantity,
}).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

console.log(results)

 
ログを確認すると、今度は期待通りの値が取得できていました。

なお、文字に豆腐っぽいのが出ていますが、ここでは気にしないことにします。

[
  { name: '奥州ロマン', color: 'firebrick', quantity: 1 },
  { name: 'シナノゴールド', color: 'gold', quantity: 5 },
  { name: 'ピンクレディ', color: 'pink', quantity: 3 },
  { name: 'ブラムリー', color: 'mediumseagreen', quantity: 2 }
]

 

Hono APIのエンドポイントを修正

上記を踏まえ、APIエンドポイント /api/apples にて D1 よりデータを取得・変形して、呼び出し元へ返すよう修正します。

ちなみに、てきとうな実装になってますが、今回はサンプルコード的な実装なので気にしないことにします。

あと、テーブルの値をそのまま返してしまうとハードコーディングしているときと同じ結果になることに気づき、 quantity の値を +1 して返すようにしています。

const appleRoute = app.get('/api/apples', async (c) => {
  const db = drizzle(c.env.DB)

  const results = await db.select({
    name: apples.name,
    color: sql`${colors.name}`.as('colorName'),
    quantity: apples.quantity,
  }).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

  console.log(results)

  const labels = results.map(r => r.name) ?? []
  // テーブルから値を取得していることを分かりやすくするため、テーブルの値 + 1 を設定
  const quantities = results.map(r => r.quantity ? r.quantity + 1 : 0) ?? []
  const colorNames = results.map(r => r.color) ?? []

  return c.json({
    labels: labels,
    datasets: [
      {
        label: '購入数',
        data: quantities,
        backgroundColor: colorNames,
        borderColor: colorNames,
        borderWidth: 1
      }
    ]
  })
})

 

ローカル環境での動作確認

実装が終わったので、ローカル環境で動作確認してみます。

Pie chartが表示され、かつ、テーブルの値が +1 されていました。良さそうです。

 

本番環境のD1をマイグレーション

本番環境のD1をマイグレーションするため、 d1 migrations apply--remote オプション付きで実行します。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

$ wrangler d1 migrations apply my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Parsing 3 statements
🌀 Executing on remote database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 3 commands in 0.7013ms
┌────────────────────────────────┬────────┐
│ name                           │ status │
├────────────────────────────────┼────────┤
│ 0000_tidy_phantom_reporter.sql │ ✅       │
└────────────────────────────────┴────────┘

 

デプロイして動作確認

あとは Cloudflare Pages へデプロイして動作確認するだけ...と思いきや、悩んだことがあったのでメモしておきます。

 

ビルドがハングするので、ビルドとデプロイを分離し、デプロイ

今まで同じように npm run deploy したところ、フロントエンドのビルドが終わったところでハングしたような挙動になりました。

$ npm run deploy

> deploy
> vite build --mode client && vite build && wrangler pages deploy dist

♻️  Generating routes...
✅ Processed routes in 163ms
...
dist/static/client.js               212.83 kB │ gzip: 67.83 kB
✓ built in 1.16s
(ここでずっと止まっている)

 
事例がないかを調べましたが見当たりませんでした。

そこで、表示を見る限り client.js のビルドは成功している感じだったため、ビルド単体で実行してみました。

しかし、それでもまた同じところでハングしてしまいました。

 
次に、フロントエンドとバックエンドのビルドを分けて実行してみました。

"scripts": {
  "build:frontend": "vite build --mode client",
  "build:backend": "vite build",
  "deploy": "wrangler pages deploy dist",
},

 
すると、フロントエンドはもちろん、バックエンドを各単体でビルドした場合でも

  • 表示はハングする
  • ビルドの成果物はきちんとできてそう

となりました。

なお、バックエンドの場合は、以下のあたりで止まります。

dist/_worker.js  76.74 kB
✓ built in 365ms
(ここでずっと止まっている)

 
そこで、ビルドの成果物さえできていればとりあえず問題ないだろうと考え、

  • (1) build:frontend でフロントエンドをビルド
    • ハングするが、ビルドの成果物ができたところでキャンセルする
  • (2) build:backend でバックエンドをビルド
    • ハングするが、ビルドの成果物ができたところでキャンセルする
  • (3) npm run deploy でデプロイ

の順に実行したところ、問題なく完了しました。

 

seed投入前の本番環境で動作確認

この時点の本番環境は

  • D1のマイグレーションは成功
    • seed は投入していないので、各テーブルの中身は空
  • デプロイも(いちおう)成功

という状態です。

 
そこで、動作確認したところ、chartのページはエラーは出ないものの Pie chart は表示されていませんでした。

seedは投入していないので、この表示は正しいという認識です。

 

本番環境のD1へ seed を投入

次に、 --remote オプションを付けて、本番環境のD1へ seed を投入します。
execute | Commands - Wrangler · Cloudflare Workers docs

$ wrangler d1 execute my-d1 --remote --file=./seeds/seed.sql
 ⛅️ wrangler 3.53.1
-------------------
🌀 Mapping SQL input into an array of statements
🌀 Parsing 2 statements
🌀 Executing on remote database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.3165ms

 

seed投入後の本番環境で動作確認

ブラウザをリロードしたところ、Pie chartが表示されました。

 
これで、Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せることができました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_hono_with_cloudflare_pages_d1-example

Cloudflare Pagesのデプロイブランチを main にしたこともあり、今回はプルリクを作りませんでした。

TypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた

前回の記事では Drizzle ORM のマイグレーション機能を中心に色々試していました。
TypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な

今回は SQLite に対して、SQLDDL を実行したり、初期データの投入(seed)をしてみたりします。

 
なお、Drizzle ORMでSQLDDLを実行する方法は、Githubリポジトリ「Drizzle ORM | SQLite」のREADMEにも詳しく書かれています。
https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/sqlite-core/README.md

また、制約やインデックスについては、Drizzle ORMの公式ドキュメントにも記載があります。
Drizzle ORM - Indexes & Constraints

 
目次

 

環境

  • Windows11 WSL2
  • TypeScript 5.4.5
  • Bun 1.1.6
  • SQLite
  • Drizzle ORM 0.30.9
  • Drizzle Kit 0.20.17

 

準備

前回の記事の続きから、実装を進めます。

ただ、前回は結果も記録したかったため、SQLiteファイルもコミットしてしまっていました。

 
そこで、今回は前回とは別のSQLiteファイル my_data.db を使用するよう、以下の2ファイルを変更しておきます。

migrate.ts

const sqlite = new Database("my_data.db")

 
drizzle.config.ts

export default {
  // ...
  dbCredentials: {
    url: "./my_data.db"
  }
} satisfies Config

 

主キーについて

ドキュメントによると、単一主キー・複合主キーとも設定できそうでした。
https://orm.drizzle.team/docs/indexes-constraints#primary-key

 

主キーなし

まずは主キーなしのテーブルを作ってみます。

スキーマ no_pks.ts を用意します。

import { text, sqliteTable } from 'drizzle-orm/sqlite-core'

export const noPks = sqliteTable("no_pks", {
  name: text("name"),
})

 
続いて drizzle-kit generate:sqlite を実行すると、以下のSQLを持つマイグレーションファイルが自動生成されました。

CREATE TABLE `no_pks` (
    `name` text
);

 
bun run migrate にてマイグレーションを実行すると、SQLiteにテーブルができました。

 

単一主キーあり

READMEより、Drizzle ORMでは primaryKey() を使うことで、単一主キーを設定できそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#column-types

 

サロゲートキーを主キーにする

今回はORMでよく見かけるサロゲートキーを主キーにしてみます。

なお、SQLiteのinteger型で自動インクリメント & 採番した番号は再利用しない目的で、 autoIncrement: true を追加しています。
SQLiteで主キーにAUTOINCREMENTを指定すると遅くなる #C# - Qiita

スキーマはこんな感じです。

import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'

export const writers = sqliteTable("writers", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
スキーマを元に生成されるマイグレーションファイルです。

CREATE TABLE `writers` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
マイグレーションすると、 writers テーブルができました。

 

SQLiteでは、後から主キーを追加できない

本題からはずれますが、SQLiteの公式ドキュメントに

4. ALTER TABLE ADD COLUMN

The ADD COLUMN syntax is used to add a new column to an existing table. The new column is always appended to the end of the list of existing columns. The column-def rule defines the characteristics of the new column. The new column may take any of the forms permissible in a CREATE TABLE statement, with the following restrictions:

  • The column may not have a PRIMARY KEY or UNIQUE constraint.
  • ...

https://www.sqlite.org/lang_altertable.html

とあったため、試してみます。

 
まずは主キーのない既存のスキーマに、主キーを追加します。

export const writers = sqliteTable("no_pks", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),  // 追加
  name: text("name"),
})

 
続いて generate:sqlite すると、以下のSQLを持つマイグレーションファイルが生成されました。

ALTER TABLE writers ADD `id` integer PRIMARY KEY NOT NULL;

 
最後に、 migrate したところエラーが表示されました。公式ドキュメント通りです。

$ bun run migrate.ts
...
DrizzleError: Failed to run the query 'ALTER TABLE no_pks ADD `id` integer PRIMARY KEY NOT NULL;'
...
SQLiteError: Cannot add a PRIMARY KEY column

 

複合主キーあり

READMEより、Drizzle ORMでは compositePk を使うことで、複合主キーを設定できそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#customizing-column-data-type

 
今回は、複合自然キーができるか試してみます。

複合自然キーの例として、今回は住所を使います。例えば 伊達市 の住所を特定するには都道府県も持っておく必要があります。

 
まず、 都道府県市区町村 を複合自然キーとするスキーマ addresses.ts を用意します。

ちなみに、複合主キーを作りたい場合、READMEには compositePk: primaryKey(pkExample.id, pkExample.name) と書かれています。

ただ、v0.30.9ではその書き方はdeprecatedです。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/primary-keys.ts#L11

その代わり、 compositePk: primaryKey({columns: [address.prefecture, address.municipality]}) という書き方にすればよいようです。

 
スキーマの全体は以下です。

import {primaryKey, sqliteTable, text} from 'drizzle-orm/sqlite-core'

export const addresses = sqliteTable("addresses", {
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (address) => ({
  compositePk: primaryKey({columns: [address.prefecture, address.municipality]})
}))

 
スキーマから生成されたマイグレーションファイルはこちら。

CREATE TABLE `addresses` (
    `prefecture` text,
    `name` text,
    PRIMARY KEY(`name`, `prefecture`)
);

 
マイグレーションすると、複合主キーを持ったテーブル addresses ができました。

 

制約

次に色々な制約をためしてみます。

 
なお、SQLiteでは ALTER TABLE が限定的なサポートになっています。
SQL Features That SQLite Does Not Implement

そこで、制約を定義する場合に Drizzle ORM がどこまでサポートしてくれるのかも確認していきます。

 

NOT NULL制約

ドキュメントより、Drizzle ORMでは notNull() を使うことで NOT NULL制約を追加できそうでした。

既存のテーブルに、NOT NULL 制約ありの列を追加

スキーマでは、 notNull() メソッドを使って NOT NULL 制約を追加します。

export const writers = sqliteTable("writers", {
  pseudonym: text("pseudonym").notNull()  // 追加
})

 
マイグレーションファイルを生成します。

ALTER TABLE writers ADD `pseudonym` text NOT NULL;

 
マイグレーションすると、 writers テーブルに NOT NULL 制約付きで pseudonym が追加されました。

WebStormのDatabase Toolsで見るとこんな感じです。

 

既存のテーブル・列への NOT NULL 制約追加は自動生成不可

SQLiteの場合、ALTER TABLE 機能はあるものの、ALTER COLUMN はないようです。

 
とはいえ、Drizzle ORM ではどうなるかを試してみます。

今回は、既存のテーブル writers の列 name に NOT NULL 制約を追加してみます。

まずはスキーマを修正します。

export const writers = sqliteTable("writers", {
  name: text("name").notNull()  // NOT NULL制約を追加
})

 
マイグレーションファイルを生成します。

$ drizzle-kit generate:sqlite                                                                                                                                                                           

drizzle-kit: v0.20.17
drizzle-orm: v0.30.9
...
[✓] Your SQL migration file ➜ drizzle/0006_flimsy_proteus.sql 🚀

 
生成されたマイグレーションファイルを見てみると、コメントが記載されていました。もし実装したい場合は、自分で実装する必要があるようです。

/*
 SQLite does not support "Set not null to column" out of the box, we do not generate automatic migration for that, so it has to be done manually
 Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php
                  https://www.sqlite.org/lang_altertable.html
                  https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3

 Due to that we don't generate migration automatically and it has to be done manually
*/

 

UNIQUE制約

ドキュメントより、Drizzle ORMでは uniqueIndex() を使うことで UNIQUE制約を追加できそうでした。

 
今回は、列 pseudonym に UNIQUE 制約を追加してみます。

また、UNIQUE制約を付与する際のインデックス名は unique_pseudonym とします。

export const writers = sqliteTable("writers", {
  // ...
  pseudonym: text("pseudonym").notNull()  // この列に対し、UNIQUE制約を追加
}, (writer) => ({
  uniqueIdx: uniqueIndex("unique_pseudonym").on(writer.pseudonym)
}))

 
マイグレーションファイルを生成したところ、以下のSQLができました。SQLite的にはIndexの生成のようです。

CREATE UNIQUE INDEX `unique_pseudonym` ON `writers` (`pseudonym`);

 
マイグレーションすると、UNIQUE 制約の index ができていました。

 

v0.30.9 時点では、CHECK制約は未実装

ドキュメントでは、CHECK制約は未実装と書かれていました。
https://orm.drizzle.team/docs/indexes-constraints#check

次にGithubを見たところ、CHECK 制約に関するissueがOpenのままでした。
[FEATURE]: Add check support in drizzle-kit · Issue #880 · drizzle-team/drizzle-orm

ちなみに、上記 issue には現時点での回避策も記載されていました。 default() の中で生SQLを書くと対応できるケースもあるようです。
https://github.com/drizzle-team/drizzle-orm/issues/880#issuecomment-1814869720

 

DEFAULT制約

READMEより、Drizzle ORMでは default() を使うことで DEFAULT 制約の追加ができそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#column-types

ただ、SQLiteには ALTER COLUMN がないことから、今回は列を追加する時にDEFAULT制約を設定してみます。

 
まずは、デフォルト値として空文字を設定する comment 列をスキーマへ追加します。

export const writers = sqliteTable("writers", {
  comment: text("comment").default(""),  // 列を追加

 
マイグレーションファイルを生成したところ、以下のSQLが生成されました。

ALTER TABLE writers ADD `comment` text DEFAULT '';

 
マイグレーションすると、DEFAULT値を持つ列 comment が追加されていました。

 

外部キー制約

ドキュメントによると、Drizzle ORMでは単一・複合・自己参照の各外部キーが設定できるようです。

 

単一外部キー

単一外部キーを試すために、2つのスキーマを作成します。

まずは、参照先の publishers を作成します。

export const publishers = sqliteTable("publishers", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
続いて外部キー制約のある books を作成します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {publishers} from "./publishers.ts";

export const books = sqliteTable("books", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  publisherId: integer("publisher_id").references(() => publishers.id) 
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

CREATE TABLE `books` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `publisher_id` integer,
    FOREIGN KEY (`publisher_id`) REFERENCES `publishers`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `publishers` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
マイグレーションすると、2つのテーブルができていました。

publishers テーブルはこちら。

 
books テーブルには外部キーが設定されています。

 

複合外部キー

SQLiteやDrizzle ORM では複合外部キーもサポートされているので、試してみます。

 

新規テーブル作成時に複合外部キーを設定

新規テーブル shops を追加し、 addresses テーブルへの複合外部キーを設定してみます。

なお、ソースコードによると foreignKey() はコールバックを使うと deprecated のようなので、オブジェクトを渡す形にします。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/foreign-keys.ts#L101

export const shops = sqliteTable("shops", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (table) => (
  {
    fk: foreignKey({
      columns: [table.prefecture, table.municipality],
      foreignColumns: [addresses.prefecture, addresses.municipality],
      name: "address_names"
    })
  }
))

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

CREATE TABLE `shops` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `prefecture` text,
    `municipality` text,
    FOREIGN KEY (`prefecture`,`municipality`) REFERENCES `addresses`(`prefecture`,`municipality`) ON UPDATE no action ON DELETE no action
);

 
マイグレーションすると shops テーブルに外部複合キーが設定されていました。

 

既存のテーブルには外部複合キーを設定できない

続いて、既存の publishers テーブルから addresses テーブルへの複合外部キーを設定してみます。

pulishers テーブルに列と外部キーを追加するよう、スキーマを修正します。

export const publishers = sqliteTable("publishers", {
  // ...
  // 以下を追加
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (table) => (
  {
    fk: foreignKey({
      columns: [table.prefecture, table.municipality],
      foreignColumns: [addresses.prefecture, addresses.municipality],
      name: "address_names"
    })
  }
))

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

SQLは出力されているものの、複合外部キーを設定するものとは異なっています。

ALTER TABLE publishers ADD `prefecture` text REFERENCES addresses(prefecture,municipality);--> statement-breakpoint
ALTER TABLE publishers ADD `municipality` text REFERENCES addresses(prefecture,municipality);--> statement-breakpoint
/*
 SQLite does not support "Creating foreign key on existing column" out of the box, we do not generate automatic migration for that, so it has to be done manually
 Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php
                  https://www.sqlite.org/lang_altertable.html

 Due to that we don't generate migration automatically and it has to be done manually
*/

 
マイグレーションすると、以下のエラーになりました。あとから複合外部キーは設定できなさそうです。

SQLiteError: foreign key on prefecture should reference only one column of table addresses

 

自己参照外部キー

Drizzle ORMのドキュメントを参考に、新規テーブル作成時に自己参照外部キーを設定してみます。
https://orm.drizzle.team/docs/indexes-constraints#foreign-key

既存テーブルへの追加については、他の制約同様設定できなさそうなので、今回は省略します。

 
まずは自己参照外部キーを設定する organizersスキーマを作成します。

parentId が自己参照外部キーです。

import {type AnySQLiteColumn, integer, sqliteTable, text} from "drizzle-orm/sqlite-core";

export const organizations = sqliteTable("organizations", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  parentId: integer("parent_id").references((): AnySQLiteColumn => organizations.id),
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

stackoverflowの回答と同じようなSQLになっています。
https://stackoverflow.com/questions/6516066/recursive-foreign-keys-in-sqlite

CREATE TABLE `organizations` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `parent_id` integer,
    FOREIGN KEY (`parent_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);

 
マイグレーションすると、自己参照外部キーが設定されている organizations テーブルができました。

 

外部キー制約のActionについて

Drizzle ORM のドキュメントによると、外部キー制約のActionも定義できるようです。
https://orm.drizzle.team/docs/rqb#foreign-key-actions

また、デフォルトのActionは

  • NO ACTION : This is the default action

とのことで、今まで生成された外部キーのSQLにあった ON UPDATE no action ON DELETE no action という定義と一致しています。

 
では実際に試してみます。(現実的にはあまりないケースですが) 今回は生成されるSQLを確認したいため、

  • 更新時は SET NULL
  • 削除時は CASCADE

と設定を分けてみます。

 
members テーブル新規作成するスキーマ members.ts を作成します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {organizations} from "./organizations.ts";

export const members = sqliteTable("members", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  organizerId: integer("organizer_id")
    .references(() => organizations.id, {
      onUpdate: 'set null',
      onDelete: 'cascade',
    })
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

ON UPDATEON DELETEスキーマで指定した値が設定されています。

CREATE TABLE `members` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `organizer_id` integer,
    FOREIGN KEY (`organizer_id`) REFERENCES `organizations`(`id`) ON UPDATE set null ON DELETE cascade
);

 
マイグレーション後、Database Toolで確認すると、スキーマの設定が反映されていました。

 

列のデータ型について

公式ドキュメントにまとまっているため、今回は特にふれません。
Drizzle ORM - SQLite column types

 

初期データの投入 (Seed)

v0.30.9 時点では自分で実装する

Drizzle ORMやDrizzle Kitのドキュメントを探しましたが、seedについては記載が見当たりませんでした。

そのため、以下の記事や動画のように自分でseedするプログラムを書くことになりそうです。

 
実際にためしてみます。

ルートディレクトリに seed.ts を作り、上記記事を参考に実装します。

ちなみに、 insert する時に await がないとテーブルにデータが保存されません。

import {Database} from "bun:sqlite";
import {drizzle} from "drizzle-orm/bun-sqlite";
import {publishers} from "./src/schema/publishers.ts";

const main = async () => {
  const sqlite = new Database("my_data.db")
  const db = drizzle(sqlite)

  const data: (typeof publishers.$inferInsert)[] = [
    { name: "foo" },
    { name: "bar" },
  ]

  console.log("start ------------->")
  await db.insert(publishers).values(data)
  console.log("<----------------end")
}

main()

 
package.json に seed 用の scripts 設定を追加します。

なお、今回は Bun を使っているため、 TypeScript ファイルを直接実行できます。
bun run – Runtime | Bun Docs

"scripts": {
  "seed": "bun run seed.ts"
},

 
最後にコマンドを実行します。

# 実行
$ bun run seed

# ログ
$ bun run seed.ts
start ------------->
<----------------end

 
SQLiteファイルを開くと、seed.tsの内容が保存されていました。

ちなみに、何回か繰り返しているので、 id 列は 1 始まりではないです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example/pull/2

TypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた

TypeScript + Bun な環境にて、SQLiteを操作したいことがありました。

Bunにはネイティブの SQLite driver があることから、そのまま bun:sqlite を使うこともできそうでした。
SQLite – API | Bun Docs

ただ、日頃ORMでDBまわりを書いていることから、ORM的な何かを使いたくなりました。

 
BunのExamplesを見ていたところ、 Drizzle ORM が紹介されていました。

 
また、Drizzleでは

Drizzle Kit — is a CLI companion for automatic SQL migrations generation and rapid prototyping.

 

Drizzle ORM - Overview

と、 Drizzle Kit を使ってマイグレーションなどを行うことができそうでした。

 
他にも、Drizzleを使っている日本語の記事を読んだところ、自分が欲しいものとしてちょうど良さそうと感じました。

 
そこで、まずは Drizzle Kit の各コマンドをためしてみたときのメモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • TypeScript 5.4.5
    • tsc -v より
  • Bun 1.1.6
  • DBは SQLite
    • bun:sqlite モジュールを使う
  • Drizzle ORM 0.30.9
  • Drizzle Kit 0.20.17
  • WebStorm 2024.1.1

 
なお、WebStorm 2024.1.1 では、まだ Bun を公式サポートしていないようです。以下のissueによると、デバッグまわりが厳しそうな印象です。

 
ただ、今回の記事ではデバッグは不要なので、WebStormで実装を進めることにします。

 

Bun 上で Drizzle Kit の各コマンドを実行するための準備

Bunのセットアップ

まだ何も Bun の環境ができていないことから、最初に Bun をセットアップします。

Bunの公式ドキュメントに従い、Bunをインストールします。
Installing | Installation | Bun Docs

今回は nodenv を使ってリポジトリごとに Node.js を使い分けている環境だったので、 npm でインストールします。

$ npm install -g bun

 
次に bun --version したところ、bun コマンドがないと言われてしまいました。

そこで、Bunの公式ドキュメントのHow to add to your PATH) に従い、追加で設定作業を行います。

まずは shell を確認します。

$ echo $SHELL
/bin/bash

 
bashだったので、 ~/.bashrc にの末尾に以下を追記します。

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"

 
新しくターミナルを開いたところ、 bun コマンドを実行できました。

$ bun --version
1.1.6

 
Bunの設定が完了したため、 bun init を実行し、projectの設定をしておきます。
bun init – Templating | Bun Docs

$ bun init

bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (drizzle_with_bun):
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 

Drizzleのセットアップ

Bunの環境ができたので、次は Drizzle ORM や Drizzle Kit のセットアップを行います。

 

必要なパッケージのインストール

Drizzle の公式ドキュメントに従い、Bun SQLite を使うために必要なパッケージをインストールします。
Bun SQLite | Drizzle ORM - SQLite

まずは Drizzle ORMをインストールします。

$ bun add drizzle-orm
bun add v1.1.6 (e58d67b4)

 installed drizzle-orm@0.30.9

 1 package installed [734.00ms]

 
続いて Drizzle Kitを -D オプション付きでインストールします。

$ bun add -D drizzle-kit
bun add v1.1.6 (e58d67b4)

 installed drizzle-kit@0.20.17 with binaries:
  - drizzle-kit

 62 packages installed [3.02s]

 

drizzle.config.ts の作成

Drizzle ORMでは drizzle.config.ts ファイルにて設定を変更できます。
Drizzle ORM - Configuration

今回のDrizzle ORM の設定は

とするため、以下の内容で drizzle.config.ts を作成します。

import type { Config } from "drizzle-kit"

export default {
  schema: "./src/schema/*",
  out: "./drizzle",
} satisfies Config

 
以上で準備は終わりです。

 

drizzle-kit generate でマイグレーションファイルを作成

まずは drizzle-kit generateマイグレーションファイルを生成してみます。
Drizzle ORM - Migrations

ドキュメントによると、PythonDjangoのように、スキーマを元にマイグレーションSQLを自動で生成するようです。

実際にためしてみます。

 

スキーマファイルを作成

マイグレーションファイルの元となるスキーマファイルを作成します。

今回用意するスキーマ

  • テーブル名 authors
  • 列は以下の2つ
    • id

      • integer型
      • 主キーで、自動インクリメント
    • name

      • string型

とし、 src/schema/authors.ts へ定義します。

ちなみに、今回の定義では以下の点を考慮しています。

 
実際の定義はこちら。

import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 

package.json に Drizzle Kit の generate コマンドを追加

コマンドは何度も使うことになりそうなので、 package.json へコマンドを追加しておきます。

"scripts": {
  "generate": "drizzle-kit generate:sqlite"
},

 

マイグレーションファイルを自動生成

続いて、 Drizzle Kit によるマイグレーションファイルの生成を行います。

Bunでは bun run により package.jsonscripts を実行できます。
Run a package.json script | bun run – Runtime | Bun Docs

そこで、先ほど追加した generate を実行してみます。

# 実行
$ bun run generate

# 以下はログ
$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file 'path/to/drizzle.config.ts'
1 tables
authors 2 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0000_yielding_the_spike.sql 🚀

 
生成されたファイル 0000_yielding_the_spike.sql を確認すると、以下のSQLが定義されていました。

CREATE TABLE `authors` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
また、 meta ディレクトリの中にもファイルが生成されていました。

drizzle_with_bun/drizzle$ tree
.
├── 0000_yielding_the_spike.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

 

もう1つマイグレーションファイルを追加

次に、Drizzle Kit がスキーマの差分をうまく検知できるか、確認してみます。

先ほど作成したスキーマ authors.tsage 列を追加します。

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  age: integer("age"),  // 追加
})

 
再度 bun run generate したところ、新しくファイルが生成されました。

$ bun run generate

$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/home/thinkami/dev/projects/typescript/drizzle/drizzle_with_bun/drizzle.config.ts'
1 tables
authors 3 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0001_flawless_hitman.sql 🚀

 
生成された 0001_flawless_hitman.sql を確認すると、以下のSQLが生成されていました。差分が検知できているようです。

ALTER TABLE authors ADD `age` integer;

 
また、 metaディレクトリの中にも変化があり、

  • 0001_snapshot.json ファイルが追加
  • _journal.json ファイルの entries に要素が追加

となっていました。

 

migtate.ts を作成し、SQLiteマイグレーションファイルを適用

ここまででマイグレーションファイルを2つ作成しました。ただ、まだこれらのファイルはSQLiteへ適用していません。

そこで、これらマイグレーションファイルを適用していきます。

 
ただ、Drizzle ORMの公式ドキュメントには

Drizzle ORM is designed to be an opt-in solution at any point of your development flow. You can either run the generated migrations via Drizzle, or treat them as generic SQL migrations and run them with any other tool.

 
https://orm.drizzle.team/docs/migrations#quick-start

とありました。

Drizzle ORMやKitには、マイグレーションファイルを適用するコマンドが無いようです。

 
そこで、Bunのドキュメントに従い、 migrate.ts ファイルを作成する方針で対応します。
Use Drizzle ORM with Bun | Bun Examples

 

migrate.ts を作成

Bunのドキュメントにある migrate.ts をそのまま流用し、 migrate.ts を作成します。

import { migrate } from "drizzle-orm/bun-sqlite/migrator"

import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database } from "bun:sqlite"

const sqlite = new Database("sqlite.db")
const db = drizzle(sqlite)

// [Ignore] TS80007: await has no effect on the type of this expression.
await migrate(db, { migrationsFolder: "./drizzle" })

 

package.jsonマイグレーション適用コマンドを追加

migrate という名前で scripts にエントリを追加します。

"scripts": {
  "generate": "drizzle-kit generate:sqlite",
  "migrate": "bun run migrate.ts",
},

 

マイグレーションを適用

bun run migrate を実行すると、マイグレーションが適用され、 sqlite.db が追加されました。

WebStorm のプラグイン Database tools and SQL for WebStormsqlite.db を開くと、 authors テーブルができていました。

age 列も追加されていることから、2つのマイグレーションファイルが一気に適用されたようです。

 
また、SQLiteには __drizzle_migrations というテーブルもできていました。

公式ドキュメントによると

By default, all information about executed migrations will be stored in the database inside the __drizzle_migrations table, and for PostgreSQL, inside the drizzle schema. However, you can configure where to store those records.

 
https://orm.drizzle.team/docs/migrations#configurations

とのことです。

__drizzle_migrations テーブルの中身はこんな感じでした。

 

0.30.9 時点ではマイグレーションロールバック機能がない模様

migrateによるマイグレーション適用があるならば、その逆方向であるロールバックもあるのでは...と思い探してみたところ、Github の discussion がありました。
Migrations Rollback · drizzle-team/drizzle-orm · Discussion #1339

上記を読む限り、現時点ではロールバック機能がないようです。

 

drizzle-kit drop の挙動を確認

上記の通り、Drizzle にはマイグレーションロールバック機能はありません。

一方、Drizzle Kitのドキュメントには Drop migration のコマンドがありました。
Drop migration | Drizzle ORM - List of commands

このコマンドを使うと何が起こるのか気になったことから、試してみます。

 

Drop migrationの実行

drizzle-kit drop を実行してみます。

$ drizzle-kit drop
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Please select migration to drop:
  0000_yielding_the_spike
❯ 0001_flawless_hitman

# 選択後
[✓] 0001_flawless_hitman migration successfully dropped

 
 
実行後に git status を見たところ、以下が差分として出ていました。

マイグレーションファイルの削除の他に、metaディレクトリの中も変更しているようです。

deleted:    drizzle/0001_flawless_hitman.sql
deleted:    drizzle/meta/0001_snapshot.json
modified:   drizzle/meta/_journal.json

 
一方、SQLiteには変更を加えていないようで、

  • __drizzle_migrations テーブルのレコードに変化なし
  • authors テーブルにも age 列が残ったまま

という状態でした。

 
以上より、drizzle-kit drop は「不要なマイグレーションファイルを削除しつつ、自動生成した meta ディレクトリの中身を変更する」と理解しました。

 

Drop migration後に、差分を検知したり元に戻せるか確認

Drop migrationではマイグレーションファイルと実際のDBで差分が生まれてしまいました。

この差分を検知したり、元に戻せるかを確認してみます。

 

drizzle-kit up では差分を検知できない

ドキュメントには

We’re rapidly evolving Drizzle Kit APIs and from time to time there’s a need to upgrade underlying metadata structure.

drizzle-kit up:{dialect} is a utility command to keep all metadata up to date.

 

https://orm.drizzle.team/kit-docs/commands#maintain-stale-metadata

とあり、Drizzle Kit APIの変更によりメタデータが壊れないようにする目的のコマンドのようでした。そのため、今回の目的とは異なりそうです。

 
それでも、 drop 直後の状態(マイグレーションファイルとテーブルの状態が不一致)で、挙動を確認してみます。

$ drizzle-kit up:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Everything's fine 🐶🔥

Drizzle Kit のバージョンアップは行っていないこともあり、 Everything's fine でした。

マイグレーションファイルとテーブルの状態が不一致なことは関係ないようです。

 

drizzle-kit check でも差分を検知できない

drizzle-kit check について、公式ドキュメントには

drizzle-kit check:{dialect} is a very powerful tool for you to check consistency of your migrations.

That’s extremely useful when you have multiple people on the project, altering database schema on different branches.

Drizzle Kit will check for all collisions and inconsistencies.

 

https://orm.drizzle.team/kit-docs/commands#check

とありました。

そこで、ここまでの「マイグレーションファイルとテーブルの状態が不一致」という状態でためしてみます。

$ drizzle-kit check:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Everything's fine 🐶🔥

Everything's fine と表示されました。Checkコマンド的には問題ないようです。

 

元に戻す方法を探す

ここまでで差分は検知できないようでした。

そこで、差分は検知できなくても元に戻す方法を探してみます。

 

【NG】再度 Generate migrations する

マイグレーションファイルを消してしまったのなら再度作り直せば良いのでは」ということで、再度マイグレーションファイルを生成してみます。

$ bun run generate
$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
1 tables
authors 3 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0001_real_raider.sql 🚀

 
再度マイグレーションファイルが生成されました。ファイル名は異なるものの、Drop migration した内容と一致しています。

ALTER TABLE authors ADD `age` integer;

 
マイグレーションファイルが生成できたので、 migrate してみます。

# 実行
$ bun run migrate

# 以降はログ
$ bun run migrate.ts
1 | import { entityKind } from "./entity.js";
2 | class DrizzleError extends Error {
3 |   static [entityKind] = "DrizzleError";
4 |   constructor({ message, cause }) {
5 |     super(message);
        ^
DrizzleError: Failed to run the query 'ALTER TABLE authors ADD `age` integer;'
      at new DrizzleError (/path/to/node_modules/drizzle-orm/errors.js:5:5)
      at run (/path/to/node_modules/drizzle-orm/sqlite-core/session.js:72:13)
      at migrate (/path/to/node_modules/drizzle-orm/sqlite-core/dialect.js:546:13)
      at migrate (/path/to/node_modules/drizzle-orm/bun-sqlite/migrator.js:4:3)
      at /path/to/migrate.ts:10:7

15 |   logger;
16 |   exec(query) {
17 |     this.client.exec(query);
18 |   }
19 |   prepareQuery(query, fields, executeMethod, isResponseInArrayMode, customResultMapper) {
20 |     const stmt = this.client.prepare(query.sql);
                      ^
SQLiteError: duplicate column name: age
 errno: 1

      at prepare (bun:sqlite:249:19)
      at prepareQuery (/path/to/node_modules/drizzle-orm/bun-sqlite/session.js:20:18)
      at run (/path/to/node_modules/drizzle-orm/sqlite-core/session.js:70:14)
      at migrate (/path/to/node_modules/drizzle-orm/sqlite-core/dialect.js:546:13)
      at migrate (/path/to/node_modules/drizzle-orm/bun-sqlite/migrator.js:4:3)
      at /path/to/migrate.ts:10:7
error: script "migrate" exited with code 1

 
エラーになってしまいました。 SQLiteError: duplicate column name: age とある通り、すでに age 列が存在しているために発生したようです。

 
この方法ではうまくいかないことがわかったので、 Drop migration しておきます。

$ drizzle-kit drop

...
[✓] 0001_real_raider migration successfully dropped

 

【NG】drizzle-kit introspect する

Drizzle Kit には、既存のDBからスキーマを生成する方法もあるようです。
https://orm.drizzle.team/kit-docs/commands#introspect--pull

そこで、 drizzle-kit introspect:sqlite を実行したところ、エラーになりました。

$ drizzle-kit introspect:sqlite

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'
 Invalid input  Either "turso", "libsql", "better-sqlite" are available options for "--driver"

 
公式ドキュメントを見たところ、bun:sqlite だけでは introspect コマンドは使えないようで、別途 driver の指定が必要そうでした。
https://orm.drizzle.team/kit-docs/config-reference#driver

 

introspect できるようセットアップする

設定ファイルの変更が必要そうなことから、 drizzle.config.ts

  • driver
  • dbCredentials

の設定を追加します。

export default {
  schema: "./src/schema/*",
  out: "./drizzle",

  // ここから下を追加
  driver: "better-sqlite",
  dbCredentials: {
    url: "./sqlite.db"
  }
} satisfies Config

 
また、公式ドキュメントによると、driverとして better-sqlite を使う場合は better-sqlite3 も必要そうなので追加でインストールします。
https://orm.drizzle.team/docs/get-started-sqlite#better-sqlite3

$ bun add better-sqlite3

 

introspect する

再度 introspect を実行してみたところ、ディレクトdrizzle の下にスキーマファイル schema.ts が生成されました。

中身を見ると、すべてのスキーマが1つのファイルに含まれていました。

import { sqliteTable, AnySQLiteColumn, numeric, text, integer } from "drizzle-orm/sqlite-core"
  import { sql } from "drizzle-orm"

export const drizzleMigrations = sqliteTable("__drizzle_migrations", {
    id: numeric("id").primaryKey(),
    hash: text("hash").notNull(),
    createdAt: numeric("created_at"),
});

export const authors = sqliteTable("authors", {
    id: integer("id").primaryKey({ autoIncrement: true }).notNull(),
    name: text("name"),
    age: integer("age"),
});

 
ただ、

から、この方法で元に戻すのは難しそうでした。

 

【OK】git reset --hard HEAD で戻す

仕方ないので

  • git reset --hard HEAD の実行
  • instrospect 実行時に生成されたファイルを削除

を行い、変更を元に戻しました。

 
見た目場は元に戻りましたが、この後の別の mirgate が成功するか気になりました。

そこで、authors.ts にスキーマの変更を追加し、ためしてみます。

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  age: integer("age"),
  note: text("note"),  // 追加
})

 
マイグレーションファイルを生成します。

$ bun run generate

# ...
No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
1 tables
authors 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0002_slow_baron_zemo.sql 🚀

 
migrate します。

$ bun run migrate

 
SQLiteの構造を見ると、 note が追加されていました。問題なさそうです。

 

drizzle-kit studio により、ブラウザでSQLiteの中身を確認する

Drizzle Kit のドキュメントを見たところ、ブラウザでDBの中身を確認する Drizzle Studio 向けのコマンドがありました。

 
ためしてみたところ、エラーになりました。エラーメッセージから、

  • driver
  • dbCredentials

の設定が必要そうと分かりました。

$ drizzle-kit studio
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'
 Invalid input  You need to specify a "driver" param in you config. It will help drizzle to know how to query you database. You can read more about drizzle.config: https://orm.drizzle.team/kit-docs/config-reference
 Invalid input  You need to specify a "dbCredentials" param in you config. It will help drizzle to know how to query you database. You can read more about drizzle.config: https://orm.drizzle.team/kit-docs/config-reference

 
そこで、introspect 向けでセットアップしたときと同じ内容を drizzle.config.ts に追加します。

export default {
  schema: "./src/schema/*",
  out: "./drizzle",

  // ここから下を追加
  driver: "better-sqlite",
  dbCredentials: {
    url: "./sqlite.db"
  }
} satisfies Config

 
再度実行したところ、Drizzle Studio が起動しました。

$ drizzle-kit studio
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'

[Warning] Drizzle Studio is currently in Beta. If you find anything that is not working as expected or should be improved, feel free to create an issue on GitHub: https://github.com/drizzle-team/drizzle-kit-mirror/issues/new or write to us on Discord: https://discord.gg/WcRKz2FFxN

Drizzle Studio is up and running on https://local.drizzle.studio

 
https://local.drizzle.studio にブラウザでアクセスしたところ、DBの中身が表示されました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example/pull/1

Hono + React + Chart.js + TanStack Router + TanStack Query を使って、Hono製APIのレスポンスをPie chartとして表示してみた

前回、Chart.jsのPie chartをReactで表示してみました。
React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみた - メモ的な思考的な

その続きとして、次はバックエンドからのレスポンスを React + Charts.js で描画したくなりました。

バックエンドは今までさわったことがないもので作ろうと考え、気になっていた Hono を使うことにしました。

 
では、HonoとReactをどう組み合わせればよいか調べたところ、以下の記事が参考になりました。

 
そこで、Hono + React + Chart.js + TanStack Router + TanStack Query な構成でアプリを作ってみたことから、そのときのメモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • React 18.2.0
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • Hono 4.2.7
    • 今回は Node.js テンプレートを使用する
  • TanStack Router 1.30.1
  • TanStack Query 5.32.0

 

Honoで Hello world する

初めて Hono を使うことから、まずは Hello world してみます。

 

各種ライブラリをインストールする

まず、公式ドキュメントの Quick Start に従い、Honoをセットアップします。

なお、今回はローカルだけで動かしどこにもデプロイしないことから、 nodejs テンプレートを選択しました。

$ npm create hono@latest

create-hono version 0.7.0
? Target directory my-app
? Which template do you want to use? nodejs
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files

 
Hello worldするには不要ですが、今後必要になるライブラリをインストールします。

まずはReactまわりをインストールします。

$ npm i react react-dom

 
続いて、開発で使う系です。型定義の他

をインストールします。

$ npm i -D @vitejs/plugin-react-swc @types/react @types/react-dom @hono/vite-dev-server

 

tsconfig.json を修正する

テンプレートの tsconfig.json に対し

  • typesに DOMDOM.Iterable を追加
  • jsxImportSource に react を指定

と修正します。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "lib" : [
      "ESNext",
      "DOM",
      "DOM.Iterable"
    ],
    "types": [
      "node"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "react",
  }
}

 

vite.config.ts を作成する

Node.jsテンプレートには vite.config.ts が含まれていなかったので新規作成します。

Hello world時点ではReactを使いませんが、一足先にReactの設定を追加しておきます。

import devServer from '@hono/vite-dev-server'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
      build: {
        rollupOptions: {
          input: './src/client.tsx',
          output: {
            entryFileNames: 'static/client.js'
          }
        }
      },
      plugins: [react()]
    }
  } else {
    return {
      ssr: {
        external: ['react', 'react-dom']
      },
      plugins: [
        devServer({
          entry: 'src/index.ts'
        })
      ]
    }
  }
})

 

src/index.ts を修正する

vite-dev-server を使うため、 src/index.ts を修正します。

Node.jsで起動する部分はコメントアウトしておきます。

また、 export app も忘れずに行っておきます。

// import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app

// const port = 3000
// console.log(`Server is running on port ${port}`)
//
// serve({
//   fetch: app.fetch,
//   port
// })

 

動作確認

npm run dev してブラウザでアクセスすると、以下が表示されました。

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/2e62c86b867c6be94b3664b623ce5247cfe87330

 

Hono + React でHTMLを表示する

では、次にReactコンポーネントを表示してみます。

 

src/client.ts を src/client.tsx へリネームし、Reactコンポーネントを書く

テンプレートで生成された src/client.tssrc/client.tsx へとリネームし、React コンポーネントを書きます。

import * as React from "react"
import {createRoot} from "react-dom/client"

const App = () => {
  return (
    <h1>Hello world</h1>
  )
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

 

src/index.tsx を修正する

JSONではなくHTMLを返すよう修正します。

import { Hono } from 'hono'
import {renderToReadableStream, renderToString} from "react-dom/server"

const app = new Hono()

app.get('*', (c) => {
  return c.html(
    renderToString(
      <html>
        <head>
          <meta charSet="utf-8"/>
          <meta content="width=device-width, initial-scale=1" name="viewport"/>
          <title>React app</title>
          {import.meta.env.PROD ? (
            <>
              <script type="module" src="/static/client.js"></script>
            </>
          ) : (
            <>
              <script type="module" src="/src/client.tsx"></script>
            </>
          )}
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
    )
  )
})

export default app

 

TS2339 エラーへ対応するため、tsconfig.json を修正する

src/index.tsx を修正すると、以下のように

TS 2339: Property env does not exist on type ImportMeta

というエラーが表示されます。

 
調べてみたところ、Vite.jsのドキュメントに記載がありました。

 
今回は

tsconfig.json 内の compilerOptions.typesvite/client を追加

という方向で対応します。

// ...
"types": [
  "node",
  "vite/client"  // 追加
],
// ...

vite.config.ts を修正する

src/client.tsx へリネームしたことから、 entry の定義を修正します。

plugins: [
  devServer({
    entry: 'src/index.tsx'  // 修正後
  })
]

 

動作確認

ブラウザでアクセスすると、以下が表示されました。コンポーネントで定義した Hello world が表示されています。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/a2694a51d689ad6350e3eb5138898db6fc26e257

 

React + Chart.js で Pie chart を描画する

次は Chart.js で Pie chart を描画してみます。

 

Chart.js まわりをインストールする

Chart.js で Pie chart を描画するため、前回同様、以下をインストールします。

$ npm i chart.js react-chartjs-2

 

src/client.tsx を修正する

Pie chartを表示するよう修正します。

なお、まずは Pie chart が表示されるかだけ確認することから、 Pie chart のデータは React コンポーネントの中にハードコーディングしておきます。

また、Pie chart が画面いっぱい表示されるのを防ぐために、 <div style={{width: '300px'}}> としてサイズを指定しています。

import * as React from "react"
import {createRoot} from "react-dom/client"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const App = () => {
  return (
    <>
      <h1>Hello world</h1>
      <ChartComponent/>
    </>
  )
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

 

動作確認

ブラウザでアクセスすると、Pie chart が表示されました。

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/dde23faf7d9427e9b64e226203d0bbfd33e5e7f5

 

TanStack Router によるルーティングを追加する

本題とは異なるのですが、 Hono + React 構成とした場合にフロントエンドでのルーティングもできるかためしてみます。

ルーティングのライブラリとして、前回の記事同様 TanStack Router を使います。
TanStack Router

 

TanStack Router まわりをインストールする

必要なものをインストールします。

$ npm i @tanstack/react-router @tanstack/router-vite-plugin @tanstack/router-devtools

 

Reactのコンポーネントを修正する

TanStack Routerでルーティングするため、Reactのコンポーネント群を移動・修正します。

今回は以下の方針とします。

 

src/client/main.tsx を作成する

TanStack Routerのドキュメントに従い、エントリポイントとなるファイルを作成します。

import {createRoot} from "react-dom/client";
import * as React from "react";
import {createRouter, RouterProvider} from "@tanstack/react-router";
import {routeTree} from "./routeTree.gen"

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
)

 

src/client/routes/__root.tsx ファイルを作成する

TanStack Routerで必要な src/client/routes/__root.tsx を作成します。

中身はほぼ空で、各ページのコンポーネントTanStackRouterDevtools だけ置いておきます。

import {createRootRoute, Outlet} from "@tanstack/react-router";
import {TanStackRouterDevtools} from "@tanstack/router-devtools";

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  )
})

 

/ 向けの index.lazy.tsx を作成する

/ にアクセスした時表示されるコンポーネント index.lazy.tsx を作成します。

今回はメッセージと Pie chart のあるページのリンクだけ置いておきます。

import {createLazyRoute, Link} from "@tanstack/react-router";

const Component = () => {
  return (
    <>
      <h1>Hello, TanStack Router</h1>
      <Link to="/chart">Chart</Link>
    </>
  )
}

export const Route = createLazyRoute('/')({
  component: Component
})

 
なお、TanStack Routerの Link コンポーネントですが、上記の書き方だと

Element Link does't have required attribute search

というワーニングが出ます。

 
ただ、今回の実装には影響しないので、いったんこのままにしておきます。。

 

/chart 向けの chart.lazy.tsx を作成する

Pie chartを表示するコンポーネントを作成します。

なお、Pie chartのデータはまだフロントエンド側に置いたままにしておきます。

import {createLazyRoute, Link} from "@tanstack/react-router";
import * as React from "react"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'


const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 

エントリポイントの移動に伴う修正をする

src/index.tsx を修正する

// 変更前
// <script type="module" src="/src/client.tsx"></script>

// 変更後
<script type="module" src="/src/client/main.tsx"></script>

 

vite.config.ts を修正する

(今回は使いませんが) rollupOptions にエントリポイントとなるファイルの指定があるので差し替えます。

rollupOptions: {
  // 修正前
  // input: './src/client.tsx',

  // 修正後
  input: './src/client/main.tsx',
  // ...
}

 
また、 plugins にも TanStackRouterVite を追加しておきます。

plugins: [
  react(),
  TanStackRouterVite()  // 追加
]

 

tsr.config.json ファイルを追加する

TanStack Router のデフォルト設定とは異なり、今回は src/client の下にフロントエンドのファイルを置いています。

そのため、デフォルト設定のままだと TanStack Router が自動でルーティングファイルを生成するときに想定した動作になりません。

そこで、ルートディレクトリに tsr.config.json ファイルを追加し、今回のディレクトリ構成に合わせた設定にします。
Configuration | File-Based Routes | TanStack Router React Docs

{
  "routesDirectory": "./src/client/routes",
  "generatedRouteTree": "./src/client/routeTree.gen.ts"
}

 

Router CLI を使って src/client/routeTree.gen.ts を初期生成する

ここまでの設定をすれば routeTree.gen.ts をTanStack Routerが自動で生成してくれる想定でした。

ただ、今回はうまく生成できなかったことから、 Router CLI を使って初期生成します。
Router CLI | File-Based Routes | TanStack Router React Docs

 
まずは公式ドキュメントに従いパッケージを追加します。

$ npm install @tanstack/router-cli

 
続いて、Router CLI を使ってファイルを生成します。

$ tsr generate

 

動作確認

以上で準備ができたので、動作確認します。

まず / へブラウザでアクセスすると、以下が表示されました。

 
次に /chart へアクセスすると Pie chart が表示されました。

なお、各リンクも正しく動作しています。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/6fe512264263ba8ad92cd99ebe244b8738ddd0ae

 

WSL2向けのHMR を追加する

今回WSL2上で開発しているせいか、ホットリロードがうまく動作しません。

そこで、以下の記事を参考に vite.config.ts へ設定を追加します。
Laravel Sailを使い、WSL2上にローカル環境を構築したが、Viteのホットリロードが動作しない

hmr: {
  host: 'localhost'
},
watch: {
  usePolling: true
}

 
設定追加後、コンポーネントファイルの修正を行うと、その内容が即座に反映されるようになりました。

 
ここまでのコミットは以下です。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/57af2bae13baf86865fc2e77da48a467c84c78a3

 

HonoのJSONレスポンスの内容をChart.jsのdataとして使う

ようやく本題です。

 

src/index.tsx を修正し、HonoでJSONレスポンスを返す

今までフロントにハードコーディングしてあった Chart.js 向けのJSONを、Honoで返すよう修正します。

ちなみに、Honoではバックエンドとフロントエンドで型を共有する RPC という機能があるため、合わせて実装しておきます(今回は GET しか使わないので有益ではないかもしれませんが。。)。
RPC - Hono

import {Hono} from 'hono'
import {renderToString} from "react-dom/server"

const app = new Hono()

const appleRoute = app.get('/api/apples', (c) => {
  return c.json({
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  })
})

// フロントエンドと型を共有するため、export type しておく
export type ApplesType = typeof appleRoute

app.get('*', (c) => {
  return c.html(
    renderToString(
      <html>
        <head>
          <meta charSet="utf-8"/>
          <meta content="width=device-width, initial-scale=1" name="viewport"/>
          <title>React app</title>
          {import.meta.env.PROD ? (
            <>
              <script type="module" src="/static/client.js"></script>
            </>
          ) : (
            <>
              <script type="module" src="/src/client/main.tsx"></script>
            </>
          )}
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
    )
  )
})

export default app

 

src/client/routes/chart.lazy.tsx を修正し、HonoのレスポンスをChart.jsで描画する

今回は Hono の RPC 機能を使うため、 fetch関数を使うのではなく、Honoの hc 関数を使います。
https://hono.dev/guides/rpc#client

import {createLazyRoute, Link} from "@tanstack/react-router"
import * as React from "react"
import {useEffect, useState} from "react"
import {ArcElement, Chart as ChartJS, ChartData, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'
import {ApplesType} from "../../index"
import {hc} from 'hono/client'

type MyChart = ChartData<"pie", number[], unknown>

// HonoのRPC機能を使う
const client = hc<ApplesType>('http://localhost:5173')

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const [data, setData] = useState<MyChart>()

  useEffect(() => {
    const fetchApples = async () => {
      const response = await client.api.apples.$get()
      console.log(response)
      if (response.ok) {
        const apples = await response.json()
        setData(apples)
      }
    }

    fetchApples()
  }, [])

  if (!data) return

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 
実装が終わったところで動作確認すると、Pie chartが同じように表示されました。

また、ブラウザのNetworkタブを見ても、バックエンドからデータが送信されていることが確認できました。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/fd566b134e5c5d980ca0c87c63ee321cdcf27dce

 

Hono APIからのデータ取得に TanStack Query を使う

HonoのRPCドキュメントを見ていたところ

You can also use a React Hook library such as SWR.

 

https://hono.dev/guides/rpc#using-swr

との記載がありました。

 
そこで、TanStack Queryでもできるか気になったため、ためしてみることにします。
TanStack Query

 

TanStack Query をインストールする

$ npm i @tanstack/react-query

 

TanStack Query を使うよう修正する

src/client/main.tsx に TanStack Query用の設定を追加する

TanStack Queryを使えるよう

  • queryClient の作成
  • <QueryClientProvider client={queryClient}> の追加

を行います。

import {createRoot} from "react-dom/client";
import * as React from "react";
import {createRouter, RouterProvider} from "@tanstack/react-router";
import {routeTree} from "./routeTree.gen"
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";

const queryClient = new QueryClient()
const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </QueryClientProvider>
)

 

TanStack Query によるカスタムフックを追加する

Hono APIにリクエストを飛ばすところを、TanStack Queryを使ったカスタムフックにします。

なお、このカスタムフックの中で Hono の hc 関数を使っています。

import {hc} from 'hono/client'
import {ApplesType} from "../../index";
import {useQuery} from "@tanstack/react-query";

const client = hc<ApplesType>('http://localhost:5173')

const queryFn = async () => {
  const response = await client.api.apples.$get()
  if (response.ok) {
    return await response.json()
  }
}

export const useApplesApi = () => {
  return useQuery({
    queryKey: ['ApiApples'],
    queryFn: queryFn
  })
}

 

src/client/routes/chart.lazy.tsx でカスタムフックを使うよう修正する

useEffect の中でHono APIを呼ぶところを、カスタムフックの利用へと修正します。

import {createLazyRoute, Link} from "@tanstack/react-router"
import * as React from "react"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'
import {useApplesApi} from "../hooks/useApplesApi"

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const {data, isLoading} = useApplesApi()
  if (isLoading) return <div>Loading...</div>
  if (!data) return

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 
実装が終わったので動作確認したところ、今までと変わらず Pie chart が表示されました。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/c8ec948097ff9ba3cc434e9c9f9c64691b616ac8

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1

React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみた

Reactアプリで Pie chart (円グラフ)を表示したくなったので調べたところ、以下の記事がありました。

   
上記では色々なライブラリが紹介されていましたが、今回はWebに情報の多そうな Chart.js を使ってみることにしました。
Chart.js | Open source HTML5 Charts for your website

 
次に、Chart.jsをReactで扱う方法を調べたところ、 react-chartjs-2 がありました。
react-chartjs-2 | react-chartjs-2

 
そこで、 Chart.js + react-chartjs-2 を使って React で Pie chart を表示してみたことから、メモを残します。

 
目次

 

環境

  • React 18.2.0
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • TanStack Router 1.29.2

 

ViteでReactアプリを作る

Vite公式ドキュメントに従い、ViteでReactアプリを作ります。
Getting Started | Vite

$ npm create vite@latest
✔ Project name: … react_chart_example
✔ Select a framework: › React
✔ Select a variant: › TypeScript

 

react-chartjs-2 の環境を構築する

リポジトリのREADMEに従い、 chart.js react-chartjs-2 をインストールします。

$ npm install --save chart.js react-chartjs-2

 

TanStack Router によるファイルベースルーティングの設定を追加する

今回はいくつかサンプルコードを作るので、ルーティングライブラリを入れておきます。

ただ、パスを考えるのが手間なので、ファイルベースルーティングができるライブラリを使いたくなりました。

以前は Generouted + React Router を使っていました。
ファイルベースルーティング: Generouted + React Router | Reactにて、useStateやuseEffectを使っていたところをTanstack Queryに置き換えてみた - メモ的な思考的な

 
そんな中、 TanStack Routerでもファイルベースルーティングができるようになったと知りました。

 
そこで今回は、TanStack Routerのファイルベースルーティングを試してみることにしました。
TanStack Router Docs

 

TanStack Router のインストール

TanStack Routerのドキュメントに従い、tanstack router と viteのプラグインをインストールします。
Vite Plugin | TanStack Router Docs

$ npm install @tanstack/react-router @tanstack/router-vite-plugin

 
devtools もインストールしておきます。
Devtools | TanStack Router Docs

$ npm install @tanstack/router-devtools --save

 

vite.config.ts への追加

公式ドキュメントに従い、 vite.config.ts へ設定を追加します。
https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing#vite-configuration

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {TanStackRouterVite} from "@tanstack/router-vite-plugin";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    TanStackRouterVite()  // 追加
  ],
})

 
続いて、 npm run dev し、 routeTree.gen.ts を生成しておきます。

♻️  Generating routes...
✅ Processed route in 148ms

 

main.tsx の修正

TanStack Router を組み込むため、 main.tsx を修正します。

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import {createRouter, RouterProvider} from "@tanstack/react-router"
import {routeTree} from "./routeTree.gen"

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

 

__root.tsx の追加

ルートとなるファイル __root.tsx を作成します。

import {createRootRoute, Outlet} from "@tanstack/react-router";
import {TanStackRouterDevtools} from "@tanstack/router-devtools";

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  )
})

 
以上で TanStack Router の準備ができました。

 

Chart.js で Pie chart を作成する

はじめての Pie chart

Chart.js の exampleを参考に、Pie chart を作ります。

 
また、ファイルベースルーティングできるよう、Pie chartのコンポーネントとルーティングを src/routes/pie_chart/first_pie_chart.lazy.tsx に作ります。

import {createLazyRoute} from "@tanstack/react-router"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'


const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} />
  )
}

export const Route = createLazyRoute('/pie_chart/first_pie_chart')({
  component: Component
})

 
実装ができたので、 npm run dev で起動します。

その後、 http://localhost:5173/pie_chart/first_pie_chart へアクセスすると、Pie chartが表示されました。

 

Legend (凡例) をカスタマイズする

今回は Legend の表示位置を Pie chart の右側に表示するようカスタマイズします。

カスタマイズ方法を調べたところ、 Chart.js のドキュメントの Legend に情報がありました。 Legend | Chart.js

また、ドキュメントには Warning として

The doughnut, pie, and polar area charts override the legend defaults. To change the overrides for those chart types, the options are defined in Chart.overrides[type].plugins.legend .

と書かれていました。

 
そこで、

  • Chart.overrides.pie.plugins.legend を指定
  • 表示位置のオプション positionright を指定
  • ファイル名は pie_chart_with_legend_on_the_right.lazy.tsx

として実装しました。

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  // 以下を追加
  ChartJS.overrides.pie.plugins.legend.position = 'right'
// ... 
// 他は同じ
}

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_legend_on_the_right を開くと、Legend が右側に表示されました。

 

Pie chartの上にラベルをを常に表示する

今まで見てきた通り、Chart.js の場合、デフォルトではマイスオーバーすることで、各領域の内訳が表示されます。

ただ、マウスオーバーしなくてもPie chart上に表示する方法がないかを調べたところ、以下の記事がありました。
【Vue.js】Vue.js + Chart.js ドーナツグラフのちょっとした小技【vue-chart.js】| blog(スワブロ) | スワローインキュベート

これを参考に、 pie_chart_with_always_text.lazy.tsx として実装してみました。

実装したときのメモは以下です。

  • alwaysTooltipPlugin という名前で Chart.js のプラグインを作る
    • その中の afterDraw コールバックで、各領域の上にテキストを描画する
  • ChartJS.register() の引数に、追加したプラグイン alwaysTooltipPlugin を指定する
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from "chart.js";
import {Pie} from "react-chartjs-2";
import {createLazyRoute} from "@tanstack/react-router";

const alwaysTooltipPlugin = {
  id: 'alwaysShowTooltip',
  afterDraw(chart: ChartJS) {
    const {ctx} = chart
    chart.data.datasets.forEach((_dataset, i) => {
      chart.getDatasetMeta(i).data.forEach((datapoint, index) => {
        const {x, y} = datapoint.tooltipPosition(false)

        const text = chart.data.labels ?
          (chart.data.labels[index] ?? '' + ': ' + chart.data.datasets[i].data[index]).toString() :
          ''

        ctx.textAlign = 'center'
        ctx.textBaseline = 'middle'
        ctx.fillText(text, x, y)
      })
    })
  }
}

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend, alwaysTooltipPlugin)
  ChartJS.overrides.pie.plugins.legend.position = 'right'

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} />
  )
}

export const Route = createLazyRoute('/pie_chart/pie_chart_with_always_text')({
  component: Component
})

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_always_text を開くと、各領域の上にラベルが表示されました。

なお、各領域のラベルの位置は調整していないため、やや重なっています。ただ、今回はサンプルコードなので気にしないこととします。

 
ちなみに、マウスオーバーすると、今まで通り Tooltip が表示されます。

 

Pie Chartの上にTooltipを常に表示する

ラベルが常に表示できるとすれば、Tooltipも常に表示できるかもしれないと考えました。

そこで、

  • 各領域の上に Tooltip を常に表示する
  • マウスオーバーしても、デフォルトの Tooltip は表示しない

の方法を調べたところ、Chart.js の公式 Youtube に情報がありました。
How to Always Show Tooltip on Pie Chart in Chart js - YouTube

 
そこで、Youtubeの内容を参考にしながら pie_chart_with_always_tooltip.lazy.tsx を実装してみました。

実装したときのメモは以下です。

  • ctx.beginPath() 以降の実装で、Tooltipを描画する
  • Pieコンポーネントの options に対して、マウスオーバーしたときのTooltipを表示しないように設定する
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from "chart.js";
import {Pie} from "react-chartjs-2";
import {createLazyRoute} from "@tanstack/react-router";

const alwaysTooltipPlugin = {
  id: 'alwaysShowTooltip',
  afterDraw(chart: ChartJS) {
    const {ctx} = chart
    ctx.save()

    chart.data.datasets.forEach((_dataset, i) => {
      chart.getDatasetMeta(i).data.forEach((datapoint, index) => {
        const {x, y} = datapoint.tooltipPosition(false)

        const text = chart.data.labels ?
          (chart.data.labels[index] ?? '' + ': ' + chart.data.datasets[i].data[index]).toString() :
          ''

        const textWidth = ctx.measureText(text).width
        ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'

        ctx.fillRect(x - (textWidth + 10) / 2, y - 25, textWidth + 10, 20 )

        // triangle
        ctx.beginPath()
        ctx.moveTo(x, y)
        ctx.lineTo(x - 5, y - 5)
        ctx.lineTo(x + 5, y - 5)
        ctx.fill()
        ctx.restore()

        // text
        ctx.font = '12px Arial'
        ctx.fillStyle = 'white'
        ctx.fillText(text, x - (textWidth / 2), y - 10)
        ctx.restore()
      })
    })
  }
}

const options = {
  plugins: {
    tooltip: {
      enabled: false
    }
  }
}

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend, alwaysTooltipPlugin)
  ChartJS.overrides.pie.plugins.legend.position = 'right'

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} options={options} />
  )
}

export const Route = createLazyRoute('/pie_chart/pie_chart_with_always_tooltip')({
  component: Component
})

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_always_tooltip を開くと、各領域の上にラベルが表示されました。

なお、各領域のTooltipの位置は調整していないため、やや重なっています。ただ、今回はサンプルコードなので気にしないこととします。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_chartjs-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/react_chartjs-example/pull/1