Merge pull request #1356 from bf4/attribute_objects

Add inline syntax for attributes and associations
This commit is contained in:
Benjamin Fleischer 2015-12-10 15:10:45 -06:00
commit f562449bd4
7 changed files with 232 additions and 68 deletions

View File

@ -3,6 +3,7 @@ require 'active_model/serializer/collection_serializer'
require 'active_model/serializer/array_serializer' require 'active_model/serializer/array_serializer'
require 'active_model/serializer/include_tree' require 'active_model/serializer/include_tree'
require 'active_model/serializer/associations' require 'active_model/serializer/associations'
require 'active_model/serializer/attributes'
require 'active_model/serializer/configuration' require 'active_model/serializer/configuration'
require 'active_model/serializer/fieldset' require 'active_model/serializer/fieldset'
require 'active_model/serializer/lint' require 'active_model/serializer/lint'
@ -13,6 +14,7 @@ module ActiveModel
class Serializer class Serializer
include Configuration include Configuration
include Associations include Associations
include Attributes
require 'active_model/serializer/adapter' require 'active_model/serializer/adapter'
# Matches # Matches
@ -45,14 +47,9 @@ module ActiveModel
end end
with_options instance_writer: false, instance_reader: false do |serializer| with_options instance_writer: false, instance_reader: false do |serializer|
class_attribute :_type, instance_reader: true serializer.class_attribute :_type, instance_reader: true
class_attribute :_attributes # @api private : names of attribute methods, @see Serializer#attribute serializer.class_attribute :_links # @api private : links definitions, @see Serializer#link
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
self._links ||= {} self._links ||= {}
serializer.class_attribute :_cache # @api private : the cache object serializer.class_attribute :_cache # @api private : the cache object
serializer.class_attribute :_fragmented # @api private : @see ::fragmented serializer.class_attribute :_fragmented # @api private : @see ::fragmented
serializer.class_attribute :_cache_key # @api private : when present, is first item in cache_key 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 serializer.class_attribute :_cache_digest # @api private : Generated
end end
# Serializers inherit _attributes and _attributes_keys. # Serializers inherit _attribute_mappings, _reflections, and _links.
# Generates a unique digest for each serializer at load. # Generates a unique digest for each serializer at load.
def self.inherited(base) def self.inherited(base)
caller_line = caller.first caller_line = caller.first
base._attributes = _attributes.dup
base._attributes_keys = _attributes_keys.dup
base._links = _links.dup base._links = _links.dup
base._cache_digest = digest_caller_file(caller_line) base._cache_digest = digest_caller_file(caller_line)
super super
@ -91,37 +86,6 @@ module ActiveModel
_links[name] = block || value _links[name] = block || value
end 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 # @api private
# Used by FragmentCache on the CachedSerializer # Used by FragmentCache on the CachedSerializer
# to call attribute methods on the fragmented cached serializer. # to call attribute methods on the fragmented cached serializer.
@ -220,6 +184,15 @@ module ActiveModel
end end
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 attr_accessor :object, :root, :scope
# `scope_name` is set as :current_user by default in the controller. # `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 root || object.class.model_name.to_s.underscore
end end
# Return the +attributes+ of +object+ as presented def read_attribute_for_serialization(attr)
# by the serializer. if self.class._serializer_instance_method_defined?(attr)
def attributes(requested_attrs = nil) send(attr)
self.class._attributes.each_with_object({}) do |name, hash| elsif self.class._fragmented
next unless requested_attrs.nil? || requested_attrs.include?(name) self.class._fragmented.read_attribute_for_serialization(attr)
if self.class._fragmented else
hash[name] = self.class._fragmented.public_send(name) object.read_attribute_for_serialization(attr)
else
hash[name] = send(name)
end
end end
end end

View File

