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

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

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

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

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()