読み取るCSVファイルの許容範囲の拡大

今の状態では、エラー無く、データベースに登録できるCSVファイルの書式があまりにも限定されている。ちょっとでも想定外の書式だと、データを見ることが出来なかったり、英字のエラー画面になってしまう...。サーバー側である程度CSVファイルの書式を最適化して、なるべく無難に登録できるようにしたい。

最適化する項目

  • テーブル名(ファイル名)、列名(CSVファイルの1行目)について
    • 利用可能な文字は、英数字と_に限定してしまった。それ以外の文字は全て_に置き換える。
    • 数字で始まる場合は、先頭に_を付加する。
  • 列名(フィールド名)
    • CSVファイルの1行目が、改行のみでデータが無い場合、ABC順の列名を作成してフィールド名とする。
    • CSVファイルの1行目で、列名が重複する場合、ABC順の列名を作成してフィールド名とする。1行目の列名はデータとして表示される。
    • CSVファイルの1行目で、ブランク項目がある場合、その項目はABC順の列名で置き換える。
  • 改行コード
    • CSVファイルの改行コードは、全てLFに変換する。
  • 余分なカンマ
    • エクセルで作表してCSVファイルとして書き出すと、1行目から最終行まで、余分なカンマが付加されてしまうことがよくある。余分なカンマは削除するようにした。

コードの修正

モデル
app/models/csv.rb
class Csv < ActiveRecord::Base
  validates_uniqueness_of :file_name
  def validate
    errors.add(:file_name, "アップロードするファイルを指定してください。") if @file.nil? || @file.size == 0
  end
  
  # Tempfileオブジェクトをインスタンス変数に保存する。
  # new()や、update_attributes()の時、呼び出される。
  def tempfile=(file)
    return if file.nil? || file.size == 0
    @file = file
    self.file_name = file.original_filename
    self.file_size = file.size
    # file.read.sizeとやってしまうと、41行目で@file.readした時、なぜかnilが返る。
    # 1回アクセスすると、そのデータは消えてしまう?
    #self.file_type = f.content_type
  end
  
  # テーブルを追加する。
  def create_csv_table
    #execute_sql("CREATE TABLE #{csv_table} (id INTEGER PRIMARY KEY NOT NULL#{csv_columns});")
    ActiveRecord::Migration.create_table csv_table do |t|
      csv_columns.each do |f|
        t.column f, :string
      end
    end
  end
  
  # テーブル名を返す。
  def csv_table
    File.basename(self.file_name, ".*").gsub(/^([\d])|\W/, '_\1')
  end
  
  # 列名の配列を返す。
  # 同時に不完全なファイル内容を最適化する。
  # インポート用の一時ファイルを保存する。
  def csv_columns
    # ファイルを読み込む。"'は描画する時に調整する。
    # 文字コードをUTF-8、改行コードをLFに変換して、先頭の余分な改行は削除する。
    # 改行で区切った配列を返す。
    str   = @file.read
    str   = NKF.nkf('-wLu', str).sub(/^\n+/, "\n")
    lines = str.split("\n")
    
    # 配列の先頭を取り出して、小文字に変換、カンマで区切った配列にして、余分な空白を削除する。
    # gsubの内容は以下の通り。
    #   数字で始まる時は、先頭に_を付ける。
    #   英数字または_でない文字は、_に置き換える。
    # split(",")は、行末の余分なカンマを無視する。
    # 例:"1,2,,3,,,".split(",") >> ["1","2","","3"]
    line = lines.shift
    columns = line.downcase.split(",").map!{|n| n.strip.gsub(/^([\d])|\W/, '_\1')}
    
    # 列タイトルが重複している場合は、データ配列に戻して、列タイトルは空行にする。
    unless columns == columns.uniq
      lines.unshift(line)
      columns = []
    end
    
    # 列タイトルが空行なら、ABCの列名を追加する。
    if columns.empty?
      name = 'A'
      lines[0].split(",").size.times do
        columns << name
        name = name.succ
      end
    end
    
    # 列タイトルの一部がブランクなら、ブランクをABCの列名に置き換える。
    name = 'A'
    columns.map! do |column|
      column = name if column.empty?
      name = name.succ
      column
    end
    
    # 行末の余分なカンマを削除する。
    n = lines[0].gsub(/[^,]/, '').size - columns.size + 1
    lines.map! {|line| line.sub(/,{#{n}}$/, '')} if n > 0
    
    # id列が存在しなければ追加する。
    if columns.index('id').nil?
      id = 0
      lines.map! {|line| id +=1; "#{id},#{line}"}
    end
    
    # path列が存在しなければ追加する。
    if columns.index('path').nil?
      columns << "path"
      lines.map! {|line| "#{line},"}
    end
    
    # インポート用の一時ファイルを保存する。
    File.delete("public/temp.csv") rescue nil
    File.open("public/temp.csv", "wb") do |f|
      f.write(lines.join("\n"))
    end
    
    # 最初のidフィールドは、読み飛ばす。
    columns.shift if columns.first == 'id'
    columns
  end
  
  # csvデータをインポートする。
  # インポートを高速化するため、sqlite3のインポート命令を、直接コマンド実行する。
  def import_csv
    env = ENV['RAILS_ENV'] || 'development'
    system("sqlite3", "-separator", ",", "db/#{env}.sqlite3", ".import public/temp.csv #{csv_table}")
  end
  
  # テーブルを削除する。
  def drop_csv_table
    ActiveRecord::Migration.drop_table(csv_table)
  end
end

試してみる

以下のようなCSVファイルであっても...



組織,,経費,,
"100",12576,7349,,
"101",7550,6260,,
営業1課計,20126,13609,,
'200,15709,6083,,
'201,6833,6068,,
'202,13691,7130,,
営業2課計,36233,19281,,
'300,15227,9769,,
営業3課計,15227,9769,,
営業1部計,71586,42659,,
'400,15362,5035,,
'401,11746,7910,,
営業1課計,27108,12945,,
'500,12530,8355,,
'501,10017,5519,,
営業2課計,22547,13874,,
営業2部計,49655,26819,,
計,121241,69478,,



こんな風に、ちゃんと読み込むことが出来る。