manipulate nested resouces in phoenix 2

July 12, 2017    Elixir Phoenix Ecto

Manipulate Nested resources over the Parent

キーワードはcast_assoc/3, preload, changeset struct, Ecto.Query, functional

前回の1記事の中で全てのassosiationをパラメータの中に入れておかないと:on_replaceが走ってしまうのがトリッキーだと感じていたのでstackoverflowやElixir Slackで聞いてみたところ以下のような回答をもらいました。

ME

Case is one_to_many and I want to insert and update necessity minimum nested resources at the same time.

According to https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3 , cast_assoc require us to update/insert nested resource with all associations in the parameter. If we don’t include all associations, cast_assoc invoke :on_replace action for the missing associations.

I think It’s OK to work with form generated by Phoenix templates because form sends all association in the parameter every time. But if this function works with JS frameworks, I can send necessity minimum associations which are changed on a web browser. In such a use case do I might as well repeat update in a transaction or do same as a template do?

I feel a little bit tricky for that behavior. And I feel it’s better to add action explicitly into the parameter instead of :on_replace What do you think about it?

REPLY

It’s not a Phoenix requirement, it’s an Ecto requirement. If Ecto does not have all of the information pertaining to your schema then it cannot reliably create an accurate changeset.

I’m not really understanding your specific question, but if you want to update the record without re-supplying associations, then create a separate changeset function that doesn’t touch the association.

ME

Yup. Exactly it’s an Ecto matter. I imagine this requirement looks redundant and creating changeset based on receiving parameter and preloaded association seems intuitive. But Ecto keeps us from doing thins so I just wonder why Ecto make us include redundant information.

REPLY

It can seem redundant at first, but keep in mind that Elixir is a functional language and can’t magically grab data from a source like, say, Rails does. If you’re generating a changeset for a record, Ecto needs to know what has changed in the association if you’re using cast_assoc, which means supplying before and after, even if they are identical. (ps. you don’t need to use cast_assoc if you don’t want to).

ここで重要なのがDBのためにchangesetを用意した時点で、その内容には何かしらのアクションが起こることを決めないと関数ではないよねということでしょう。:on_replaceでaccept(何もしない)みたいな物があればいいんですけど、何もしないはchangeしないだからそもそも含めるなと言うことでしょうか?

とうことで変更したいリソースだけchangesetに加えてあげるという方法で必要最小限の更新ができるか試してみました。 


バージョン情報

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

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

iex(5)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers)
[debug] QUERY OK source="whiteboards" db=27.1ms decode=0.1ms queue=0.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=3.4ms
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: [%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},
  %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: 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}], updated_at: ~N[2017-07-10 10:04:28.094048]}

前回作成したstickersです。このstickersの中で変更したいリソースだけpreloadすることにします。今回の例だとIDが1と3のstickerだけ取ってきます。そのためにEcto.Queryからfromマクロをimportします。

iex(6)> import Ecto.Query, only: [from: 2]
Ecto.Query
iex(7)> Whiteboard |> Repo.get(1) |> Repo.preload(stickers: (from s in Sticker, where: s.id in [1,3]))
[debug] QUERY OK source="whiteboards" db=3.2ms
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=3.6ms
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."id" IN (1,3)) AND (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: [%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},

  %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}], updated_at: ~N[2017-07-10 10:04:28.094048]}

パラメータにID:1, 3を設定して確かめてみます。

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

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

Changeset structの状態を見てみるとネストしたリソースも親リソースもvalid: trueであることがわかります。

iex(11)> Whiteboard |> Repo.get(1) |> Repo.preload(stickers: (from s in Sticker, where: s.id in [1,3])) |> Whiteboard.changeset(params2)
[debug] QUERY OK source="whiteboards" db=3.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=2.9ms
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."id" IN (1,3)) AND (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]

#Ecto.Changeset<action: nil,
 changes: %{stickers: [#Ecto.Changeset<action: :update, changes: %{},
     errors: [], data: #Concertrip.Sticker<>, valid?: true>,
    #Ecto.Changeset<action: :update,
     changes: %{url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"},
     errors: [], data: #Concertrip.Sticker<>, valid?: true>]}, errors: [],
 data: #Concertrip.Whiteboard<>, valid?: true>

改めてパラメータにIDを含め、更に新たなリソースを用意します。最後はupdateまでかけてみるとID:4のリソースと1と3のリソースが更新されました。

iex(12)> params2 = %{stickers: [%{id: 1, url: "https://blog.m346.info", title: "sourceiji"}, %{id: 3, url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"}, %{url: "foo", title: "bar"}]}

%{stickers: [%{id: 1, title: "sourceiji", url: "https://blog.m346.info"},
   %{id: 3,
     url: "http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/"},
   %{title: "bar", url: "foo"}]}
iex(13)> Whiteboard |> Repo.get(1) |> Repo.preload(stickers: (from s in Sticker, where: s.id in [1,3])) |> Whiteboard.changeset(params2) |>
...(13)> Repo.update!
[debug] QUERY OK source="whiteboards" db=0.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.0ms
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."id" IN (1,3)) AND (s0."whiteboard_id" = $1) ORDER BY s0."whiteboard_id" [1]
[debug] QUERY OK db=4.0ms
begin []
[debug] QUERY OK db=10.3ms
UPDATE "stickers" SET "url" = $1, "updated_at" = $2 WHERE "id" = $3 ["http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/", {{2017, 7, 11}, {6, 31, 35, 912063}}, 3]
[debug] QUERY OK db=9.3ms
INSERT INTO "stickers" ("title","url","whiteboard_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["bar", "foo", 1, {{2017, 7, 11}, {6, 31, 35, 930119}}, {{2017, 7, 11}, {6, 31, 35, 930130}}]
[debug] QUERY OK db=46.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: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: 3, inserted_at: ~N[2017-07-10 12:48:03.527535],
   title: "Ecto.Changeset-Ecto v2.1.4",
   updated_at: ~N[2017-07-11 06:31:35.912063],
   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: 4, inserted_at: ~N[2017-07-11 06:31:35.930119], title: "bar",
   updated_at: ~N[2017-07-11 06:31:35.930130], url: "foo",
   whiteboard: #Ecto.Association.NotLoaded<association :whiteboard is not loaded>,
   whiteboard_id: 1}], updated_at: ~N[2017-07-10 10:04:28.094048]}

ネストしたリソースを問題なく全て取得することができました。

iex(14)> Whiteboard |> Repo.get(1) |> Repo.preload(:stickers)                                                                   [debug] QUERY OK source="whiteboards" db=1.6ms
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.8ms
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: [%Concertrip.Sticker{__meta__: #Ecto.Schema.Metadata<:loaded, "stickers">,
   id: 4, inserted_at: ~N[2017-07-11 06:31:35.930119], title: "bar",
   updated_at: ~N[2017-07-11 06:31:35.930130], url: "foo",
   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-11 06:31:35.912063],
   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: 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: 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}], updated_at: ~N[2017-07-10 10:04:28.094048]}

ということでpreloadする際にパラメータからidと取ってくるような関数を書いて上げれば必要最小限の更新と作成が同時に行えるということが分かりました。

またこのことからEctoはchangesetの中に含まれているデータに対して何が起きているのかを把握する責任をもっているということがわかりました。基本的にはinputに対してどんなアクションを起こしてoutputが出てくるのかを意識してchangesetを作ると思った通りの挙動をしてくれるのかなと思いました。



comments powered by Disqus