From 3e33f427bd6e8405c82c5e476249fa9e1b4ba3fc Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:49:11 -0400 Subject: [PATCH 1/6] Add ability to configure the behavior of writing to a non-writable field --- lib/ecto/repo/schema.ex | 42 +++++++++++++++---- lib/ecto/schema.ex | 37 +++++++++++------ test/ecto/repo_test.exs | 86 ++++++++++++++++++++++++++++++++++++++- test/ecto/schema_test.exs | 32 +++++++++++++-- 4 files changed, 171 insertions(+), 26 deletions(-) diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index 0ac755b928..a18f16c1d0 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -6,6 +6,7 @@ defmodule Ecto.Repo.Schema do alias Ecto.Changeset alias Ecto.Changeset.Relation require Ecto.Query + require Logger import Ecto.Query.Planner, only: [attach_prefix: 2] @@ -433,7 +434,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:insert, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - {keep_fields, drop_fields} = schema.__schema__(:insertable_fields) + {insertable_fields, non_insertable} = schema.__schema__(:insertable) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -451,8 +452,8 @@ defmodule Ecto.Repo.Schema do # On insert, we always merge the whole struct into the # changeset as changes, except the primary key if it is nil. changeset = put_repo_and_action(changeset, :insert, repo, tuplet) - changeset = Relation.surface_changes(changeset, struct, keep_fields ++ assocs) - changeset = update_in(changeset.changes, &Map.drop(&1, drop_fields)) + changeset = Relation.surface_changes(changeset, struct, insertable_fields ++ assocs) + changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_insertable, :insert)) wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> assoc_opts = assoc_opts(assocs, opts) @@ -471,7 +472,7 @@ defmodule Ecto.Repo.Schema do {changes, cast_extra, dump_extra, return_types, return_sources} = autogenerate_id(autogen_id, changes, return_types, return_sources, adapter) - changes = Map.take(changes, keep_fields) + changes = Map.take(changes, insertable_fields) autogen = autogenerate_changes(schema, :insert, changes) dump_changes = @@ -543,7 +544,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:update, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - {keep_fields, drop_fields} = schema.__schema__(:updatable_fields) + {updatable_fields, non_updatable} = schema.__schema__(:updatable) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -560,7 +561,7 @@ defmodule Ecto.Repo.Schema do # fields into the changeset. All changes must be in the # changeset before hand. changeset = put_repo_and_action(changeset, :update, repo, tuplet) - changeset = update_in(changeset.changes, &Map.drop(&1, drop_fields)) + changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_updatable, :update)) if changeset.changes != %{} or force? do wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> @@ -575,7 +576,7 @@ defmodule Ecto.Repo.Schema do if changeset.valid? do embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :update) - changes = changeset.changes |> Map.merge(embeds) |> Map.take(keep_fields) + changes = changeset.changes |> Map.merge(embeds) |> Map.take(updatable_fields) autogen = autogenerate_changes(schema, :update, changes) dump_changes = dump_changes!(:update, changes, autogen, schema, [], dumper, adapter) @@ -624,6 +625,31 @@ defmodule Ecto.Repo.Schema do {:error, put_repo_and_action(changeset, :update, repo, tuplet)} end + defp drop_non_writable_changes!(changes, non_writable, action) do + Enum.reduce(non_writable, changes, fn {name, on_writable_violation}, changes -> + case Map.pop(changes, name) do + {nil, changes} -> + changes + {_change, changes} -> + handle_writable_violation(name, on_writable_violation, action) + changes + end + end) + end + + defp handle_writable_violation(name, on_writable_violation, action) do + message = "attempted to write to non-writable field #{inspect(name)} during #{action}" + + case on_writable_violation do + :raise -> + raise ArgumentError, message + :warn -> + Logger.warning(message) + _ -> + :ok + end + end + @doc """ Implementation for `Ecto.Repo.insert_or_update/2`. """ @@ -964,7 +990,7 @@ defmodule Ecto.Repo.Schema do end defp replace_all_fields!(_kind, schema, to_remove) do - {updatable_fields, _} = schema.__schema__(:updatable_fields) + {updatable_fields, _} = schema.__schema__(:updatable) Enum.map(updatable_fields -- to_remove, &field_source!(schema, &1)) end diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index de9d03d82c..5bb2ccda26 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -544,7 +544,8 @@ defmodule Ecto.Schema do :where, :references, :skip_default_validation, - :writable + :writable, + :on_writable_violation ] @doc """ @@ -700,6 +701,13 @@ defmodule Ecto.Schema do be further modified, even in an upsert. If set to `:never`, the field becomes read only. Defaults to `:always`. + * `:on_writable_violation` - Defines what action to take when performing an insert or update + attempts to modify a field that should not be modified according to it's `:writable` value. + Must be one of `:nothing`, `:warn`, or `:raise`. If set to `:nothing`, the modification is + silently ignored. If set to `:warn`, the modification is ignored and a warning is logged. If set + to `:raise`, an exception is raised and the operation is aborted. If `:writable` is set to `:always`, + `:on_writable_violation` must be set to `:nothing`. Defaults to `:nothing`. + """ defmacro field(name, type \\ :string, opts \\ []) do quote do @@ -2013,6 +2021,7 @@ defmodule Ecto.Schema do virtual? = opts[:virtual] || false pk? = opts[:primary_key] || false writable = opts[:writable] || :always + on_writable_violation = opts[:on_writable_violation] || :nothing put_struct_field(mod, name, Keyword.get(opts, :default)) redact_field? = @@ -2069,6 +2078,10 @@ defmodule Ecto.Schema do raise ArgumentError, "autogenerated fields must always be writable" end + if writable == :always and on_writable_violation != :nothing do + raise ArgumentError, "on_writable_violation must be :nothing for always writable fields" + end + if pk? do Module.put_attribute(mod, :ecto_primary_keys, name) end @@ -2077,7 +2090,7 @@ defmodule Ecto.Schema do Module.put_attribute(mod, :ecto_query_fields, {name, type}) end - Module.put_attribute(mod, :ecto_fields, {name, {type, writable}}) + Module.put_attribute(mod, :ecto_fields, {name, {type, {writable, on_writable_violation}}}) end end @@ -2383,7 +2396,7 @@ defmodule Ecto.Schema do end load = - for {name, {type, _writable}} <- fields do + for {name, {type, {_writable, _on_writable_violation}}} <- fields do if alias = field_sources[name] do {name, {:source, alias, type}} else @@ -2392,17 +2405,17 @@ defmodule Ecto.Schema do end dump = - for {name, {type, writable}} <- fields do + for {name, {type, {writable, _on_writable_violation}}} <- fields do {name, {field_sources[name] || name, type, writable}} end field_sources_quoted = - for {name, {_type, _writable}} <- fields do + for {name, {_type, {_writable, _on_writable_violation}}} <- fields do {[:field_source, name], field_sources[name] || name} end types_quoted = - for {name, {type, _writable}} <- fields do + for {name, {type, {_writable, _on_writable_violation}}} <- fields do {[:type, name], Macro.escape(type)} end @@ -2426,19 +2439,19 @@ defmodule Ecto.Schema do embed_names = Enum.map(embeds, &elem(&1, 0)) updatable = - for {name, {_, writable}} <- fields, reduce: {[], []} do + for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do :always -> {[name | keep], drop} - _ -> {keep, [name | drop]} + _ -> {keep, [{name, on_writable_violation} | drop]} end end insertable = - for {name, {_, writable}} <- fields, reduce: {[], []} do + for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do - :never -> {keep, [name | drop]} + :never -> {keep, [{name, on_writable_violation} | drop]} _ -> {[name | keep], drop} end end @@ -2448,8 +2461,8 @@ defmodule Ecto.Schema do {[:load], load |> Macro.escape()}, {[:associations], assoc_names}, {[:embeds], embed_names}, - {[:updatable_fields], updatable}, - {[:insertable_fields], insertable}, + {[:updatable], updatable}, + {[:insertable], insertable}, {[:redact_fields], redacted_fields}, {[:autogenerate_fields], Enum.flat_map(autogenerate, &elem(&1, 0))}, {[:virtual_fields], Enum.map(virtual_fields, &elem(&1, 0))}, diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index 31991ef54a..ec230d9939 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -3,6 +3,7 @@ defmodule Ecto.RepoTest do import Ecto.Query import Ecto, only: [put_meta: 2] + import ExUnit.CaptureLog alias Ecto.TestRepo defmodule MyParent do @@ -181,6 +182,26 @@ defmodule Ecto.RepoTest do end end + defmodule MySchemaWritableWarn do + use Ecto.Schema + + schema "my_schema" do + field :never, :integer, writable: :never, on_writable_violation: :warn + field :always, :integer, writable: :always + field :insert, :integer, writable: :insert, on_writable_violation: :warn + end + end + + defmodule MySchemaWritableRaise do + use Ecto.Schema + + schema "my_schema" do + field :never, :integer, writable: :never, on_writable_violation: :raise + field :always, :integer, writable: :always + field :insert, :integer, writable: :insert, on_writable_violation: :raise + end + end + defmodule MySchemaOneField do use Ecto.Schema @@ -2418,7 +2439,7 @@ defmodule Ecto.RepoTest do ] end - test "update only saves changes for writable: :always" do + test "update with on_writable_violation: :nothing saves changes for writable: :always and ignores changes for writable: :insert/:never" do %MySchemaWritable{id: 1} |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) |> TestRepo.update() @@ -2426,6 +2447,39 @@ defmodule Ecto.RepoTest do assert_received {:update, %{changes: [always: 10]}} end + test "update with on_writable_violation: :warn saves changes for writable: :always, ignores changes for writable: :insert/:never, and logs a warning" do + log = capture_log(fn -> + %MySchemaWritableWarn{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.update() + + assert_received {:update, %{changes: [always: 10]}} + end) + + assert log =~ "attempted to write to non-writable field :insert during update" + assert log =~ "attempted to write to non-writable field :never during update" + end + + test "update with on_writable_violation: :raise saves changes for writable: :always and raises for changes for writable: :insert/:never" do + assert_raise ArgumentError, "attempted to write to non-writable field :never during update", fn -> + %MySchemaWritableRaise{id: 1} + |> Ecto.Changeset.change(%{never: 10}) + |> TestRepo.update() + end + + assert_raise ArgumentError, "attempted to write to non-writable field :insert during update", fn -> + %MySchemaWritableRaise{id: 2} + |> Ecto.Changeset.change(%{insert: 11}) + |> TestRepo.update() + end + + %MySchemaWritableRaise{id: 3} + |> Ecto.Changeset.change(%{always: 12}) + |> TestRepo.update() + + assert_received {:update, %{changes: [always: 12]}} + end + test "update is a no-op when updatable fields are not changed" do %MySchemaWritable{id: 1} |> Ecto.Changeset.change(%{never: "can't update", insert: "can't update either"}) @@ -2459,7 +2513,7 @@ defmodule Ecto.RepoTest do end end - test "insert only saves changes for writable: :always/:insert" do + test "insert with on_writable_violation: :nothing saves changes for writable: :always/:insert and ignores changes for writable: :never" do %MySchemaWritable{id: 1} |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) |> TestRepo.insert() @@ -2468,6 +2522,34 @@ defmodule Ecto.RepoTest do assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] end + test "insert with on_writable_violation: :warn saves changes for writable: :always/:insert, ignores changes for writable: :never, and logs a warning" do + log = capture_log(fn -> + %MySchemaWritableWarn{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] + end) + + assert log =~ "attempted to write to non-writable field :never during insert" + end + + test "insert with on_writable_violation: :raise saves changes for writable: :always/:insert and raises for changes for writable: :never" do + assert_raise ArgumentError, "attempted to write to non-writable field :never during insert", fn -> + %MySchemaWritableRaise{id: 1} + |> Ecto.Changeset.change(%{never: 10}) + |> TestRepo.insert() + end + + %MySchemaWritableRaise{id: 2} + |> Ecto.Changeset.change(%{insert: 11, always: 12}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 12, id: 2, insert: 11] + end + test "insert with returning" do %MySchemaWritable{id: 1} |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs index 6c68168285..099b9216dd 100644 --- a/test/ecto/schema_test.exs +++ b/test/ecto/schema_test.exs @@ -41,7 +41,7 @@ defmodule Ecto.SchemaTest do :comment_id ] - assert Schema.__schema__(:insertable_fields) == + assert Schema.__schema__(:insertable) == {[ :comment_id, :non_updatable, @@ -53,11 +53,11 @@ defmodule Ecto.SchemaTest do :email, :name, :id - ], [:unwritable]} + ], [{:unwritable, :nothing}]} - assert Schema.__schema__(:updatable_fields) == + assert Schema.__schema__(:updatable) == {[:comment_id, :no_query_load, :uuid, :array, :count, :password, :email, :name, :id], - [:non_updatable, :unwritable]} + [{:non_updatable, :nothing}, {:unwritable, :nothing}]} assert Schema.__schema__(:virtual_fields) == [:temp] @@ -718,6 +718,30 @@ defmodule Ecto.SchemaTest do end end + test "invalid on_writable_violation for writable" do + assert_raise ArgumentError, "on_writable_violation must be :nothing for always writable fields", + fn -> + defmodule OnWritableViolationFail do + use Ecto.Schema + + schema "hello" do + field :x, :string, writable: :always, on_writable_violation: :warn + end + end + end + + assert_raise ArgumentError, "on_writable_violation must be :nothing for always writable fields", + fn -> + defmodule OnWritableViolationFail do + use Ecto.Schema + + schema "hello" do + field :x, :string, writable: :always, on_writable_violation: :raise + end + end + end + end + test "inline embed defined without schema" do # embeds_one message = ~r"`embeds_one/3` expects `schema` to be a module name, but received \[do:" From 16670ce78d24659773bf37ea8ba4e6c4e907958d Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:36:06 -0400 Subject: [PATCH 2/6] Simplify :on_writable_violation handling --- lib/ecto/repo/schema.ex | 30 ++++++++++++++++-------------- lib/ecto/schema.ex | 18 ++++++++++++------ test/ecto/schema_test.exs | 8 ++++---- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index a18f16c1d0..d66ce6ce19 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -434,7 +434,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:insert, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - {insertable_fields, non_insertable} = schema.__schema__(:insertable) + {keep_fields, drop_fields} = schema.__schema__(:insertable_fields) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -452,8 +452,8 @@ defmodule Ecto.Repo.Schema do # On insert, we always merge the whole struct into the # changeset as changes, except the primary key if it is nil. changeset = put_repo_and_action(changeset, :insert, repo, tuplet) - changeset = Relation.surface_changes(changeset, struct, insertable_fields ++ assocs) - changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_insertable, :insert)) + changeset = Relation.surface_changes(changeset, struct, keep_fields ++ assocs) + changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, drop_fields, schema, :insert)) wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> assoc_opts = assoc_opts(assocs, opts) @@ -472,7 +472,7 @@ defmodule Ecto.Repo.Schema do {changes, cast_extra, dump_extra, return_types, return_sources} = autogenerate_id(autogen_id, changes, return_types, return_sources, adapter) - changes = Map.take(changes, insertable_fields) + changes = Map.take(changes, keep_fields) autogen = autogenerate_changes(schema, :insert, changes) dump_changes = @@ -544,7 +544,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:update, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - {updatable_fields, non_updatable} = schema.__schema__(:updatable) + {keep_fields, drop_fields} = schema.__schema__(:updatable_fields) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -561,7 +561,7 @@ defmodule Ecto.Repo.Schema do # fields into the changeset. All changes must be in the # changeset before hand. changeset = put_repo_and_action(changeset, :update, repo, tuplet) - changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_updatable, :update)) + changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, drop_fields, schema, :update)) if changeset.changes != %{} or force? do wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> @@ -576,7 +576,7 @@ defmodule Ecto.Repo.Schema do if changeset.valid? do embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :update) - changes = changeset.changes |> Map.merge(embeds) |> Map.take(updatable_fields) + changes = changeset.changes |> Map.merge(embeds) |> Map.take(keep_fields) autogen = autogenerate_changes(schema, :update, changes) dump_changes = dump_changes!(:update, changes, autogen, schema, [], dumper, adapter) @@ -625,20 +625,22 @@ defmodule Ecto.Repo.Schema do {:error, put_repo_and_action(changeset, :update, repo, tuplet)} end - defp drop_non_writable_changes!(changes, non_writable, action) do - Enum.reduce(non_writable, changes, fn {name, on_writable_violation}, changes -> - case Map.pop(changes, name) do + defp drop_non_writable_changes!(changes, non_writable_fields, schema, action) do + Enum.reduce(non_writable_fields, changes, fn field, changes -> + case Map.pop(changes, field) do {nil, changes} -> changes {_change, changes} -> - handle_writable_violation(name, on_writable_violation, action) + handle_writable_violation(field, schema, action) changes end end) end - defp handle_writable_violation(name, on_writable_violation, action) do - message = "attempted to write to non-writable field #{inspect(name)} during #{action}" + defp handle_writable_violation(field, schema, action) do + on_writable_violation = schema.__schema__(:on_writable_violation)[field] + + message = "attempted to write to non-writable field #{inspect(field)} during #{action}" case on_writable_violation do :raise -> @@ -990,7 +992,7 @@ defmodule Ecto.Repo.Schema do end defp replace_all_fields!(_kind, schema, to_remove) do - {updatable_fields, _} = schema.__schema__(:updatable) + {updatable_fields, _} = schema.__schema__(:updatable_fields) Enum.map(updatable_fields -- to_remove, &field_source!(schema, &1)) end diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index 5bb2ccda26..8808595609 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -2439,30 +2439,36 @@ defmodule Ecto.Schema do embed_names = Enum.map(embeds, &elem(&1, 0)) updatable = - for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do + for {name, {_, {writable, _on_writable_violation}}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do :always -> {[name | keep], drop} - _ -> {keep, [{name, on_writable_violation} | drop]} + _ -> {keep, [name | drop]} end end insertable = - for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do + for {name, {_, {writable, _on_writable_violation}}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do - :never -> {keep, [{name, on_writable_violation} | drop]} + :never -> {keep, [name | drop]} _ -> {[name | keep], drop} end end + on_writable_violation = + for {name, {_, {_writable, on_writable_violation}}} <- fields do + {name, on_writable_violation} + end + single_arg = [ {[:dump], dump |> Map.new() |> Macro.escape()}, {[:load], load |> Macro.escape()}, {[:associations], assoc_names}, {[:embeds], embed_names}, - {[:updatable], updatable}, - {[:insertable], insertable}, + {[:updatable_fields], updatable}, + {[:insertable_fields], insertable}, + {[:on_writable_violation], on_writable_violation |> Map.new() |> Macro.escape()}, {[:redact_fields], redacted_fields}, {[:autogenerate_fields], Enum.flat_map(autogenerate, &elem(&1, 0))}, {[:virtual_fields], Enum.map(virtual_fields, &elem(&1, 0))}, diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs index 099b9216dd..b9e2526578 100644 --- a/test/ecto/schema_test.exs +++ b/test/ecto/schema_test.exs @@ -41,7 +41,7 @@ defmodule Ecto.SchemaTest do :comment_id ] - assert Schema.__schema__(:insertable) == + assert Schema.__schema__(:insertable_fields) == {[ :comment_id, :non_updatable, @@ -53,11 +53,11 @@ defmodule Ecto.SchemaTest do :email, :name, :id - ], [{:unwritable, :nothing}]} + ], [:unwritable]} - assert Schema.__schema__(:updatable) == + assert Schema.__schema__(:updatable_fields) == {[:comment_id, :no_query_load, :uuid, :array, :count, :password, :email, :name, :id], - [{:non_updatable, :nothing}, {:unwritable, :nothing}]} + [:non_updatable, :unwritable]} assert Schema.__schema__(:virtual_fields) == [:temp] From 21a626d0e2e9e4a6e57a4fc42dbc9a9c8e112a22 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:26:08 -0400 Subject: [PATCH 3/6] Ensure that we detect writable violation which set a field to nil --- lib/ecto/repo/schema.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index d66ce6ce19..82b4dccaa3 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -627,12 +627,12 @@ defmodule Ecto.Repo.Schema do defp drop_non_writable_changes!(changes, non_writable_fields, schema, action) do Enum.reduce(non_writable_fields, changes, fn field, changes -> - case Map.pop(changes, field) do - {nil, changes} -> - changes - {_change, changes} -> + case changes do + %{^field => _change} -> handle_writable_violation(field, schema, action) changes + %{} -> + changes end end) end From 084197c06b7dc47f0a933189d90e1d5f1dc28d86 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:26:56 -0400 Subject: [PATCH 4/6] Remove requirement that on_writable_violation must be :nothing for writable: :always --- lib/ecto/schema.ex | 4 ---- test/ecto/schema_test.exs | 24 ------------------------ 2 files changed, 28 deletions(-) diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index 8808595609..5a5445fc14 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -2078,10 +2078,6 @@ defmodule Ecto.Schema do raise ArgumentError, "autogenerated fields must always be writable" end - if writable == :always and on_writable_violation != :nothing do - raise ArgumentError, "on_writable_violation must be :nothing for always writable fields" - end - if pk? do Module.put_attribute(mod, :ecto_primary_keys, name) end diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs index b9e2526578..6c68168285 100644 --- a/test/ecto/schema_test.exs +++ b/test/ecto/schema_test.exs @@ -718,30 +718,6 @@ defmodule Ecto.SchemaTest do end end - test "invalid on_writable_violation for writable" do - assert_raise ArgumentError, "on_writable_violation must be :nothing for always writable fields", - fn -> - defmodule OnWritableViolationFail do - use Ecto.Schema - - schema "hello" do - field :x, :string, writable: :always, on_writable_violation: :warn - end - end - end - - assert_raise ArgumentError, "on_writable_violation must be :nothing for always writable fields", - fn -> - defmodule OnWritableViolationFail do - use Ecto.Schema - - schema "hello" do - field :x, :string, writable: :always, on_writable_violation: :raise - end - end - end - end - test "inline embed defined without schema" do # embeds_one message = ~r"`embeds_one/3` expects `schema` to be a module name, but received \[do:" From 26c6df13f785c91d699f216dc0c6ef55b49c8751 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:33:18 -0400 Subject: [PATCH 5/6] Use new, separate :ecto_on_writable_violation to not pollute :ecto_fields --- lib/ecto/schema.ex | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index 5a5445fc14..b8bdd26938 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -525,6 +525,7 @@ defmodule Ecto.Schema do Module.register_attribute(__MODULE__, :ecto_autogenerate, accumulate: true) Module.register_attribute(__MODULE__, :ecto_autoupdate, accumulate: true) Module.register_attribute(__MODULE__, :ecto_redact_fields, accumulate: true) + Module.register_attribute(__MODULE__, :ecto_on_writable_violation, accumulate: true) end end @@ -2078,6 +2079,8 @@ defmodule Ecto.Schema do raise ArgumentError, "autogenerated fields must always be writable" end + Module.put_attribute(mod, :ecto_on_writable_violation, {name, on_writable_violation}) + if pk? do Module.put_attribute(mod, :ecto_primary_keys, name) end @@ -2086,7 +2089,7 @@ defmodule Ecto.Schema do Module.put_attribute(mod, :ecto_query_fields, {name, type}) end - Module.put_attribute(mod, :ecto_fields, {name, {type, {writable, on_writable_violation}}}) + Module.put_attribute(mod, :ecto_fields, {name, {type, writable}}) end end @@ -2376,6 +2379,7 @@ defmodule Ecto.Schema do autoupdate = Module.get_attribute(module, :ecto_autoupdate) |> Enum.reverse() read_after_writes = Module.get_attribute(module, :ecto_raw) |> Enum.reverse() autogenerate_id = Module.get_attribute(module, :ecto_autogenerate_id) + on_writable_violation = Module.get_attribute(module, :ecto_on_writable_violation) struct_fields = Module.get_attribute(module, :ecto_struct_fields) |> Enum.reverse() derive = Module.get_attribute(module, :derive) @@ -2392,7 +2396,7 @@ defmodule Ecto.Schema do end load = - for {name, {type, {_writable, _on_writable_violation}}} <- fields do + for {name, {type, _writable}} <- fields do if alias = field_sources[name] do {name, {:source, alias, type}} else @@ -2401,17 +2405,17 @@ defmodule Ecto.Schema do end dump = - for {name, {type, {writable, _on_writable_violation}}} <- fields do + for {name, {type, writable}} <- fields do {name, {field_sources[name] || name, type, writable}} end field_sources_quoted = - for {name, {_type, {_writable, _on_writable_violation}}} <- fields do + for {name, {_type, _writable}} <- fields do {[:field_source, name], field_sources[name] || name} end types_quoted = - for {name, {type, {_writable, _on_writable_violation}}} <- fields do + for {name, {type, _writable}} <- fields do {[:type, name], Macro.escape(type)} end @@ -2435,7 +2439,7 @@ defmodule Ecto.Schema do embed_names = Enum.map(embeds, &elem(&1, 0)) updatable = - for {name, {_, {writable, _on_writable_violation}}} <- fields, reduce: {[], []} do + for {name, {_, writable}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do :always -> {[name | keep], drop} @@ -2444,7 +2448,7 @@ defmodule Ecto.Schema do end insertable = - for {name, {_, {writable, _on_writable_violation}}} <- fields, reduce: {[], []} do + for {name, {_, writable}} <- fields, reduce: {[], []} do {keep, drop} -> case writable do :never -> {keep, [name | drop]} @@ -2452,11 +2456,6 @@ defmodule Ecto.Schema do end end - on_writable_violation = - for {name, {_, {_writable, on_writable_violation}}} <- fields do - {name, on_writable_violation} - end - single_arg = [ {[:dump], dump |> Map.new() |> Macro.escape()}, {[:load], load |> Macro.escape()}, From fc4ae9d46bb92ec6534057fffcd58498f378aefd Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:09:39 -0400 Subject: [PATCH 6/6] Fix and add tests for writable violations due to surfaced changes at insertion --- lib/ecto/repo/schema.ex | 2 +- test/ecto/repo_test.exs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index 82b4dccaa3..c80e62f920 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -452,7 +452,7 @@ defmodule Ecto.Repo.Schema do # On insert, we always merge the whole struct into the # changeset as changes, except the primary key if it is nil. changeset = put_repo_and_action(changeset, :insert, repo, tuplet) - changeset = Relation.surface_changes(changeset, struct, keep_fields ++ assocs) + changeset = Relation.surface_changes(changeset, struct, keep_fields ++ drop_fields ++ assocs) changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, drop_fields, schema, :insert)) wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index ec230d9939..2a71ad107b 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -2513,6 +2513,15 @@ defmodule Ecto.RepoTest do end end + test "insert with surfaced changes on_writable_violation: :nothing saves changes for writable: :always/:insert and ignores changes for writable: :never" do + %MySchemaWritable{id: 1, always: 10, never: 11, insert: 12} + |> Ecto.Changeset.change(%{}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] + end + test "insert with on_writable_violation: :nothing saves changes for writable: :always/:insert and ignores changes for writable: :never" do %MySchemaWritable{id: 1} |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) @@ -2522,6 +2531,19 @@ defmodule Ecto.RepoTest do assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] end + test "insert with with surfaced changes and on_writable_violation: :warn saves changes for writable: :always/:insert, ignores changes for writable: :never, and logs a warning" do + log = capture_log(fn -> + %MySchemaWritableWarn{id: 1, always: 10, never: 11, insert: 12} + |> Ecto.Changeset.change(%{}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] + end) + + assert log =~ "attempted to write to non-writable field :never during insert" + end + test "insert with on_writable_violation: :warn saves changes for writable: :always/:insert, ignores changes for writable: :never, and logs a warning" do log = capture_log(fn -> %MySchemaWritableWarn{id: 1} @@ -2535,6 +2557,21 @@ defmodule Ecto.RepoTest do assert log =~ "attempted to write to non-writable field :never during insert" end + test "insert with surfaced changes and on_writable_violation: :raise saves changes for writable: :always/:insert and raises for changes for writable: :never" do + assert_raise ArgumentError, "attempted to write to non-writable field :never during insert", fn -> + %MySchemaWritableRaise{id: 1, never: 10} + |> Ecto.Changeset.change(%{}) + |> TestRepo.insert() + end + + %MySchemaWritableRaise{id: 2, insert: 11, always: 12} + |> Ecto.Changeset.change(%{}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 12, id: 2, insert: 11] + end + test "insert with on_writable_violation: :raise saves changes for writable: :always/:insert and raises for changes for writable: :never" do assert_raise ArgumentError, "attempted to write to non-writable field :never during insert", fn -> %MySchemaWritableRaise{id: 1}