エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

ETS (Erlang Term Storage)を利用する(今後は、たぶん)

ETS とは

Erlang/OTP には ETS というストレージライブラリが標準で用意されています。 当然 Elixir でも利用できます。

www.erlang.org elixirschool.com

ただ、ETS の問い合わせには match specification と呼ばれる構造を利用する必要があります。

www.erlang.org

たとえば。 「三つ組のタプルの、3 番目の値が 2 より大きいものの、1 番目のデータを集める」 という問い合わせをしたい場合、問い合わせには次のようなデータを用意しなければなりません。

[{{:"$1", :_, :"$2"}, [{:>, {:length, :"$2"}, 2}], [:"$1"]}]

これは構造としては次のような関数と同じです。

fn {a1, _, a2} when a2 > 2 -> a1 end

こうして見せられればなるほどとわかるのですが、最初から迷わずに書ける自信がありません。

個人的にはこの記述をするのがおっくうで、 データの格納先に ETS を利用することはあまり考えていませんでした。

それが最近になって ets:fun2ms/1 という関数の存在を知りました。 昔からある関数なのですが、ETS を利用する頭がなかったので、そのような便利なものがあることも知らず過ごしていた始末。

iex(1)> :ets.fun2ms(fn {a1, _, a2} when a2 > 2 -> a1 end)
[{{:"$1", :_, :"$2"}, [{:>, :"$2", 2}], [:"$1"]}]

よし、これで使える! …と思ったのも束の間。

IEx 上では利用できるものの、モジュールに組み込んで実行するとエラーになってしまいます。

defmodule Foo do
  def run do
    :ets.fun2ms(fn {a1, _, a2} when a2 > 2 -> a1 end)
  end
end

Foo.run() |> IO.inspect()
$ elixir foo.exs
** (exit) exited in: :ets.fun2ms(:function, :called, :with, :real, :fun, :should, :be, :transformed, :with, :parse_transform, :or, :called, :with, :a, :fun, :generated, :in, :the, :s
hell)
    ** (EXIT) :badarg
    (stdlib 3.17.1) ets.erl:608: :ets.fun2ms/1
    foo.exs:7: (file)
    (elixir 1.13.3) lib/code.ex:1183: Code.require_file/2

詳しい理由とかは、この記事を読んでいただくとして。

elixirforum.com

悲しい気分で記事を読み進んでいたわけですが。

実は関数から match specification へ変換する Elixir 製のパッケージが提供されているとのこと。 これもけっこう昔から。

Ex2ms

hex.pm

Ex2ms には fun/1 というマクロが 1 つ定義されています。 import すれば、ほぼ関数と同じ書き方で match specification を取得することができます。

iex(1)> Mix.install([:ex2ms])
:ok
iex(2)> import Ex2ms
Ex2ms
iex(3)> fun do {a1, _, a2} when a2 > 2 -> a1 end
[{{:"$1", :_, :"$2"}, [{:>, :"$2", 2}], [:"$1"]}]

match specification を簡単に取得する方法が見つかった以上、ETS を使わない理由がなくなりました。

と、いうわけで、さっそく書いてみました。

ETS を Ex2ms で使う

気象庁の過去の気象データ検索を利用して、東京の 2022 年 2 月の日々の気温データを取得しました。

www.data.jma.go.jp

このデータを ETS のテーブルに格納して、いろいろ検索してみます。

Mix.install([:ex2ms])

defmodule JMA do
  import Ex2ms


  # [気象庁|過去の気象データ検索](https://www.data.jma.go.jp/obd/stats/etrn/index.php) で取得した
  # 東京の 2022年2月の1日ごとの平均気温,最高気温,最低気温
  @temperatures [
    {~D[2022-02-01], 5.6, 11.2,  1.1},
    {~D[2022-02-02], 5.5, 11.1,  0.9},
    {~D[2022-02-03], 5.8, 11.8,  0.6},
    {~D[2022-02-04], 4.9,  8.5,  2.4},
    {~D[2022-02-05], 3.5,  9.2,  0.3},
    {~D[2022-02-06], 2.3,  8.2, -1.9},
    {~D[2022-02-07], 4.5,  9.7, -0.5},
    {~D[2022-02-08], 5.3,  9.1,  2.2},
    {~D[2022-02-09], 6.2, 10.8,  1.8},
    {~D[2022-02-10], 2.3,  6.1,  0.6},
    {~D[2022-02-11], 4.1,  9.2,  0.7},
    {~D[2022-02-12], 4.7,  9.9,  0.2},
    {~D[2022-02-13], 3.1,  5.0,  0.8},
    {~D[2022-02-14], 3.9,  8.0,  0.8},
    {~D[2022-02-15], 5.5, 11.6,  1.1},
    {~D[2022-02-16], 6.0, 11.8,  1.3},
    {~D[2022-02-17], 4.6,  9.7,  0.5},
    {~D[2022-02-18], 5.7, 11.4, -0.2},
    {~D[2022-02-19], 5.0,  8.9,  1.5},
    {~D[2022-02-20], 5.9, 10.0,  2.4},
    {~D[2022-02-21], 4.1,  8.7,  1.2},
    {~D[2022-02-22], 4.4, 10.0, -0.5},
    {~D[2022-02-23], 4.4, 10.3,  0.1},
    {~D[2022-02-24], 4.8, 10.1,  1.7},
    {~D[2022-02-25], 6.5, 13.4, -0.1},
    {~D[2022-02-26], 8.6, 14.7,  3.0},
    {~D[2022-02-27], 9.5, 18.5,  3.2},
    {~D[2022-02-28], 9.8, 15.9,  4.5}
  ]

  def run do
    # テーブルを作成する
    tid = :ets.new(:jma, [])

    # 気温データを投入する
    @temperatures
    |> Enum.each(&:ets.insert(tid, &1))

    IO.puts("\n最低気温が氷点下の日のデータ")
    :ets.select(tid, fun do {_date, _avg, _max, min} = t when min < 0 -> t end)
    |> IO.inspect()

    IO.puts("\n最高気温と最低気温の差が15度を超える日のデータ")
    :ets.select(tid, fun do {date, _avg, max, min} when (max - min) > 15 -> {date, max, min, max - min} end)
    |> IO.inspect()

    IO.puts("\n最高気温と最低気温の差が15度を超える日のデータ(結果をキーワードリストで取得する)")
    :ets.select(tid, fun do {date, _avg, max, min} when (max - min) > 15 -> [date: date, max: max, min: min, diff: max - min] end)
    |> IO.inspect()
  end
