Skip to content

Commit a74ea18

Browse files
committed
Refactors of the Attribute adapter. Adds support for nested associations specified from the include key in the controller. Adds some tests and some method documentation
1 parent 94cee19 commit a74ea18

File tree

5 files changed

+289
-31
lines changed

5 files changed

+289
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Features:
2323
* adds support for `pagination links` at top level of JsonApi adapter [@bacarini]
2424
* adds extended format for `include` option to JsonApi adapter [@beauby]
2525
* adds support for wildcards in `include` option [@beauby]
26+
* adds support for nested associations for JSON and Attributes adapters via the `include` option [@NullVoxPopuli, @beauby]
2627

2728
Fixes:
2829

lib/active_model/serializer/adapter/attributes.rb

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,12 @@ def initialize(serializer, options = {})
99

1010
def serializable_hash(options = nil)
1111
options ||= {}
12+
1213
if serializer.respond_to?(:each)
13-
result = serializer.map { |s| Attributes.new(s, instance_options).serializable_hash(options) }
14+
serializable_hash_for_collection(options)
1415
else
15-
hash = {}
16-
17-
core = cache_check(serializer) do
18-
serializer.attributes(options)
19-
end
20-
21-
serializer.associations(@include_tree).each do |association|
22-
serializer = association.serializer
23-
association_options = association.options
24-
25-
if serializer.respond_to?(:each)
26-
array_serializer = serializer
27-
hash[association.key] = array_serializer.map do |item|
28-
cache_check(item) do
29-
item.attributes(association_options)
30-
end
31-
end
32-
else
33-
hash[association.key] =
34-
if serializer && serializer.object
35-
cache_check(serializer) do
36-
serializer.attributes(options)
37-
end
38-
elsif association_options[:virtual_value]
39-
association_options[:virtual_value]
40-
end
41-
end
42-
end
43-
result = core.merge hash
16+
serializable_hash_for_single_resource(options)
4417
end
45-
result
4618
end
4719

4820
def fragment_cache(cached_hash, non_cached_hash)
@@ -51,10 +23,43 @@ def fragment_cache(cached_hash, non_cached_hash)
5123

5224
private
5325

26+
def serializable_hash_for_collection(options)
27+
serializer.map { |s| Attributes.new(s, instance_options).serializable_hash(options) }
28+
end
29+
30+
def serializable_hash_for_single_resource(options)
31+
resource = resource_object_for(options)
32+
relationships = resource_relationships(options)
33+
resource.merge!(relationships)
34+
end
35+
36+
def resource_relationships(options)
37+
relationships = {}
38+
serializer.associations(@include_tree).each do |association|
39+
relationships[association.key] = relationship_value_for(association, options)
40+
end
41+
42+
relationships
43+
end
44+
45+
def relationship_value_for(association, options)
46+
return association.options[:virtual_value] if association.options[:virtual_value]
47+
return unless association.serializer && association.serializer.object
48+
49+
opts = instance_options.merge(include: @include_tree[association.key])
50+
Attributes.new(association.serializer, opts).serializable_hash(options)
51+
end
52+
5453
# no-op: Attributes adapter does not include meta data, because it does not support root.
5554
def include_meta(json)
5655
json
5756
end
57+
58+
def resource_object_for(options)
59+
cache_check(serializer) do
60+
serializer.attributes(options)
61+
end
62+
end
5863
end
5964
end
6065
end

lib/active_model/serializer/include_tree.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
11
module ActiveModel
22
class Serializer
3+
# TODO: description of this class, and overview of how it's used
34
class IncludeTree
45
module Parsing
56
module_function
67

8+
# Translates a comma separated list of dot separated paths (JSON API format) into a Hash.
9+
#
10+
# @example
11+
# `'posts.author, posts.comments.upvotes, posts.comments.author'`
12+
#
13+
# would become
14+
#
15+
# `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`.
16+
#
17+
# @param [String] included
18+
# @return [Hash] a Hash representing the same tree structure
719
def include_string_to_hash(included)
20+
# TODO: Needs comment walking through the process of what all this is doing.
821
included.delete(' ').split(',').reduce({}) do |hash, path|
922
include_tree = path.split('.').reverse_each.reduce({}) { |a, e| { e.to_sym => a } }
1023
hash.deep_merge!(include_tree)
1124
end
1225
end
1326

