Skip to content

Commit

Permalink
Allow schema fields to be read only (#4335)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: José Valim <[email protected]>
  • Loading branch information
greg-rychlewski and josevalim authored Dec 12, 2023
1 parent fa6e7db commit 253e2a3
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 36 deletions.
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

0 comments on commit 253e2a3

Please sign in to comment.