個人開発においては、費用負担をせずにデータを保存したいことが多いかと思います。私も例に漏れず、Firebaseができた当時より、Realtime Databaseを無料枠の範囲内で使用していました。最近、FirebaseのNoSQLからSupabaseのSQLに移行しましたので、その話をさせていただきます。
はじめに
個人開発で家計簿WEBアプリを作っていました。
以下の要件を満たすものをイメージいただければと思います。(実際にはもっと複雑です)
- 1つの会計ごとに支出の記録ができる
- 「記録」の際、ユーザは、金額、カテゴリ、支払い方法、メモを入力する
- 「カテゴリ」と「支払い方法」は、あらかじめ自分で設定した選択肢から選ぶ
- 以下の情報をみることができる
- 任意の年月に、その月全体の支出に対して、どのカテゴリがどの程度の割合を占めているか(円グラフで表示)
- 「支払い方法」、「カテゴリ」は設定画面から追加・編集・削除できる
説明の便宜上、3つの要件をそれぞれ満たす画面を、上から「記録画面」「集計画面」「設定画面」として説明することとします。
Firebase Realtime Database
Firebase Realtime Database は、JSON 形式で値を保存できるデータベースになります。
Realtime Database では、SQL でできるような、テーブルの外部結合や、複雑なクエリ操作ができません。
そのため、要件を満たすために以下のような構造としていました。
{
"records": {
"0": { // 0 から始まるインデックス
"price": 500,
"category_id": 1,
"method_id": 100,
"note": "弁当代",
"created_at": "2022-01-01 12:00:00"
},
"1": {
"price": 210,
"category_id": 2,
"method_id": 100,
"created_at": "2022-01-01 19:00:00"
},
},
"categories": {
"0": { // 0 から始まるインデックス
"id": 1,
"name": "食費",
},
"1": {
"id": 2,
"name": "交通費",
}
},
"methods": {
"0": { // 0 から始まるインデックス
"id": 100,
"name": "現金",
}
},
"summary": {
"2022-10": {
"1": 500, // key は category_id に対応する
"2": 210,
}
}
}
このデータを、画面側では以下のように使っていました。
- 記録画面
- 初期表示として、”categories”や”methods” を取得する
- ユーザが記録を行うたびに、以下を行う
- “records” に新しい要素を追加する。
- このとき、”method_id”や”category_id”には、”categories”や”method”から取得してきた”id”を割り振る
- 集計用のデータ”summary”を更新する。
- このとき、既存の”records”をもとに、期間ごと、カテゴリごとに集計し階層構造を決定する
- “records” に新しい要素を追加する。
- 集計画面
- 初期表示として、”summary”を取得する。
- 設定画面 – 初期表示として、”categories”と”methods”を取得する。
- “categories”の要素と”methods”の要素を、それぞれ登録・編集・削除できるようにする
- このときも、集計用のデータ”summary”を更新する
- “categories”の要素と”methods”の要素を、それぞれ登録・編集・削除できるようにする
集計画面については、「”created_at”が該当月である”records”の要素を取得し、フロント内部でソート・グループ化を行い、画面に表示させる」こともできましたが、「データベースの状態が変わるタイミングで集計データ”summary”を更新する。集計画面では表示のみを行う」という方針をとっていました。
私が「家計簿WEBアプリを作ろう」と思った段階では、無料でSQLを使用できるサービスがなく、イマイチだと思いながら、以下のように運用していました。
- データに対しての簡単な取得操作、登録操作、更新操作、削除操作は、用意されたメソッド呼び出し(例)で行う
- 複雑な絞り込みやソート、グループ化が施されたデータを取得したい場合は、JavaScript 内で行う
Supabase PostgreSQL
Supabase が最近注目され、重たい腰をあげてSQLに移行しました。
具体的には、データベースを以下の構造にするようにしました。
CREATE TABLE categories (
id SERIAL NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE methods (
id SERIAL NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE records (
price INT NOT NULL,
category_id INT NOT NULL,
method_id INT NOT NULL,
note TEXT,
created_at DATETIME NOT NULL DEFAULT NOW(),
FOREIGN KEY (category_id) REFERENCES categories(id),
FOREIGN KEY (method_id) REFERENCES methods(id)
);
簡単なCRUD処理は、Supabase が用意してくれるメソッド呼び出しで解決できます。例としては以下です。
await supabase.from('todos').insert({
'price': 500,
'category_id': 1,
'method_id': 1,
'note': null,
});
Firebase Realtime Database で使用していた”summary”については、RPCを用いて以下のクエリを呼び出すことにより、代替することができました。
WITH summarized_records AS (
SELECT DISTINCT
records.category_id,
sum(price) AS sum
FROM records
WHERE
-- input_year と input_month にはそれぞれ、集計したい年と月を指定する
TO_CHAR(CAST(created_at AS DATE), 'YYYY-MM') = input_year || '-' || input_month
GROUP BY category_id
)
SELECT
categories.name AS category_name,
summarized_records.sum
FROM summarized_records
INNER JOIN categories ON
categories.id = summarized_records.category_id
ORDER BY summarized_records.sum DESC
;
その他の複雑なデータ操作についてもRPCで行うことができます。
おわりに
Supabase を採用することで、当初フロント側で持っていたデータ操作ロジックを、バックエンド側に持たせることができました。
もともとNoSQLで管理する必要がないデータ構造であったため、SQLを用いてデータ操作・移行作業ができるようになったのもメリットです。
また、`firebase/database` のサイズよりも、`supabase/supabase-js` のサイズの方が小さいため、バンドルサイズの削減にも寄与しました。