12/08/2018, 14:35

Import CSV with validations - Rails

I. Chuẩn bị Giả sử mình cần import một số data vào database, thì CSV là format mình thấy đơn giản nhất, ở bài viết này chúng ta sử dụng gem Roo để hỗ trợ đọc file. Mặc định chúng ta có sẵn 2 model là User và Product và muốn import data vào cả 2 model này với 2 file CSV riêng. Đầu tiên, trong file ...

I. Chuẩn bị

Giả sử mình cần import một số data vào database, thì CSV là format mình thấy đơn giản nhất, ở bài viết này chúng ta sử dụng gem Roo để hỗ trợ đọc file. Mặc định chúng ta có sẵn 2 model là User và Product và muốn import data vào cả 2 model này với 2 file CSV riêng. Đầu tiên, trong file config/application.rb thêm require:

require "csv"

Trong Gemfile:

gem "roo"

II. Chi tiết

Controller

Chúng ta sẽ tách riêng 1 controller cho việc import để dễ xử lý: Trong controller/import_controller:

class ImportsController < ApplicationController
  def new
    @import = Import.new
  end

  def create
    @import = Import.new(params[:import])
    if @import.save
      flash[:success] = "Imported successfully!"
      redirect_to new_import_path
    else
      render :new
    end
  end
end

View

Ở view, chúng ta dùng form_for để submit file và lưu xuống database. Vì có thể import được vào 2 model là User và Product nên ngoài file cần truyền thêm 1 params là model_type:

<%= form_for new_import_path, method: :get do |f| %>
  <% if @import.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@import.errors.count, "error") %> prohibited this import from completing:</h2>
      <ul>
      <% @import.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
      </ul>
    </div>
  <% end %>
  <div class="form-group">
    <%= f.select :model_type, ["User", "Product"],
      {include_blank: "Model type"}%>
  </div>
  <div class="field">
    <%= f.file_field :file %>
  </div>
  <div class="buttons"><%= f.submit "Import" %></div>
<% end %>

Routes

Trong config/routes , thêm:

resources :imports, only: [:new, :create]

Model

Đây là phần quan trọng nhất. Model này ko được lưu ở database, nó chỉ đơn giản là một Ruby class, nhưng việc validate cũng giống như các model khác. Ở view chúng ta có truyền thêm params model_type nên ở đây cần thêm vào attr_accessor :

attr_accessor :file, :model_type

Ví dụ về file csv của model Product:

id,name,released_on,price,created_at,updated_at
4,Acoustic Guitar,2012-12-26,1025.0,2012-12-29 18:23:40 UTC,2012-12-29 18:23:40 UTC
5,Agricola,2012-10-31,45.99,2012-12-29 18:23:40 UTC,2012-12-29 18:23:40 UTC

Dòng đầu tiên là header, từ dòng thứ 2 trở đi là các data sẽ lưu vào db. Ở đoạn code bên dưới, gem Roo đã hỗ trợ đọc từ dòng thứ 2 đến dòng cuối cùng của file CSV, dựa vào params model_type để lấy đúng model. Trong trường hợp id trong file csv đã có trong db thì sẽ thực hiện update, nếu không sẽ thực hiện tạo mới:

def load_imported_master header, spreadsheet
    (2..spreadsheet.last_row).each do |i|
      row = Hash[[header, spreadsheet.row(i)].transpose]
      object = model_type.constantize.find_by(id: row["id"]) || model_type.constantize.new
      object.attributes = row.to_hash
      object
    end
  end

Hàm save như bên dưới, chỉ cho phép import CSV type. Chúng ta cũng kiểm tra luôn cho trường hợp file csv lấy vào ko đúng so với các thuộc tính trong model được chọn:

def save
    #Chỉ cho phép import file dạng CSV
    unless File.extname(file.original_filename) == ".csv"
      errors.add :base, I18n.t("import.unknown_file_type")
      return
    end
    spreadsheet = Roo::Spreadsheet.open file.path
    header = spreadsheet.row 1
    header_model = model_type.constantize.column_names
    # Kiểm tra nếu file csv lấy vào ko khớp với model được chọn
    unless (header -  header_model).empty?
      errors.add :base, "File CSV does not match. Pls try again!"
      return
    end
    load_imported_master header, spreadsheet
    if object.valid?
      object.save!
    else
      object.errors.messages.each do |_key, message|
        errors.add :base, "#{message}"
      end
    end
  end

Chúng ta đã có code hiển thị lỗi ở view, vì vậy việc cần làm tiếp theo là validate cho từng model User và Product, ở đây mình chỉ demo đơn giản nên validate not nil cho vài trường, các bạn có thể bổ sung thêm. Trong file models/user:

validates :name, :email, presence: true

tương tự cho các model khác.

Cuối cùng ta có thể ráp code lại như sau: models/import.rb:

class Import < ApplicationRecord
  attr_accessor :file, :model_type

  def initialize attributes = {}
    attributes.each{|name, value| send("#{name}=", value)}
  end
  
  def save
    #Chỉ cho phép import file dạng CSV
    unless File.extname(file.original_filename) == ".csv"
      errors.add :base, I18n.t("import.unknown_file_type")
      return
    end
    spreadsheet = Roo::Spreadsheet.open file.path
    header = spreadsheet.row 1
    header_model = model_type.constantize.column_names
    # Kiểm tra nếu file csv lấy vào ko khớp với model được chọn
    unless (header -  header_model).empty?
      errors.add :base, "File CSV does not match. Pls try again!"
      return
    end
    load_imported_master header, spreadsheet
  end

  def load_imported_master header, spreadsheet
  ActiveRecord::Base.transaction do
    (2..spreadsheet.last_row).each do |i|
      binding.pry
      row = Hash[[header, spreadsheet.row(i)].transpose]
      object = master_type.constantize.find_by(id: row["id"]) || master_type.constantize.new
      object.attributes = row.to_hash
      if object.valid?
         object.save!
      else
        object.errors.messages.each do |_key, message|
          errors.add :base, "#{message}"
        end
      end
      raise ActiveRecord::Rollback
    end
  end
end

III. Kết luận

Như vậy chúng ta vừa tìm hiểu cách import csv với các validations, các bạn có thể thêm bớt hoặc chỉnh sửa code để hợp với mục đích của mình hơn. Hẹn gặp lại trong các loạt bài sau.

Link tham khảo

http://railscasts.com/episodes/396-importing-csv-and-excel

0