Skip to content

Commit 54917ef

Browse files
committed
Scan struct data for prepare callbacks without surfacing
The prepare callback check surfaced embeds from the data on insert, building a changeset for every nested embed, and to_struct/4 then surfaced them again. Only a changeset stored in the data can have prepare callbacks, so look for those directly instead of building new ones. Any other value that surfacing would build a changeset from, such as a map, opens a transaction, so its callbacks still run inside one.
1 parent 50d6d29 commit 54917ef

2 files changed

Lines changed: 106 additions & 5 deletions

File tree

‎lib/ecto/embedded.ex‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,7 @@ defmodule Ecto.Embedded do
145145
# prepare callbacks, in which case a transaction is required.
146146
#
147147
# It must mirror the traversal done by prepare/4 below,
148-
# including surfacing embeds (but not other fields, which
149-
# are not traversed) from the data on insert.
148+
# including the embeds surfaced from the data on insert.
150149
@doc false
151150
def prepare?(changeset, embeds, repo_action) do
152151
%{changes: changes, types: types} = changeset
@@ -172,12 +171,43 @@ defmodule Ecto.Embedded do
172171

173172
defp to_struct_prepare?(%Changeset{}, :delete, _embed), do: false
174173

175-
defp to_struct_prepare?(%Changeset{data: data} = changeset, action, %{related: schema}) do
174+
defp to_struct_prepare?(%Changeset{} = changeset, action, %{related: schema}) do
175+
%{data: data, changes: changes} = changeset
176176
embeds = schema.__schema__(:embeds)
177-
changeset = maybe_surface_changes(changeset, data, embeds, action)
178-
prepare?(changeset, embeds, action)
177+
surfaces_data? = action == :insert
178+
179+
prepare?(changeset, embeds, action) or
180+
(surfaces_data? and data_prepare?(data, schema, embeds, changes))
181+
end
182+
183+
# On insert, to_struct/4 surfaces the embeds left in the data. Only a changeset
184+
# stored there can have prepare callbacks, so we look for those directly
185+
# instead of building a changeset for every embed.
186+
defp data_prepare?(data, schema, embeds, changes) do
187+
Enum.any?(embeds, fn name ->
188+
not is_map_key(changes, name) and
189+
data_value_prepare?(Map.get(data, name), schema.__schema__(:embed, name))
190+
end)
191+
end
192+
193+
defp data_value_prepare?(nil, _embed), do: false
194+
195+
defp data_value_prepare?(values, embed) when is_list(values) do
196+
Enum.any?(values, &data_value_prepare?(&1, embed))
197+
end
198+
199+
defp data_value_prepare?(%Changeset{action: action} = changeset, embed) do
200+
surfaced_changeset = %{changeset | action: action || :insert}
201+
prepare_each?(embed, surfaced_changeset, :insert)
179202
end
180203

204+
defp data_value_prepare?(%{__struct__: schema} = struct, %{related: schema}) do
205+
no_changes = %{}
206+
data_prepare?(struct, schema, schema.__schema__(:embeds), no_changes)
207+
end
208+
209+
defp data_value_prepare?(_unknown_value, _embed), do: true
210+
181211
# Callback invoked by repository to prepare embeds.
182212
#
183213
# It replaces the changesets for embeds inside changes

‎test/ecto/repo/embedded_test.exs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ defmodule Ecto.Repo.EmbeddedTest do
4242
end
4343
end
4444

45+
defmodule DeepEmbed do
46+
use Ecto.Schema
47+
48+
embedded_schema do
49+
embeds_one :embed, MyEmbed
50+
embeds_many :embeds, MyEmbed
51+
end
52+
end
53+
54+
defmodule DeepSchema do
55+
use Ecto.Schema
56+
57+
schema "deep_schema" do
58+
embeds_one :deep_embed, DeepEmbed
59+
end
60+
end
61+
4562
@uuid "30313233-3435-4637-9839-616263646566"
4663

