社内用の顧客管理ツールを作った際に、バックオフィスのメンバーから「CSV出力機能がほしい!」という熱い要望がありました。
CSV出力機能はよくある機能ですが、実装後も細かな要望に答えて改修したので実務あるあるとしてまとめました。
前提のお話
前提としてtownは複数のuserを持ち、userは1つのtownと紐づきます。
特定のtownごとのuserをまとめてCSV出力するボタンを作りました。
class Town < ApplicationRecord
has_many :users
end
class User < ApplicationRecord
belongs_to :town
end
<div class="flex space-between">
<div>
<% @towns.each do |town| %>
<p><%= town.name %></p>
<%= link_to "CSV出力", towns_csv_town_user_path(id: town.id) %>
<% end %>
</div>
</div>
userテーブル
id | town_id | last_name | first_name | address | |
---|---|---|---|---|---|
1 | 1 | 田中 | 太郎 | 〇〇町 | tanaka@gmail.com |
2 | 2 | 佐藤 | 花子 | △△町 | sato@gmail.com |
townテーブル
id | name | population | food | tourist spot |
---|---|---|---|---|
1 | 名古屋 | 300000 | 味噌カツ | 名古屋港水族館 |
2 | 札幌 | 200000 | カニ | 時計台 |
resources :towns do
get "csv_town_user", on: :member
end
ボタンを押した時にtowns_controllerのcsv_town_userメソッドにtown.idを渡して、townごとのuserをCSV出力します。
基本のCSV出力
require "csv"
class TownsController < ApplicationController
def index
@towns = Town.all
end
def csv_town_user
head :no_content
users = User.where(town_id: params[:id])
town = Town.find(params[:id])
#ファイル名を指定 ここはお好みで
filename = town.name + Date.current.strftime("%Y%m%d")
csv1 = CSV.generate do |csv|
#カラム名を1行目として入れる
csv << User.column_names
users.each do |user|
#各行の値を入れていく
csv << user.attributes.values_at(*User.column_names)
end
end
create_csv(filename, csv1)
end
private
def create_csv(filename, csv1)
#ファイル書き込み
File.open("./#{filename}.csv", "w", encoding: "SJIS") do |file|
file.write(csv1)
end
#send_fileを使ってCSVファイル作成後に自動でダウンロードされるようにする
stat = File::stat("./#{filename}.csv")
send_file("./#{filename}.csv", filename: "#{filename}.csv", length: stat.size)
end
end
実務あるある1: 一部のカラムだけCSV出力したい
require "csv"
class TownsController < ApplicationController
#略
def csv_town_user
head :no_content
users = User.where(town_id: params[:id])
town = Town.find(params[:id])
filename = town.name + Date.current.strftime("%Y%m%d")
csv1 = CSV.generate do |csv|
#ここでカラム指定
columns = ["town_id", "last_name", "first_name", "address", "email" ]
csv << columns
users.each do |user|
csv << user.attributes.values_at(*columns)
end
end
create_csv(filename, csv1)
end
private
#以下略
end
実務あるある2: 1行目はカラム名を日本語にしたい
require "csv"
class TownsController < ApplicationController
#略
def csv_town_user
head :no_content
users = User.where(town_id: params[:id])
town = Town.find(params[:id])
filename = town.name + Date.current.strftime("%Y%m%d")
#日本語のカラム名を用意
columns_ja = ["都市ID", "名字", "名前", "住所", "メールアドレス"]
columns = ["town_id", "last_name", "first_name", "address", "email" ]
csv1 = CSV.generate do |csv|
#1行目は日本語のカラム名
csv << columns_ja
users.each do |user|
csv << user.attributes.values_at(*columns)
end
end
create_csv(filename, csv1)
end
private
#以下略
end
実務あるある3: テーブル結合して別テーブルの情報も表示したい
現在は各userの"town_id", "name", "address", "email" が出力されるが、"town_id"ではなく、townテーブルを結合してtownテーブルのnameカラムを出力したいとします。
require "csv"
class TownsController < ApplicationController
#略
def csv_town_user
head :no_content
#ここでtownテーブルの情報を同時に引いてくる
users = User.where(town_id: params[:id]).includes(:town)
filename = town.name + Date.current.strftime("%Y%m%d")
#都市IDではなく都市名を表示するようにする
columns_ja = ["都市名", "名字", "名前", "住所", "メールアドレス"]
columns = ["town_name", "last_name", "first_name", "address", "email" ]
csv1 = CSV.generate do |csv|
csv << columns_ja
users.each do |user|
user_attributes = user.attributes
#user.attributesオブジェクトにtown_nameというキー名でtownテーブルのnameカラムを追加する
#includesで結合しても呼び出すときはuser.nameではなくuser.town.nameなので注意
user_attributes["town_name"] = user.town.name
csv << user_attributes.values_at(*columns)
end
end
create_csv(filename, csv1)
end
private
#以下略
end
##7/7追記
コメントを頂いたのでcsv作成部分をcontrollerからviewsフォルダ内の.rbファイルに切り分けたパターンも書いてみました!controllerの記述量が減って良い感じです。
ちなみに、コメントではrespond_toで呼ばれるformatごとに呼び出すviewファイルを分ける方法を教えていただきましたが、今回出力したかったのはviews/index.html.erb上で選択した都市ごとのcsvファイルだったので、indexのformat切り替えの方法ではなくメソッドを分けることにしました。
index.html.erbではformat: :csvと書くことで、views/towns/csv_user.rubyを読み込むようにします。
(微妙にpathも変更しました)
<div>
<div>
<% @towns.each do |town| %>
<p><%= town.name %></p>
<%= link_to "CSV出力", csv_user_town_path(id: town.id, format: :csv) %>
<% end %>
</div>
</div>
routesはこんな感じです
Rails.application.routes.draw do
resources :towns, only: [:index, :new, :create] do
get 'csv_user', on: :member
end
get 'users/new'
end
今まではcontrollerにcsvファイル作成処理を書いていましたが、新しくviews/towns/csv_user.rubyを作成してcsv出力処理をここに切り分けました。
require "csv"
@users = User.where(town_id: params[:id])
town = Town.find(params[:id])
CSV.generate do |csv|
csv << User.column_names
@users.each do |user|
csv << user.attributes.values_at(*User.column_names)
end
end
↑ここで作られたcsvはcontroller内のrender_to_stringで取得されます。
send_fileからsend_dataにすることで、ファイルそのものではなくデータのみを渡すようになり、File.openで最初にファイルを作成する必要もなくなりました。
require "csv"
class TownsController < ApplicationController
def index
@towns = Town.all
end
def csv_town_user
head :no_content
send_data(render_to_string, type: "text/csv")
end
end
エンコードエラー対策やi18n導入はまだできていませんが、ひとまずcontrollerの記述を減らす部分まで...
scivolaさんありがとうございます。