Skip to content

Commit 3e33f42

Browse files
committed
Add ability to configure the behavior of writing to a non-writable field
1 parent 129c2ea commit 3e33f42

4 files changed

Lines changed: 171 additions & 26 deletions

File tree

lib/ecto/repo/schema.ex

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Ecto.Repo.Schema do
66
alias Ecto.Changeset
77
alias Ecto.Changeset.Relation
88
require Ecto.Query
9+
require Logger
910

1011
import Ecto.Query.Planner, only: [attach_prefix: 2]
1112

@@ -433,7 +434,7 @@ defmodule Ecto.Repo.Schema do
433434
struct = struct_from_changeset!(:insert, changeset)
434435
schema = struct.__struct__
435436
dumper = schema.__schema__(:dump)
436-
{keep_fields, drop_fields} = schema.__schema__(:insertable_fields)
437+
{insertable_fields, non_insertable} = schema.__schema__(:insertable)
437438
assocs = schema.__schema__(:associations)
438439
embeds = schema.__schema__(:embeds)
439440

@@ -451,8 +452,8 @@ defmodule Ecto.Repo.Schema do
451452
# On insert, we always merge the whole struct into the
452453
# changeset as changes, except the primary key if it is nil.
453454
changeset = put_repo_and_action(changeset, :insert, repo, tuplet)
454-
changeset = Relation.surface_changes(changeset, struct, keep_fields ++ assocs)
455-
changeset = update_in(changeset.changes, &Map.drop(&1, drop_fields))
455+
changeset = Relation.surface_changes(changeset, struct, insertable_fields ++ assocs)
456+
changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_insertable, :insert))
456457

457458
wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn ->
458459
assoc_opts = assoc_opts(assocs, opts)
@@ -471,7 +472,7 @@ defmodule Ecto.Repo.Schema do
471472
{changes, cast_extra, dump_extra, return_types, return_sources} =
472473
autogenerate_id(autogen_id, changes, return_types, return_sources, adapter)
473474

474-
changes = Map.take(changes, keep_fields)
475+
changes = Map.take(changes, insertable_fields)
475476
autogen = autogenerate_changes(schema, :insert, changes)
476477

477478
dump_changes =
@@ -543,7 +544,7 @@ defmodule Ecto.Repo.Schema do
543544
struct = struct_from_changeset!(:update, changeset)
544545
schema = struct.__struct__
545546
dumper = schema.__schema__(:dump)
546-
{keep_fields, drop_fields} = schema.__schema__(:updatable_fields)
547+
{updatable_fields, non_updatable} = schema.__schema__(:updatable)
547548
assocs = schema.__schema__(:associations)
548549
embeds = schema.__schema__(:embeds)
549550

@@ -560,7 +561,7 @@ defmodule Ecto.Repo.Schema do
560561
# fields into the changeset. All changes must be in the
561562
# changeset before hand.
562563
changeset = put_repo_and_action(changeset, :update, repo, tuplet)
563-
changeset = update_in(changeset.changes, &Map.drop(&1, drop_fields))
564+
changeset = update_in(changeset.changes, &drop_non_writable_changes!(&1, non_updatable, :update))
564565

565566
if changeset.changes != %{} or force? do
566567
wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn ->
@@ -575,7 +576,7 @@ defmodule Ecto.Repo.Schema do
575576
if changeset.valid? do
576577
embeds = Ecto.Embedded.prepare(changeset, embeds, adapter, :update)
577578

578-
changes = changeset.changes |> Map.merge(embeds) |> Map.take(keep_fields)
579+
changes = changeset.changes |> Map.merge(embeds) |> Map.take(updatable_fields)
579580
autogen = autogenerate_changes(schema, :update, changes)
580581
dump_changes = dump_changes!(:update, changes, autogen, schema, [], dumper, adapter)
581582

@@ -624,6 +625,31 @@ defmodule Ecto.Repo.Schema do
624625
{:error, put_repo_and_action(changeset, :update, repo, tuplet)}
625626
end
626627

