気づけば簡単な話なのですが、RailsでCSVインポート機能を作っている時にサンプルCSVがもしエクセルで開いても文字化けしないようにとBOMをつけたら、読み込む時に一致するカラムの値を取り出せなくなったので記録しておこうと思います。
TL;DR
BOMがついている時にCSVの値をカラム名で指定してしまうと列名の最初の文字にBOM(ZERO WIDTH NO-BREAK SPACE, 文字コード65279)が入ってしまい、読み込む際に列名を指定しても適切に取得できなくなります。
見えないので最初の文字をString#ord
メソッドなどで比較しないと気づきにくいです。
解決方法は追記に書いてあります。
本文
下記がBOMをつけてダウンロードするようにしたサンプルCSVのapplication_controller.rb
です。
他のコントローラでこのコントローラを継承して、rowsメソッドを実装する形になります。
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
class Admin::Csv::Sample::SportsController < Admin::Csv::Sample::ApplicationController
private
def rows
[
["name"],
["バスケットボール"],
]
end
end
このようにしてダウンロードしたCSVをモデルに記述したメソッドでインポートしようとします。
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
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_attributes
のname
という文字列と一致しません。
これによって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|