はじめに

Rails で Devise と論理削除を両立する方法。

TL;DR

この記事が参考になった方
ここここからチャージや購入してくれると嬉しいです(ブログ主へのプレゼントではなく、ご自身へのチャージ)
欲しいもの / Wish list

目次

  1. はじめに
  2. TL;DR
  3. 環境・条件
  4. 詳細
    1. 論理削除の導入
    2. 論理削除されたユーザーのログインを無効化
    3. DB ユニーク制約 再設定
    4. バリデーション定義
  5. まとめ
  6. その他・メモ
  7. 参考文献

環境・条件

1
2
3
4
5
6
7
8
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 10 (buster)"

$ ruby -v
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]

$ rails -v
Rails 6.0.3.4
1
2
3
4
mysql> status;
--------------
mysql Ver 14.14 Distrib 5.7.33, for Linux (x86_64) using EditLine wrapper
Server version: 5.7.33 MySQL Community Server (GPL)

詳細

論理削除の導入

参考: Railsで論理削除(soft delete)を実装する(discard gem利用) - Qiita

いくつか Gem があるが jhawthorn/discard を採用した。

  • rubysherpas/paranoia
    • 上記 Qiita にも書かれている新規導入には非推奨
  • ActsAsParanoid/acts_as_paranoid
    • paranoia のオリジナル。destroy などがオーバーライドされるので同様の理由で非推奨(という認識)
    • あと試したらレコード削除時になんかエラーになった(内容はメモし忘れた)

README に従ってセットアップすれば OK。

Gemfilegem 'discard', '~> 1.2' を追記。

1
gem 'discard', '~> 1.2'

bundle install でインストール。

1
$ bundle install

論理削除用のカラムを追加。

1
2
$ rails g migration add_discarded_at_to_users discarded_at:datetime:index
$ rails db:migrate

モデルに include Discard::Model を追加

1
2
3
4
class User < Activerecord::Base
include Discard::Model # ***** 追加
# 略
end

user.discard で論理削除したりが可能になる、以下は主要なもの。その他は Usage を参照。

1
2
3
4
5
6
7
8
9
10
user.discard    # 論理削除(discarded_at に現在日時が設定される)
user.discard! # 同上。例外を投げるかどうかが異なる
user.undiscard # 論理削除取り消し(discarded_at をクリア)
user.undiscard! # 同上。例外を投げるかどうかが異なる
user.discarded? # 論理削除済みかどうか(true or false)
user.kept? # 有効かどうか(true or false)

User.all # すべて
User.kept # 論理削除されていないレコード
User.discarded # 論理削除されているレコード

論理削除されたレコードをデフォルトで除外したい場合は、デフォルトスコープを設定する。

1
2
3
4
class User < Activerecord::Base
include Discard::Model
default_scope -> { kept }
end

デフォルトスコープを設定した場合は、対象の抽出時に注意。

1
2
3
4
User.all                       # 論理削除されていないレコード
User.kept # 論理削除されていないレコード
User.with_discarded # すべて
User.with_discarded.discarded # 論理削除されているレコード

論理削除されたユーザーのログインを無効化

参考: Working with Devise

active_for_authentication? をオーバーライドして、論理削除されていないユーザーのみがログイン可能に変更。(公式では super && !discarded? だけど、 !discarded? が二重否定みたいな感じがするので kept? に変更した。)

1
2
3
4
5
6
7
class User < Activerecord::Base
include Discard::Model
# 略
def active_for_authentication?
super && kept?
end
end

DB ユニーク制約 再設定

MySQL 前提

参考:

email に対してユニーク制約が設定されているので、Generated Column を追加して再設定する。MySQL と Generated Column については MySQL でユニーク制約と論理削除を同時に実現する方法 を参照。

1
$ rails g migration reset_unique_constraint_to_users

Qiita 記事では up の中で execute "ALTER TABLE ... としていたが、as でも設定できる。
(Rails のバージョンによっては使えないかも)

1
2
3
4
5
6
7
8
9
10
11
12
13
class ResetUniqueConstraintToUsers < ActiveRecord::Migration[6.0]
def up
remove_index :users, :email
add_column :users, :active_email, :string, as: 'CASE WHEN `discarded_at` IS NULL THEN `email` ELSE NULL END', after: :email
add_index :users, :active_email, unique: true
end

def down
remove_index :users, :active_email
remove_column :users, :active_email
add_index :users, :email, unique: true
end
end

バリデーション定義

