Skip to content

Commit f4d84a1

Browse files
Map insert_all source fields through the destination schema (#4795)
This PR maps selected fields through the destination schema. It keeps headers aligned with the source select list. This addresses the cases where destination writeable: true and renamed columns were not respected. It also hardens checks against duplicate projections and other unsupported cases. Tests cover mapped and read-only fields, map updates, and schema and non-schema sources Fixes #4794 Co-authored-by: José Valim <jose.valim@gmail.com>
1 parent 24f914a commit f4d84a1

2 files changed

Lines changed: 290 additions & 38 deletions

File tree

‎lib/ecto/repo/schema.ex‎

Lines changed: 85 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -175,48 +175,23 @@ defmodule Ecto.Repo.Schema do
175175

176176
header =
177177
case query.select do
178-
%Ecto.Query.SelectExpr{expr: {:%{}, [], [{:|, _, [{:&, _, [ix]}, args]}]}, fields: fields} ->
179-
{updated_fields, updated_set} =
180-
Enum.map_reduce(args, MapSet.new(), fn {field, _}, set ->
181-
dumped_field = insert_all_select_dump!(field, dumper)
182-
{dumped_field, MapSet.put(set, dumped_field)}
183-
end)
178+
%Ecto.Query.SelectExpr{expr: {:%{}, [], [{:|, _, [{:&, _, [ix]}, args]}]}} ->
179+
updated_fields =
180+
Enum.map(args, fn {field, _} -> insert_all_select_dump!(field, dumper) end)
184181

185-
unchanged_fields =
186-
for {{:., _, [{:&, _, [^ix]}, field]}, [], []} = expr <- fields,
187-
not MapSet.member?(updated_set, field),
188-
do: insert_all_select_dump!(expr)
189-
190-
unchanged_fields ++ updated_fields
182+
insert_all_source_fields(query, ix, args, dumper) ++ updated_fields
191183

192184
%Ecto.Query.SelectExpr{expr: {:%{}, _ctx, args}} ->
193185
Enum.map(args, fn {field, _} -> insert_all_select_dump!(field, dumper) end)
194186

195187
%Ecto.Query.SelectExpr{take: %{^ix => {_fun, fields}}} ->
196188
Enum.map(fields, &insert_all_select_dump!(&1, dumper))
197189

198-
%Ecto.Query.SelectExpr{expr: {:&, _, [_ix]}, fields: fields} ->
199-
Enum.map(fields, &insert_all_select_dump!(&1))
190+
%Ecto.Query.SelectExpr{expr: {:&, _, [ix]}} ->
191+
insert_all_source_fields(query, ix, [], dumper)
200192

201193
_ ->
202-
raise ArgumentError, """
203-
cannot generate a fields list for insert_all from the given source query:
204-
205-
#{inspect(query)}
206-
207-
The select clause must be one of the following:
208-
209-
* A single `map/2` or several `map/2` expressions combined with `select_merge`
210-
* A single `struct/2` or several `struct/2` expressions combined with `select_merge`
211-
* A source such as `p` in the query `from p in Post`
212-
* A single literal map or several literal maps combined with `select_merge`. If
213-
combining several literal maps, there cannot be any query interpolations
214-
except in the last `select_merge`. Consider using `Ecto.Query.exclude/2`
215-
to rebuild the select expression from scratch if you need multiple `select_merge`
216-
statements with interpolations
217-
218-
All keys must exist in the schema that is being inserted into
219-
"""
194+
insert_all_select_error!(query)
220195
end
221196

222197
counter = fn -> length(dump_params) end
@@ -336,27 +311,99 @@ defmodule Ecto.Repo.Schema do
336311
{rows, Enum.reverse(cast_params), counter}
337312
end
338313

339-
defp insert_all_select_dump!({{:., dot_meta, [{:&, _, [_]}, field]}, [], []}) do
340-
if dot_meta[:writable] == :never do
341-
raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all"
342-
else
343-
field
314+
defp insert_all_source_fields(query, ix, updates, dumper) do
315+
fields = query.select.fields
316+
count = length(fields) - length(updates)
317+
if count < 0, do: insert_all_select_error!(query)
318+
fields = Enum.take(fields, count)
319+
updates = Map.new(updates)
320+
321+
# Schema fields are logical names; SELECT fields contain physical column names.
322+
case elem(query.sources, ix) do
323+
{_, schema, _} when is_atom(schema) and not is_nil(schema) ->
324+
selected =
325+
case query.select.take do
326+
%{^ix => {_, selected}} -> selected
327+
_ -> schema.__schema__(:query_fields)
328+
end
329+
330+
source_dumper = schema.__schema__(:dump)
331+
332+
{header, leftover} =
333+
for field <- selected, is_atom(field), not is_map_key(updates, field), reduce: {[], fields} do
334+
{header, fields} ->
335+
source =
336+
case source_dumper do
337+
%{^field => {source, _, _}} -> source
338+
_ -> field
339+
end
340+
341+
case fields do
342+
[{{:., _, [{:&, _, [^ix]}, ^source]}, [], []} | fields] ->
343+
field = insert_all_select_dump!(if(dumper, do: field, else: source), dumper)
344+
{[field | header], fields}
345+
346+
_ ->
347+
insert_all_select_error!(query)
348+
end
349+
end
350+
351+
if leftover != [], do: insert_all_select_error!(query)
352+
Enum.reverse(header)
353+
354+
_ ->
355+
Enum.map(fields, fn
356+
{{:., _, [{:&, _, [^ix]}, field]}, [], []} ->
357+
if is_map_key(updates, field), do: insert_all_select_error!(query)
358+
insert_all_select_dump!(field, dumper)
359+
360+
_ ->
361+
insert_all_select_error!(query)
362+
end)
344363
end
345364
end
346365

366+
defp insert_all_select_error!(query) do
367+
raise ArgumentError, """
368+
cannot generate a fields list for insert_all from the given source query:
369+
370+
#{inspect(query)}
371+
372+
The select clause must be one of the following:
373+
374+
* A single `map/2` or several `map/2` expressions combined with `select_merge`
375+
* A single `struct/2` or several `struct/2` expressions combined with `select_merge`
376+
* A source such as `p` in the query `from p in Post`
377+
* A single literal map or several literal maps combined with `select_merge`. If
378+
combining several literal maps, there cannot be any query interpolations
379+
except in the last `select_merge`. Consider using `Ecto.Query.exclude/2`
380+
to rebuild the select expression from scratch if you need multiple `select_merge`
381+
statements with interpolations
382+
383+
All keys must exist in the schema that is being inserted into
384+
"""
385+
end
386+
347387
defp insert_all_select_dump!(field, dumper) when is_atom(field) do
348388
case dumper do
349389
%{^field => {source, _, writable}} when writable != :never ->
350390
source
351391

352-
%{} ->
392+
%{^field => {_, _, :never}} ->
353393
raise ArgumentError, "cannot select unwritable field `#{inspect(field)}` for insert_all"
354394

395+
%{} ->
396+
raise ArgumentError, "cannot select unknown field `#{inspect(field)}` for insert_all"
397+
355398
nil ->
356399
field
357400
end
358401
end
359402

403+
defp insert_all_select_dump!(field, _dumper) do
404+
raise ArgumentError, "cannot select non-atom field `#{inspect(field)}` for insert_all"
405+
end
406+
360407
defp autogenerate_id(nil, fields, header, _adapter) do
361408
{fields, header}
362409
end

‎test/ecto/repo_test.exs‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,50 @@ defmodule Ecto.RepoTest do
211211
end
212212
end
213213

214+
defmodule InsertSelectRenamed do
215+
use Ecto.Schema
216+
217+
@primary_key false
218+
schema "insert_select_renamed" do
219+
field :name, :string, source: :renamed_name
220+
field :value, :string
221+
end
222+
end
223+
224+
defmodule InsertSelectReadOnly do
225+
use Ecto.Schema
226+
227+
@primary_key false
228+
schema "insert_select_read_only" do
229+
field :name, :string, source: :source_name, writable: :never
230+
field :value, :string
231+
end
232+
end
233+
234+
defmodule InsertSelectDisjointSource do
235+
use Ecto.Schema
236+
237+
@primary_key false
238+
schema "insert_select_disjoint_source" do
239+
field :b, :string, source: :src_b
240+
field :c, :string, source: :src_c
241+
field :a, :string, source: :src_a
242+
field :only_source, :string
243+
end
244+
end
245+
246+
defmodule InsertSelectDisjointDestination do
247+
use Ecto.Schema
248+
249+
@primary_key false
250+
schema "insert_select_disjoint_destination" do
251+
field :only_destination, :string
252+
field :a, :string
253+
field :c, :string, source: :dst_c
254+
field :b, :string, source: :dst_b
255+
end
256+
end
257+
214258
test "defines child_spec/1" do
215259
assert TestRepo.child_spec([]) == %{
216260
id: TestRepo,
@@ -765,6 +809,167 @@ defmodule Ecto.RepoTest do
765809
assert header == [:id, :x, :yyy, :z, :array, :map]
766810
end
767811

812+
test "maps read-only source fields through the writable destination schema" do
813+
query = from s in InsertSelectReadOnly, select: s
814+
TestRepo.insert_all(InsertSelectRenamed, query)
815+
816+
assert_received {:insert_all, %{header: [:renamed_name, :value]},
817+
{%Ecto.Query{select: %{fields: fields}}, _params}}
818+
819+
assert [
820+
{{:., _, [{:&, _, [0]}, :source_name]}, [], []},
821+
{{:., _, [{:&, _, [0]}, :value]}, [], []}
822+
] = fields
823+
end
824+
825+
test "maps fragment columns through the destination schema" do
826+
query =
827+
from f in fragment("select 1 as name, 2 as value", columns: [:name, :value]), select: f
828+
829+
TestRepo.insert_all(InsertSelectRenamed, query)
830+
831+
assert_received {:insert_all, %{header: [:renamed_name, :value]}, {%Ecto.Query{}, _params}}
832+
end
833+
834+
test "maps subquery fields through the destination schema" do
835+
inner = from s in InsertSelectReadOnly, select: %{name: s.name, value: s.value}
836+
query = from s in subquery(inner), select: s
837+
TestRepo.insert_all(InsertSelectRenamed, query)
838+
839+
assert_received {:insert_all, %{header: [:renamed_name, :value]}, {%Ecto.Query{}, _params}}
840+
end
841+
842+
test "rejects a map update that repeats a subquery field" do
843+
inner = from s in InsertSelectReadOnly, select: %{name: s.name, value: s.value}
844+
query = from s in subquery(inner), select: %{s | value: "new"}
845+
846+
# This query shape still projects the overwritten field from the subquery.
847+
# Reject the duplicate projection; this does not add support for subquery map updates.
848+
assert_raise ArgumentError,
849+
~r/cannot generate a fields list for insert_all from the given source query:/,
850+
fn -> TestRepo.insert_all(InsertSelectRenamed, query) end
851+
end
852+
853+
test "maps values fields through the destination schema" do
854+
query =
855+
from v in values([%{name: "n", value: "v"}], %{name: :string, value: :string}),
856+
select: %{v | value: "new"}
857+
858+
TestRepo.insert_all(InsertSelectRenamed, query)
859+
860+
assert_received {:insert_all, %{header: [:renamed_name, :value]}, {%Ecto.Query{}, _params}}
861+
end
862+
863+
test "maps a joined binding through the destination schema" do
864+
query = from x in "other", join: s in InsertSelectReadOnly, on: true, select: s
865+
TestRepo.insert_all(InsertSelectRenamed, query)
866+
867+
assert_received {:insert_all, %{header: [:renamed_name, :value]}, {%Ecto.Query{}, _params}}
868+
end
869+
870+
test "keeps source columns when the destination has no schema" do
871+
query = from s in InsertSelectReadOnly, select: s
872+
TestRepo.insert_all("insert_select_renamed", query)
873+
874+
assert_received {:insert_all, %{header: [:source_name, :value]}, {%Ecto.Query{}, _params}}
875+
end
876+
877+
test "rejects full source fields that are unwritable in the destination" do
878+
query = from s in InsertSelectRenamed, select: s
879+
880+
assert_raise ArgumentError,
881+
"cannot select unwritable field `:name` for insert_all",
882+
fn -> TestRepo.insert_all(InsertSelectReadOnly, query) end
883+
end
884+
885+
test "maps unchanged map update fields through the destination schema" do
886+
query = from s in InsertSelectReadOnly, select: %{s | value: s.value}
887+
TestRepo.insert_all(InsertSelectRenamed, query)
888+
889+
assert_received {:insert_all, %{header: [:renamed_name, :value]},
890+
{%Ecto.Query{select: %{fields: fields}}, _params}}
891+
892+
assert [
893+
{{:., _, [{:&, _, [0]}, :source_name]}, [], []},
894+
{{:., _, [{:&, _, [0]}, :value]}, [], []}
895+
] = fields
896+
end
897+
898+
test "does not include an overwritten source field twice when columns differ" do
899+
query = from s in InsertSelectReadOnly, select: %{s | name: s.name}
900+
TestRepo.insert_all(InsertSelectRenamed, query)
901+
902+
assert_received {:insert_all, %{header: [:value, :renamed_name]}, {%Ecto.Query{}, _params}}
903+
end
904+
905+
test "rejects unchanged map update fields that are unwritable in the destination" do
906+
query = from s in InsertSelectRenamed, select: %{s | value: s.value}
907+
908+
assert_raise ArgumentError,
909+
"cannot select unwritable field `:name` for insert_all",
910+
fn -> TestRepo.insert_all(InsertSelectReadOnly, query) end
911+
end
912+
913+
test "rejects map updates whose values expand to multiple select fields" do
914+
query = from s in InsertSelectReadOnly, select: %{s | value: {s.name, s.value}}
915+
916+
assert_raise ArgumentError,
917+
~r/cannot generate a fields list for insert_all from the given source query:/,
918+
fn -> TestRepo.insert_all(InsertSelectRenamed, query) end
919+
end
920+
921+
test "maps a reordered source subset independently of destination field order" do
922+
query =
923+
from s in InsertSelectDisjointSource,
924+
select: %{map(s, [:b, :c, :a]) | a: fragment("'x'")}
925+
926+
TestRepo.insert_all(InsertSelectDisjointDestination, query)
927+
928+
assert_received {:insert_all, %{header: [:dst_b, :dst_c, :a]},
929+
{%Ecto.Query{select: %{fields: fields}}, _params}}
930+
931+
assert [
932+
{{:., _, [{:&, _, [0]}, :src_b]}, [], []},
933+
{{:., _, [{:&, _, [0]}, :src_c]}, [], []},
934+
{:fragment, _, _}
935+
] = fields
936+
end
937+
938+
test "allows destination-only map updates" do
939+
query =
940+
from s in InsertSelectDisjointSource,
941+
select: %{map(s, [:b, :c]) | a: s.c, only_destination: s.b}
942+
943+
TestRepo.insert_all(InsertSelectDisjointDestination, query)
944+
945+
assert_received {:insert_all, %{header: [:dst_b, :dst_c, :a, :only_destination]},
946+
{%Ecto.Query{}, _params}}
947+
end
948+
949+
test "reports source-only fields" do
950+
query = from s in InsertSelectDisjointSource, select: s
951+
952+
assert_raise ArgumentError,
953+
"cannot select unknown field `:only_source` for insert_all",
954+
fn -> TestRepo.insert_all(InsertSelectDisjointDestination, query) end
955+
end
956+
957+
test "rejects whole schemaless bindings with a select error" do
958+
query = from s in "insert_select_source", select: s
959+
960+
assert_raise ArgumentError,
961+
~r/cannot generate a fields list for insert_all from the given source query:/,
962+
fn -> TestRepo.insert_all(InsertSelectRenamed, query) end
963+
end
964+
965+
test "rejects non-atom insert select keys with a clear error" do
966+
query = from s in InsertSelectReadOnly, select: %{"name" => s.name}
967+
968+
assert_raise ArgumentError,
969+
"cannot select non-atom field `\"name\"` for insert_all",
970+
fn -> TestRepo.insert_all(InsertSelectRenamed, query) end
971+
end
972+
768973
test "takes query selecting on source with join" do
769974
query = from p in MyParent, join: a in MySchemaWithAssoc, on: true, select: a
770975
TestRepo.insert_all(MySchemaWithAssoc, query)

0 commit comments

Comments
 (0)