628+
defp drop_non_writable_changes!(changes, non_writable, action) do
629+
Enum.reduce(non_writable, changes, fn {name, on_writable_violation}, changes ->
630+
case Map.pop(changes, name) do
631+
{nil, changes} ->
632+
changes
633+
{_change, changes} ->
634+
handle_writable_violation(name, on_writable_violation, action)
635+
changes
636+
end
637+
end)
638+
end
639+
640+
defp handle_writable_violation(name, on_writable_violation, action) do
641+
message = "attempted to write to non-writable field #{inspect(name)} during #{action}"
642+
643+
case on_writable_violation do
644+
:raise ->
645+
raise ArgumentError, message
646+
:warn ->
647+
Logger.warning(message)
648+
_ ->
649+
:ok
650+
end
651+
end
652+
627653
@doc """
628654
Implementation for `Ecto.Repo.insert_or_update/2`.
629655
"""
@@ -964,7 +990,7 @@ defmodule Ecto.Repo.Schema do
964990
end
965991

966992
defp replace_all_fields!(_kind, schema, to_remove) do
967-
{updatable_fields, _} = schema.__schema__(:updatable_fields)
993+
{updatable_fields, _} = schema.__schema__(:updatable)
968994
Enum.map(updatable_fields -- to_remove, &field_source!(schema, &1))
969995
end
970996

lib/ecto/schema.ex

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,8 @@ defmodule Ecto.Schema do
544544
:where,
545545
:references,
546546
:skip_default_validation,
547-
:writable
547+
:writable,
548+
:on_writable_violation
548549
]
549550

550551
@doc """
@@ -700,6 +701,13 @@ defmodule Ecto.Schema do
700701
be further modified, even in an upsert. If set to `:never`, the field becomes
701702
read only. Defaults to `:always`.
702703
704+
* `:on_writable_violation` - Defines what action to take when performing an insert or update
705+
attempts to modify a field that should not be modified according to it's `:writable` value.
706+
Must be one of `:nothing`, `:warn`, or `:raise`. If set to `:nothing`, the modification is
707+
silently ignored. If set to `:warn`, the modification is ignored and a warning is logged. If set
708+
to `:raise`, an exception is raised and the operation is aborted. If `:writable` is set to `:always`,
709+
`:on_writable_violation` must be set to `:nothing`. Defaults to `:nothing`.
710+
703711
"""
704712
defmacro field(name, type \\ :string, opts \\ []) do
705713
quote do
@@ -2013,6 +2021,7 @@ defmodule Ecto.Schema do
20132021
virtual? = opts[:virtual] || false
20142022
pk? = opts[:primary_key] || false
20152023
writable = opts[:writable] || :always
2024+
on_writable_violation = opts[:on_writable_violation] || :nothing
20162025
put_struct_field(mod, name, Keyword.get(opts, :default))
20172026

20182027
redact_field? =
@@ -2069,6 +2078,10 @@ defmodule Ecto.Schema do
20692078
raise ArgumentError, "autogenerated fields must always be writable"
20702079
end
20712080

2081+
if writable == :always and on_writable_violation != :nothing do
2082+
raise ArgumentError, "on_writable_violation must be :nothing for always writable fields"
2083+
end
2084+
20722085
if pk? do
20732086
Module.put_attribute(mod, :ecto_primary_keys, name)
20742087
end
@@ -2077,7 +2090,7 @@ defmodule Ecto.Schema do
20772090
Module.put_attribute(mod, :ecto_query_fields, {name, type})
20782091
end
20792092

2080-
Module.put_attribute(mod, :ecto_fields, {name, {type, writable}})
2093+
Module.put_attribute(mod, :ecto_fields, {name, {type, {writable, on_writable_violation}}})
20812094
end
20822095
end
20832096

@@ -2383,7 +2396,7 @@ defmodule Ecto.Schema do
23832396
end
23842397

