MySQL
golang
GORM
softdelete
unique

Gorm+MySQLでUNIQUE制約と論理削除を両立させる方法

UNIQUE制約

DBの特定の列に重複した値が入力されないように制限を掛けることができます。
複数列に1つのUNIQUE制約を掛けることも可能です。(複合UNIQUE制約)

論理削除 (Soft Delete)

DB上の値を実際には削除せずに、deleted_at などの「削除フラグ」を立てることで擬似的に削除を実現することを論理削除といいます。

UNIQUE制約と論理削除の両立

例えば、username カラムに対してUNIQUE制約を掛ける場合、論理削除と併用して使うと、ユーザを削除してもDB上では実際に削除されず残っているので、制約に引っかかってしまい、新しく同じusernameのユーザを作成できなくなってしまいます。

deleted_atusernameに複合UNIQUE制約を掛けても、deleted_atNULL の場合、NULL ≠ NULL と見なされ、制約に引っかからず思ったように動いてくれません。

この場合は、deleted_at などの削除フラグにdefault値を入れ、usernamedeleted_at に複合UNIQUE制約を掛けることで解決することができます。

GormでのUNIQUE制約と論理削除の両立

Gormは、FindScan をする際に、自動で WHERE deleted_at IS NULL を追加してくれるため、論理削除をあまり意識しなくとも利用することができます。
しかし、UNIQUE制約と論理削除を両立させたい場合、WHERE deleted_at = default valueの条件で値を取得してほしいので、これはあまり嬉しくありません。
DBにUNIQUE制約を書けずに、コード側で対処することもできますが、どこかで破綻しそうで好ましくない気がします。
論理削除をやめて物理削除にすれば解決しますが、削除されたデータを追いたい場合もあるので、これも今回の場合は好ましくありません。

Gormのカスタマイズ

GormでUNIQUE制約と論理削除の両立は厳しいのか...? と思いましたが、カスタマイズするための機能が存在しました。(Write Plugins)

deleted_at に default値として、'1970-01-01 00:00:01' を与え、
FindScanが実行されたときに走るCallbackの前に、WHERE deleted_at = '1970-01-01 00:00:01' などの独自のロジックを追加して、通常のWHERE deleted_at IS NULL が走らないようにすれば、UNIQUE制約と論理削除を両立させられそうです。

以下、それを実装したものです。(一部省略しています)


package main

import (
    "fmt"
    "time"
    _ "github.com/go-sql-driver/mysql"
    "github.com/jinzhu/gorm"
)

type User struct {
  Username  string    `json:"username"   gorm:"unique_index:unique_username"`
  DeletedAt time.Time `json:"deleted_at" gorm:"default:'1970-01-01 00:00:01';unique_index:unique_username"`
}

var DB *gorm.DB

func main() {
  DB = newDB
  DB.Callback().Query().Before("gorm:query").Register("new_before_query_callback", newBeforeQueryFunction)
}

func newBeforeQueryFunction(scope *gorm.Scope) {
    var (
        quotedTableName                   = scope.QuotedTableName()
        deletedAtField, hasDeletedAtField = scope.FieldByName("DeletedAt")
        defaultTimeStamp                  = "1970-01-01 00:00:01"
    )

    if !scope.Search.Unscoped && hasDeletedAtField {
        scope.Search.Unscoped = true
        sql := fmt.Sprintf("%v.%v = '%v'", quotedTableName, scope.Quote(deletedAtField.DBName), defaultTimeStamp)
        scope.Search.Where(sql)
    }
}

後は、通常どおり使うことができます。

最後に

間違っている箇所や、もっといい方法などがあれば是非教えてください。