ActiveRecord partial_updates broken when duping with Single Table Inheritance
We ran into a strange bug in ActiveRecord when upgrading our Rails 3.2.x app to Rails 4.0.x. The bug was that we would dup
a record, change some values, save it, and only the values we had changed would be used in the INSERT
statement. That is, none of the values that came from the duped object would be used. It turned out to be a bug in the implementation of partial_updates
in ActiveRecord, but the cause wasn’t as obvious as we first thought.
Because we tracked it down to partial_updates
, our first inclination was just to disable partial_updates
like so:
ActiveRecord::Base.partial_updates = false
That definitely worked, but seemed like disabling a default Rails feature shouldn’t have been necessary. We tracked the problem down so that we could keep using partial_updates
.
Here’s some code that reproduces our issue:
# class CreateTestUsers < ActiveRecord::Migration
# def change
# create_table :test_users do |t|
# t.string :name, :null => false
# t.string :email
# t.string :type
#
# t.timestamps
# end
# end
# end
class TestUser < ActiveRecord::Base
validates :email, :presence => true
validates :name, :presence => true
end
class UserA < TestUser
end
class UserB < TestUser
end
original_user = UserA.create(:email => 'johndoe@gmail.com',
:name => 'John Doe')
dup_user = original_user.dup.becomes(UserB)
dup_user[:type] = 'UserB'
dup_user.email = 'dup@gmail.com'
dup_user.save! # exception!
# ActiveRecord::StatementInvalid: Mysql2::Error: Field 'name' doesn't have
# a default value: INSERT INTO `test_users` (`created_at`, `email`, `type`,
# `updated_at`) VALUES ('2014-03-03 16:33:23', 'dup@gmail.com', 'UserB',
# '2014-03-03 16:33:23')
Certainly not the most common of use cases; it was clear that STI was causing the issue as we worked on creating our minimal test case.
After reviewing how becomes
works, we came to the conclusion that it was mangling the state of the object in the way that caused the bug. As you might expect, changing the call order made the difference.
This alternative works with partial_updates
enabled:
dup_user = original_user.becomes!(UserB).dup
dup_user.email = 'dup@gmail.com'
dup_user.save!
One could argue that there’s still a bug to fix in Rails, but this is such a corner case that we probably have one of very few codebases that had this issue. We might take the time to submit a pull request, but a blog post is a lot more likely to help someone else in the meantime.
Versions:
- Ruby 2.1.1
- Rails 4.0.3
- mysql2 gem 0.3.15
- MySQL Server 5.5.27