27+
# Translates the arguments passed to the include option into a Hash. The format can be either
28+
# a String (see #include_string_to_hash), an Array of Symbols and Hashes, or a mix of both.
29+
#
30+
# @example
31+
# `posts: [:author, comments: [:author, :upvotes]]`
32+
#
33+
# would become
34+
#
35+
# `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`.
36+
#
37+
# @example
38+
# `[:author, :comments => [:author]]`
39+
#
40+
# would become
41+
#
42+
# `{:author => {}, :comments => { author: {} } }`
43+
#
44+
# @param [Symbol, Hash, Array, String] included
45+
# @return [Hash] a Hash representing the same tree structure
1446
def include_args_to_hash(included)
1547
case included
1648
when Symbol
@@ -47,6 +79,8 @@ def self.from_string(included)
4779
# @return [IncludeTree]
4880
#
4981
def self.from_include_args(included)
82+
return included if included.is_a?(IncludeTree)
83+
5084
new(Parsing.include_args_to_hash(included))
5185
end
5286

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
require 'test_helper'
2+
3+
module ActionController
4+
module Serialization
5+
class Json
6+
class IncludeTest < ActionController::TestCase
7+
class IncludeTestController < ActionController::Base
8+
def setup_data
9+
ActionController::Base.cache_store.clear
10+
11+
@author = Author.new(id: 1, name: 'Steve K.')
12+
13+
@post = Post.new(id: 42, title: 'New Post', body: 'Body')
14+
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
15+
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT')
16+
17+
@post.comments = [@first_comment, @second_comment]
18+
@post.author = @author
19+
20+
@first_comment.post = @post
21+
@second_comment.post = @post
22+
23+
@blog = Blog.new(id: 1, name: 'My Blog!!')
24+
@post.blog = @blog
25+
@author.posts = [@post]
26+
27+
@first_comment.author = @author
28+
@second_comment.author = @author
29+
@author.comments = [@first_comment, @second_comment]
30+
@author.roles = []
31+
@author.bio = {}
32+
end
33+
34+
def render_without_include
35+
setup_data
36+
render json: @author, adapter: :json
37+
end
38+
39+
def render_resource_with_include_hash
40+
setup_data
41+
render json: @author, include: { posts: :comments }, adapter: :json
42+
end
43+
44+
def render_resource_with_include_string
45+
setup_data
46+
render json: @author, include: 'posts.comments', adapter: :json
47+
end
48+
49+
def render_resource_with_deep_include
50+
setup_data
51+
render json: @author, include: 'posts.comments.author', adapter: :json
52+
end
53+
end
54+
55+
tests IncludeTestController
56+
57+
def test_render_without_include
58+
get :render_without_include
59+
response = JSON.parse(@response.body)
60+
expected = {
61+
'author' => {
62+
'id' => 1,
63+
'name' => 'Steve K.',
64+
'posts' => [
65+
{
66+
'id' => 42, 'title' => 'New Post', 'body' => 'Body'
67+
}
68+
],
69+
'roles' => [],
70+
'bio' => {}
71+
}
72+
}
73+
74+
assert_equal(expected, response)
75+
end
76+
77+
def test_render_resource_with_include_hash
78+
get :render_resource_with_include_hash
79+
response = JSON.parse(@response.body)
80+
expected = {
81+
'author' => {
82+
'id' => 1,
83+
'name' => 'Steve K.',
84+
'posts' => [
85+
{
86+
'id' => 42, 'title' => 'New Post', 'body' => 'Body',
87+
'comments' => [
88+
{
89+
'id' => 1, 'body' => 'ZOMG A COMMENT'
90+
},
91+
{
92+
'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT'
93+
}
94+
]
95+
}
96+
]
97+
}
98+
}
99+
100+
assert_equal(expected, response)
101+
end
102+
103+
def test_render_resource_with_include_string
104+
get :render_resource_with_include_string
105+
106+
response = JSON.parse(@response.body)
107+
expected = {
108+
'author' => {
109+
'id' => 1,
110+
'name' => 'Steve K.',
111+
'posts' => [
112+
{
113+
'id' => 42, 'title' => 'New Post', 'body' => 'Body',
114+
'comments' => [
115+
{
116+
'id' => 1, 'body' => 'ZOMG A COMMENT'
117+
},
118+
{
119+
'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT'
120+
}
121+
]
122+
}
123+
]
124+
}
125+
}
126+
127+
assert_equal(expected, response)
128+
end
129+
130+
def test_render_resource_with_deep_include
131+
get :render_resource_with_deep_include
132+
133+
response = JSON.parse(@response.body)
134+
expected = {
135+
'author' => {
136+
'id' => 1,
137+
'name' => 'Steve K.',
138+
'posts' => [
139+
{
140+
'id' => 42, 'title' => 'New Post', 'body' => 'Body',
141+
'comments' => [
142+
{
143+
'id' => 1, 'body' => 'ZOMG A COMMENT',
144+
'author' => {
145+
'id' => 1,
146+
'name' => 'Steve K.'
147+
}
148+
},
149+
{
150+
'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT',
151+
'author' => {
152+
'id' => 1,
153+
'name' => 'Steve K.'
154+
}
155+
}
156+
]
157+
}
158+
]
159+
}
160+
}
161+
162+
assert_equal(expected, response)
163+
end
164+
end
165+
end
166+
end
167+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require 'test_helper'
2+
3+
module ActiveModel
4+
class Serializer
5+
class IncludeTree
6+
module Parsing
7+
class IncludeArgsToHashTest < MiniTest::Test
8+
def test_include_args_to_hash_from_symbol
9+
expected = { author: {} }
10+
input = :author
11+
actual = Parsing.include_args_to_hash(input)
12+
13+
assert_equal(expected, actual)
14+
end
15+
16+
def test_include_args_to_hash_from_array
17+
expected = { author: {}, comments: {} }
18+
input = [:author, :comments]
19+
actual = Parsing.include_args_to_hash(input)
20+
21+
assert_equal(expected, actual)
22+
end
23+
24+
def test_include_args_to_hash_from_nested_array
25+
expected = { author: {}, comments: { author: {} } }
26+
input = [:author, comments: [:author]]
27+
actual = Parsing.include_args_to_hash(input)
28+
29+
assert_equal(expected, actual)
30+
end
31+
32+
def test_include_args_to_hash_from_array_of_hashes
33+
expected = {
34+
author: {},
35+
blogs: { posts: { contributors: {} } },
36+
comments: { author: { blogs: { posts: {} } } }
37+
}
38+
input = [
39+
:author,
40+
blogs: [posts: :contributors],
41+
comments: { author: { blogs: :posts } }
42+
]
43+
actual = Parsing.include_args_to_hash(input)
44+
45+
assert_equal(expected, actual)
46+
end
47+
end
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)