rom-rb/rom-mapper

View on GitHub
lib/rom/mapper/attribute_dsl.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'rom/header'
require 'rom/mapper/model_dsl'

module ROM
  class Mapper
    # Mapper attribute DSL exposed by mapper subclasses
    #
    # This class is private even though its methods are exposed by mappers.
    # Typically it's not meant to be used directly.
    #
    # TODO: break this madness down into smaller pieces
    #
    # @api private
    class AttributeDSL
      include ModelDSL

      attr_reader :attributes, :options, :copy_keys, :symbolize_keys, :reject_keys, :steps

      # @param [Array] attributes accumulator array
      # @param [Hash] options
      #
      # @api private
      def initialize(attributes, options)
        @attributes = attributes
        @options = options
        @copy_keys = options.fetch(:copy_keys)
        @symbolize_keys = options.fetch(:symbolize_keys)
        @prefix = options.fetch(:prefix)
        @prefix_separator = options.fetch(:prefix_separator)
        @reject_keys = options.fetch(:reject_keys)
        @steps = []
      end

      # Redefine the prefix for the following attributes
      #
      # @example
      #
      #   dsl = AttributeDSL.new([])
      #   dsl.attribute(:prefix, 'user')
      #
      # @api public
      def prefix(value = Undefined)
        if value.equal?(Undefined)
          @prefix
        else
          @prefix = value
        end
      end

      # Redefine the prefix separator for the following attributes
      #
      # @example
      #
      #   dsl = AttributeDSL.new([])
      #   dsl.attribute(:prefix_separator, '.')
      #
      # @api public
      def prefix_separator(value = Undefined)
        if value.equal?(Undefined)
          @prefix_separator
        else
          @prefix_separator = value
        end
      end

      # Define a mapping attribute with its options and/or block
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.attribute(:name)
      #   dsl.attribute(:email, from: 'user_email')
      #   dsl.attribute(:name) { 'John' }
      #   dsl.attribute(:name) { |t| t.upcase }
      #
      # @api public
      def attribute(name, options = EMPTY_HASH, &block)
        with_attr_options(name, options) do |attr_options|
          raise ArgumentError,
            "can't specify type and block at the same time" if options[:type] && block
          attr_options[:coercer] = block if block
          add_attribute(name, attr_options)
        end
      end

      def exclude(name)
        attributes << [name, { exclude: true }]
      end

      # Perform transformations sequentially
      #
      # @example
      #   dsl = AttributeDSL.new()
      #
      #   dsl.step do
      #     attribute :name
      #   end
      #
      # @api public
      def step(options = EMPTY_HASH, &block)
        steps << new(options, &block)
      end

      # Define an embedded attribute
      #
      # Block exposes the attribute dsl too
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.embedded :tags, type: :array do
      #     attribute :name
      #   end
      #
      #   dsl.embedded :address, type: :hash do
      #     model Address
      #     attribute :name
      #   end
      #
      # @param [Symbol] name attribute
      #
      # @param [Hash] options
      # @option options [Symbol] :type Embedded type can be :hash or :array
      # @option options [Symbol] :prefix Prefix that should be used for
      #                                  its attributes
      #
      # @api public
      def embedded(name, options, &block)
        with_attr_options(name) do |attr_options|
          mapper = options[:mapper]

          if mapper
            embedded_options = { type: :array }.update(options)
            attributes_from_mapper(
              mapper, name, embedded_options.update(attr_options)
            )
          else
            dsl = new(options, &block)
            attr_options.update(options)
            add_attribute(
              name, { header: dsl.header, type: :array }.update(attr_options)
            )
          end
        end
      end

      # Define an embedded hash attribute that requires "wrapping" transformation
      #
      # Typically this is used in sql context when relation is a join.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.wrap(address: [:street, :zipcode, :city])
      #
      #   dsl.wrap(:address) do
      #     model Address
      #     attribute :street
      #     attribute :zipcode
      #     attribute :city
      #   end
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def wrap(*args, &block)
        ensure_mapper_configuration('wrap', args, block_given?)

        with_name_or_options(*args) do |name, options, mapper|
          wrap_options = { type: :hash, wrap: true }.update(options)

          if mapper
            attributes_from_mapper(mapper, name, wrap_options)
          else
            dsl(name, wrap_options, &block)
          end
        end
      end

      # Define an embedded hash attribute that requires "unwrapping" transformation
      #
      # Typically this is used in no-sql context to normalize data before
      # inserting to sql gateway.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.unwrap(address: [:street, :zipcode, :city])
      #
      #   dsl.unwrap(:address) do
      #     attribute :street
      #     attribute :zipcode
      #     attribute :city
      #   end
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def unwrap(*args, &block)
        with_name_or_options(*args) do |name, options, mapper|
          unwrap_options = { type: :hash, unwrap: true }.update(options)

          if mapper
            attributes_from_mapper(mapper, name, unwrap_options)
          else
            dsl(name, unwrap_options, &block)
          end
        end
      end

      # Define an embedded hash attribute that requires "grouping" transformation
      #
      # Typically this is used in sql context when relation is a join.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.group(tags: [:name])
      #
      #   dsl.group(:tags) do
      #     model Tag
      #     attribute :name
      #   end
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def group(*args, &block)
        ensure_mapper_configuration('group', args, block_given?)

        with_name_or_options(*args) do |name, options, mapper|
          group_options = { type: :array, group: true }.update(options)

          if mapper
            attributes_from_mapper(mapper, name, group_options)
          else
            dsl(name, group_options, &block)
          end
        end
      end

      # Define an embedded array attribute that requires "ungrouping" transformation
      #
      # Typically this is used in non-sql context being prepared for import to sql.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #   dsl.ungroup(tags: [:name])
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def ungroup(*args, &block)
        with_name_or_options(*args) do |name, options, *|
          ungroup_options = { type: :array, ungroup: true }.update(options)
          dsl(name, ungroup_options, &block)
        end
      end

      # Define an embedded hash attribute that requires "fold" transformation
      #
      # Typically this is used in sql context to fold single joined field
      # to the array of values.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.fold(tags: [:name])
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def fold(*args, &block)
        with_name_or_options(*args) do |name, *|
          fold_options = { type: :array, fold: true }
          dsl(name, fold_options, &block)
        end
      end

      # Define an embedded hash attribute that requires "unfold" transformation
      #
      # Typically this is used in non-sql context to convert array of
      # values (like in Cassandra 'SET' or 'LIST' types) to array of tuples.
      #
      # Source values are assigned to the first key, the other keys being left blank.
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.unfold(tags: [:name, :type], from: :tags_list)
      #
      #   dsl.unfold :tags, from: :tags_list do
      #     attribute :name, from: :tag_name
      #     attribute :type, from: :tag_type
      #   end
      #
      # @see AttributeDSL#embedded
      #
      # @api public
      def unfold(name, options = EMPTY_HASH)
        with_attr_options(name, options) do |attr_options|
          old_name = attr_options.fetch(:from, name)
          dsl(old_name, type: :array, unfold: true) do
            attribute name, attr_options
            yield if block_given?
          end
        end
      end

      # Define an embedded combined attribute that requires "combine" transformation
      #
      # Typically this can be used to process results of eager-loading
      #
      # @example
      #   dsl = AttributeDSL.new([])
      #
      #   dsl.combine(:tags, user_id: :id) do
      #     model Tag
      #
      #     attribute :name
      #   end
      #
      # @param [Symbol] name
      # @param [Hash] options
      #   @option options [Hash] :on The "join keys"
      #   @option options [Symbol] :type The type, either :array (default) or :hash
      #
      # @api public
      def combine(name, options, &block)
        dsl = new(options, &block)

        attr_opts = {
          type: options.fetch(:type, :array),
          keys: options.fetch(:on),
          combine: true,
          header: dsl.header
        }

        add_attribute(name, attr_opts)
      end

      # Generate a header from attribute definitions
      #
      # @return [Header]
      #
      # @api private
      def header
        Header.coerce(attributes, copy_keys: copy_keys, model: model, reject_keys: reject_keys)
      end

      private

      # Remove the attribute used somewhere else (in wrap, group, model etc.)
      #
      # @api private
      def remove(*names)
        attributes.delete_if { |attr| names.include?(attr.first) }
      end

      # Handle attribute options common for all definitions
      #
      # @api private
      def with_attr_options(name, options = EMPTY_HASH)
        attr_options = options.dup

        if @prefix
          attr_options[:from] ||= "#{@prefix}#{@prefix_separator}#{name}"
          attr_options[:from] = attr_options[:from].to_sym if name.is_a? Symbol
        end

        if symbolize_keys
          attr_options.update(from: attr_options.fetch(:from) { name }.to_s)
        end

        yield(attr_options)
      end

      # Handle "name or options" syntax used by `wrap` and `group`
      #
      # @api private
      def with_name_or_options(*args)
        name, options =
          if args.size > 1
            args
          else
            [args.first, {}]
          end

        yield(name, options, options[:mapper])
      end

      # Create another instance of the dsl for nested definitions
      #
      # This is used by embedded, wrap and group
      #
      # @api private
      def dsl(name_or_attrs, options, &block)
        if block
          attributes_from_block(name_or_attrs, options, &block)
        else
          attributes_from_hash(name_or_attrs, options)
        end
      end

      # Define attributes from a nested block
      #
      # Used by embedded, wrap and group
      #
      # @api private
      def attributes_from_block(name, options, &block)
        dsl = new(options, &block)
        header = dsl.header
        add_attribute(name, options.update(header: header))
        header.each { |attr| remove(attr.key) unless name == attr.key }
      end

      # Define attributes from the `name => attributes` hash syntax
      #
      # Used by wrap and group
      #
      # @api private
      def attributes_from_hash(hash, options)
        hash.each do |name, header|
          with_attr_options(name, options) do |attr_options|
            add_attribute(name, attr_options.update(header: header.zip))
            header.each { |attr| remove(attr) unless name == attr }
          end
        end
      end

      # Infer mapper header for an embedded attribute
      #
      # @api private
      def attributes_from_mapper(mapper, name, options)
        if mapper.is_a?(Class)
          add_attribute(name, { header: mapper.header }.update(options))
        else
          raise(
            ArgumentError, ":mapper must be a class #{mapper.inspect}"
          )
        end
      end

      # Add a new attribute and make sure it overrides previous definition
      #
      # @api private
      def add_attribute(name, options)
        remove(name, name.to_s)
        attributes << [name, options]
      end

      # Create a new dsl instance of potentially overidden options
      #
      # Embedded, wrap and group can override top-level options like `prefix`
      #
      # @api private
      def new(options, &block)
        dsl = self.class.new([], @options.merge(options))
        dsl.instance_exec(&block) unless block.nil?
        dsl
      end

      # Ensure the mapping configuration isn't ambiguous
      #
      # @api private
      def ensure_mapper_configuration(method_name, args, block_present)
        if args.first.is_a?(Hash) && block_present
          raise MapperMisconfiguredError,
                "Cannot configure `#{method_name}#` using both options and a block"
        end
        if args.first.is_a?(Hash) && args.first[:mapper]
          raise MapperMisconfiguredError,
                "Cannot configure `#{method_name}#` using both options and a mapper"
        end
      end
    end
  end
end