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

Fix duplicate resources inside included in compound document. #1239

Merged
merged 2 commits into from
Oct 8, 2015
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Features:
- [#1050](https://github.com/rails-api/active_model_serializers/pull/1050) Add support for toplevel jsonapi member (@beauby, @bf4)

Fixes:
- [#1239](https://github.com/rails-api/active_model_serializers/pull/1239) Fix duplicates in JSON API compound documents (@beauby)
- [#1214](https://github.com/rails-api/active_model_serializers/pull/1214) retrieve the key from the reflection options when building associations (@NullVoxPopuli, @hut8)

Misc:
Expand Down
33 changes: 18 additions & 15 deletions lib/active_model/serializer/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,18 @@ def fragment_cache(cached_hash, non_cached_hash)

def serializable_hash_for_collection(options)
hash = { data: [] }
included = []
serializer.each do |s|
result = self.class.new(s, instance_options.merge(fieldset: fieldset)).serializable_hash(options)
hash[:data] << result[:data]
next unless result[:included]

if result[:included]
hash[:included] ||= []
hash[:included] |= result[:included]
end
included |= result[:included]
Copy link
Member

Choose a reason for hiding this comment

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

refactor

end

included.delete_if { |resource| hash[:data].include?(resource) }
Copy link
Member

Choose a reason for hiding this comment

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

I see we are deleting here the duplicated resource, but it seems hacky, it's not addressing the problem itself, just working around it, did you found the main issue causing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is a structural issue. When the primary resource is a single resource (serializable_hash_for_single_resource), we make sure we do not add to included any related resource that is the primary resource. However, when the primary resource is a collection (serializable_hash_for_collection), the adapter works by computing the hash for every resource independently, and then combining the hashes into a big one. This has the advantage of clear code, but the disadvantage that when serializing [a, b] where a includes b, we have no other choice than manually removing duplicates when aggregating.

Copy link
Member

Choose a reason for hiding this comment

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

got it 😉

Copy link
Member

Choose a reason for hiding this comment

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

deletes resource from included if is a root resource

this included stuff is getting messy. needs a refactor before it sends even more vines through the class

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 believe it is the best we can achieve without refactoring the adapter so that it does not call itself recursively for collections (which I'm in favor of, in a subsequent PR). The scope of this PR was to fix a behavior breaking JSON API spec, without modifying the adapter too much.

hash[:included] = included if included.any?

if serializer.paginated?
hash[:links] ||= {}
hash[:links].update(links_for(serializer, options))
Expand All @@ -102,9 +104,10 @@ def serializable_hash_for_collection(options)
def serializable_hash_for_single_resource
primary_data = primary_data_for(serializer)
relationships = relationships_for(serializer)
included = included_resources(@include_tree)
primary_data[:relationships] = relationships if relationships.any?
Copy link
Member

Choose a reason for hiding this comment

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

simple refactor, nice

hash = { data: primary_data }
hash[:data][:relationships] = relationships if relationships.any?

included = included_resources(@include_tree, [primary_data])
Copy link
Member

Choose a reason for hiding this comment

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

so, included_resources now takes in [primary_data] as a second argument

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is to ensure, preemptively, that we do not include any of the primary data in the included hash when building the hash for a single resource.

hash[:included] = included if included.any?

hash
Expand Down Expand Up @@ -171,31 +174,31 @@ def relationships_for(serializer)
end
end

def included_resources(include_tree)
def included_resources(include_tree, primary_data)
included = []

serializer.associations(include_tree).each do |association|
add_included_resources_for(association.serializer, include_tree[association.key], included)
add_included_resources_for(association.serializer, include_tree[association.key], primary_data, included)
Copy link
Member

Choose a reason for hiding this comment

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

and add_included_resources_for passes primary_data (which is [primary_data]) through

end

included
end

def add_included_resources_for(serializer, include_tree, included)
def add_included_resources_for(serializer, include_tree, primary_data, included)
if serializer.respond_to?(:each)
serializer.each { |s| add_included_resources_for(s, include_tree, included) }
serializer.each { |s| add_included_resources_for(s, include_tree, primary_data, included) }
Copy link
Member

Choose a reason for hiding this comment

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

and primary_data (which is [primary_data]) is a new argument to add_included_resources_for

else
return unless serializer && serializer.object

primary_data = primary_data_for(serializer)
resource_object = primary_data_for(serializer)
relationships = relationships_for(serializer)
primary_data[:relationships] = relationships if relationships.any?
resource_object[:relationships] = relationships if relationships.any?
Copy link
Member

Choose a reason for hiding this comment

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

nice refactor


return if included.include?(primary_data)
included.push(primary_data)
return if included.include?(resource_object) || primary_data.include?(resource_object)
Copy link
Member

Choose a reason for hiding this comment

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

👍

Copy link
Member

Choose a reason for hiding this comment

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

And we add primary_data.include?(resource_object)

( which is true when [primary_data].include(resource_object) which is approx [primary_data_for(serializer).update(relationships: relationships_for(serializer))].include(primary_data_for(serializer).update(relationships: relationships_for(serializer)))

i.e. primary data is for same serializer)

as condition to return early

included.push(resource_object)

serializer.associations(include_tree).each do |association|
add_included_resources_for(association.serializer, include_tree[association.key], included)
add_included_resources_for(association.serializer, include_tree[association.key], primary_data, included)
Copy link
Member

Choose a reason for hiding this comment

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

and pass along our [primary_data] again to add_included_resources_for

end
end
end
Expand Down
113 changes: 112 additions & 1 deletion test/adapter/json_api/linked_test.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
require 'test_helper'

NestedPost = Class.new(Model)
class NestedPostSerializer < ActiveModel::Serializer
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say because this model and serializer are created here, and specific to this test, they should be namespaced under the test. :-\

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 we agree we'll start doing that soon

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. Let's leave them there for now, and take care of it during the Great Test Refactor.

has_many :nested_posts
end

module ActiveModel
class Serializer
module Adapter
class JsonApi
class LinkedTest < Minitest::Test
def setup
ActionController::Base.cache_store.clear
@author1 = Author.new(id: 1, name: 'Steve K.')
@author2 = Author.new(id: 2, name: 'Tenderlove')
@bio1 = Bio.new(id: 1, content: 'AMS Contributor')
Expand Down Expand Up @@ -277,6 +282,112 @@ def test_nil_link_with_specified_serializer
assert_equal expected, adapter.serializable_hash
end
end

class NoDuplicatesTest < Minitest::Test
Post = Class.new(::Model)
Author = Class.new(::Model)

class PostSerializer < ActiveModel::Serializer
type 'posts'
belongs_to :author
end

class AuthorSerializer < ActiveModel::Serializer
type 'authors'
has_many :posts
end

def setup
@author = Author.new(id: 1, posts: [], roles: [], bio: nil)
@post1 = Post.new(id: 1, author: @author)
@post2 = Post.new(id: 2, author: @author)
@author.posts << @post1
@author.posts << @post2

@nestedpost1 = ::NestedPost.new(id: 1, nested_posts: [])
@nestedpost2 = ::NestedPost.new(id: 2, nested_posts: [])
@nestedpost1.nested_posts << @nestedpost1
@nestedpost1.nested_posts << @nestedpost2
@nestedpost2.nested_posts << @nestedpost1
@nestedpost2.nested_posts << @nestedpost2
end

def test_no_duplicates
hash = ActiveModel::SerializableResource.new(@post1, adapter: :json_api,
include: '*.*')
.serializable_hash
expected = [
{
type: 'authors', id: '1',
relationships: {
posts: {
data: [
{ type: 'posts', id: '1' },
{ type: 'posts', id: '2' }
]
}
}
},
{
type: 'posts', id: '2',
relationships: {
author: {
data: { type: 'authors', id: '1' }
}
}
}
]
assert_equal(expected, hash[:included])
end

def test_no_duplicates_collection
hash = ActiveModel::SerializableResource.new(
[@post1, @post2], adapter: :json_api,
include: '*.*')
.serializable_hash
expected = [
{
type: 'authors', id: '1',
relationships: {
posts: {
data: [
{ type: 'posts', id: '1' },
{ type: 'posts', id: '2' }
]
}
}
}
]
assert_equal(expected, hash[:included])
end

def test_no_duplicates_global
hash = ActiveModel::SerializableResource.new(
@nestedpost1,
adapter: :json_api,
include: '*').serializable_hash
expected = [
type: 'nested_posts', id: '2',
relationships: {
nested_posts: {
data: [
{ type: 'nested_posts', id: '1' },
{ type: 'nested_posts', id: '2' }
]
}
}
]
assert_equal(expected, hash[:included])
end

def test_no_duplicates_collection_global
hash = ActiveModel::SerializableResource.new(
[@nestedpost1, @nestedpost2],
adapter: :json_api,
include: '*').serializable_hash
assert_nil(hash[:included])
end
end
end
end
end
Expand Down