23852398
load =
2386-
for {name, {type, _writable}} <- fields do
2399+
for {name, {type, {_writable, _on_writable_violation}}} <- fields do
23872400
if alias = field_sources[name] do
23882401
{name, {:source, alias, type}}
23892402
else
@@ -2392,17 +2405,17 @@ defmodule Ecto.Schema do
23922405
end
23932406

23942407
dump =
2395-
for {name, {type, writable}} <- fields do
2408+
for {name, {type, {writable, _on_writable_violation}}} <- fields do
23962409
{name, {field_sources[name] || name, type, writable}}
23972410
end
23982411

23992412
field_sources_quoted =
2400-
for {name, {_type, _writable}} <- fields do
2413+
for {name, {_type, {_writable, _on_writable_violation}}} <- fields do
24012414
{[:field_source, name], field_sources[name] || name}
24022415
end
24032416

24042417
types_quoted =
2405-
for {name, {type, _writable}} <- fields do
2418+
for {name, {type, {_writable, _on_writable_violation}}} <- fields do
24062419
{[:type, name], Macro.escape(type)}
24072420
end
24082421

@@ -2426,19 +2439,19 @@ defmodule Ecto.Schema do
24262439
embed_names = Enum.map(embeds, &elem(&1, 0))
24272440

24282441
updatable =
2429-
for {name, {_, writable}} <- fields, reduce: {[], []} do
2442+
for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do
24302443
{keep, drop} ->
24312444
case writable do
24322445
:always -> {[name | keep], drop}
2433-
_ -> {keep, [name | drop]}
2446+
_ -> {keep, [{name, on_writable_violation} | drop]}
24342447
end
24352448
end
24362449

24372450
insertable =
2438-
for {name, {_, writable}} <- fields, reduce: {[], []} do
2451+
for {name, {_, {writable, on_writable_violation}}} <- fields, reduce: {[], []} do
24392452
{keep, drop} ->
24402453
case writable do
2441-
:never -> {keep, [name | drop]}
2454+
:never -> {keep, [{name, on_writable_violation} | drop]}
24422455
_ -> {[name | keep], drop}
24432456
end
24442457
end
@@ -2448,8 +2461,8 @@ defmodule Ecto.Schema do
24482461
{[:load], load |> Macro.escape()},
24492462
{[:associations], assoc_names},
24502463
{[:embeds], embed_names},
2451-
{[:updatable_fields], updatable},
2452-
{[:insertable_fields], insertable},
2464+
{[:updatable], updatable},
2465+
{[:insertable], insertable},
24532466
{[:redact_fields], redacted_fields},
24542467
{[:autogenerate_fields], Enum.flat_map(autogenerate, &elem(&1, 0))},
24552468
{[:virtual_fields], Enum.map(virtual_fields, &elem(&1, 0))},

test/ecto/repo_test.exs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule Ecto.RepoTest do
33

44
import Ecto.Query
55
import Ecto, only: [put_meta: 2]
6+
import ExUnit.CaptureLog
67
alias Ecto.TestRepo
78

89
defmodule MyParent do
@@ -181,6 +182,26 @@ defmodule Ecto.RepoTest do
181182
end
182183
end
183184

185+
defmodule MySchemaWritableWarn do
186+
use Ecto.Schema
187+
188+
schema "my_schema" do
189+
field :never, :integer, writable: :never, on_writable_violation: :warn
190+
field :always, :integer, writable: :always
191+
field :insert, :integer, writable: :insert, on_writable_violation: :warn
192+
end
193+
end
194+
195+
defmodule MySchemaWritableRaise do
196+
use Ecto.Schema
197+
198+
schema "my_schema" do
199+
field :never, :integer, writable: :never, on_writable_violation: :raise
200+
field :always, :integer, writable: :always
201+
field :insert, :integer, writable: :insert, on_writable_violation: :raise
202+
end
203+
end
204+
184205
defmodule MySchemaOneField do
185206
use Ecto.Schema
186207

@@ -2418,14 +2439,47 @@ defmodule Ecto.RepoTest do
24182439
]
24192440
end
24202441

