ようへいの日々精進XP

よかろうもん

ゴールデンウィークスペシャル : 5 月病対策「ExcelできればElixirマスターできる」を ExUnit で写経する (2) #fukuokaex

tl;dr

ゴールデンウィークが終わって, 早速, 5 月病になりそうなかっぱです. おはげようございます.

inokara.hateblo.jp

前回の続き.

写経 (「Excel の操作と同じ Elixir」の続き)

前回は

以下の関数について, Excel でのデータ操作と比較しながら ExUnit を書きつつ動作の確認をしてきた.

  • Enum.sort()
  • Enum.filter()
  • Enum.map()
  • これらの合わせ技

スライドのタイトル通り, Excel を操作する感覚で Elixir でデータの操作を出来た気がする.

ということで, 引き続き「Excel の操作と同じ Elixir」の後半部分を ExUnit を書きながら写経していきたいと思う.

マップ

スライドでも触れられているが, 一瞬, Enum.map() と混同しそうになる, マップについては, プログラミング Elixir で以下のように記述されている.

マップはキーと値のペアのコレクションだ。 マップのリテラルはこう書く。 %{ key => value, key => value }

Excel で読み替えると, キーは「列名」となる.

尚, マップについては, 全てのキーは同じ型である必要がない.

# キーは文字列
iex(1)> states = %{ "AL" => "Alabama", "WI" => "Wisconsin" }
%{"AL" => "Alabama", "WI" => "Wisconsin"}
# キーはタプル
iex(2)> response = %{ { :error, :enoent } => :fatal, { :error, :busy } => :retry }
%{{:error, :busy} => :retry, {:error, :enoent} => :fatal}
# キーはアトム
iex(3)> colors = %{ :red => 0xff0000, :green => 0x00ff00, :blue => 0x0000ff }
%{blue: 255, green: 65280, red: 16711680}
iex(4)> 

以下のような混在も可能.

iex(4)> %{ "one" => 1, :two => 2, {1, 1, 1} => 3}
%{:two => 2, {1, 1, 1} => 3, "one" => 1}

そして, 以下のように複数のマップをリストに内包している場合, マップリストと呼んだりする.

data = [%{"name" => "山田", "age" => 49, "team" => "チーム山田"}, %{"name" => "田中", "age" => 45, "team" => "チーム田中"}, %{"name" => "かっぱ", "age" => 43, "team" => "チームかっぱ"}]

一行では見づらいけど, iex で出力すると以下のように出力される.

iex(5)> data = [%{"name" => "山田", "age" => 49, "team" => "チーム山田"}, %{"name" => "田中", "age" => 45, "team" => "チーム田中"}, %{"name" => "かっぱ", "age" => 43, "team" => "チームかっぱ"}]
[
  %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
  %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
  %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
]
iex(6)> 

List.first()

List.first/1 はリスト型のデータにおいて, 最初の要素を返す関数である. 例えば, 先述の data において, List.first() を利用すると, 以下のように出力される.

iex(6)> data |> List.first
%{"age" => 49, "name" => "山田", "team" => "チーム山田"}
iex(7)> 

また, マップの各要素へのアクセスは, ["列名"] を利用する.

iex(10)> person = data |> List.first
%{"age" => 49, "name" => "山田", "team" => "チーム山田"}
iex(11)> person["name"]             
"山田"

ということで, 以下のようなテストケースを書いた.

defmodule ElixirExcelHandsonTest do
  use ExUnit.Case

  setup_all do
    {:ok, data: [
             %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
             %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
             %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
          ]
    }
  end

  test "List.first() を試す", state do
    result = %{"age" => 49, "name" => "山田", "team" => "チーム山田"}
    assert ElixirExcelHandson.list_first(state[:data]) == result
  end

  test "List.first() してから, map の要素にアクセスする", state do
    result = "チーム山田"
    assert ElixirExcelHandson.list_first_and_map_access(state[:data]) == result
  end
end

