🍆

Supabase + Next.js でバリデーションはどうやるの?

2022/06/25に公開

この記事は「クライアントサイドでデータのCRUD」をする前提で書いています。
サーバーサイドでデータのやりとりをする場合は従来の考え方(PHPなどのようにサーバーサイドで処理するイメージ)で問題ないでしょう。

Supabaseのドキュメントを読んでもINSERT / UPDATEの際のバリデーション方法が全く書かれていない...。Githubのディスカッションやissuesを確認しても、それらしき記述は見つからなかった。(今になって思えば目にしていたが、PostgreSQL童貞には結局どれが正解なのか全く見当すらつかなかった。)

そして、Supabaseを取り扱う記事の多くが何故かバリデーション関連には全く触れていないのです...。あまりにも基礎的すぎる内容だからなのか...?それとも誰もバリデーションする気が無いのか...?とにかく、雑魚にも分かりやすくしてほちぃ><

Supabaseは素晴らしいサービスなので、僕と同じようにPostgreSQL童貞の方々が 「Supabaseわけわからん」とならないように僕が代わりに苦しんでおきました。

苦しんだ結果をここにご報告します。

Next.jsのAPI Routesでバリデーションはしない!(んだと思います)

恐らくNext.js の API Routes でバリデーションを行おうと考える人もいるんじゃないでしょうか。せっかくフロントエンドとバックエンドを切り離して開発できるのに、わざわざ API Routes を介してしまうと負けた気がします。また、恐らくSupabaseの中の人も望んでいないと思います。(Supabaseの良いところはDBに直接データを投げたり、データを取得できるところでもありますもんね!)

ということで、Supabaseに全てお任せすることにしましょう。

Supabaseは発展途上中。GUIで全て完結できると思うな!(そうだと思います)

SupabaseにはRow Level Securityを設定するためのGUIが存在しておりますが、CHECK制約のGUIがありません。ここに僕は苦しめられました。GUIでCRUDに必要な全ての要素が詰まっているものと思っていましたが、バリデーションっぽいものをするにはSQL Editorを使ってSQL文を実行して設定を行う必要があったのです。

ちなみに、RLSでカラム毎にバリデーションを行っていくものだと思っていましたが、それは全くの間違いでした。1つのアクションに対して複数のRLSを設定しても、先に登録したRLSが採用されるだけで、全てのRLSが有効にはなりませんでした。

という紆余曲折ありながら、CHECK制約に辿り着いたのでした。

SupabaseでバリデーションをするならCHECK制約を使おう!(でいいと思います)

Supabaseのデータベースは PostgreSQL です。
PostgreSQLにはCHECK制約という機能があります。

例えば下記のようにしてみましょう。

SQL Editor
alter table public.profiles
add constraint valid_name_length
check (
  LENGTH(name) >= 1
  AND LENGTH(name) <= 30
)

このように記述すると、 public.profiles テーブルに対して valid_name_length という制約の名前をつけて、「1文字以上、30文字以下」の値のみ name カラムに登録できるという制約ができます。

この制約があることで、この条件に合致しない INSERT / UPDATE が来た場合には、例外が発生してDB操作が中断されるようになります。つまり、データベースが値を検証して可否判定をし、判定結果に応じてDB処理が完了することになります。

正規表現を関数化してCHECK制約をかける。

色々触ってみた結果、関数化した方が便利そうでした。
そのまま add constraint ~ check で直書きしてしまうと、上書きができないようでして、一度dropで削除してから、もう一度適用する必要がありました。これはめちゃくちゃ面倒です。

ところが、関数化して適用すると、関数を編集するだけでリアルタイムに処理が変わるような挙動でしたので、関数化した方がかなり便利だと感じました。


SupabaseのGUIからDatabase -> Functionsから regex_japanese という関数を作成しました。

Return type は text で、Argumentsに min_lengthとmax_lengthをint型で指定しました。

Return typeとは、この関数を実行することによってどんな型が返却されるのかを指定します。
Argumentsとは、この関数の引数です。

