保存前に同名を確認したら、DBのUNIQUEは不要?
タグの保存前確認とDBの一意性は、別の場所で重複を防ぎます。実TagServiceとマイグレーションを読み、完全に分離したPostgreSQLの架空テーブルで大文字・小文字の重複拒否を試しました。
CONTENTSこの記事の目次5項目
タグ作成の実装には、同じ名前がないかを確認する処理があります。それなら、DB側の一意インデックスはなくてもよいのでしょうか。定義を読むと、DBにもLOWER(name)の重複を許さない指定がありました。専用の架空テーブルでは、アプリの確認を通さずに追加しても重複を拒否できました。
保存前の確認は、利用者へ理由を返す入口
TagServiceは、入力した名前の前後の空白を取り除き、大文字・小文字を区別しない存在確認を行います。すでにあれば、同名のタグがあるという例外を返し、通常の保存へ進みません。 この確認は、操作の早い段階で理由を伝えるために役立ちます。ただし、ある時点で「ない」と確認したことと、後で保存する瞬間にも重複がないことは同じではありません。
確認と保存の間に、別の処理が入る可能性
たとえばAとBが同時に同じ名前を作ろうとする架空の状況を考えます。AもBも、まだ保存されていない時点で存在確認をすると、両方が「ない」と判断する可能性があります。 この時間差の説明は、競合の概念例です。今回は実TagServiceを二つ同時に走らせた試験ではありません。DBへ直接入れる小さな試験で、保存側の制約の役割を確かめます。
隔離した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日。実装・検証結果・公式資料は確認時点のものです。