Skip to content

Commit ac06013

Browse files
committed
Add support for wildcard includes + improve perfs on JsonApi includes.
1 parent ab1e2af commit ac06013

File tree

11 files changed

+238
-145
lines changed

11 files changed

+238
-145
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Features:
2222
* adds FlattenJSON as default adapter [@joaomdmoura]
2323
* adds support for `pagination links` at top level of JsonApi adapter [@bacarini]
2424
* adds extended format for `include` option to JsonApi adapter [@beauby]
25+
* adds support for wildcards in `include` option [@beauby]
2526

2627
Fixes:
2728

lib/active_model/serializer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
require 'thread_safe'
22
require 'active_model/serializer/adapter'
33
require 'active_model/serializer/array_serializer'
4+
require 'active_model/serializer/include_tree'
45
require 'active_model/serializer/associations'
56
require 'active_model/serializer/configuration'
67
require 'active_model/serializer/fieldset'
78
require 'active_model/serializer/lint'
8-
require 'active_model/serializer/utils'
99

1010
module ActiveModel
1111
class Serializer

lib/active_model/serializer/adapter/attributes.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ module ActiveModel
22
class Serializer
33
module Adapter
44
class Attributes < Base
5+
def initialize(serializer, options = {})
6+
super
7+
@include_tree = IncludeTree.from_include_args(options[:include] || '*')
8+
end
9+
510
def serializable_hash(options = nil)
611
options ||= {}
712
if serializer.respond_to?(:each)
@@ -13,7 +18,7 @@ def serializable_hash(options = nil)
1318
serializer.attributes(options)
1419
end
1520

16-
serializer.associations.each do |association|
21+
serializer.associations(@include_tree).each do |association|
1722
serializer = association.serializer
1823
association_options = association.options
1924

lib/active_model/serializer/adapter/json_api.rb

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ class JsonApi < Base
88

99
def initialize(serializer, options = {})
1010
super
11-
@included = ActiveModel::Serializer::Utils.include_args_to_hash(instance_options[:include])
11+
@include_tree = IncludeTree.from_include_args(options[:include])
12+
1213
fields = options.delete(:fields)
1314
if fields
1415
@fieldset = ActiveModel::Serializer::Fieldset.new(fields, serializer.json_key)
@@ -19,10 +20,11 @@ def initialize(serializer, options = {})
1920

2021
def serializable_hash(options = nil)
2122
options ||= {}
23+
2224
if serializer.respond_to?(:each)
23-
serializable_hash_for_collection(serializer, options)
25+
serializable_hash_for_collection(options)
2426
else
25-
serializable_hash_for_single_resource(serializer, options)
27+
serializable_hash_for_single_resource(options)
2628
end
2729
end
2830

@@ -34,10 +36,10 @@ def fragment_cache(cached_hash, non_cached_hash)
3436
private
3537

3638
ActiveModel.silence_warnings do
37-
attr_reader :included, :fieldset
39+
attr_reader :fieldset
3840
end
3941

40-
def serializable_hash_for_collection(serializer, options)
42+
def serializable_hash_for_collection(options)
4143
hash = { data: [] }
4244
serializer.each do |s|
4345
result = self.class.new(s, instance_options.merge(fieldset: fieldset)).serializable_hash(options)
@@ -57,10 +59,10 @@ def serializable_hash_for_collection(serializer, options)
5759
hash
5860
end
5961

60-
def serializable_hash_for_single_resource(serializer, options)
62+
def serializable_hash_for_single_resource(options)
6163
primary_data = primary_data_for(serializer, options)
6264
relationships = relationships_for(serializer)
63-
included = included_for(serializer)
65+
included = included_resources(@include_tree)
6466
hash = { data: primary_data }
6567
hash[:data][:relationships] = relationships if relationships.any?
6668
hash[:included] = included if included.any?
@@ -123,37 +125,37 @@ def relationship_value_for(serializer, options = {})
123125
end
124126

125127
def relationships_for(serializer)
126-
Hash[serializer.associations.map { |association| [association.key, { data: relationship_value_for(association.serializer, association.options) }] }]
128+
serializer.associations.each_with_object({}) do |association, hash|
129+
hash[association.key] = { data: relationship_value_for(association.serializer, association.options) }
130+
end
127131
end
128132

129-
def included_for(serializer)
130-
included.flat_map { |inc|
131-
association = serializer.associations.find { |assoc| assoc.key == inc.first }
132-
_included_for(association.serializer, inc.second) if association
133-
}.uniq
133+
def included_resources(include_tree)
134+
included = []
135+
136+
serializer.associations(include_tree).each do |association|
137+
add_included_resources_for(association.serializer, include_tree[association.key], included)
138+
end
139+
140+
included
134141
end
135142

136-
def _included_for(serializer, includes)
143+
def add_included_resources_for(serializer, include_tree, included)
137144
if serializer.respond_to?(:each)
138-
serializer.flat_map { |s| _included_for(s, includes) }.uniq
145+
serializer.each { |s| add_included_resources_for(s, include_tree, included) }
139146
else
140-
return [] unless serializer && serializer.object
147+
return unless serializer && serializer.object
141148

142149
primary_data = primary_data_for(serializer, instance_options)
143150
relationships = relationships_for(serializer)
144151
primary_data[:relationships] = relationships if relationships.any?
145152

146-
included = [primary_data]
153+
return if included.include?(primary_data)
154+
included.push(primary_data)
147155

