From 8e4a94e4337dba93ec481fa310c7c00fd33d06b1 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 4 Aug 2024 20:43:04 -0400 Subject: [PATCH 1/3] read_only to writable --- integration_test/cases/repo.exs | 106 -------------------- integration_test/support/schemas.exs | 1 - lib/ecto/query/planner.ex | 18 ++-- lib/ecto/repo/schema.ex | 30 +++--- lib/ecto/schema.ex | 37 +++---- lib/ecto/schema/loader.ex | 2 +- test/ecto/changeset_test.exs | 16 +-- test/ecto/query/planner_test.exs | 14 +-- test/ecto/query/subquery_test.exs | 2 +- test/ecto/repo_test.exs | 142 ++++++++++++++++++++++++++- test/ecto/schema_test.exs | 30 ++++-- 11 files changed, 219 insertions(+), 179 deletions(-) diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs index fd33a3d2b1..667db70a77 100644 --- a/integration_test/cases/repo.exs +++ b/integration_test/cases/repo.exs @@ -1312,112 +1312,6 @@ defmodule Ecto.Integration.RepoTest do assert TestRepo.get(Post, id).temp == "temp" end - describe "read only fields" do - test "select with read only field" do - {1, _} = TestRepo.insert_all("posts", [%{title: "1", read_only: "readonly"}]) - query = from p in Post, where: p.read_only == ^"readonly", select: p.read_only - - assert "readonly" == TestRepo.one(query) - assert %{read_only: "readonly"} = TestRepo.one(Post) - end - - test "update with read only field" do - %Post{id: id} = post = TestRepo.insert!(%Post{title: "1"}) - cs = Ecto.Changeset.change(post, %{title: "2", read_only: "nope"}) - TestRepo.update!(cs) - assert is_nil(TestRepo.get(Post, id).read_only) - end - - @tag :returning - test "update with read only field and returning" do - post = TestRepo.insert!(%Post{title: "1"}) - cs = Ecto.Changeset.change(post, %{title: "2", read_only: "nope"}) - updated_post = TestRepo.update!(cs, returning: true) - assert is_nil(updated_post.read_only) - end - - test "update_all with read only field" do - TestRepo.insert!(%Post{title: "1"}) - update_query = from p in Post, where: p.title == "1", update: [set: [read_only: "nope"]] - - assert_raise Ecto.QueryError, ~r/cannot update unwritable field `read_only` in query/, fn -> - TestRepo.update_all(update_query, []) - end - end - - test "insert with read only field" do - %Post{id: id} = TestRepo.insert!(%Post{title: "1", read_only: "nope"}) - assert is_nil(TestRepo.get(Post, id).read_only) - end - - @tag :returning - test "insert with read only field and returning" do - post = TestRepo.insert!(%Post{title: "1", read_only: "nope"}, returning: true) - assert is_nil(post.read_only) - end - - test "insert with read only field and conflict query" do - on_conflict = from Post, update: [set: [read_only: "nope"]] - - assert_raise Ecto.QueryError, ~r/cannot update unwritable field `read_only` in query/, fn -> - TestRepo.insert!(%Post{title: "1"}, on_conflict: on_conflict) - end - - assert_raise Ecto.QueryError, ~r/cannot update unwritable field `read_only` in query/, fn -> - TestRepo.insert!(%Post{title: "1"}, on_conflict: [set: [read_only: "nope"]]) - end - end - - test "insert with read only field and conflict replace" do - msg = "cannot replace unwritable field `:read_only` in :on_conflict option" - - assert_raise ArgumentError, msg, fn -> - TestRepo.insert!(%Post{title: "1"}, on_conflict: {:replace, [:read_only]}) - end - end - - @tag :with_conflict_target - @tag :upsert - test "insert with read only field and conflict replace_all" do - uuid = Ecto.UUID.generate() - TestRepo.insert!(%Post{uuid: uuid, title: "1"}) - %Post{id: id} = TestRepo.insert!(%Post{uuid: uuid, title: "2"}, conflict_target: [:uuid], on_conflict: :replace_all) - assert %{title: "2", read_only: nil} = TestRepo.get(Post, id) - end - - @tag :returning - @tag :with_conflict_target - @tag :upsert - test "insert with read only field and conflict replace_all and returning" do - uuid = Ecto.UUID.generate() - TestRepo.insert!(%Post{uuid: uuid, title: "1"}) - - post = - TestRepo.insert!(%Post{uuid: uuid, title: "2", read_only: "nope"}, - conflict_target: [:uuid], - on_conflict: :replace_all, - returning: true - ) - - assert %{title: "2", read_only: nil} = post - end - - test "insert_all with read only field" do - msg = ~r/Unwritable fields, such as virtual and read only fields are not supported./ - - assert_raise ArgumentError, msg, fn -> - TestRepo.insert_all(Post, [%{title: "1", read_only: "nope"}]) - end - - msg = "cannot select unwritable field `read_only` for insert_all" - - assert_raise ArgumentError, msg, fn -> - query = from p in Post, select: %{read_only: p.read_only} - TestRepo.insert_all(Post, query) - end - end - end - ## Query syntax defmodule Foo do diff --git a/integration_test/support/schemas.exs b/integration_test/support/schemas.exs index 01c50e7a7e..62b96d4a5f 100644 --- a/integration_test/support/schemas.exs +++ b/integration_test/support/schemas.exs @@ -46,7 +46,6 @@ defmodule Ecto.Integration.Post do field :links, {:map, :string} field :intensities, {:map, :float} field :posted, :date - field :read_only, :string, read_only: true has_many :comments, Ecto.Integration.Comment, on_delete: :delete_all, on_replace: :delete has_many :force_comments, Ecto.Integration.Comment, on_replace: :delete_if_exists has_many :ordered_comments, Ecto.Integration.Comment, preload_order: [:text] diff --git a/lib/ecto/query/planner.ex b/lib/ecto/query/planner.ex index c0841f2c95..0051e048a3 100644 --- a/lib/ecto/query/planner.ex +++ b/lib/ecto/query/planner.ex @@ -2197,7 +2197,7 @@ defmodule Ecto.Query.Planner do {{:source, {source, schema}, prefix || query.prefix, types}, fields} {{:ok, {_, fields}}, _} -> - {{:map, Enum.map(fields, &{&1, {:value, :any}})}, Enum.map(fields, &select_field(&1, ix, false))} + {{:map, Enum.map(fields, &{&1, {:value, :any}})}, Enum.map(fields, &select_field(&1, ix, :always))} {:error, {:fragment, _, _}} -> {{:value, :map}, [{:&, [], [ix]}]} @@ -2207,7 +2207,7 @@ defmodule Ecto.Query.Planner do dumper = types - |> Enum.map(fn {field, type} -> {field, {field, type, false}} end) + |> Enum.map(fn {field, type} -> {field, {field, type, :always}} end) |> Enum.into(%{}) {types, fields} = select_dump(fields, dumper, ix, drop) @@ -2224,7 +2224,7 @@ defmodule Ecto.Query.Planner do {:error, %Ecto.SubQuery{select: select}} -> fields = subquery_source_fields(select) - {select, Enum.map(fields, &select_field(&1, ix, false))} + {select, Enum.map(fields, &select_field(&1, ix, :always))} end end @@ -2233,16 +2233,16 @@ defmodule Ecto.Query.Planner do |> Enum.reverse() |> Enum.reduce({[], []}, fn field, {types, exprs} when is_atom(field) and not is_map_key(drop, field) -> - {source, type, read_only?} = Map.get(dumper, field, {field, :any, false}) - {[{field, type} | types], [select_field(source, ix, read_only?) | exprs]} + {source, type, writable} = Map.get(dumper, field, {field, :any, :always}) + {[{field, type} | types], [select_field(source, ix, writable) | exprs]} _field, acc -> acc end) end - defp select_field(field, ix, read_only?) do - {{:., [read_only: read_only?], [{:&, [], [ix]}, field]}, [], []} + defp select_field(field, ix, writable) do + {{:., [writable: writable], [{:&, [], [ix]}, field]}, [], []} end defp get_ix!({:&, _, [ix]} = expr, _kind, query) do @@ -2609,8 +2609,8 @@ defmodule Ecto.Query.Planner do end case dumper do - %{^k => {_, _, false}} -> :ok - %{} -> error!(query, "cannot update unwritable field `#{k}`") + %{^k => {_, _, :always}} -> :ok + %{} -> error!(query, "cannot update non-updatable field `#{inspect(k)}`") nil -> :ok end diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index eb6362c5df..e8316834d6 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -185,7 +185,7 @@ defmodule Ecto.Repo.Schema do defp init_mapper(schema, dumper, adapter, placeholder_map) do fn {field, value}, acc -> case dumper do - %{^field => {source, type, false}} -> + %{^field => {source, type, writable}} when writable != :never -> extract_value(source, value, type, placeholder_map, acc, fn val -> dump_field!(:insert_all, schema, field, type, val, adapter) end) @@ -274,8 +274,8 @@ defmodule Ecto.Repo.Schema do end defp insert_all_select_dump!({{:., dot_meta, [{:&, _, [_]}, field]}, [], []}) do - if dot_meta[:read_only] == true do - raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all" + if dot_meta[:writable] == :never do + raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all" else field end @@ -283,8 +283,8 @@ defmodule Ecto.Repo.Schema do defp insert_all_select_dump!(field, dumper) when is_atom(field) do case dumper do - %{^field => {source, _, false}} -> source - %{} -> raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all" + %{^field => {source, _, writable}} when writable != :never -> source + %{} -> raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all" nil -> field end end @@ -366,7 +366,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:insert, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - writable_fields = schema.__schema__(:writable_fields) + insertable_fields = schema.__schema__(:insertable_fields) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -383,7 +383,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, writable_fields ++ assocs) + changeset = Relation.surface_changes(changeset, struct, insertable_fields ++ assocs) wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn -> assoc_opts = assoc_opts(assocs, opts) @@ -402,7 +402,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, writable_fields) + changes = Map.take(changes, insertable_fields) autogen = autogenerate_changes(schema, :insert, changes) dump_changes = @@ -458,7 +458,7 @@ defmodule Ecto.Repo.Schema do struct = struct_from_changeset!(:update, changeset) schema = struct.__struct__ dumper = schema.__schema__(:dump) - writable_fields = schema.__schema__(:writable_fields) + updatable_fields = schema.__schema__(:updatable_fields) assocs = schema.__schema__(:associations) embeds = schema.__schema__(:embeds) @@ -487,7 +487,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(writable_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) @@ -669,7 +669,7 @@ defmodule Ecto.Repo.Schema do end defp fields_to_sources(fields, dumper) do Enum.reduce(fields, {[], []}, fn field, {types, sources} -> - {source, type, _read_only?} = Map.fetch!(dumper, field) + {source, type, _writable} = Map.fetch!(dumper, field) {[{field, type} | types], [source | sources]} end) end @@ -786,12 +786,12 @@ defmodule Ecto.Repo.Schema do defp replace_fields!(dumper, fields) do Enum.map(fields, fn field -> case dumper do - %{^field => {source, _type, false}} -> + %{^field => {source, _type, :always}} -> source _ -> raise ArgumentError, - "cannot replace unwritable field `#{inspect(field)}` in :on_conflict option" + "cannot replace non-updatable field `#{inspect(field)}` in :on_conflict option" end end) end @@ -801,7 +801,7 @@ defmodule Ecto.Repo.Schema do end defp replace_all_fields!(_kind, schema, to_remove) do - Enum.map(schema.__schema__(:writable_fields) -- to_remove, &field_source!(schema, &1)) + Enum.map(schema.__schema__(:updatable_fields) -- to_remove, &field_source!(schema, &1)) end defp field_source!(nil, field) do @@ -1117,7 +1117,7 @@ defmodule Ecto.Repo.Schema do defp dump_fields!(action, schema, kw, dumper, adapter) do for {field, value} <- kw do - {alias, type, _read_only?} = Map.fetch!(dumper, field) + {alias, type, _writable} = Map.fetch!(dumper, field) {alias, dump_field!(action, schema, field, type, value, adapter)} end end diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index f00695c5f3..5d4122d385 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -507,7 +507,6 @@ 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_read_only, accumulate: true) Module.put_attribute(__MODULE__, :ecto_derive_inspect_for_redacted_fields, true) Module.put_attribute(__MODULE__, :ecto_autogenerate_id, nil) end @@ -529,7 +528,7 @@ defmodule Ecto.Schema do :where, :references, :skip_default_validation, - :read_only + :writable ] @doc """ @@ -645,7 +644,6 @@ defmodule Ecto.Schema do assocs = @ecto_assocs |> Enum.reverse() embeds = @ecto_embeds |> Enum.reverse() redacted_fields = @ecto_redact_fields - read_only = @ecto_read_only |> Enum.reverse() loaded = Ecto.Schema.__loaded__(__MODULE__, @ecto_struct_fields) if redacted_fields != [] and not List.keymember?(@derive, Inspect, 0) and @@ -672,7 +670,8 @@ defmodule Ecto.Schema do def __schema__(:loaded), do: unquote(Macro.escape(loaded)) def __schema__(:redact_fields), do: unquote(redacted_fields) def __schema__(:virtual_fields), do: unquote(Enum.map(virtual_fields, &elem(&1, 0))) - def __schema__(:writable_fields), do: unquote(for {name, false} <- read_only, do: name) + def __schema__(:updatable_fields), do: unquote(for {name, {_, :always}} <- fields, do: name) + def __schema__(:insertable_fields), do: unquote(for {name, {_, writable}} when writable != :never <- fields, do: name) def __schema__(:autogenerate_fields), do: unquote(Enum.flat_map(autogenerate, &elem(&1, 0))) @@ -694,8 +693,7 @@ defmodule Ecto.Schema do field_sources, assocs, embeds, - virtual_fields, - read_only + virtual_fields ), {args, body} <- clauses do def __schema__(unquote_splicing(args)), do: unquote(body) @@ -773,7 +771,11 @@ defmodule Ecto.Schema do * `:skip_default_validation` - When true, it will skip the type validation step at compile time. - * `:read_only` - If `true`, the schema field cannot be written to. Defaults to `false`. + * `:writable` - Defines when a field is allowed to be modified. Must be one of + `:always`, `:insert`, or `:never`. If set to `:always`, the field can be modified + by any repo operation. If set to `:insert`, the field can be inserted but cannot + be further modified, even in an upsert. If set to `:never`, the field becomes + read only. Defaults to `:always`. """ defmacro field(name, type \\ :string, opts \\ []) do @@ -2061,7 +2063,7 @@ defmodule Ecto.Schema do defp define_field(mod, name, type, opts) do virtual? = opts[:virtual] || false pk? = opts[:primary_key] || false - read_only? = opts[:read_only] || false + writable = opts[:writable] || :always put_struct_field(mod, name, Keyword.get(opts, :default)) if Keyword.get(opts, :redact, false) do @@ -2101,8 +2103,8 @@ defmodule Ecto.Schema do raise ArgumentError, "cannot mark the same field as autogenerate and read_after_writes" end - if read_only? && gen do - raise ArgumentError, "cannot mark the same field as autogenerate and read only" + if writable != :always && gen do + raise ArgumentError, "autogenerated fields must always be writable" end if pk? do @@ -2113,8 +2115,7 @@ defmodule Ecto.Schema do Module.put_attribute(mod, :ecto_query_fields, {name, type}) end - Module.put_attribute(mod, :ecto_read_only, {name, read_only?}) - Module.put_attribute(mod, :ecto_fields, {name, type}) + Module.put_attribute(mod, :ecto_fields, {name, {type, writable}}) end end @@ -2321,9 +2322,9 @@ defmodule Ecto.Schema do end @doc false - def __schema__(fields, field_sources, assocs, embeds, virtual_fields, read_only) do + def __schema__(fields, field_sources, assocs, embeds, virtual_fields) do load = - for {name, type} <- fields do + for {name, {type, _writable}} <- fields do if alias = field_sources[name] do {name, {:source, alias, type}} else @@ -2332,17 +2333,17 @@ defmodule Ecto.Schema do end dump = - for {name, type} <- fields do - {name, {field_sources[name] || name, type, read_only[name]}} + for {name, {type, writable}} <- fields do + {name, {field_sources[name] || name, type, writable}} end field_sources_quoted = - for {name, _type} <- fields do + for {name, {_type, _writable}} <- fields do {[:field_source, name], field_sources[name] || name} end types_quoted = - for {name, type} <- fields do + for {name, {type, _writable}} <- fields do {[:type, name], Macro.escape(type)} end diff --git a/lib/ecto/schema/loader.ex b/lib/ecto/schema/loader.ex index 7015b3c3be..8ee6471467 100644 --- a/lib/ecto/schema/loader.ex +++ b/lib/ecto/schema/loader.ex @@ -91,7 +91,7 @@ defmodule Ecto.Schema.Loader do Dumps the given data. """ def safe_dump(struct, types, dumper) do - Enum.reduce(types, %{}, fn {field, {source, type, _read_only?}}, acc -> + Enum.reduce(types, %{}, fn {field, {source, type, _writable}}, acc -> value = Map.get(struct, field) case dumper.(type, value) do diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index 5969ceb647..70abd7a3f9 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -97,7 +97,7 @@ defmodule Ecto.ChangesetTest do field :topics, {:array, :string} field :seo_metadata, :map field :virtual, :string, virtual: true - field :read_only, :string, read_only: true + field :unwritable, :string, writable: :never field :published_at, :naive_datetime field :source, :map field :permalink, :string, source: :url @@ -124,7 +124,7 @@ defmodule Ecto.ChangesetTest do cast( schema, params, - ~w(id token title author_email body upvotes decimal color topics seo_metadata virtual read_only)a + ~w(id token title author_email body upvotes decimal color topics seo_metadata virtual unwritable)a ) end @@ -1173,10 +1173,10 @@ defmodule Ecto.ChangesetTest do assert changeset.valid? assert changeset.errors == [] - # When read only + # When writable is set to non-default value changeset = - changeset(%{"read_only" => "hello"}) - |> validate_change(:read_only, fn :read_only, "hello" -> [] end) + changeset(%{"unwritable" => "hello"}) + |> validate_change(:unwritable, fn :unwritable, "hello" -> [] end) assert changeset.valid? assert changeset.errors == [] @@ -3586,7 +3586,7 @@ defmodule Ecto.ChangesetTest do field :username, :string field :display_name, :string, redact: false field :virtual_pass, :string, redact: true, virtual: true - field :read_only_pass, :string, redact: true, read_only: true + field :unwritable_pass, :string, redact: true, writable: :never end end @@ -3645,9 +3645,9 @@ defmodule Ecto.ChangesetTest do assert inspect(changeset) =~ "**redacted**" end - test "redacts read only fields marked redact: true" do + test "redacts unwritable fields marked redact: true" do changeset = - Ecto.Changeset.cast(%RedactedSchema{}, %{read_only_pass: "hunter2"}, [:read_only_pass]) + Ecto.Changeset.cast(%RedactedSchema{}, %{unwritable_pass: "hunter2"}, [:unwritable_pass]) refute inspect(changeset) =~ "hunter2" assert inspect(changeset) =~ "**redacted**" diff --git a/test/ecto/query/planner_test.exs b/test/ecto/query/planner_test.exs index f0173c7769..7099932a1f 100644 --- a/test/ecto/query/planner_test.exs +++ b/test/ecto/query/planner_test.exs @@ -166,7 +166,7 @@ defmodule Ecto.Query.PlannerTest do defp select_fields(fields, ix) do for field <- fields do - {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []} + {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []} end end @@ -1866,7 +1866,7 @@ defmodule Ecto.Query.PlannerTest do start_param_ix = 0 native_types = %{bid: :uuid, num: :integer} types_kw = Enum.map(types, fn {field, _} -> {field, native_types[field]} end) - field_ast = Enum.map(types, fn {field, _} -> {{:., [read_only: false], [{:&, [], [0]}, field]}, [], []} end) + field_ast = Enum.map(types, fn {field, _} -> {{:., [writable: :always], [{:&, [], [0]}, field]}, [], []} end) assert q.from.source == {:values, [], [types_kw, start_param_ix, length(values)]} assert q.select.fields == field_ast @@ -1884,7 +1884,7 @@ defmodule Ecto.Query.PlannerTest do start_param_ix = 1 native_types = %{bid: :uuid, num: :integer} types_kw = Enum.map(types, fn {field, _} -> {field, native_types[field]} end) - field_ast = Enum.map(types, fn {field, _} -> {{:., [read_only: false], [{:&, [], [1]}, field]}, [], []} end) + field_ast = Enum.map(types, fn {field, _} -> {{:., [writable: :always], [{:&, [], [1]}, field]}, [], []} end) [join] = q.joins assert join.source == {:values, [], [types_kw, start_param_ix, length(values)]} @@ -2675,8 +2675,8 @@ defmodule Ecto.Query.PlannerTest do query = "schema" |> select([s], %{x1: selected_as(s.x, :integer), x2: s.x}) %{select: select} = from(q in subquery(query)) |> normalize() - field1 = {{:., [read_only: false], [{:&, [], [0]}, :integer]}, [], []} - field2 = {{:., [read_only: false], [{:&, [], [0]}, :x2]}, [], []} + field1 = {{:., [writable: :always], [{:&, [], [0]}, :integer]}, [], []} + field2 = {{:., [writable: :always], [{:&, [], [0]}, :x2]}, [], []} assert [^field1, ^field2] = select.fields end @@ -2685,8 +2685,8 @@ defmodule Ecto.Query.PlannerTest do s2 = from s in subquery(s1), select: %{y1: selected_as(s.integer, :integer2), y2: s.x2} %{select: select} = from(q in subquery(s2)) |> normalize() - field1 = {{:., [read_only: false], [{:&, [], [0]}, :integer2]}, [], []} - field2 = {{:., [read_only: false], [{:&, [], [0]}, :y2]}, [], []} + field1 = {{:., [writable: :always], [{:&, [], [0]}, :integer2]}, [], []} + field2 = {{:., [writable: :always], [{:&, [], [0]}, :y2]}, [], []} assert [^field1, ^field2] = select.fields end diff --git a/test/ecto/query/subquery_test.exs b/test/ecto/query/subquery_test.exs index 80ac26f583..8842f78f2e 100644 --- a/test/ecto/query/subquery_test.exs +++ b/test/ecto/query/subquery_test.exs @@ -56,7 +56,7 @@ defmodule Ecto.Query.SubqueryTest do defp select_fields(fields, ix) do for field <- fields do - {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []} + {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []} end end diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index 0ad992ef95..a4ddaba688 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -160,11 +160,13 @@ defmodule Ecto.RepoTest do end end - defmodule MySchemaReadOnly do + defmodule MySchemaWritable do use Ecto.Schema schema "my_schema" do - field :x, :string, read_only: true + field :never, :integer, writable: :never + field :always, :integer, writable: :always + field :insert, :integer, writable: :insert end end @@ -764,7 +766,7 @@ defmodule Ecto.RepoTest do msg = ~r"cannot select unwritable field" assert_raise ArgumentError, msg, fn -> - TestRepo.insert_all(MySchemaReadOnly, from(r in MySchemaReadOnly, select: r)) + TestRepo.insert_all(MySchemaWritable, from(w in MySchemaWritable, select: w)) end end @@ -1746,7 +1748,7 @@ defmodule Ecto.RepoTest do end test "raises on non-existent fields on replace" do - msg = "cannot replace unwritable field `:unknown` in :on_conflict option" + msg = "cannot replace non-updatable field `:unknown` in :on_conflict option" assert_raise ArgumentError, msg, fn -> TestRepo.insert( @@ -2090,9 +2092,139 @@ defmodule Ecto.RepoTest do end end + describe "writable field option" do + test "select" do + TestRepo.all(from(w in MySchemaWritable, select: w)) + assert_receive {:all, query} + + assert query.select.fields == [ + {{:., [writable: :always], [{:&, [], [0]}, :id]}, [], []}, + {{:., [writable: :never], [{:&, [], [0]}, :never]}, [], []}, + {{:., [writable: :always], [{:&, [], [0]}, :always]}, [], []}, + {{:., [writable: :insert], [{:&, [], [0]}, :insert]}, [], []} + ] + end + + + test "update only saves changes for writable: :always" do + %MySchemaWritable{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.update() + + assert_received {:update, %{changes: [always: 10]}} + 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"}) + |> TestRepo.update() + + refute_received {:update, _meta} + end + + test "update with returning" do + %MySchemaWritable{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.update(returning: true) + + assert_received {:update, %{returning: [:insert, :always, :never, :id]}} + end + + test "update_all raises if non-updatable field is set" do + update_query = from w in MySchemaWritable, update: [set: [never: 10]] + + assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:never` in query/, fn -> + TestRepo.update_all(update_query, []) + end + + update_query = from w in MySchemaWritable, update: [set: [insert: 10]] + + assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:insert` in query/, fn -> + TestRepo.update_all(update_query, []) + end + end + + test "insert only saves changes for writable: :always/:insert" do + %MySchemaWritable{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.insert() + + assert_received {:insert, %{fields: [always: 10, id: 1, insert: 12]}} + end + + test "insert with returning" do + %MySchemaWritable{id: 1} + |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) + |> TestRepo.insert(returning: true) + + assert_received {:insert, + %{ + fields: [always: 10, id: 1, insert: 12], + returning: [:insert, :always, :never, :id] + }} + end + + test "insert with on_conflict" do + # conflict query + on_conflict = from w in MySchemaWritable, update: [set: [insert: 10]] + + assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:insert` in query/, fn -> + TestRepo.insert(%MySchemaWritable{}, on_conflict: on_conflict) + end + + # conflict keyword + assert_raise Ecto.QueryError, ~r/cannot update non-updatable field `:never` in query/, fn -> + TestRepo.insert(%MySchemaWritable{}, on_conflict: [set: [never: 10]]) + end + + # conflict replace + assert_raise ArgumentError, ~r/cannot replace non-updatable field `:never` in :on_conflict option/, fn -> + TestRepo.insert(%MySchemaWritable{}, on_conflict: {:replace, [:always, :never]}) + end + end + + test "insert with on_conflict = replace_all and returning" do + TestRepo.insert!(%MySchemaWritable{always: 1, never: 2, insert: 3}, + on_conflict: :replace_all, + returning: true + ) + + assert_received {:insert, + %{ + fields: [always: 1, insert: 3], + returning: [:id, :insert, :always, :never] + }} + end + + test "insert_all" do + # selecting maps + msg = ~r/Unwritable fields, such as virtual and read only fields are not supported./ + + assert_raise ArgumentError, msg, fn -> + TestRepo.insert_all(MySchemaWritable, [%{always: 1, insert: 3, never: 2}]) + end + + # selecting individual fields + msg = "cannot select unwritable field `:never` for insert_all" + + assert_raise ArgumentError, msg, fn -> + query = from w in MySchemaWritable, select: %{always: w.always, insert: w.insert, never: w.insert} + TestRepo.insert_all(MySchemaWritable, query) + end + + # selecting sources + msg = "cannot select unwritable field `:never` for insert_all" + + assert_raise ArgumentError, msg, fn -> + query = from w in MySchemaWritable, select: w + TestRepo.insert_all(MySchemaWritable, query) + end + end + end + defp select_fields(fields, ix) do for field <- fields do - {{:., [read_only: false], [{:&, [], [ix]}, field]}, [], []} + {{:., [writable: :always], [{:&, [], [ix]}, field]}, [], []} end end end diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs index 661dc6706a..a8b4280a60 100644 --- a/test/ecto/schema_test.exs +++ b/test/ecto/schema_test.exs @@ -15,7 +15,8 @@ defmodule Ecto.SchemaTest do field :array, {:array, :string} field :uuid, Ecto.UUID, autogenerate: true field :no_query_load, :string, load_in_query: false - field :read_only, :string, read_only: true + field :unwritable, :string, writable: :never + field :non_updatable, :string, writable: :insert belongs_to :comment, Comment belongs_to :permalink, Permalink, define_field: false end @@ -26,15 +27,15 @@ defmodule Ecto.SchemaTest do assert Schema.__schema__(:prefix) == nil assert Schema.__schema__(:fields) == - [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :read_only, :comment_id] + [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :unwritable, :non_updatable, :comment_id] - assert Schema.__schema__(:writable_fields) == - [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :comment_id] + assert Schema.__schema__(:insertable_fields) == + [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :non_updatable, :comment_id] assert Schema.__schema__(:virtual_fields) == [:temp] assert Schema.__schema__(:query_fields) == - [:id, :name, :email, :password, :count, :array, :uuid, :read_only, :comment_id] + [:id, :name, :email, :password, :count, :array, :uuid, :unwritable, :non_updatable, :comment_id] assert Schema.__schema__(:read_after_writes) == [:email, :count] assert Schema.__schema__(:primary_key) == [:id] @@ -73,7 +74,8 @@ defmodule Ecto.SchemaTest do id: :id, uuid: Ecto.UUID, no_query_load: :string, - read_only: :string + unwritable: :string, + non_updatable: :string } end @@ -589,13 +591,25 @@ defmodule Ecto.SchemaTest do end assert_raise ArgumentError, - "cannot mark the same field as autogenerate and read only", + "autogenerated fields must always be writable", fn -> defmodule AutogenerateFail do use Ecto.Schema schema "hello" do - field :x, Ecto.UUID, autogenerate: true, read_only: true + field :x, Ecto.UUID, autogenerate: true, writable: :never + end + end + end + + assert_raise ArgumentError, + "autogenerated fields must always be writable", + fn -> + defmodule AutogenerateFail do + use Ecto.Schema + + schema "hello" do + field :x, Ecto.UUID, autogenerate: true, writable: :insert end end end From 028fae11cdbbe3883f3bea2372077d4681f5dfbd Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 4 Aug 2024 20:56:55 -0400 Subject: [PATCH 2/3] more test --- test/ecto/schema_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/ecto/schema_test.exs b/test/ecto/schema_test.exs index a8b4280a60..6e9fd99c6b 100644 --- a/test/ecto/schema_test.exs +++ b/test/ecto/schema_test.exs @@ -32,6 +32,9 @@ defmodule Ecto.SchemaTest do assert Schema.__schema__(:insertable_fields) == [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :non_updatable, :comment_id] + assert Schema.__schema__(:updatable_fields) == + [:id, :name, :email, :password, :count, :array, :uuid, :no_query_load, :comment_id] + assert Schema.__schema__(:virtual_fields) == [:temp] assert Schema.__schema__(:query_fields) == From 2187d2018cc8ae33ce0f50ef07c5cc70a5e7f62c Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 4 Aug 2024 21:06:47 -0400 Subject: [PATCH 3/3] fix sorting --- test/ecto/repo_test.exs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index a4ddaba688..63ee040a79 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -2127,7 +2127,8 @@ defmodule Ecto.RepoTest do |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) |> TestRepo.update(returning: true) - assert_received {:update, %{returning: [:insert, :always, :never, :id]}} + assert_received {:update, %{returning: returning}} + assert Enum.sort(returning) == [:always, :id, :insert, :never] end test "update_all raises if non-updatable field is set" do @@ -2149,7 +2150,8 @@ defmodule Ecto.RepoTest do |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) |> TestRepo.insert() - assert_received {:insert, %{fields: [always: 10, id: 1, insert: 12]}} + assert_received {:insert, %{fields: inserted_fields}} + assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] end test "insert with returning" do @@ -2157,11 +2159,9 @@ defmodule Ecto.RepoTest do |> Ecto.Changeset.change(%{always: 10, never: 11, insert: 12}) |> TestRepo.insert(returning: true) - assert_received {:insert, - %{ - fields: [always: 10, id: 1, insert: 12], - returning: [:insert, :always, :never, :id] - }} + assert_received {:insert, %{fields: inserted_fields, returning: returning}} + assert Enum.sort(inserted_fields) == [always: 10, id: 1, insert: 12] + assert Enum.sort(returning) == [:always, :id, :insert, :never] end test "insert with on_conflict" do @@ -2189,11 +2189,9 @@ defmodule Ecto.RepoTest do returning: true ) - assert_received {:insert, - %{ - fields: [always: 1, insert: 3], - returning: [:id, :insert, :always, :never] - }} + assert_received {:insert, %{fields: inserted_fields, returning: returning}} + assert Enum.sort(inserted_fields) == [always: 1, insert: 3] + assert Enum.sort(returning) == [:always, :id, :insert, :never] end test "insert_all" do