【Tips】 assign_attributes は association を更新する!?

form object を触っていた時に不思議な挙動に出会いました。

周知の通り、入力値を validation で弾いた時は、DB は更新されません。

しかし、 association だけは更新されてしまいます。

普段は、#update が**トランザクション**の中で [#assign_attributes, #valid, #save] の更新処理を一括で担ってくれていたことに気づかされました。

もし、#update を使用しない場合の解決策としては、[rails の issue](https://github.com/rails/rails/issues/17368#issuecomment-411725867)にあるように個別にトランザクションを張ると解決されるでしょう。

---
- 開発環境
  - Ruby: 2.4.9
  - Rails: 5.1.2
- references
  - [Updating associations with assign\_attributes should not persist · Issue \#17368 · rails/rails](https://github.com/rails/rails/issues/17368)
  - [Using accepts\_nested\_attributes\_for with assign\_attributes means immediate saving of associated model \- rubyonrails\-core \- Ruby on Rails Discussions](https://discuss.rubyonrails.org/t/using-accepts-nested-attributes-for-with-assign-attributes-means-immediate-saving-of-associated-model/70525)
- note: 参考として現在は非推奨の解決法
  - [Ruby on Rails \- assign\_attributesメソッドでDBに保存しようとしてしまう｜teratail](https://teratail.com/questions/227617)
- note: form object とは？
  - [Railsアプリケーションでフォームをオブジェクトにして育てる \- クックパッド開発者ブログ](https://techlife.cookpad.com/entry/2018/05/30/084420)
  - [form objectを使ってみよう \- メドピア開発者ブログ](https://tech.medpeer.co.jp/entry/2017/05/09/070758)

---

# 【本文】
## □ 前提条件

```ruby:app/models/user.rb
# rails チュートリアルの user model より抜粋
class User < ApplicationRecord
  #...
  has_many :following,
    through: 'active_relationships',
     source: 'followed'
  has_many :followers,
    through: 'passive_relationships',
     source: 'follower'
  #...
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  #...
end
```

## □ 動作結果

### ■ association が更新される!?
```bash
# 登録情報
> user = User.first

> user.email
=> "example@railstutorial.org"

> user.follower_ids.size
=> 38

# email が invalid となる params を与えてみる
> invalid_params = { email: "invalid", follower_ids: [] }
=> {:email=>"invalid", :follower_ids=>[]}

> user.assign_attributes(invalid_params)
  User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE 1=0
  User Load (1.2ms)  SELECT `users`.* FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`followed_id` = 1
   (0.4ms)  SAVEPOINT active_record_1
  SQL (2.8ms)  DELETE FROM `relationships` WHERE `relationships`.`followed_id` = 1 AND `relationships`.`follower_id` IN (4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41)
   (1.1ms)  RELEASE SAVEPOINT active_record_1
=> {:email=>"invalid", :follower_ids=>[]}

> user.save!
   (1.0ms)  SAVEPOINT active_record_1
  User Exists (1.0ms)  SELECT  1 AS one FROM `users` WHERE LOWER(`users`.`email`) = LOWER('invalid') AND (`users`.`id` != 1) LIMIT 1
   (0.7ms)  ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::RecordInvalid: Validation failed: Email is invalid
from /usr/local/bundle/gems/activerecord-5.1.2/lib/active_record/validations.rb:78:in `raise_validation_error`

# invalid_params の割り当て前にインスタンスの状態を戻す
> user.reload
  User Load (0.6ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
=> #<User:0x000055a87fa97978...

# 戻したはずなのに DB が更新されてる！？
> user.follower_ids.size
=> 0
```

### ■ 対策でトランザクションを張る

```bash:
> user = User.first

> invalid_params = { email: "invalid", follower_ids: [] }

> User.transaction do
    user.assign_attributes(invalid_params)
    user.save!
  end

> user.reload

# DB の値は更新されない
> user.follower_ids.size
=> 38
```


### ■ 参考: #update のソースコード
```ruby:rails/activerecord/lib/active_record/persistence.rb
#...
def update(attributes)
  # The following transaction covers any possible database side-effects of the
  # attributes assignment. For example, setting the IDs of a child collection.
  with_transaction_returning_status do
    assign_attributes(attributes)
    save
  end
end
#...
```