Skip to content

Commit

Permalink
Reset belongs_to association if foreign key update results in a misma…
Browse files Browse the repository at this point in the history
…tch (#4299)
  • Loading branch information
greg-rychlewski authored Oct 16, 2023
1 parent 2f97643 commit d8ede0c
Show file tree
Hide file tree
Showing 3 changed files with 49 additions and 14 deletions.
2 changes: 2 additions & 0 deletions lib/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,8 @@ defmodule Ecto do
"""
@spec reset_fields(Ecto.Schema.t(), list()) :: Ecto.Schema.t()
def reset_fields(struct, []), do: struct

def reset_fields(%{__struct__: schema} = struct, fields) do
default_struct = schema.__struct__()
default_fields = Map.take(default_struct, fields)
Expand Down
46 changes: 32 additions & 14 deletions lib/ecto/repo/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,8 @@ defmodule Ecto.Repo.Schema do
assoc_opts = assoc_opts(assocs, opts)
user_changeset = run_prepare(changeset, prepare)

{changeset, parents, children} = pop_assocs(user_changeset, assocs)
changeset = process_parents(changeset, user_changeset, parents, adapter, assoc_opts)
{changeset, parents, children, _} = pop_assocs(user_changeset, assocs)
changeset = process_parents(changeset, user_changeset, parents, [], adapter, assoc_opts)

if changeset.valid? do
embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :insert)
Expand Down Expand Up @@ -439,8 +439,8 @@ defmodule Ecto.Repo.Schema do
assoc_opts = assoc_opts(assocs, opts)
user_changeset = run_prepare(changeset, prepare)

{changeset, parents, children} = pop_assocs(user_changeset, assocs)
changeset = process_parents(changeset, user_changeset, parents, adapter, assoc_opts)
{changeset, parents, children, reset_parents} = pop_assocs(user_changeset, assocs)
changeset = process_parents(changeset, user_changeset, parents, reset_parents, adapter, assoc_opts)

if changeset.valid? do
embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :update)
Expand Down Expand Up @@ -865,29 +865,46 @@ defmodule Ecto.Repo.Schema do
end

defp pop_assocs(changeset, []) do
{changeset, [], []}
{changeset, [], [], []}
end

defp pop_assocs(%{changes: changes, types: types} = changeset, assocs) do
{changes, parent, child} =
Enum.reduce assocs, {changes, [], []}, fn assoc, {changes, parent, child} ->
defp pop_assocs(%{changes: changes, types: types, data: data} = changeset, assocs) do
{changes, parent, child, reset} =
Enum.reduce(assocs, {changes, [], [], []}, fn assoc, {changes, parent, child, reset} ->
case changes do
%{^assoc => value} ->
changes = Map.delete(changes, assoc)

case types do
%{^assoc => {:assoc, %{relationship: :parent} = refl}} ->
{changes, [{refl, value} | parent], child}
{changes, [{refl, value} | parent], child, reset}

%{^assoc => {:assoc, %{relationship: :child} = refl}} ->
{changes, parent, [{refl, value} | child]}
{changes, parent, [{refl, value} | child], reset}
end

%{} ->
{changes, parent, child}
with %{^assoc => {:assoc, %{relationship: :parent} = refl}} <- types,
true <- reset_parent?(changes, data, refl) do
{changes, parent, child, [assoc | reset]}
else
_ -> {changes, parent, child, reset}
end
end
end
end)

{%{changeset | changes: changes}, parent, child}
{%{changeset | changes: changes}, parent, child, reset}
end

defp reset_parent?(changes, data, assoc) do
%{field: field, owner_key: owner_key, related_key: related_key} = assoc

with %{^owner_key => owner_value} <- changes,
%{^field => %{^related_key => related_value}} when owner_value != related_value <- data do
true
else
_ -> false
end
end

# Don't mind computing options if there are no assocs
Expand All @@ -897,14 +914,15 @@ defmodule Ecto.Repo.Schema do
Keyword.take(opts, [:timeout, :log, :telemetry_event, :prefix])
end

defp process_parents(changeset, user_changeset, assocs, adapter, opts) do
defp process_parents(changeset, user_changeset, assocs, reset_assocs, adapter, opts) do
%{changes: changes, valid?: valid?} = changeset

# Even if the changeset is invalid, we want to run parent callbacks
# to collect feedback. But if all is ok, still return the user changeset.
case Ecto.Association.on_repo_change(changeset, assocs, adapter, opts) do
{:ok, struct} when valid? ->
changes = change_parents(changes, struct, assocs)
struct = Ecto.reset_fields(struct, reset_assocs)
%{changeset | changes: changes, data: struct}

{:ok, _} ->
Expand Down
15 changes: 15 additions & 0 deletions test/ecto/repo/belongs_to_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -481,4 +481,19 @@ defmodule Ecto.Repo.BelongsToTest do
loaded = put_in schema.__meta__.state, :loaded
TestRepo.insert!(loaded)
end

test "reset assoc when foreign key update results in a mismatch" do
schema = TestRepo.insert!(%MySchema{assoc_id: 1, assoc: %MyAssoc{id: 1}})
assert schema.assoc_id == 1
assert %MyAssoc{id: 1} = schema.assoc

updated_schema =
schema
|> Ecto.Changeset.change(%{assoc_id: 2})
|> TestRepo.update!()


assert updated_schema.assoc_id == 2;
assert %Ecto.Association.NotLoaded{} = updated_schema.assoc
end
end

0 comments on commit d8ede0c

Please sign in to comment.