Phoenix LiveView で CSV エクスポート機能を作る

@koyo-miyamura です！
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務でのElixir実装にハマっております

https://zenn.dev/koyo/articles/c00527aef96c9f

# CSV ダウンロード
業務システムを作っているとよく「CSVエクスポート」を作ることありますよね。
さてこれを Phoenix LiveView でどう実装するんだ・・・？と思って色々調べて参考実装を作ったので紹介します。

## 完成画面
まず完成イメージから

エクスポート用のボタンを生やします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/f8245852-2a40-8d9f-6b7a-50fb17c2cc81.png)

ボタンを押すとファイルダウンロード（ `content-disposition` `attachment` の挙動）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/038692a9-4bfa-4dec-7326-83b099607f49.png)

CSVファイルの生成
![2022-06-05_23h28_12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/6883de21-604d-a935-e79c-7964119b8fc0.png)


## 要件
※前回のページネーションの実装をベースにします

https://qiita.com/koyo-miyamura/items/663230bf4bc61e80a711

- エクスポートボタンを押すとすべての User データがダウンロードされる
- CSVのヘッダは `id, name, inserted_at, updated_at` である
- 文字コードは UTF-8
- 改行コードは CRLF

## 実装
実装方針としては「CSVダウンロード用のエンドポイントを生やして LiveView の画面からリクエストできるようにする」とします。
ここでCSVダウンロード用のエンドポイントは LiveView ではなく単なる Phoenix コントローラーの実装であることに注意です！

まず下記モジュールを追加します。マップを CSV に変換してくれる便利モジュールです！
https://hexdocs.pm/csv/CSV.html

```elixir:mix.exs
{:csv, "~> 2.4"}
```

`ExportUserController` を作る想定でエンドポイントを追加します
```elixir:router.ex
  scope "/", PaginateSampleWeb do
...
    post "/export", ExportUserController, :create
...
  end
```

ボタンをユーザ一覧画面に置きます
```elixir:index.html.heex
...
  </.modal>
<% end %>

<div>
  <%= button to: Routes.export_user_path(@socket, :create), method: :post do %>
    Export
  <% end %>
</div>

<table>
  <thead>
...
```

## ExportUserController の実装
ここまでいけばあとはエクスポートの実装ができればOKです！
コントローラーの実装は以下のようになります

```elixir:lib/paginate_sample_web/controllers/export_user_controller.ex
defmodule PaginateSampleWeb.ExportUserController do
  use PaginateSampleWeb, :controller

  alias PaginateSample.Users

  def create(conn, _params) do
    send_download(conn, {:binary, csv_data()}, filename: "export.csv")
  end

  defp csv_data do
    csv_content(users(), fields())
  end

  defp users do
    Users.list_all_users()
  end

  defp fields do
    [:id, :name, :inserted_at, :updated_at]
  end

  defp csv_content(records, fields) do
    csv_content_list(records, fields)
    |> CSV.encode()
    |> Enum.to_list()
    |> to_string()
  end

  defp csv_content_list(records, fields) do
    [fields] ++ csv_data_list(records, fields)
  end

  defp csv_data_list(records, fields) do
    records |> Enum.map(&fetch_by_fields_order(&1, fields))
  end

  defp fetch_by_fields_order(record, fields) do
    fields |> Enum.map(fn field -> Map.fetch!(record, field) end)
  end
end
```

自前でヘッダとか組み立てて `send_resp` してもいいんですが `send_download/3` という関数を使えば、いい感じにレスポンスを組み立てて返してくれるので便利です！
https://hexdocs.pm/phoenix/Phoenix.Controller.html#send_download/3

また Map は順序を保証しないので、ちゃんと fields で定義した順番に取り出せるように注意して実装します。
（単に `Map.values()` などとすると、順序が壊れます）

## 追記: より簡単な実装
@koga1020 さんからコメントいただいてより簡素な実装になりました！

```elixir:lib/paginate_sample_web/controllers/export_user_controller.ex
defmodule PaginateSampleWeb.ExportUserController do
  use PaginateSampleWeb, :controller

  alias PaginateSample.Users

  def create(conn, _params) do
    send_download(conn, {:binary, csv_data()}, filename: "export.csv")
  end

  defp csv_data do
    users()
    |> CSV.encode(headers: fields())
    |> Enum.join()
  end

  defp users do
    Users.list_all_users()
  end

  defp fields do
    [:id, :name, :inserted_at, :updated_at]
  end
end
```

# まとめ
これで完成！です！
Elixir で書くとパイプでシンプルな実装になってよいですね...美しい！
CSVヘッダーの増減は `fields` 関数を修正すればいいし、 対象のデータが変わったら `users` 関数の修正をすればよいです。
また、改行コードを変えたい場合は `CSV.encode/2` が対応しているので見てみてください。

https://hexdocs.pm/csv/CSV.html#encode/2

リポジトリはこちらです:pray:
https://github.com/koyo-miyamura/paginate_sample_ex

# 参考にした記事
下記の英語記事を参考にしつつ、順番が保証されないところなどを修正して記事にしました！
https://fullstackphoenix.com/tutorials/csv-export