diff --git a/activemodel/lib/active_model/attribute.rb b/activemodel/lib/active_model/attribute.rb index df56e7e5dfe88..a48c5dd7c4060 100644 --- a/activemodel/lib/active_model/attribute.rb +++ b/activemodel/lib/active_model/attribute.rb @@ -68,8 +68,16 @@ def changed_in_place? has_been_read? && type.changed_in_place?(original_value_for_database, value) end + # Returns an attribute that no longer remembers previous assignments. def forgetting_assignment - with_value_from_database(value_for_database) + case + when changed_in_place? + with_cast_value(value) + when changed_from_assignment? + dup_with_forgetting_assignment + else + self + end end def with_value_from_user(value) @@ -155,6 +163,10 @@ def initialize_dup(other) end end + def dup_with_forgetting_assignment + self.dup.forget_original_assignment! + end + def changed_from_assignment? assigned? && type.changed?(original_value, value, value_before_type_cast) end @@ -163,6 +175,13 @@ def _original_value_for_database type.serialize(original_value) end + protected + # only used when duplicating attribute before ever returning to the user + def forget_original_assignment! + @original_attribute = nil + self + end + class FromDatabase < Attribute # :nodoc: def type_cast(value) type.deserialize(value) @@ -185,13 +204,22 @@ def came_from_user? end class WithCastValue < Attribute # :nodoc: + def initialize(*args, &block) + super + + @initial_cast_value = !value_before_type_cast || value_before_type_cast.frozen? ? value_before_type_cast : value_before_type_cast.dup.freeze + end + def type_cast(value) value end def changed_in_place? - false + initial_cast_value != value end + + private + attr_reader :initial_cast_value end class Null < Attribute # :nodoc: diff --git a/activemodel/test/cases/attribute_test.rb b/activemodel/test/cases/attribute_test.rb index 18d0d93ae9764..37d89b9d231b3 100644 --- a/activemodel/test/cases/attribute_test.rb +++ b/activemodel/test/cases/attribute_test.rb @@ -227,6 +227,47 @@ def assert_valid_value(*) assert changed.changed? # Check to avoid a false positive assert_not_predicate forgotten, :changed? + assert_equal "foo", forgotten.value + end + + class SerializingNonDeserializingType < Type::String + def serialize(value) + "serialized: #{value}" + end + end + + test "forgetting assignments works when serialize/deserialize are not inverse" do + from_user = Attribute.from_user(:custom, "foo", SerializingNonDeserializingType.new).forgetting_assignment + from_db = Attribute.from_database(:custom, "foo", SerializingNonDeserializingType.new).forgetting_assignment + from_cast = Attribute.with_cast_value(:custom, "foo", SerializingNonDeserializingType.new).forgetting_assignment + + assert_equal "foo", from_user.value + assert_equal "foo", from_db.value + assert_equal "foo", from_cast.value + end + + test "forgetting assignments after assigning attribute" do + from_db = Attribute.from_database(:custom, +"bar", Type::String.new) + assigned = from_db.with_value_from_user("foo") + forgotten = assigned.forgetting_assignment + + assert assigned.changed? + assert_not_predicate forgotten, :changed? + assert_equal "foo", forgotten.value + end + + test "forgetting assignments after in-place mutation" do + from_user = Attribute.from_user(:custom, +"foo", Type::String.new) + from_db = Attribute.from_database(:custom, +"foo", Type::String.new) + from_cast = Attribute.with_cast_value(:custom, +"foo", Type::String.new) + + from_user.value << " user" + from_db.value << " db" + from_cast.value << " cast" + + assert_equal "foo user", from_user.forgetting_assignment.value + assert_equal "foo db", from_db.forgetting_assignment.value + assert_equal "foo cast", from_cast.forgetting_assignment.value end test "with_value_from_user validates the value" do