2421-
test "update only saves changes for writable: :always" do
2442+
test "update with on_writable_violation: :nothing saves changes for writable: :always and ignores changes for writable: :insert/:never" do
24222443
%MySchemaWritable{id: 1}
24232444
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
24242445
|> TestRepo.update()
24252446

24262447
assert_received {:update, %{changes: [always: 10]}}
24272448
end
24282449

2450+
test "update with on_writable_violation: :warn saves changes for writable: :always, ignores changes for writable: :insert/:never, and logs a warning" do
2451+
log = capture_log(fn ->
2452+
%MySchemaWritableWarn{id: 1}
2453+
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
2454+
|> TestRepo.update()
2455+
2456+
assert_received {:update, %{changes: [always: 10]}}
2457+
end)
2458+
2459+
assert log =~ "attempted to write to non-writable field :insert during update"
2460+
assert log =~ "attempted to write to non-writable field :never during update"
2461+
end
2462+
2463+
test "update with on_writable_violation: :raise saves changes for writable: :always and raises for changes for writable: :insert/:never" do
2464+
assert_raise ArgumentError, "attempted to write to non-writable field :never during update", fn ->
2465+
%MySchemaWritableRaise{id: 1}
2466+
|> Ecto.Changeset.change(%{never: 10})
2467+
|> TestRepo.update()
2468+
end
2469+
2470+
assert_raise ArgumentError, "attempted to write to non-writable field :insert during update", fn ->
2471+
%MySchemaWritableRaise{id: 2}
2472+
|> Ecto.Changeset.change(%{insert: 11})
2473+
|> TestRepo.update()
2474+
end
2475+
2476+
%MySchemaWritableRaise{id: 3}
2477+
|> Ecto.Changeset.change(%{always: 12})
2478+
|> TestRepo.update()
2479+
2480+
assert_received {:update, %{changes: [always: 12]}}
2481+
end
2482+
24292483
test "update is a no-op when updatable fields are not changed" do
24302484
%MySchemaWritable{id: 1}
24312485
|> Ecto.Changeset.change(%{never: "can't update", insert: "can't update either"})
@@ -2459,7 +2513,7 @@ defmodule Ecto.RepoTest do
24592513
end
24602514
end
24612515

2462-
test "insert only saves changes for writable: :always/:insert" do
2516+
test "insert with on_writable_violation: :nothing saves changes for writable: :always/:insert and ignores changes for writable: :never" do
24632517
%MySchemaWritable{id: 1}
24642518
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
24652519
|> TestRepo.insert()
@@ -2468,6 +2522,34 @@ defmodule Ecto.RepoTest do
24682522
assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12]
24692523
end
24702524

2525+
test "insert with on_writable_violation: :warn saves changes for writable: :always/:insert, ignores changes for writable: :never, and logs a warning" do
2526+
log = capture_log(fn ->
2527+
%MySchemaWritableWarn{id: 1}
2528+
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})
2529+
|> TestRepo.insert()
2530+
2531+
assert_received {:insert, %{fields: inserted_fields}}
2532+
assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12]
2533+
end)
2534+
2535+
assert log =~ "attempted to write to non-writable field :never during insert"
2536+
end
2537+
2538+
test "insert with on_writable_violation: :raise saves changes for writable: :always/:insert and raises for changes for writable: :never" do
2539+
assert_raise ArgumentError, "attempted to write to non-writable field :never during insert", fn ->
2540+
%MySchemaWritableRaise{id: 1}
2541+
|> Ecto.Changeset.change(%{never: 10})
2542+
|> TestRepo.insert()
2543+
end
2544+
2545+
%MySchemaWritableRaise{id: 2}
2546+
|> Ecto.Changeset.change(%{insert: 11, always: 12})
2547+
|> TestRepo.insert()
2548+
2549+
assert_received {:insert, %{fields: inserted_fields}}
2550+
assert Enum.sort(inserted_fields) == [always: 12, id: 2, insert: 11]
2551+
end
2552+
24712553
test "insert with returning" do
24722554
%MySchemaWritable{id: 1}
24732555
|> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12})

0 commit comments

Comments
 (0)