Skip to content

Commit 606e2ae

Browse files
committed
Merge pull request #1127 from NullVoxPopuli/support-nested-associations-for-json-adapter
Support nested associations for Json and Attributes adapters + Refactor Attributes adapter
2 parents bac43af + a74ea18 commit 606e2ae

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)