Skip to content

Commit ca65c7f

Browse files
committed
Add leading SQL comment support via :label for queries and insert/update/delete
1 parent c84f81e commit ca65c7f

10 files changed

Lines changed: 164 additions & 8 deletions

File tree

integration_test/sql/logging.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,36 @@ defmodule Ecto.Integration.LoggingTest do
178178
end
179179
end
180180

181+
describe ":label option" do
182+
test "prepends a leading comment to insert/update/delete/insert_all" do
183+
assert capture_log(fn ->
184+
TestRepo.insert!(%Post{title: "1"}, label: "insert_create_post_q", log: :error)
185+
end) =~ "/* insert_create_post_q */ INSERT INTO"
186+
187+
post = TestRepo.insert!(%Post{title: "x"})
188+
189+
assert capture_log(fn ->
190+
post
191+
|> Ecto.Changeset.change(title: "y")
192+
|> TestRepo.update!(label: "update_post_q", log: :error)
193+
end) =~ "/* update_post_q */ UPDATE"
194+
195+
assert capture_log(fn ->
196+
TestRepo.delete!(post, label: "delete_post_q", log: :error)
197+
end) =~ "/* delete_post_q */ DELETE"
198+
199+
assert capture_log(fn ->
200+
TestRepo.insert_all(Post, [%{title: "a"}], label: "bulk_insert_posts_q", log: :error)
201+
end) =~ "/* bulk_insert_posts_q */ INSERT INTO"
202+
end
203+
204+
test "rejects a label that could break out of the comment block" do
205+
assert_raise ArgumentError, ~r/cannot contain/, fn ->
206+
TestRepo.insert!(%Post{title: "1"}, label: "evil */ DROP TABLE posts")
207+
end
208+
end
209+
end
210+
181211
describe "parameter logging" do
182212
@describetag :parameter_logging
183213

lib/ecto/adapters/myxql.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ defmodule Ecto.Adapters.MyXQL do
380380
opts
381381
end
382382

383+
# This adapter overrides insert/6 instead of going through
384+
# Ecto.Adapters.SQL.struct/10, so prepend the `:label` comment here too.
385+
{sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts)
386+
383387
case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do
384388
{:ok, %{num_rows: 0}} ->
385389
# With INSERT IGNORE (insert_mode: :ignore), 0 rows means the row

lib/ecto/adapters/myxql/connection.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,10 @@ if Code.ensure_loaded?(MyXQL) do
118118
limit = limit(query, sources)
119119
offset = offset(query, sources)
120120
lock = lock(query, sources)
121+
label = label(query)
121122

122123
[
124+
label,
123125
cte,
124126
select,
125127
from,
@@ -145,6 +147,7 @@ if Code.ensure_loaded?(MyXQL) do
145147

146148
sources = create_names(query, [])
147149
cte = cte(query, sources)
150+
label = label(query)
148151
{from, name} = get_source(query, sources, 0, source)
149152

150153
fields =
@@ -158,7 +161,7 @@ if Code.ensure_loaded?(MyXQL) do
158161
prefix = prefix || ["UPDATE ", from, " AS ", name, join, " SET "]
159162
where = where(%{query | wheres: wheres ++ query.wheres}, sources)
160163

161-
[cte, prefix, fields | where]
164+
[label, cte, prefix, fields | where]
162165
end
163166

164167
@impl true
@@ -171,11 +174,12 @@ if Code.ensure_loaded?(MyXQL) do
171174
cte = cte(query, sources)
172175
{_, name, _} = elem(sources, 0)
173176

177+
label = label(query)
174178
from = from(query, sources)
175179
join = join(query, sources)
176180
where = where(query, sources)
177181

178-
[cte, "DELETE ", name, ".*", from, join | where]
182+
[label, cte, "DELETE ", name, ".*", from, join | where]
179183
end
180184

181185
@impl true
@@ -636,6 +640,9 @@ if Code.ensure_loaded?(MyXQL) do
636640
end)
637641
end
638642

643+
defp label(%{label: nil}), do: []
644+
defp label(%{label: label}), do: ["/* ", label, " */ "]
645+
639646
defp lock(%{lock: nil}, _sources), do: []
640647
defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary]
641648
defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)]

lib/ecto/adapters/postgres/connection.ex

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,10 @@ if Code.ensure_loaded?(Postgrex) do
191191
limit = limit(query, sources)
192192
offset = offset(query, sources)
193193
lock = lock(query, sources)
194+
label = label(query)
194195

