Rails + PostgreSQL で論理削除と一意性制約を両立させる

# はじめに

最近読んだ @yuba さんの [論理削除と一意性制約を両立させる方法・DB製品別](https://qiita.com/yuba/items/70165875cfe02b03513d) という記事で、**部分インデックス** というインデックスについて知ることができました。そこで今回、Rails + PostgreSQL の環境でこの部分インデックスを活用するには具体的にどうすればいいかをまとめました。

# TL;DR

[add_index](http://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_index) の `where` オプションを使う。

```rb
add_index :users, :email, unique: true, where: 'deleted_at IS NULL'
```

# 問題

論理削除の機能を使った User モデルがあるとします。`deleted_at` 列に (削除された際の) 日付が設定された場合に論理削除されていると見なします。ここでは [paranoia](https://github.com/rubysherpas/paranoia) という Gem を使用していることを想定します。ただし、この記事では Gem を使用するかどうかやその種類は重要ではありません。

次のマイグレーションでは `users` テーブルの `email` 列に対して UNIQUE インデックスを作成しています。

```db/migrate/20171024065340_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :name, null: false
      t.datetime :deleted_at, index: true
      t.timestamps
    end

    add_index :users, :email, unique: true
  end
end
```

```app/models/user.rb
class User < ApplicationRecord
  # 論理削除を使用する。
  acts_as_paranoid
end
```

UNIQUE インデックス (一意性制約) により、同じメールアドレスを持つ User レコードを作成できません。 

```rb
maho = User.create(name: '西住 まほ', email: 'nishizumi@example.com')
#=> #<User:0x00007fd7b6dd72b0
# id: 1,
# email: "nishizumi@example.com",
# name: "西住 まほ",
# deleted_at: nil,
# created_at: Tue, 24 Oct 2017 16:05:46 JST +09:00,
# updated_at: Tue, 24 Oct 2017 16:05:46 JST +09:00>

User.create(name: '西住 みほ', email: 'nishizumi@example.com')
# ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email"
```

既に同じメールアドレスを持つレコードを (物理削除の挙動を論理削除にオーバーライドされた) `destroy` メソッドで削除しても結果は同じです。ここでの削除はあくまで論理削除であり、同じメールアドレスのレコードは依然としてデータベースに存在するためです。どうしても作成したい場合は、論理削除されたレコードをさらに物理削除するしかありません。

```rb
maho = User.find_by(name: '西住 まほ', email: 'nishizumi@example.com')
maho.destroy
#=> #<User:0x00007fd7b6dd72b0
# id: 1,
# email: "nishizumi@example.com",
# name: "西住 まほ",
# deleted_at: Tue, 24 Oct 2017 16:07:17 JST +09:00,
# created_at: Tue, 24 Oct 2017 16:05:46 JST +09:00,
# updated_at: Tue, 24 Oct 2017 16:07:17 JST +09:00>

User.count
#=> 0

# 論理削除されたレコードの中に email: nishizumi@example.com のものが存在するため
# 作成に失敗する。
User.create(name: '西住 みほ', email: 'nishizumi@example.com')
# ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email"
```

そこで **論理削除されていないレコードのみに一意性制約を適用** したいです。

# 解決方法

マイグレーションの `add_index` の引数を次のように更新します。

```db/migrate/20171024065340_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :name, null: false
      t.datetime :deleted_at, index: true
      t.timestamps
    end

    # where オプションを追加した。
    add_index :users, :email, unique: true, where: 'deleted_at IS NULL'
  end
end
```

こうすることで `deleted_at` 列が `NULL` のレコード、すなわち論理削除されていないレコードにのみ UNIQUE インデックス (一意性制約) が適用されます。

```rb
maho = User.create(name: '西住 まほ', email: 'nishizumi@example.com')
#=> #<User:0x00007f87a5931de8
# id: 1,
# email: "nishizumi@example.com",
# name: "西住 まほ",
# deleted_at: nil,
# created_at: Tue, 24 Oct 2017 16:31:33 JST +09:00,
# updated_at: Tue, 24 Oct 2017 16:31:33 JST +09:00>

User.create(name: '西住 みほ', email: 'nishizumi@example.com')
# ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email"

maho.destroy
#=> #<User:0x00007f87a5931de8
# id: 1,
# email: "nishizumi@example.com",
# name: "西住 まほ",
# deleted_at: Tue, 24 Oct 2017 16:31:48 JST +09:00,
# created_at: Tue, 24 Oct 2017 16:31:33 JST +09:00,
# updated_at: Tue, 24 Oct 2017 16:31:48 JST +09:00>

User.count
#=> 0

# 論理削除されたレコードの中に同じメールアドレスのものが存在するが、
# 部分インデックスの効果で作成に成功する。
User.create(name: '西住 みほ', email: 'nishizumi@example.com')
#=> #<User:0x00007f87a5a42390
# id: 2,
# email: "nishizumi@example.com",
# name: "西住 みほ",
# deleted_at: nil,
# created_at: Tue, 24 Oct 2017 16:32:02 JST +09:00,
# updated_at: Tue, 24 Oct 2017 16:32:02 JST +09:00>

# 論理削除されているレコードを含めた User レコードを取得する。
# 同じメールアドレスを持つレコードが 2 件存在する。
User.with_deleted
#=> [#<User:0x00007f87a174cd88
#  id: 1,
#  email: "nishizumi@example.com",
#  name: "西住 まほ",
#  deleted_at: Tue, 24 Oct 2017 16:31:48 JST +09:00,
#  created_at: Tue, 24 Oct 2017 16:31:33 JST +09:00,
#  updated_at: Tue, 24 Oct 2017 16:31:48 JST +09:00>,
# #<User:0x00007f87a1739198
#  id: 2,
#  email: "nishizumi@example.com",
#  name: "西住 みほ",
#  deleted_at: nil,
#  created_at: Tue, 24 Oct 2017 16:32:02 JST +09:00,
#  updated_at: Tue, 24 Oct 2017 16:32:02 JST +09:00>]
```

# 参考

## Qiita

* [論理削除と一意性制約を両立させる方法・DB製品別](https://qiita.com/yuba/items/70165875cfe02b03513d)

## Rails

* [ActiveRecord::ConnectionAdapters::SchemaStatements#add_index](http://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_index)

## PostgreSQL

* [PostgreSQL 9.6.5文書 » 11.8. 部分インデックス](https://www.postgresql.jp/document/9.6/html/indexes-partial.html)