Skip to content

Add support for nested serializers. #1157

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

Closed
wants to merge 15 commits into from
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
7 changes: 6 additions & 1 deletion lib/active_model/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'active_model/serializer/configuration'
require 'active_model/serializer/fieldset'
require 'active_model/serializer/lint'
require 'active_model/serializer/utils'

module ActiveModel
class Serializer
Expand Down Expand Up @@ -79,6 +80,10 @@ def self.cache(options = {})
self._cache_options = (options.empty?) ? nil : options
end

def self.nested_lookup_paths
@_nested_lookup_paths ||= Utils.nested_lookup_paths(self)
end

def self.serializer_for(resource, options = {})
if resource.respond_to?(:serializer_class)
resource.serializer_class
Expand Down Expand Up @@ -111,7 +116,7 @@ def self.digest_caller_file(caller_line)
def self.get_serializer_for(klass)
serializers_cache.fetch_or_store(klass) do
serializer_class_name = "#{klass.name}Serializer"
serializer_class = serializer_class_name.safe_constantize
serializer_class = Utils.nested_lookup(nested_lookup_paths, serializer_class_name)

if serializer_class
serializer_class
Expand Down
5 changes: 2 additions & 3 deletions lib/active_model/serializer/array_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ class ArraySerializer
def initialize(resources, options = {})
@root = options[:root]
@object = resources
@parent_serializer = options[:_parent] || ActiveModel::Serializer
@serializers = resources.map do |resource|
serializer_class = options.fetch(:serializer) do
ActiveModel::Serializer.serializer_for(resource)
end
serializer_class = options.fetch(:serializer) { @parent_serializer.serializer_for(resource) }

if serializer_class.nil?
fail NoSerializerError, "No serializer found for resource: #{resource.inspect}"
Expand Down
7 changes: 4 additions & 3 deletions lib/active_model/serializer/reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ class Serializer
def build_association(subject, parent_serializer_options)
association_value = subject.send(name)
reflection_options = options.dup
serializer_class = ActiveModel::Serializer.serializer_for(association_value, reflection_options)
serializer_class = subject.class.serializer_for(association_value, reflection_options)

if serializer_class
begin
serializer = serializer_class.new(
association_value,
serializer_options(parent_serializer_options, reflection_options)
serializer_options(parent_serializer_options, reflection_options, subject)
)
rescue ActiveModel::Serializer::ArraySerializer::NoSerializerError
reflection_options[:virtual_value] = association_value.try(:as_json) || association_value
Expand All @@ -62,11 +62,12 @@ def build_association(subject, parent_serializer_options)

private

def serializer_options(parent_serializer_options, reflection_options)
def serializer_options(parent_serializer_options, reflection_options, subject)
serializer = reflection_options.fetch(:serializer, nil)
Copy link
Member

Choose a reason for hiding this comment

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

why is subject called parent?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because, as any parent, it is its own subject, but its children's parent. The serializer_options is the hash that we build for passing down to the associated (i.e. child) serializer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you feel strongly about this, and think of a better name, I don't mind changing it.


serializer_options = parent_serializer_options.except(:serializer)
serializer_options[:serializer] = serializer if serializer
serializer_options[:_parent] = subject.class
serializer_options
end
end
Expand Down
31 changes: 31 additions & 0 deletions lib/active_model/serializer/utils.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module ActiveModel::Serializer::Utils
module_function

# Gives the existing lookup paths that are suffixes of the fully qualified path of `klass`.
# @param [Object] klass
# @return [Array<Symbol>]
def nested_lookup_paths(klass)
Copy link
Contributor

Choose a reason for hiding this comment

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

can you add some yardoc?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 👍

paths = klass.to_s.split('::').reverse.reduce([]) { |a, e| a + [[e] + Array(a.last)] }.reverse
Copy link
Member

Choose a reason for hiding this comment

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

