manipulate nested resouces in phoenix

July 10, 2017    Elixir Phoenix Ecto

Manipulate Nested resources over the Parent

開発している時に親リソースからone-to-manyの関係にある子リソースを変更するという ネストしたリーソスの集合体として親リソースをupdateすることで一回のリクエストで擬似的に一つのリソースを更新しているという手法を取ることはapplication開発の中で当たり前のこととなっていると思います。

今回はElixirのWeb FrameworkであるPhoenixでそのようなことを実現しようと思ったらどうしたらいいか試してみました。

キーワードはbuild_assoc, put_assoc/4, cast_assoc/3, preload, schema, map, :on_replace


バージョン情報

  • Elixir: 1.4.2
  • Phoenix: 1.2.1
  • Ecto: 2.1.4

今回は既に親がある状態からネストしたリーソスを増やしたり更新したりする場合を試してみました。 登場してくるモデルはwhiteboardsticker  

whiteboardにstickerをペタペタ貼っていくイメージでwhiteboardのリソースにCRUDの操作を行うことでstickerのリソースを操作していきます。

defmodule Concertrip.Whiteboard do
  use Concertrip.Web, :model

  schema "whiteboards" do
    has_many :stickers, Concertrip.Sticker

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [])
  end
end

defmodule Concertrip.Sticker do
  use Concertrip.Web, :model

  schema "stickers" do
    field :url, :string
    field :title, :string
    belongs_to :whiteboard, Concertrip.Whiteboard

    timestamps()
  end

  @required_params ~w(url title)a

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, @required_params)
    |> validate_required(@required_params)
  end
end

お互いのモデルからわかるようにwhiteboardとstickerはont-to-manyの関係になっています。 DBの観点から2つのモデルを見ると別のテーブルになっているレコードを一回のリクエストで操作することになるのでデータ整合性を考慮してトランザクションの中で更新されるようにしないといけません。Railsではnested_attributesという特異メソッドをActive Recordに定義してどんなモデルからでもトランザクション内で更新をかけられるようになっています。 Phoenixの場合はEcto1というモジュールがDB周りの関数を提供しています。(今回の場合,正確にはEcto.Changesetの中に定義されている関数です。)

その中にput_assoc, build_assoc そしてcast_assocという関数がありDB操作をするときにassociatonを考慮したschemaを生成することが出来ます。

Phoenixはモデル内で利用されるデータ形式はEcto.Schemaという構造体がメインになりそうです。schemaless changesetとしてkeyword, list, mapなどが使われるケースもあるみたいですが、その違いを意識するとPhoenixを使うのがわかりやすくなるかと思います。

build_assocは一つのnestしたリソースに対してChangeset Schemaを引数にとりassociationを操作することが出来ます。 put_assocはChangesetのSchemaを第三引数に取るように作られているので、外部から来たmapをそのまま使うことはできません。

どの関数を使うべきかはEctoのドキュメントやElixirの作者José Valimによって書かれた記事2があるので詳し事を知りたい人は御覧ください。

今回はcast_assocという関数がstickerリソースを作成するのも更新するのも適しているという風に感じたので、それを使うことにします。

上で少し触れましたがcast_assocの使いみちとしては外部から来たリクエストのパラメータをそのまま利用して、一気にネストしたリソースを操作する用途があるようです。

先にcast_assocの振る舞いを載せておくと以下のようになります。

Once cast_assoc/3 is called, Ecto will compare those parameters with the addresses already associated with the user and act as follows:
  • If the parameter does not contain an ID, the parameter data will be passed to changeset/2 with a new struct and become an insert operation
  • If the parameter contains an ID and there is no associated child with such ID, the parameter data will be passed to changeset/2 with a new struct and become an insert operation
  • If the parameter contains an ID and there is an associated child with such ID, the parameter data will be passed to changeset/2 with the existing struct and become an update operation
  • If there is an associated child with an ID and its ID is not given as parameter, the :on_replace callback for that association will be invoked (see the “On replace” section on the module documentation)

web/models/whiteboard.exにcast_assocを追加してみましょう。

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [])
+   |> cast_assoc(:stickers, required: true)
  end

その後はiexを使って擬似的にリクエストを送ってみたいと思います. それぞれaliasを貼って使いやすくしたいと思います。

