LoginSignup
14
13

devise_invitableを利用するときは、バリデーションの有無を意識しよう

Last updated at Posted at 2019-06-13

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/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に対して使うようにしてください。

14
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
13