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

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

Elixir の構造体を JSON データからデシリアライズする

過去のブログ記事を検索してみても、たびたびシリアライズやデシリアライズと格闘していることが伺えます。

見れば 2018 年にも Poison パッケージを使った Elixir の構造体のシリアライズ/デシリアライズを試みていました。

主流は Posion から Jason になり、Elixir 1.18 からは JSON モジュールが標準装備されました。

Posion がデシリアライズしたデータを指定した構造体に格納する仕組みを用意しているのに対し、JSON モジュールはシンプルなエンコードとデコードを提供するのみ。

シンプルな JSON モジュールが装備された今、それを使って構造体のシリアライズとデシリアライズをどう実装するか、考えてみました。

構造体はマップである

知られているように、Elixir の構造体はあるルールに従ったマップです。

defmodule MyApp.Data do
  defstruct [:name, :value]
end
iex> %MyApp.Data{} |> Map.keys()
[:name, :value, :__struct__]

iex> %MyApp.Data{} |> Map.values()
[nil, nil, MyApp.Data]

iex> %MyApp.Data{} |> Map.to_list()
[name: nil, value: nil, __struct__: MyApp.Data]

: __struct__ というキーにモジュール名を格納しているにすぎません。 なので :__struct__ を指定してマップを作ると構造体の値になります。

iex> %{__struct__: MyApp.Data, name: "Foo", value: 42}
%MyApp.Data{name: "Foo", value: 42}

マップは JSON モジュールで encode / decode が可能です。

iex> %{name: "Foo", value: 42} |> JSON.encode!()
"{\"name\":\"Foo\",\"value\":42}"

iex> %{name: "Foo", value: 42} |> JSON.encode!() |> JSON.decode!()
%{"name" => "Foo", "value" => 42}

しかし構造体はそのままではエラーになってしまいます。

iex> %MyApp.Data{} |> JSON.encode!()
** (Protocol.UndefinedError) protocol JSON.Encoder not implemented for type MyApp.Data (a struct), the protocol must be explicitly implemented.

JSON モジュールには JSON.Encoder というプロトコルが用意されています。

defmodule MyApp.Data do
  @derive JSON.Encoder
  defstruct [:name, :value]
end

これを利用すれば構造体の値も encode できるようになります。

しかし、構造体としての情報は落ちてしまいます。

iex> %MyApp.Data{} |> JSON.encode!()
"{\"name\":null,\"value\":null}"

iex> %MyApp.Data{} |> JSON.encode!() |> JSON.decode!()
%{"name" => nil, "value" => nil}

なんとかずるができないか? と考えた結果、キーを文字列にしてしまえば普通のマップの扱いになるはず、ということに思い至りました。 すべてのキーを文字列にする必要はなく、:__struct__ だけ文字列になっていればいいはずです。

%MyApp.Data{name: "Foo", value: 42} |> Map.to_list() |> Map.new(fn
  {:__struct__, module} -> {"__struct__", module}
  kv -> kv
end)
#=> %{:name => "Foo", :value => 42, "__struct__" => MyApp.Data}

うまく行った気配です。

Map.new/2 も構造体の値を受け付けないので、Map.to_list/1 でタプルのリストにしてから変換しています。

このまま JSON.encode!/1 に投入します。

%MyApp.Data{name: "Foo", value: 42} |> Map.to_list() |> Map.new(fn
  {:__struct__, module} -> {"__struct__", module}
  kv -> kv
end) |> JSON.encode!()
#=> "{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}"

モジュール名の実態はアトムで、大文字から始まるアトムには内部では Elixir. という接頭辞が付いています。 これを文字列に変換したため "Elixir.MyApp.Data" という文字列で出力されました。

これを構造体の値に戻してみます。

"{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}" |> JSON.decode!()
#=> %{"__struct__" => "Elixir.MyApp.Data", "name" => "Foo", "value" => 42}

単純に decode するだけでは文字列をキーとしたマップが出力されるのみです。 キーとモジュール名をアトムに変換します。

"{\"name\":\"Foo\",\"value\":42,\"__struct__\":\"Elixir.MyApp.Data\"}" |> JSON.decode!() |> Map.new(fn
  {"__struct__", module} -> {:__struct__, String.to_existing_atom(module)}
  {key, value} -> {String.to_existing_atom(key), value}
end)
%MyApp.Data{name: "Foo", value: 42}

構造体の値に戻ってきました。

詳しく見れば、アトムは encode で文字列に変換され decode しても文字列のままであるため、このままでは完全には戻りません。 もうひと工夫が必要です。

そこまでするのであれば、このようなずるでなくきちんと定義した形式へ変換するのが正解のようです。 それでも、Elixir のデータ構造のシンプルさはとても扱いやすさを生んでいるように感じられます。

「Specification は述語である」という話

勉強不足を晒すことになりますが。 仕事で "Specification" なるオブジェクトが出てきたときに、しばらくは漠然としたイメージしか持てませんでした。

当面は直接関わることがなかったのでそのままにしていたのですが、原典に当たったことですっきりしました。

仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。

www.shoeisha.co.jp

なるほど、述語のオブジェクトということのようです。

その応用については Martin Fowler のサイトで公開されている論文に詳しく書かれています。

そこで、この論文に出てくる CompositeSpecificationRuby で書いてみました。

module CompositeSpecification
  class AndSpecification
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) && @right.satisfied_by?(object)
    end
  end

  class OrSpecfication
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) || @right.satisfied_by?(object)
    end
  end

  class NotSpecification
    include CompositeSpecification

    def initialize(specification)
      @specification = specification
    end

    def satisfied_by?(object)
      !@specification.satisfied_by?(object)
    end
  end

  def and(another)
    AndSpecification.new(self, another)
  end

  def or(another)
    OrSpecfication.new(self, another)
  end

  def !
    NotSpecification.new(self)
  end
end

使い方の例です。

まず、 1 から 30 の数値のうち、仕様を満たす値のみを出力する関数を用意します。

def satisfy(spec)
  puts (1..30).select { |i| spec.satisfied_by?(i) }.join(' ')
end

次に 3 の倍数であることを FizzSpecification 、5 の倍数であることを BuzzSpecification と定義します。

require_relative './composite_specification'

class FizzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 3).zero?
  end
end

class BuzzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 5).zero?
  end
end

それぞれの Specification オブジェクトを適用してみます。

fizz_spec = FizzSpecification.new
satisfy(fizz_spec)
#=> 3 6 9 12 15 18 21 24 27 30

buzz_spec = BuzzSpecification.new
satisfy(buzz_spec)
#=> 5 10 15 20 25 30

CompositeSpecification を include したこれらの Specification は合成することができます。

FizzSpecificationBuzzSpecification を合成して、新しい Specification オブジェクトを作ります。

fizz_buzz_spec = fizz_spec.and(buzz_spec)
satisfy(fizz_buzz_spec)
#=> 15 30

どんどん合成できます。

satisfy(fizz_spec.or(buzz_spec))
#=> 3 5 6 9 10 12 15 18 20 21 24 25 27 30

satisfy(!fizz_spec.or(buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

satisfy((!fizz_spec).and(!buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

「エリック・エヴァンスのドメイン駆動設計」にはこうも書かれています。

論理プログラミングは、 「述語」と呼ばれる、独立した結合可能なルールオブジェクトの概念を提供するが、この概念をオブジェクトで完全に実装するのは面倒である。

なるほど。 それでは論理プログラミングなら簡単なのかも。

試してみます。

そんなわけで。 久々の Prolog プログラミングです。

fizz/1buzz/1 を定義して specification.prolog というファイル名で保存します。

fizz(N) :- Rem is N mod 3, Rem == 0.
buzz(N) :- Rem is N mod 5, Rem == 0.

REPL を起動します。 使うのはいつものように GNU Prolog です。

$ gprolog

プログラムを読み込みます。

| ?- [specification].       

1から30までの間で fizz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), fizz(N)), Ns).
Ns = [3,6,9,12,15,18,21,24,27,30]

1から30までの間で buzz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), buzz(N)), Ns).
Ns = [5,10,15,20,25,30]

Prolog ではコンマ( (',')/2 )は論理積を、セミコロン( (;)/2 )は論理和を表す演算子です。

ちなみに否定を表す演算子(\+)/1 です。

ですので fizz_buzz/1 はこのように定義できます。

fizz_buzz(N) :- fizz(N), buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_buzz(N)), Ns).
Ns = [15,30]

他にも:

