🚫

[PostgreSQL・Go] CHECK 制約・EXCLUDE 制約,色んな制約を使ってみた

2024/03/08に公開

これは何?

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