Rails5へのアップグレードを諦めなくていい話(或いは、has_many through の片方がreadonlyな場合の問題が解決したこと)

1ヶ月ほど寝かせておいたら[Rails5へのアップグレードを諦めた話(或いは、has_many :through の片方がreadonlyな場合の問題)](http://qiita.com/moperon/items/8ca0a138eb9968689630)の件が片付いていたので続き。

# TL;DR
Rails5.0.2にバージョンアップするか、readonlyなモデルへの`has_many through`のオプションで`autosave: false`すると解決するので、Rails4.2からRails5へアップグレードする希望を持てました。

(ただし、[元ネタ](https://teratail.com/questions/52290)の方は「`has_many`や`belongs_to`に`autosave: false`のオプションを追加する」を試したとされているので、また別の事象かもしれません。)

# 再現試験
こちらにPoCを置きました。
https://github.com/moperon/readonly_has_many_through

## 使い方
```
$ git clone https://github.com/moperon/readonly_has_many_through.git
$ cd readonly_has_many_through
$ git branch -r
  origin/HEAD -> origin/master
  origin/master
  origin/rails4.2.6
  origin/rails5.0.0.1
  origin/rails5.0.0.1_workaround
  origin/rails5.0.1
  origin/rails5.0.1_workaround
  origin/rails5.0.2
$
```

各Railsバージョンごとにbranch切ってあります。問題を確認したバージョン(Rails5.0.0.1とRails5.0.1)についてはworkaroundも試しています。

モデルの構成は[前回記事](http://qiita.com/moperon/items/8ca0a138eb9968689630)を参照してください。ただし、今回のPoCでは[activerecord-be_readonly](https://github.com/garysweaver/activerecord-be_readonly)を利用しています。

テストコードはこんな感じです。

```spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'who has existing role' do
    fixtures :roles

    let(:role) { Role.first }
    let(:user) { User.new(roles: [role]) }

    context 'when created' do
      subject { user.save }

      it { is_expected.to be true }
    end
  end
end
```

このPoCではworkaroundも試しています。`has_many through`のオプションで`autosave: false`とすることです。

```app/models/user.rb
class User < ApplicationRecord
  has_many :role_users
  has_many :roles, through: :role_users, autosave: false
end
```


## Rails4.2.6の場合
テストは問題なくpassします。

```
$ git checkout rails4.2.6
$ bundle install
$ bin/rake db:setup RAILS_ENV=test
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 22179
.

Finished in 0.08095 seconds (files took 0.58613 seconds to load)
1 example, 0 failures

$
```

## Rails5.0.0.1 の場合
テストは失敗します。

```
$ git checkout rails5.0.0.1
$ bundle install
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 22895
F

Failures:

  1) User who has existing role when created
     Failure/Error: subject { user.save }

     ActiveRecord::ReadOnlyRecord:
       Role is marked as readonly
     # ./spec/models/user_spec.rb:11:in `block (4 levels) in <top (required)>'
     # ./spec/models/user_spec.rb:13:in `block (4 levels) in <top (required)>'
     # -e:1:in `<main>'

Finished in 0.08019 seconds (files took 0.56101 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/user_spec.rb:13 # User who has existing role when created

$
```


## rails5.0.0.1 workaround

workaroundは有効でした。

```
$ git checkout rails5.0.0.1_workaround
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 23196
.

Finished in 0.10364 seconds (files took 0.66413 seconds to load)
1 example, 0 failures

$
```


## Rails5.0.1の場合
前回の記事で試したバージョンです。前回確認した通りです。

```
$ git checkout rails5.0.1
$ bundle install
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 23743
F

Failures:

  1) User who has existing role when created
     Failure/Error: subject { user.save }

     ActiveRecord::ReadOnlyRecord:
       Role is marked as readonly
     # ./spec/models/user_spec.rb:11:in `block (4 levels) in <top (required)>'
     # ./spec/models/user_spec.rb:13:in `block (4 levels) in <top (required)>'
     # -e:1:in `<main>'

Finished in 0.09718 seconds (files took 0.64053 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/user_spec.rb:13 # User who has existing role when created

$
```

## Rails5.0.1 workaround
こちらもworkaroundが有効でした。

```
$ git checkout rails5.0.1_workaround
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 25048
.

Finished in 0.09832 seconds (files took 0.71255 seconds to load)
1 example, 0 failures

$
```

## Rails5.0.2の場合
workaroundなしでテストは成功します。

```
$ git checkout rails5.0.2
$ bundle install
$ bin/rspec spec/models/user_spec.rb
Running via Spring preloader in process 24408
.

Finished in 0.07626 seconds (files took 0.69258 seconds to load)
1 example, 0 failures

$
```


# どうしてこうなった

v5.0.1とv5.0.2の差分は[こちら](https://github.com/rails/rails/compare/v5.0.1...v5.0.2)で見られます。今回注目している事象はこちらのPRで解決しました。

https://github.com/rails/rails/pull/27490

解決したコードがこちら。

```activerecord/lib/active_record/associations/has_many_through_association.rb
      def insert_record(record, validate = true, raise = false)
          ensure_not_nested
  
 -        if raise
 -          record.save!(:validate => validate)
 -        else
 -          return unless record.save(:validate => validate)
 +        if record.new_record? || record.has_changes_to_save?
 +          if raise
 +            record.save!(validate: validate)
 +          else
 +            return unless record.save(validate: validate)
 +          end
          end
  
          save_through_record(record)
```

詳しくはPRと関連するissueを参照していただくとして、簡単に解説すると、既存のchild(role)をセットしたparent(user)をcreateするとき、childに変更がなくてもchildがsave!されるのは無駄なので、必要な時だけsave!するようにしよう。という趣旨のものです。

ちなみに、Rails4.2-stableの同じ場所のコードは[こちら](https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/associations/has_many_through_association.rb#L55-L66)です。

```activerecord/lib/active_record/associations/has_many_through_association.rb
      def insert_record(record, validate = true, raise = false)
        ensure_not_nested

        if record.new_record?
          if raise
            record.save!(:validate => validate)
          else
            return unless record.save(:validate => validate)
          end
        end

        save_through_record(record)
```

Rails5.0.2のものとも微妙に違うけど、今回のケースに関してはrecordに入ってくるchild(role)がnew_recordではないのでsave!されません。なのでうまくいっていたんですね。

現場からは以上です。
ご安全に。