fizz_or_buzz(N) :- fizz(N); buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_or_buzz(N)), Ns).
Ns = [3,5,6,9,10,12,15,15,18,20,21,24,25,27,30,30]
not_fizz_or_buzz(N) :- \+fizz_or_buzz(N).
| ?- findall(N, (between(1, 30, N), not_fizz_or_buzz(N)), Ns).
Ns = [1,2,4,7,8,11,13,14,16,17,19,22,23,26,28,29]

確かに合成は簡単、…というかこれは論理プログラミングそのものですね、確かに。

合成の部分に関しては。 例えば次のように演算をオブジェクトして遅延評価したいときに顔をだす構造。

module Calculable
  class Add
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval + @right.eval
    end
  end

  class Sub
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval - @right.eval
    end
  end
  
  def add(another)
    Add.new(self, another)
  end

  def sub(another)
    Sub.new(self, another)
  end
end

class Int
  include Calculable

  def initialize(n)
    @n = n
  end

  def eval
    @n
  end
end

three = Int.new(3)
two = Int.new(2)

puts (three.add(two)).sub(three.sub(two)).eval
#=> 4

これは Ruby on RailsActiveRecord のクエリメソッドで、普段からお世話になっている仕組みですね。

述語の詳細をオブジェクトで表現しなければならい状況に遭遇しないと、なぜこれが有意義なのかわかりにくいですが、よくよく調べてみると確かにそういった状況があると納得し、その解決方法の一つが Specification なのだとようやく腑に落ちたのでした。

GoogleDriveに行と列の位置を指定して値を書き込むのが面倒なのでヘッダを指定して書き込めるWriterを書いてみた

ことの起こり

"google_drive" という Ruby gem があります。

rubygems.org

これを使うと Ruby のコードから Google Drive にアクセスして操作することができます。

仕事で Google Spreadsheet に書き込みに使っているのですが、既存のコードではセルへの書き込みがべた書きになっていて使い勝手が今ひとつ。

require 'google_drive'

