lib/datashift/populators/populator.rb
# 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