(primary) eiji@/Users/eiji/Coding/FrameWorks/Phoenix/concertrip 8:37PM::> iex -S mix phoenix.server
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Failed to check for new Hex version
{:failed_connect, [{:to_address, {'repo.hex.pm', 443}}, {:inet, [:inet], :nxdomain}]}
[info] Running Concertrip.Endpoint with Cowboy using http://localhost:4000
Interactive Elixir (1.4.2) - press Ctrl+C to exit (type h() ENTER for help)

iex(2)> alias Concertrip.Whiteboard
Concertrip.Whiteboard
iex(3)> alias Concertrip.Sticker
Concertrip.Sticker
iex(4)> alias Concertrip.Repo
Concertrip.Repo

まずはWhitebaordだけの状態をチェックしてみます。

iex(5)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers)
[debug] QUERY OK source="whiteboards" db=16.0ms decode=6.1ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]
[debug] QUERY OK source="stickers" db=23.7ms
SELECT s0."id", s0."url", s0."title", s0."whiteboard_id", s0."inserted_at", s0."updated_at", s0."whiteboard_id" FROM "stickers" AS s0 WHERE (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]

%Concertrip.Whiteboard{__meta__: #Ecto.Schema.Metadata<:loaded, "whiteboards">,
 id: 1, inserted_at: ~N[2017-07-10 10:04:28.094042],
 stickers: [], updated_at: ~N[2017-07-10 10:04:28.094048]}

stickersの中身はないです。

外部からのパラメータをセットします。外部から来るパラメータはChangeset Schemaではなくmapのデータ形式で受け取ります。今回は複数のリソースを用意しました。

