Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow schema fields to be set to read only #4335

Merged
merged 13 commits into from
Dec 12, 2023
106 changes: 106 additions & 0 deletions integration_test/cases/repo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,112 @@ 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
Expand Down
1 change: 1 addition & 0 deletions integration_test/support/schemas.exs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ 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]
Expand Down
26 changes: 22 additions & 4 deletions lib/ecto/query/planner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1726,7 +1726,7 @@ defmodule Ecto.Query.Planner do

{:error, {:values, _, [types, _]}} ->
fields = Keyword.keys(types)
dumper = types |> Enum.map(fn {field, type} -> {field, {field, type}} end) |> Enum.into(%{})
dumper = types |> Enum.map(fn {field, type} -> {field, {field, type, false}} end) |> Enum.into(%{})
{types, fields} = select_dump(fields, dumper, ix, drop)
{{:source, :values, nil, types}, fields}

Expand All @@ -1749,7 +1749,7 @@ 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} = Map.get(dumper, field, {field, :any})
{source, type, _read_only?} = Map.get(dumper, field, {field, :any, false})
{[{field, type} | types], [select_field(source, ix) | exprs]}
_field, acc ->
acc
Expand Down Expand Up @@ -2027,15 +2027,23 @@ defmodule Ecto.Query.Planner do
defp cte_fields([], [], _aliases), do: []

defp assert_update!(%Ecto.Query{updates: updates} = query, operation) do
dumper = dumper_for_update(query)

changes =
Enum.reduce(updates, %{}, fn update, acc ->
Enum.reduce(update.expr, acc, fn {_op, kw}, acc ->
Enum.reduce(kw, acc, fn {k, v}, acc ->
if Map.has_key?(acc, k) do
error! query, "duplicate field `#{k}` for `#{operation}`"
else
Map.put(acc, k, v)
end

case dumper do
%{^k => {_, _, false}} -> :ok
%{} -> error! query, "cannot update unwritable field `#{k}`"
nil -> :ok
end

Map.put(acc, k, v)
end)
end)
end)
Expand Down Expand Up @@ -2070,6 +2078,16 @@ defmodule Ecto.Query.Planner do
end
end

defp dumper_for_update(query) do
case get_source!(:updates, query, 0) do
{source, schema, _} when is_binary(source) and schema != nil ->
schema.__schema__(:dump)

_ ->
nil
end
end

defp filter_and_reraise(exception, stacktrace) do
reraise exception, Enum.reject(stacktrace, &match?({__MODULE__, _, _, _}, &1))
end
Expand Down
61 changes: 45 additions & 16 deletions lib/ecto/repo/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ defmodule Ecto.Repo.Schema do
{rows, header, row_cast_params, placeholder_cast_params, placeholder_dump_params, fn -> counter end}
end

defp extract_header_and_fields(repo, %Ecto.Query{} = query, _schema, _dumper, _autogen_id, _placeholder_map, adapter, opts) do
defp extract_header_and_fields(repo, %Ecto.Query{} = query, _schema, dumper, _autogen_id, _placeholder_map, adapter, opts) do
{query, opts} = repo.prepare_query(:insert_all, query, opts)
query = attach_prefix(query, opts)

Expand All @@ -117,10 +117,22 @@ defmodule Ecto.Repo.Schema do

header = case query.select do
%Ecto.Query.SelectExpr{expr: {:%{}, _ctx, args}} ->
Enum.map(args, &elem(&1, 0))
Enum.map(args, fn {field, _} ->
case dumper do
%{^field => {_, _, false}} -> field
%{} -> raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all"
nil -> field
end
end)

%Ecto.Query.SelectExpr{take: %{^ix => {_fun, fields}}} ->
fields
Enum.map(fields, fn field ->
case dumper do
%{^field => {_, _, false}} -> field
%{} -> raise ArgumentError, "cannot select unwritable field `#{field}` for insert_all"
nil -> field
end
end)

