Skip to content

Commit a9376cc

Browse files
authored
feat (Repo): Raise an error on query-like keyword opts to Repo functions (#4717)
Consider the following, written by a new-to-Elixir developer: ```elixir Repo.delete_all(User, where: [account_id: user.account_id]) ``` The developer mistakenly believed that the `opts` to the Repo function worked like the Ecto.Query DSL, when in fact these keyword options were entirely ignored (making this an unscoped deletion of all users). With this change, Ecto will raise a runtime error when given `opts` keys that seem to indicate the user was trying to modify the queryable passed in to: - Repo.all/2 - Repo.all_by/3 - Repo.delete_all/2 - Repo.exists?/2 - Repo.get/3 - Repo.get_by/3 - Repo.one/2 - Repo.stream/2
1 parent d962b66 commit a9376cc

File tree

2 files changed

+106
-0
lines changed

2 files changed

+106
-0
lines changed

lib/ecto/repo.ex

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,37 @@ defmodule Ecto.Repo do
326326
|> Keyword.merge(opts)
327327
end
328328

329+
# Keyword list keys a user may try to pass to something like Repo.all/2, under
330+
# the mistaken belief that the keyword arguments operate like the Ecto.Query DSL.
331+
# We want to proactively raise an error on something like:
332+
#
333+
# Repo.delete_all(User, where: [id: user.id])
334+
#
335+
# ...since the query will be unscoped in a way that the caller almost certainly
336+
# did not intend.
337+
@unsupported_query_opts [
338+
:where,
339+
:preload,
340+
:order_by,
341+
:limit,
342+
:group_by,
343+
:distinct,
344+
:select,
345+
:update
346+
]
347+
348+
defp validate_query_opts!(opts, function_name) do
349+
Enum.each(opts, fn
350+
{key, _} when key in @unsupported_query_opts ->
351+
raise ArgumentError,
352+
"unsupported option #{inspect(key)} for Repo.#{function_name}. " <>
353+
"Instead, use Ecto.Query to build a query, and pass that query into this function."
354+
355+
_ ->
356+
:ok
357+
end)
358+
end
359+
329360
## Transactions
330361

331362
if Ecto.Adapter.Transaction in behaviours do
@@ -491,6 +522,8 @@ defmodule Ecto.Repo do
491522
end
492523

493524
def delete_all(queryable, opts \\ []) do
525+
validate_query_opts!(opts, :delete_all)
526+
494527
repo = get_dynamic_repo()
495528

496529
Ecto.Repo.Queryable.delete_all(
@@ -502,6 +535,8 @@ defmodule Ecto.Repo do
502535
end
503536

504537
def all(queryable, opts \\ []) do
538+
validate_query_opts!(opts, :all)
539+
505540
repo = get_dynamic_repo()
506541

507542
Ecto.Repo.Queryable.all(
@@ -512,6 +547,8 @@ defmodule Ecto.Repo do
512547
end
513548

514549
def all_by(queryable, clauses, opts \\ []) do
550+
validate_query_opts!(opts, :all_by)
551+
515552
repo = get_dynamic_repo()
516553

517554
Ecto.Repo.Queryable.all_by(
@@ -523,6 +560,8 @@ defmodule Ecto.Repo do
523560
end
524561

525562
def stream(queryable, opts \\ []) do
563+
validate_query_opts!(opts, :stream)
564+
526565
repo = get_dynamic_repo()
527566

528567
Ecto.Repo.Queryable.stream(
@@ -533,6 +572,8 @@ defmodule Ecto.Repo do
533572
end
534573

535574
def get(queryable, id, opts \\ []) do
575+
validate_query_opts!(opts, :get)
576+
536577
repo = get_dynamic_repo()
537578

538579
Ecto.Repo.Queryable.get(
@@ -555,6 +596,8 @@ defmodule Ecto.Repo do
555596
end
556597

557598
def get_by(queryable, clauses, opts \\ []) do
599+
validate_query_opts!(opts, :get_by)
600+
558601
repo = get_dynamic_repo()
559602

560603
Ecto.Repo.Queryable.get_by(
@@ -597,6 +640,8 @@ defmodule Ecto.Repo do
597640
end
598641

599642
def one(queryable, opts \\ []) do
643+
validate_query_opts!(opts, :one)
644+
600645
repo = get_dynamic_repo()
601646

602647
Ecto.Repo.Queryable.one(
@@ -657,6 +702,8 @@ defmodule Ecto.Repo do
657702
end
658703

659704
def exists?(queryable, opts \\ []) do
705+
validate_query_opts!(opts, :exists?)
706+
660707
repo = get_dynamic_repo()
661708

662709
Ecto.Repo.Queryable.exists?(

test/ecto/repo_test.exs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,63 @@ defmodule Ecto.RepoTest do
16001600
assert schema.__meta__.prefix == %{key: :private}
16011601
end
16021602

1603+
test "get, get_by, one, all, all_by, exists?, stream, and delete_all raise an error when given Ecto.Query-like opts" do
1604+
for {unsupported_option, bad_opts} <- [
1605+
{:where, [where: [user_id: Ecto.UUID.generate()]]},
1606+
{:preload, [preload: :users]},
1607+
{:order_by, [order_by: :name]},
1608+
{:limit, [limit: 10]}
1609+
] do
1610+
assert_raise ArgumentError,
1611+
~r"unsupported option #{inspect(unsupported_option)} for Repo.get",
1612+
fn ->
1613+
TestRepo.get(MySchema, 123, bad_opts)
1614+
end
1615+
1616+
assert_raise ArgumentError,
1617+
~r"unsupported option #{inspect(unsupported_option)} for Repo.get_by",
1618+
fn ->
1619+
TestRepo.get_by(MySchema, [id: 123], bad_opts)
1620+
end
1621+
1622+
assert_raise ArgumentError,
1623+
~r"unsupported option #{inspect(unsupported_option)} for Repo.one",
1624+
fn ->
1625+
TestRepo.one(MySchema, bad_opts)
1626+
end
1627+
1628+
assert_raise ArgumentError,
1629+
~r"unsupported option #{inspect(unsupported_option)} for Repo.all",
1630+
fn ->
1631+
TestRepo.all(MySchema, bad_opts)
1632+
end
1633+
1634+
assert_raise ArgumentError,
1635+
~r"unsupported option #{inspect(unsupported_option)} for Repo.all_by",
1636+
fn ->
1637+
TestRepo.all_by(MySchema, [id: 123], bad_opts)
1638+
end
1639+
1640+
assert_raise ArgumentError,
1641+
~r"unsupported option #{inspect(unsupported_option)} for Repo.exists?",
1642+
fn ->
1643+
TestRepo.exists?(MySchema, bad_opts)
1644+
end
1645+
1646+
assert_raise ArgumentError,
1647+
~r"unsupported option #{inspect(unsupported_option)} for Repo.stream",
1648+
fn ->
1649+
TestRepo.stream(MySchema, bad_opts)
1650+
end
1651+
1652+
assert_raise ArgumentError,
1653+
~r"unsupported option #{inspect(unsupported_option)} for Repo.delete_all",
1654+
fn ->
1655+
TestRepo.delete_all(MySchema, bad_opts)
1656+
end
1657+
end
1658+
end
1659+
16031660
describe "changeset prepare" do
16041661
defp prepare_changeset() do
16051662
%MySchema{id: 1}
@@ -1970,7 +2027,9 @@ defmodule Ecto.RepoTest do
19702027

19712028
test "includes conflict target in :replace_all when replace_changed is false" do
19722029
fields = [:map, :array, :z, :yyy, :x, :id]
2030+
19732031
TestRepo.insert(%MySchema{id: 1}, on_conflict: :replace_all, conflict_target: [:id], replace_changed: false)
2032+
19742033
assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], [:id]}}}
19752034
end
19762035

0 commit comments

Comments
 (0)