Elixir Ecto.Typeでcuctom typeの作成　atomのTupleを保存してみる

# なぜTupleを保存？

Exlixir勉強中です。

勉強用にオセロゲームを作ってみました。盤面はTupleで保存しています。
タプルの中身は、:none, :black , :whiteのいずれか。これで盤面を表しています。
Ectoで盤面を保存して、棋譜の履歴を保存を実現したい。
↓
Tupleを保存したい。

Tupleを使わず、ListやMapで盤面を保存していれば、型を作らなくても問題ないと思いますが、勉強も兼ねて、Tupleに挑戦してみます。

実はこんな方法もあるよ。などコメントいただけると嬉しいです。

## 修正前

かなり、ゴチャゴチャです。
盤面はBoard.cellsにTuple型で保存されています。
Tupleを含んでいるBoardは、changesetに受け付けられないので、Tuple.to_list でリストに変換。
Atomを数値に変換するconvert_cell_to_integer()かませてます

```Elixir
  def push(board = %Board{}) do
    id = board.id
    cells = Tuple.to_list(board.cells)
    |> Enum.map(&convert_cell_to_integer(&1))
    board_map = %{board | cells: cells, id: id, turn: convert_cell_to_integer(board.turn)}|> Map.from_struct()
    new_data = changeset(%__MODULE__{},board_map)
    from(log in __MODULE__, where: log.id >= ^id) |> Repo.delete_all
    if id > 0, do: Repo.insert(new_data)
    board
  end
```

Ecto.Typeを使って`EctoTupleAtom`型を作り、変換処理をEcto側に持っていきます。


# Ecto.Type/ParameterizedTypeの試行錯誤

本家の[ドキュメント](https://hexdocs.pm/ecto/Ecto.Type.html)を見て簡単にできるかと思ったんですが、castってなに？dumpとどんな関係なの？という疑問で、ハマりました。この点をまとめておきます。

|callback|入力→出力|例|コメント|
|-------|-------|---|------|
|cast()|任意の型→dumpが受け付ける型|Tuple→Tuple|dumpが受け付ける型に変換（前処理）。受け付けない型は拒否|
|dump()|castが出力する型→Ecto native|Tuple→:string|DBに書き込むデータに変換する|
|load()|Ecto native→任意の型|:string→Tuple|DBから読んだ生の値を、要望の型に変換する|

Ecto nativeの型は、フィールドに設定されている型(:integerや、:string等）と考えてよいかと思います。

今回は、castでは、何も処理しませんでした（tupleであるかの確認だけ）が、listでもtupleでも受け付けるようにする場合、listを受け取り、tupleで出力するcastを追加すれば両方に対応できます。

基本、cast dumb loadのcallbackを書くだけなので、この役割分担がわかれば、あとは一直線でした。

# Ecto.ParameterizedTypeの実装

Ecto.TypeとEcto.ParameterizedTypeの違いは、パラメータがあるか無いかの違いです。
atomの変換をパラメータとし持たせたかったので、Ecto.ParameterizedTypeを使います。
パラメータを受け付ける、init()追加で必要になります。

EctoTupleAtomの実装は次の通りです。

```Elixir:ecto_tuble_atom.ex
defmodule EctoTupleAtom do
  use Ecto.ParameterizedType

  def type(_params), do: :string

  def init(opts) do
    forward_map = Enum.into(opts[:values], %{})
    reverce_map = Map.new(forward_map, fn {key, val} -> {val, key} end)
    %{f_map: forward_map, r_map: reverce_map}
  end

  def cast(cells, _params) when is_tuple(cells) do
    {:ok, cells}
  end

  def load(data, _loader, params) when is_binary(data) do
    {:ok,
     data
     |> String.split("", trim: true)
     |> Enum.map(fn x -> params.r_map[x] end)
     |> List.to_tuple()}
  end

  def dump(cells, _dumper, params) when is_tuple(cells) do
    {:ok,
     cells
     |> Tuple.to_list()
     |> Enum.map(fn x -> params.f_map[x] end)
     |> Enum.join("")}
  end

  def dump(nil, _, _), do: {:ok, ""}

  def dump(_, _, _), do: :error
end

```

# Schema

EctoTupleAtomをschemaに組み込んで使ってみます。valuesにAtomと対応する文字のリストを指定します。

```Elixir:log.ex
defmodule HexReversi.BoardHistory.Log do
  use Ecto.Schema
  import Ecto.Changeset

  schema "logs" do
    field(:xs, :integer)
    field(:ys, :integer)
    field(:cells, EctoTupleAtom, values: [none: "0", black: "1", white: "2"])
    field(:gameover, :boolean)
    field(:turn, Ecto.Enum, values: [none: "0", black: "1", white: "2"])
    field(:white, :integer)
    field(:black, :integer)

    timestamps()
  end

  @doc false
  def changeset(log, attrs) do
    log
    |> cast(attrs, [:xs, :ys, :cells, :gameover, :turn, :white, :black, :id])
    |> validate_required([:cells])
  end
end

```

# 変換の様子

dumpとloadにIO.Inspectでデータを入れて、データの変換の様子をみてみました。
実行した結果は以下の通りです。
atomを文字に変換してつなげたものになっています。

```Elixir
dump_in: {:none, :none, :black, :white}
dump_out: "0012"
load_in: "0012"
load_out: {:none, :none, :black, :white}
```


# EctoTupleAtom適用後のプログラム

データの変換処理が不要になり、すっきりできました。
pushでは、delete()とinsert()を行てたので、分けてみました。
修正前に比べて、かなり見通しが良くなりました。

```Elixir
  def push(board = %Board{}) do
    %{board | id: board.id + 1}
    |> delete()
    |> insert()
  end

  def delete(board = %Board{}) do
    id = board.id
    from(log in Log, where: log.id >= ^id) |> Repo.delete_all()
    board
  end

  def insert(board = %Board{}) do
    new_data = Log.changeset(%Log{}, Map.from_struct(board))
    if board.id > 0, do: Repo.insert(new_data)
    board
  end
```

# Ecto.Enumについて

当初、atomをDBで保存処理を、[Ecto.Enum](https://hexdocs.pm/ecto/Ecto.Enum.html)を参考にしてできないか検討しました。
[Ecto.Enumのソース](https://github.com/gjaldon/ecto_enum)を調べました。
残念ながら、うまくこの機能を持ってくることはできませんでしたが、cast/dumpがどのように使うか参考になりました。

## cast
基本はAtomが入力になる
Atom以外にvalue(変換後の値)や Atom.to_string(key)も受け付ける。
想定外の入力があった場合には、これをAtomに変換する処理するcastを作成しています。

```Elixir:/lib/ecto_enum/use.ex:29
      for {key, value} <- opts, k <- Enum.uniq([key, value, Atom.to_string(key)]) do
        def cast(unquote(k)), do: {:ok, unquote(key)}
      end
```
## dump
atomから目的の値に変換するdumpを作成しています。

```Elixir:/lib/ecto_enum/use.ex:35
      for {key, value} <- opts, k <- Enum.uniq([key, value, Atom.to_string(key)]) do
        def dump(unquote(k)), do: {:ok, unquote(value)}
      end
```
値の変換のメインはdump。castは前処理という位置づけ


# まとめ

Tupleを保存する型を作ることで、データの保存処理が簡素に書けるようになった。


参考
https://hexdocs.pm/ecto/Ecto.Type.html
https://hexdocs.pm/ecto/Ecto.ParameterizedType.html
https://hexdocs.pm/ecto/Ecto.Enum.html
https://github.com/gjaldon/ecto_enum