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
2 changes: 1 addition & 1 deletion Earthfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ integration-test-base:
apk del .build-dependencies && rm -f msodbcsql*.sig mssql-tools*.apk
ENV PATH="/opt/mssql-tools/bin:${PATH}"

GIT CLONE https://github.com/elixir-ecto/ecto_sql.git /src/ecto_sql
GIT CLONE --single-branch --branch read_only_migration https://github.com/greg-rychlewski/ecto_sql.git /src/ecto_sql
WORKDIR /src/ecto_sql
RUN mix deps.get

Expand Down
100 changes: 100 additions & 0 deletions integration_test/cases/repo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,106 @@ 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 read only field `read_only`/, 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 read only field `read_only`/, fn ->
TestRepo.insert!(%Post{title: "1"}, on_conflict: on_conflict)
end

assert_raise Ecto.QueryError, ~r/cannot update read only field `read_only`/, 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 read only 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
test "insert with read only field and conflict replace_all" do
%Post{id: id} = TestRepo.insert!(%Post{id: 1, title: "1"})
TestRepo.insert!(%Post{id: 1, title: "2"}, conflict_target: [:id], on_conflict: :replace_all)
assert %{id: 1, title: "2", read_only: nil} = TestRepo.get(Post, id)
end

@tag :returning
@tag :with_conflict_target
test "insert with read only field and conflict replace_all and returning" do
TestRepo.insert!(%Post{id: 1, title: "1"})

post =
TestRepo.insert!(%Post{id: 1, title: "2", read_only: "nope"},
conflict_target: [:id],
on_conflict: :replace_all,
returning: true
)

assert %{id: 1, title: "2", read_only: nil} = post
end

test "insert_all with read only field" do
msg = "cannot give read only field `read_only` to insert_all"

assert_raise ArgumentError, msg, fn ->
TestRepo.insert_all(Post, [%{title: "1", read_only: "nope"}])
end

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, mode: :readonly
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
19 changes: 17 additions & 2 deletions lib/ecto/query/planner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2027,14 +2027,29 @@ defmodule Ecto.Query.Planner do
defp cte_fields([], [], _aliases), do: []

defp assert_update!(%Ecto.Query{updates: updates} = query, operation) do
modes =
case get_source!(:updates, query, 0) do
{source, schema, _} when is_binary(source) and schema != nil ->
schema.__schema__(:mode)

_ ->
%{}
end

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 modes do
%{^k => :readonly} ->
error! query, "cannot update read only field `#{k}`"

_ ->
Map.put(acc, k, v)
end
end)
end)
Expand Down
52 changes: 44 additions & 8 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 Down Expand Up @@ -142,6 +142,20 @@ defmodule Ecto.Repo.Schema do
"""
end

if schema do
modes = schema.__schema__(:mode)

Enum.each(header, fn field ->
case modes do
%{^field => :readonly} ->
raise ArgumentError, "cannot give read only field `#{field}` to insert_all"

_ ->
:ok
end
end)
end

counter = fn -> length(dump_params) end

{{query, dump_params}, header, cast_params, [], [], counter}
Expand All @@ -158,7 +172,17 @@ defmodule Ecto.Repo.Schema do
end

defp init_mapper(schema, dumper, adapter, placeholder_map) do
modes = schema.__schema__(:mode)

fn {field, value}, acc ->
case modes do
%{^field => :readonly} ->
raise ArgumentError, "cannot give read only field `#{field}` to insert_all"

_ ->
:ok
end

case dumper do
%{^field => {source, type}} ->
greg-rychlewski marked this conversation as resolved.
Show resolved Hide resolved
extract_value(source, value, type, placeholder_map, acc, fn val ->
Expand Down Expand Up @@ -324,7 +348,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 +365,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 +384,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 +440,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 +469,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 @@ -711,7 +735,19 @@ defmodule Ecto.Repo.Schema do
{{:nothing, [], conflict_target}, []}

{:replace, keys} when is_list(keys) ->
fields = Enum.map(keys, &field_source!(schema, &1))
modes = schema && schema.__schema__(:mode) || %{}

fields =
Enum.map(keys, fn key ->
case modes do
%{^key => :readonly} ->
raise ArgumentError, "cannot replace read only field `#{key}` in :on_conflict option"

_ ->
field_source!(schema, key)
end
end)

{{fields, [], conflict_target}, []}

:replace_all ->
Expand All @@ -738,7 +774,7 @@ defmodule Ecto.Repo.Schema do
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
Loading
Loading