# ご自身のアクセスキーは Google Cloud console でご用意ください
session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('べた書きシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('べた書きシート')

worksheet[1, 1] = 'Alice'
worksheet[1, 2] = 'Bob'
worksheet[1, 3] = 'Charlie'

worksheet[2, 1] = 1
worksheet[2, 2] = 2
worksheet[2, 3] = 3

worksheet[3, 1] = 11
worksheet[3, 2] = 22
worksheet[3, 3] = 33

worksheet[4, 1] = 111
worksheet[4, 2] = 222
worksheet[4, 3] = 333

worksheet.save

これでは例えば AliceBob の間に David を追加したいとなったとき、BobCharlie の列は挿入する位置をすべてずらさなければなりません。

スプレッドシートはヘッダ行で列を特定できるのですから、挿入するときもヘッダで指定したいものです。

書き込みクラス

そんなわけで。 ヘッダで列を指定できるクラスを書いてみました。

コードは GitHub Gist にも置いてあります。

SheetWriter · GitHub

class SheetWriter
  class Row
    def initialize(writer, headers, index)
      @writer = writer
      @headers = headers
      @index = index
    end

    def []= (header, value)
      @writer[@index, @headers.index_of(header)] = value
    end
  end

  class Headers
    def initialize(writer, headers, initial_index)
      @writer = writer
      @indices = headers.zip(initial_index..).to_h
      @next_header_index = @indices.size + initial_index
      @initial_index = initial_index

      @indices.each { |header, index| writer[initial_index, index] = header }
    end

    def add(header)
      @writer[@initial_index, @next_header_index] = header
      @indices[header] = @next_header_index
      @next_header_index = @next_header_index.succ
    end

    def index_of(header)
      add(header) if !@indices.has_key?(header)

      @indices[header]
    end
  end

  def initialize(sheet, headers: [], initial_index: 1)
    @sheet = sheet
    @headers = Headers.new(self, headers, initial_index)
    @last_row_index = initial_index
  end

  def next_row
    @last_row_index = @last_row_index.succ
    Row.new(self, @headers, @last_row_index)
  end

  def []= (row_index, column_index, value)
    @sheet.append(row_index, column_index, value)
  end
end

使ってみる

require 'google_drive'
require_relative 'sheet_writer'

session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('writerを使ったシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('writerを使ったシート')

# インタフェース変換のアダプタ
class Adapter
  def initialize(worksheet); 
    @worksheet = worksheet
  end

  def append(row_index, column_index, value)
    @worksheet[row_index, column_index] = value
  end
end

writer = SheetWriter.new(Adapter.new(worksheet))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

worksheet.save

無事スプレッドシートに書き込めました。

ちなみに。 worksheet オブジェクトは []= で操作できますが、より一般的なメソッド呼び出しで操作できるようにしたいため、あえて #append で操作するように定義しています。 そのため、インタフェースを変換するためのアダプタを使っています。

特異メソッドを使って操作するオブジェクトにメソッドを直接定義するという方法もありますが、オブジェクトごとに使えるメソッドが違うことを把握するのも面倒なので、使い所は選びそうです。

def worksheet.append(row_index, column_index, value)
  self[row_index, column_index] = value
end

writer = SheetWriter.new(worksheet)

David を追加する

AliceBob の間に David を追加したい」ばあいの面倒がどのようになったか見てみます。

# ここまで上のコードと同じなので省略

# 先にヘッダの順序を指定
writer = SheetWriter.new(Adapter.new(worksheet), headers: %w(Alice David Bob Charlie))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3
row['David'] = 4 # 追加

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11
row['David'] = 44 # 追加

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222
row['David'] = 444 # 追加

worksheet.save

ヘッダの順序をあらかじめ指定しておくことで、列の順序を気にすることなくヘッダで指定した位置に値を設定することができるようになりました。

配列に出力する

#append が定義されているオブジェクトなら何にでも出力できる利点を享受してみましょう。

たとえば配列には Array#append が定義されているので、そのまま出力対象のオブジェクトに指定できます。

require_relative 'sheet_writer'

my_sheet = []

# 配列の添字は 0 始まりなので initial_index に 0 を指定
writer = SheetWriter.new(my_sheet, initial_index: 0)

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

pp my_sheet

結果。

[0, 0, "Alice", 1, 0, 1, 0, 1, "Bob", 1, 1, 2, 0, 2, "Charlie", 1, 2, 3, 2, 1, 22, 2, 2, 33, 2, 0, 11, 3, 2, 333, 3, 0, 111, 3, 1, 222]

Array#append は引数の複数の値をフラットに追加するので Enumerable#each_slice で少しみやすくします。 あわせて行と列の位置の順に並べ替えます。

pp my_sheet.each_slice(3).sort
[[0, 0, "Alice"], [0, 1, "Bob"], [0, 2, "Charlie"], [1, 0, 1], [1, 1, 2], [1, 2, 3], [2, 0, 11], [2, 1, 22], [2, 2, 33], [3, 0, 111], [3, 1, 222], [3, 2, 333]]

位置をヘッダで指定できるというだけでなく出力対象を出力操作から分離できるので、テストのときにも重宝するはずです。

実は不満なところ

インスタンス変数が多くなりました。 RowHeaders をうまく書くとそれらを減らせるのではないかという気がしています。 Ruby のブロックのしくみなどを使えばよいのかもしれません。 逆に無駄に技巧的になりすぎるだけかもしれません。

いまだ思案中。

Rubyでunfoldを書く

特定の値から出発して演算を繰り返し値の並びを出力する unfold 。 そういえば Ruby に unfold ってないんだっけ? というのが発端。

unfold とは

早い話が fold の逆です。

Elixir では Stream.unfold/2 が定義されています。

# 1 から始めて、前の値に 1 を加える
Stream.unfold(1, fn x -> {x, x + 1} end) |> Enum.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 1 から始めて、前の値を 2 倍にする
Stream.unfold(1, fn x -> {x, x * 2} end) |> Enum.take(10)
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
# フィボナッチ数列
Stream.unfold({1, 1}, fn {n1, n2} -> {n1, {n2, n1 + n2}} end) |> Enum.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Stream.unfold/2 自体は終端を定義しません。 Enum でなく Stream で定義されているのもそのためです。 Enum.take/2 などで必要な分を取り出す必要があります。

Ruby で unfold を書く(終端を条件で判断するばあい)

最初に思いつくのは、繰り返しの中で yield を使い、結果を集める方法。 しかし無制限にいつまでも繰り返すわけにはいかないので、終了条件を織り込まないとなりません。

実装するとこんな感じ。

def unfold(x)
  result = []
  loop do
    y, x = yield x
    break if x.nil?
    result.push y
  end
  result
end
# 1 から始めて、10 以下の範囲で前の値に 1 を加える
unfold(1) do |x|
  [x, x + 1] if x <= 10
end
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 1 から始めて、512 以下の範囲で前の値を 2 倍にする
unfold(1) do |x|
  [x, x * 2] if x <= 512
end
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
# 55 以下の値のフィボナッチ数列
unfold([1, 1]) do |n1, n2|
  [n1, [n2, n1 + n2]] if n1 <= 55
end
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Ruby で unfold を書く

しかし冒頭の Stream.unfold/2 の例のように決められた個数の要素を取り出すようなばあいにはこれでは不便です。

そこで「Ruby unfold」で検索すると、簡単にヒットするのがこちらのコード。

実のところ、これがほぼ正解っぽいです。

Enumerator.new のブロックの引数として渡される Enumerator::Yielder オブジェクトの存在も初めて知りました。

docs.ruby-lang.org

def unfold(x)
  Enumerator.new do |yielder|
    loop do
      y, x = yield x
      yielder << y
    end
  end
end
unfold(1) { |x| [x, x + 1] }.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
unfold(1) { |x| [x, x * 2] }.take(10)
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
unfold([1, 1]) { |x, y| [x, [y, x + y]] }.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

この実装でしたら最初に書いた終端を条件で判断するケースにも対応できます。

unfold([1, 1]) { |x, y| [x, [y, x + y]] }.take_while { _1 < 100 }
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

Elixir の Stream.unfold/2Enum.take_while/2 を使って同じように書けます。

Stream.unfold({1, 1}, fn {n1, n2} -> {n1, {n2, n1 + n2}} end) |> Enum.take_while(& &1 < 100)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

モジュールにしてみた

繰り返し利用できるようにモジュールを定義してみました。 が、今ひとつ。 定義の仕方がうまくないというのもありますが、初期値と操作の結びつきが強いので、任意の操作を受け取れるようにしても使い勝手がよくないということなのかもしれません。

module Unfoldable
  def unfold
    x = self
    Enumerator.new do |yielder|
      loop do
        y, x = yield x
        yielder << y
      end
    end
  end
end
Integer.include(Unfoldable)
1.unfold { |x| [x, x + 1] }.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
a = [1, 1]
a.extend(Unfoldable)
a.unfold { |n1, n2| [n1, [n2, n1 + n2]] }.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

クエリをオブジェクトにして ActiveRecord から分離する

仕事では Ruby on Rails を主戦場としているわけですが。 最近、クエリメソッドについて考えています。

Rails のクエリメソッドといえば「スコープ」を定義するのが常套手段です。

# app/models/user.rb
class User < ApplicationRecord
  scope :created_at_between, -> (from, to) { where(created_at: from..to) }
end
User.created_at_between('2024-11-01', '2024-11-30')

ここで、クエリメソッドがクラスと一体になっていることが便利なのか否か、このところ考えをめぐらせています。

たとえば、同じクエリメソッドを別のクラスで利用したいばあいはどのように実装するのがよいのか。

Book.created_at_between('2024-11-01', '2024-11-30')

Ruby らしい解としては、モジュールに分離して必要なクラスが mixin するのがよさそうです。

# app/models/concerns/created_at_between.rb
module CreatedAtBetween
  def created_at_between(from, to)
    where(created_at: from..to)
  end
end
# app/models/book.rb
class Book < ApplicationRecord
  extend CreatedAtBetween
end

しかし。 混ぜ合わせるのでなく、もっともっと互いに独立した関係にできないか?

検索すれば、クエリオプジェクトというパタンがすぐにヒットします。

martinfowler.com

これを Ruby に当てはめてみようと思います。

実装は、たとえばこんな感じ。

# app/models/created_at_between.rb
class CreatedAtBetween
  def initialize(from, to)
    @from = from
    @to = to
  end

  def range
    @from..@to
  end

  def apply(query)
    query.where(created_at: range)
  end
end

こんな感じで使えます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User)

#apply には ActiveRecord のクラスだけでなくリレーションも渡せます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User.limit(3))

クエリの内容が適合すれば、ActiveRecord の種類は問いません。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(Book)

別のクエリオブジェクトも作ってみましょう。

# app/models/order_by.rb
class OrderBy
  def initialize(key, direction)
    @key = key
    @direction = direction
  end

  def apply(query)
    query.order(@key => @direction)
  end
end
OrderBy.new(:age, :asc).apply(User)

#apply の結果もまたクエリなので、#reduce などで畳み込むこともできます。

criteria = [
  CreatedAtBetween.new('2024-11-01', '2024-11-30'),
  OrderBy.new(:age, :asc)
]

criteria.reduce(User) { |query, criterion| criterion.apply(query) }

複数のクエリオブジェクトを集約して、畳み込むためのインタフェースを提供するクラスも考えることができそうです。

# app/models/query.rb
class Query
  def initialize(criteria = [], criterion = nil)
    @criteria = [*criteria, *Array(criterion)]
  end

  def created_at_between(from, to)
    Query.new(@criteria, CreatedAtBetween.new(from, to))
  end

  def order_by(key, direction = 'asc')
    Query.new(@criteria, OrderBy.new(key, direction))
  end

  def apply(query)
    @criteria.reduce(query) do |query, criterion|
      criterion.apply(query)
    end
  end
end

メソッドの戻り値も同じクラスのオブジェクトなので、メソッドをチェインして呼び出せませす。

Query.new
  .created_at_between('2024-11-01', '2024-11-30')
  .order_by(:age, :asc)
  .apply(User)

メソッドを呼び出してもオブジェクトの状態は変わらず、新しいオブジェクトを作成して返すので、途中までのクエリを共用できます。

query = Query.new.created_at_between('2024-11-01', '2024-11-30')

query.order_by(:age, :asc).apply(User)
query.order_by(:title, :asc).apply(Book)

なんとなく ActiveRecord とクエリの分離ができましたが、このままだと ActiveRecord がただのデータの塊のようで Ruby っぽい感じがしません。

小さいメソッドを追加して外から見える呼び出しの方向を変えてみます。

# app/models/user.rb
class User < ApplicationRecord
  def self.match(query)
    query.apply(self)
  end
end
query = Query.new.created_at_between('2024-11-01', '2024-11-30').order_by(:age, :asc)

User.match(query)

ActiveRecord に操作が移って Ruby っぽくなったと思います。

もちろんリレーションからも利用できます。

User.where('name LIKE ?', '%A%').match(query)

さらにこのメソッドをスーパークラスで定義すれば、どの ActiveRecord からも利用できるようになるはずです。

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.match(query)
    query.apply(self)
  end
end
query = Query.new
          .created_at_between('2024-11-01', '2024-11-30')
          .order_by(:created_at, :desc)

User.match(query)
Book.match(query)

悪くなさそうです。

調べればもっと洗練された実装が見つかると思うのですが、自身の理解の最初の一歩としては悪くない感じです。

Reqのテストを書く覚書

HTTP クライアントとして Req をよく利用するのですが。

hex.pm

こういった外部の環境と接続する操作はテストが面倒なもの。

その点において Req はテストのための仕組みをパッケージ自身が提供してくれています。 その仕組みの使い方と、ちょっとした工夫の覚え書きです。

新しいプロジェクトを作って順を追って説明します。

$ mix new my_app
$ cd my_app

Req を使う

まず Req の使い方のおさらいから。

mix.exs の依存パッケージに Req を追加し、パッケージを取得します。

  # ...

  defp deps do
    [
      {:req, "~> 0.5"}
    ]
  end

  # ...
$ mix deps.get

Req を使う関数を追加します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    Req.request(url: url)
  end
end

追加した関数のテストを書きます。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

これでもテストとして実行できますが、実行するたびに指定した URL へのアクセスが発生します。 まずこれをスタブにします。

スタブを使う

Req はテストのために Req.Test というモジュールを用意しています。

hexdocs.pm

まずこのモジュールの利用を指定する設定をします。

plug: {Req.Test, MyApp} は、HTTP リクエストのアダプタとして Req.TestMyApp という名前で指定することを表しています。 指定の詳細については Req.new/1 のオプションの説明を参照してください。

設定を config/config.exs に直接書いてもよいのですが、Config.import_config/1 を使った環境ごとに分離する定石に従うことにします。

  • config/test.exs
import Config

config :my_app,
  req_options: [
    plug: {Req.Test, MyApp}
  ]
  • config/config.exs
import Config

import_config "#{config_env()}.exs"

また devprod のために config/dev.exsconfig/prod.exs も作成しておきます。 これらは空のファイルで大丈夫です。

次に MyApp.get/1 を編集して設定した内容を利用するように変更します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    [url: url]
    |> Keyword.merge(Application.get_env(:my_app, :req_options, []))
    |> Req.request()
  end
end

テストもスタブを利用するように変更します。

スタブは Req.Test.stub/2 で設定します。 第 1 引数は設定で指定した名前です。 第 2 引数は、Phoenix でもおなじみの Plug.Conn の構造体を受け取り、レスポンスを返す関数です。

ここでは Req.Test.text/2 を使ってプレーンテキストを返していますが、他にも html/2json/2 といった関数が用意されています。

また Plug.Conn.put_status/2 などの Plug の関数を利用することも可能です。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      Req.Test.stub(MyApp, fn conn ->
        Req.Test.text(conn, "Hello Req stub!")
      end)

      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、依存パッケージに Plug を追加します。

hex.pm

今回はテストでだけ利用するので [only: :test] オプションを指定しています。

  • mix.exs
  # ...

  defp deps do
    [
      {:req, "~> 0.5"},
      {:plug, "~> 1.16", only: :test}
    ]
  end

  # ...

パッケージを取得してテストを実行します。 Req.Test.text/2 で指定したテキストが返されることが確認できると思います。

$ mix deps.get
$ mix test

setup を使う

スタブを設定するコードを ExUnit.Callbacks.setup/2 に移動して、繰り返し利用できるようにします。 加えてレスポンスのステータスとテキストもテストごとに設定できるように、 @tag で指定できるようにしています。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  setup context do
    body = Map.get(context, :body, "")
    status = Map.get(context, :status, 200)

    Req.Test.stub(MyApp, fn conn ->
      conn
      |> Plug.Conn.put_status(status)
      |> Req.Test.text(body)
    end)
  end

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

Stub module を使う

setup も繰り返し利用できるように、モジュールに分離してみます。

まず、モジュールを追加して setup に書いた内容を移動します。

  • test/support/my_app_stub.ex
defmodule MyApp.Stub do
  defmacro __using__(_) do
    quote do
      setup context do
        body = Map.get(context, :body, "")
        status = Map.get(context, :status, 200)

        Req.Test.stub(MyApp, fn conn ->
          conn
          |> Plug.Conn.put_status(status)
          |> Req.Test.text(body)
        end)
      end
    end
  end
end

今回はモジュールを use することで利用できるように __using__/1マクロを利用しましたが、他にもっとよい方法があるかもしれません。

テストでは setup を削除して、追加したモジュールを use します。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  use MyApp.Stub
  doctest MyApp

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、追加したモジュールがテストのときにだけコンパイルされるようにする設定を追加します。

  • mix.exs
  # ...

  def project do
    [
      # ...
      elixirc_paths: elixirc_paths(Mix.env()),
      # ...
    ]
  end

  # ...

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  # ...

これでスタブを定義したモジュールを use するだけで繰り返しスタブが利用できるようになりました。

Elixirの関数っぽい関数でない何かと、Prologの述語っぽい述語でない何か

Canada という小さな実装のライブラリがあります。

hex.pm

Ruby でいうところの CanCanCan のような権限判定のためのライブラリなのですが、とても興味深い実装をしています。

例えば userarticleread できるか判定するとき、

can?(user, read(article))

あるいは

user |> can?(read(article))

のような書き方をするのですが、このとき read/1 という関数は定義しません。 定義する必要がありませんというのがより正しいかもしれません。

何をやっているのか、その仕組みをなぞるコードを書いて確認してみましょう。

Elixir のばあい

まず、構造体 User と Article を定義します。

defmodule User do
  defstruct [:id, :role, :name]
end
defmodule Article do
  defstruct [:user_id, :title, :body]
end

次に判定のための関数 available?/3 を用意します。 この関数は User の値と Article の値、および atom で操作を受け取り、その組み合わせで操作の可否を返します。

ここでは任意の User は Article を read でき、role が editor である User あるいは所有者である User は Article を更新でき、所有者である User は Article を削除できる、としています。 それ以外の操作はできません。

User の種類 read write delete
任意の User 不可 不可
編集者 (role = editir) 不可
所有者 (User.id = Article.user_id)
defmodule Can do
  def available?(%User{}, :read, %Article{}), do: true
  def available?(%User{role: :editor}, :update, %Article{}), do: true
  def available?(%User{id: id}, :update, %Article{user_id: id}), do: true
  def available?(%User{id: id}, :delete, %Article{user_id: id}), do: true
  def available?(%User{}, _, %Article{}), do: false

  # 後半に続く

最後に、マクロ can?/2 を定義します。 ここで第 2 引数は「関数呼び出し」を受け取るようにします。

関数を呼び出した結果ではなく、関数呼び出しそのものを受け取るという点が要点です。

マクロでは関数呼び出しは関数名と引数に分解されます。

iex> quote do: read(foo)
{:read, [], [{:foo, [], Elixir}]}

マクロの引数に関数呼び出しを渡すと、この分解された形で受け取ることになるので、分解された関数名と引数を使って available?/3 を評価します。

  # 前半からの続き

  defmacro can?(user, {action, _, [article]}) do
    quote do
      available?(unquote(user), unquote(action), unquote(article))
    end
  end
end

マクロを有効にするために import して判定をしてみます。

import Can

# 任意の User
user = %User{id: 123}

# 編集者
editor = %User{id: 234, role: :editor}

# 所有者
owner = %User{id: 345}

article = %Article{user_id: 345}

user |> can?(read(article))     #=> true
user |> can?(update(article))   #=> false
user |> can?(delete(article))   #=> false

editor |> can?(read(article))   #=> true
editor |> can?(update(article)) #=> true
editor |> can?(delete(article)) #=> false

owner |> can?(read(article))    #=> true
owner |> can?(update(article))  #=> true
owner |> can?(delete(article))  #=> true

read/1update/1delete/1 といった関数の呼び出しが現れますが、それらを呼び出した結果でなく呼び出しそのものがマクロの引数となるため、関数の定義は存在しないという興味深い実装になっています。

Canada ではさらに available?/3 に相当する部分がプロトコルで実現されているために、任意の構造体に対して判定を定義することが可能になっています。

Prolog のばあい

同じようなことを Prolog でも書いてみました。

Elixir の母体である Erlang は最初は Prolog で書かれ Prolog の影響を受けていることは知られています。 実際 Prolog で何が起こるか見てみることで、似ているところ違うところを感じてみましょう。

次のコードを can.prolog と言うファイル名で保存します。

can(user(id:_, role:_), read(article(user_id:_, title:_, body:_))) :- !.
can(user(id:_, role:editor), update(article(user_id:_, title:_, body:_))) :- !.
can(user(id:ID, role:_), update(article(user_id:ID, title:_, body:_))) :- !.
can(user(id:ID, role:_), delete(article(user_id:ID, title:_, body:_))) :- !.

GNU Prolog を起動します。

$ gprolog

Prolog のプロンプトが表示されたら ['can.prolog']. と入力してコードを読み込みます。

| ?- ['can.prolog'].
yes

Elixir で書いた時と同じように、任意の User、編集者、所有者それぞれに対して read, update, delete が可能か判定させてみます。

| ?- can(user(id: 123, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 123, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
no
| ?- can(user(id: 123, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 234, role: editor), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 234, role: editor), update(article(user_id: 345 title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 234, role: editor), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 345, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 345, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 345, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
yes

同じように判定することができました。

ここで read/1update/1delete/1 といった述語は定義していません。 加えて user/2article/3 も定義していません。 さらに言うと、Prolog には : という演算子は定義されていません。

Prolog は遅延評価であるため、明示的に評価するまで字面のまま扱われます。

そこで user(id: 123, role: reader) と言う記述は述語の定義の user(id:ID, role:_) にマッチし、変数 ID123 が束縛されます。 あとパタンマッチングによって can/2 の定義に適えば yes をそうでなければ no を返すと言うふるまいをします。

Elixir ではマクロという仕組みを使って「関数呼び出し」を引数として受け取れるようにしましたが、Prolog のばあいは逆に明示的に評価するまでは渡された引数の形のまま扱われるため、評価したときにどのような値が得られるかという定義がなくてもパタンマッチに利用できるという面白さがあります。