railsでCSV出力をする方法+実務あるある(関連テーブルのカラム出力など)

社内用の顧客管理ツールを作った際に、バックオフィスのメンバーから「CSV出力機能がほしい!」という熱い要望がありました。

CSV出力機能はよくある機能ですが、実装後も細かな要望に答えて改修したので実務あるあるとしてまとめました。

# 前提のお話
前提としてtownは複数のuserを持ち、userは1つのtownと紐づきます。
特定のtownごとのuserをまとめてCSV出力するボタンを作りました。

```models/town.rb
class Town < ApplicationRecord
  has_many :users
end
```
```models/user.rb
class User < ApplicationRecord
  belongs_to :town
end
```
```towns/index.html.erb
<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 |email |
|---|---|---|---|---|---|
|1  |1  |田中  |太郎  | 〇〇町 |tanaka@gmail.com  |
|2  |2  |佐藤 |花子  |△△町 |sato@gmail.com |

### townテーブル

|id |name  |population  |food | tourist spot |
|---|---|---|---|---|
|1  |名古屋  |300000 | 味噌カツ | 名古屋港水族館 |
|2 |札幌  |200000  | カニ |時計台 |
```config/routes.rb
  resources :towns do
    get "csv_town_user", on: :member
  end
```
ボタンを押した時にtowns_controllerのcsv_town_userメソッドにtown.idを渡して、townごとのuserをCSV出力します。

# 基本のCSV出力

```towns_controller.rb
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出力したい

```towns_controller.rb
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行目はカラム名を日本語にしたい

```towns_controller.rb
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カラムを出力したいとします。


```towns_controller.rb
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も変更しました)

```towns/index.html.erb
<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はこんな感じです

```config/routes.rb
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出力処理をここに切り分けました。

```views/towns/csv_user.ruby
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で最初にファイルを作成する必要もなくなりました。

```towns_controller.rb
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さんありがとうございます。