end

JMA.run()

実行。

$ elixir jma.exs

最低気温が氷点下の日のデータ
[
  {~D[2022-02-06], 2.3, 8.2, -1.9},
  {~D[2022-02-22], 4.4, 10.0, -0.5},
  {~D[2022-02-07], 4.5, 9.7, -0.5},
  {~D[2022-02-18], 5.7, 11.4, -0.2},
  {~D[2022-02-25], 6.5, 13.4, -0.1}
]

最高気温と最低気温の差が15度を超える日のデータ
[{~D[2022-02-27], 18.5, 3.2, 15.3}]

最高気温と最低気温の差が15度を超える日のデータ(結果をキーワードリストで取得する)
[[date: ~D[2022-02-27], max: 18.5, min: 3.2, diff: 15.3]]

なかなかいい感じ。

いまさらながら ETS の便利さを感じたできごとでした。

いつか読むはずっと読まない:先入観

ETS を使わなければならない状況にならなかったというのもあってか、「問い合わせを書くのが面倒だなぁ」という思い込みを持ったままずっと来てしまいました。

そう感じるのは、たいていは、自分だけではないはずなので、それを解消する方法がすでにあるはず、と思い至ればよかったのですが。 一度思い込んでしまうと、なかなかそれを払拭できないものですね。

ExUnit で change matcher を使いたい、ので、書いてみた

change matcher とは

普段、しごとでは Ruby on Rails を使い、テストには RSpec を利用しています。 RSpec は matcher が充実していて、単純な一致や不一致だけでなく、いろいろなパタンの検証をすることができ重宝しています。

Elixir に標準装備の ExUnit はその点はとてもシンプルで、基本は assert とその反転の refute 、あとは例外補足などのいくつかのバリエーションが用意されているだけです。 その中にメッセージの受信の検証が用意されているのは、Elixir らしいところと言いますか、メッセージのやりとりは Elixir の基本ということを思い出させてくれます。

それぞれ勝手が違うので RSpec と ExUnit を切り替えると束の間とまどうのですが、どちらも検証したいことの記述はできますので、テストが書けなくなるようなことはありません。

ただ一つ。 RSpec にあって ExUnit になく、ExUnit にあってくれたら、と思うものが change matcher です。

relishapp.com

RSpec では、ある式の値がある処理の前後で変化することを検証するとき、次のように簡潔に記述することができます。

# RSpec
it '処理の前後で式の値が変化する' do
  expect { 処理 }.to change { 式 }.from(処理前の値).to(処理後の値)
end

ExUnit で同じ検証をしようとすると、おおむね次のようになります。

# ExUnit
test '処理の前後で式の値が変化する' do
  assert 処理前の値 == 式
  処理
  assert 処理後の値 ==end

また変化量を検証する場合、RSpec では:

# RSpec
it '処理の前後で式の値が変化する' do
  expect { 処理 }.to change { 式 }.by(処理前後の変化量)
end

ExUnit では:

# ExUnit
test '処理の前後で式の値が変化する' do
  処理前の値 = 式
  処理
  処理後の値 =assert 処理前後の変化量 == (処理後の値 - 処理前の値)
end

やりたいことはできていて不満はないのですが、同じ式を処理の前後に書かなければなりません。 できればもう少し簡単に書きたいところ。

と、いうわけで。 今回は、自前で change matcher を書いてみた、という話です。

準備

今回は matcher を記述するアプリケーションと、その matcher を利用するアプリケーションを用意して実装を進めます。

my_matcher - matcher を提供するアプリケーション

matcher を記述するアプリケーションの名前は my_matcher 、モジュールの名前は MyMatcher とします。

$ mix new my_matcher

my_app - matcher を利用するアプリケーション

matcher を利用するアプリケーションは my_app として、my_matcher と同じディレクトリに作成することにします。

$ mix new my_app

my_appmix.exs を編集して、依存関係に my_matcher を追加します。

defmodule MyApp.MixProject do
  use Mix.Project

  # ... 中略

  defp deps do
    [
      {:my_matcher, path: "../my_matcher", only: :test}
    ]
  end
end

Version 1: 無名関数を利用して実現する

処理の前後で式を評価したいのですから、change matcher には評価前の式を渡さなければなりません。 また処理自体も、式の最初の評価の後に評価されなければなりません。

評価しない状態を値にしたいのならば、無名関数を利用するのが定番です。

そのばあい、テストは次のように書かれるはずです。

expect(fn -> 処理 end, fn ->end, 処理前の値, 処理後の値)

expect(fn -> 処理 end, fn ->end, 処理前後の変化量)

このアイディアをもとに関数 expect/4, expect/3 を書いてみました。

実装

