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


:::note warn
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](https://github.com/scambra/devise_invitable)というgemがあります。

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

この記事では、そういったケースでdevise_invitableがどういった条件でバリデーションを実行するのかについて説明します。

## クラスメソッドの場合：validate_on_inviteの設定で検証するかどうかが決まる

```ruby
# Userの作成とメール送信を同時に行う
User.invite!(email: 'new_user@example.com')

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

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

```ruby
# 入力必須のnameを未入力の状態でinvite!する
User.invite!(email: 'new_user@example.com', name: '')

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

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

```ruby
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`の設定を変更することもできます。

```ruby:config/initializers/devise.rb
# どのモデルでも招待時にバリデーションを実行する
config.validate_on_invite = true
```

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

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

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

:::note info
2023-5-16追記
ここから下ではdevise_invitable 2.0.8時点の情報を記述します。
:::

## インスタンスメソッドの場合：validate_on_inviteの設定とvalidateオプションで決まる

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

インスタンスメソッド版を使うと先ほどのコードは次のように書き直すことができます。以下のコードを実行すると結果はどうなるでしょうか？

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

# バリデーションは実行される？されない？
# データはDBに保存される？されない？招待メールも送信される？されない？
user.persisted?
```

これはアプリケーションの設定次第です。
前述の`validate_on_invite: true`が設定されていればバリデーションが実行されるので、データはDBに保存されず、招待メールも送信されません。

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

# validate_on_invite: true なら検証エラーが発生するので保存されない
user.persisted?
#=> false
```

反対に`validate_on_invite: false`（これがデフォルト）であれば、検証エラーがあっても招待は正常に完了します。

```ruby
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`を指定します。

```ruby
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`を指定します。

```ruby
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を事前に設定しておかないと検証エラーが発生してデータの保存に失敗します。

```ruby
# メアドも名前も入力済みだから検証エラーは起きないはず（？）
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"]
```

よって、何らかのパスワードを事前に設定しておく必要があります。

```ruby
# メアドも名前に加えて、ランダムなパスワードを設定しておく
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!`メソッドの戻り値を参照すると、保存に失敗したか成功したかを判定できそうな気がします。が、できません！🙅‍♂️

```ruby 
# invite!の戻り値は成功と失敗の判定に使えない
if user.invite!(nil, validate: true)
  # 成功？
else
  # 失敗？
end
```

devise_invitable 2.0.8の`invite!`メソッドの実装は以下のようになっていて、特に明示的に戻り値を返しているわけではないようです。

```ruby 
# 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
```

ですので、バリデーションエラーの有無を調べる場合は、以下のようなコードを書くのが良いと思います。

```ruby 
user.invite!(nil, validate: true)
if user.errors.empty?
  # 成功
else
  # 失敗
end
```

## まとめ

というわけで、devise_invitableを利用する場合は、`validate_on_invite`の設定や、`validate`オプションの仕様をしっかり把握して、「ここではバリデーションが走るのかどうか」を見極めて使う方が良いと思います。


:::note alert
2023-5-16追記
ここから下はdevise_invitable 2.0.1時代の古い情報なので無視してください。
:::

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

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

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

```ruby
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](https://github.com/scambra/devise_invitable/blob/v2.0.1/README.rdoc)を見ると、インスタンスメソッド版の`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に対して招待メールを送信することもできます）

```ruby
user = User.find(42)
user.invite!(current_user)  # invited_by属性を指定するcurrent_userはオプションです
```

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

### ~~実装を確認する~~

~~ちなみに、インスタンスメソッド版の`invite!`メソッドの[実装](https://github.com/scambra/devise_invitable/blob/v2.0.1/lib/devise_invitable/models.rb#L139-L166)は次のようになっています。~~

```ruby
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のタイミングで動いてほしい重要なバリデーションがある場合は、自前で検証のステップを入れた方が良いかもしれません。~~

```ruby
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](https://qiita.com/jnchito/items/52db805e19441b7547cf)~~

## ~~まとめ~~

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

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