lib/pragma/decorator/association.rb
# frozen_string_literal: true
module Pragma
module Decorator
# Associations provide a way to define related records on your decorators.
#
# Once you define an association, it can be expanded by API clients through the +expand+ query
# parameter to load the full record.
#
# @example Defining and expanding an association
# class ArticleDecorator < Pragma::Decorator::Base
# belongs_to :author, decorator: API::V1::User::Decorator
# end
#
# # This will return a hash whose `author` key is the ID of the article's author.
# ArticleDecorator.new(article).to_hash
#
# # This will return a hash whose `author` key is a hash representing the decorated
# # article's author.
# ArticleDecorator.new(article).to_hash(user_options: { expand: ['author'] })
module Association
def self.included(klass)
klass.extend ClassMethods
klass.include InstanceMethods
end
module ClassMethods # :nodoc:
# Returns the associations defined on this decorator.
#
# @return [Hash{Symbol => Reflection}] the associations
def associations
@associations ||= superclass.respond_to?(:associations) ? superclass.associations : {}
end
# Defines a +belongs_to+ association on this decorator.
#
# This will first create an association definition and then define a new property with the
# name of the association.
#
# This method supports all the usual options accepted by +#property+.
#
# @param attribute_name [Symbol] name of the association
# @param options [Hash] the association's options
def belongs_to(attribute_name, options = {})
define_association :belongs_to, attribute_name, options
end
# Defines a +has_one+ association on this decorator.
#
# This will first create an association definition and then define a new property with the
# name of the association.
#
# This method supports all the usual options accepted by +#property+.
#
# @param attribute_name [Symbol] name of the association
# @param options [Hash] the association's options
def has_one(attribute_name, options = {}) # rubocop:disable Naming/PredicateName
define_association :has_one, attribute_name, options
end
private
def define_association(type, attribute_name, options = {})
create_association_definition(type, attribute_name, options)
create_association_property(attribute_name, options)
end
def create_association_definition(type, attribute_name, options)
association_name = options.fetch(:as, attribute_name.to_sym)
associations[association_name] = Reflection.new(options.merge(
type: type,
name: association_name,
attribute: attribute_name
))
end
def create_association_property(attribute_name, options)
association_name = options.fetch(:as, attribute_name.to_sym)
property_options = options.dup.tap { |po| po.delete(:decorator) }.merge(
exec_context: :decorator,
as: options[:as] || attribute_name,
getter: (lambda do |decorator:, user_options:, **_args|
Bond.new(
reflection: decorator.class.associations[association_name],
decorator: decorator
).render(user_options)
end)
)
property("_#{association_name}_association", property_options)
end
end
module InstanceMethods # :nodoc:
def validate_expansion(expand)
check_parent_associations_are_expanded(expand)
check_expanded_associations_exist(expand)
end
private
def check_parent_associations_are_expanded(expand)
expand = normalize_expand(expand)
expand.each do |property|
next unless property.include?('.')
parent_path = property.split('.')[0..-2].join('.')
next if expand.include?(parent_path)
fail Association::UnexpandedAssociationParent.new(property, parent_path)
end
end
def check_expanded_associations_exist(expand)
expand = normalize_expand(expand)
expand.each do |property|
next if self.class.associations.key?(property.to_sym) || property.include?('.')
fail Association::AssociationNotFound, property
end
end
def normalize_expand(expand)
[expand].flatten.map(&:to_s).reject(&:blank?)
end
end
end
end
end