このテストケースでは, list_first 関数と, list_first_and_map_access 関数の 2 つの関数をテストしている. list_first 関数では, 引数で渡したマップリストから最初のマップを返すことを期待している. 続いて, list_first_and_map_access 関数では, 引数で渡したマップリストから最初のマップを取り出して, 列名 (キー名) team の値を返すことを期待している.

このテストケースが正しく通るように list_first 関数及び list_first_and_map_access 関数を書くと以下のようなコードとなるはず.

defmodule ElixirExcelHandson do
  def list_first(data) do
    data |> List.first
  end

  def list_first_and_map_access(data) do
    person = data |> List.first
    person["team"]
  end
end

実際にテストを走らせてみる.

root@77bd00ecbf3f:/elixir-excel/elixir_excel_handson# mix test --trace

ElixirExcelHandsonTest
  * test List.first() してから, map の要素にアクセスする (1.7ms)
  * test List.first() を試す (0.00ms)


Finished in 0.03 seconds
2 tests, 0 failures

Randomized with seed 545180

いい感じ.

リストマップデータのフィルタ

例えば, 上述のリストマップに定義されているマップデータから age キーのデータを利用して, フィルタ (45 歳以上を抽出) したい場合, 前回の記事でも試した Enum.filter() を利用する.

iex で試すと以下のような感じ.

