Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for Nested Relationships for the Json Adapter #1114

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 109 additions & 28 deletions lib/active_model/serializer/adapter/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,13 @@ class Json < Adapter
def serializable_hash(options = nil)
options ||= {}
if serializer.respond_to?(:each)
@result = serializer.map { |s| FlattenJson.new(s).serializable_hash(options) }
@result = serialize_array_without_root(serializer, options)
else
@hash = {}
@result = resource_object_for(serializer)
@result = add_resource_relationships(@result, serializer)

@core = cache_check(serializer) do
serializer.attributes(options)
end

serializer.associations.each do |association|
serializer = association.serializer
opts = association.options

if serializer.respond_to?(:each)
array_serializer = serializer
@hash[association.key] = array_serializer.map do |item|
cache_check(item) do
item.attributes(opts)
end
end
else
@hash[association.key] =
if serializer && serializer.object
cache_check(serializer) do
serializer.attributes(options)
end
elsif opts[:virtual_value]
opts[:virtual_value]
end
end
end
@result = @core.merge @hash
@result
end

{ root => @result }
Expand All @@ -46,6 +22,111 @@ def serializable_hash(options = nil)
def fragment_cache(cached_hash, non_cached_hash)
Json::FragmentCache.new().fragment_cache(cached_hash, non_cached_hash)
end

private
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


# iterate through the associations on the serializer,
# adding them to the parent as needed (as singular or plural)
#
# nested_associations is a list of symbols that governs what
# associations on the passed in seralizer to include
def add_resource_relationships(parent, serializer, nested_associations = [])
# have the include array normalized
nested_associations = ActiveModel::Serializer::Utils.include_array_to_hash(nested_associations)

included_associations = if nested_associations.present?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so nested_associations should be an normalized array with the nested objects you need.

Eg.
BlogSerializer with has_many :posts include: [:author]

so nested_associations will tell you that you need author but inside this if you check if it is included iinside serializer.associations with .select. But serializer.associations will have only posts.

Initially I thought this was to check if it's an already rendered relationship and avoid render it again. but it seems it is used here to loop the associations.

What I presume is the the code inside this if might never be executed unless you have something like (probably):

Eg.
BlogSerializer with has_many :posts include: [:author.posts]

then you will find the posts inside nested_associations as a member of serializer.associations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if body actually decides what nested association to render.

Cause, lets say a post has a ton of associations, but you only want the author.
by the time you get to rendering the posts, [:author] will be passed in as nested_associations and the select filters for :author among all of a post's associations.

(The tests also cover this scenario)

