LoginSignup
1
1

More than 1 year has passed since last update.

RailsでインポートするCSVにBOMをつけたら列名に一致するカラムの値を取り出せなくなった

Last updated at Posted at 2021-06-10

気づけば簡単な話なのですが、RailsでCSVインポート機能を作っている時にサンプルCSVがもしエクセルで開いても文字化けしないようにとBOMをつけたら、読み込む時に一致するカラムの値を取り出せなくなったので記録しておこうと思います。

TL;DR

BOMがついている時にCSVの値をカラム名で指定してしまうと列名の最初の文字にBOM(ZERO WIDTH NO-BREAK SPACE, 文字コード65279)が入ってしまい、読み込む際に列名を指定しても適切に取得できなくなります。
見えないので最初の文字をString#ordメソッドなどで比較しないと気づきにくいです。
解決方法は追記に書いてあります。

本文

下記がBOMをつけてダウンロードするようにしたサンプルCSVのapplication_controller.rb
です。
他のコントローラでこのコントローラを継承して、rowsメソッドを実装する形になります。

controllers/admin/csv/sample/application_controller.rb
class Admin::Csv::Sample::ApplicationController < Admin::Csv::ApplicationController
  def show
    respond_to do |format|
      format.csv do
        send_data create_csv,
                  type: :csv,
                  filename: "sample_#{controller_name}.csv"
      end
    end
  end

  protected
  # 子のコントローラでオーバーライドを強制
  def rows
    raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
  end

  def create_csv
    bom = %w[EF BB BF].map { |e| e.hex.chr }.join
    CSV.generate(bom) do |csv|
      rows.each do |row|
        csv << row
      end
    end
  end
end

下記は例としてsports_controller.rb

controllers/admin/csv/sample/sports_controller.rb
class Admin::Csv::Sample::SportsController < Admin::Csv::Sample::ApplicationController
  private

  def rows
    [
      ["name"],
      ["バスケットボール"],
    ]
  end
end

このようにしてダウンロードしたCSVをモデルに記述したメソッドでインポートしようとします。

models/sport.rb
class Sport < ApplicationRecord
  class << self
    def import(file)
      CSV.foreach(file.path, headers: true) do |row|
        record = find_by(id: row["id"]) || new
        record.attributes = row.to_hash.slice(*updatable_attributes)
        record.save
      end
    end

    def updatable_attributes
      ["name"]
    end
  end
end
controllers/admin/csv/sports_controller.rb
class Admin::Csv::SportsController < Admin::Csv::ApplicationController
  def new
  end

  def create
    Sport.import(params[:file])
    flash[:notice] = t('action.imported')
    redirect_to admin_sports_url
  end
end

着目すべきは以下の行です。

record.attributes = row.to_hash.slice(*updatable_attributes)

ここではモデルのインスタンスの各フィールドに許可された値を挿入しています。
sport.rbで許可された値はnameのみなので、{'name': 'バスケットボール'}のような値が入るはずですが、BOMがついているとrow.to_hashで返ってくるkeyの部分がBOM付きのnameとなりupdatable_attributesnameという文字列と一致しません。

これによってCSVインポートしても値が更新されないという現象が発生してしまいます。
しかもぱっと見は同じ文字列に見えるのでなかなか気づきにくいです。
これを解決するにはsampleのCSVにBOMをつけないか、列名ではなく列の順序などで値を取得するなどの方法が考えられるかと思います。
BOM付きのCSVをインポートする場合はお気をつけください。

追記

@mcfishさんのご指摘によりCSVクラスの特異メソッドforeachの引数encodingにBOMを考慮した'BOM|UTF-8'を設定すればBOM付きのCSVでも問題なくインポートできました。
ご指摘頂きありがとうございます。

CSV.foreach(file.path, headers: true, endoging: 'BOM|UTF-8') do |row|
1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1