[PostgreSQL・Go] CHECK 制約・EXCLUDE 制約,色んな制約を使ってみた
これは何?
PostgreSQL の機能であるConstraintsをプロジェクトで実際に使ってみたので,制約の書き方やエラーハンドリングの方法を残しておく.
導入した背景
POST 時に go-validator でリクエストのバリデーションはしているし,entity の生成時には引数のバリデーションもしているけれど,データベースレベルで何もしていないの不安だなぁ・・・
過渡期に手作業で変なレコードが挿入されて不具合が起きると困る・・
そうだ!Constraints をうまく使えないだろうか!使ったことないし,やってみよう!
条件設定
開発環境
- 開発言語
- Go
- 使用するライブラリ
- gorm.io/gorm (DB操作)
- cockroachdb/errors (エラーハンドリング)
- jackc/pgerrcode (エラーハンドリング)
- jackc/pgx/v5/pgconn (エラーハンドリング)
.sql ファイル上で,↓のテーブル定義に追記する形で制約を課すとします
-- ユーザーのライセンスを管理するテーブル
CREATE TABLE "licenses" (
"id" UUID PRIMARY KEY,
"user_id" TEXT NOT NULL,
"product_id" UUID NOT NULL,
"type" TEXT NOT NULL, -- ライセンスの種類.通常・無料トライアルといった種類がある.
"expires_at" TIMESTAMPTZ NOT NULL, -- ライセンスの有効期限
"created_at" TIMESTAMPTZ NOT NULL, -- レコード作成日時
);
実際にやってみた
CHECK 制約
使用したモチベーション
テーブルにあるレコードの type
が「特定の文字列の中のいずれか」であることを保証したいなぁ
制約の書き方
-- ユーザーのライセンスを管理するテーブル
CREATE TABLE "licenses" (
....
"type" TEXT NOT NULL, -- ライセンスの種類
....
CHECK (type IN ('normal', 'oneTimeTrial')), -- 通常,無料トライアル
);
エラーハンドリング
pgerrcode には Postgres で起こるエラーコードが網羅されているので,
それで判定を行います.
// type の CHECK制約を破るレコードを作ろうとする
badLicense := Linsence{
....
Type: "hogehoge",
....
}
result := db.Create(&badLicense)
if err := result.Error; err != nil {
pgErr := new(pgconn.PgError)
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.CheckViolation {
return errors.New("check error!")
}
return err
}
感じたメリットやデメリット
スペルミスのある type
が INSERT されないことが保証されるのはありがたかった.
ただ,文言に変更があった場合や新しい type
が追加された場合, CHECK 制約を書き直すまで挿入ができないのが少し面倒くさい.しかし,それだけ一貫性が保たれている証でもある.
EXCLUDE 制約
使用したモチベーション
ライセンスの重複を防ぎたいなぁ〜.
より正確にいうと,ユーザーが有効期間内の商品Aのライセンスを既に保持している場合,新たに商品Aのライセンスを付与できないようにしたい.
制約の書き方
-- ユーザーのライセンスを管理するテーブル
CREATE TABLE "licenses" (
....
"expires_at" TIMESTAMPTZ NOT NULL, -- ライセンスの有効期限
"created_at" TIMESTAMPTZ NOT NULL, -- レコード作成日時
EXCLUDE USING gist (
"user_id" WITH =,
"user_id" WITH =,
tstzrange("created_at", "expires_at", '[)') WITH &&
)
....
);
エラーハンドリング
result := db.Create(&badLicense)
if err := result.Error; err != nil {
pgErr := new(pgconn.PgError)
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ExclusionViolation {
return errors.New("exclude error!")
}
return err
}
感じたメリットやデメリット
「期間重複してるかどうか確かめる処理」をわざわざ用意しなくても,
POST APIを処理するときに,ExclusionViolation
エラーが返ってきたら「ライセンスが重複してます」エラーをクライアントに返せば良いだけなのは楽だった.
使用するには, btree_gist という EXTENSION を導入する必要があるが,言ってしまえばデメリットはそれくらいしか感じられなかった.
条件付き UNIQUE インデックス
制約とは微妙に異なるが,まとめて書いておく.
Partially Indexes という機能を使用した.
使用したモチベーション
商品の特定のライセンスは,1ユーザーに対して一度だけ付与できるようにしたい
例えば,商品は一度だけ無料でトライアルできて,それ以降は有料にしたいときを想定する.
無料トライアルライセンスは商品に対してユーザーに一度だけ付与できるようにしたい
制約の書き方
-- ユーザーのライセンスを管理するテーブル
CREATE TABLE "licenses" (
...
"user_id" TEXT NOT NULL,
"product_id" UUID NOT NULL,
"type" TEXT NOT NULL, -- ライセンスの種類.通常・無料トライアルといった種類がある.
...
);
-- 無料トライアルライセンスは, 商品に対してユーザーに一度だけ付与できる
CREATE UNIQUE INDEX "partial_unique_index"
ON "licenses" ("user_id", "product_id")
WHERE "type" == 'oneTimeTrial';
エラーハンドリング
今回は ErrDuplicatedKey
が gorm に定義されているので
そのまま使いました.
result := db.Create(&badLicense)
if err := result.Error; err != nil{
if errors.Is(err, gorm.ErrDuplicatedKey) {
return errors.New("unique error!")
}
return err
}
感じたメリットやデメリット
柔軟にユニーク制約をかけられるのはありがたかった.
最後に感想
挿入できないレコードを明示するというのは,整合性を保つ上で非常に役立つと思いましたが,
下手な制約を課してしまうと,余計に面倒くさいことになるので,そこは注意しないといけないなぁと思いました.
できれば今後も使っていきたい.
Discussion