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

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

順序を入れ替えるための覚書

今回の話は次のようなことを念頭に置いています。

1の要素を3の位置に移動する

これは順序を決定する情報を各要素が持っているばあい、たとえば次のようなばあい、

その情報を入れ替えることを意味します。

順序情報をもとに並び替えると次のようになります。

Elixir で、Elixir のデータ構造を使うケースと、Ecto (データベース)を使うケースを考えます。

タプルによる実装

まず次のようなタプル表現で考えます。

[{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]

前から後ろへ移動

前から後ろへ移動するばあい、次のような結果になればよいはずです。

[{"Alice", 1}, {"Bob", 4}, {"Charlie", 2}, {"Dave", 3}, {"Erin", 5}]

もしくは

[{"Alice", 1}, {"Charlie", 2}, {"Dave", 3}, {"Bob", 4}, {"Erin", 5}]

まず、Enum.split/2 を使って順序を入れ替える部分だけを切り出します。

users = [{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]
#=> [{"Alice", 1}, {"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]

{leading, rest} = users |> Enum.split(1)
#=> {[{"Alice", 1}], [{"Bob", 2}, {"Charlie", 3}, {"Dave", 4}, {"Erin", 5}]}

{target, trailing} = rest |> Enum.split(3)
#=> {[{"Bob", 2}, {"Charlie", 3}, {"Dave", 4}], [{"Erin", 5}]}

これで target の範囲で順序情報を入れ替えればよいはずです。

次のコードでは Enum.chunk_every/3 を使って隣り合う要素の組みを作り、順序情報を隣に受け渡します。 末尾の要素の順序情報は、先頭の要素に受け渡します。

[{head_name, _} | _] = target
target
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [{_, order}, {name, _}] -> {name, order}
  [{_, order}] -> {head_name, order}
end)
#=> [{"Charlie", 2}, {"Dave", 3}, {"Bob", 4}]

この結果の前後に leadingtrailing を連結すれば完了です。

後ろから前へ移動

後ろから前へ移動するばあい、分割までは同じですが、前から後ろへ移動する場合と逆の方向で順序情報を受け渡します。 先頭の要素の順序情報は、末尾の要素に受け渡します。

[{_, head_order} | _] = target
target
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [{name, _}, {_, order}] -> {name, order}
  [{name, _}] -> {name, head_order}
end)
#=> [{"Bob", 3}, {"Charlie", 4}, {"Dave", 2}]

補足 Enum.split/2 のふるまいについて

Enum.split/2 は、分割する位置が長さよりも大きいばあいには、常に全体と空リストに分割されます。 このため指定される位置が長さを超えていてもそのまま適用するできます。

[1, 2, 3, 4, 5] |> Enum.split(4) #=> {[1, 2, 3, 4], [5]}
[1, 2, 3, 4, 5] |> Enum.split(5) #=> {[1, 2, 3, 4, 5], []}
[1, 2, 3, 4, 5] |> Enum.split(6) #=> {[1, 2, 3, 4, 5], []}
[1, 2, 3, 4, 5] |> Enum.split(7) #=> {[1, 2, 3, 4, 5], []}

負数のばあいは、末尾から分割されます。 位置が長さよりも大きいばあいと同じように、範囲外の場合に常に空リストと全体に分割されるようにするには、負数が与えられたときに 0 として扱うように調整する必要があります。

[1, 2, 3, 4, 5] |> Enum.split(1)  #=> {[1], [2, 3, 4, 5]}
[1, 2, 3, 4, 5] |> Enum.split(0)  #=> {[], [1, 2, 3, 4, 5]}
[1, 2, 3, 4, 5] |> Enum.split(-1) #=> {[1, 2, 3, 4], [5]}
[1, 2, 3, 4, 5] |> Enum.split(-2) #=> {[1, 2, 3], [4, 5]}

Ecto による実装

次に Ecto で次のようなスキーマのデータを持つばあいを考えます。

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    has_one :order, Order
  end
end
defmodule Order do
  use Ecto.Schema

  schema "orders" do
    field :priority, :integer
    belongs_to :user, User
  end
end

またデータベースには、すでに次のようにデータが格納されているとします。

from(
  user in User,
  join: order in Order,
  on: user.id == order.user_id,
  order_by: order.priority,
  select: {user.name, order.priority}
)
|> MyApp.Repo.all
#=> [{"Alice", 1}, {"Dave", 2}, {"Bob", 3}, {"Charlie", 4}, {"Erin", 5}]

前から後ろへ移動する

users =
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority
  )
  |> preload(:order)
  |> MyApp.Repo.all()

{_leading, rest} = users |> Enum.split(1)
{target, _trailing} = rest |> Enum.split(3)

[head | _] = target

target
|> Enum.map(& &1.order)
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [%{priority: priority}, order] ->
    Ecto.Changeset.change(order, %{priority: priority})

  [order] ->
    Ecto.Changeset.change(head.order, %{priority: order.priority})
end)
|> Enum.map(&Repo.update/1)

本来は複数のレコードの更新を一体の操作にするためにトランザクション管理が必要ですが、今回は順序操作が主題なので横着しています。 詳しくはドキュメントを参照してください。

後ろから前へ移動する

users =
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority
  )
  |> preload(:order)
  |> Repo.all()

{_leading, rest} = users |> Enum.split(1)
{target, _trailing} = rest |> Enum.split(3)

[head | _] = target

target
|> Enum.map(& &1.order)
|> Enum.chunk_every(2, 1)
|> Enum.map(fn
  [order, %{priority: priority}] ->
    Ecto.Changeset.change(order, %{priority: priority})

  [order] ->
    Ecto.Changeset.change(order, %{priority: head.order.priority})
end)
|> Enum.map(&Repo.update/1)

必要な範囲のデータを取得する

上の例ではタプルで操作したときと同じように、すでに全体のデータを取得する前提で操作を組み立てました。 全体のデータは不要であれば、offsetlimit を利用して最初から操作する範囲のみ取得して済ませることができます。

[head | _] = target = 
  from(
    user in User,
    join: order in Order,
    on: user.id == order.user_id,
    order_by: order.priority,
    offset: 1,
    limit: 3
  )
  |> preload(:order)
  |> Repo.all()

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 するだけで繰り返しスタブが利用できるようになりました。