Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions activemodel/lib/active_model/type/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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

Expand All @@ -50,6 +71,8 @@ def klass

def cast_value(value)
case value
when klass
value
when Hash
klass.new(value)
else
Expand Down
42 changes: 31 additions & 11 deletions activemodel/test/cases/attributes_test.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "cases/helper"
require "models/user"
require "models/post"

module ActiveModel
class AttributesTest < ActiveModel::TestCase
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -112,7 +132,7 @@ def attribute=(_, _)
"string_with_default",
"date_field",
"boolean_field",
"user_model"
"post_model"
]

assert_equal names, ModelForAttributesTest.attribute_names
Expand Down
2 changes: 1 addition & 1 deletion activemodel/test/cases/type/date_time_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions activemodel/test/cases/type/model_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ 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)

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
Expand All @@ -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" }

Expand Down
2 changes: 2 additions & 0 deletions activemodel/test/models/post.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ class Post

attribute :title, :string
attribute :body, :string

alias_attribute :name, :title
end
2 changes: 0 additions & 2 deletions activemodel/test/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ class User
include ActiveModel::Attributes
include ActiveModel::Dirty
include ActiveModel::SecurePassword
include ActiveModel::Model
include ActiveModel::Attributes

attribute :name, :string

Expand Down
2 changes: 2 additions & 0 deletions activerecord/lib/active_record/type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions activerecord/lib/active_record/type/model.rb
Original file line number Diff line number Diff line change
@@ -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
Loading