Skip to content

Commit 254e166

Browse files
nvasilevskiseanpdoyle
authored andcommitted
Add Active Model Collection attribute type
1 parent 145398c commit 254e166

5 files changed

Lines changed: 229 additions & 5 deletions

File tree

activemodel/lib/active_model/type.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "active_model/type/big_integer"
88
require "active_model/type/binary"
99
require "active_model/type/boolean"
10+
require "active_model/type/collection"
1011
require "active_model/type/date"
1112
require "active_model/type/date_time"
1213
require "active_model/type/decimal"
@@ -43,6 +44,7 @@ def default_value # :nodoc:
4344
register(:big_integer, Type::BigInteger)
4445
register(:binary, Type::Binary)
4546
register(:boolean, Type::Boolean)
47+
register(:collection, Type::Collection)
4648
register(:date, Type::Date)
4749
register(:datetime, Type::DateTime)
4850
register(:decimal, Type::Decimal)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveModel
4+
module Type
5+
# Attribute type for a collection of values. It is registered under
6+
# the +:collection+ key. +:element_type+ option is used to specify elements type
7+
#
8+
# class User
9+
# include ActiveModel::Attributes
10+
#
11+
# attribute :lucky_numbers, :collection, element_type: :integer
12+
# end
13+
#
14+
# user = User.new(lucky_numbers: [1, 2, 3])
15+
# user.lucky_numbers # => [1, 2, 3]
16+
#
17+
# Value is wrapped into an Array if not an Array already
18+
#
19+
# User.new(lucky_numbers: 1).lucky_numbers # => [1]
20+
#
21+
# Collection elements are coerced by their +:element_type+ type
22+
#
23+
# User.new(lucky_numbers: ["1"]).lucky_numbers # => [1]
24+
#
25+
class Collection < Value
26+
def initialize(**args)
27+
@element_type = args.delete(:element_type)
28+
@type_object = Type.lookup(element_type, **args)
29+
@serializer = args.delete(:serializer) || ActiveSupport::JSON
30+
super()
31+
end
32+
33+
def type
34+
:collection
35+
end
36+
37+
def cast(value)
38+
return [] if value.nil?
39+
Array(value).map { |el| @type_object.cast(el) }
40+
end
41+
42+
def serializable?(value)
43+
value.all? { |el| @type_object.serializable?(el) }
44+
end
45+
46+
def serialize(value)
47+
serializer.encode(value.map { |el| @type_object.serialize(el) })
48+
end
49+
50+
def deserialize(value)
51+
serializer.decode(value).map { |el| @type_object.deserialize(el) }
52+
end
53+
54+
def assert_valid_value(value)
55+
return if valid_value?(value)
56+
raise ArgumentError, "'#{value}' is not a valid #{type} of #{element_type}"
57+
end
58+
59+
def changed_in_place?(raw_old_value, new_value)
60+
old_value = deserialize(raw_old_value)
61+
return true if old_value.size != new_value.size
62+
63+
old_value.each_with_index.any? do |raw_old, i|
64+
@type_object.changed_in_place?(raw_old, new_value[i])
65+
end
66+
end
67+
68+
def valid_value?(value)
69+
value.is_a?(Array) && value.all? { |el| @type_object.valid_value?(el) }
70+
end
71+
72+
private
73+
attr_reader :element_type, :serializer
74+
end
75+
end
76+
end

activemodel/lib/active_model/type/value.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,13 @@ def hash
130130
[self.class, precision, scale, limit].hash
131131
end
132132

133-
def assert_valid_value(_)
133+
def assert_valid_value(value)
134+
return if valid_value?(value)
135+
raise ArgumentError, "'#{value}' is not a valid #{type}"
136+
end
137+
138+
def valid_value?(_)
139+
true
134140
end
135141

136142
def serialized? # :nodoc:

activemodel/test/cases/attributes_test.rb

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class ModelForAttributesTest
1414
attribute :string_with_default, :string, default: "default string"
1515
attribute :date_field, :date, default: -> { Date.new(2016, 1, 1) }
1616
attribute :boolean_field, :boolean
17+
attribute :integer_collection, :collection, element_type: :integer
18+
attribute :string_collection, :collection, element_type: :string
1719
end
1820

1921
class ChildModelForAttributesTest < ModelForAttributesTest
@@ -56,7 +58,9 @@ def attribute=(_, _)
5658
integer_field: "2.3",
5759
string_field: "Rails FTW",
5860
decimal_field: "12.3",
59-
boolean_field: "0"
61+
boolean_field: "0",
62+
integer_collection: ["1", "2", "3"],
63+
string_collection: [1, 2, 3]
6064
)
6165

6266
assert_equal 2, data.integer_field
@@ -65,6 +69,9 @@ def attribute=(_, _)
6569
assert_equal "default string", data.string_with_default
6670
assert_equal Date.new(2016, 1, 1), data.date_field
6771
assert_equal false, data.boolean_field
72+
assert_equal [1, 2, 3], data.integer_collection
73+
assert_equal ["1", "2", "3"], data.string_collection
74+
6875

6976
data.integer_field = 10
7077
data.string_with_default = nil
@@ -80,7 +87,9 @@ def attribute=(_, _)
8087
integer_field: 1.1,
8188
string_field: 1.1,
8289
decimal_field: 1.1,
83-
boolean_field: 1.1
90+
boolean_field: 1.1,
91+
string_collection: ["Rails"],
92+
integer_collection: [1],
8493
)
8594

8695
expected_attributes = {
@@ -89,7 +98,10 @@ def attribute=(_, _)
8998
decimal_field: BigDecimal("1.1"),
9099
string_with_default: "default string",
91100
date_field: Date.new(2016, 1, 1),
92-
boolean_field: true
101+
boolean_field: true,
102+
string_collection: ["Rails"],
103+
integer_collection: [1]
104+
93105
}.stringify_keys
94106