@ -12,9 +12,10 @@ module ActiveModel
DEFAULT_INCLUDE_TREE = ActiveModel::Serializer::IncludeTree.from_string('*') DEFAULT_INCLUDE_TREE = ActiveModel::Serializer::IncludeTree.from_string('*')
included do |base| included do
class << base with_options instance_writer: false, instance_reader: true do |serializer|
attr_accessor :_reflections serializer.class_attribute :_reflections
self._reflections ||= []
end end
extend ActiveSupport::Autoload extend ActiveSupport::Autoload
@ -29,7 +30,8 @@ module ActiveModel
module ClassMethods module ClassMethods
def inherited(base) def inherited(base)
base._reflections = self._reflections.try(:dup) || [] super
base._reflections = _reflections.dup
end end
# @param [Symbol] name of the association # @param [Symbol] name of the association
@ -39,8 +41,8 @@ module ActiveModel
# @example # @example
# has_many :comments, serializer: CommentSummarySerializer # has_many :comments, serializer: CommentSummarySerializer
# #
def has_many(name, options = {}) def has_many(name, options = {}, &block)
associate HasManyReflection.new(name, options) associate(HasManyReflection.new(name, options, block))
end end
# @param [Symbol] name of the association # @param [Symbol] name of the association
@ -50,8 +52,8 @@ module ActiveModel
# @example # @example
# belongs_to :author, serializer: AuthorSerializer # belongs_to :author, serializer: AuthorSerializer
# #
def belongs_to(name, options = {}) def belongs_to(name, options = {}, &block)
associate BelongsToReflection.new(name, options) associate(BelongsToReflection.new(name, options, block))
end end
# @param [Symbol] name of the association # @param [Symbol] name of the association
@ -61,8 +63,8 @@ module ActiveModel
# @example # @example
# has_one :author, serializer: AuthorSerializer # has_one :author, serializer: AuthorSerializer
# #
def has_one(name, options = {}) def has_one(name, options = {}, &block)
associate HasOneReflection.new(name, options) associate(HasOneReflection.new(name, options, block))
end end
private private
@ -76,10 +78,6 @@ module ActiveModel
def associate(reflection) def associate(reflection)
self._reflections = _reflections.dup self._reflections = _reflections.dup
define_method reflection.name do
object.send reflection.name
end unless method_defined?(reflection.name)
self._reflections << reflection self._reflections << reflection
end end
end end

View File

@ -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

View File

@ -7,8 +7,16 @@ module ActiveModel
# class PostSerializer < ActiveModel::Serializer # class PostSerializer < ActiveModel::Serializer
# has_one :author, serializer: AuthorSerializer # has_one :author, serializer: AuthorSerializer
# has_many :comments # has_many :comments
# has_many :comments, key: :last_comments do
# last(1)
# end
# 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 #=> # PostSerializer._reflections #=>
# # [ # # [
# # HasOneReflection.new(:author, serializer: AuthorSerializer), # # HasOneReflection.new(:author, serializer: AuthorSerializer),
@ -17,7 +25,30 @@ module ActiveModel
# #
# So you can inspect reflections in your Adapters. # 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 association. This method is used internally to
# build serializer's association by its reflection. # build serializer's association by its reflection.
# #
@ -40,7 +71,7 @@ module ActiveModel
# @api private # @api private
# #
def build_association(subject, parent_serializer_options) def build_association(subject, parent_serializer_options)
association_value = subject.send(name) association_value = value(subject)
reflection_options = options.dup reflection_options = options.dup
serializer_class = subject.class.serializer_for(association_value, reflection_options) serializer_class = subject.class.serializer_for(association_value, reflection_options)

View File

@ -116,7 +116,7 @@ RoleSerializer = Class.new(ActiveModel::Serializer) do
attributes :id, :name, :description, :slug attributes :id, :name, :description, :slug
def slug def slug
"#{name}-#{id}" "#{object.name}-#{object.id}"
end end
belongs_to :author belongs_to :author

View File

@ -126,6 +126,35 @@ module ActiveModel
assert expected_association_keys.include? :site assert expected_association_keys.include? :site
end 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 NamespacedResourcesTest < Minitest::Test
class ResourceNamespace class ResourceNamespace
Post = Class.new(::Model) Post = Class.new(::Model)

View File

@ -43,6 +43,15 @@ module ActiveModel
assert_equal({ blog: { id: 'AMS Hints' } }, adapter.serializable_hash) assert_equal({ blog: { id: 'AMS Hints' } }, adapter.serializable_hash)
end 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 def test_type_attribute
attribute_serializer = Class.new(ActiveModel::Serializer) do attribute_serializer = Class.new(ActiveModel::Serializer) do
attribute :id, key: :type attribute :id, key: :type
@ -71,6 +80,21 @@ module ActiveModel
assert_equal('custom', hash[:blog][:id]) assert_equal('custom', hash[:blog][:id])
end 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 end
end end