「PostgreSQL のウィンドウ関数、ちょっと試したいだけなんだけどな」
この「ちょっと試したい」が、毎回どうにも重い。Docker を起動して、postgres:16 を pull して、psql で入って、テーブルを作って、データを入れて——動かしたかったのは RANK OVER (PARTITION BY ...) の 1 行だけなのに、そこに辿り着くまでが長い。
ブラウザのタブを開いた 3 秒後には SELECT version; が返ってくる。 そういう場所が欲しかった。
それが pg-pad です。SQLite ではなく、本物の PostgreSQL がブラウザの中で動きます。
#🎯 なぜ SQLite ではダメだったのか
ラボにはすでに SQL道場 があります。こちらは sql.js(SQLite の WebAssembly 版)を使った学習サービスで、課題を解きながら SELECT から GROUP BY までを身に付ける構成です。
だから「ブラウザで SQL」というニーズ自体はすでに埋まっている——はずでした。ところが自分で使っていて、埋まっていない穴が 2 つあることに気づきます。
#穴 1: 方言が違う
SQLite と PostgreSQL は、基本の SELECT こそ同じでも、実務で確かめたい部分ほど食い違います。
serial/SEQUENCEが SQLite には無いRANKDENSE_RANKなどのウィンドウ関数の対応状況が違うEXPLAINの出力形式がまったく別物- 型の扱い(SQLite は型が緩い)
「Postgres でこう書けるか」を確かめたいときに SQLite で試すのは、確認になっていません。
#穴 2: 課題形式が邪魔になるときがある
SQL道場は正誤判定があるからこそ学習に効くのですが、「自分の書きたいクエリを自由に投げたい」ときには、その枠が邪魔になります。
学ぶための SQL と 確かめるための SQL は、必要な道具が違う。だから pg-pad は課題を一切持たず、エディタと実行ボタンと結果テーブルだけにしました。
#🏗️ 技術選定:PGlite という選択肢
PostgreSQL をブラウザで動かす、というのは数年前なら冗談みたいな話でした。それを現実にしたのが PGlite(@electric-sql/pglite)です。PostgreSQL 本体を WebAssembly にビルドし、サーバープロセスなしでライブラリとして呼べるようにしたもの。
決め手は 3 つでした。
- 本物の Postgres である — 互換実装ではなく、PostgreSQL のソースをそのまま WASM にしている
- 単体で完結する — サーバーもワーカーの前提もなく、
new PGliteだけで立ち上がる - サイズが現実的 — 数 MB 台で、初回だけ落とせば以降はキャッシュに乗る
#🔌 CDN サイドロードで読み込む
pg-pad の実装で一番のポイントは、PGlite を npm 依存として入れず、CDN から動的に読み込んでいるところです。
const PGLITE_CDN = "https://cdn.jsdelivr.net/npm/@electric-sql/pglite@0.4.6/dist/index.js"
async function getDb: Promise<PGliteLike> {
if (!dbPromise) {
dbPromise = (async => {
const url = PGLITE_CDN
const mod = (await import(/* webpackIgnore: true */ /* @vite-ignore */ url)) as PGliteModule
const db = new mod.PGlite
await db.exec(SEED_SQL)
return db
})
}
return dbPromise
}見ての通り、import に渡しているのは変数です。ここが肝で、リテラル文字列を書くとバンドラが静的に解決しようとしてビルドに巻き込まれます。変数にしておくと解決を諦めてくれるので、ブラウザネイティブの dynamic import としてそのまま実行時に走る。webpackIgnore / @vite-ignore のコメントは、その意図を明示するための保険です。
wasm ファイルは PGlite 側が dist/ からの相対で解決してくれるので、こちらで面倒を見る必要はありません。
このパターンは、ラボの PyPad(Pyodide で Python を動かすサービス)でも同じ形を使っています。重いランタイムを「使うときだけ、CDN から」読む——これが一度確立すると、以降のサービスで使い回せる資産になります。
#dbPromise を使い回す理由
getDb が返すのは DB インスタンスではなく Promise そのものをキャッシュしたものです。これは、初期化中に実行ボタンを連打されても PGlite が二重に起動しないようにするためです。Promise を保持しておけば、後続の呼び出しは全部同じ初期化を待つことになります。
#🌱 開いた瞬間に触れる状態を作る
「環境構築ゼロ」を謳う以上、開いた直後に空っぽのデータベースが出てくるのでは意味がありません。起動と同時にサンプルデータを流し込んでいます。
CREATE TABLE employees (
id serial PRIMARY KEY,
name text NOT NULL,
dept text NOT NULL,
salary integer NOT NULL,
hired date NOT NULL
);
INSERT INTO employees (name, dept, salary, hired) VALUES
('Sato Yuki', 'Engineering', 6200000, '2021-04-01'),
('Tanaka Aoi', 'Engineering', 7400000, '2019-07-15'),
...employees(社員 7 名・4 部署)と projects(プロジェクト 3 件、lead_id で社員を参照)の 2 テーブル。この構成にしたのは、JOIN・GROUP BY・ウィンドウ関数のすべてが意味を持つ最小構成だからです。
部署が 4 つあるので GROUP BY dept が成立し、給与に幅があるので PARTITION BY dept ORDER BY salary DESC の順位付けに意味が出る。外部キーがあるので JOIN が自然に書ける。テーブルが 1 つだと、このどれも試せません。
サンプルクエリもボタンで用意しました。
{
label: "ウィンドウ関数 (部署内 順位)",
sql: "SELECT name, dept, salary,\n RANK OVER (PARTITION BY dept ORDER BY salary DESC) AS rank_in_dept\nFROM employees;",
},
{
label: "EXPLAIN",
sql: "EXPLAIN SELECT * FROM employees WHERE salary > 6000000;",
},SELECT version; を入れてあるのは半分ネタで、半分は本気です。これを実行すると PostgreSQL のバージョン文字列がそのまま返ってくる。「本当に Postgres が動いている」ことが、説明ではなく出力で伝わります。
#😓 苦労したところ
#複数文を投げたときにどれを表示するか
exec は複数の SQL 文をまとめて実行でき、結果は文の数だけ配列で返ってきます。ところが CREATE TABLE や INSERT は結果セットを持たない。素直に全部並べると、空のテーブルがいくつも表示されて何が起きたのか分かりません。
そこで、列を持つ結果だけを拾って、最後のものを表示する方針にしました。
const shown = results.filter((r) => r.fields && r.fields.length > 0)
const last = shown[shown.length - 1] ?? nullCREATE TABLE ...; INSERT ...; SELECT ...; と 3 文投げたときに、期待どおり最後の SELECT の結果が出ます。実行した文の数と行数はステータス行に出しているので、「3 文 実行 ・ 7 行 ・ 42 ms」という形で全体の様子は分かる。
#起動待ちをどう見せるか
PGlite の初回起動は、CDN からの取得と WASM の初期化で数秒かかります。ここで何も出さないと「壊れている」と判断されて離脱されます。
対策として、ページ表示直後にウォームアップを走らせるようにしました。
useEffect( => {
let alive = true
warmup.then((ok) => {
if (!alive) return
setReady(ok)
setStatus(ok ? "準備 完了 — Ctrl/⌘ + Enter で 実行" : "起動 に 失敗 (ネットワーク を 確認)")
})
return => { alive = false }
}, [])ユーザーがサンプルクエリを眺めている間に裏で起動が終わるので、実際に実行ボタンを押す頃には準備ができている。ステータス表示を「PostgreSQL を 起動 中…」→「準備 完了」と変えることで、待ち時間が正体不明の空白にならないようにしています。
alive フラグは、初期化が終わる前にページを離れた場合に状態更新を止めるためのものです。
#リセットの実装
いろいろ試した後に初期状態へ戻したくなるので、リセットを付けました。中身は身も蓋もなく、キャッシュした Promise を捨てて作り直すだけです。
export async function resetDb: Promise<void> {
dbPromise = null
await getDb
}DROP TABLE を並べて掃除する実装も考えましたが、ユーザーが勝手に作ったテーブルまで面倒を見ることになって際限がない。丸ごと捨てるほうが確実でした。
#🔭 今後の拡張
- クエリ履歴 — 実行したものを残して呼び戻せるように
- CSV / JSON のインポート — 自分のデータで試せると用途が一段広がる
- 結果のエクスポート — 集計した表をそのまま持ち出せるように
- スキーマビューア — 今あるテーブルと列を横に表示する
- 永続化 — PGlite は IndexedDB へのデータ保存に対応しているので、タブを閉じても続きから触れる
#💡 このサービスから言えること
「サーバーが要る」と思われている機能が、実はもうブラウザで動く——このケースは思っているより多いはずです。PostgreSQL がその筆頭でした。
サーバーを持たないと決めると、運用コストがゼロになるだけでなく、入力したデータがどこにも送られないという性質がついてきます。業務のスキーマを試しに貼っても、それは自分の端末から出ません。プライバシーの説明が「送っていません」ではなく「送る先がありません」になるのは、かなり強い。
もうひとつ。SQL道場と pg-pad は、どちらも「ブラウザで SQL」ですが役割が違います。学ぶための道具と、確かめるための道具は分けたほうがいい。 1 つのサービスに機能を足して両方に対応させると、どちらの目的にとっても中途半端になります。並べて置いて、必要なほうを選んでもらう。そのほうが結果的に両方使ってもらえる、という感触があります。
[ ./next_action ]
読んだら、 pg-pad を実際に動かす。
この開発ログは pg-pad をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。