Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions lib/ecto/repo/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -452,7 +453,7 @@ defmodule Ecto.Repo.Schema do
# 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 = 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)
Expand Down Expand Up @@ -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, drop_fields, schema, :update))

if changeset.changes != %{} or force? do
wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn ->
Expand Down Expand Up @@ -624,6 +625,33 @@ defmodule Ecto.Repo.Schema do
{:error, put_repo_and_action(changeset, :update, repo, tuplet)}
end

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(field, schema, action)
changes
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.

This can accidentally ignore a field being written to nil. The best is probably:

case changes do
  %{^field => _} ->
    handle_writable_violation(field, schema, action)
    Map.delete(changes, field)
  %{} ->
    changes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🤦 Thank you for catching this! Will fix 👍

end)
end

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 ->
raise ArgumentError, message
:warn ->
Logger.warning(message)
_ ->
:ok
end
end

@doc """
Implementation for `Ecto.Repo.insert_or_update/2`.
"""
Expand Down
35 changes: 27 additions & 8 deletions lib/ecto/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,8 @@ defmodule Ecto.Schema do
:where,
:references,
:skip_default_validation,
:writable
:writable,
:on_writable_violation
]

@doc """
Expand Down Expand Up @@ -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`.
Comment on lines +705 to +710

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I added this as "the path of least resistance" to illustrate the functionality, but I am not confident this is the desired way forward as to the public facing API of how to configure this behavior.

Some other options:

writable: [when: :never, on_violation: :raise]
writable: {:never, :raise}
writable: [:never, :raise]

Open to suggestions :)


"""
defmacro field(name, type \\ :string, opts \\ []) do
quote do
Expand Down Expand Up @@ -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? =
Expand Down Expand Up @@ -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

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.

We probably don't need this check. And maybe users want to set on_writable_violation: :warn as a default somewhere and this would get in the way. :)

if pk? do
Module.put_attribute(mod, :ecto_primary_keys, name)
end
Expand All @@ -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}}})

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.

We should not need to change this now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I might be missing something - doesn't this need to be included here so that I can reference use the value to populate the data for the __schema__(:on_writable_violation) call below?

Or are you suggesting to separately populate something like :ecto_on_writable_violations and use that value in __schema__ instead perhaps?

I put it here so it was next to writable which was pre-existing 🤔

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.

Yeah, to use a separate attribute so we don't "pollute" all other fields to store this information.

end
end

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -2426,7 +2439,7 @@ 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}
Expand All @@ -2435,21 +2448,27 @@ defmodule Ecto.Schema do
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]}
_ -> {[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_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))},
Expand Down
86 changes: 84 additions & 2 deletions test/ecto/repo_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -2418,14 +2439,47 @@ 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()

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"})
Expand Down Expand Up @@ -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()
Expand All @@ -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})
Expand Down
24 changes: 24 additions & 0 deletions test/ecto/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down