Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updating unrelated attributes resets serialized attribute with a wrong value in 7.1 #49809

Closed
janklimo opened this issue Oct 27, 2023 · 4 comments · Fixed by #49832
Closed

Updating unrelated attributes resets serialized attribute with a wrong value in 7.1 #49809

janklimo opened this issue Oct 27, 2023 · 4 comments · Fixed by #49832

Comments

@janklimo
Copy link
Contributor

janklimo commented Oct 27, 2023

Steps to reproduce

The simplified example below shows that updating an unrelated attribute (name) of a user resets the serialized attribute value with wrong data. Reloading the record fixes the issue.

# frozen_string_literal: true

require 'bundler/inline'

gemfile(true) do
  source 'https://rubygems.org'

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem 'activerecord', '7.1.1' # works on 7.0.8
  gem 'sqlite3'
  gem 'pry-rails'
end

require 'active_record'
require 'minitest/autorun'
require 'logger'

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.text :name
    t.json :cached_configuration
  end

  create_table :configurations, force: true do |t|
    t.integer :user_id
  end

  create_table :options, force: true do |t|
    t.integer :configuration_id
    t.string :key
    t.string :value
  end
end

class ConfigurationCache
  class << self
    def load(configuration_json)
      return if configuration_json.blank?

      attributes = JSON.parse(configuration_json)

      Configuration.new(attributes.except('options')).tap do |configuration|
        configuration.options = attributes['options'].map do |option_attributes|
          Option.new(option_attributes)
        end
      end
    end

    def dump(configuration)
      configuration.to_json(include: :options)
    end
  end
end

class User < ActiveRecord::Base
  has_one :configuration

  serialize :cached_configuration, coder: ConfigurationCache
end

class Configuration < ActiveRecord::Base
  belongs_to :user
  has_many :options
end

class Option < ActiveRecord::Base
  belongs_to :configuration
end

class AttributeSerializationTest < Minitest::Test
  def test_attribute_serialization
    user = User.create!(name: 'Tom')

    configuration = user.create_configuration!
    configuration.options << Option.create(key: 'key', value: 'secret')

    user.update!(cached_configuration: configuration)

    # Correctly serialized and loaded
    assert_equal configuration.id, user.cached_configuration.id
    assert_equal 1, user.cached_configuration.options.size

    # All correct – does not update serialized attribute
    user.name = 'Amanda'

    assert_equal configuration.id, user.cached_configuration.id
    assert_equal 1, user.cached_configuration.options.size

    # Wrong – updates serialized attribute with incomplete data
    user.update!(name: 'Joe')

    # user
    # => #<User:0x00000001199ac468 id: 1, name: "Joe", cached_configuration: #<Configuration:0x000000011bb0cbd0 id: nil, user_id: 1>>
    
    # user.cached_configuration.options
    # => []

    assert_equal configuration.id, user.cached_configuration.id
    assert_equal 1, user.cached_configuration.options.size

    # All good again – reloading the record fixes the serialized attribute
    user.reload

    assert_equal configuration.id, user.cached_configuration.id
    assert_equal 1, user.cached_configuration.options.size
  end
end

Expected behavior

Updating a record should not update serialized attributes.

Actual behavior

The attribute is updated with faulty data.

System configuration

Rails version: 7.1.1

Ruby version: 3.2.2

@fatkodima
Copy link
Member

I bisected this behaviour into the 3814168 change (cc @eileencodes).

You assign an ActiveRecord record in user.update!(cached_configuration: configuration) and update! during its call chain clears dirty attributes an "forgets attribute assignment"

forget_attribute_assignments

which then calls 3814168#diff-5208e586ef2714ffb46327930338aa312264b56587696a1e7fee0a6fe6642788R178-R189 which then calls dup on the ActiveRecord record 3814168#diff-5208e586ef2714ffb46327930338aa312264b56587696a1e7fee0a6fe6642788R185 which resets primary key.

@fatkodima
Copy link
Member

The dumbest solution from the rails side is to check if the @value is not an ActiveRecord::Base instance and do not call that dup, but I am not sure this is the correct solution.

@eileencodes
Copy link
Member

This is the actual change #46282. I reverted it while we were debugging a Rails upgrade and then unreverted it. cc/ @jonathanhefner

@janklimo janklimo changed the title Updating unrelated attributes resets serialized attribute with a wrong value Updating unrelated attributes resets serialized attribute with a wrong value in 7.1 Oct 27, 2023
@jonathanhefner jonathanhefner self-assigned this Oct 27, 2023
jonathanhefner added a commit to jonathanhefner/rails that referenced this issue Oct 28, 2023
Follow-up to rails#46282.

The purpose of the optimization from rails#46282 is to avoid unnecessary
`deserialize` / `cast` / `serialize` calls associated with invoking
`value_for_database` on an attribute that has not changed.  `dup`ing the
attribute accomplishes that, but `dup`ing the attribute's value is not
appropriate for some value types.  For example, a value of the type
`ActiveModel::Type::ImmutableString` requires `clone` instead of `dup`,
and a value of the type `ActiveRecord::Type::Json` likely requires
`deep_dup`.  In some cases the only appropriate method may be
`deserialize(serialize(value))`, such as when a serializer for the type
`ActiveRecord::Type::Serialized` deserializes `ActiveRecord::Base`
instances.  (In that case, `dup`ing the value would clear its `id`, and
`clone`ing the value would only produce a shallow copy.)  However,
`deserialize(serialize(value))` is expensive and would defeat the
purpose of the optimization.

Instead of `dup`ing the attribute, this commit changes the optimization
to use `with_value_from_database(value_before_type_cast)`, which
parallels `with_value_from_database(value_for_database)` in the base
implementation.  This drops the (cast) value entirely, causing a fresh
copy to be deserialized if the attribute is subsequently read.  In cases
where the attribute is _not_ subsequently read, this will actually be
more efficient since no extra work is performed.  And in cases where the
attribute _is_ subsequently read, it will still be more efficient than
`deserialize(serialize(value))`.

Fixes rails#49809.
@jonathanhefner
Copy link
Member

@fatkodima Thank you for investigating! It was extremely helpful! 🙇

So, in my opinion, the root problem is that ActiveModel::Attribute#initialize_dup assumes that attribute values can be copied simply by calling dup:

def initialize_dup(other)
if defined?(@value) && @value.duplicable?
@value = @value.dup
end
end

That can cause surprising behavior in other situations too:

user = User.create!(name: 'Tom')

configuration = user.create_configuration!
configuration.options << Option.create(key: 'key', value: 'secret')

user.update!(cached_configuration: configuration)

user.cached_configuration.options.size     # => 1
user.dup.cached_configuration.options.size # => 0

Or, a more simplified example:

class MyModel
  include ActiveModel::Attributes

  attribute :foo, :immutable_string, default: "foo"
end

m = MyModel.new

m.dup.foo.frozen? # => true
m.foo.frozen?     # => true
m.dup.foo.frozen? # => false

(Though if you are using ActiveModel::Dirty — as ActiveRecord::Base does — its initialize_dup will cover up the issue with frozen strings.)

Unfortunately, fixing ActiveModel::Attribute#initialize_dup is tricky due to cross-cutting concerns. I will continue to think about it, but for now I have submitted #49832 to fix this issue specifically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants
@jonathanhefner @eileencodes @fatkodima @janklimo and others