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: '[email protected]')

User.exists?(email: '[email protected]')
#=> true

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

# 入力必須のnameを未入力の状態でinvite!する
User.invite!(email: '[email protected]', name: '')

# 名前が未入力のまま保存されてしまう(招待メールも送信されてしまう)
User.exists?(email: '[email protected]')
#=> 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: '[email protected]', name: '')

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

User.exists?(email: '[email protected]')
#=> false

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

さて、ここからが本題です。
devise_invitabaleにはクラスメソッド版のinvite!とインスタンスメソッド版のinvite!があります。
上で使ったのはクラスメソッド版です。

インスタンスメソッド版を使うと先ほどのコードは次のように書き直すことができるように思えます。
ですが、クラスメソッド版とは異なり、バリデーションは実行されません。
そのため、Userの保存と招待メールの送信が実行されてしまいます。

user = User.new(email: '[email protected]', name: '')
# 名前が未入力の状態でインスタンスメソッド版のinvite!を使う
user.invite!

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

User.exists?(email: '[email protected]')
#=> 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に対して使うようにしてください。