diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index a34136c9..eadcb32e 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -3,6 +3,7 @@ require 'active_model/serializer/collection_serializer' require 'active_model/serializer/array_serializer' require 'active_model/serializer/include_tree' require 'active_model/serializer/associations' +require 'active_model/serializer/attributes' require 'active_model/serializer/configuration' require 'active_model/serializer/fieldset' require 'active_model/serializer/lint' @@ -13,6 +14,7 @@ module ActiveModel class Serializer include Configuration include Associations + include Attributes require 'active_model/serializer/adapter' # Matches @@ -45,14 +47,9 @@ module ActiveModel end with_options instance_writer: false, instance_reader: false do |serializer| - class_attribute :_type, instance_reader: true - class_attribute :_attributes # @api private : names of attribute methods, @see Serializer#attribute - self._attributes ||= [] - class_attribute :_attributes_keys # @api private : maps attribute value to explict key name, @see Serializer#attribute - self._attributes_keys ||= {} - class_attribute :_links # @api private : links definitions, @see Serializer#link + serializer.class_attribute :_type, instance_reader: true + serializer.class_attribute :_links # @api private : links definitions, @see Serializer#link self._links ||= {} - serializer.class_attribute :_cache # @api private : the cache object serializer.class_attribute :_fragmented # @api private : @see ::fragmented serializer.class_attribute :_cache_key # @api private : when present, is first item in cache_key @@ -69,12 +66,10 @@ module ActiveModel serializer.class_attribute :_cache_digest # @api private : Generated end - # Serializers inherit _attributes and _attributes_keys. + # Serializers inherit _attribute_mappings, _reflections, and _links. # Generates a unique digest for each serializer at load. def self.inherited(base) caller_line = caller.first - base._attributes = _attributes.dup - base._attributes_keys = _attributes_keys.dup base._links = _links.dup base._cache_digest = digest_caller_file(caller_line) super @@ -91,37 +86,6 @@ module ActiveModel _links[name] = block || value end - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # attributes :id, :name, :recent_edits - def self.attributes(*attrs) - attrs = attrs.first if attrs.first.class == Array - - attrs.each do |attr| - attribute(attr) - end - end - - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # attributes :id, :recent_edits - # attribute :name, key: :title - # - # def recent_edits - # object.edits.last(5) - # enr - def self.attribute(attr, options = {}) - key = options.fetch(:key, attr) - _attributes_keys[attr] = { key: key } if key != attr - _attributes << key unless _attributes.include?(key) - - ActiveModelSerializers.silence_warnings do - define_method key do - object.read_attribute_for_serialization(attr) - end unless method_defined?(key) || _fragmented.respond_to?(attr) - end - end - # @api private # Used by FragmentCache on the CachedSerializer # to call attribute methods on the fragmented cached serializer. @@ -220,6 +184,15 @@ module ActiveModel end end + def self._serializer_instance_method_defined?(name) + _serializer_instance_methods.include?(name) + end + + def self._serializer_instance_methods + @_serializer_instance_methods ||= (public_instance_methods - Object.public_instance_methods).to_set + end + private_class_method :_serializer_instance_methods + attr_accessor :object, :root, :scope # `scope_name` is set as :current_user by default in the controller. @@ -244,16 +217,13 @@ module ActiveModel root || object.class.model_name.to_s.underscore end - # Return the +attributes+ of +object+ as presented - # by the serializer. - def attributes(requested_attrs = nil) - self.class._attributes.each_with_object({}) do |name, hash| - next unless requested_attrs.nil? || requested_attrs.include?(name) - if self.class._fragmented - hash[name] = self.class._fragmented.public_send(name) - else - hash[name] = send(name) - end + def read_attribute_for_serialization(attr) + if self.class._serializer_instance_method_defined?(attr) + send(attr) + elsif self.class._fragmented + self.class._fragmented.read_attribute_for_serialization(attr) + else + object.read_attribute_for_serialization(attr) end end diff --git a/lib/active_model/serializer/associations.rb b/lib/active_model/serializer/associations.rb index af627a13..c4da3515 100644 --- a/lib/active_model/serializer/associations.rb +++ b/lib/active_model/serializer/associations.rb @@ -12,9 +12,10 @@ module ActiveModel DEFAULT_INCLUDE_TREE = ActiveModel::Serializer::IncludeTree.from_string('*') - included do |base| - class << base - attr_accessor :_reflections + included do + with_options instance_writer: false, instance_reader: true do |serializer| + serializer.class_attribute :_reflections + self._reflections ||= [] end extend ActiveSupport::Autoload @@ -29,7 +30,8 @@ module ActiveModel module ClassMethods def inherited(base) - base._reflections = self._reflections.try(:dup) || [] + super + base._reflections = _reflections.dup end # @param [Symbol] name of the association @@ -39,8 +41,8 @@ module ActiveModel # @example # has_many :comments, serializer: CommentSummarySerializer # - def has_many(name, options = {}) - associate HasManyReflection.new(name, options) + def has_many(name, options = {}, &block) + associate(HasManyReflection.new(name, options, block)) end # @param [Symbol] name of the association @@ -50,8 +52,8 @@ module ActiveModel # @example # belongs_to :author, serializer: AuthorSerializer # - def belongs_to(name, options = {}) - associate BelongsToReflection.new(name, options) + def belongs_to(name, options = {}, &block) + associate(BelongsToReflection.new(name, options, block)) end # @param [Symbol] name of the association @@ -61,8 +63,8 @@ module ActiveModel # @example # has_one :author, serializer: AuthorSerializer # - def has_one(name, options = {}) - associate HasOneReflection.new(name, options) + def has_one(name, options = {}, &block) + associate(HasOneReflection.new(name, options, block)) end private @@ -76,10 +78,6 @@ module ActiveModel def associate(reflection) self._reflections = _reflections.dup - define_method reflection.name do - object.send reflection.name - end unless method_defined?(reflection.name) - self._reflections << reflection end end diff --git a/lib/active_model/serializer/attributes.rb b/lib/active_model/serializer/attributes.rb new file mode 100644 index 00000000..81f6e49a --- /dev/null +++ b/lib/active_model/serializer/attributes.rb @@ -0,0 +1,112 @@ +module ActiveModel + class Serializer + module Attributes + # @api private + class Attribute + delegate :call, to: :reader + + attr_reader :name, :reader + + def initialize(name) + @name = name + @reader = :no_reader + end + + def self.build(name, block) + if block + AttributeBlock.new(name, block) + else + AttributeReader.new(name) + end + end + end + # @api private + class AttributeReader < Attribute + def initialize(name) + super(name) + @reader = ->(instance) { instance.read_attribute_for_serialization(name) } + end + end + # @api private + class AttributeBlock < Attribute + def initialize(name, block) + super(name) + @reader = ->(instance) { instance.instance_eval(&block) } + end + end + + extend ActiveSupport::Concern + + included do + with_options instance_writer: false, instance_reader: false do |serializer| + serializer.class_attribute :_attribute_mappings # @api private : maps attribute key names to names to names of implementing methods, @see #attribute + self._attribute_mappings ||= {} + end + + # Return the +attributes+ of +object+ as presented + # by the serializer. + def attributes(requested_attrs = nil, reload = false) + @attributes = nil if reload + @attributes ||= self.class._attribute_mappings.each_with_object({}) do |(key, attribute_mapping), hash| + next unless requested_attrs.nil? || requested_attrs.include?(key) + hash[key] = attribute_mapping.call(self) + end + end + end + + module ClassMethods + def inherited(base) + super + base._attribute_mappings = _attribute_mappings.dup + end + + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # attributes :id, :name, :recent_edits + def attributes(*attrs) + attrs = attrs.first if attrs.first.class == Array + + attrs.each do |attr| + attribute(attr) + end + end + + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # attributes :id, :recent_edits + # attribute :name, key: :title + # + # attribute :full_name do + # "#{object.first_name} #{object.last_name}" + # end + # + # def recent_edits + # object.edits.last(5) + # end + def attribute(attr, options = {}, &block) + key = options.fetch(:key, attr) + _attribute_mappings[key] = Attribute.build(attr, block) + end + + # @api private + # names of attribute methods + # @see Serializer::attribute + def _attributes + _attribute_mappings.keys + end + + # @api private + # maps attribute value to explict key name + # @see Serializer::attribute + # @see Adapter::FragmentCache#fragment_serializer + def _attributes_keys + _attribute_mappings + .each_with_object({}) do |(key, attribute_mapping), hash| + next if key == attribute_mapping.name + hash[attribute_mapping.name] = { key: key } + end + end + end + end + end +end diff --git a/lib/active_model/serializer/reflection.rb b/lib/active_model/serializer/reflection.rb index 18850abe..c027d96e 100644 --- a/lib/active_model/serializer/reflection.rb +++ b/lib/active_model/serializer/reflection.rb @@ -7,8 +7,16 @@ module ActiveModel # class PostSerializer < ActiveModel::Serializer # has_one :author, serializer: AuthorSerializer # has_many :comments + # has_many :comments, key: :last_comments do + # last(1) + # end # end # + # Notice that the association block is evaluated in the context of the association. + # Specifically, the association 'comments' is evaluated two different ways: + # 1) as 'comments' and named 'comments'. + # 2) as 'comments.last(1)' and named 'last_comments'. + # # PostSerializer._reflections #=> # # [ # # HasOneReflection.new(:author, serializer: AuthorSerializer), @@ -17,7 +25,30 @@ module ActiveModel # # So you can inspect reflections in your Adapters. # - Reflection = Struct.new(:name, :options) do + Reflection = Struct.new(:name, :options, :block) do + delegate :call, to: :reader + + attr_reader :reader + + def initialize(*) + super + @reader = self.class.build_reader(name, block) + end + + # @api private + def value(instance) + call(instance) + end + + # @api private + def self.build_reader(name, block) + if block + ->(instance) { instance.read_attribute_for_serialization(name).instance_eval(&block) } + else + ->(instance) { instance.read_attribute_for_serialization(name) } + end + end + # Build association. This method is used internally to # build serializer's association by its reflection. # @@ -40,7 +71,7 @@ module ActiveModel # @api private # def build_association(subject, parent_serializer_options) - association_value = subject.send(name) + association_value = value(subject) reflection_options = options.dup serializer_class = subject.class.serializer_for(association_value, reflection_options) diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb index 573b3fa8..5a6e3681 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -116,7 +116,7 @@ RoleSerializer = Class.new(ActiveModel::Serializer) do attributes :id, :name, :description, :slug def slug - "#{name}-#{id}" + "#{object.name}-#{object.id}" end belongs_to :author diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb index 205cfcc8..ecb671f2 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -126,6 +126,35 @@ module ActiveModel assert expected_association_keys.include? :site end + class InlineAssociationTestPostSerializer < ActiveModel::Serializer + has_many :comments + has_many :comments, key: :last_comments do + last(1) + end + end + + def test_virtual_attribute_block + comment1 = ::ARModels::Comment.create!(contents: 'first comment') + comment2 = ::ARModels::Comment.create!(contents: 'last comment') + post = ::ARModels::Post.create!( + title: 'inline association test', + body: 'etc', + comments: [comment1, comment2] + ) + actual = serializable(post, adapter: :attributes, serializer: InlineAssociationTestPostSerializer).as_json + expected = { + :comments => [ + { :id => 1, :contents => 'first comment' }, + { :id => 2, :contents => 'last comment' } + ], + :last_comments => [ + { :id => 2, :contents => 'last comment' } + ] + } + + assert_equal expected, actual + end + class NamespacedResourcesTest < Minitest::Test class ResourceNamespace Post = Class.new(::Model) diff --git a/test/serializers/attribute_test.rb b/test/serializers/attribute_test.rb index 99452e53..cf9dae4f 100644 --- a/test/serializers/attribute_test.rb +++ b/test/serializers/attribute_test.rb @@ -43,6 +43,15 @@ module ActiveModel assert_equal({ blog: { id: 'AMS Hints' } }, adapter.serializable_hash) end + def test_object_attribute_override + serializer = Class.new(ActiveModel::Serializer) do + attribute :name, key: :object + end + + adapter = ActiveModel::Serializer::Adapter::Json.new(serializer.new(@blog)) + assert_equal({ blog: { object: 'AMS Hints' } }, adapter.serializable_hash) + end + def test_type_attribute attribute_serializer = Class.new(ActiveModel::Serializer) do attribute :id, key: :type @@ -71,6 +80,21 @@ module ActiveModel assert_equal('custom', hash[:blog][:id]) end + + PostWithVirtualAttribute = Class.new(::Model) + class PostWithVirtualAttributeSerializer < ActiveModel::Serializer + attribute :name do + "#{object.first_name} #{object.last_name}" + end + end + + def test_virtual_attribute_block + post = PostWithVirtualAttribute.new(first_name: 'Lucas', last_name: 'Hosseini') + hash = serializable(post).serializable_hash + expected = { name: 'Lucas Hosseini' } + + assert_equal(expected, hash) + end end end end