Devise の設定とかソースコードを見た限りでは、validatable の挙動変更はできないようなので、自前で定義する。

  • active_email はレコード保存後に有効になるので on: :update に制限
  • email_required? を定義
    • email のユニーク検証は scoe: :discarded_at を追加
  • 類似の active_email_required? を追加
  • password_required? を定義
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class User < Activerecord::Base
include Discard::Model

devise :database_authenticatable # validatable があれば削る

# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L30 相当を定義
validates_presence_of :email, if: :email_required?
validates_uniqueness_of :email, scope: :discarded_at, allow_blank: true, case_sensitive: true
validates_format_of :email, with: Devise.email_regexp, allow_blank: true

validates_presence_of :active_email, if: :active_email_required?, on: :update
validates_uniqueness_of :active_email, allow_blank: true, case_sensitive: true, if: :active_email_required?, on: :update
validates_format_of :active_email, with: Devise.email_regexp, allow_blank: true, if: :active_email_required?, on: :update

validates_presence_of :password, if: :password_required?
validates_confirmation_of :password, if: :password_required?
validates_length_of :password, within: Devise.password_length, allow_blank: true

protected
# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L60
def password_required?
!persisted? || !password.nil? || !password_confirmation.nil?
end

# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L64
def email_required?
true
end

def active_email_required?
discarded_at.nil?
end

# 略
def active_for_authentication?
super && kept?
end
end

lynndylanhurley/devise_token_auth を使う場合は、DeviseTokenAuth::Concerns::UserOmniauthCallbacks 相当も実装する必要がある

config/initializers/devise_token_auth.rb を変更、コールバックを無効化。

1
2
3
4
   # By default we will use callbacks for single omniauth.
# It depends on fields like email, provider and uid.
- # config.default_callbacks = true
+ config.default_callbacks = false

バリデーションや関連メソッドを定義。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class User < Activerecord::Base
include Discard::Model

devise :database_authenticatable # validatable があれば削る

# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L30 相当を定義
validates_presence_of :email, if: :email_required?
validates_uniqueness_of :email, scope: :discarded_at, allow_blank: true, case_sensitive: true
validates_format_of :email, with: Devise.email_regexp, allow_blank: true

validates_presence_of :active_email, if: :active_email_required?, on: :update
validates_uniqueness_of :active_email, allow_blank: true, case_sensitive: true, if: :active_email_required?, on: :update
validates_format_of :active_email, with: Devise.email_regexp, allow_blank: true, if: :active_email_required?, on: :update

validates_presence_of :password, if: :password_required?
validates_confirmation_of :password, if: :password_required?
validates_length_of :password, within: Devise.password_length, allow_blank: true

include DeviseTokenAuth::Concerns::User

# https://github.com/lynndylanhurley/devise_token_auth/blob/a9904e564b5d2fdcb0e547ffe5fb57ae95aaa16f/app/models/devise_token_auth/concerns/user_omniauth_callbacks.rb#L7 相当を定義
validates :email, presence: true, if: [:email_provider?, :email_required?]
validates :email, devise_token_auth_email: true, allow_blank: true, if: :email_provider?
validates_presence_of :uid, unless: :email_provider?
validates :email, uniqueness: { case_sensitive: false, scope: [:provider, :discarded_at] }, if: :email_provider?

validates :active_email, presence: true, if: [:email_provider?, :active_email_required?], on: :update
validates :active_email, devise_token_auth_email: true, allow_blank: true, if: [:email_provider?, :active_email_required?], on: :update
validates_presence_of :active_uid, unless: [:email_provider?, :active_email_required?], on: :update
validates :active_email, uniqueness: { case_sensitive: false, scope: :provider }, on: :update, if: [:email_provider?, :active_email_required?]

before_save :sync_uid
before_create :sync_uid

protected
# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L60
def password_required?
!persisted? || !password.nil? || !password_confirmation.nil?
end

# https://github.com/heartcombo/devise/blob/master/lib/devise/models/validatable.rb#L64
def email_required?
true
end

def active_email_required?
discarded_at.nil?
end

# https://github.com/lynndylanhurley/devise_token_auth/blob/a9904e564b5d2fdcb0e547ffe5fb57ae95aaa16f/app/models/devise_token_auth/concerns/user_omniauth_callbacks.rb#L19
protected
def email_provider?
provider == 'email'
end

def sync_uid
self.uid = email if email_provider?
end

def active_for_authentication?
super && kept?
end
end

まとめ

その他・メモ

後半(特に バリデーション定義)は書くのに力尽きて、なにかミスとかあるかもしれないので注意。

参考文献

関連記事

この記事が参考になった方
ここここからチャージや購入してくれると嬉しいです(ブログ主へのプレゼントではなく、ご自身へのチャージ)
欲しいもの / Wish list