# frozen_string_literal: true require "cases/helper" require "models/person" require "models/traffic_light" require "models/post" require "models/binary_field" class SerializedAttributeTest < ActiveRecord::TestCase fixtures :topics, :posts MyObject = Struct.new :attribute1, :attribute2 class Topic < ActiveRecord::Base serialize :content end class ImportantTopic < Topic serialize :important, Hash end teardown do Topic.serialize("content") end def test_serialize_does_not_eagerly_load_columns Topic.reset_column_information assert_no_queries do Topic.serialize(:content) end end def test_serialized_attribute Topic.serialize("content", MyObject) myobj = MyObject.new("value1", "value2") topic = Topic.create("content" => myobj) assert_equal(myobj, topic.content) topic.reload assert_equal(myobj, topic.content) end def test_serialized_attribute_on_alias_attribute klass = Class.new(ActiveRecord::Base) do self.table_name = Topic.table_name alias_attribute :object, :content serialize :object, MyObject end myobj = MyObject.new("value1", "value2") topic = klass.create!(object: myobj) assert_equal(myobj, topic.object) topic.reload assert_equal(myobj, topic.object) end def test_serialized_attribute_with_default klass = Class.new(ActiveRecord::Base) do self.table_name = Topic.table_name serialize(:content, Hash, default: { key: "value" }) end t = klass.new assert_equal({ key: "value" }, t.content) end def test_serialized_attribute_on_custom_attribute_with_default klass = Class.new(ActiveRecord::Base) do self.table_name = Topic.table_name attribute :content, default: { key: "value" } serialize :content, Hash end t = klass.new assert_equal({ key: "value" }, t.content) end def test_serialized_attribute_in_base_class Topic.serialize("content", Hash) hash = { "content1" => "value1", "content2" => "value2" } important_topic = ImportantTopic.create("content" => hash) assert_equal(hash, important_topic.content) important_topic.reload assert_equal(hash, important_topic.content) end def test_serialized_attributes_from_database_on_subclass Topic.serialize :content, Hash t = ImportantTopic.new(content: { foo: :bar }) assert_equal({ foo: :bar }, t.content) t.save! t = ImportantTopic.last assert_equal({ foo: :bar }, t.content) end def test_serialized_attribute_calling_dup_method Topic.serialize :content, JSON orig = Topic.new(content: { foo: :bar }) clone = orig.dup assert_equal(orig.content, clone.content) end def test_serialized_json_attribute_returns_unserialized_value Topic.serialize :content, JSON my_post = posts(:welcome) t = Topic.new(content: my_post) t.save! t.reload assert_instance_of(Hash, t.content) assert_equal(my_post.id, t.content["id"]) assert_equal(my_post.title, t.content["title"]) end def test_json_read_legacy_null Topic.serialize :content, JSON # Force a row to have a JSON "null" instead of a database NULL (this is how # null values are saved on 4.1 and before) id = Topic.connection.insert "INSERT INTO topics (content) VALUES('null')" t = Topic.find(id) assert_nil t.content end def test_json_read_db_null Topic.serialize :content, JSON # Force a row to have a database NULL instead of a JSON "null" id = Topic.connection.insert "INSERT INTO topics (content) VALUES(NULL)" t = Topic.find(id) assert_nil t.content end def test_serialized_attribute_declared_in_subclass hash = { "important1" => "value1", "important2" => "value2" } important_topic = ImportantTopic.create("important" => hash) assert_equal(hash, important_topic.important) important_topic.reload assert_equal(hash, important_topic.important) assert_equal(hash, important_topic.read_attribute(:important)) end def test_serialized_time_attribute myobj = Time.local(2008, 1, 1, 1, 0) topic = Topic.create("content" => myobj).reload assert_equal(myobj, topic.content) end def test_serialized_string_attribute myobj = "Yes" topic = Topic.create("content" => myobj).reload assert_equal(myobj, topic.content) end def test_nil_serialized_attribute_without_class_constraint topic = Topic.new assert_nil topic.content end def test_nil_not_serialized_without_class_constraint assert Topic.new(content: nil).save assert_equal 1, Topic.where(content: nil).count end def test_nil_not_serialized_with_class_constraint Topic.serialize :content, Hash assert Topic.new(content: nil).save assert_equal 1, Topic.where(content: nil).count end def test_serialized_attribute_should_raise_exception_on_assignment_with_wrong_type Topic.serialize(:content, Hash) assert_raise(ActiveRecord::SerializationTypeMismatch) do Topic.new(content: "string") end end def test_should_raise_exception_on_serialized_attribute_with_type_mismatch myobj = MyObject.new("value1", "value2") topic = Topic.new(content: myobj) assert topic.save Topic.serialize(:content, Hash) assert_raise(ActiveRecord::SerializationTypeMismatch) { Topic.find(topic.id).content } end def test_serialized_attribute_with_class_constraint settings = { "color" => "blue" } Topic.serialize(:content, Hash) topic = Topic.new(content: settings) assert topic.save assert_equal(settings, Topic.find(topic.id).content) end def test_where_by_serialized_attribute_with_array settings = [ "color" => "green" ] Topic.serialize(:content, Array) topic = Topic.create!(content: settings) assert_equal topic, Topic.where(content: settings).take end def test_where_by_serialized_attribute_with_hash settings = { "color" => "green" } Topic.serialize(:content, Hash) topic = Topic.create!(content: settings) assert_equal topic, Topic.where(content: settings).take end def test_where_by_serialized_attribute_with_hash_in_array settings = { "color" => "green" } Topic.serialize(:content, Hash) topic = Topic.create!(content: settings) assert_equal topic, Topic.where(content: [settings]).take end def test_serialized_default_class Topic.serialize(:content, Hash) topic = Topic.new assert_equal Hash, topic.content.class assert_equal Hash, topic.read_attribute(:content).class topic.content["beer"] = "MadridRb" assert topic.save topic.reload assert_equal Hash, topic.content.class assert_equal "MadridRb", topic.content["beer"] end def test_serialized_no_default_class_for_object topic = Topic.new assert_nil topic.content end def test_serialized_boolean_value_true topic = Topic.new(content: true) assert topic.save topic = topic.reload assert_equal true, topic.content end def test_serialized_boolean_value_false topic = Topic.new(content: false) assert topic.save topic = topic.reload assert_equal false, topic.content end def test_serialize_with_coder some_class = Struct.new(:foo) do def self.dump(value) value.foo end def self.load(value) new(value) end end Topic.serialize(:content, some_class) topic = Topic.new(content: some_class.new("my value")) topic.save! topic.reload assert_kind_of some_class, topic.content assert_equal some_class.new("my value"), topic.content end def test_serialize_attribute_via_select_method_when_time_zone_available with_timezone_config aware_attributes: true do Topic.serialize(:content, MyObject) myobj = MyObject.new("value1", "value2") topic = Topic.create(content: myobj) assert_equal(myobj, Topic.select(:content).find(topic.id).content) assert_raise(ActiveModel::MissingAttributeError) { Topic.select(:id).find(topic.id).content } end end def test_serialize_attribute_can_be_serialized_in_an_integer_column insures = ["life"] person = SerializedPerson.new(first_name: "David", insures: insures) assert person.save person = person.reload assert_equal(insures, person.insures) end def test_regression_serialized_default_on_text_column_with_null_false light = TrafficLight.new assert_equal [], light.state assert_equal [], light.long_state end def test_unexpected_serialized_type Topic.serialize :content, Hash topic = Topic.create!(content: { zomg: true }) Topic.serialize :content, Array topic.reload error = assert_raise(ActiveRecord::SerializationTypeMismatch) do topic.content end expected = "can't load `content`: was supposed to be a Array, but was a Hash. -- {:zomg=>true}" assert_equal expected, error.to_s end def test_serialized_column_should_unserialize_after_update_column t = Topic.create(content: "first") assert_equal("first", t.content) t.update_column(:content, ["second"]) assert_equal(["second"], t.content) assert_equal(["second"], t.reload.content) end def test_serialized_column_should_unserialize_after_update_attribute t = Topic.create(content: "first") assert_equal("first", t.content) t.update_attribute(:content, "second") assert_equal("second", t.content) assert_equal("second", t.reload.content) end def test_nil_is_not_changed_when_serialized_with_a_class Topic.serialize(:content, Array) topic = Topic.new(content: nil) assert_not_predicate topic, :content_changed? end def test_classes_without_no_arg_constructors_are_not_supported assert_raises(ArgumentError) do Topic.serialize(:content, Regexp) end end def test_newly_emptied_serialized_hash_is_changed Topic.serialize(:content, Hash) topic = Topic.create(content: { "things" => "stuff" }) topic.content.delete("things") topic.save! topic.reload assert_equal({}, topic.content) end if current_adapter?(:Mysql2Adapter) def test_is_not_changed_when_stored_in_mysql_blob value = %w(Fée) model = BinaryField.create!(normal_blob: value, normal_text: value) model.reload model.normal_text = value assert_not_predicate model, :normal_text_changed? model.normal_blob = value assert_not_predicate model, :normal_blob_changed? end class FrozenBinaryField < BinaryField class FrozenCoder < ActiveRecord::Coders::YAMLColumn def dump(obj) super&.freeze end end serialize :normal_blob, FrozenCoder.new(:normal_blob, Array) end def test_is_not_changed_when_stored_in_mysql_blob_frozen_payload value = %w(Fée) model = FrozenBinaryField.create!(normal_blob: value, normal_text: value) model.reload model.normal_blob = value assert_not_predicate model, :normal_blob_changed? end end def test_values_cast_from_nil_are_persisted_as_nil # This is required to fulfil the following contract, which must be universally # true in Active Record: # # model.attribute = value # assert_equal model.attribute, model.tap(&:save).reload.attribute Topic.serialize(:content, Hash) topic = Topic.create!(content: {}) topic2 = Topic.create!(content: nil) assert_equal [topic, topic2], Topic.where(content: nil).sort_by(&:id) end def test_nil_is_always_persisted_as_null Topic.serialize(:content, Hash) topic = Topic.create!(content: { foo: "bar" }) topic.update_attribute :content, nil assert_equal [topic], Topic.where(content: nil) end class EncryptedType < ActiveRecord::Type::Text include ActiveModel::Type::Helpers::Mutable attr_reader :subtype, :encryptor def initialize(subtype: ActiveModel::Type::String.new) super() @subtype = subtype @encryptor = ActiveSupport::MessageEncryptor.new("abcd" * 8) end def serialize(value) subtype.serialize(value).yield_self do |cleartext| encryptor.encrypt_and_sign(cleartext) unless cleartext.nil? end end def deserialize(ciphertext) encryptor.decrypt_and_verify(ciphertext) .yield_self { |cleartext| subtype.deserialize(cleartext) } unless ciphertext.nil? end def changed_in_place?(old, new) if old.nil? !new.nil? else deserialize(old) != new end end end def test_decorated_type_with_type_for_attribute old_registry = ActiveRecord::Type.registry ActiveRecord::Type.registry = ActiveRecord::Type.registry.dup ActiveRecord::Type.register :encrypted, EncryptedType klass = Class.new(ActiveRecord::Base) do self.table_name = Topic.table_name store :content attribute :content, :encrypted, subtype: type_for_attribute(:content) end topic = klass.create!(content: { trial: true }) assert_equal({ "trial" => true }, topic.content) ensure ActiveRecord::Type.registry = old_registry end def test_decorated_type_with_decorator_block klass = Class.new(ActiveRecord::Base) do self.table_name = Topic.table_name store :content attribute(:content) { |subtype| EncryptedType.new(subtype: subtype) } end topic = klass.create!(content: { trial: true }) assert_equal({ "trial" => true }, topic.content) end def test_mutation_detection_does_not_double_serialize coder = Object.new def coder.dump(value) return if value.nil? value + " encoded" end def coder.load(value) return if value.nil? value.gsub(" encoded", "") end type = Class.new(ActiveModel::Type::Value) do include ActiveModel::Type::Helpers::Mutable def serialize(value) return if value.nil? value + " serialized" end def deserialize(value) return if value.nil? value.gsub(" serialized", "") end end.new model = Class.new(Topic) do attribute :foo, type serialize :foo, coder end topic = model.create!(foo: "bar") topic.foo assert_not_predicate topic, :changed? end def test_serialized_attribute_works_under_concurrent_initial_access model = Class.new(Topic) topic = model.create! topic.update group: "1" model.serialize :group, JSON model.reset_column_information # This isn't strictly necessary for the test, but a little bit of # knowledge of internals allows us to make failures far more likely. model.define_singleton_method(:define_attribute) do |*args, **options| Thread.pass super(*args, **options) end threads = 4.times.map do Thread.new do topic.reload.group end end # All the threads should retrieve the value knowing it is JSON, and # thus decode it. If this fails, some threads will instead see the # raw string ("1"), or raise an exception. assert_equal [1] * threads.size, threads.map(&:value) end end