# 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

  setup do
    ActiveRecord::Base.use_yaml_unsafe_load = true
  end

  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_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

  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


class SerializedAttributeTestWithYAMLSafeLoad < SerializedAttributeTest
  setup do
    ActiveRecord::Base.use_yaml_unsafe_load = false
  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_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_should_raise_exception_on_serialized_attribute_with_type_mismatch
    myobj = String.new("value1")
    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
    Topic.serialize("content", String)

    myobj = String.new("value1")
    topic = Topic.create("content" => myobj)
    assert_equal(myobj, topic.content)

    topic.reload
    assert_equal(myobj, topic.content)
  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_serialize_attribute_via_select_method_when_time_zone_available
    with_timezone_config aware_attributes: true do
      Topic.serialize(:content, String)

      myobj = String.new("value1")
      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_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

  def test_serialized_time_attribute
    skip "Time is not a supported class in Psych::safe_load."
  end
end
