rom-rb/rom-mapper

View on GitHub
lib/rom/processor/transproc.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'transproc/all'

require 'rom/processor'

module ROM
  class Processor
    # Data mapping transformer builder using Transproc
    #
    # This builds a transproc function that is used to map a whole relation
    #
    # @see https://github.com/solnic/transproc too
    #
    # @private
    class Transproc < Processor
      include ::Transproc::Composer

      module Functions
        extend ::Transproc::Registry

        import ::Transproc::Coercions
        import ::Transproc::ArrayTransformations
        import ::Transproc::HashTransformations
        import ::Transproc::ClassTransformations
        import ::Transproc::ProcTransformations
        INVALID_INJECT_UNION_VALUE = "%s attribute: block is required for :from with union value.".freeze

        def self.identity(tuple)
          tuple
        end

        def self.filter_empty(arr)
          arr.reject { |row| row.values.all?(&:nil?) }
        end

        def self.inject_union_value(tuple, name, keys, coercer)
          raise ROM::MapperMisconfiguredError, INVALID_INJECT_UNION_VALUE % [name] if !coercer

          values = tuple.values_at(*keys)
          result = coercer.call(*values)

          tuple.merge(name => result)
        end
      end

      # @return [Mapper] mapper that this processor belongs to
      #
      # @api private
      attr_reader :mapper

      # @return [Header] header from a mapper
      #
      # @api private
      attr_reader :header

      # @return [Class] model class from a mapper
      #
      # @api private
      attr_reader :model

      # @return [Hash] header's attribute mapping
      #
      # @api private
      attr_reader :mapping

      # @return [Proc] row-processing proc
      #
      # @api private
      attr_reader :row_proc

      # Build a transproc function from the header
      #
      # @param [ROM::Header] header
      #
      # @return [Transproc::Function]
      #
      # @api private
      def self.build(mapper, header)
        new(mapper, header).to_transproc
      end

      # @api private
      def initialize(mapper, header)
        @mapper = mapper
        @header = header
        @model = header.model
        @mapping = header.mapping
        initialize_row_proc
      end

      # Coerce mapper header to a transproc data mapping function
      #
      # @return [Transproc::Function]
      #
      # @api private
      def to_transproc
        compose(t(:identity)) do |ops|
          combined = header.combined
          ops << t(:combine, combined.map(&method(:combined_args))) if combined.any?
          ops << header.preprocessed.map { |attr| visit(attr, true) }
          ops << t(:map_array, row_proc) if row_proc
          ops << header.postprocessed.map { |attr| visit(attr, true) }
        end
      end

      private

      # Visit an attribute from the header
      #
      # This forwards to a specialized visitor based on the attribute type
      #
      # @param [Header::Attribute] attribute
      # @param [Array] args Allows to send `preprocess: true`
      #
      # @api private
      def visit(attribute, *args)
        type = attribute.class.name.split('::').last.downcase
        send("visit_#{type}", attribute, *args)
      end

      # Visit plain attribute
      #
      # It will call block transformation if it's used
      #
      # If it's a typed attribute a coercion transformation is added
      #
      # @param [Header::Attribute] attribute
      #
      # @api private
      def visit_attribute(attribute)
        coercer = attribute.meta[:coercer]
        if attribute.union?
          compose do |ops|
            ops << t(:inject_union_value, attribute.name, attribute.key, coercer)
            ops << t(:reject_keys, attribute.key) unless header.copy_keys
          end
        elsif coercer
          t(:map_value, attribute.name, t(:bind, mapper, coercer))
        elsif attribute.typed?
          t(:map_value, attribute.name, t(:"to_#{attribute.type}"))
        end
      end

      # Visit hash attribute
      #
      # @param [Header::Attribute::Hash] attribute
      #
      # @api private
      def visit_hash(attribute)
        with_row_proc(attribute) do |row_proc|
          t(:map_value, attribute.name, row_proc)
        end
      end

      # Visit combined attribute
      #
      # @api private
      def visit_combined(attribute)
        op = with_row_proc(attribute) do |row_proc|
          array_proc =
            if attribute.type == :hash
              t(:map_array, row_proc) >> -> arr { arr.first }
            else
              t(:map_array, row_proc)
            end

          t(:map_value, attribute.name, array_proc)
        end

        if op
          op
        elsif attribute.type == :hash
          t(:map_value, attribute.name, -> arr { arr.first })
        end
      end

      # Visit array attribute
      #
      # @param [Header::Attribute::Array] attribute
      #
      # @api private
      def visit_array(attribute)
        with_row_proc(attribute) do |row_proc|
          t(:map_value, attribute.name, t(:map_array, row_proc))
        end
      end

      # Visit wrapped hash attribute
      #
      # :nest transformation is added to handle wrapping
      #
      # @param [Header::Attribute::Wrap] attribute
      #
      # @api private
      def visit_wrap(attribute)
        name = attribute.name
        keys = attribute.tuple_keys

        compose do |ops|
          ops << t(:nest, name, keys)
          ops << visit_hash(attribute)
        end
      end

      # Visit unwrap attribute
      #
      # :unwrap transformation is added to handle unwrapping
      #
      # @param [Header::Attributes::Unwrap]
      #
      # @api private
      def visit_unwrap(attribute)
        name = attribute.name
        keys = attribute.pop_keys

        compose do |ops|
          ops << visit_hash(attribute)
          ops << t(:unwrap, name, keys)
        end
      end

      # Visit group hash attribute
      #
      # :group transformation is added to handle grouping during preprocessing.
      # Otherwise we simply use array visitor for the attribute.
      #
      # @param [Header::Attribute::Group] attribute
      # @param [Boolean] preprocess true if we are building a relation preprocessing
      #                             function that is applied to the whole relation
      #
      # @api private
      def visit_group(attribute, preprocess = false)
        if preprocess
          name = attribute.name
          header = attribute.header
          keys = attribute.tuple_keys

          others = header.preprocessed

          compose do |ops|
            ops << t(:group, name, keys)
            ops << t(:map_array, t(:map_value, name, t(:filter_empty)))
            ops << others.map { |attr|
              t(:map_array, t(:map_value, name, visit(attr, true)))
            }
          end
        else
          visit_array(attribute)
        end
      end

      # Visit ungroup attribute
      #
      # :ungroup transforation is added to handle ungrouping during preprocessing.
      # Otherwise we simply use array visitor for the attribute.
      #
      # @param [Header::Attribute::Ungroup] attribute
      # @param [Boolean] preprocess true if we are building a relation preprocessing
      #                             function that is applied to the whole relation
      #
      # @api private
      def visit_ungroup(attribute, preprocess = false)
        if preprocess
          name = attribute.name
          header = attribute.header
          keys = attribute.pop_keys

          others = header.postprocessed

          compose do |ops|
            ops << others.map { |attr|
              t(:map_array, t(:map_value, name, visit(attr, true)))
            }
            ops << t(:ungroup, name, keys)
          end
        else
          visit_array(attribute)
        end
      end

      # Visit fold hash attribute
      #
      # :fold transformation is added to handle folding during preprocessing.
      #
      # @param [Header::Attribute::Fold] attribute
      # @param [Boolean] preprocess true if we are building a relation preprocessing
      #                             function that is applied to the whole relation
      #
      # @api private
      def visit_fold(attribute, preprocess = false)
        if preprocess
          name = attribute.name
          keys = attribute.tuple_keys

          compose do |ops|
            ops << t(:group, name, keys)
            ops << t(:map_array, t(:map_value, name, t(:filter_empty)))
            ops << t(:map_array, t(:fold, name, keys.first))
          end
        end
      end

      # Visit unfold hash attribute
      #
      # :unfold transformation is added to handle unfolding during preprocessing.
      #
      # @param [Header::Attribute::Unfold] attribute
      # @param [Boolean] preprocess true if we are building a relation preprocessing
      #                             function that is applied to the whole relation
      #
      # @api private
      def visit_unfold(attribute, preprocess = false)
        if preprocess
          name = attribute.name
          header = attribute.header
          keys = attribute.pop_keys
          key = keys.first

          others = header.postprocessed

          compose do |ops|
            ops << others.map { |attr|
              t(:map_array, t(:map_value, name, visit(attr, true)))
            }
            ops << t(:map_array, t(:map_value, name, t(:insert_key, key)))
            ops << t(:map_array, t(:reject_keys, [key] - [name]))
            ops << t(:ungroup, name, [key])
          end
        end
      end

      # Visit excluded attribute
      #
      # @param [Header::Attribute::Exclude] attribute
      #
      # @api private
      def visit_exclude(attribute)
        t(:reject_keys, [attribute.name])
      end

      # @api private
      def combined_args(attribute)
        other = attribute.header.combined

        if other.any?
          children = other.map(&method(:combined_args))
          [attribute.name, attribute.meta[:keys], children]
        else
          [attribute.name, attribute.meta[:keys]]
        end
      end

      # Build row_proc
      #
      # This transproc function is applied to each row in a dataset
      #
      # @api private
      def initialize_row_proc
        @row_proc = compose { |ops|
          alias_handler = header.copy_keys ? :copy_keys : :rename_keys
          process_header_keys(ops)

          ops << t(alias_handler, mapping) if header.aliased?
          ops << header.map { |attr| visit(attr) }
          ops << t(:constructor_inject, model) if model
        }
      end

      # Process row_proc header keys
      #
      # @api private
      def process_header_keys(ops)
        if header.reject_keys
          all_keys = header.tuple_keys + header.non_primitives.map(&:key)
          ops << t(:accept_keys, all_keys)
        end
        ops
      end

      # Yield row proc for a given attribute if any
      #
      # @param [Header::Attribute] attribute
      #
      # @api private
      def with_row_proc(attribute)
        row_proc = row_proc_from(attribute)
        yield(row_proc) if row_proc
      end

      # Build a row_proc from a given attribute
      #
      # This is used by embedded attribute visitors
      #
      # @api private
      def row_proc_from(attribute)
        new(mapper, attribute.header).row_proc
      end

      # Return a new instance of the processor
      #
      # @api private
      def new(*args)
        self.class.new(*args)
      end

      # @api private
      def t(*args)
        Functions[*args]
      end
    end
  end
end