serializer.associations.select{ |association|
# nested_associations is a hash of:
# key => nested association to include
nested_associations.has_key?(association.name)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably be nested_associations.has_key?(association.name)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dig it.

else
serializer.associations
end

included_associations.each do |association|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can loop straight through serializer.associations here and delete the whole included_associations block before it because of what I mentioned

serializer = association.serializer
opts = association.options
key = association.key

# sanity check if the association has nesting data
has_nesting = nested_associations[key].present?
if has_nesting
include_options_from_parent = { include: nested_associations[key] }
opts = opts.merge(include_options_from_parent)
end

if serializer.respond_to?(:each)
parent[key] = add_relationships(serializer, opts)
else
parent[key] = add_relationship(serializer, opts)
end
end

parent
end

# add a singular relationship
# the options should always belong to the serializer
def add_relationship(serializer, options)
serialized_relationship = serialized_or_virtual_of(serializer, options)

nested_associations_to_include = options[:include]
if nested_associations_to_include.present?
serialized_relationship = add_resource_relationships(
serialized_relationship,
serializer,
nested_associations_to_include)
end

serialized_relationship
end

# add a many relationship
def add_relationships(serializer, options)
serialize_array(serializer, options) do |serialized_item, item_serializer|
nested_associations_to_include = options[:include]

if nested_associations_to_include.present?
serialized_item = add_resource_relationships(
serialized_item,
item_serializer,
nested_associations_to_include)
end

serialized_item
end
end


def serialize_array_without_root(serializer, options)
serializer.map { |s| FlattenJson.new(s).serializable_hash(options) }
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the serializer should be calling the adapter... but I guess you didn't add that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the json_api adapter

serializer.each do |s|
result = self.class.new(s, @options.merge(fieldset: @fieldset)).serializable_hash(options)
does

          if serializer.respond_to?(:each)
            serializer.each do |s|
              result = self.class.new(s, @options.merge(fieldset: @fieldset)).serializable_hash(options)
              @hash[:data] << result[:data]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that doesn't look very nice to look at. :-\

aren't there json_api refactors in the prs?

I'm curious how that cleans things up.


# a virtual value is something that doesn't need a serializer,
# such as a ruby array, or any other raw value
def serialized_or_virtual_of(serializer, options)
if serializer && serializer.object
resource_object_for(serializer)
elsif options[:virtual_value]
options[:virtual_value]
end
end

def serialize_array(serializer, options)
serializer.map do |item|
serialized_item = resource_object_for(item)
serialized_item = yield(serialized_item, item) if block_given?
serialized_item
end
end

def resource_object_for(serializer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice abstraction! 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

cache_check(serializer) do
serializer.attributes(serializer.options)
end
end

end
end
end
Expand Down
42 changes: 42 additions & 0 deletions lib/active_model/serializer/utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module ActiveModel::Serializer::Utils
module_function

# converts the include hash to a standard format
# for constrained serialization recursion
#
# converts
# [:author, :comments => [:author]] to
# {:author => [], :comments => [:author]}
#
# and
# [:author, :comments => {:author => :bio}, :posts => [:comments]] to
# {:author => [], :comments => {:author => :bio}, :posts => [:comments]}
#
# The data passed in to this method should be an array where the last
# parameter is a hash
#
# the point of this method is to normalize the include
# options for the child relationships.
# if a sub inclusion is still an array after this method,
# it will get converted during the next iteration
def include_array_to_hash(include_array)
# still don't trust input
# but this also allows
# include: :author syntax
include_array = Array[*include_array].compact

result = {}

hashes = include_array.select{|a| a.is_a?(Hash)}
non_hashes = include_array - hashes

hashes += non_hashes.map{ |association_name| { association_name => [] } }

# now merge all the hashes
hashes.each{|hash| result.merge!(hash) }

result
end


end
1 change: 1 addition & 0 deletions lib/active_model_serializers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def silence_warnings
require 'action_controller'

require 'active_model/serializer'
require 'active_model/serializer/utils'
require 'active_model/serializable_resource'
require 'active_model/serializer/version'

Expand Down
18 changes: 10 additions & 8 deletions test/action_controller/serialization_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ def render_using_implicit_serializer
end

def render_using_default_adapter_root
@profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1')
@profile = Profile.new({ name: 'Name 1', description: 'Description 1', comments: 'Comments 1' })
render json: @profile
end

def render_array_using_custom_root
@profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1')
render json: [@profile], root: 'custom_root'
@profile = Profile.new({ name: 'Name 1', description: 'Description 1', comments: 'Comments 1' })
render json: [@profile], root: "custom_root"
end

def render_array_that_is_empty_using_custom_root
render json: [], root: 'custom_root'
render json: [], root: "custom_root"
end

def render_object_using_custom_root
@profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1')
render json: @profile, root: 'custom_root'
@profile = Profile.new({ name: 'Name 1', description: 'Description 1', comments: 'Comments 1' })
render json: @profile, root: "custom_root"
end

def render_array_using_implicit_serializer
Expand All @@ -39,8 +39,9 @@ def render_array_using_implicit_serializer

def render_array_using_implicit_serializer_and_meta
@profiles = [
Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1')
Profile.new({ name: 'Name 1', description: 'Description 1', comments: 'Comments 1' })
]

render json: @profiles, meta: { total: 10 }
end

Expand Down Expand Up @@ -178,7 +179,8 @@ def test_render_array_using_custom_root
with_adapter :json do
get :render_array_using_custom_root
end
expected = { custom_roots: [{ name: 'Name 1', description: 'Description 1' }] }

expected = {custom_roots: [{name: "Name 1", description: "Description 1"}]}
assert_equal 'application/json', @response.content_type
assert_equal expected.to_json, @response.body
end
Expand Down
Loading