begin
  return '^[ぁ-んァ-ン一-龠0-9a-zA-Z0-9\-。、ー!"#$%&''()*+-.,\/:;=?@\^_`|~\U00000023,\U0000002A,\U00000030-\U00000039,\U000000A9,\U000000AE,\U0000200D,\U0000203C,\U00002049,\U00002122,\U00002139,\U00002194-\U00002199,\U000021A9-\U000021AA,\U0000231A-\U0000231B,\U00002328,\U000023CF,\U000023E9-\U000023F3,\U000023F8-\U000023FA,\U000024C2,\U000025AA-\U000025AB,\U000025B6,\U000025C0,\U000025FB-\U000025FE,\U00002600-\U00002604,\U0000260E,\U00002611,\U00002614-\U00002615,\U00002618,\U0000261D,\U00002620,\U00002622-\U00002623,\U00002626,\U0000262A,\U0000262E-\U0000262F,\U00002638-\U0000263A,\U00002640,\U00002642,\U00002648-\U00002653,\U0000265F-\U00002660,\U00002663,\U00002665-\U00002666,\U00002668,\U0000267B,\U0000267E-\U0000267F,\U00002692-\U00002697,\U00002699,\U0000269B-\U0000269C,\U000026A0-\U000026A1,\U000026A7,\U000026AA-\U000026AB,\U000026B0-\U000026B1,\U000026BD-\U000026BE,\U000026C4-\U000026C5,\U000026C8,\U000026CE-\U000026CF,\U000026D1,\U000026D3-\U000026D4,\U000026E9-\U000026EA,\U000026F0-\U000026F5,\U000026F7-\U000026FA,\U000026FD,\U00002702,\U00002705,\U00002708-\U0000270D,\U0000270F,\U00002712,\U00002714,\U00002716,\U0000271D,\U00002721,\U00002728,\U00002733-\U00002734,\U00002744,\U00002747,\U0000274C,\U0000274E,\U00002753-\U00002755,\U00002757,\U00002763-\U00002764,\U00002795-\U00002797,\U000027A1,\U000027B0,\U000027BF,\U00002934-\U00002935,\U00002B05-\U00002B07,\U00002B1B-\U00002B1C,\U00002B50,\U00002B55,\U00003030,\U0000303D,\U00003297,\U00003299, \U0000FE0F, \U0001F004,\U0001F0CF,\U0001F170-\U0001F171,\U0001F17E-\U0001F17F,\U0001F18E,\U0001F191-\U0001F19A,\U0001F1E6-\U0001F1FF,\U0001F201-\U0001F202,\U0001F21A,\U0001F22F,\U0001F232-\U0001F23A,\U0001F250-\U0001F251,\U0001F300-\U0001F321,\U0001F324-\U0001F393,\U0001F396-\U0001F397,\U0001F399-\U0001F39B,\U0001F39E-\U0001F3F0,\U0001F3F3-\U0001F3F5,\U0001F3F7-\U0001F4FD,\U0001F4FF-\U0001F53D,\U0001F549-\U0001F54E,\U0001F550-\U0001F567,\U0001F56F-\U0001F570,\U0001F573-\U0001F57A,\U0001F587,\U0001F58A-\U0001F58D,\U0001F590,\U0001F595-\U0001F596,\U0001F5A4-\U0001F5A5,\U0001F5A8,\U0001F5B1-\U0001F5B2,\U0001F5BC,\U0001F5C2-\U0001F5C4,\U0001F5D1-\U0001F5D3,\U0001F5DC-\U0001F5DE,\U0001F5E1,\U0001F5E3,\U0001F5E8,\U0001F5EF,\U0001F5F3,\U0001F5FA-\U0001F64F,\U0001F680-\U0001F6C5,\U0001F6CB-\U0001F6D2,\U0001F6D5-\U0001F6D7,\U0001F6DD-\U0001F6E5,\U0001F6E9,\U0001F6EB-\U0001F6EC,\U0001F6F0,\U0001F6F3-\U0001F6FC,\U0001F7E0-\U0001F7EB,\U0001F7F0,\U0001F90C-\U0001F93A,\U0001F93C-\U0001F945,\U0001F947-\U0001F9FF,\U0001FA70-\U0001FA74,\U0001FA78-\U0001FA7C,\U0001FA80-\U0001FA86,\U0001FA90-\U0001FAAC,\U0001FAB0-\U0001FABA,\U0001FAC0-\U0001FAC5,\U0001FAD0-\U0001FAD9,\U0001FAE0-\U0001FAE7,\U0001FAF0-\U0001FAF6]{'|| min_length ||','|| max_length ||'}$';
end;

Definition に記述した内容は上記の通り。
ひらがな、カタカナ、漢字、英数字、絵文字、min_length(最小文字数)、max_length(最大文字数)に合致したものがtrueとして動きます。(true => バリデーション成功)

GUI上で作成した関数はそのままSQL Editorで使うことができます。

SQL Editor
alter table public.profiles
add constraint valid_regex_name
check (
  name ~ regex_japanese(1, 30)
)

これで上に定めた条件が適用されます。

nameの値を '' という空の状態でPOSTした結果が上記です。
INSERT / UPDATE のタイミングで動き、しっかりと制約が働いていることが分かります。

※なお、INSERT / UPDATEについてはRLSで uid() = id のように書き込み権限を別途付与しておく必要があります。

バリデーション方法は以上で終わり!constraint_nameの管理をどうしようか。

SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = 'public' AND table_name = 'profiles'

で適用中の制約を確認することができます。テーブル毎に表示することができますが、カラム毎に制約を確認することができません。超分かりづらいし、管理が難しいので命名規則が超重要になってきそうです。

例えば、名前の末尾に _カラム名 とするルールを作れば、

SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = 'public' AND table_name = 'profiles' AND constraint_name LIKE '%_カラム名'

で指定したテーブルの指定したカラム名に絞って制約を引っ張り出すことができそうです。

この辺りはどうやって運用するといいかは分からないのでPostgreSQLの大先輩たちに意見を伺いたいところです。

関数の記述を確認したい

https://zenn.dev/link/comments/171d69847f3b23
こちらに書きました。

【2023年11月30日追記】トリガーを使って値を検証するのもアリ!

Supabase(Postgres)のトリガー機能を使って Before のタイミングで値を検証するのもアリです!Next.jsで開発しているのであれば、Server ActionsやAPI Routesを用いてサーバー側で検証しても良いでしょう。またはSupabaseのEdge Functionsでも可。しかし、僕はアプリケーションサーバー側でリソースを使いたくないタイプの人なので、トリガーを用いて値を検証することにしています。

Discussion