_ ->
raise ArgumentError, """
Expand Down Expand Up @@ -160,15 +172,16 @@ defmodule Ecto.Repo.Schema do
defp init_mapper(schema, dumper, adapter, placeholder_map) do
fn {field, value}, acc ->
case dumper do
%{^field => {source, type}} ->
%{^field => {source, type, false}} ->
extract_value(source, value, type, placeholder_map, acc, fn val ->
dump_field!(:insert_all, schema, field, type, val, adapter)
end)

%{} ->
raise ArgumentError,
"unknown field `#{inspect(field)}` in schema #{inspect(schema)} given to " <>
"insert_all. Note virtual fields and associations are not supported"
"insert_all. Unwritable fields, such as virtual and read only fields " <>
"are not supported. Associations are also not supported"
end
end
end
Expand Down Expand Up @@ -324,7 +337,7 @@ defmodule Ecto.Repo.Schema do
struct = struct_from_changeset!(:insert, changeset)
schema = struct.__struct__
dumper = schema.__schema__(:dump)
fields = schema.__schema__(:fields)
writable_fields = schema.__schema__(:writable_fields)
assocs = schema.__schema__(:associations)
embeds = schema.__schema__(:embeds)

Expand All @@ -341,7 +354,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, fields ++ assocs)
changeset = Relation.surface_changes(changeset, struct, writable_fields ++ assocs)

wrap_in_transaction(adapter, adapter_meta, opts, changeset, assocs, embeds, prepare, fn ->
assoc_opts = assoc_opts(assocs, opts)
Expand All @@ -360,7 +373,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, fields)
changes = Map.take(changes, writable_fields)
autogen = autogenerate_changes(schema, :insert, changes)

dump_changes =
Expand Down Expand Up @@ -416,7 +429,7 @@ defmodule Ecto.Repo.Schema do
struct = struct_from_changeset!(:update, changeset)
schema = struct.__struct__
dumper = schema.__schema__(:dump)
fields = schema.__schema__(:fields)
writable_fields = schema.__schema__(:writable_fields)
assocs = schema.__schema__(:associations)
embeds = schema.__schema__(:embeds)

Expand Down Expand Up @@ -445,7 +458,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(fields)
changes = changeset.changes |> Map.merge(embeds) |> Map.take(writable_fields)
autogen = autogenerate_changes(schema, :update, changes)
dump_changes = dump_changes!(:update, changes, autogen, schema, [], dumper, adapter)

Expand Down Expand Up @@ -627,7 +640,7 @@ defmodule Ecto.Repo.Schema do
end
defp fields_to_sources(fields, dumper) do
Enum.reduce(fields, {[], []}, fn field, {types, sources} ->
{source, type} = Map.fetch!(dumper, field)
{source, type, _read_only?} = Map.fetch!(dumper, field)
{[{field, type} | types], [source | sources]}
end)
end
Expand Down Expand Up @@ -687,7 +700,7 @@ defmodule Ecto.Repo.Schema do
defp conflict_target(conflict_target, dumper) do
for target <- List.wrap(conflict_target) do
case dumper do
%{^target => {alias, _}} ->
%{^target => {alias, _, _}} ->
alias
%{} when is_atom(target) ->
raise ArgumentError, "unknown field `#{inspect(target)}` in conflict_target"
Expand All @@ -711,8 +724,7 @@ defmodule Ecto.Repo.Schema do
{{:nothing, [], conflict_target}, []}

{:replace, keys} when is_list(keys) ->
fields = Enum.map(keys, &field_source!(schema, &1))
{{fields, [], conflict_target}, []}
{{replace_fields!(schema, keys), [], conflict_target}, []}

:replace_all ->
{{replace_all_fields!(:replace_all, schema, []), [], conflict_target}, []}
Expand All @@ -733,12 +745,29 @@ defmodule Ecto.Repo.Schema do
end
end

defp replace_fields!(nil, fields), do: fields

defp replace_fields!(schema, fields) do
dumper = schema.__schema__(:dump)

Enum.map(fields, fn field ->
case dumper do
%{^field => {source, _type, false}} ->
source

_ ->
raise ArgumentError,
"cannot replace unwritable field `#{inspect(field)}` in :on_conflict option"
end
end)
end

defp replace_all_fields!(kind, nil, _to_remove) do
raise ArgumentError, "cannot use #{inspect(kind)} on operations without a schema"
end

defp replace_all_fields!(_kind, schema, to_remove) do
Enum.map(schema.__schema__(:fields) -- to_remove, &field_source!(schema, &1))
Enum.map(schema.__schema__(:writable_fields) -- to_remove, &field_source!(schema, &1))
end

defp field_source!(nil, field) do
Expand Down Expand Up @@ -1054,7 +1083,7 @@ defmodule Ecto.Repo.Schema do

defp dump_fields!(action, schema, kw, dumper, adapter) do
for {field, value} <- kw do
{alias, type} = Map.fetch!(dumper, field)
{alias, type, _read_only?} = Map.fetch!(dumper, field)
{alias, dump_field!(action, schema, field, type, value, adapter)}
end
end
Expand Down
Loading