defmodule MyMatcher do
  defmodule V1 do
    defmacro expect(op, exp, from, to) do
      quote bind_quoted: [op: op, exp: exp, from: from, to: to] do
        first = exp.()

        assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった"

        op.()

        second = exp.()

        refute from == second, "初期値の #{inspect(from)} から変化してない"
        assert to == second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した"
      end
    end

    defmacro expect(op, exp, by) do
      quote bind_quoted: [op: op, exp: exp, by: by] do
        first = exp.()
        op.()
        second = exp.()

        refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない"

        actual = second - first

        assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した"
      end
    end
  end
end

ちなみに。 関数でなくマクロで記述しているのは次のような理由からです。

  1. テストに失敗したとき、スタックトレースexpect/4, expect/3 内のアサーションの位置が表示されてしまう(不要な情報なのでノイズになってしまう)
  2. マクロはテスト内に展開されるので、モジュールの import の必要なく assert/2, refute/2 を利用できる
  3. このあとの話の布石

これを利用してテストを書いてみます。

テストコード

関数そのものは入力に対して出力を返すだけのものですので、ある処理の前後であっても、本来は返す値は変化しません。 ですがプロセスが抱えている値は変化することがあります。

ここでは Agent のプロセスが持つ値の変化を検証してみます。

マクロを利用するためにモジュールを import しています。 import を describe の中で行っているのは、このあと実装するバージョンとマクロが衝突しないようにするという、このあとの都合のためです。

defmodule MyAppTest do
  use ExUnit.Case

  setup do
    {:ok, pid} = start_supervised({Agent, fn -> 0 end})

    [pid: pid]
  end

  describe "V1 expect ... to change ... from ... to ..." do
    import MyMatcher.V1

    test "成功するテスト", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 1
    end

    test "処理前の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1, 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 2
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 0, 1
    end
  end

  describe "V1 expect ... to change ... by ..." do
    import MyMatcher.V1

    test "成功するテスト", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 2
    end
  end
end

テスト結果

テストを実行すると、期待どおりの結果がえられるこが確認できると思います。

.

  1) test V1 expect ... to change ... from ... to ... 処理前の値が正しくない (MyAppTest)
     test/my_app_test.exs:17
     初期値が 1 のはずが 0 だった
     code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1, 1
     stacktrace:
       test/my_app_test.exs:18: (test)



  2) test V1 expect ... to change ... from ... to ... 処理後の値が正しくない (MyAppTest)
     test/my_app_test.exs:21
     処理後に 2 へ変化するはずが 1 へ変化した
     code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 2
     stacktrace:
       test/my_app_test.exs:22: (test)



  3) test V1 expect ... to change ... from ... to ... 処理後に値が変化しない (MyAppTest)
     test/my_app_test.exs:25
     初期値の 0 から変化してない
     code: expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 0, 1
     stacktrace:
       test/my_app_test.exs:26: (test)

.

  4) test V1 expect ... to change ... by ... 処理後に値が変化しない (MyAppTest)
     test/my_app_test.exs:37
     処理後に値が変化するはずなのに 0 から変化していない
     code: expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 1
     stacktrace:
       test/my_app_test.exs:38: (test)



  5) test V1 expect ... to change ... by ... 処理後の値が正しくない (MyAppTest)
     test/my_app_test.exs:41
     処理後に 2 だけ値が変化するはずが 1 だけ変化した
     code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 2
     stacktrace:
       test/my_app_test.exs:42: (test)

Version 2: 無名関数を利用して実現する その2、キーワードリストで情報を増やす

これでテストはできますが、4 つの引数の羅列なので、テストコードの意味や意図をくみとるための情報が不足しています。 せっかくのテストなのですから、もっと説明的な記述をしたいものです。

というわけで。 第 2, 3, 4 引数をキーワードリストで受けるようにしてみます。

実装

defmodule MyMatcher do
  # 省略
  
  defmodule V2 do
    defmacro expect(op, to_change: exp, from: from, to: to) do
      quote bind_quoted: [op: op, exp: exp, from: from, to: to] do
        first = exp.()

        assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった"

        op.()

        second = exp.()

        refute from == second, "初期値の #{inspect(from)} から変化してない"
        assert to == second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した"
      end
    end

    defmacro expect(op, to_change: exp, by: by) do
      quote bind_quoted: [op: op, exp: exp, by: by] do
        first = exp.()
        op.()
        second = exp.()

        refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない"

        actual = second - first

        assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した"
      end
    end
  end
end

コードとしては、引数の前にキーワードになるアトムを追加するだけです。 ただし気をつけておきたい点は、今回定義したマクロはどちらも 2 引数のマクロ、 expect/2 という点です。

このことが影響することは少ないと思いますが、マクロにあたえる引数の数をまちがえたときに、予想外のエラーメッセージを目にして混乱するかもしれません。

テストコード

V1 のテストに、キーワード :to_change, :from, :to をつけただけです。 だけですが情報が増えて、意味を理解する助けになります。

defmodule MyAppTest do
  use ExUnit.Case

  setup do
    {:ok, pid} = start_supervised({Agent, fn -> 0 end})

    [pid: pid]
  end

  # 省略

  describe "V2 expect ... to change ... from ... to ..." do
    import MyMatcher.V2

    test "成功するテスト", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 1
    end

    test "処理前の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 1, to: 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 2
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 0 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 1
    end
  end

  describe "V2 expect ... to change ... by ..." do
    import MyMatcher.V2

    test "成功するテスト", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 1
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 0 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 2
    end
  end
end

テストの実行結果は同じになりますので省略。

Version 3: コードをコードとして受け取り展開する、マクロなのだから

マクロは、あたえられたコード片をマクロを記述した位置に展開するしくみです。 であれば、無名関数を利用しなくても、評価前の式をそのまま扱うことができるはずです。

実装