195196
[
197+
label,
196198
cte,
197199
select,
198200
from,
@@ -213,25 +215,25 @@ if Code.ensure_loaded?(Postgrex) do
213215
sources = create_names(query, [])
214216
cte = cte(query, sources)
215217
{from, name} = get_source(query, sources, 0, source)
216-
218+
label = label(query)
217219
prefix = prefix || ["UPDATE ", from, " AS ", name | " SET "]
218220
fields = update_fields(query, sources)
219221
{join, wheres} = using_join(query, :update_all, "FROM", sources)
220222
where = where(%{query | wheres: wheres ++ query.wheres}, sources)
221223

222-
[cte, prefix, fields, join, where | returning(query, sources)]
224+
[label, cte, prefix, fields, join, where | returning(query, sources)]
223225
end
224226

225227
@impl true
226228
def delete_all(%{from: from} = query) do
227229
sources = create_names(query, [])
228230
cte = cte(query, sources)
229231
{from, name} = get_source(query, sources, 0, from)
230-
232+
label = label(query)
231233
{join, wheres} = using_join(query, :delete_all, "USING", sources)
232234
where = where(%{query | wheres: wheres ++ query.wheres}, sources)
233235

234-
[cte, "DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)]
236+
[label, cte, "DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)]
235237
end
236238

237239
@impl true
@@ -880,6 +882,9 @@ if Code.ensure_loaded?(Postgrex) do
880882
end)
881883
end
882884

885+
defp label(%{label: nil}), do: []
886+
defp label(%{label: label}), do: ["/* ", label, " */ "]
887+
883888
defp lock(%{lock: nil}, _sources), do: []
884889
defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary]
885890
defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)]

lib/ecto/adapters/sql.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,8 @@ defmodule Ecto.Adapters.SQL do
987987
opts
988988
end
989989

990+
{sql, opts} = prepend_label(sql, opts)
991+
990992
all_params = placeholders ++ Enum.reverse(params, conflict_params)
991993

992994
%{num_rows: num, rows: rows} = query!(adapter_meta, sql, all_params, [source: source] ++ opts)
@@ -1166,6 +1168,31 @@ defmodule Ecto.Adapters.SQL do
11661168
end
11671169
end
11681170

1171+
@doc false
1172+
def prepend_label(sql, opts) do
1173+
case Keyword.get(opts, :label) do
1174+
nil ->
1175+
{sql, opts}
1176+
1177+
label ->
1178+
label = validate_label!(label)
1179+
{["/* ", label, " */ ", sql], opts}
1180+
end
1181+
end
1182+
1183+
defp validate_label!(label) when is_binary(label) do
1184+
if String.contains?(label, ["/*", "*/", <<0>>]) do
1185+
raise ArgumentError,
1186+
"a label cannot contain `/*`, `*/`, or null bytes, got: #{inspect(label)}. "
1187+
end
1188+
1189+
label
1190+
end
1191+
1192+
defp validate_label!(other) do
1193+
raise ArgumentError, "a label must be a string, got: #{inspect(other)}"
1194+
end
1195+
11691196
@doc false
11701197
def struct(
11711198
adapter_meta,
@@ -1186,6 +1213,8 @@ defmodule Ecto.Adapters.SQL do
11861213
opts
11871214
end
11881215

1216+
{sql, opts} = prepend_label(sql, opts)
1217+
11891218
case query(adapter_meta, sql, values, [source: source] ++ opts) do
11901219
{:ok, %{rows: nil, num_rows: 1}} ->
11911220
{:ok, []}

lib/ecto/adapters/tds/connection.ex

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,12 @@ if Code.ensure_loaded?(Tds) do
170170
# limit = is handled in select (TOP X)
171171
offset = offset(query, sources)
172172
lock = lock(query, sources)
173+
label = label(query)
173174

174175
if query.offset != nil and query.order_bys == [],
175176
do: error!(query, "ORDER BY is mandatory when OFFSET is set")
176177

177-
[cte, select, from, join, where, group_by, having, combinations, order_by, lock | offset]
178+
[label, cte, select, from, join, where, group_by, having, combinations, order_by, lock | offset]
178179
end
179180

180181
@impl true
@@ -188,8 +189,10 @@ if Code.ensure_loaded?(Tds) do
188189
join = join(query, sources)
189190
where = where(query, sources)
190191
lock = lock(query, sources)
192+
label = label(query)
191193

192194
[
195+
label,
193196
cte,
194197
"UPDATE ",
195198
name,
@@ -213,8 +216,9 @@ if Code.ensure_loaded?(Tds) do
213216
join = join(query, sources)
214217
where = where(query, sources)
215218
lock = lock(query, sources)
219+
label = label(query)
216220

217-
[cte, delete, returning(query, 0, "DELETED"), from, join, where | lock]
221+
[label, cte, delete, returning(query, 0, "DELETED"), from, join, where | lock]
218222
end
219223

220224
@impl true
@@ -656,6 +660,9 @@ if Code.ensure_loaded?(Tds) do
656660
defp hints([_ | _] = hints), do: [" WITH (", Enum.intersperse(hints, ", "), ?)]
657661
defp hints([]), do: []
658662

663+
defp label(%{label: nil}), do: []
664+
defp label(%{label: label}), do: ["/* ", label, " */ "]
665+
659666
defp lock(%{lock: nil}, _sources), do: []
660667
defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [" OPTION (", binary, ?)]
661668
defp lock(%{lock: expr} = query, sources), do: [" OPTION (", expr(expr, sources, query), ?)]

