Ecto から取得したデータを CSV に変換するモジュールを作る（NimbleCSV 版）

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

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

前回下記の記事でCSVエクスポート機能を作成しました
ここから一歩踏み込んで、Ecto から取得したデータをCSV文字列に変換するモジュールを作成しつつ、使用するライブラリをよりメジャーな NimbleCSV に変えて実装してみようと思います

https://qiita.com/koyo-miyamura/items/14401b77bde9027b6f97

https://github.com/dashbitco/nimble_csv

# モジュールの作成
## 作りたいモジュールのインターフェース

作成するモジュール名を `PaginateSample.Utils.CsvConverter` として下記のようなインターフェースを想定します
Ecto から取得したレコードの配列とそこから抽出したいフィールドのキーワードリストを入力として受け取り、CSV文字列を出力します

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

  alias PaginateSample.Users
+ alias PaginateSample.Utils.CsvConverter

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

  defp csv_data do
+   CsvConverter.to_csv(users(), fields())
-   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
```

## モジュールの実装

実装は下記のようになります。また doctest も書いてみます

```elixir:lib/paginate_sample/utils/csv_converter.ex
defmodule PaginateSample.Utils.CsvConverter do
  @moduledoc """
  CSV Converter
  """

  @doc ~S"""
  Convert struct lists to CSV

  ## Examples

      iex> import DummyStruct
      iex> records = [%DummyStruct{id: 1, name: :hoge, age: 10}, %DummyStruct{id: 2, name: :fuga, age: 20}, %DummyStruct{id: 3, name: :hogefuga, age: 30}]
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:name, :age])
      "name,age\r\nhoge,10\r\nfuga,20\r\nhogefuga,30\r\n"
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:age])
      "age\r\n10\r\n20\r\n30\r\n"
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:not_found])
      "not_found\r\n\r\n\r\n\r\n"

  """
  def to_csv(records, fields) do
    records
    |> CSV.encode(headers: fields)
    |> Enum.join()
  end
end
```

## モジュールのテスト

doctest をするためにテストファイルを配置します

```elixir:test/paginate_sample/utils/csv_converter_test.exs
defmodule PaginateSample.Utils.CsvConverterTest do
  use ExUnit.Case
  doctest PaginateSample.Utils.CsvConverter
end
```

doctest 中で使用するテスト用ダミー struct を定義しておきます（doctest 中で struct 定義はうまく機能しなかった :innocent: ）

```elixir:test/support/csv_converter/dummy_struct.ex
defmodule DummyStruct do
  defstruct id: nil, name: nil, age: nil
end
```

https://elixirforum.com/t/doctests-defining-a-struct-in-your-doctest/2917

テスト実行すると無事通りますね :thumbsup: 

```bash
$ mix test test/paginate_sample/utils/csv_converter_test.exs
.

Finished in 0.02 seconds (0.00s async, 0.02s sync)
1 doctest, 0 failures

Randomized with seed 513464
```

## 使用するライブラリを NimbleCSV にする

今まで使用していた CSV ライブラリはやや更新が滞っています

https://github.com/beatrichartz/csv/blob/master/CHANGELOG.md

なのでよりアクティブに開発されている NimbleCSV を使用する実装に変えてみます

https://github.com/dashbitco/nimble_csv

## NimbleCSV を用いた実装

```elixir:lib/paginate_sample/utils/csv_converter.ex
defmodule PaginateSample.Utils.CsvConverter do
  @moduledoc """
  CSV Converter
  """

  @doc ~S"""
  Convert struct lists to CSV

  ## Examples

      iex> import DummyStruct
      iex> records = [%DummyStruct{id: 1, name: :hoge, age: 10}, %DummyStruct{id: 2, name: :fuga, age: 20}, %DummyStruct{id: 3, name: :hogefuga, age: 30}]
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:name, :age])
      "name,age\r\nhoge,10\r\nfuga,20\r\nhogefuga,30\r\n"
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:age])
      "age\r\n10\r\n20\r\n30\r\n"
      iex> PaginateSample.Utils.CsvConverter.to_csv(records, [:not_found])
      "not_found\r\n\r\n\r\n\r\n"

  """
  def to_csv(records, fields) do
    ([fields] ++ csv_content(records, fields))
    |> NimbleCSV.RFC4180.dump_to_stream()
    |> Enum.join()
  end

  defp csv_content(records, fields) do
    records
    |> Enum.map(fn record ->
      fields
      |> Enum.map(&Map.get(record, &1))
    end)
  end
end
```

単に `Map.take(fields)` とすると、fields 順にならないので `Enum.map` と `Map.get` を組み合わせるのがポイントです
（もっとスマートな書き方あったら教えてください）

テストも通りますね :thumbsup: 

```bash
$ mix test test/paginate_sample/utils/csv_converter_test.exs
.

Finished in 0.02 seconds (0.00s async, 0.02s sync)
1 doctest, 0 failures

Randomized with seed 654005
```

# まとめ

リポジトリはこちらです:pray:

https://github.com/koyo-miyamura/paginate_sample_ex