iex(7)> params = %{stickers: [%{url: "https://blog.m346.info", title: "sourceiji"}, %{url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/", title: "Working with Ecto associations and embeds"}]}

%{stickers: [%{title: "sourceiji", url: "https://blog.m346.info"},
   %{title: "Working with Ecto associations and embeds",
     url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"}]}

iex(8)> Whiteboard |> Repo.get(1) |> Whiteboard.changeset(params) |> Repo.update!
[debug] QUERY OK source="whiteboards" db=31.0ms decode=0.1ms queue=0.3ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]

** (RuntimeError) attempting to cast or change association `stickers` from `Concertrip.Whiteboard` that was not loaded. Please preload your associations before manipulating them through changesets
    (ecto) lib/ecto/changeset/relation.ex:66: Ecto.Changeset.Relation.load!/2
    (ecto) lib/ecto/changeset.ex:690: Ecto.Changeset.cast_relation/4

associationを予めリロードしないと行けないみたいです。

おそらくですがPhoenixではlazy loadをパフォーマンスの観点からサポートしていないのでeager loadして受け取ったDB内の値をパラメータと比較するということをしているのではないでしょうか?

ということでpreloadして操作をすると以下のようになります。

iex(8)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers) |> Whiteboard.changeset(params) |> Repo.update!
[debug] QUERY OK source="whiteboards" db=3.7ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]
[debug] QUERY OK source="stickers" db=4.3ms queue=0.1ms
SELECT s0."id", s0."url", s0."title", s0."whiteboard_id", s0."inserted_at", s0."updated_at", s0."whiteboard_id" FROM "stickers" AS s0 WHERE (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]
[debug] QUERY OK db=0.5ms
begin []
[debug] QUERY OK db=14.9ms
INSERT INTO "stickers" ("title","url","whiteboard_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["sourceiji", "https://blog.m346.info", 1, {{2017, 7, 10}, {12, 44, 25, 744137}}, {{2017, 7, 10}, {12, 44, 25, 744143}}]
[debug] QUERY OK db=21.1ms
INSERT INTO "stickers" ("title","url","whiteboard_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["Working with Ecto associations and embeds", "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/", 1, {{2017, 7, 10}, {12, 44, 25, 766843}}, {{2017, 7, 10}, {12, 44, 25, 766858}}]
[debug] QUERY OK db=62.8ms
commit []

%Concertrip.Whiteboard{__meta__: #Ecto.Schema.Metadata<:loaded, "whiteboards">,
 id: 1, inserted_at: ~N[2017-07-10 10:04:28.094042],
 stickers: [%Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 1, inserted_at: ~N[2017-07-10 12:44:25.744137], title: "sourceiji",
   updated_at: ~N[2017-07-10 12:44:25.744143], url: "https://blog.m346.info",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1},
  %Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 2, inserted_at: ~N[2017-07-10 12:44:25.766843],
   title: "Working with Ecto associations and embeds",
   updated_at: ~N[2017-07-10 12:44:25.766858],
   url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1}], updated_at: ~N[2017-07-10 10:04:28.094048]}

見事に2つのネストしたリソースが作成されているのがわかります。

次にparams1ではupdateの操作をしたいのでidをパラメータに含めます。1つめのurlに”articles”と付け加えて変更してみます。

iex(9)> params1 = %{stickers: [%{id: 1, url: "https://blog.m346.info/articles", title: "sourceiji"}, %{id: 2, url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/", title: "Working with Ecto associations and embeds"}]}

%{stickers: [%{id: 1, title: "sourceiji",
     url: "https://blog.m346.info/articles"},
   %{id: 2, title: "Working with Ecto associations and embeds",
     url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"}]}

iex(10)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers) |> Whiteboard.changeset(params1) |> Repo.update!
[debug] QUERY OK source="whiteboards" db=3.1ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]
[debug] QUERY OK source="stickers" db=7.0ms queue=0.1ms
SELECT s0."id", s0."url", s0."title", s0."whiteboard_id", s0."inserted_at", s0."updated_at", s0."whiteboard_id" FROM "stickers" AS s0 WHERE (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]
[debug] QUERY OK db=0.3ms
begin []
[debug] QUERY OK db=3.5ms
UPDATE "stickers" SET "url" = $1, "updated_at" = $2 WHERE "id" = $3 ["https://blog.m346.info/articles", {{2017, 7, 10}, {12, 45, 35, 576360}}, 1]
[debug] QUERY OK db=1.4ms
commit []

%Concertrip.Whiteboard{__meta__: #Ecto.Schema.Metadata<:loaded, "whiteboards">,
 id: 1, inserted_at: ~N[2017-07-10 10:04:28.094042],
 stickers: [%Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 1, inserted_at: ~N[2017-07-10 12:44:25.744137], title: "sourceiji",
   updated_at: ~N[2017-07-10 12:45:35.576360],
   url: "https://blog.m346.info/articles",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1},
  %Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 2, inserted_at: ~N[2017-07-10 12:44:25.766843],
   title: "Working with Ecto associations and embeds",
   updated_at: ~N[2017-07-10 12:44:25.766858],
   url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1}], updated_at: ~N[2017-07-10 10:04:28.094048]}

見事変更されています。

次はinsertとupdateを同時に行います。 1つめのurlを元に戻して、3つ目にidの持っていないmapをつけくわえます。

iex(11)> params2 = %{stickers: [%{id: 1, url: "https://blog.m346.info", title: "sourceiji"}, %{id: 2, url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/", title: "Working with Ecto associations and embeds"}, %{url: "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3", title: "Ecto.Changeset-Ecto v2.1.4"}]}

%{stickers: [%{id: 1, title: "sourceiji", url: "https://blog.m346.info"},
   %{id: 2, title: "Working with Ecto associations and embeds",
     url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"},
   %{title: "Ecto.Changeset-Ecto v2.1.4",
     url: "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3"}]}

iex(13)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers) |> Whiteboard.changeset(params2) |> Repo.update!
[debug] QUERY OK source="whiteboards" db=2.3ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]
[debug] QUERY OK source="stickers" db=1.5ms queue=0.1ms
SELECT s0."id", s0."url", s0."title", s0."whiteboard_id", s0."inserted_at", s0."updated_at", s0."whiteboard_id" FROM "stickers" AS s0 WHERE (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]
[debug] QUERY OK db=0.1ms
begin []
[debug] QUERY OK db=1.6ms
UPDATE "stickers" SET "url" = $1, "updated_at" = $2 WHERE "id" = $3 ["https://blog.m346.info", {{2017, 7, 10}, {12, 48, 3, 525591}}, 1]
[debug] QUERY OK db=1.0ms
INSERT INTO "stickers" ("title","url","whiteboard_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["Ecto.Changeset-Ecto v2.1.4", "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3", 1, {{2017, 7, 10}, {12, 48, 3, 527535}}, {{2017, 7, 10}, {12, 48, 3, 527541}}]
[debug] QUERY OK db=2.4ms
commit []

%Concertrip.Whiteboard{__meta__: #Ecto.Schema.Metadata<:loaded, "whiteboards">,
 id: 1, inserted_at: ~N[2017-07-10 10:04:28.094042],
 stickers: [%Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 1, inserted_at: ~N[2017-07-10 12:44:25.744137], title: "sourceiji",
   updated_at: ~N[2017-07-10 12:48:03.525591], url: "https://blog.m346.info",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1},
  %Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 2, inserted_at: ~N[2017-07-10 12:44:25.766843],
   title: "Working with Ecto associations and embeds",
   updated_at: ~N[2017-07-10 12:44:25.766858],
   url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1},
  %Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 3, inserted_at: ~N[2017-07-10 12:48:03.527535],
   title: "Ecto.Changeset-Ecto v2.1.4",
   updated_at: ~N[2017-07-10 12:48:03.527541],
   url: "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1}], updated_at: ~N[2017-07-10 10:04:28.094048]}

最後に注意点です。ドキュメントにも書いてあるように,既にassociationが形成されていてパラメータの中に相当するIDを持ったmapがない時に:on_replaceというactionが走ります。

:on_replaceはデフォルトでraiseが設定されおり以下のようなエラーメッセジが吐かれます。

iex(14)> params3 = %{stickers: [%{url: "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3", title: "Ecto.Changeset-Ecto v2.1.4"}]}

%{stickers: [%{title: "Ecto.Changeset-Ecto v2.1.4",
     url: "https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3"}]}

iex(15)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers) |> Whiteboard.changeset(params3) |> Repo.update!
[debug] QUERY OK source="whiteboards" db=2.0ms
SELECT w0."id", w0."room_id", w0."inserted_at", w0."updated_at" FROM "whiteboards" AS w0 WHERE (w0."id" = $1) [1]
[debug] QUERY OK source="stickers" db=0.9ms queue=0.1ms
SELECT s0."id", s0."url", s0."title", s0."whiteboard_id", s0."inserted_at", s0."updated_at", s0."whiteboard_id" FROM "stickers" AS s0 WHERE (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]

** (RuntimeError) you are attempting to change relation :stickers of
Concertrip.Whiteboard, but there is missing data.

If you are attempting to update an existing entry, please make sure
you include the entry primary key (ID) alongside the data.

If you have a relationship with many children, at least the same N
children must be given on update. By default it is not possible to
orphan embed nor associated records, attempting to do so results in
this error message.

If you don't desire the current behavior or if you are using embeds
without a primary key, it is possible to change this behaviour by
setting `:on_replace` when defining the relation. See `Ecto.Changeset`'s
section on related data for more info.

    (ecto) lib/ecto/changeset/relation.ex:176: Ecto.Changeset.Relation.on_replace/2
    (ecto) lib/ecto/changeset/relation.ex:299: Ecto.Changeset.Relation.reduce_delete_changesets/5
    (ecto) lib/ecto/changeset.ex:691: Ecto.Changeset.cast_relation/4

他にも:mark_as_invalide,:nilify,update(has_oneの時のみ),それから:deleteなどパラメータの中にないIDをもったリソースのレコードの振る舞いが記述できます。

  schema "whiteboards" do
    has_many :stickers, Concertrip.Sticker, on_replace: :mark_as_invalid

    timestamps()
  end
Assocs, embeds and on replace

Using changesets you can work with associations as well as with embedded structs. Sometimes related data may be replaced by incoming data and by default Ecto won’t allow such. Such behaviour can be changed when defining the relation by setting :on_replace option in your association/embed definition according to the values below:

options
  • :raise (default) - do not allow removing association or embedded data via parent changesets
  • :mark_as_invalid - if attempting to remove the association or embedded data via parent changeset - an error will be added to the parent changeset, and it will be marked as invalid
  • :nilify - sets owner reference column to nil (available only for associations)
  • :update - updates the association, available only for has_one and belongs_to. This option will update all the fields given to the changeset including the id for the association
  • :delete - removes the association or related data from the database. This option has to be used carefully

preloadしなきゃいけない点や受け取るパラメータは毎回、全てのリソースという設計が少しトリッキーだと感じています。

特に後者の挙動はphoenixで生成されるtemplateで生成されるページはinput_forヘルパー関数でパラメータが渡ってくると思うのですが、react&reduxやangularなどフロントエンドのフレームワークを利用していて必要最小限のパラメータを送る場合は上手く行かないという事に陥りそうだと感じています。

自分でMultiを使ってtransaction内で処理するか、毎回全てパラメータに載せるかといった具合でしょうか?

今後はフレームワークの思想にまだ疎いのでslackに投げて確認してみようかなというような感じです。



comments powered by Disqus