= Party Import
Mapper: Object that maps between a column in the CSV document and a field in our models.
Import: Object that holds the CSV and a reference to a Mapper object.
== Workflow
1. Ask the user for the file to import
2. Receive the file and store it in the DB
3. Create a basic Mapper object that knows about the number of columns and present that to the user
4. Let the user map the columns
5. Once the user says "Import", take recreate the Mapper instance from the request and import using the data in the Import object
6. During the import, keep a reference to each line and whether or not it was imported
7. During import, log all errors in the Import object
8. If any errors are found, ROLLBACK the transaction (unless the Import is flagged "force")
9. Let the user change his mapper
10. Rince and repeat
== Tasks
1. Create tables
2. Create Mapper objects for common formats (Outlook Express)
== Tables
=== imports
id
party_id INTEGER NOT NULL
mapper_id INTEGER
csv TEXT
import_errors TEXT
imported_lines TEXT
updated_at DATETIME
created_at DATETIME
class Import
serialize :import_errors, :imported_lines
end
=== mappers
id
mapping TEXT
name VARCHAR(63)
description VARCHAR
updated_at DATETIME
created_at DATETIME
class Mapper
serialize :mappings
end
# mappings will be an Array of Hashes identifying the column and the work to do on that column
#
# [
# {:model => "Party", :field => "last_name", :name => nil, :tr => :capitalize},
# {:model => "PhoneContactRoute", :field => "number", :name => "office", :tr => nil},
# {:model => "EmailContactRoute", :field => "email_address", :name => "work", :tr => nil},
# nil, # means don't import this column
# {:model => "AddressContactRoute", :field => "state", :name => "main", :tr => :upcase}
# ]
So, this mapper object has five columns of data, and we do not import column #4 (column at index 3 in the returned array from the CSV parsing).
Since we know we are going to import within the Party object and it's associated has_manies, we can build in a few assumptions. For example, we know that EmailContactRoute maps to #email_addresses in Party. And so on.
BTW, "tr" stands for "transliteration": http://dictionary.reference.com/browse/transliteration
== Semi pseudo-code
# app/controllers/imports_controller.rb
class ImportsController
def go
@import = Import.find(params[:id])
@import.update_attributes(params[:import])
@import.go
redirect_to import_path(@import)
end
end
# app/views/imports/show.rhtml
<% unless @import.import_errors.blank? then -%>
There were errors during the import
<%= render :partial => "error", :collection => @import.import_errors[0,20] %>
<% if @import.import_errors.size > 20 then -%>
- And <%= @import.import_errors.size - 20 %> more errors…
<% end -%>
<% end -%>
# app/models/import.rb
class Import
def go
self.import_errors, self.imported_rows = [], []
row = 0
CSV::Reader.new(self.data) do |row|
row += 1
begin
Party.transaction do
party = self.mapper.to_object(row)
party = resolve_conflicts(party)
party.save!
self.imported_rows << true
end
rescue ActiveRecord::RecordInvalid
self.import_errors << [row, $!.record.errors.full_messages]
self.imported_rows << false
end
end
end
def resolve_conflicts(new_party)
addresses = party.email_addresses.map(&:email_address)
routes = EmailContactRoute.find(:all, :conditions => {:email_address, addresses})
case routes.size
when 0
# None match, return the new party
new_party
when 1
# Party already on file, update instead of create
party = routes.first
new_party.copy_to(party)
else
# Multiple contacts match!
# Raise an exception that logs the error
end
end
end
# app/models/party.rb
class Party
def copy_to(target)
target.attributes = self.attributes
self.email_addresses.each do |addr|
foreign_addr = target.email_addresses.detect {|e| e.name == addr.name}
foreign_addr ||= target.email_addresses.build(:name => addr.name)
addr.copy_to(foreign_addr)
end
self.addresses.each do |addr|
# Same as above
end
self.phones.each do |addr|
# Same as above
end
self.links.each do |addr|
# Same as above
end
end
end
# app/models/email_contact_route.rb
class EmailContactRoute
def copy_to(target)
target.attributes = self.attributes
end
end
# app/models/mapper.rb
class Mapper
# Returns a Party object from an Array of strings, using the
# +mappings+ to determine which column goes in which property.
def to_object(row)
returning(Party.new) do |root|
self.mappings.each_with_index do |mapping, column|
obj = mapping_to_object(mapping, root)
obj.send("#{mapping[:field]}=", row[column])
end
end
end
# Returns an Array of strings that respects the mapping.
def from_object(object)
returning(Array.new(self.mappings.size)) do |row|
self.mappings.each_with_index do |mapping, column|
obj = mapping_to_object(mapping, root)
row[column] = obj.send("#{mapping[:field]}").to_s
end
end
end
protected
def mapping_to_object(mapping, root)
case mapping[:model]
when "Party"
root
when "EmailContactRoute"
root.email_addresses.detect {|e| e.name.downcase == mapping[:name]} || root.email_addresses.build(:name => mapping[:name].capitalize)
when "PhoneContactRoute"
root.phones.detect {|e| e.name.downcase == mapping[:name]} || root.phones.build(:name => mapping[:name].capitalize)
when "AddressContactRoute"
root.addresses.detect {|e| e.name.downcase == mapping[:name]} || root.addresses.build(:name => mapping[:name].capitalize)
when "LinkContactRoute"
root.links.detect {|e| e.name.downcase == mapping[:name]} || root.links.build(:name => mapping[:name].capitalize)
end
end
end