FEATUREわかったつもりの、その先へ
知る・学ぶ

保存前に同名を確認したら、DBのUNIQUEは不要?

タグの保存前確認とDBの一意性は、別の場所で重複を防ぎます。実TagServiceとマイグレーションを読み、完全に分離したPostgreSQLの架空テーブルで大文字・小文字の重複拒否を試しました。

CONTENTSこの記事の目次5項目

タグ作成の実装には、同じ名前がないかを確認する処理があります。それなら、DB側の一意インデックスはなくてもよいのでしょうか。定義を読むと、DBにもLOWER(name)の重複を許さない指定がありました。専用の架空テーブルでは、アプリの確認を通さずに追加しても重複を拒否できました。

保存前の確認は、利用者へ理由を返す入口

TagServiceは、入力した名前の前後の空白を取り除き、大文字・小文字を区別しない存在確認を行います。すでにあれば、同名のタグがあるという例外を返し、通常の保存へ進みません。 この確認は、操作の早い段階で理由を伝えるために役立ちます。ただし、ある時点で「ない」と確認したことと、後で保存する瞬間にも重複がないことは同じではありません。

確認と保存の間に、別の処理が入る可能性

たとえばAとBが同時に同じ名前を作ろうとする架空の状況を考えます。AもBも、まだ保存されていない時点で存在確認をすると、両方が「ない」と判断する可能性があります。 この時間差の説明は、競合の概念例です。今回は実TagServiceを二つ同時に走らせた試験ではありません。DBへ直接入れる小さな試験で、保存側の制約の役割を確かめます。

実マイグレーションと同じLOWER(name)の一意条件を持つ、専用の架空テーブルによる試験です。実サービスの同時実行試験ではありません。

隔離したDBへ、同じ名前を直接追加する

ネットワーク接続も既存ボリュームとの共有もない、専用のPostgreSQL 17を使いました。整数IDと名前だけの架空テーブルを作り、LOWER(name)に一意インデックスを設定します。実CMSのタグ表は使っていません。 最初にWebを保存し、次に別IDのweb、さらに別IDのWebを追加しました。結果は次の通りです。

専用テーブルへの直接追加
入力した名前結果エラーの分類
最初のWeb保存できたなし
別IDのweb拒否された23505:一意性違反
別IDのWeb拒否された23505:一意性違反

二回の拒否後もタグの行数は1でした。IDを変えても、制約が比べるのはLOWER(name)の値なので、名前側の重複は通りません。

LOWERだけで、あらゆる表記ゆれが同じになるわけではない

今回の試験はASCIIのWebとwebです。LOWER(name)を使うことは、前後の空白や全角・半角など、すべての違いをまとめて消す意味ではありません。 実サービスのtrimはアプリ側の処理で、一意インデックス自体はLOWER(name)を対象にしています。どの段階がどの正規化をするかを分けます。文字種や照合条件を変えた網羅試験は行っていません。

アプリの案内と、DBが守る条件を組み合わせる

保存前の確認は分かりやすい応答のため、DBの制約は保存する値の条件を守るために役立ちます。片方があるからもう片方の意味がなくなる、とは限りません。 また、DBが拒否できても、そのエラーを利用者へどう返すかは別の設計です。今回確かめたのは重複がDBへ保存されないことまでで、競合時の実API応答まで確認したものではありません。

参照:PostgreSQL 17公式「Constraints」「Indexes on Expressions」。実TagServiceとV31マイグレーションを読み、専用モデルで制約を実行しました。

確認日:2026年9月22日。実装・検証結果・公式資料は確認時点のものです。