= 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 -%>
  1. And <%= @import.import_errors.size - 20 %> more errors…
  2. <% 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