autotelik/datashift

View on GitHub
lib/datashift/populators/populator.rb

Summary

Maintainability
D
1 day
Test Coverage
# Copyright:: (c) Autotelik Media Ltd 2012
# Author ::   Tom Statter
# Date ::     March 2012
# License::   MIT
#
# Details::   The default Populator class for assigning data to models
#
#             Provides individual population methods on an AR model.
#
#             Enables users to assign values to AR object, without knowing much about that receiving object.
#
require_relative 'has_many'
require_relative 'insistent_assignment'

module DataShift

  class Populator

    include DataShift::Logging
    extend DataShift::Logging

    include DataShift::Delimiters
    extend DataShift::Delimiters

    def self.insistent_method_list
      @insistent_method_list ||= %i[to_s downcase to_i to_f to_b]
    end

    # When looking up an association, when no field provided, try each of these in turn till a match
    # i.e find_by_name, find_by_title, find_by_id
    def self.insistent_find_by_list
      @insistent_find_by_list ||= %i[name title id]
    end

    attr_reader :value, :attribute_hash

    attr_accessor :previous_value, :original_data

    def initialize(transformer = nil)
      # reset
      @transformer = transformer || Transformation.factory

      @attribute_hash = {}
    end

    # Main client hooks :

    # Prepare the data to be populated, then assign to the Db record

    def prepare_and_assign(context, record, data)
      prepare_and_assign_method_binding(context.method_binding, record, data)
    end

    # This is the most pertinent hook for derived Processors, where you can provide custom
    # population messages for specific Method bindings

    def prepare_and_assign_method_binding(method_binding, record, data)
      prepare_data(method_binding, data)

      assign(method_binding, record)
    end

    def reset
      @value = nil
      @previous_value = nil
      @original_data = nil
      @attribute_hash = {}
    end

    def value?
      !value.nil?
    end

    def self.attribute_hash_const_regexp
      @attribute_hash_const_regexp ||= Regexp.new( attribute_list_start + '.*' + attribute_list_end)
    end

    # Check supplied value, validate it, and if required :
    #   set to provided default value
    #   prepend any provided prefixes
    #   add any provided postfixes
    #
    # Rtns : tuple of [:value, :attribute_hash]
    #

    def prepare_data(method_binding, data)

      raise NilDataSuppliedError, 'No method_binding supplied for prepare_data' unless method_binding

      @original_data = data

      begin
        model_method = method_binding.model_method

        if(data.is_a?(ActiveRecord::Relation)) # Rails 4 - query no longer returns an array
          @value = data.to_a

        elsif(data.class.ancestors.include?(ActiveRecord::Base) || data.is_a?(Array))
          @value = data

        elsif(!DataShift::Guards.jruby? &&
            (data.is_a?(Spreadsheet::Formula) || data.class.ancestors.include?(Spreadsheet::Formula)) )

          @value = data.value # TOFIX jruby/apache poi equivalent ?

        elsif( model_method.can_cast? && model_method.cast_type.is_a?(ActiveRecord::Type::Boolean))

          # DEPRECATION WARNING: You attempted to assign a value which is not explicitly `true` or `false` ("0.00")
          # to a boolean column. Currently this value casts to `false`.
          # This will change to match Ruby's semantics, and will cast to `true` in Rails 5.
          # If you would like to maintain the current behavior, you should explicitly handle the values you would like cast to `false`.

          @value = if(data.in? [true, false])
                     data
                   else
                     data.to_s.casecmp('true').zero? || data.to_s.to_i == 1 ? true : false
                   end
        else
          @value = data.to_s.dup

          @attribute_hash = @value.slice!( Populator.attribute_hash_const_regexp )

          if attribute_hash.present?
            @attribute_hash = Populator.string_to_hash( attribute_hash )
            logger.info "Populator found attribute hash :[#{attribute_hash.inspect}]"
          else
            @attribute_hash = {}
          end
        end

        run_transforms(method_binding)
      rescue StandardError => e
        logger.error(e.message)
        logger.error("Populator stacktrace: #{e.backtrace.first}")
        raise DataProcessingError, "Populator failed to prepare data [#{value}] for #{method_binding.pp}"
      end

      [value, attribute_hash]
    end

    def assign(method_binding, record)

      model_method = method_binding.model_method

      operator = model_method.operator

      klass = model_method.klass

      if model_method.operator_for(:belongs_to)
        insistent_belongs_to(method_binding, record, value)

      elsif(model_method.operator_for(:has_many))

        DataShift::Populators::HasMany.call(record, value, method_binding)

      elsif model_method.operator_for(:has_one)

        begin
          record.send(operator + '=', value)
        rescue StandardError => x
          logger.error("Cannot assign value [#{value.inspect}] for has_one [#{operator}]")
          logger.error(x.inspect)

          if value.is_a?(model_method.klass)
            logger.error("Value was Correct Type (#{value.class}) - [#{model_method.klass}]")
          else
            logger.error("Value was Type (#{value.class}) - Required Type is [#{model_method.klass}]")
          end
        end

      elsif model_method.operator_for(:assignment)

        if model_method.connection_adapter_column

          return if check_process_enum(record, model_method ) # TOFIX .. enum section probably belongs in prepare_data

          assignment(record, value, model_method)

        else
          DataShift::Populators::InsistentAssignment.call(record, value, operator)

          logger.debug("Assigned #{value} => [#{operator}]")
        end

      elsif model_method.operator_for(:method)

        begin
          params_num = record.method(operator.to_sym).arity

          # There are situations where -1 returned, this is normally related to variable number of arguments,
          # but maybe buggy - have seen -1 for what seems perfceatlly normal method e.g   def attach_audio_file_helper(file_name)
          #
          if(params_num == 0)
            logger.debug("Calling Custom Method (no value) [#{operator}]")
            record.send(operator)
          elsif(value)
            logger.debug("Custom Method assignment of value  #{value} => [#{operator}]")
            record.send(operator, value)
          end
        rescue StandardError => e
          logger.error e.backtrace.first
          raise DataProcessingError, "Method [#{operator}] could not process #{value} - #{e.inspect}"
        end

      else
        logger.warn("Cannot assign via [#{operator}] to #{record.inspect} ")
      end

    end

    def assignment(record, value, model_method)

      operator = model_method.operator
      connection_adapter_column = model_method.connection_adapter_column

      begin
        if(connection_adapter_column.respond_to? :type_cast)
          logger.debug("Assignment via [#{operator}] to [#{value}] (CAST TYPE [#{model_method.connection_adapter_column.type_cast(value).inspect}])")

          record.send( operator + '=', model_method.connection_adapter_column.type_cast( value ) )

        else
          logger.debug("Assignment via [#{operator}] to [#{value}] (NO CAST)")

          # Good guide on diff ways to set attributes
          #   http://www.davidverhasselt.com/set-attributes-in-activerecord/
          if(DataShift::Configuration.call.update_and_validate)
            record.update( operator => value)
          else
            record.send( operator + '=', value)
          end
        end
      rescue StandardError => e
        logger.error e.backtrace.first
        logger.error("Assignment failed #{e.inspect}")
        raise DataProcessingError, "Failed to set [#{value}] via [#{operator}] due to ERROR : #{e.message}"
      end
    end

    # Attempt to find the associated object via id, name, title ....
    def insistent_belongs_to(method_binding, record, value )

      operator = method_binding.operator

      klass = method_binding.model_method.operator_class

      if value.class == klass
        logger.info("Populator assigning #{value} to belongs_to association #{operator}")
        record.send(operator) << value
      else

        unless method_binding.klass.respond_to?('where')
          raise CouldNotAssignAssociation, "Populator failed to assign [#{value}] to belongs_to [#{operator}]"
        end

        # Try the default field names

        # TODO: - add find by operators from headers or configuration to  insistent_find_by_list
        Populator.insistent_find_by_list.each do |find_by|
          begin
            item = klass.where(find_by => value).first_or_create

            next unless item

            logger.info("Populator assigning #{item.inspect} to belongs_to association #{operator}")
            record.send(operator + '=', item)
            break
          rescue StandardError => e
            logger.error(e.inspect)
            logger.error("Failed attempting to find belongs_to for #{method_binding.pp}")
            if find_by == Populator.insistent_method_list.last
              unless value.nil?
                raise CouldNotAssignAssociation,
                      "Populator failed to assign [#{value}] to belongs_to association [#{operator}]"
              end
            end
          end
        end

      end
    end

    def check_process_enum(record, model_method)

      klass = model_method.klass
      operator = model_method.operator

      if klass.respond_to?(operator.pluralize)

        enums = klass.send(operator.pluralize)

        logger.debug("Checking for enum - #{enums.inspect} - #{value.parameterize.underscore}" )

        if enums.is_a?(Hash) && enums.keys.include?(value.parameterize.underscore)
          # ENUM
          logger.debug("[#{operator}] Appears to be an ENUM - setting to [#{value}])")

          # TODO: - now we know this column is an enum set operator type to :enum to save this check in future
          # probably requires changes above to just assign enum directly without this check
          model_method.operator_for(:assignment)

          record.send( operator + '=', value.parameterize.underscore)
          return true
        end
      end
    end

    def self.string_to_hash( str )
      str.to_hash_object
    end

    private

    attr_writer :value, :attribute_hash

    # TOFIX - Does not belong in this class
    def run_transforms(method_binding)
      default( method_binding ) if value.blank?

      override( method_binding )

      substitute( method_binding )

      prefix( method_binding )

      postfix( method_binding )

      # TODO: - enable clients to register their own transformation methods and call them here
    end

    # Transformations

    def default( method_binding )
      default = Transformation.factory.default(method_binding)

      return unless default

      @previous_value = value
      @value = default
    end

    # Checks Transformation for a substitution for column defined in method_binding
    def substitute( method_binding )
      sub = Transformation.factory.substitution(method_binding)

      return unless sub
      @previous_value = value
      @value = previous_value.gsub(sub.pattern.to_s, sub.replacement.to_s)
    end

    def override( method_binding )
      override = Transformation.factory.override(method_binding)

      return unless override
      @previous_value = value
      @value = override
    end

    def prefix( method_binding )
      prefix = Transformation.factory.prefix(method_binding)

      return unless prefix
      @previous_value = value
      @value = prefix + @value
    end

    def postfix( method_binding )
      postfix = Transformation.factory.postfix(method_binding)

      return unless postfix
      @previous_value = value
      @value += postfix
    end

  end
end