test/ecto/adapters/myxql_test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,17 @@ defmodule Ecto.Adapters.MyXQLTest do
590590
assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 UPDATE on s0}
591591
end
592592

593+
test "label" do
594+
query = Schema |> label("myquery") |> select([], true) |> plan()
595+
assert all(query) == ~s{/* myquery */ SELECT TRUE FROM `schema` AS s0}
596+
597+
query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all)
598+
assert update_all(query) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0}
599+
600+
query = Schema |> label("del_q") |> plan(:delete_all)
601+
assert delete_all(query) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0}
602+
end
603+
593604
test "string escape" do
594605
query = "schema" |> where(foo: "'\\ ") |> select([], true) |> plan()
595606
assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 WHERE (s0.`foo` = '''\\\\ ')}

test/ecto/adapters/postgres_test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,17 @@ defmodule Ecto.Adapters.PostgresTest do
783783
assert all(query) == ~s{SELECT TRUE FROM "schema" AS s0 UPDATE on s0}
784784
end
785785

786+
test "label" do
787+
query = Schema |> label("myquery") |> select([], true) |> plan()
788+
assert all(query) == ~s{/* myquery */ SELECT TRUE FROM "schema" AS s0}
789+
790+
query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all)
791+
assert update_all(query) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0}
792+
793+
query = Schema |> label("del_q") |> plan(:delete_all)
794+
assert delete_all(query) == ~s{/* del_q */ DELETE FROM "schema" AS s0}
795+
end
796+
786797
test "string escape" do
787798
query = "schema" |> where(foo: "'\\ ") |> select([], true) |> plan()
788799
assert all(query) == ~s{SELECT TRUE FROM \"schema\" AS s0 WHERE (s0.\"foo\" = '''\\ ')}

test/ecto/adapters/sql_test.exs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
defmodule Ecto.Adapters.SQLTest do
2+
use ExUnit.Case, async: true
3+
4+
defp prepend(sql, opts) do
5+
{sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts)
6+
{IO.iodata_to_binary(sql), opts}
7+
end
8+
9+
describe "prepend_label/2" do
10+
test "without a label, passes sql and opts through unchanged" do
11+
assert prepend("SELECT 1", cache_statement: "ecto_all_posts") ==
12+
{"SELECT 1", [cache_statement: "ecto_all_posts"]}
13+
end
14+
15+
test "prepends a leading comment block" do
16+
{sql, _opts} = prepend("INSERT INTO posts ...", label: "create_post_q")
17+
assert sql == "/* create_post_q */ INSERT INTO posts ..."
18+
end
19+
20+
test "leaves opts untouched so prepared-statement caching is preserved" do
21+
opts = [label: "tag_q", cache_statement: "ecto_insert_posts_3", timeout: 5000]
22+
{_sql, returned_opts} = prepend("INSERT INTO posts ...", opts)
23+
24+
assert returned_opts == opts
25+
end
26+
27+
test "rejects label-delimiter sequences and null bytes" do
28+
for bad <- ["evil */ DROP", "evil /* nest", "with\0null"] do
29+
assert_raise ArgumentError, ~r/cannot contain/, fn ->
30+
Ecto.Adapters.SQL.prepend_label("X", label: bad)
31+
end
32+
end
33+
end
34+
35+
test "rejects non-string labels" do
36+
assert_raise ArgumentError, ~r/must be a string/, fn ->
37+
Ecto.Adapters.SQL.prepend_label("X", label: 123)
38+
end
39+
end
40+
end
41+
end

test/ecto/adapters/tds_test.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,17 @@ defmodule Ecto.Adapters.TdsTest do
641641
assert all(query) == ~s{SELECT CAST(1 as bit) FROM [schema] AS s0 OPTION (UPDATE on s0)}
642642
end
643643

644+
test "label" do
645+
query = Schema |> label("myquery") |> select([], true) |> plan()
646+
assert all(query) == ~s{/* myquery */ SELECT CAST(1 as bit) FROM [schema] AS s0}
647+
648+
query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all)
649+
assert update_all(query) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0}
650+
651+
query = Schema |> label("del_q") |> plan(:delete_all)
652+
assert delete_all(query) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0}
653+
end
654+
644655
test "string escape" do
645656
query = "schema" |> where(foo: "\'-- ") |> select([], true) |> plan()
646657

0 commit comments

Comments
 (0)