6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Postgresql だけで SaaS を作れるのかを検証してみた話

Posted at

どうも、Postgresql に一晩いじめられてた Xu です。

以前から考えていたことがございました。

「実はユーザーが本当に欲しいのは、処理済みのデータであって、製品ではない。」

一見、何当たりのことを言ってるんだ、誰もお金が嫌いだからわざわざ決して安くない料金を支払って製品を買っているわけじゃない、でも現実的に製品がそのデータ処理機能を提供してるんだから結局は一緒でしょ。と思うかもしれません。

しかし、ユーザーは別に製品、つまり「データ処理能力自体」が欲しいわけではなくて、「処理済みデータ」を便利な魔法でも忠実な使用人でもなんでも誰でもいいからポンと出して欲しいだけです。

似たような話でマークベニオフ氏の「顧客はソフトを買いたいのではなく使いたいのだ」や、「最強の UI は Excel」とかもありますよね。

つまり、データはすべての中心で、データありきな業界だったのでは?

そうだ、そのはずだ、

情報技術は何もないところに対して価値はない

IT は元からほかの業界をサポートする立場にいたはずだ、いつの間にか最もバブル作ってるけど。

データがすべてで、データこそがすべてだ。
ならば、データに最も近いのはどこだ、データベースだ!

もしもデータベースだけですべてが完結するなら、それこそが最高のリソース管理であり、最強のパフォーマンスチューニングであって、クラウドネイティブの時代最終のソリューションなのでは?

Postgresql で Web アプリのほとんどの機能を提供することができると聞いたことがあります。

技術は取り入れた時点でいずれは負債になります。
個人開発者にとって保守コストが増えるのはかなりしんどいので、基本的に技術スタックはむやみに増やさないのが個人的なポリシーです。
特別ビジネス需要があるか、今使っている複数の技術をカバーする新しい技術が生まれたときだけ乗り移りという形式で取り入れます

一つの技術スタックで今抱えている負債を半数以上切り捨てれるならどんなに素晴らしいだろうか。

というわけで、今回は検証として、Postgresql だけで ToDo SaaS を作り、PostgREST で API 公開するところまでやろうと思います。

全体構成

Client (curl / browser)
   ↓ HTTP
PostgREST
   ↓ SQL
PostgreSQL

① PostgreSQL 側の準備

拡張機能のインストール

create extension if not exists pgcrypto;

スキーマ分離

PostgREST では schema = API の境界であるため、専用スキーマを作成します。

CREATE DATABASE todoapp;
\c todoapp
create schema todoapi;

これで外部公開するものは todoapi スキーマのみに制限できます。

ユーザーテーブルの作成

create table todoapi.users (
  id uuid primary key default gen_random_uuid(),
  email text unique not null,
  password_hash text not null,
  created_at timestamptz default now()
);

ToDo テーブルの作成

create table todoapi.todos (
  id uuid default gen_random_uuid() primary key,
  user_id uuid not null,
  title text not null,
  body text,
  created_at timestamptz default now()
);

認証

create extension if not exists pgcrypto;

-- パスワード保存
insert into users (email, password_hash)
values (
  'test@example.com',
  crypt('password123', gen_salt('bf'))
);

-- 認証チェック
select id
from users
where email = 'test@example.com'
and password_hash = crypt('password123', password_hash);

② Row Level Security(RLS) とポリシー作成

RLS の有効化

alter table todoapi.todos enable row level security;

ポリシーの作成

自分の ToDo のみ参照可能にするポリシーを作成します。

create policy todo_owner_select
on todoapi.todos
for select
using (user_id = current_setting('request.jwt.claim.sub')::uuid);

create policy todo_owner_modify
on todoapi.todos
for insert with check (user_id = current_setting('request.jwt.claim.sub')::uuid);

create policy todo_owner_update
on todoapi.todos
for update using (user_id = current_setting('request.jwt.claim.sub')::uuid);

認証と機能用のポリシー

CREATE POLICY todos_authenticated
ON todoapi.todos
FOR INSERT
TO authenticated
WITH CHECK (
  user_id = current_setting('request.jwt.claim.sub', true)::uuid
);

-- SELECT
CREATE POLICY todos_select_own
ON todoapi.todos
FOR SELECT
TO authenticated
USING (
  user_id =
    (current_setting('request.jwt.claims')::json ->> 'sub')::uuid
);

-- INSERT
CREATE POLICY todos_insert_own
ON todoapi.todos
FOR INSERT
TO authenticated
WITH CHECK (
  user_id =
    (current_setting('request.jwt.claims')::json ->> 'sub')::uuid
);

③ Function

認証用 (ログイン)

PostgRESTは FunctionをRPCとして公開できます。

create or replace function todoapi.login(
  p_email text,
  p_password text
) returns json as $$
declare
  v_user todoapi.users;
begin
  select *
  into v_user
  from todoapi.users
  where email = p_email
    and password_hash = crypt(p_password, password_hash);

  if not found then
    raise exception 'invalid login';
  end if;

  return json_build_object(
    'user_id', v_user.id
  );
end;
$$ language plpgsql security definer;

Insert 時に user_id を自動注入

CREATE OR REPLACE FUNCTION todoapi.set_user_id()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = todoapi
AS $$
DECLARE
  v_sub uuid;
BEGIN
  v_sub :=
    (current_setting('request.jwt.claims', true)::json ->> 'sub')::uuid;

  IF v_sub IS NULL THEN
    RAISE EXCEPTION 'JWT sub is missing';
  END IF;

  NEW.user_id := v_sub;
  RETURN NEW;
END;
$$;

トリガー

create trigger set_user_id_trigger
before insert on todoapi.todos
for each row
execute function todoapi.set_user_id();

④ JWT発行 (外部)

JWT 発行での Type 不一致で永遠に時間を取られてました。
今回は Windows 機で開発をしているため、残念なことにどうも sign(json, text) は使えなさそうです。
pgcrypto だけで JWT(HS256)を自作する方法も試してみましたが、うまくいかなかったので、泣く泣く Postgresql 外で生成します。

あまりよろしくないですが、あくまでテスト用なので、シークレットを todoapp_jwt_secret_2026_super_secure_key とします。

pnpm init
pnpm install jsonwebtoken
import jwt from "jsonwebtoken";

const secret = "todoapp_jwt_secret_2026_super_secure_key";

const userId = process.argv[2];
if (!userId) {
  console.error("user_id required");
  process.exit(1);
}

const token = jwt.sign(
  {
    sub: userId,
    role: "authenticated"
  },
  secret,
  { expiresIn: "1d" }
);

console.log(token);

ちなみに sign(json, text) の導入は

create extension if not exists pgjwt;

しかし、これは OS レベルで拡張がインストールされていないと不可能

⑤ PostgREST 用ロール

create role authenticator
  noinherit
  login
  password 'auth';
create role anon nologin;
create role authenticated nologin;

権限設定

grant usage on schema todoapi to anon, authenticated;
grant select, insert, update, delete on todoapi.todos to authenticated;
grant usage on all sequences in schema todoapi to authenticated;
grant select on todoapi.todos to anon;
grant execute on function todoapi.login(text, text) to anon;
grant anon to authenticator;
grant authenticated to authenticator;

⑥ PostgREST 設定ファイル

postgrest.conf

db-uri = "postgres://authenticator:auth@localhost:5432/todoapp"
db-schemas = "todoapi"
db-anon-role = "anon"

jwt-secret = "todoapp_jwt_secret_2026_super_secure_key"
jwt-secret-is-base64 = false
jwt-role-claim-key = ".role"
jwt-claims = "claims"
jwt-claims-path = "."

⑦ RESTful API サーバー起動

まずは PostgREST の GitHub リポジトリーから、最新の PostgREST の windows-x86-64.zip ファイルをダウンロードします。

image.png

⑥ で作成した postgrest.conf と同じディレクトリに置き、ターミナルから起動。

.\postgrest.exe postgrest.conf

動作確認

ログイン

  • Request
curl -X POST http://localhost:3000/rpc/login \
  -H "Content-Type: application/json" \
  -d '{"p_email":"test@example.com","p_password":"password"}'
  • Response
{"user_id" : "d0c465fb-b012-4104-9c1c-995940e51996"}

JWT発行

node issue-jwt.js d0c465fb-b012-4104-9c1c-995940e51996
  • Result
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkMGM0NjVmYi1iMDEyLTQxMDQtOWMxYy05OTU5NDBlNTE5OTYiLCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImlhdCI6MTc2ODQ4OTA0OSwiZXhwIjoxNzY4NTc1NDQ5fQ.Ku0ciFEq2ZW-ZzWaZV8b-WYAyDqT8RfYiPkd9U9VqKQ

PostgREST に ToDo を渡して挿入

curl -X POST http://localhost:3000/todos \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkMGM0NjVmYi1iMDEyLTQxMDQtOWMxYy05OTU5NDBlNTE5OTYiLCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImlhdCI6MTc2ODQ4OTA0OSwiZXhwIjoxNzY4NTc1NDQ5fQ.Ku0ciFEq2ZW-ZzWaZV8b-WYAyDqT8RfYiPkd9U9VqKQ" \
  -H "Content-Type: application/json" \
  -d '{"title":"記事を書く","body":"Qiita記事を書く"}'

ここで注意!INSER が成功しても Response 返さない時あります。
HTTP ステータスを確認するか、DB に入って確認するかしましょう。

結果確認

SELECT id, title, body, user_id, created_at
FROM todoapi.todos
ORDER BY created_at DESC
LIMIT 5;
722d05dc-3f58-41e4-896d-bd9c9861f117|記事を書く|Qiita記事を書く|d0c465fb-b012-4104-9c1c-995940e51996|2026-01-15 21:36:34.541533+09

無事 ToDo が作成されましたね!

後書き

Postgresql のポリシーと細かい仕様本当に混乱しますよね、まるで地獄のようなデバッグと何回も DB ごと消して構成を考え直しました。
JWT だけはどうしてもうまくいかなかったのはご愛嬌ってことで...

冒頭で想定していたことは何一つ実現できそうにないけど、もう二度とやることないと思います(笑)。

そんなわけで今回は半分メモのような形式ですが、これにて失礼いたします。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?