Would you mind helping me read this code?

  1. TweetSerializer::AuthorSerializer.to_s.split('::').reverse #=> ['AuthorSerializer', 'TweetSerializer']
  2. _.reduce([]) {|result, element| result + [[element] + Array(result.last)] }.reverse
    1. paths = []
      1. paths = [] + [['AuthorSerializer'] + Array([]) ] #=> [['AuthorSerializer']]
      2. paths = [['AuthorSerializer']] + [ ['TweetSerializer'] + Array(['AuthorSerializer']) ] #=> [ ["AuthorSerializer"], ["TweetSerializer", "AuthorSerializer"] ]
    2. paths.reverse #=> ["TweetSerializer", "AuthorSerializer"], ["AuthorSerializer"]]
  3. ["TweetSerializer", "AuthorSerializer"], ["AuthorSerializer"]].map! {|path| path.join('::') } #=> ["TweetSerializer::AuthorSerializer", "AuthorSerializer"]
  4. [TweetSerializer::AuthorSerializer", "AuthorSerializer"].select! {|path| Object.const_defined?(path, false) }.map! {|path| Object.const_get(path) }.push(Object) #=> [TweetSerializer::AuthorSerializer, AuthorSerializer, Object]

Is that right?

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 indeed. I admit that part was slightly cryptic.

Copy link
Member

Choose a reason for hiding this comment

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

So that as the test says

Utils.nested_lookup_paths(TweetSerializer::ShareSerializer::AuthorSerializer) #=> [::TweetSerializer::ShareSerializer::AuthorSerializer, ::ShareSerializer::AuthorSerializer, ::AuthorSerializer, ::Object]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yessir.

Copy link
Member

Choose a reason for hiding this comment

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

And
Utils.nested_lookup([::TweetSerializer, ::Object], 'ShareSerializer') #=> ::TweetSerializer::ShareSerializer ?

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 so.

Copy link
Member

Choose a reason for hiding this comment

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

So that

          @share1 = Share.new
TweetSerializer.serializer_for(@share1) #=> TweetSerializer::ShareSerializer
ActiveModel::Serializer.serializer_for(@share1) #=> ShareSerializer

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exactly.

Copy link
Member

Choose a reason for hiding this comment

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

So that you can build associations on tweet.author using serializer: TweetSerializer::AuthorSerializer?

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, exactly, if available, and safely fallback to any less specialized serializer that can handle an Author.

paths.map! { |path| "#{path.join('::')}" }
paths.map! do |path|
Copy link
Contributor

Choose a reason for hiding this comment

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

@beauby can you add comments after each line, explaining what it does -- so that if debugging of this needs to happen in the future, we don't need to go through the exercise you and @bf4 went through?

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'll comment the first line, which might be pretty cryptic. The rest is pretty much self-explanatory, no?

begin
path.constantize
rescue NameError
nil
end
end
paths.delete(nil)
paths.push(Object)

paths
end

# Gives the class named `class_name` if it exists (directly) inside one of the lookup `paths`.
# @param [Array<Symbol>] paths
# @param [Symbol, String] class_name
# @return [Object, nil]
def nested_lookup(paths, class_name)
class_path = paths.find { |path| path.const_defined?(class_name, false) }
class_path.const_get(class_name) if class_path
end
end
35 changes: 35 additions & 0 deletions test/adapter/json_api/nested_serializers_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
require 'test_helper'

module ActiveModel
class Serializer
module Adapter
class JsonApi
class NestedSerializersTest < Minitest::Test
def setup
@tweet = Tweet.new(id: 1, body: 'Tweet 1', date: 'Jan 15')
@share1 = Share.new(id: 1, platform: 'facebook', date: 'Jan 16')
@author = Author.new(id: 1, name: 'Lucas H.')
@tweet.author = @author
@tweet.shares = [@share1]
@share1.author = @author
@author.posts = []
@author.roles = []
@author.bio = nil
end

def test_nested_serializers
actual = ActiveModel::SerializableResource.new(@tweet, adapter: :json_api).serializable_hash
expected = {
data: {
id: '1', type: 'tweets', attributes: { body: 'Tweet 1', date: 'Jan 15' },
relationships: { author: { data: { id: '1', type: 'authors' } },
shares: { data: [{ id: '1', type: 'shares' }] } }
}
}
assert_equal(expected, actual)
end
end
end
end
end
end
32 changes: 32 additions & 0 deletions test/fixtures/poro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class ProfilePreviewSerializer < ActiveModel::Serializer
Place = Class.new(Model)
Tag = Class.new(Model)
VirtualValue = Class.new(Model)
Tweet = Class.new(Model)
Share = Class.new(Model)
Comment = Class.new(Model) do
# Uses a custom non-time-based cache key
def cache_key
Expand Down Expand Up @@ -258,4 +260,34 @@ def maker
cache only: [:id]
attributes :id
end

class TweetSerializer < ActiveModel::Serializer
attributes :id, :body, :date

belongs_to :author
class AuthorSerializer < ActiveModel::Serializer
attributes :id
end
Copy link
Member

Choose a reason for hiding this comment

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

How is this different from belongs_to: :author, serializer: TweetAuthorSerializer?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One thing is that the namespacing makes it so that you don't have to use funky adapter names that you then have to specify manually.

Copy link
Member

Choose a reason for hiding this comment

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

So, this PR is more a syntactic sugar than a new feature?

Copy link
Contributor

Choose a reason for hiding this comment

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

that's sorta how I see it as well. But for me, the biggest benefit would be to greatly reduce the number of top level serializers that actually have VERY specific purposes

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, indeed syntactic sugar. However, I could see in the future having, say the Json adapter default to including all nested relationships (default to ** instead of * currently), in a kind of safe way since the user would provide all the subsequent serializers to make the graph explicit (maybe with a warning when there is an undefined serializer and possibly when using a fallback serializer).


has_many :shares
class ShareSerializer < ActiveModel::Serializer
attributes :id, :platform

belongs_to :author
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :name
end
end
end

class ShareSerializer < ActiveModel::Serializer
attributes :id, :platform, :date

belongs_to :author
class AuthorSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
belongs_to :post
end

$VERBOSE = verbose
12 changes: 12 additions & 0 deletions test/serializers/serializer_for_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def setup
@my_profile = MyProfile.new
@custom_profile = CustomProfile.new
@model = ::Model.new
@tweet = Tweet.new
@share1 = Share.new
end

def test_serializer_for_existing_serializer
Expand All @@ -59,6 +61,16 @@ def test_serializer_custom_serializer
serializer = ActiveModel::Serializer.serializer_for(@custom_profile)
assert_equal ProfileSerializer, serializer
end

def test_serializer_nested_serializer
serializer = TweetSerializer.serializer_for(@share1)
assert_equal(TweetSerializer::ShareSerializer, serializer)
end

def test_serializer_non_nested_serializer
serializer = ActiveModel::Serializer.serializer_for(@share1)
assert_equal(ShareSerializer, serializer)
end
end
end
end
Expand Down
24 changes: 24 additions & 0 deletions test/utils/nested_lookup_paths_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
require 'test_helper'

module ActiveModel
class Serializer
module Utils
class NestedLookupPathsTest < Minitest::Test
def test_activemodel_serializer
expected = [::ActiveModel::Serializer, ::Object]
actual = Utils.nested_lookup_paths(ActiveModel::Serializer)
assert_equal(expected, actual)
end

def test_nested_classes
expected = [::TweetSerializer::ShareSerializer::AuthorSerializer,
::ShareSerializer::AuthorSerializer,
::AuthorSerializer,
::Object]
actual = Utils.nested_lookup_paths(TweetSerializer::ShareSerializer::AuthorSerializer)
assert_equal(expected, actual)
end
end
end
end
end
22 changes: 22 additions & 0 deletions test/utils/nested_lookup_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
require 'test_helper'

module ActiveModel
class Serializer
module Utils
class NestedLookupTest < Minitest::Test
def test_existing
paths = [::TweetSerializer, ::Object]
expected = ::TweetSerializer::ShareSerializer
actual = Utils.nested_lookup(paths, 'ShareSerializer')
assert_equal(expected, actual)
end

def test_non_existing
paths = [::Object]
actual = Utils.nested_lookup(paths, 'NonExistingSerializer')
assert_nil(actual)
end
end
end
end
end