pragmarb/pragma-decorator

View on GitHub
lib/pragma/decorator/association.rb

Summary

Maintainability
A
35 mins
Test Coverage
# 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