V1 や V2 では :bind_quoted オプションを使って、コード片を展開する前に引数をすべて評価しましたが、今回は unquote/1 を使って評価する位置に埋め込んでいます。

defmodule MyMatcher do
  # 省略

  defmodule V3 do
    defmacro expect(op, to_change: exp, from: from, to: to) do
      quote do
        from = unquote(from)
        to = unquote(to)

        first = unquote(exp)

        assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった"

        unquote(op)

        second = unquote(exp)

        refute from == second, "初期値の #{inspect(from)} から変化してない"
        assert to === second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した"
      end
    end

    defmacro expect(op, to_change: exp, by: by) do
      quote do
        by = unquote(by)

        first = unquote(exp)
        unquote(op)
        second = unquote(exp)

        refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない"

        actual = second - first

        assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した"
      end
    end
  end
end

テストコード

引数に渡す値がコード片の中に展開されそこで評価されるので、無名関数ではなく、処理そのもの式そのものを引数として直接記述できるようになりました。

defmodule MyAppTest do
  use ExUnit.Case

  setup do
    {:ok, pid} = start_supervised({Agent, fn -> 0 end})

    [pid: pid]
  end

  # 省略

  describe "V3 expect ... to change ... from ... to ..." do
    import MyMatcher.V3

    test "成功するテスト", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 0, to: 1
    end

    test "処理前の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 1, to: 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 0, to: 2
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 0 end), to_change: Agent.get(pid, & &1), from: 0, to: 1
    end
  end

  describe "V3 expect ... to change ... by ..." do
    import MyMatcher.V3

    test "成功するテスト", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), by: 1
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 0 end), to_change: Agent.get(pid, & &1), by: 1
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), by: 2
    end
  end
end

これでほぼ充分に表現できているのですが、せっかくなのでもう一段階やり過ぎてみましょう。

Version 4: キーワードリスト形式から関数形式へ

マクロに引数を渡すと、その引数は評価されずそのままの形でマクロに受け取られます。 正確には、引数は AST としてマクロに渡されています。

hexdocs.pm

このことを利用すると、存在しない関数を呼び出す式を引数として渡す、ということが可能になります。

何を言っているかというと。 簡単なサンプルで説明します。

次のようなマクロを定義します。 簡単なマクロなので、ここでは IEx で入力した様子を書いています。

iex(1)> defmodule Sample do
...(1)>   defmacro expect(arg1, arg2) do
...(1)>     IO.inspect(arg1, label: "arg1")
...(1)>     IO.inspect(arg2, label: "arg2")
...(1)>     :ok
...(1)>   end
...(1)> end
{:module, Sample,
 <<70, 79, 82, 49, 0, 0, 5, 176, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 166,
   0, 0, 0, 17, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101,
   8, 95, 95, 105, 110, 102, 111, 95, 95, 10, 97, 116, 116, 114, 105, 98, 117,
   116, 101, 115, 7, 99, 111, 109, 112, 105, 108, 101, 10, 100, 101, 112, 114,
   101, 99, 97, 116, 101, 100, 11, 101, 120, 112, 111, 114, 116, 115, 95, 109,
   100, 53, 9, 102, 117, 110, 99, 116, 105, 111, 110, 115, 6, 109, 97, 99, 114,
   111, 115, 3, 109, 100, 53, 6, 109, 111, 100, 117, 108, 101, 6, 101, 114, 108,
   97, 110, 103, ...>>, {:expect, 2}}

マクロを import し、そのマクロの引数に foo("abc")bar(12345, 678900) といった存在しない関数を呼び出す式を渡します。

面白いことに undefined function foo/1 のようなエラーメッセージは表示されず、引数に渡した関数呼び出しの AST が表示されます。

iex(2)> import Sample
Sample
iex(3)> expect(foo("abc"), bar(12345, 67890))
arg1: {:foo, [line: 4], ["abc"]}
arg2: {:bar, [line: 4], [12345, 67890]}
:ok

ドキュメントにあるように、関数の呼び出しは {関数名のアトム, メタデータ, 引数のリスト} という形式になっていることがわかります。

実はこれも引数のパタンマッチングに使えます。

再びサンプルです。 expect(foo("abc"), bar("ABC")) という呼び出しに対して値の評価はせず、関数名はパタンマッチングにだけ利用しています。

iex(1)> defmodule Sample do
...(1)>   defmacro expect({:foo, _, [arg1]}, {:bar, _, [arg2]}) do
...(1)>     quote do
...(1)>       [foo: unquote(arg1), bar: unquote(arg2)]
...(1)>     end
...(1)>   end
...(1)> end
{:module, Sample,
 <<70, 79, 82, 49, 0, 0, 5, 116, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 153,
   0, 0, 0, 16, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101,
   8, 95, 95, 105, 110, 102, 111, 95, 95, 10, 97, 116, 116, 114, 105, 98, 117,
   116, 101, 115, 7, 99, 111, 109, 112, 105, 108, 101, 10, 100, 101, 112, 114,
   101, 99, 97, 116, 101, 100, 11, 101, 120, 112, 111, 114, 116, 115, 95, 109,
   100, 53, 9, 102, 117, 110, 99, 116, 105, 111, 110, 115, 6, 109, 97, 99, 114,
   111, 115, 3, 109, 100, 53, 6, 109, 111, 100, 117, 108, 101, 6, 101, 114, 108,
   97, 110, 103, ...>>, {:expect, 2}}
iex(2)> import Sample
Sample
iex(3)> expect(foo("abc"), bar("ABC"))
[foo: "abc", bar: "ABC"]

と、いうことは。 これと同じように to_change(式) という記述に対してパタンマッチさせ、式のみを利用するということが可能なはずです。

実装

