Rails
ActiveRecord
Rails5

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

More than 1 year has passed since last update.

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


TL;DR

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

(ただし、元ネタの方は「has_manybelongs_toautosave: 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も試しています。

モデルの構成は前回記事を参照してください。ただし、今回のPoCでは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の差分はこちらで見られます。今回注目している事象はこちらの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の同じ場所のコードはこちらです。


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!されません。なのでうまくいっていたんですね。

現場からは以上です。

ご安全に。