4764
## insert
@@ -203,6 +220,9 @@ defmodule Ecto.Repo.EmbeddedTest do
203220
TestRepo.insert!(%MySchema{embed: %MyEmbed{x: "xyz"}, embeds: [%MyEmbed{x: "xyz"}]})
204221
refute_received {:transaction, _, _}
205222

223+
TestRepo.insert!(%MySchema{embed: %MyEmbed{x: "xyz", sub_embed: %SubEmbed{y: "xyz"}}})
224+
refute_received {:transaction, _, _}
225+
206226
embed =
207227
%MyEmbed{x: "xyz"}
208228
|> Ecto.Changeset.change()
@@ -292,6 +312,57 @@ defmodule Ecto.Repo.EmbeddedTest do
292312
assert_received {:prepared_in_transaction?, true}
293313
end
294314

315+
test "runs embed prepare callbacks surfaced from a map in struct data in transaction" do
316+
sub_embed = embed_changeset_with_prepare(%SubEmbed{y: "xyz"})
317+
deep_embed = %DeepEmbed{embed: %{x: "xyz", sub_embed: sub_embed}}
318+
319+
TestRepo.insert!(%DeepSchema{deep_embed: deep_embed})
320+
assert_received {:transaction, _, _}
321+
assert_received {:prepared_in_transaction?, true}
322+
end
323+
324+
test "runs embed prepare callbacks surfaced from a struct in struct data in transaction" do
325+
sub_embed = embed_changeset_with_prepare(%SubEmbed{y: "xyz"})
326+
deep_embed = %DeepEmbed{embed: %MyEmbed{x: "xyz", sub_embed: sub_embed}}
327+
328+
TestRepo.insert!(%DeepSchema{deep_embed: deep_embed})
329+
assert_received {:transaction, _, _}
330+
assert_received {:prepared_in_transaction?, true}
331+
end
332+
333+
test "runs embed prepare callbacks surfaced from a list in struct data in transaction" do
334+
embed_with_prepare = embed_changeset_with_prepare(%MyEmbed{x: "xyz"})
335+
deep_embed = %DeepEmbed{embeds: [%MyEmbed{x: "abc"}, embed_with_prepare]}
336+
337+
TestRepo.insert!(%DeepSchema{deep_embed: deep_embed})
338+
assert_received {:transaction, _, _}
339+
assert_received {:prepared_in_transaction?, true}
340+
end
341+
342+
test "runs embed prepare callbacks surfaced from a changeset without action in struct data in transaction" do
343+
sub_embed = embed_changeset_with_prepare(%SubEmbed{y: "xyz"})
344+
embed_without_action = Ecto.Changeset.change(%MyEmbed{x: "xyz", sub_embed: sub_embed})
345+
deep_embed = %DeepEmbed{embed: embed_without_action}
346+
347+
TestRepo.insert!(%DeepSchema{deep_embed: deep_embed})
348+
assert_received {:transaction, _, _}
349+
assert_received {:prepared_in_transaction?, true}
350+
end
351+
352+
test "does not run embed prepare callbacks in struct data overridden by changes" do
353+
overridden_embed = embed_changeset_with_prepare(%MyEmbed{x: "xyz"})
354+
deep_embed = Ecto.Changeset.change(%DeepEmbed{embed: overridden_embed})
355+
deep_embed = %{deep_embed | changes: %{embed: nil}}
356+
357+
%DeepSchema{}
358+
|> Ecto.Changeset.change()
359+
|> Ecto.Changeset.put_embed(:deep_embed, deep_embed)
360+
|> TestRepo.insert!()
361+
362+
refute_received {:transaction, _, _}
363+
refute_received {:prepared_in_transaction?, _}
364+
end
365+
295366
test "raises on embed changesets for another struct" do
296367
changeset = Ecto.Changeset.change(%MySchema{})
297368
embed = %Ecto.Changeset{data: %URI{}, valid?: true}

0 commit comments

Comments
 (0)