diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 418f10ba..c267e5f0 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -3,16 +3,18 @@ require 'thread_safe' module ActiveModel class Serializer extend ActiveSupport::Autoload + autoload :Configuration autoload :ArraySerializer autoload :Adapter autoload :Lint + autoload :Associations include Configuration + include Associations class << self attr_accessor :_attributes attr_accessor :_attributes_keys - attr_accessor :_associations attr_accessor :_urls attr_accessor :_cache attr_accessor :_fragmented @@ -24,12 +26,12 @@ module ActiveModel end def self.inherited(base) - base._attributes = self._attributes.try(:dup) || [] + base._attributes = self._attributes.try(:dup) || [] base._attributes_keys = self._attributes_keys.try(:dup) || {} - base._associations = self._associations.try(:dup) || {} base._urls = [] serializer_file = File.open(caller.first[/^[^:]+/]) base._cache_digest = Digest::MD5.hexdigest(serializer_file.read) + super end def self.attributes(*attrs) @@ -46,7 +48,7 @@ module ActiveModel def self.attribute(attr, options = {}) key = options.fetch(:key, attr) - @_attributes_keys[attr] = {key: key} if key != attr + @_attributes_keys[attr] = { key: key } if key != attr @_attributes << key unless @_attributes.include?(key) define_method key do object.read_attribute_for_serialization(attr) @@ -59,58 +61,13 @@ module ActiveModel # Enables a serializer to be automatically cached def self.cache(options = {}) - @_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching - @_cache_key = options.delete(:key) - @_cache_only = options.delete(:only) - @_cache_except = options.delete(:except) + @_cache = ActionController::Base.cache_store if Rails.configuration.action_controller.perform_caching + @_cache_key = options.delete(:key) + @_cache_only = options.delete(:only) + @_cache_except = options.delete(:except) @_cache_options = (options.empty?) ? nil : options end - # Defines an association in the object should be rendered. - # - # The serializer object should implement the association name - # as a method which should return an array when invoked. If a method - # with the association name does not exist, the association name is - # dispatched to the serialized object. - def self.has_many(*attrs) - associate(:has_many, attrs) - end - - # Defines an association in the object that should be rendered. - # - # The serializer object should implement the association name - # as a method which should return an object when invoked. If a method - # with the association name does not exist, the association name is - # dispatched to the serialized object. - def self.belongs_to(*attrs) - associate(:belongs_to, attrs) - end - - # Defines an association in the object should be rendered. - # - # The serializer object should implement the association name - # as a method which should return an object when invoked. If a method - # with the association name does not exist, the association name is - # dispatched to the serialized object. - def self.has_one(*attrs) - associate(:has_one, attrs) - end - - def self.associate(type, attrs) #:nodoc: - options = attrs.extract_options! - self._associations = _associations.dup - - attrs.each do |attr| - unless method_defined?(attr) - define_method attr do - object.send attr - end - end - - self._associations[attr] = {type: type, association_options: options} - end - end - def self.url(attr) @_urls.push attr end @@ -125,19 +82,17 @@ module ActiveModel elsif resource.respond_to?(:to_ary) config.array_serializer else - options - .fetch(:association_options, {}) - .fetch(:serializer, get_serializer_for(resource.class)) + options.fetch(:serializer, get_serializer_for(resource.class)) end end def self.adapter adapter_class = case config.adapter - when Symbol - ActiveModel::Serializer::Adapter.adapter_class(config.adapter) - when Class - config.adapter - end + when Symbol + ActiveModel::Serializer::Adapter.adapter_class(config.adapter) + when Class + config.adapter + end unless adapter_class valid_adapters = Adapter.constants.map { |klass| ":#{klass.to_s.downcase}" } raise ArgumentError, "Unknown adapter: #{config.adapter}. Valid adapters are: #{valid_adapters}" @@ -153,12 +108,12 @@ module ActiveModel attr_accessor :object, :root, :meta, :meta_key, :scope def initialize(object, options = {}) - @object = object - @options = options - @root = options[:root] - @meta = options[:meta] - @meta_key = options[:meta_key] - @scope = options[:scope] + @object = object + @options = options + @root = options[:root] + @meta = options[:meta] + @meta_key = options[:meta_key] + @scope = options[:scope] scope_name = options[:scope_name] if scope_name && !respond_to?(scope_name) @@ -199,48 +154,10 @@ module ActiveModel end end - def each_association(&block) - self.class._associations.dup.each do |name, association_options| - next unless object - association_value = send(name) - - serializer_class = ActiveModel::Serializer.serializer_for(association_value, association_options) - - if serializer_class - begin - serializer = serializer_class.new( - association_value, - options.except(:serializer).merge(serializer_from_options(association_options)) - ) - rescue ActiveModel::Serializer::ArraySerializer::NoSerializerError - virtual_value = association_value - virtual_value = virtual_value.as_json if virtual_value.respond_to?(:as_json) - association_options[:association_options][:virtual_value] = virtual_value - end - elsif !association_value.nil? && !association_value.instance_of?(Object) - association_options[:association_options][:virtual_value] = association_value - end - - association_key = association_options[:association_options][:key] || name - if block_given? - block.call(association_key, serializer, association_options[:association_options]) - end - end - end - - def serializer_from_options(options) - opts = {} - serializer = options.fetch(:association_options, {}).fetch(:serializer, nil) - opts[:serializer] = serializer if serializer - opts - end - def self.serializers_cache @serializers_cache ||= ThreadSafe::Cache.new end - private - attr_reader :options def self.get_serializer_for(klass) @@ -255,6 +172,5 @@ module ActiveModel end end end - end end diff --git a/lib/active_model/serializer/adapter/json.rb b/lib/active_model/serializer/adapter/json.rb index 271c6959..58704b56 100644 --- a/lib/active_model/serializer/adapter/json.rb +++ b/lib/active_model/serializer/adapter/json.rb @@ -7,7 +7,7 @@ module ActiveModel def serializable_hash(options = nil) options ||= {} if serializer.respond_to?(:each) - @result = serializer.map{|s| FlattenJson.new(s).serializable_hash(options) } + @result = serializer.map { |s| FlattenJson.new(s).serializable_hash(options) } else @hash = {} @@ -15,24 +15,26 @@ module ActiveModel serializer.attributes(options) end - serializer.each_association do |key, association, opts| - if association.respond_to?(:each) - array_serializer = association - @hash[key] = array_serializer.map do |item| + 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 - if association && association.object - @hash[key] = cache_check(association) do - association.attributes(options) + @hash[association.key] = + if serializer && serializer.object + cache_check(serializer) do + serializer.attributes(options) + end + elsif opts[:virtual_value] + opts[:virtual_value] end - elsif opts[:virtual_value] - @hash[key] = opts[:virtual_value] - else - @hash[key] = nil - end end end @result = @core.merge @hash diff --git a/lib/active_model/serializer/adapter/json_api.rb b/lib/active_model/serializer/adapter/json_api.rb index 50dfb7eb..551ed54b 100644 --- a/lib/active_model/serializer/adapter/json_api.rb +++ b/lib/active_model/serializer/adapter/json_api.rb @@ -75,8 +75,10 @@ module ActiveModel end serializers.each do |serializer| - serializer.each_association do |key, association, opts| - add_included(key, association, resource_path) if association + serializer.associations.each do |association| + serializer = association.serializer + + add_included(association.key, serializer, resource_path) if serializer end if include_nested_assoc? resource_path end end @@ -131,22 +133,26 @@ module ActiveModel def add_resource_relationships(attrs, serializer, options = {}) options[:add_included] = options.fetch(:add_included, true) - serializer.each_association do |key, association, opts| + serializer.associations.each do |association| + key = association.key + serializer = association.serializer + opts = association.options + attrs[:relationships] ||= {} - if association.respond_to?(:each) - add_relationships(attrs, key, association) + if serializer.respond_to?(:each) + add_relationships(attrs, key, serializer) else if opts[:virtual_value] add_relationship(attrs, key, nil, opts[:virtual_value]) else - add_relationship(attrs, key, association) + add_relationship(attrs, key, serializer) end end if options[:add_included] - Array(association).each do |association| - add_included(key, association) + Array(serializer).each do |serializer| + add_included(key, serializer) end end end diff --git a/lib/active_model/serializer/association.rb b/lib/active_model/serializer/association.rb new file mode 100644 index 00000000..af1cb914 --- /dev/null +++ b/lib/active_model/serializer/association.rb @@ -0,0 +1,21 @@ +module ActiveModel + class Serializer + # This class hold all information about serializer's association. + # + # @param [Symbol] name + # @param [ActiveModel::Serializer] serializer + # @param [Hash{Symbol => Object}] options + # + # @example + # Association.new(:comments, CommentSummarySerializer, embed: :ids) + # + Association = Struct.new(:name, :serializer, :options) do + + # @return [Symbol] + # + def key + options.fetch(:key, name) + end + end + end +end diff --git a/lib/active_model/serializer/associations.rb b/lib/active_model/serializer/associations.rb new file mode 100644 index 00000000..a41358c6 --- /dev/null +++ b/lib/active_model/serializer/associations.rb @@ -0,0 +1,107 @@ +module ActiveModel + class Serializer + # Defines an association in the object should be rendered. + # + # The serializer object should implement the association name + # as a method which should return an array when invoked. If a method + # with the association name does not exist, the association name is + # dispatched to the serialized object. + # + module Associations + extend ActiveSupport::Concern + + included do |base| + class << base + attr_accessor :_reflections + end + + autoload :Association + autoload :Reflection + autoload :SingularReflection + autoload :CollectionReflection + autoload :BelongsToReflection + autoload :HasOneReflection + autoload :HasManyReflection + end + + module ClassMethods + def inherited(base) + base._reflections = self._reflections.try(:dup) || [] + end + + # @param [Array(Array, Hash{Symbol => Object})] attrs + # @return [void] + # + # @example + # has_many :comments, serializer: CommentSummarySerializer + # has_many :commits, authors + # + def has_many(*attrs) + associate attrs do |name, options| + HasManyReflection.new(name, options) + end + end + + # @param [Array(Array, Hash{Symbol => Object})] attrs + # @return [void] + # + # @example + # belongs_to :author, serializer: AuthorSerializer + # + def belongs_to(*attrs) + associate attrs do |name, options| + BelongsToReflection.new(name, options) + end + end + + # @param [Array(Array, Hash{Symbol => Object})] attrs + # @return [void] + # + # @example + # has_one :author, serializer: AuthorSerializer + # + def has_one(*attrs) + associate attrs do |name, options| + HasOneReflection.new(name, options) + end + end + + private + + # Add reflection and define {name} accessor. + # @param [Array] + # @yield [Symbol] return reflection + # + # @api private + # + def associate(attrs) + options = attrs.extract_options! + + self._reflections = _reflections.dup + + attrs.each do |name| + unless method_defined?(name) + define_method name do + object.send name + end + end + + self._reflections << yield(name, options) + end + end + end + + # @return [Enumerator] + # + def associations + return unless object + + Enumerator.new do |y| + self.class._reflections.each do |reflection| + y.yield reflection.build_association(self, options) + end + end + end + end + end +end diff --git a/lib/active_model/serializer/belongs_to_reflection.rb b/lib/active_model/serializer/belongs_to_reflection.rb new file mode 100644 index 00000000..8cc5a201 --- /dev/null +++ b/lib/active_model/serializer/belongs_to_reflection.rb @@ -0,0 +1,10 @@ +module ActiveModel + class Serializer + # @api private + class BelongsToReflection < SingularReflection + def macro + :belongs_to + end + end + end +end diff --git a/lib/active_model/serializer/collection_reflection.rb b/lib/active_model/serializer/collection_reflection.rb new file mode 100644 index 00000000..3436becf --- /dev/null +++ b/lib/active_model/serializer/collection_reflection.rb @@ -0,0 +1,7 @@ +module ActiveModel + class Serializer + # @api private + class CollectionReflection < Reflection + end + end +end diff --git a/lib/active_model/serializer/has_many_reflection.rb b/lib/active_model/serializer/has_many_reflection.rb new file mode 100644 index 00000000..08be4417 --- /dev/null +++ b/lib/active_model/serializer/has_many_reflection.rb @@ -0,0 +1,10 @@ +module ActiveModel + class Serializer + # @api private + class HasManyReflection < CollectionReflection + def macro + :has_many + end + end + end +end diff --git a/lib/active_model/serializer/has_one_reflection.rb b/lib/active_model/serializer/has_one_reflection.rb new file mode 100644 index 00000000..5a915f7f --- /dev/null +++ b/lib/active_model/serializer/has_one_reflection.rb @@ -0,0 +1,10 @@ +module ActiveModel + class Serializer + # @api private + class HasOneReflection < SingularReflection + def macro + :has_one + end + end + end +end diff --git a/lib/active_model/serializer/reflection.rb b/lib/active_model/serializer/reflection.rb new file mode 100644 index 00000000..d9dbd670 --- /dev/null +++ b/lib/active_model/serializer/reflection.rb @@ -0,0 +1,74 @@ +module ActiveModel + class Serializer + # Holds all the meta-data about an association as it was specified in the + # ActiveModel::Serializer class. + # + # @example + # class PostSerializer < ActiveModel::Serializer + # has_one :author, serializer: AuthorSerializer + # has_many :comments + # end + # + # PostSerializer._reflections #=> + # # [ + # # HasOneReflection.new(:author, serializer: AuthorSerializer), + # # HasManyReflection.new(:comments) + # # ] + # + # So you can inspect reflections in your Adapters. + # + Reflection = Struct.new(:name, :options) do + # Build association. This method is used internally to + # build serializer's association by its reflection. + # + # @param [Serializer] subject is a parent serializer for given association + # @param [Hash{Symbol => Object}] parent_serializer_options + # + # @example + # # Given the following serializer defined: + # class PostSerializer < ActiveModel::Serializer + # has_many :comments, serializer: CommentSummarySerializer + # end + # + # # Then you instantiate your serializer + # post_serializer = PostSerializer.new(post, foo: 'bar') # + # # to build association for comments you need to get reflection + # comments_reflection = PostSerializer._reflections.detect { |r| r.name == :comments } + # # and #build_association + # comments_reflection.build_association(post_serializer, foo: 'bar') + # + # @api private + # + 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) + + if serializer_class + begin + serializer = serializer_class.new( + association_value, + serializer_options(parent_serializer_options, reflection_options) + ) + rescue ActiveModel::Serializer::ArraySerializer::NoSerializerError + reflection_options[:virtual_value] = association_value.try(:as_json) || association_value + end + elsif !association_value.nil? && !association_value.instance_of?(Object) + reflection_options[:virtual_value] = association_value + end + + Association.new(name, serializer, reflection_options) + end + + private + + def serializer_options(parent_serializer_options, reflection_options) + serializer = reflection_options.fetch(:serializer, nil) + + serializer_options = parent_serializer_options.except(:serializer) + serializer_options[:serializer] = serializer if serializer + serializer_options + end + end + end +end diff --git a/lib/active_model/serializer/singular_reflection.rb b/lib/active_model/serializer/singular_reflection.rb new file mode 100644 index 00000000..f90ecc21 --- /dev/null +++ b/lib/active_model/serializer/singular_reflection.rb @@ -0,0 +1,7 @@ +module ActiveModel + class Serializer + # @api private + class SingularReflection < Reflection + end + end +end diff --git a/test/serializers/association_macros_test.rb b/test/serializers/association_macros_test.rb new file mode 100644 index 00000000..f99e1980 --- /dev/null +++ b/test/serializers/association_macros_test.rb @@ -0,0 +1,36 @@ +require 'test_helper' + +module ActiveModel + class Serializer + class AssociationMacrosTest < Minitest::Test + AuthorSummarySerializer = Class.new + class AssociationsTestSerializer < Serializer + belongs_to :author, serializer: AuthorSummarySerializer + has_many :comments, embed: :ids + has_one :category + end + + def before_setup + @reflections = AssociationsTestSerializer._reflections + end + + def test_has_one_defines_reflection + has_one_reflection = HasOneReflection.new(:category, {}) + + assert_includes(@reflections, has_one_reflection) + end + + def test_has_many_defines_reflection + has_many_reflection = HasManyReflection.new(:comments, embed: :ids) + + assert_includes(@reflections, has_many_reflection) + end + + def test_belongs_to_defines_reflection + belongs_to_reflection = BelongsToReflection.new(:author, serializer: AuthorSummarySerializer) + + assert_includes(@reflections, belongs_to_reflection) + end + end + end +end diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb index 735311d6..89afdcc6 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -45,21 +45,20 @@ module ActiveModel end def test_has_many_and_has_one - assert_equal( - { posts: { type: :has_many, association_options: { embed: :ids } }, - roles: { type: :has_many, association_options: { embed: :ids } }, - bio: { type: :has_one, association_options: {} } }, - @author_serializer.class._associations - ) - @author_serializer.each_association do |key, serializer, options| - if key == :posts - assert_equal({embed: :ids}, options) + @author_serializer.associations.each do |association| + key = association.key + serializer = association.serializer + options = association.options + + case key + when :posts + assert_equal({ embed: :ids }, options) assert_kind_of(ActiveModel::Serializer.config.array_serializer, serializer) - elsif key == :bio + when :bio assert_equal({}, options) assert_nil serializer - elsif key == :roles - assert_equal({embed: :ids}, options) + when :roles + assert_equal({ embed: :ids }, options) assert_kind_of(ActiveModel::Serializer.config.array_serializer, serializer) else flunk "Unknown association: #{key}" @@ -68,7 +67,11 @@ module ActiveModel end def test_has_many_with_no_serializer - PostWithTagsSerializer.new(@post).each_association do |key, serializer, options| + PostWithTagsSerializer.new(@post).associations.each do |association| + key = association.key + serializer = association.serializer + options = association.options + assert_equal key, :tags assert_equal serializer, nil assert_equal [{ attributes: { name: "#hashtagged" }}].to_json, options[:virtual_value].to_json @@ -76,70 +79,67 @@ module ActiveModel end def test_serializer_options_are_passed_into_associations_serializers - @post_serializer.each_association do |key, association| - if key == :comments - assert association.first.custom_options[:custom_options] - end - end + association = @post_serializer + .associations + .detect { |association| association.key == :comments } + + assert association.serializer.first.custom_options[:custom_options] end def test_belongs_to - assert_equal( - { post: { type: :belongs_to, association_options: {} }, - author: { type: :belongs_to, association_options: {} } }, - @comment_serializer.class._associations - ) - @comment_serializer.each_association do |key, serializer, options| - if key == :post - assert_equal({}, options) + @comment_serializer.associations.each do |association| + key = association.key + serializer = association.serializer + + case key + when :post assert_kind_of(PostSerializer, serializer) - elsif key == :author - assert_equal({}, options) + when :author assert_nil serializer else flunk "Unknown association: #{key}" end + + assert_equal({}, association.options) end end def test_belongs_to_with_custom_method - blog_is_present = false - - @post_serializer.each_association do |key, serializer, options| - blog_is_present = true if key == :blog - end - - assert blog_is_present + assert( + @post_serializer.associations.any? do |association| + association.key == :blog + end + ) end def test_associations_inheritance inherited_klass = Class.new(PostSerializer) - assert_equal(PostSerializer._associations, inherited_klass._associations) + assert_equal(PostSerializer._reflections, inherited_klass._reflections) end def test_associations_inheritance_with_new_association inherited_klass = Class.new(PostSerializer) do has_many :top_comments, serializer: CommentSerializer end - expected_associations = PostSerializer._associations.merge( - top_comments: { - type: :has_many, - association_options: { - serializer: CommentSerializer - } - } + + assert( + PostSerializer._reflections.all? do |reflection| + inherited_klass._reflections.include?(reflection) + end + ) + + assert( + inherited_klass._reflections.any? do |reflection| + reflection.name == :top_comments + end ) - assert_equal(inherited_klass._associations, expected_associations) end def test_associations_custom_keys serializer = PostWithCustomKeysSerializer.new(@post) - expected_association_keys = [] - serializer.each_association do |key, serializer, options| - expected_association_keys << key - end + expected_association_keys = serializer.associations.map(&:key) assert expected_association_keys.include? :reviews assert expected_association_keys.include? :writer