148-
includes.each do |inc|
149-
association = serializer.associations.find { |assoc| assoc.key == inc.first }
150-
if association
151-
included.concat(_included_for(association.serializer, inc.second))
152-
included.uniq!
153-
end
156+
serializer.associations(include_tree).each do |association|
157+
add_included_resources_for(association.serializer, include_tree[association.key], included)
154158
end
155-
156-
included
157159
end
158160
end
159161

lib/active_model/serializer/associations.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class Serializer
1010
module Associations
1111
extend ActiveSupport::Concern
1212

13+
DEFAULT_INCLUDE_TREE = ActiveModel::Serializer::IncludeTree.from_string('*')
14+
1315
included do |base|
1416
class << base
1517
attr_accessor :_reflections
@@ -82,13 +84,15 @@ def associate(reflection)
8284
end
8385
end
8486

87+
# @param [IncludeTree] include_tree (defaults to all associations when not provided)
8588
# @return [Enumerator<Association>]
8689
#
87-
def associations
90+
def associations(include_tree = DEFAULT_INCLUDE_TREE)
8891
return unless object
8992

9093
Enumerator.new do |y|
9194
self.class._reflections.each do |reflection|
95+
next unless include_tree.key?(reflection.name)
9296
y.yield reflection.build_association(self, instance_options)
9397
end
9498
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
module ActiveModel
2+
class Serializer
3+
class IncludeTree
4+
module Parsing
5+
module_function
6+
7+
def include_string_to_hash(included)
8+
included.delete(' ').split(',').reduce({}) do |hash, path|
9+
include_tree = path.split('.').reverse_each.reduce({}) { |a, e| { e.to_sym => a } }
10+
hash.deep_merge!(include_tree)
11+
end
12+
end
13+
14+
def include_args_to_hash(included)
15+
case included
16+
when Symbol
17+
{ included => {} }
18+
when Hash
19+
included.each_with_object({}) { |(key, value), hash|
20+
hash[key] = include_args_to_hash(value)
21+
}
22+
when Array
23+
included.reduce({}) { |a, e| a.merge!(include_args_to_hash(e)) }
24+
when String
25+
include_string_to_hash(included)
26+
else
27+
{}
28+
end
29+
end
30+
end
31+
32+
# Builds an IncludeTree from a comma separated list of dot separated paths (JSON API format).
33+
# @example `'posts.author, posts.comments.upvotes, posts.comments.author'`
34+
#
35+
# @param [String] included
36+
# @return [IncludeTree]
37+
#
38+
def self.from_string(included)
39+
new(Parsing.include_string_to_hash(included))
40+
end
41+
42+
# Translates the arguments passed to the include option into an IncludeTree.
43+
# The format can be either a String (see #from_string), an Array of Symbols and Hashes, or a mix of both.
44+
# @example `posts: [:author, comments: [:author, :upvotes]]`
45+
#
46+
# @param [Symbol, Hash, Array, String] included
47+
# @return [IncludeTree]
48+
#
49+
def self.from_include_args(included)
50+
new(Parsing.include_args_to_hash(included))
51+
end
52+
53+
# @param [Hash] hash
54+
def initialize(hash = {})
55+
@hash = hash
56+
end
57+
58+
def key?(key)
59+
@hash.key?(key) || @hash.key?(:*) || @hash.key?(:**)
60+
end
61+
62+
def [](key)
63+
# TODO(beauby): Adopt a lazy caching strategy for generating subtrees.
64+
case
65+
when @hash.key?(key)
66+
self.class.new(@hash[key])
67+
when @hash.key?(:*)
68+
self.class.new(@hash[:*])
69+
when @hash.key?(:**)
70+
self.class.new(:** => {})
71+
end
72+
end
73+
end
74+
end
75+
end

lib/active_model/serializer/utils.rb

Lines changed: 0 additions & 35 deletions
This file was deleted.

test/action_controller/json_api/linked_test.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ def render_resource_with_nested_include
5151
render json: @post, include: [comments: [:author]], adapter: :json_api
5252
end
5353

54-
def render_resource_with_nested_has_many_include
54+
def render_resource_with_nested_has_many_include_wildcard
5555
setup_post
56-
render json: @post, include: 'author.roles', adapter: :json_api
56+
render json: @post, include: 'author.*', adapter: :json_api
5757
end
5858

5959
def render_resource_with_missing_nested_has_many_include
@@ -96,7 +96,7 @@ def test_render_resource_with_include
9696
end
9797

9898
def test_render_resource_with_nested_has_many_include
99-
get :render_resource_with_nested_has_many_include
99+
get :render_resource_with_nested_has_many_include_wildcard
100100
response = JSON.parse(@response.body)
101101
expected_linked = [
102102
{
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
require 'test_helper'
2+
3+
module ActiveModel
4+
class Serializer
5+
class IncludeTree
6+
class FromStringTest < Minitest::Test
7+
def test_simple_array
8+
input = [:comments, :author]
9+
actual = ActiveModel::Serializer::IncludeTree.from_include_args(input)
10+
assert(actual.key?(:author))
11+
assert(actual.key?(:comments))
12+
end
13+
14+
def test_nested_array
15+
input = [:comments, posts: [:author, comments: [:author]]]
16+
actual = ActiveModel::Serializer::IncludeTree.from_include_args(input)
17+
assert(actual.key?(:posts))
18+
assert(actual[:posts].key?(:author))
19+
assert(actual[:posts].key?(:comments))
20+
assert(actual[:posts][:comments].key?(:author))
21+
assert(actual.key?(:comments))
22+
end
23+
end
24+
end
25+
end
26+
end

0 commit comments

Comments
 (0)