V3 でキーワードリストになっていた部分を、関数呼び出しの AST のパタンマッチングに置き換えます。

defmodule MyMatcher do
  # 省略

  defmodule V4 do
    defmacro expect(op, {:to_change, _, [exp]}, {:from, _, [from]}, {:to, _, [to]}) do
      quote do
        from = unquote(from)
        to = unquote(to)

        first = unquote(exp)

        assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった"

        unquote(op)

        second = unquote(exp)

        refute from == second, "初期値の #{inspect(from)} から変化してない"
        assert to === second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した"
      end
    end

    defmacro expect(op, {:to_change, _, [exp]}, {:by, _, [by]}) do
      quote do
        by = unquote(by)

        first = unquote(exp)
        unquote(op)
        second = unquote(exp)

        refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない"

        actual = second - first

        assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した"
      end
    end
  end
end

テストコード

defmodule MyAppTest do
  use ExUnit.Case

  setup do
    {:ok, pid} = start_supervised({Agent, fn -> 0 end})

    [pid: pid]
  end

  # 省略

  describe "V4" do
    import MyMatcher.V4

    test "成功するテスト", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(0), to(1)
    end

    test "処理前の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(1), to(1)
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(0), to(2)
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 0 end), to_change(Agent.get(pid, & &1)), from(0), to(1)
    end
  end

  describe "V4 expect ... to change ... by ..." do
    import MyMatcher.V4

    test "成功するテスト", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), by(1)
    end

    test "処理後に値が変化しない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 0 end), to_change(Agent.get(pid, & &1)), by(1)
    end

    test "処理後の値が正しくない", %{pid: pid} do
      expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), by(2)
    end
  end
end

あらためて Rspec の記述とくらべてみましょう。

# RSpec
it '処理の前後で式の値が変化する' do
  expect { 処理 }.to change { 式 }.from(処理前の値).to(処理後の値)
end
# ExUnit
test '処理の前後で式の値が変化する' do
  expect 処理, to_change(式), from(処理前の値), to(処理後の値)
end

これで RSpec で見慣れた形に近づけることができたのではないかと思います。

微調整: formatter への配慮

この状態で mix format を実行すると、expect に括弧が追加されてしまいます。

# `mix format` を実行すると、括弧が追加される
expect(処理, to_change(式), from(処理前の値), to(処理後の値))

テストでは、ここまで書いてきたように、括弧なしの表現のままにしておきたいものです。

そこで .formatter.exs を修正して expect/2, expect/3, expect/4 には括弧を追加しない設定を加えます。

mix new コマンドを実行すると、次のような内容の .formatter.exs ファイルが作成されます。