iex(4)> data |> Enum.filter(fn(n) -> n["age"] > 45 end)
[%{"age" => 49, "name" => "山田", "team" => "チーム山田"}]
iex(5)> data |> Enum.filter(&(&1["age"] > "45"))         
[%{"age" => 49, "name" => "山田", "team" => "チーム山田"}

テストケースは以下のように書いた.

defmodule ElixirExcelHandsonTest do
  use ExUnit.Case

  setup_all do
    {:ok, data: [
             %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
             %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
             %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
          ]
    }
  end

  test "リストマップデータに対してフィルタを試す_1", state do
    result = [%{"age" => 49, "name" => "山田", "team" => "チーム山田"}]
    assert ElixirExcelHandson.list_map_data_filter1(state[:data]) == result
  end

  test "リストマップデータに対してフィルタを試す_2", state do
    result = [
      %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
      %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
    ]
    assert ElixirExcelHandson.list_map_data_filter2(state[:data], 43) == result
  end
end

このテストケースでは 2 つのパターンでリストマップデータをフィルタしている.

まず, list_map_data_filter1 関数では, 引数で指定したリストマップデータからマップデータの "age" の値で 45 以上の値を持ったマップを返すことを期待している. list_map_data_filter2 関数では, 第一引数で指定したリストマップデータから, 第二引数で指定した "age" の値以上の値を持ったリストマップを返すことを期待している.

このテストケースが正しく通るように, 以下のような関数を書いた.

defmodule ElixirExcelHandson do
  def list_map_data_filter1(data) do
    data |> Enum.filter(fn(n) -> n["age"] > "45" end)
  end

  def list_map_data_filter2(data, age) do
    data |> Enum.filter(fn(n) -> n["age"] > age end)
  end
end

テストを走らせてみる.

root@77bd00ecbf3f:/elixir-excel/elixir_excel_handson# mix test --trace
Compiling 1 file (.ex)

ElixirExcelHandsonTest
  * test リストマップデータに対してフィルタを試す_2 (0.00ms)
  * test リストマップデータに対してフィルタを試す_1 (0.00ms)


Finished in 0.04 seconds
2 tests, 0 failures

Randomized with seed 345094

リストマップデータのソート

引き続き, マップデータの "age" キー ("age" 列) のデータを利用してリストマップデータをソートする. &1["age"] は現在の行, &2["age"] は次の行となり, 降順であれば > を利用, 昇順であれば < を利用する.

iex で試すと以下のような感じになる.

iex(3)> data |> Enum.sort(&(&1["age"] > &2["age"]))        
[
  %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
  %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
  %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
]
iex(4)> data |> Enum.sort(&(&1["age"] < &2["age"]))
[
  %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"},
  %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
  %{"age" => 49, "name" => "山田", "team" => "チーム山田"}
]

テストケースは以下のように書いた.

defmodule ElixirExcelHandsonTest do
  use ExUnit.Case

  test "リストマップデータに対して age でソートを掛ける (昇順)", state do
    result = [
      %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"},
      %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
      %{"age" => 49, "name" => "山田", "team" => "チーム山田"}
    ]
    assert ElixirExcelHandson.list_map_data_sort1(state[:data]) == result
  end

  test "リストマップデータに対して age でソートを掛ける (降順)", state do
    result = [
      %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
      %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
      %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
    ]
    assert ElixirExcelHandson.list_map_data_sort2(state[:data]) == result
  end
end

list_map_data_sort1 関数では, 昇順にデータ返ってくることを期待している. また, list_map_data_sort2 関数では, 降順にデータが返ってくることを期待している. このテストケースが正しく通るように, 以下のような関数を書いた.

defmodule ElixirExcelHandson do
  def list_map_data_sort1(data) do
    data |> Enum.sort(&(&1["age"] < &2["age"]))
  end

  def list_map_data_sort2(data) do
    data |> Enum.sort(&(&1["age"] > &2["age"]))
  end
end

テストを走らせてみる.

# mix test --trace

ElixirExcelHandsonTest
  * test リストマップデータに対して age でソートを掛ける (昇順) (2.2ms)
  * test リストマップデータに対して age でソートを掛ける (降順) (0.00ms)


Finished in 0.05 seconds
2 tests, 0 failures

Randomized with seed 592352

いい感じ.

リストマップデータの加工

マップデータの "age" キー ("age" 列) のデータを加工してみる. この場合, Enum.map() を利用する.

iex だと以下のような感じ.

data = [
         %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
         %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
         %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
       ]
iex(1)> data = [
...(1)>              %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
...(1)>              %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
...(1)>              %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
...(1)>           ]
[
  %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
  %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
  %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
]
iex(2)> data |> Enum.map(&(&1["age"] + 100)) 
[149, 145, 143]

テストは以下のように書いた.

defmodule ElixirExcelHandsonTest do
  use ExUnit.Case
  test "リストマップデータに対して age キーの値に 100 を加算する", state do
    result = [149, 145, 143]
    assert ElixirExcelHandson.list_map_data_modify(state[:data]) == result
  end
end

このテストが正しく通るように, 以下のように関数を書いた.

defmodule ElixirExcelHandson do
  def list_map_data_modify(data) do
    data |> Enum.map(&(&1["age"] + 100))
  end
end

テストを走らせてみる.

root@77bd00ecbf3f:/elixir-excel/elixir_excel_handson# mix test --trace

ElixirExcelHandsonTest
  * test リストマップデータに対して age キーの値に 100 を加算する (1.7ms)


Finished in 0.05 seconds
1 tests, 0 failures

Randomized with seed 787391

いい感じ.

おまけ

ExUnit の setup_all でデータを用意する場合, 以下のように書く.

...
  setup_all do
    {:ok, data: [
             %{"age" => 49, "name" => "山田", "team" => "チーム山田"},
             %{"age" => 45, "name" => "田中", "team" => "チーム田中"},
             %{"age" => 43, "name" => "かっぱ", "team" => "チームかっぱ"}
          ]
    }
  end
...

setup_all については, Elixir School のテスト に, 以下のように書かれている.

いくつかの場合に、テスト前にセットアップを行う必要があるかもしれません。セットアップを行うために、 setup と setup_all マクロを使うことができます。 setup は各テストの前、 setup_all は全体のテストの前に一度だけ実行されます。どちらも {:ok, state} のタプルを返すことが期待されていて、stateはテスト内で利用可能です。

以上

ざっくりと写経してみたけど, 欲しいデータが Excel のセルを操作に近い感覚で取得出来たり, 加工出来るのは気持ち良かった. しかし, 各関数の中で利用する無名関数 (fn() で始まる構文や &(&1["age"] + 100) こんな書き方) については, 慣れが必要だと思った.

以上, Elixir 初心者による現場からの報告でした.