devise_invitableのインスタンスメソッド版invite!は、原則として保存済みのUserに対して使う


TL;DR(最初に結論)


  • devise_invitableにはクラスメソッド版のinvite!とインスタンスメソッド版のinvite!がある

  • インスタンスメソッド版のinvite!は新規Userではなく、保存済みのUserに対して呼び出す

  • 新規Userに対してインスタンスメソッド版のinvite!を呼び出すと常に未検証のまま保存されるため、不正な状態でデータが保存される恐れがある


対象バージョン

本記事の内容は以下の環境で動作確認しました。


  • devise_invitable 2.0.1

  • devise 4.6.2

  • Rails 5.2.3


はじめに

DeviseにはUser作成時に招待メールを送ってパスワードの設定を後回しにできる、devise_invitableというgemがあります。

標準的な使い方であれば、「Devise::InvitationsControllerに全部おまかせ」で終わるのですが、実装の要件によってはプログラム上で招待メールを送るタイミングをコントロールしたい場合があります。

# Userの作成とメール送信を同時に行う

User.invite!(email: 'new_user@example.com')

User.exists?(email: 'new_user@example.com')
#=> true

ただし、この方法だとバリデーションが実行されません。

# 入力必須のnameを未入力の状態でinvite!する

User.invite!(email: 'new_user@example.com', name: '')

# 名前が未入力のまま保存されてしまう(招待メールも送信されてしまう)
User.exists?(email: 'new_user@example.com')
#=> true

これを防止するにはvalidate_on_inviteオプションを使います。

class User < ApplicationRecord

devise :invitable, :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, validate_on_invite: true

validates :name, presence: true
end

こうすると、検証失敗時にUserの保存と招待メールの送信が中止されます。

user = User.invite!(email: 'new_user@example.com', name: '')

# 検証エラーがあると保存されない(招待メールも送信されない)
user.persisted?
#=> false

User.exists?(email: 'new_user@example.com')
#=> false


問題:インスタンスメソッド版のinvite!は検証エラーの有無に関係なく処理が進んでしまう

さて、ここからが本題です。

devise_invitabaleにはクラスメソッド版のinvite!とインスタンスメソッド版のinvite!があります。

上で使ったのはクラスメソッド版です。

インスタンスメソッド版を使うと先ほどのコードは次のように書き直すことができるように思えます。

ですが、クラスメソッド版とは異なり、バリデーションは実行されません。

そのため、Userの保存と招待メールの送信が実行されてしまいます。

user = User.new(email: 'new_user@example.com', name: '')

# 名前が未入力の状態でインスタンスメソッド版のinvite!を使う
user.invite!

# バリデーションが実行されず、Userが保存されてしまう(招待メールも送信される)
user.persisted?
#=> true

User.exists?(email: 'new_user@example.com')
#=> true

また、上の実行結果を見てもわかるとおり、検証エラーが起きる状態でinvite!を呼びだしても例外が発生することもありません。

!が付いているので、save!メソッドのように検証エラー時に例外が発生することを期待する人がいるかもしれませんが、ActiveRecordのsave!と同じ感覚でinvite!を呼び出すことはできません。

(そもそも!なしのinviteメソッドは、インスタンスメソッド版にもクラスメソッド版にもありません)


対処方法:インスタンスメソッド版のinvite!は保存済みのUserに対して使う

devise_invitableのREADMEを見ると、インスタンスメソッド版のinvite!は次のように説明されています。


Sending an invitation after user creation(User作成後に招待メールを送信する)

You can send an invitation to an existing user if your workflow creates them separately:

(他の方法でUserを作成するワークフローになっている場合は、既存のUserに対して招待メールを送信することもできます)


user = User.find(42)

user.invite!(current_user) # invited_by属性を指定するcurrent_userはオプションです

ご覧のとおり、インスタンスメソッド版のinvite!メソッドは、保存済みのUserに対して呼び出されることを想定していることがわかります。


実装を確認する

ちなみに、インスタンスメソッド版のinvite!メソッドの実装は次のようになっています。

def invite!(invited_by = nil, options = {})

# ...

run_callbacks :invitation_created do
# ...

if save(validate: false)
self.invited_by.decrement_invitation_limit! if !was_invited and self.invited_by.present?
deliver_invitation(options) unless skip_invitation
end
end
end

コードを見るとわかりますが、save(validate: false)でレコードを保存しています。

このため、新規Userの場合でも保存処理自体は実行されますが、validate: falseオプションが付いているため、validate_on_inviteのありなしに関わらず、常に未検証のまま保存されます。

もちろん、「未検証のまま保存される」というのは既存のレコードに対しても(=update時も)同じです。

ですので、updateのタイミングで動いてほしい重要なバリデーションがある場合は、自前で検証のステップを入れた方が良いかもしれません。

user = User.find(42)

# 既存のUserに対して、検証エラーがないことを確認してからinvite!を実行する
if user.valid?
user.invite!(current_user)
end

ですが、厳密にいうと、上のような書き方をしてもまだinvite!に失敗する可能性はあります。

なぜなら、valid?がtrueを返してもレコードの保存に失敗するケースがあるからです。

詳しい内容は以下の記事にまとめてあるので、こちらをご覧ください。

valid?がtrueを返しても、after_save、after_create、after_updateによって保存が失敗する可能性を考慮する - Qiita


まとめ

というわけで、devise_invitableを利用する場合は、「Devise::InvitationsControllerに全部おまかせ」で終わらせるのが理想的ですが、それが無理な場合は極力クラスメソッド版のinvite!を利用するようにしましょう。

もし、インスタンスメソッド版のinvite!を使いたくなったときは、原則として保存済みのUserに対して使うようにしてください。