SQLocalで拓かれるフロントエンドデータベースの世界
はじめに
SQLocalという面白そうなものを見つけたので遊んでみました。
SQLocalとは
SQLocalは、WebブラウザでSQLiteを便利に使うためのライブラリです。
軽量RDBMSとして知られるSQLiteは、本来はネイティブなアプリケーションに組み込まれるもので、一昔前まではWebブラウザでは動作しませんでした。しかしWebAssemblyの登場により、コンパイルすればWebブラウザでも動くようになりました。
SQLocalはSQLite公式が提供する@sqlite.org/sqlite-wasm
パッケージのラッパーとして、実際にSQLiteをフロントエンドで使う上で必要になってくる機能を提供しています。
例えば、SQLocalはSQLiteのデータベースファイルをOrigin Private File Systemに保存する機能を提供します。Origin Private File Systemというのは、そのWebサイトのOriginと紐づく形で提供されるプライベートなファイルシステムです。これまでは、フロントエンドでファイルを動的に作成するにはBlobなどを駆使する必要がありましたが、OPFSでは、より扱いやすいAPIでハイパフォーマンスな操作が可能です。
また、SQLocalはWeb Workerとして動作するため、メインスレッドの処理をブロックしません。そして、KyselyとDrizzleという2種類のクエリビルダーに対応し、型安全にデータベースを操作できるようになっています。
実際に遊んでみる
公式ドキュメントを読んでいただくのが一番手っ取り早いですが、それだとこの記事の存在意義がなくなるのでところどころに解説を挟みつつ紹介します。
とりあえずフロントエンド開発ということで、Viteで開発環境をセットアップしましょう。本題からは逸れるので省略しますが、ReactでもVueでもQwikでも大丈夫だと思います。
pnpm create vite@latest
次に、SQLocalをインストールします。また、SQLocalで提供されるデータベースを操作するためのクエリビルダーであるKyselyもインストールします。
pnpm install sqlocal kysely
また、公式ドキュメントを参考に、Viteの設定をしていきます。Viteでうまく読み込まれないWeb Workerファイルのために除外設定をし、OPFSのために開発サーバーが適切なHTTPヘッダーをつけるように設定します。最終的には次のようなvite.config.ts
になります。
import { defineConfig } from "vite";
import { qwikVite } from "@builder.io/qwik/optimizer";
// https://vitejs.dev/config/
// https://sqlocal.dallashoffman.com/guide/setup#vite-configuration
export default defineConfig({
optimizeDeps: {
exclude: ["sqlocal"],
},
plugins: [
qwikVite({ csr: true }),
{
name: "configure-response-headers",
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
next();
});
},
},
],
});
さて、それでは(どのフレームワークを選んだかによっても変わってきますが)./src/app.tsx
のようなエントリーポイントをエディタで開いて、編集していきましょう。
import { SQLocalKysely } from "sqlocal/kysely";
import { Kysely, type ColumnType } from "kysely";
// SQLocalKyselyを初期化し、`dialect`をKyselyへ渡す形でKyselyも初期化します
const { dialect } = new SQLocalKysely("database.sqlite3"); // ファイル名は適当でもOK(`:memory:`も使えそうな雰囲気)
const db = new Kysely<Database>({ dialect });
// 初期化さえ終えてしまえば、SQLocalに構う必要はもうありません
// Kyselyにジェネリクスとして渡した`Database`型をここで定義します
// この型がうまく作用することで、クエリの構築などで型チェックや補完を得ることができます
type Database = {
user: User;
post: Post;
};
/** ユーザーテーブル */
type User = {
// `ColumnType`はそれぞれselect時、insert時、update時の型を定義します
// neverを使うことで「更新してはいけないカラム」を表現しています
id: ColumnType<string, string, never>;
name: ColumnType<string, string, string>;
};
/** ユーザーによる投稿テーブル */
type Post = {
id: ColumnType<string, string, never>;
content: ColumnType<string, string, string>;
userId: ColumnType<string, string, never>;
};
コメントとしていろいろと書きましたが、ここでも少し補足します。
SQLocalはWASM版SQLiteのラッパーとして機能しますが、私達がSQLocalに対してしなければならない処理は初期化くらいです。初期化後はクエリビルダーを使って問い合わせていく形になります。
KyselyはSQLocalがサポートするクエリビルダーの一つです。一般的なORMと違い、Kyselyはクエリのビルドとその返り値の提供に専念しています。(それゆえWebブラウザのようなデータベースとは無縁だったような環境でも動作します。)Kyselyでの問い合わせの例はKyselyの公式ドキュメントを参照してください。ここで少し紹介すると、
const alice = await db
.insertInto("user")
.values({
id: crypto.randomUUID(),
name: "Alice",
})
.returningAll()
.executeTakeFirst();
const allUsers = await db
.selectFrom("user")
.selectAll()
.execute();
のような、とてもSQL的な書き方です。Kyselyの初期化時にDatabase
型をジェネリクスとして渡しているため、テーブル名や挿入時の.values({})
や、alice
やallUsers
といった返り値には型がついています。
さて、実際に上記のコードを動かしてみるとわかりますが、まだエラーが出て動きません。というのもデータベースの初期化(マイグレーション)をしていないためです。マイグレーションは、(すでにサーバー上にあるデータベースファイルを使うようにしてしまうという方法もありますが、)Kyselyの機能を使うと行えます。
KyselyはMigrator
というクラスをexport
しており、これに適切に実装されたMigrationProvider
を渡しつつインスタンス化し、migrateToLatest()
メソッドを呼び出すことでマイグレーションが行われます。
-
Migration
インターフェースはup()
とdown()
をプロパティとして持つ -
MigrationProvider
インターフェースはgetMigrations()
メソッドの呼び出しに対し、Record<string, Migration>
を返す -
Migrator
クラスはMigrationProvider
をコンストラクタで受け取り、migrateToLatest()
などのメソッドの呼び出しでマイグレーションを行う
import { Migrator, type MigrationProvider, type Migration } from "kysely";
class InitialMigrationProvider implements MigrationProvider {
public async getMigrations(): Promise<Record<string, Migration>> {
return {
"-": {
async up(db) {
// Userテーブルの初期化
await db.schema
.createTable("user")
.addColumn("id", "varchar", (cb) => cb.primaryKey().notNull())
.addColumn("name", "varchar", (cb) => cb.notNull())
.execute();
// Postテーブルの初期化
await db.schema
.createTable("post")
.addColumn("id", "varchar", (cb) => cb.primaryKey().notNull())
.addColumn("content", "varchar", (cb) => cb.notNull())
.addColumn("userId", "varchar", (cb) => cb.notNull())
.addForeignKeyConstraint("post.userId", ["userId"], "user", ["id"])
.execute();
},
async down(db) {
// `up()`をなかったことにするマイグレーション
await db.schema.dropTable("user").execute();
await db.schema.dropTable("post").execute();
},
},
};
}
}
// マイグレーションを実行
await new Migrator({
db: db,
provider: new InitialMigrationProvider(),
}).migrateToLatest();
これでSQLiteが使えるようになりました。あとはユーザー操作やAPIレスポンスの受け取りに応じてデータベースに問い合わせていくロジックを実装すればOKです。流石に本題から逸れるので省略しますが、先程例として挙げたようにdb.insertInto
などを呼び出すだけです。
使い所
WebブラウザでSQLiteが使えると、APIからのレスポンスをうまい形で保存しておけば、余計なAPI呼び出しを削減できたり、APIでは実現できないような複雑な問い合わせをフロント側だけで処理できたり、オフライン環境でもある程度動くようなWebサイトを作れたりする可能性があります。
現在私は自作予定のSNS(Misskey)クライアントにこのライブラリが使えないか考えているところです。しかしSNSとなると投稿が削除されることもあるので、存在確認のためにAPIを呼び出す必要はどうしてもあり続けるかもしれません。とはいえ、すべてのデータをメモリ上に置き、それをJavaScriptで愚直にフィルタリングしていくより、SQLite上に置いた上でSQLとして問い合わせていくほうがパフォーマンスや開発体験がいい可能性はあります。もしくは、バックエンド開発でデータベースとRedisを組み合わせることが一般的に行われているように、データ本体はSQLiteに置いておいて、IDなど必要最低限な情報のみをJavaScript上でSet
として持っておくというのもありかもしれません。……こういった工夫を凝らせるようになるという意味で、このライブラリは嬉しいものです。
おわりに
この記事では、Webブラウザ上でSQLiteを使えるようにするライブラリ「SQLocal」について紹介しました。まだうまい活用方法は思いついていませんが、楽しく遊べそうなライブラリで嬉しいです。
Discussion