2023-5-16追記
devise_invitable 2.0.6からinvite!メソッドの仕様が一部変わったので、情報を追記しています。
また、記事のタイトルを「devise_invitableのインスタンスメソッド版invite!は、原則として保存済みのUserに対して使う」から「devise_invitableを利用するときは、バリデーションの有無を意識しよう」に変更しました
TL;DR(最初に結論)
- devise_invitableにはクラスメソッド版の
invite!
とインスタンスメソッド版のinvite!
がある インスタンスメソッド版のinvite!
は新規Userではなく、保存済みのUserに対して呼び出す新規Userに対してインスタンスメソッド版のinvite!
を呼び出すと 常に未検証のまま保存されるため、不正な状態でデータが保存される恐れがある- (2023-5-16追記)クラスメソッド版の
invite!
はvalidate_on_invite
の設定で検証するかどうかが決まる - (2023-5-16追記)インスタンスメソッド版の
invite!
はvalidate_on_invite
の設定とvalidate
オプションで検証するかどうかが決まる
対象バージョン
本記事の内容は以下の環境で動作確認しました。
devise_invitable 2.0.1- (2023-5-16追記)devise_invitable 2.0.8
- devise 4.6.2
- Rails 5.2.3
はじめに
DeviseにはUser作成時に招待メールを送ってパスワードの設定を後回しにできる、devise_invitableというgemがあります。
標準的な使い方であれば、「Devise::InvitationsController
に全部おまかせ」で終わるのですが、実装の要件によってはプログラム上で招待メールを送るタイミングをコントロールしたい場合があります。
この記事では、そういったケースでdevise_invitableがどういった条件でバリデーションを実行するのかについて説明します。
クラスメソッドの場合:validate_on_inviteの設定で検証するかどうかが決まる
# 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モデル以外にもdevise_invitableで招待が必要なモデルがある場合は、config/initializers/devise.rb
の設定を変更することもできます。
# どのモデルでも招待時にバリデーションを実行する
config.validate_on_invite = true
こうすると、検証失敗時にUserの保存と招待メールの送信が中止されます。
user = User.invite!(email: 'new_user@example.com', name: '')
# 検証エラーがあると保存されない(招待メールも送信されない)
user.persisted?
#=> false
2023-5-16追記
ここから下ではdevise_invitable 2.0.8時点の情報を記述します。
インスタンスメソッドの場合:validate_on_inviteの設定とvalidateオプションで決まる
devise_invitabaleにはクラスメソッド版のinvite!
とインスタンスメソッド版のinvite!
があります。
上で使ったのはクラスメソッド版です。
インスタンスメソッド版を使うと先ほどのコードは次のように書き直すことができます。以下のコードを実行すると結果はどうなるでしょうか?
user = User.new(email: 'new_user@example.com', name: '')
# 名前が未入力の状態でインスタンスメソッド版のinvite!を使う
user.invite!
# バリデーションは実行される?されない?
# データはDBに保存される?されない?招待メールも送信される?されない?
user.persisted?
これはアプリケーションの設定次第です。
前述のvalidate_on_invite: true
が設定されていればバリデーションが実行されるので、データはDBに保存されず、招待メールも送信されません。
user = User.new(email: 'new_user@example.com', name: '')
# 名前が未入力の状態でインスタンスメソッド版のinvite!を使う
user.invite!
# validate_on_invite: true なら検証エラーが発生するので保存されない
user.persisted?
#=> false
反対にvalidate_on_invite: false
(これがデフォルト)であれば、検証エラーがあっても招待は正常に完了します。
user = User.new(email: 'new_user@example.com', name: '')
# 名前が未入力の状態でインスタンスメソッド版のinvite!を使う
user.invite!
# validate_on_invite: false (デフォルト)の場合、
# バリデーションが実行されず、Userが保存されてしまう(招待メールも送信される)
user.persisted?
#=> true
validate_on_invite: false
だが、一時的にバリデーションを有効にしたい、という場合はinvite!
メソッドにvalidate: true
を指定します。
user = User.new(email: 'new_user@example.com', name: '')
# validate: trueオプションを有効にする場合、第1引数は省略できない
# この引数は誰が招待したのかを明示したいときに使う(明示しない場合はnil)
invited_by = nil
# validate_on_invite: false だが、validate: true オプションを付けてここだけ検証を有効にする
user.invite!(invited_by, validate: true)
# validate: true なら検証エラーが発生するので保存されない
user.persisted?
#=> false
反対にvalidate_on_invite: true
だが、一時的にバリデーションを無効にしたい、という場合はinvite!
メソッドにvalidate: false
を指定します。
user = User.new(email: 'new_user@example.com', name: '')
invited_by = nil
# validate_on_invite: true だが、validate: false オプションを付けてここだけ検証を無効にする
user.invite!(invited_by, validate: false)
# validate: false ならバリデーションが実行されず、Userが保存される(招待メールも送信される)
user.persisted?
#=> true
備考1. バリデーションを有効にすると、passwordの入力が必要
バリデーションが有効かつ、インスタンスメソッド版のinvite!
を使う場合はpasswordを事前に設定しておかないと検証エラーが発生してデータの保存に失敗します。
# メアドも名前も入力済みだから検証エラーは起きないはず(?)
user = User.new(email: 'new_user@example.com', name: 'John Doe')
invited_by = nil
user.invite!(invited_by, validate: true)
# 予想に反して保存されていない!
user.persisted?
#=> false
# パスワードが未入力なのでエラーになった😱
user.errors.full_messages
#=> ["Password can't be blank"]
よって、何らかのパスワードを事前に設定しておく必要があります。
# メアドも名前に加えて、ランダムなパスワードを設定しておく
user = User.new(email: 'new_user@example.com', name: 'John Doe', password: SecureRandom.base64)
invited_by = nil
user.invite!(invited_by, validate: true)
# これなら検証エラーは発生しない(招待メールも送信される)
user.persisted?
#=> true
備考2. invite!メソッドの戻り値は当てにならない
なんとなく、invite!
メソッドの戻り値を参照すると、保存に失敗したか成功したかを判定できそうな気がします。が、できません!🙅♂️
# invite!の戻り値は成功と失敗の判定に使えない
if user.invite!(nil, validate: true)
# 成功?
else
# 失敗?
end
devise_invitable 2.0.8のinvite!
メソッドの実装は以下のようになっていて、特に明示的に戻り値を返しているわけではないようです。
# https://github.com/scambra/devise_invitable/blob/v2.0.8/lib/devise_invitable/models.rb#L139-L167
def invite!(invited_by = nil, options = {})
# 略
run_callbacks :invitation_created do
# 略
validate = options.key?(:validate) ? options[:validate] : self.class.validate_on_invite
if save(validate: validate)
self.invited_by.decrement_invitation_limit! if !was_invited and self.invited_by.present?
deliver_invitation(options) unless skip_invitation
end
end
end
ですので、バリデーションエラーの有無を調べる場合は、以下のようなコードを書くのが良いと思います。
user.invite!(nil, validate: true)
if user.errors.empty?
# 成功
else
# 失敗
end
まとめ
というわけで、devise_invitableを利用する場合は、validate_on_invite
の設定や、validate
オプションの仕様をしっかり把握して、「ここではバリデーションが走るのかどうか」を見極めて使う方が良いと思います。
2023-5-16追記
ここから下はdevise_invitable 2.0.1時代の古い情報なので無視してください。
問題:インスタンスメソッド版の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に対して使うようにしてください。