[
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

ここに :locals_without_parens オプションで括弧を追加したくない関数を並べます。

[
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
  locals_without_parens: [expect: 2, expect: 3, expect: 4]
]

このオプションの詳細については、ドキュメントを参照ください。

hexdocs.pm hexdocs.pm

いつか読むはずっと読まない:「学ぶ」を学ぶ

本文に、メタ認知が重要、とありました。

わたしにとってブログを書くことは「『自分の理解』を理解」する手続きなのかもしれません。

HTML要素に適用するコードを再利用する、Alpine.jsで。

要素に適用するコードを再利用する、Alpine.jsで。

初期のリリースでは、HTML の中にデータやコードを埋め込むスタイルだった Alpine.js ですが、v3.0 以降はその分離と再利用のためのしくみが整備されてきているようです。

今月になって新しいしくみが提供されました。

github.com

alpinejs.dev

# Alpine.bind

Alpine.bind(...) provides a way to re-use x-bind objects within your application.

HTML と JavaScript は分離したい勢としては、うれしい仕様です。

ここでは、押すと表示と状態が変化するボタンを例に説明します。

f:id:E_Mattsan:20220130105340p:plain

未分離のAlpine.js

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  </head>
  <body>
    <div x-data="{label: 'Greet', disabled: false}">
      <button x-text="label" @click="label = 'Hello!'; disabled = true" :disabled="disabled"></button>
    </div>
  </body>
</html>

未分離の書き方では、x-dada 属性にデータを記述し、x-on:click(省略形は @click )にクリック時に実行するコードを記述します。

x-bind:disabled(省略形は :disabled )は、HTML の属性 disabledJavaScript の値 disabled を結びつけていて、JavaScriptの値の変化が HTML の属性に反映されるようになっています。

これでは少し規模が大きくなるだけで、コードが混乱することが簡単に想像できます。

Alpine.js を最初に見つけたとき、関心をよせたものの積極的に利用しようとは考えなかったのも、このあたりがその理由でした。

HTMLとデータを分離したAlpine.js

HTML の要素とデータやコードを結びつけるための Alpine.data という関数が提供されています。

alpinejs.dev

Alpine.data の使い方については、以前にも記事を書いていますので、そちらも参照してみてください。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  </head>
  <body>
    <div x-data="greeting">
      <button x-text="label" @click="greet" :disabled="disabled"></button>
    </div>

    <script>
      document.addEventListener('alpine:init', () => {
        Alpine.data('greeting', () => {
          return {
            label: 'Greet',
            disabled: false,

            greet() {
              this.label = 'Hello!'
              this.disabled = true
            }
          }
        })
      })
    </script>
  </body>
</html>

これで大きなところは分離できましたが、まだ x-text, @click, :disabled といった属性が HTML に埋め込まれたままになっています。

x-bind は HTML の属性と JavaScript の値を結びつけるしくみですが、独自の名前を割り当てることで属性をまとめて JavaScript 内に記述することができるようになります。

ただし。 見てのとおり、「ごちゃついた感じ」はぬぐえません。 個人的な感想は「なんと面妖なコード」です。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  </head>
  <body>
    <div x-data="greeting">
      <button x-bind="greetingButton"></button>
    </div>

    <script>
      document.addEventListener('alpine:init', () => {
        Alpine.data('greeting', () => {
          return {
            label: 'Greet',
            disabled: false,

            greetingButton: {
              ['x-text']() {
                return this.label
              },

              ['@click']() {
                this.label = 'Hello!'
                this.disabled = true
              },

              [':disabled']() {
                return this.disabled
              }
            }
          }
        })
      })
    </script>
  </body>
</html>

データとふるまいを分離した Alpine.js

新たに導入された Alpine.bind は、greetingButton の部分をデータの定義から分離します。

ただし分離とは言ってもデータを定義する部分からの分離で、データを参照したり更新したりといった部分でつながっています。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.2/new.min.css">
  </head>
  <body>
    <div x-data="greeting">
      <button x-bind="greetingButton"></button>
    </div>

    <script>
      document.addEventListener('alpine:init', () => {
        Alpine.data('greeting', () => {
          return {
            label: 'Greet',
            disabled: false
          }
        })

        Alpine.bind('greetingButton', () => {
          return {
            type: 'button',

            'x-text'() {
              return this.label
            },

            '@click'() {
              this.label = 'Hello!'
              this.disabled = true
            },

            ':disabled'() {
              return this.disabled
            }
          }
        })
      })
    </script>
  </body>
</html>

データが分離されているということで、単独のコンポーネントとしての再利用には向いていませんが、逆に分離されているために別々のデータに割り当てることができるというメリットがあります。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  </head>
  <body>
    <div x-data="greeting1">
      <button x-bind="greetingButton"></button>
    </div>

    <div x-data="greeting2">
      <button x-bind="greetingButton"></button>
    </div>

    <script>
      document.addEventListener('alpine:init', () => {
        Alpine.data('greeting1', () => {
          return {
            label: 'Greet',
            message: 'Hello!',
            disabled: false
          }
        })

        Alpine.data('greeting2', () => {
          return {
            label: 'あいさつしましょう',
            message: 'やはぁ',
            disabled: false
          }
        })

        Alpine.bind('greetingButton', () => {
          return {
            type: 'button',

            'x-text'() {
              return this.label
            },

            '@click'() {
              this.label = this.message
              this.disabled = true
            },

            ':disabled'() {
              return this.disabled
            }
          }
        })
      })
    </script>
  </body>
</html>

Alpine.js の活きる道

Alpine.js は フロントエンド向けの JavaScript フレームワークですが、より普及しているフレームワークと違って、HTML を構築する部分がありません。 このため、HTML 部分は Alpine.js のコードとは別に自分で組み立てる必要があります。

これはフレームワークとして「弱い」部分ではあると思いますが、一方でその「主張しない」ことが Alpine.js の強みになっていると考えています。

Ruby on RailsPhoenix Framework のように自前で HTML を組み立てる機構を持つサーバサイドのフレームワークに、他に特別な設定を必要せずに JavaScript パッケージの一つとして組み込むことができ、ほとんど衝突することなく利用することができるのは、サーバサイドの開発をする者にとって大きな利点かなと思います。

いつか読むはずっと読まない:あなたに見えているものと、わたしに見えているものは、違うということ

仕事をリモートでするようになってそこそこ時間が経ちましたが。 コミュニケーションをとりやすくなったのか、とりにくくなったのか、だいぶ個人差があるような印象。

CSVファイルをフィルタリングする

「元のCSVファイルから特定の条件の行だけを抜き出した新しいCSVファイルを作成する」という仕事中の話題から。

Ruby

Rubyでしたら、不都合がない限り標準添付のcsvライブラリを利用すると思います。

docs.ruby-lang.org

onliner

「ファイルから読み込んで、行を選択して、ファイルに書き出す」という手続きをそのままコードにしたもの。

require 'csv'

File.write('dst.csv', CSV::Table.new(CSV.read('src.csv', headers: true).select {|row| row['max'].to_i >= 100 }))

手続きごとに分解すると。

require 'csv'

src_data = CSV.read('src.csv', headers: true)              # (1) ヘッダ情報付きでファイルから読み込む
dst_data = src_data.select {|row| row['max'].to_i >= 100 } # (2) 行を選択する
dst_csv = CSV::Table.new(dst_data)                         # (3) 行の集まりをCSVにする
File.write('dst.csv', dst_csv)                             # (4) ファイルに書き出す

このうち(4)の「行の集まりをCSVにする」部分は、CSV::Table.newが「CSV::Rowインスタンスの配列」を引数として受け取ることを利用しています。

docs.ruby-lang.org

CSV::Table.new([CSV::Row.new(%w(a b c), [1, 2, 3]), CSV::Row.new(%w(a b c), [4, 5, 6])])
# => #<CSV::Table mode:col_or_row row_count:3>

またCSV::Tableインスタンスは文字列に変換するとCSV形式の文字列になります。 ここではIOへの出力されたときに自動的に文字列に変換されることを利用しています。

csv = CSV::Table.new([CSV::Row.new(%w(a b c), [1, 2, 3]), CSV::Row.new(%w(a b c), [4, 5, 6])])

csv.to_s
# => "a,b,c\n1,2,3\n4,5,6\n"

puts csv
# => a,b,c
#    1,2,3
#    4,5,6

Stream

入力となるCSVと出力となるCSVを同時に開き、読み込む1行ごとに選択と書き込みをする、というアイディアです。

書き込み時にもヘッダをつける場合には:headersオプションの他に:write_headersオプションを指定する必要があります。

また足をすくわれた点として。 CSV#headersは、ヘッダの利用を指定していても、読み込み前の状態ではtrueしか返してくれません。

[https://docs.ruby-lang.org/ja/latest/method/CSV/i/headers.html:docs.ruby-lang.org

ヘッダの配列を取得するために、ここでは最初に1回CSV#getsを呼び出しています。

require 'csv'

CSV.open('src.csv', headers: true) do |src|
  CSV.open('dst.csv', 'w', headers: true, write_headers: true) do |dst|
    # 1行目を読み込む
    row = src.gets

    # ヘッダ行を書き込む
    dst.puts src.headers

    while row # nil だったら、ファイルの終端に達した
    # 条件に合う場合、その行を書き込む
    dst.puts row if row['max'].to_i >= 100

    # 次の行を読み込む
    row = src.gets
    end
  end
end

SQLite3

あるデータから条件にあったデータを取得するというのならば。 そもそもSQLなどを使うのがよいのでは? と思い浮かんだので書いてみました。

CSV SQLで検索すると色々出てきますが、今回はSQLite3を使うことにしました。

import

www.sqlite.org

まず、SQLite3 のコンソールを起動して動きを確認します。

$ sqlite3 temp.db
sqlite>

.modeコマンドでモードをCSVに変更し、.importコマンドでCSVファイルをテーブルにインポートします。

sqlite> .mode csv
sqlite> .import src.csv data

インポートされたデータはSQLite3のデータですので、当然SQLで検索することができます。

sqlite> select * from data;

ただし、型を指定せずにインポートしたばあい、textとして扱われてしまうため、数値として扱いたいばあい具合がよくありません。

sqlite> select typeof(max) from data limit 1;
text

テキストを数値に変換する関数が必要ですが、明確な変換関数は用意されていないようです。 調べたところround関数が目的のはたらきをしてくれることがわかりました。

www.sqlite.org

sqlite> select round('123');
123.0
sqlite> select typeof(round('123')); -- 型を確認
real
sqlite> select round('123.456');
123.0
sqlite> select round('123.456', 2); -- 桁数を指定
123.46

次のように書くことで目的を達成できました。

sqlite> select * from data where round(max) >= 100;

ヘッダ

SQLite3は、デフォルトでは結果にヘッダを出力しません。

sqlite> select 123 as value;
123

結果をCSVとして取得したいので、.headersコマンドでヘッダの出力を有効にします。

sqlite> .headers on
sqlite> select 123 as value;
value
123

SQLにまとめる

SQLite3にはファイルに出力する方法も用意されています。

www.sqlite.org

ですが今回はリダイレクトを使ってファイルに出力する方法を取ることにします。

まず、ここまでの作業をまとめたSQLファイルを作成します。

-- filtering.sql
.mode csv
.import src.csv data
.headers on
select * from data where round(max) >= 100;

これをリダイレクトでsqlite3コマンドに流し込み、結果もリダイレクトでファイルに出力します。

$ sqlite3 tmp.db < filtering.sql > dst.csv

Rubyでsqlite3コマンドを実行する

RubyからSQLite3を操作するなら、gem sqlite3 を利用するのがマットウな手段と理解していますが。

rubygems.org

手っ取り早く、sqlite3コマンドを実行することで解決してしまいます。

require 'tempfile'

src_filename = 'src.csv'
dst_filename = 'dst.csv'

sql = <<~SQL
.mode csv
.import #{src_filename} data
.headers on
select * from data where round(max) >= 100;
SQL

Tempfile.open do |tempfile|
  IO.popen("sqlite3 #{tempfile.path}", 'r+') do |io|
    io.write sql
    io.close_write

    File.open(dst_filename, 'w') do |dst_file|
      IO.copy_stream(io, dst_file)
    end
  end
end

データベースのファイルをテンポラリで指定し、IO.popensqlite3コマンドを実行します。 これでsqlite3コマンドを実行しているサブプロセスの標準入出力がioにつながった状態になります。

docs.ruby-lang.org

次に用意したSQLを流し込み、入力側を閉じます。

出力先のファイルを書き込みモードで開き、IO.copy_streamでサブプロセスから出力された内容をすべて書き込みます。

docs.ruby-lang.org

ここまで書いておいてなんですが。 どうしてもRubyから実行したいという理由がないかぎりは、シェルスクリプトなどで書いた方が早いですね。

いつか読むはずっと読まない:失敗の恐れ

さいころの経験が影響しているのか。 この年齢になっても失敗を恐ろしく感じることが多々あります。

失敗せずに済むのなら…といつも思いますが、失敗なく済ませられるほど世の中は簡単ではなく。

感情として怖いのはしかたがないとして。 失敗を糧にする心構えだけは、持っておきたいものです。

日ごとの更新件数をEctoで集計したいとき

日ごとの更新件数をEctoで集計したいとき

データベースへは時刻(日時)で記録しておき、利用するときに日付単位で集計したいときがあります。

例えば更新の頻度を日付単位で集計したいばあい、次のような SQL になると思います。

select
  date(updated_at) as updated_date,
  count(1)
from
  items
group by
  updated_date
order by
  updated_date

これを Ecto で実現したい、というのが今回のテーマです。

ちなみに補足をしておくと。 Ecto はデータベース操作のための Elixir のライブラリです。 ですので今回も Elixir の話題です。 念のため。

前提

ここで Repoリポジトリモジュール、Itemitems テーブルのスキーマモジュール、Ecto.Query モジュールは import 済みとします。

hexdocs.pm

hexdocs.pm

hexdocs.pm

日付のみを取得する

まず、データベース上で日付のみを取得したいので、時刻から日付を取得するデータベースの関数 date を利用することが前提になります。

データベースの関数を利用するには、記述した SQL 片を直接データベースに適用する Ecto.Query.API.fragment/1 を利用します。

hexdocs.pm

from(
  i in Item,
  select: fragment("date(?) as updated_date", i.updated_at)
)
|> Repo.all()

生成される SQL は次のようになります(読みやすいように整形しています)。

SELECT
  date(i0."updated_at") as updated_date
 FROM
   "items" AS i0

頻度を数える

頻度を得るためには group by を利用しますが、group_by: "updated_date"group_by: i.updated_date とは記述できません。 updated_dateSQL に「直書き」された名前なので、参照するばあいにも fragment を使う必要があります。

from(
  i in Item,
  group_by: fragment("updated_date"),
  select: %{
    updated_date: fragment("date(?) as updated_date", i.updated_at),
    count: count(1)
  }
)
|> Repo.all()

生成される SQL を確認すると、最初に書いた SQL と同じ構造になっていることがわかります。 これでまずは目的を達成することができました。

SELECT
  date(i0."updated_at") as updated_date,
  count(1)
FROM
  "items" AS i0
GROUP BY
  updated_date

…できましたが。 クエリとして融通が効きませんし、SQL 片を直接組み立てているので危うさがあります。

サブクエリを使う

もっとよい方法がないかと Elixir Forum を探した結果、サブクエリ Ecto.Query.subquery/2を使う方法に行き当たりました。

elixirforum.com

hexdocs.pm

まず、日付だけを抽出するクエリを組み立てます。 このとき select のパラメータはマップ形式にしてキーで値を引けるようにしておきます。

dates =
  from(
    i in Item,
    select: %{updated_date: fragment("date(?)", i.updated_at)}
  )

日付のクエリを subquery を使って頻度を取得するクエリに埋め込みます。 日付の値をキーで取得できるようにしておいたので、新しいクエリの中でもそのキーを利用することができます。

from(
  d in subquery(dates),
  group_by: d.updated_date,
  select: %{
    updated_date: d.updated_date,
    count: count(1)
  }
)
|> Repo.all()

生成される SQL も、当然ですが、サブクエリで構築されています。

SELECT
  s0."updated_date",
  count(1)
FROM
  (
    SELECT
      date(si0."updated_at") AS "updated_date"
     FROM
      "items" AS si0
  ) AS s0
GROUP BY
  s0."updated_date"

今回のケースではサブクエリを使うまでもないかもしれませんが、複数の手段を用意しておいて使い分けできるとよさそうです。

いつか読むはずっと読まない:没後20年の遺作

ティーブン・ジェイ・グールドが2002年に亡くなられて20年近くが経ちましたが。 亡くなる直前に刊行された書籍の邦訳が今秋刊行されました。

実は。 グールド最後のエッセイ集を、読んでしまうのが惜しいと、手をつけられずにいます。

ですが。 これを機に、ページを開こうかと思います。

選択したファイルをプレビュー表示する

ファイルをアップロードするときに利用する input タグ <input type="file"> ですが、ファイルを選択した時点でブラウザ上で画像データを取得することできることを利用してプレビュー表示させる方法です。

過去に使ったことがあったんですが、件のコードにしか情報がなかったので、あらためてブログに記録です。

アイディアとしては、ファイルが選択されたときの change イベントの中で、選択されたファイルを FileReader で読み出し、最終的に Base64エンコードして img タグに放り込む、というものです。

readAsArrayBuffer 関数は非同期なので、完了時に呼び出されるハンドラ onload 内に読み出したデータの処理を記述しています。

データの変換がなかなかケイオスなので、もう少しどうにかならないかという思いがあるところ。

<!DOCTYPE html>
<html>
  <body>
    <div>
      <input type="file" id="file-input">
    </div>

    <div>
      <img id="preview-image">
    </div>

    <script>
      const fileInput = document.getElementById('file-input')
      const previewImage = document.getElementById('preview-image')

      fileInput.addEventListener('change', (event) => {
        const reader = new window.FileReader()
        const file = event.target.files[0]

        reader.onload = (readerEvent) => {
          const data = new Uint8Array(readerEvent.target.result)

          const chunkSize = 0x8000;
          const chars = [];
          for (let i = 0; i < data.length; i += chunkSize) {
            chars.push(String.fromCharCode.apply(null, data.subarray(i, i + chunkSize)));
          }
          const encodedData = window.btoa(chars.join(''))

          previewImage.src = `data:${file.type};base64,${encodedData}`
        };

        reader.readAsArrayBuffer(file)
      })
    </script>
  </body>
</html>

Phoenix 1.6 と HEEx

先の日曜日、2021-09-26 に Phoenix 1.6 がリリースされました!

www.phoenixframework.org

…が、なぜかブログ記事の日付は August 26th, 2021 。

今回も目玉はいくつかあるのですが、その中でも気になる存在が HEEx 。

新しく追加されたシジル ~H を使ってコンポーネントを定義することができます。

lib/my_app_web/views/page_view.ex

defmodule MyAppWeb.PageView do
  use MyAppWeb, :view

  def greeting(assigns) do
    ~H"""
      Hello <%= @target %>
    """
  end
end

テンプレートファイルも、拡張子 .heex が標準になっていて、上で定義したコンポーネントを利用できるようになっています。

lib/my_app_web/templates/page/index.html.heex

<.greeting target="world" />

またパラメータも <%= %> に代わる構文として { } で記述することができるようになりました。

lib/my_app_web/controllers/page_controller.ex

defmodule MyAppWeb.PageController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> assign(:name, "Phoenix")
    |> render("index.html")
  end
end
<.greeting target={@name} />

…ただ、個人的には。 テンプレートがコードに混ざる構文はいまひとつ気になるところ。

このところ仕事が佳境で時間が取れていないのですが、追っていろいろと試してみようと思います。