diff --git a/activemodel/lib/active_model/type/model.rb b/activemodel/lib/active_model/type/model.rb index c990385b70062..e278835d38c3a 100644 --- a/activemodel/lib/active_model/type/model.rb +++ b/activemodel/lib/active_model/type/model.rb @@ -3,15 +3,30 @@ module ActiveModel module Type class Model < Value # :nodoc: - def initialize(**args) - @class_name = args.delete(:class_name) - @serializer = args.delete(:serializer) || ActiveSupport::JSON + module NullSerializer + extend self + + def encode(value) + value + end + + def decode(value) + value + end + end + + def initialize(**options) + @class_name = options.delete(:class_name) + @serializer = options.delete(:serializer) || NullSerializer super end def changed_in_place?(raw_old_value, value) - old_value = deserialize(raw_old_value) - old_value.attributes != value.attributes + if (old_value = deserialize(raw_old_value)) + old_value.attributes != value.attributes + else + !value.nil? + end end def valid_value?(value) @@ -29,10 +44,14 @@ def serializable?(value) end def serialize(value) + return nil if value.nil? + serializer.encode(value.attributes_for_database) end def deserialize(value) + return nil if value.nil? + attributes = serializer.decode(value) klass.new(attributes) end @@ -41,6 +60,8 @@ def deserialize(value) attr_reader :serializer def valid_hash?(value) + value = value.transform_keys { |key| klass.attribute_alias(key) || key } + value.keys.map(&:to_s).difference(klass.attribute_names).none? end @@ -50,6 +71,8 @@ def klass def cast_value(value) case value + when klass + value when Hash klass.new(value) else diff --git a/activemodel/test/cases/attributes_test.rb b/activemodel/test/cases/attributes_test.rb index 28f60e185f54b..e11cb0fffa43f 100644 --- a/activemodel/test/cases/attributes_test.rb +++ b/activemodel/test/cases/attributes_test.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "cases/helper" -require "models/user" +require "models/post" module ActiveModel class AttributesTest < ActiveModel::TestCase @@ -15,7 +15,7 @@ class ModelForAttributesTest attribute :string_with_default, :string, default: "default string" attribute :date_field, :date, default: -> { Date.new(2016, 1, 1) } attribute :boolean_field, :boolean - attribute :user_model, :model, class_name: "User" + attribute :post_model, :model, class_name: "Post" end class ChildModelForAttributesTest < ModelForAttributesTest @@ -59,7 +59,7 @@ def attribute=(_, _) string_field: "Rails FTW", decimal_field: "12.3", boolean_field: "0", - user_model: User.new(name: "Nikita") + post_model: Post.new(title: "Nikita") ) assert_equal 2, data.integer_field @@ -68,27 +68,27 @@ def attribute=(_, _) assert_equal "default string", data.string_with_default assert_equal Date.new(2016, 1, 1), data.date_field assert_equal false, data.boolean_field - assert_equal "Nikita", data.user_model.name + assert_equal "Nikita", data.post_model.title data.integer_field = 10 data.string_with_default = nil data.boolean_field = "1" - data.user_model = { name: "Bob" } + data.post_model = { title: "Bob" } assert_equal 10, data.integer_field assert_nil data.string_with_default assert_equal true, data.boolean_field - assert_equal "Bob", data.user_model.name + assert_equal "Bob", data.post_model.title end test "reading attributes" do - user = User.new(name: "Nikita") + post = Post.new(title: "Nikita") data = ModelForAttributesTest.new( integer_field: 1.1, string_field: 1.1, decimal_field: 1.1, boolean_field: 1.1, - user_model: user + post_model: post ) expected_attributes = { @@ -100,8 +100,28 @@ def attribute=(_, _) boolean_field: true, }.stringify_keys - assert_equal expected_attributes, data.attributes.except("user_model") - assert_equal user.attributes, data.attributes["user_model"].attributes + assert_equal expected_attributes, data.attributes.except("post_model") + assert_equal post.attributes, data.attributes["post_model"].attributes + assert_same post, data.attributes["post_model"] + end + + test "assigning aliased attributes" do + data = ModelForAttributesTest.new( + post_model: Post.new(name: "Nikita") + ) + + assert_equal "Nikita", data.post_model.title + assert_equal data.post_model.title, data.post_model.name + end + + test "reading aliased attributes" do + post = Post.new(name: "Nikita") + data = ModelForAttributesTest.new( + post_model: post + ) + + assert_equal post.attributes, data.attributes["post_model"].attributes + assert_same post, data.attributes["post_model"] end test "reading attribute names" do @@ -112,7 +132,7 @@ def attribute=(_, _) "string_with_default", "date_field", "boolean_field", - "user_model" + "post_model" ] assert_equal names, ModelForAttributesTest.attribute_names diff --git a/activemodel/test/cases/type/date_time_test.rb b/activemodel/test/cases/type/date_time_test.rb index bee7da7124339..76434a9ab68a2 100644 --- a/activemodel/test/cases/type/date_time_test.rb +++ b/activemodel/test/cases/type/date_time_test.rb @@ -34,7 +34,7 @@ def test_hash_to_time def test_hash_with_wrong_keys type = Type::DateTime.new error = assert_raises(ArgumentError) { type.cast(a: 1) } - assert_equal "Provided hash {:a=>1} doesn't contain necessary keys: [1, 2, 3]", error.message + assert_equal "Provided hash {a: 1} doesn't contain necessary keys: [1, 2, 3]", error.message end test "serialize_cast_value is equivalent to serialize after cast" do diff --git a/activemodel/test/cases/type/model_test.rb b/activemodel/test/cases/type/model_test.rb index af4e88b2a6d4c..e350e8ff7779b 100644 --- a/activemodel/test/cases/type/model_test.rb +++ b/activemodel/test/cases/type/model_test.rb @@ -17,7 +17,7 @@ class ModelTest < ActiveModel::TestCase assert_equal "Hello!", model.body end - test "#cast returns a new model instance from the given model instance" do + test "#cast returns the same model instance from the given model instance" do model = Post.new(title: "Greeting", body: "Hello!") new_model = @type.cast(model) @@ -25,7 +25,7 @@ class ModelTest < ActiveModel::TestCase assert_equal "Greeting", new_model.title assert_equal "Hello!", new_model.body - assert_not_same model, new_model + assert_same model, new_model end test "#valid_value? returns true if the value is an object of the same class as the type" do @@ -46,6 +46,12 @@ class ModelTest < ActiveModel::TestCase assert @type.valid_value?(model_hash) end + test "#valid_value? returns true if the value is a hash that contains only aliased required keys" do + model_hash = { name: "Greeting" } + + assert @type.valid_value?(model_hash) + end + test "#valid_value? returns false if value is a hash but with not-supported keys" do model_hash = { title: "Greeting", body: "Hello!", what_am_i: "I'm not supposed to be here" } diff --git a/activemodel/test/models/post.rb b/activemodel/test/models/post.rb index a3dd01c9bb787..bf2821b4d906e 100644 --- a/activemodel/test/models/post.rb +++ b/activemodel/test/models/post.rb @@ -6,4 +6,6 @@ class Post attribute :title, :string attribute :body, :string + + alias_attribute :name, :title end diff --git a/activemodel/test/models/user.rb b/activemodel/test/models/user.rb index 40fd400adf091..6a6a2ac8d9c25 100644 --- a/activemodel/test/models/user.rb +++ b/activemodel/test/models/user.rb @@ -5,8 +5,6 @@ class User include ActiveModel::Attributes include ActiveModel::Dirty include ActiveModel::SecurePassword - include ActiveModel::Model - include ActiveModel::Attributes attribute :name, :string diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index dadc02095e6b5..53627a71a4a85 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -8,6 +8,7 @@ require "active_record/type/date_time" require "active_record/type/decimal_without_scale" require "active_record/type/json" +require "active_record/type/model" require "active_record/type/time" require "active_record/type/text" require "active_record/type/unsigned_integer" @@ -75,6 +76,7 @@ def current_adapter_name register(:float, Type::Float, override: false) register(:integer, Type::Integer, override: false) register(:immutable_string, Type::ImmutableString, override: false) + register(:model, Type::Model, override: false) register(:json, Type::Json, override: false) register(:string, Type::String, override: false) register(:text, Type::Text, override: false) diff --git a/activerecord/lib/active_record/type/model.rb b/activerecord/lib/active_record/type/model.rb new file mode 100644 index 0000000000000..c8c34c2709a5d --- /dev/null +++ b/activerecord/lib/active_record/type/model.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActiveRecord + module Type + class Model < ActiveModel::Type::Model + def initialize(**options) + options.with_defaults!(serializer: ActiveSupport::JSON) + + super + end + end + end +end diff --git a/activerecord/test/cases/model_attribute_test.rb b/activerecord/test/cases/model_attribute_test.rb new file mode 100644 index 0000000000000..7dabb250b42fa --- /dev/null +++ b/activerecord/test/cases/model_attribute_test.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ModelAttributeTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Author + include ActiveModel::Model + include ActiveModel::Dirty + include ActiveModel::Attributes + + attribute :name, :string + end + + class Post + include ActiveModel::Model + include ActiveModel::Dirty + include ActiveModel::Attributes + + attribute :author, :model, class_name: Author.name + attribute :title, :string + attribute :published_on, :datetime + + alias_attribute :name, :title + end + + class ModelDataTypeOnText < ActiveRecord::Base + attribute :post, :model, class_name: Post.name + end + + class ModelDataTypeOnJson < ActiveRecord::Base + attribute :post, :model, class_name: Post.name + end + + setup do + @connection = ActiveRecord::Base.lease_connection + @connection.create_table(ModelDataTypeOnText.table_name, force: true) { |t| t.text :post } + @connection.create_table(ModelDataTypeOnJson.table_name, force: true) { |t| t.json :post } + end + + teardown do + @connection.drop_table ModelDataTypeOnText.table_name, if_exists: true + @connection.drop_table ModelDataTypeOnJson.table_name, if_exists: true + ModelDataTypeOnText.reset_column_information + ModelDataTypeOnJson.reset_column_information + end + + test "writes :model attribute instance to text column" do + post = Post.new(title: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnText.create!(post: post) + + assert_equal post.attributes.to_json, record.post_before_type_cast + end + + test "writes :model attribute Hash to text column" do + post = Post.new(title: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnText.create!(post: post.attributes) + + assert_kind_of Post, record.post + assert_equal post.attributes.to_json, record.post_before_type_cast + end + + test "writes nested :model instance attribute to text column" do + attributes = { author: { name: "Matz" }, title: nil, published_on: nil } + author = Author.new(attributes[:author]) + post = Post.new(author: author) + record = ModelDataTypeOnText.create!(post: post) + + assert_kind_of Author, record.post.author + assert_equal attributes.to_json, record.post_before_type_cast + end + + test "writes nested :model Hash attribute to text column" do + attributes = { author: { name: "Matz" }, title: nil, published_on: nil } + record = ModelDataTypeOnText.create!(post: attributes) + + assert_kind_of Author, record.post.author + assert_equal(attributes.to_json, record.post_before_type_cast) + end + + test "writes aliased attribute to text column" do + post = Post.new(name: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnText.create!(post: post) + + value = JSON.parse(record.post_before_type_cast) + + assert_equal "Rails", value["title"] + end + + test "reads :model attribute from text column" do + post = Post.new(title: "Rails") + record = ModelDataTypeOnText.create!(post: post) + + record.reload + + assert_kind_of Post, record.post + assert_equal post.attributes, record.post.attributes + end + + test "reads nested :model attribute from text column" do + author = Author.new(name: "Matz") + post = Post.new(author: author) + record = ModelDataTypeOnText.create!(post: post) + + record.reload + + assert_kind_of Author, record.post.author + assert_equal author.attributes, record.post.author.attributes + end + + test "reads aliased attribute from text column" do + post = Post.new(name: "Rails") + record = ModelDataTypeOnText.create!(post: post) + + record.reload + + assert_equal "Rails", record.post.name + assert_equal "Rails", record.post.title + end + + test "writes :model attribute instance to json column" do + post = Post.new(title: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnJson.create!(post: post) + + assert_equal post.attributes.to_json, record.post_before_type_cast + end + + test "writes :model attribute Hash to json column" do + post = Post.new(title: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnJson.create!(post: post.attributes) + + assert_kind_of Post, record.post + assert_equal post.attributes.to_json, record.post_before_type_cast + end + + test "writes nested :model instance attribute to json column" do + attributes = { author: { name: "Matz" }, title: nil, published_on: nil } + author = Author.new(attributes[:author]) + post = Post.new(author: author) + record = ModelDataTypeOnJson.create!(post: post) + + assert_kind_of Author, record.post.author + assert_equal attributes.to_json, record.post_before_type_cast + end + + test "writes nested :model Hash attribute to json column" do + attributes = { author: { name: "Matz" }, title: nil, published_on: nil } + record = ModelDataTypeOnJson.create!(post: attributes) + + assert_kind_of Author, record.post.author + assert_equal(attributes.to_json, record.post_before_type_cast) + end + + test "writes aliased attribute to json column" do + post = Post.new(name: "Rails", published_on: "2025-12-06") + record = ModelDataTypeOnJson.create!(post: post) + + value = JSON.parse(record.post_before_type_cast) + + assert_equal "Rails", value["title"] + end + + test "reads :model attribute from json column" do + post = Post.new(title: "Rails") + record = ModelDataTypeOnJson.create!(post: post) + + record.reload + + assert_kind_of Post, record.post + assert_equal post.attributes, record.post.attributes + end + + test "reads nested :model attribute from json column" do + author = Author.new(name: "Matz") + post = Post.new(author: author) + record = ModelDataTypeOnJson.create!(post: post) + + record.reload + + assert_kind_of Author, record.post.author + assert_equal author.attributes, record.post.author.attributes + end + + test "reads aliased attribute from json column" do + post = Post.new(name: "Rails") + record = ModelDataTypeOnJson.create!(post: post) + + record.reload + + assert_equal "Rails", record.post.name + assert_equal "Rails", record.post.title + end + + test "delegates to :model attribute dirty checking when available" do + record = ModelDataTypeOnText.create!(post: Post.new(title: "Ruby")) + + assert_changes -> { record.changed? }, from: false, to: true do + record.post.title = "Rails" + end + assert_equal "Rails", record.post.title + assert_predicate record.post, :title_changed? + end + + test "delegates to nested :model attribute dirty checking when available" do + author = Author.new(name: "Matz") + post = Post.new(author: author) + record = ModelDataTypeOnText.create!(post: post) + + assert_changes -> { record.changed? }, from: false, to: true do + record.post.author.name = "Changed" + end + assert_equal "Changed", record.post.author.name + assert_predicate record.post.author, :name_changed? + end +end