95107
assert_equal expected_attributes, data.attributes
@@ -102,7 +114,9 @@ def attribute=(_, _)
102114
"decimal_field",
103115
"string_with_default",
104116
"date_field",
105-
"boolean_field"
117+
"boolean_field",
118+
"integer_collection",
119+
"string_collection"
106120
]
107121

108122
assert_equal names, ModelForAttributesTest.attribute_names
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/user"
5+
6+
module ActiveModel
7+
module Type
8+
class CollectionTest < ActiveModel::TestCase
9+
setup do
10+
@element_type = Minitest::Mock.new
11+
@serializer = Minitest::Mock.new
12+
ActiveModel::Type.stub(:lookup, @element_type) do
13+
@collection = Collection.new(element_type: :element_type, serializer: @serializer)
14+
end
15+
@my_collection = [1, 2, 3]
16+
end
17+
18+
test "#valid_value? returns false if value is not an array" do
19+
assert_not @collection.valid_value?("Nikita")
20+
end
21+
22+
test "#valid_value? returns false if value is not an array of valid type_objects" do
23+
@element_type.expect(:valid_value?, false, ["Nikita"])
24+
assert_not @collection.valid_value?(["Nikita"])
25+
end
26+
27+
test "#assert_valid_value doesn't raise if valid_value? returns true" do
28+
assert_nothing_raised do
29+
@collection.stub(:valid_value?, true) do
30+
@collection.assert_valid_value(["Nikita"])
31+
end
32+
end
33+
end
34+
35+
test "#assert_valid_value raises ArgumentError if valid_value? returns false" do
36+
err = assert_raises(ArgumentError) do
37+
@collection.stub(:valid_value?, false) do
38+
@collection.assert_valid_value({ name: "Nikita" })
39+
end
40+
end
41+
42+
assert_equal "'{:name=>\"Nikita\"}' is not a valid collection of element_type", err.message
43+
end
44+
45+
test "#valid_value? delegates valid_value? check to the element type" do
46+
@my_collection.each { |i| @element_type.expect(:valid_value?, true, [i]) }
47+
assert @collection.valid_value?(@my_collection)
48+
end
49+
50+
test "#serialize delegates serialize check to the element type" do
51+
serialized_items = @my_collection.map { |i| "serialized #{i}" }
52+
@my_collection.each_with_index do |item, index|
53+
@element_type.expect(:serialize, serialized_items[index], [item])
54+
end
55+
expected = "serialized_collection: #{serialized_items}"
56+
@serializer.expect(:encode, expected, [serialized_items])
57+
assert_equal expected, @collection.serialize(@my_collection)
58+
end
59+
60+
test "#deserialize delegates deserialize check to the element type" do
61+
serialized_collection = "serialized collection"
62+
@my_collection.each { |i| @element_type.expect(:deserialize, "deserialized #{i}", [i]) }
63+
expected = @my_collection.map { |i| "deserialized #{i}" }
64+
@serializer.expect(:decode, @my_collection, [serialized_collection])
65+
66+
assert_equal expected, @collection.deserialize(serialized_collection)
67+
end
68+
69+
test "#serializable? delegates serializable? check to the element type" do
70+
@my_collection.each { |i| @element_type.expect(:serializable?, true, [i]) }
71+
assert @collection.serializable?(@my_collection)
72+
end
73+
74+
test "#changed_in_place? delegates changed_in_place? check to the element type" do
75+
my_collection_raw = "my_serialized_collection"
76+
@my_collection.each do |el|
77+
@element_type.expect(:changed_in_place?, false, [el, el])
78+
end
79+
80+
@collection.stub(:deserialize, @my_collection, [my_collection_raw]) do
81+
assert_not @collection.changed_in_place?(my_collection_raw, @my_collection)
82+
end
83+
end
84+
85+
test "#changed_in_place? returns true if size of new and old collections is different" do
86+
my_collection_raw = "my_serialized_collection"
87+
88+
@collection.stub(:deserialize, @my_collection, [my_collection_raw]) do
89+
assert @collection.changed_in_place?(my_collection_raw, [1])
90+
end
91+
end
92+
93+
test "#changed_in_place? returns true if collections are the same size but with different elements" do
94+
new_collection = [1, "changed", 3]
95+
my_collection_raw = "my_serialized_collection"
96+
@element_type.expect(:changed_in_place?, false, [1, 1])
97+
@element_type.expect(:changed_in_place?, true, [2, "changed"])
98+
99+
@collection.stub(:deserialize, @my_collection, [my_collection_raw]) do
100+
assert @collection.changed_in_place?(my_collection_raw, new_collection)
101+
end
102+
end
103+
104+
test "#cast returns an empty array if value is nil" do
105+
assert_equal [], @collection.cast(nil)
106+
end
107+
108+
test "#cast wraps false value in an array" do
109+
@element_type.expect(:cast, false, [false])
110+
assert_equal [false], @collection.cast(false)
111+
end
112+
113+
test "#cast wraps value in an array if value is not an array" do
114+
@element_type.expect(:cast, 1, [1])
115+
assert_equal [1], @collection.cast(1)
116+
end
117+
118+
test "#cast delegates cast to the element type" do
119+
string_collection = ["1", "2", "3"]
120+
string_collection.each { |el| @element_type.expect(:cast, el.to_i, [el]) }
121+
122+
assert_equal @my_collection, @collection.cast(string_collection)
123+
end
124+
end
125+
end
126+
end

0 commit comments

Comments
 (0)