Skip to content

Commit 4c6f104

Browse files
authored
Merge pull request #2022 from bf4/fix_model_attribute_accessors
Fix model attribute accessors
2 parents 4e6bd61 + 1570437 commit 4c6f104

File tree

12 files changed

+233
-38
lines changed

12 files changed

+233
-38
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ Breaking changes:
66

77
Features:
88

9-
- [#2021](https://github.com/rails-api/active_model_serializers/pull/2021) ActiveModelSerializers::Model#attributes. (@bf4)
9+
- [#2021](https://github.com/rails-api/active_model_serializers/pull/2021) ActiveModelSerializers::Model#attributes. Originally in [#1982](https://github.com/rails-api/active_model_serializers/pull/1982). (@bf4)
1010

1111
Fixes:
1212

13+
- [#2022](https://github.com/rails-api/active_model_serializers/pull/2022) Mutation of ActiveModelSerializers::Model now changes the attributes. Originally in [#1984](https://github.com/rails-api/active_model_serializers/pull/1984). (@bf4)
14+
1315
Misc:
1416

1517
- [#2021](https://github.com/rails-api/active_model_serializers/pull/2021) Make test attributes explicit. Tests have Model#associations. (@bf4)

docs/howto/serialize_poro.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ define and validate which methods ActiveModelSerializers expects to be implement
2929

3030
An implementation of the complete spec is included either for use or as reference:
3131
[`ActiveModelSerializers::Model`](../../lib/active_model_serializers/model.rb).
32-
You can use in production code that will make your PORO a lot cleaner.
32+
You can use in production code that will make your PORO a lot cleaner.
3333

3434
The above code now becomes:
3535

lib/active_model_serializers/model.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ class Model
66
include ActiveModel::Serializers::JSON
77
include ActiveModel::Model
88

9+
# Declare names of attributes to be included in +sttributes+ hash.
10+
# Is only available as a class-method since the ActiveModel::Serialization mixin in Rails
11+
# uses an +attribute_names+ local variable, which may conflict if we were to add instance methods here.
12+
#
13+
# @overload attribute_names
14+
# @return [Array<Symbol>]
15+
class_attribute :attribute_names, instance_writer: false, instance_reader: false
16+
# Initialize +attribute_names+ for all subclasses. The array is usually
17+
# mutated in the +attributes+ method, but can be set directly, as well.
18+
self.attribute_names = []
19+
920
# Easily declare instance attributes with setters and getters for each.
1021
#
1122
# All attributes to initialize an instance must have setters.
@@ -25,12 +36,46 @@ class Model
2536
# @param names [Array<String, Symbol>]
2637
# @param name [String, Symbol]
2738
def self.attributes(*names)
39+
self.attribute_names |= names.map(&:to_sym)
2840
# Silence redefinition of methods warnings
2941
ActiveModelSerializers.silence_warnings do
3042
attr_accessor(*names)
3143
end
3244
end
3345

46+
# Opt-in to breaking change
47+
def self.derive_attributes_from_names_and_fix_accessors
48+
unless included_modules.include?(DeriveAttributesFromNamesAndFixAccessors)
49+
prepend(DeriveAttributesFromNamesAndFixAccessors)
50+
end
51+
end
52+
53+
module DeriveAttributesFromNamesAndFixAccessors
54+
def self.included(base)
55+
# NOTE that +id+ will always be in +attributes+.
56+
base.attributes :id
57+
end
58+
59+
# Override the initialize method so that attributes aren't processed.
60+
#
61+
# @param attributes [Hash]
62+
def initialize(attributes = {})
63+
@errors = ActiveModel::Errors.new(self)
64+
super
65+
end
66+
67+
# Override the +attributes+ method so that the hash is derived from +attribute_names+.
68+
#
69+
# The the fields in +attribute_names+ determines the returned hash.
70+
# +attributes+ are returned frozen to prevent any expectations that mutation affects
71+
# the actual values in the model.
72+
def attributes
73+
self.class.attribute_names.each_with_object({}) do |attribute_name, result|
74+
result[attribute_name] = public_send(attribute_name).freeze
75+
end.with_indifferent_access.freeze
76+
end
77+
end
78+
3479
# Support for validation and other ActiveModel::Errors
3580
# @return [ActiveModel::Errors]
3681
attr_reader :errors

test/action_controller/adapter_selector_test.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
module ActionController
44
module Serialization
55
class AdapterSelectorTest < ActionController::TestCase
6+
class Profile < Model
7+
attributes :id, :name, :description
8+
associations :comments
9+
end
10+
class ProfileSerializer < ActiveModel::Serializer
11+
type 'profiles'
12+
attributes :name, :description
13+
end
14+
615
class AdapterSelectorTestController < ActionController::Base
716
def render_using_default_adapter
817
@profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1')

test/action_controller/namespace_lookup_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module ActionController
44
module Serialization
55
class NamespaceLookupTest < ActionController::TestCase
66
class Book < ::Model
7-
attributes :title, :body
7+
attributes :id, :title, :body
88
associations :writer, :chapters
99
end
1010
class Chapter < ::Model
@@ -86,15 +86,15 @@ def explicit_namespace_as_string
8686
book = Book.new(title: 'New Post', body: 'Body')
8787

8888
# because this is a string, ruby can't auto-lookup the constant, so otherwise
89-
# the looku things we mean ::Api::V2
89+
# the lookup thinks we mean ::Api::V2
9090
render json: book, namespace: 'ActionController::Serialization::NamespaceLookupTest::Api::V2'
9191
end
9292

9393
def explicit_namespace_as_symbol
9494
book = Book.new(title: 'New Post', body: 'Body')
9595

9696
# because this is a string, ruby can't auto-lookup the constant, so otherwise
97-
# the looku things we mean ::Api::V2
97+
# the lookup thinks we mean ::Api::V2
9898
render json: book, namespace: :'ActionController::Serialization::NamespaceLookupTest::Api::V2'
9999
end
100100

test/active_model_serializers/model_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ def test_attributes_can_be_read_for_serialization
4949
assert_equal 1, instance.read_attribute_for_serialization(:one)
5050
end
5151

52+
def test_attributes_can_be_read_for_serialization_with_attributes_accessors_fix
53+
klass = Class.new(ActiveModelSerializers::Model) do
54+
derive_attributes_from_names_and_fix_accessors
55+
attributes :one, :two, :three
56+
end
57+
original_attributes = { one: 1, two: 2, three: 3 }
58+
original_instance = klass.new(original_attributes)
59+
60+
# Initial value
61+
instance = original_instance
62+
expected_attributes = { one: 1, two: 2, three: 3 }.with_indifferent_access
63+
assert_equal expected_attributes, instance.attributes
64+
assert_equal 1, instance.one
65+
assert_equal 1, instance.read_attribute_for_serialization(:one)
66+
67+
expected_attributes = { one: :not_one, two: 2, three: 3 }.with_indifferent_access
68+
# Change via accessor
69+
instance = original_instance.dup
70+
instance.one = :not_one
71+
assert_equal expected_attributes, instance.attributes
72+
assert_equal :not_one, instance.one
73+
assert_equal :not_one, instance.read_attribute_for_serialization(:one)
74+
75+
# Attributes frozen
76+
assert instance.attributes.frozen?
77+
end
78+
5279
def test_id_attribute_can_be_read_for_serialization
5380
klass = Class.new(ActiveModelSerializers::Model) do
5481
attributes :id, :one, :two, :three
@@ -81,5 +108,35 @@ def test_id_attribute_can_be_read_for_serialization
81108
ensure
82109
self.class.send(:remove_const, :SomeTestModel)
83110
end
111+
112+
def test_id_attribute_can_be_read_for_serialization_with_attributes_accessors_fix
113+
klass = Class.new(ActiveModelSerializers::Model) do
114+
derive_attributes_from_names_and_fix_accessors
115+
attributes :id, :one, :two, :three
116+
end
117+
self.class.const_set(:SomeTestModel, klass)
118+
original_attributes = { id: :ego, one: 1, two: 2, three: 3 }
119+
original_instance = klass.new(original_attributes)
120+
121+
# Initial value
122+
instance = original_instance.dup
123+
expected_attributes = { id: :ego, one: 1, two: 2, three: 3 }.with_indifferent_access
124+
assert_equal expected_attributes, instance.attributes
125+
assert_equal :ego, instance.id
126+
assert_equal :ego, instance.read_attribute_for_serialization(:id)
127+
128+
expected_attributes = { id: :superego, one: 1, two: 2, three: 3 }.with_indifferent_access
129+
# Change via accessor
130+
instance = original_instance.dup
131+
instance.id = :superego
132+
assert_equal expected_attributes, instance.attributes
133+
assert_equal :superego, instance.id
134+
assert_equal :superego, instance.read_attribute_for_serialization(:id)
135+
136+
# Attributes frozen
137+
assert instance.attributes.frozen?
138+
ensure
139+
self.class.send(:remove_const, :SomeTestModel)
140+
end
84141
end
85142
end

test/adapter/json/has_many_test.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ module ActiveModelSerializers
44
module Adapter
55
class Json
66
class HasManyTestTest < ActiveSupport::TestCase
7+
class ModelWithoutSerializer < ::Model
8+
attributes :id, :name
9+
end
10+
711
def setup
812
ActionController::Base.cache_store.clear
913
@author = Author.new(id: 1, name: 'Steve K.')
@@ -16,7 +20,7 @@ def setup
1620
@second_comment.post = @post
1721
@blog = Blog.new(id: 1, name: 'My Blog!!')
1822
@post.blog = @blog
19-
@tag = Tag.new(id: 1, name: '#hash_tag')
23+
@tag = ModelWithoutSerializer.new(id: 1, name: '#hash_tag')
2024
@post.tags = [@tag]
2125
end
2226

@@ -30,7 +34,11 @@ def test_has_many
3034
end
3135

3236
def test_has_many_with_no_serializer
33-
serializer = PostWithTagsSerializer.new(@post)
37+
post_serializer_class = Class.new(ActiveModel::Serializer) do
38+
attributes :id
39+
has_many :tags
40+
end
41+
serializer = post_serializer_class.new(@post)
3442
adapter = ActiveModelSerializers::Adapter::Json.new(serializer)
3543
assert_equal({
3644
id: 42,

test/adapter/json_api/has_many_test.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ module ActiveModelSerializers
44
module Adapter
55
class JsonApi
66
class HasManyTest < ActiveSupport::TestCase
7+
class ModelWithoutSerializer < ::Model
8+
attributes :id, :name
9+
end
10+
711
def setup
812
ActionController::Base.cache_store.clear
913
@author = Author.new(id: 1, name: 'Steve K.')
@@ -26,7 +30,7 @@ def setup
2630
@blog.articles = [@post]
2731
@post.blog = @blog
2832
@post_without_comments.blog = nil
29-
@tag = Tag.new(id: 1, name: '#hash_tag')
33+
@tag = ModelWithoutSerializer.new(id: 1, name: '#hash_tag')
3034
@post.tags = [@tag]
3135
@serializer = PostSerializer.new(@post)
3236
@adapter = ActiveModelSerializers::Adapter::JsonApi.new(@serializer)
@@ -129,7 +133,11 @@ def test_include_type_for_association_when_different_than_name
129133
end
130134

131135
def test_has_many_with_no_serializer
132-
serializer = PostWithTagsSerializer.new(@post)
136+
post_serializer_class = Class.new(ActiveModel::Serializer) do
137+
attributes :id
138+
has_many :tags
139+
end
140+
serializer = post_serializer_class.new(@post)
133141
adapter = ActiveModelSerializers::Adapter::JsonApi.new(serializer)
134142

135143
assert_equal({

test/adapter/json_api/include_data_if_sideloaded_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@ def all
1414
[{ foo: 'bar' }]
1515
end
1616
end
17+
class Tag < ::Model
18+
attributes :id, :name
19+
end
1720

1821
class TagSerializer < ActiveModel::Serializer
22+
type 'tags'
1923
attributes :id, :name
2024
end
2125

26+
class PostWithTagsSerializer < ActiveModel::Serializer
27+
type 'posts'
28+
attributes :id
29+
has_many :tags
30+
end
31+
2232
class IncludeParamAuthorSerializer < ActiveModel::Serializer
2333
class_attribute :comment_loader
2434

0 commit comments

Comments
 (0)