Skip to content

Do not reflect changes ignored due to :writable in returned struct#4736

Open
green-david wants to merge 6 commits into
elixir-ecto:masterfrom
green-david:dg-fix-ignored-writable-changes-in-struct
Open

Do not reflect changes ignored due to :writable in returned struct#4736
green-david wants to merge 6 commits into
elixir-ecto:masterfrom
green-david:dg-fix-ignored-writable-changes-in-struct

Conversation

@green-david

Copy link
Copy Markdown
Contributor

Oops @greg-rychlewski, I realized just slightly too late that I accidentally broke #4524 / #4525 in #4734. This PR fixes the regression and adds accompanying unit tests.

@greg-rychlewski

Copy link
Copy Markdown
Member

Wait do I understand correctly that before we were not removing the fields that should be dropped? I'm really confused why our old tests passed then

@greg-rychlewski

Copy link
Copy Markdown
Member

Oh I guess the old tests were only checking that we sent the correct fields to the write but not that we were dropping them correctly from the returned struct?

@green-david

Copy link
Copy Markdown
Contributor Author

Oh I guess the old tests were only checking that we sent the correct fields to the write but not that we were dropping them correctly from the returned struct?

Exactly! I didnt apply Jose's suggestion correctly in my last PR. We filtered them later before the write.

Comment thread test/ecto/repo_test.exs Outdated
Comment on lines 2456 to 2462

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test "update with on_writable_violation: :nothing saves changes for writable: :always and ignores changes for writable: :insert/:never" do
%{never: nil} =
%MySchemaWritable{id: 1}
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
|> TestRepo.update!()
assert_received {:update, %{changes: [always: 10]}}
end

I think this is enough and then you can also make this minimal change to the existing insert test.

@green-david

Copy link
Copy Markdown
Contributor Author

@greg-rychlewski I realized as well that even with this fix (and even prior to any of my changes I think) that non-writable changes are not reflected in the returned struct as mentioned in #4524, but that if a non-writable field has a value in the underlying data that is surfaced as a change during insert, the returned struct does reflect an updated value which I dont think should be happening?

For example, this seems to fail:

%{never: nil} = 
%SchemaWritable{never: 10}
|> Ecto.Changeset.change(%{})
|> TestRepo.insert!()

And this seems to succeed:

%{never: nil} = 
%SchemaWritable{}
|> Ecto.Changeset.change(%{never: 10})
|> TestRepo.insert!()

I can try to come up with a fix later tonight. Perhaps we just force the underlying field in the data to nil, not sure. Would you prefer a separate PR for that specifically or put it in here?

@greg-rychlewski

Copy link
Copy Markdown
Member

I think this PR is ok but I also have to think about it. This is kind of a weird case and I'm a bit wary of over-complicating the already complicated code.

@greg-rychlewski

Copy link
Copy Markdown
Member

I guess niling them out at the end in the same place we clean up the changes is probably best. But this is weird enough I'd like @josevalim to also take a look after you put the change in

Comment thread lib/ecto/repo/schema.ex Outdated

@greg-rychlewski greg-rychlewski Jun 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, drop_fields, schema, :insert))
changeset = update_in(changeset.data, &best_way_to_nil_out_keys_in_elixir(&1, drop_fields))

@green-david something like this...i'm not sure off the top of my head if there is a built-in function to do this rather than rolling our own reducer but this is the idea

@green-david

Copy link
Copy Markdown
Contributor Author

@greg-rychlewski @josevalim This appears to work, I think its ready for another look. 🙇‍♂️

Comment thread lib/ecto/repo/schema.ex Outdated
Comment on lines +629 to +650
changes =
Enum.reduce(non_writable_fields, changeset.changes, fn field, changes ->
case changes do
%{^field => _change} ->
handle_writable_violation(field, schema, action)
Map.delete(changes, field)
%{} ->
changes
end
end)

# On insert, if the underlying struct has a value for a non-writable
# field, we need to nilify it so that the struct returned from the
# insert operation reflects that the write was not actually performed.
# Discarding the change above only prevents the actual write from happening.
data = case action do
:insert ->
updates = Enum.map(non_writable_fields, fn field -> {field, nil} end)
struct!(changeset.data, updates)
_ ->
changeset.data
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
changes =
Enum.reduce(non_writable_fields, changeset.changes, fn field, changes ->
case changes do
%{^field => _change} ->
handle_writable_violation(field, schema, action)
Map.delete(changes, field)
%{} ->
changes
end
end)
# On insert, if the underlying struct has a value for a non-writable
# field, we need to nilify it so that the struct returned from the
# insert operation reflects that the write was not actually performed.
# Discarding the change above only prevents the actual write from happening.
data = case action do
:insert ->
updates = Enum.map(non_writable_fields, fn field -> {field, nil} end)
struct!(changeset.data, updates)
_ ->
changeset.data
end
{changes, data_updates} =
Enum.reduce(non_writable_fields, {changeset.changes, []}, fn field, {changes, data_updates} ->
case changes do
%{^field => _change} ->
handle_writable_violation(field, schema, action)
data_updates = if action == :insert, do: [{field, nil} | data_updates], else: data_updates
{Map.delete(changes, field), data-updates}
%{} ->
{changes, data_updates}
end
end)
data = if data_updates != [], do: struct!(changeset.data, data_updates), else: changeset.data

maybe something like this...then you only iterate once and also you only update fields that were not nil to begin with

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done :)

Comment thread test/ecto/repo_test.exs Outdated
Comment on lines +2478 to +2481
%{always: 12, never: nil, insert: nil} =
%MySchemaWritableRaise{id: 3}
|> Ecto.Changeset.change(%{always: 12})
|> TestRepo.update!()

@greg-rychlewski greg-rychlewski Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe we need to change this since it is not trying to update things that shouldn't be updated and we have other tests for the happy path

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed 👍

Comment thread test/ecto/repo_test.exs Outdated
%MySchemaWritableRaise{id: 2}
|> Ecto.Changeset.change(%{insert: 11, always: 12})
|> TestRepo.insert()
%{always: 12, never: nil, insert: 11} =

@greg-rychlewski greg-rychlewski Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we don't need this change either since we are not attempting to change something that shouldn't be changed and we have other tests already asserting the fields that should be changed are present

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed 👍

@josevalim

Copy link
Copy Markdown
Member

I am a bit unsure what to do in this case. It feels surfacing the field as changed, only to discard it, is a bit confusing. Perhaps what we whould do is to not surface it and then to a separate pass on insert on the struct for drop fields. So in a nutshell, we remove the drop_fields from surface_changes and then we do:

if insert
  for field <- drop_fields do
    case struct do
      %{^field => value} when value != nil -> 
        # warn

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants