diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f90e7fb0..5f23557c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Features: * adds support for `pagination links` at top level of JsonApi adapter [@bacarini] * adds extended format for `include` option to JsonApi adapter [@beauby] * adds support for wildcards in `include` option [@beauby] + * adds support for nested associations for JSON and Attributes adapters via the `include` option [@NullVoxPopuli, @beauby] Fixes: diff --git a/lib/active_model/serializer/adapter/attributes.rb b/lib/active_model/serializer/adapter/attributes.rb index 79cf58dcd..2d2da6ebb 100644 --- a/lib/active_model/serializer/adapter/attributes.rb +++ b/lib/active_model/serializer/adapter/attributes.rb @@ -9,40 +9,12 @@ def initialize(serializer, options = {}) def serializable_hash(options = nil) options ||= {} + if serializer.respond_to?(:each) - result = serializer.map { |s| Attributes.new(s, instance_options).serializable_hash(options) } + serializable_hash_for_collection(options) else - hash = {} - - core = cache_check(serializer) do - serializer.attributes(options) - end - - serializer.associations(@include_tree).each do |association| - serializer = association.serializer - association_options = association.options - - if serializer.respond_to?(:each) - array_serializer = serializer - hash[association.key] = array_serializer.map do |item| - cache_check(item) do - item.attributes(association_options) - end - end - else - hash[association.key] = - if serializer && serializer.object - cache_check(serializer) do - serializer.attributes(options) - end - elsif association_options[:virtual_value] - association_options[:virtual_value] - end - end - end - result = core.merge hash + serializable_hash_for_single_resource(options) end - result end def fragment_cache(cached_hash, non_cached_hash) @@ -51,10 +23,43 @@ def fragment_cache(cached_hash, non_cached_hash) private + def serializable_hash_for_collection(options) + serializer.map { |s| Attributes.new(s, instance_options).serializable_hash(options) } + end + + def serializable_hash_for_single_resource(options) + resource = resource_object_for(options) + relationships = resource_relationships(options) + resource.merge!(relationships) + end + + def resource_relationships(options) + relationships = {} + serializer.associations(@include_tree).each do |association| + relationships[association.key] = relationship_value_for(association, options) + end + + relationships + end + + def relationship_value_for(association, options) + return association.options[:virtual_value] if association.options[:virtual_value] + return unless association.serializer && association.serializer.object + + opts = instance_options.merge(include: @include_tree[association.key]) + Attributes.new(association.serializer, opts).serializable_hash(options) + end + # no-op: Attributes adapter does not include meta data, because it does not support root. def include_meta(json) json end + + def resource_object_for(options) + cache_check(serializer) do + serializer.attributes(options) + end + end end end end diff --git a/lib/active_model/serializer/include_tree.rb b/lib/active_model/serializer/include_tree.rb index 6d675e116..d4c82ef2b 100644 --- a/lib/active_model/serializer/include_tree.rb +++ b/lib/active_model/serializer/include_tree.rb @@ -1,16 +1,48 @@ module ActiveModel class Serializer + # TODO: description of this class, and overview of how it's used class IncludeTree module Parsing module_function + # Translates a comma separated list of dot separated paths (JSON API format) into a Hash. + # + # @example + # `'posts.author, posts.comments.upvotes, posts.comments.author'` + # + # would become + # + # `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`. + # + # @param [String] included + # @return [Hash] a Hash representing the same tree structure def include_string_to_hash(included) + # TODO: Needs comment walking through the process of what all this is doing. included.delete(' ').split(',').reduce({}) do |hash, path| include_tree = path.split('.').reverse_each.reduce({}) { |a, e| { e.to_sym => a } } hash.deep_merge!(include_tree) end end + # Translates the arguments passed to the include option into a Hash. The format can be either + # a String (see #include_string_to_hash), an Array of Symbols and Hashes, or a mix of both. + # + # @example + # `posts: [:author, comments: [:author, :upvotes]]` + # + # would become + # + # `{ posts: { author: {}, comments: { author: {}, upvotes: {} } } }`. + # + # @example + # `[:author, :comments => [:author]]` + # + # would become + # + # `{:author => {}, :comments => { author: {} } }` + # + # @param [Symbol, Hash, Array, String] included + # @return [Hash] a Hash representing the same tree structure def include_args_to_hash(included) case included when Symbol @@ -47,6 +79,8 @@ def self.from_string(included) # @return [IncludeTree] # def self.from_include_args(included) + return included if included.is_a?(IncludeTree) + new(Parsing.include_args_to_hash(included)) end diff --git a/test/action_controller/json/include_test.rb b/test/action_controller/json/include_test.rb new file mode 100644 index 000000000..ac5ab25e6 --- /dev/null +++ b/test/action_controller/json/include_test.rb @@ -0,0 +1,167 @@ +require 'test_helper' + +module ActionController + module Serialization + class Json + class IncludeTest < ActionController::TestCase + class IncludeTestController < ActionController::Base + def setup_data + ActionController::Base.cache_store.clear + + @author = Author.new(id: 1, name: 'Steve K.') + + @post = Post.new(id: 42, title: 'New Post', body: 'Body') + @first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT') + @second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT') + + @post.comments = [@first_comment, @second_comment] + @post.author = @author + + @first_comment.post = @post + @second_comment.post = @post + + @blog = Blog.new(id: 1, name: 'My Blog!!') + @post.blog = @blog + @author.posts = [@post] + + @first_comment.author = @author + @second_comment.author = @author + @author.comments = [@first_comment, @second_comment] + @author.roles = [] + @author.bio = {} + end + + def render_without_include + setup_data + render json: @author, adapter: :json + end + + def render_resource_with_include_hash + setup_data + render json: @author, include: { posts: :comments }, adapter: :json + end + + def render_resource_with_include_string + setup_data + render json: @author, include: 'posts.comments', adapter: :json + end + + def render_resource_with_deep_include + setup_data + render json: @author, include: 'posts.comments.author', adapter: :json + end + end + + tests IncludeTestController + + def test_render_without_include + get :render_without_include + response = JSON.parse(@response.body) + expected = { + 'author' => { + 'id' => 1, + 'name' => 'Steve K.', + 'posts' => [ + { + 'id' => 42, 'title' => 'New Post', 'body' => 'Body' + } + ], + 'roles' => [], + 'bio' => {} + } + } + + assert_equal(expected, response) + end + + def test_render_resource_with_include_hash + get :render_resource_with_include_hash + response = JSON.parse(@response.body) + expected = { + 'author' => { + 'id' => 1, + 'name' => 'Steve K.', + 'posts' => [ + { + 'id' => 42, 'title' => 'New Post', 'body' => 'Body', + 'comments' => [ + { + 'id' => 1, 'body' => 'ZOMG A COMMENT' + }, + { + 'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT' + } + ] + } + ] + } + } + + assert_equal(expected, response) + end + + def test_render_resource_with_include_string + get :render_resource_with_include_string + + response = JSON.parse(@response.body) + expected = { + 'author' => { + 'id' => 1, + 'name' => 'Steve K.', + 'posts' => [ + { + 'id' => 42, 'title' => 'New Post', 'body' => 'Body', + 'comments' => [ + { + 'id' => 1, 'body' => 'ZOMG A COMMENT' + }, + { + 'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT' + } + ] + } + ] + } + } + + assert_equal(expected, response) + end + + def test_render_resource_with_deep_include + get :render_resource_with_deep_include + + response = JSON.parse(@response.body) + expected = { + 'author' => { + 'id' => 1, + 'name' => 'Steve K.', + 'posts' => [ + { + 'id' => 42, 'title' => 'New Post', 'body' => 'Body', + 'comments' => [ + { + 'id' => 1, 'body' => 'ZOMG A COMMENT', + 'author' => { + 'id' => 1, + 'name' => 'Steve K.' + } + }, + { + 'id' => 2, 'body' => 'ZOMG ANOTHER COMMENT', + 'author' => { + 'id' => 1, + 'name' => 'Steve K.' + } + } + ] + } + ] + } + } + + assert_equal(expected, response) + end + end + end + end +end diff --git a/test/include_tree/include_args_to_hash_test.rb b/test/include_tree/include_args_to_hash_test.rb new file mode 100644 index 000000000..cb5d5c355 --- /dev/null +++ b/test/include_tree/include_args_to_hash_test.rb @@ -0,0 +1,51 @@ +require 'test_helper' + +module ActiveModel + class Serializer + class IncludeTree + module Parsing + class IncludeArgsToHashTest < MiniTest::Test + def test_include_args_to_hash_from_symbol + expected = { author: {} } + input = :author + actual = Parsing.include_args_to_hash(input) + + assert_equal(expected, actual) + end + + def test_include_args_to_hash_from_array + expected = { author: {}, comments: {} } + input = [:author, :comments] + actual = Parsing.include_args_to_hash(input) + + assert_equal(expected, actual) + end + + def test_include_args_to_hash_from_nested_array + expected = { author: {}, comments: { author: {} } } + input = [:author, comments: [:author]] + actual = Parsing.include_args_to_hash(input) + + assert_equal(expected, actual) + end + + def test_include_args_to_hash_from_array_of_hashes + expected = { + author: {}, + blogs: { posts: { contributors: {} } }, + comments: { author: { blogs: { posts: {} } } } + } + input = [ + :author, + blogs: [posts: :contributors], + comments: { author: { blogs: :posts } } + ] + actual = Parsing.include_args_to_hash(input) + + assert_equal(expected, actual) + end + end + end + end + end +end