Skip to content

Commit dc5c18c

Browse files
committed
Support pre_comment and post_comment options in ecto.
1 parent 76da993 commit dc5c18c

8 files changed

Lines changed: 251 additions & 54 deletions

File tree

lib/ecto/query.ex

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ defmodule Ecto.Query do
415415
lock: nil,
416416
windows: [],
417417
with_ctes: nil,
418-
label: nil
418+
comments: []
419419

420420
defmodule FromExpr do
421421
@moduledoc false
@@ -956,6 +956,7 @@ defmodule Ecto.Query do
956956
Ecto.Query.exclude(query, :limit)
957957
Ecto.Query.exclude(query, :offset)
958958
Ecto.Query.exclude(query, :lock)
959+
Ecto.Query.exclude(query, :comments)
959960
Ecto.Query.exclude(query, :preload)
960961
Ecto.Query.exclude(query, :update)
961962
Ecto.Query.exclude(query, :windows)
@@ -1025,6 +1026,7 @@ defmodule Ecto.Query do
10251026
defp do_exclude(%Ecto.Query{} = query, :limit), do: %{query | limit: nil}
10261027
defp do_exclude(%Ecto.Query{} = query, :offset), do: %{query | offset: nil}
10271028
defp do_exclude(%Ecto.Query{} = query, :lock), do: %{query | lock: nil}
1029+
defp do_exclude(%Ecto.Query{} = query, :comments), do: %{query | comments: []}
10281030
defp do_exclude(%Ecto.Query{} = query, :preload), do: %{query | preloads: [], assocs: []}
10291031
defp do_exclude(%Ecto.Query{} = query, :update), do: %{query | updates: []}
10301032
defp do_exclude(%Ecto.Query{} = query, :windows), do: %{query | windows: []}
@@ -1151,7 +1153,8 @@ defmodule Ecto.Query do
11511153
end
11521154

11531155
@from_join_opts [:as, :prefix, :hints]
1154-
@no_binds [:union, :union_all, :except, :except_all, :intersect, :intersect_all]
1156+
@no_binds [:union, :union_all, :except, :except_all, :intersect, :intersect_all] ++
1157+
[:pre_comment, :post_comment]
11551158
@binds [:lock, :where, :or_where, :select, :distinct, :order_by, :group_by, :windows] ++
11561159
[:having, :or_having, :limit, :offset, :preload, :update, :select_merge, :with_ctes]
11571160

@@ -2538,6 +2541,56 @@ defmodule Ecto.Query do
25382541
Builder.Lock.build(query, binding, expr, __CALLER__)
25392542
end
25402543

2544+
@doc ~S"""
2545+
Adds a comment *before* the generated statement.
2546+
2547+
The text is rendered as a leading SQL comment, immediately before the
2548+
statement keyword:
2549+
2550+
/* list_users */ SELECT ...
2551+
2552+
This is useful to tag and identify queries in database logs and monitoring
2553+
tools. A leading comment (rather than a trailing one, see `post_comment/2`)
2554+
survives truncation of long statements in logs.
2555+
2556+
The comment must be a compile-time literal string, so the set of comments for
2557+
a given query stays bounded and the query remains safely cacheable. It is
2558+
embedded verbatim and therefore cannot contain `/*`, `*/`, or null bytes. For
2559+
dynamic comments, use the `:comments` option on the repository operation
2560+
instead. Multiple comments may be added and are rendered in order.
2561+
2562+
## Keywords example
2563+
2564+
from(p in Post, pre_comment: "list_posts", select: p.title)
2565+
2566+
## Expressions example
2567+
2568+
Post |> pre_comment("list_posts") |> select([p], p.title)
2569+
"""
2570+
defmacro pre_comment(query, expr) do
2571+
Builder.Comment.build(:pre, query, expr, __CALLER__)
2572+
end
2573+
2574+
@doc ~S"""
2575+
Adds a comment *after* the generated statement.
2576+
2577+
Works like `pre_comment/2` but renders the text as a trailing SQL comment,
2578+
after the statement (the SQLCommenter convention):
2579+
2580+
SELECT ... /* list_users */
2581+
2582+
## Keywords example
2583+
2584+
from(p in Post, post_comment: "list_posts", select: p.title)
2585+
2586+
## Expressions example
2587+
2588+
Post |> post_comment("list_posts") |> select([p], p.title)
2589+
"""
2590+
defmacro post_comment(query, expr) do
2591+
Builder.Comment.build(:post, query, expr, __CALLER__)
2592+
end
2593+
25412594

25422595
@doc ~S"""
25432596
An update query expression.

lib/ecto/query/builder/comment.ex

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Kernel, except: [apply: 3]
2+
3+
defmodule Ecto.Query.Builder.Comment do
4+
@moduledoc false
5+
6+
alias Ecto.Query.Builder
7+
8+
# A comment is rendered verbatim inside a `/* ... */` SQL comment, so the only
9+
# way for its text to escape into executable SQL is to manipulate the comment
10+
# delimiters. Reject `*/` (closes the block early) and `/*` (opens a nested
11+
# block that, where comments nest, swallows the closing `*/`), plus null bytes.
12+
@forbidden ["*/", "/*", <<0>>]
13+
14+
@doc """
15+
Escapes the comment text.
16+
17+
iex> escape("my-query")
18+
"my-query"
19+
20+
"""
21+
@spec escape(Macro.t()) :: Macro.t()
22+
def escape(comment) when is_binary(comment) do
23+
if String.contains?(comment, @forbidden) do
24+
Builder.error!(
25+
"a comment cannot contain `/*`, `*/`, or null bytes, got: `#{comment}`. "
26+
)
27+
end
28+
29+
comment
30+
end
31+
32+
def escape({:^, _, [_]}) do
33+
Builder.error!(
34+
"interpolation is not allowed in a query comment. " <>
35+
"Comments must be compile-time literal strings so they stay a bounded set " <>
36+
"and remain safe to cache. For dynamic comments use the `:comments` repo option"
37+
)
38+
end
39+
40+
def escape(other) do
41+
Builder.error!("`#{Macro.to_string(other)}` is not a valid comment, it must be a literal string")
42+
end
43+
44+
@doc """
45+
Builds a quoted expression that appends a `{position, comment}` to the query.
46+
"""
47+
@spec build(:pre | :post, Macro.t(), Macro.t(), Macro.Env.t()) :: Macro.t()
48+
def build(position, query, expr, env) do
49+
Builder.apply_query(query, __MODULE__, [position, escape(expr)], env)
50+
end
51+
52+
@doc """
53+
The callback applied by `build/4` to build the query.
54+
"""
55+
@spec apply(Ecto.Queryable.t(), :pre | :post, term) :: Ecto.Query.t()
56+
def apply(%Ecto.Query{} = query, position, value) do
57+
%{query | comments: query.comments ++ [{position, value}]}
58+
end
59+
60+
def apply(query, position, value) do
61+
apply(Ecto.Queryable.to_query(query), position, value)
62+
end
63+
end

lib/ecto/query/inspect.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ defimpl Inspect, for: Ecto.Query do
124124
updates = kw_exprs(:update, query.updates, names)
125125

126126
lock = kw_inspect(:lock, query.lock)
127+
comments = comments(query.comments)
127128
offset = kw_expr(:offset, query.offset, names)
128129
select = kw_expr(:select, query.select, names)
129130
distinct = kw_expr(:distinct, query.distinct, names)
@@ -140,6 +141,7 @@ defimpl Inspect, for: Ecto.Query do
140141
limit,
141142
offset,
142143
lock,
144+
comments,
143145
distinct,
144146
updates,
145147
select,
@@ -248,6 +250,10 @@ defimpl Inspect, for: Ecto.Query do
248250
defp kw_inspect(_key, nil), do: []
249251
defp kw_inspect(key, val), do: [{key, inspect(val)}]
250252

253+
defp comments(comments) do
254+
for {position, comment} <- comments, do: {:"#{position}_comment", inspect(comment)}
255+
end
256+
251257
defp kw_as_and_prefix(%{as: as, prefix: prefix}) do
252258
kw_inspect(:as, as) ++ kw_inspect(:prefix, prefix)
253259
end

lib/ecto/query/planner.ex

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,7 @@ defmodule Ecto.Query.Planner do
10731073
end
10741074

10751075
defp finalize_cache(query, operation, cache) do
1076-
%{assocs: assocs, prefix: prefix, lock: lock, label: label, select: select, aliases: aliases} =
1076+
%{assocs: assocs, prefix: prefix, lock: lock, comments: comments, select: select, aliases: aliases} =
10771077
query
10781078
aliases = Map.delete(aliases, @parent_as)
10791079

@@ -1091,7 +1091,7 @@ defmodule Ecto.Query.Planner do
10911091
|> prepend_if(assocs != [], assocs: assocs)
10921092
|> prepend_if(prefix != nil, prefix: prefix)
10931093
|> prepend_if(lock != nil, lock: lock)
1094-
|> prepend_if(label != nil, label: label)
1094+
|> prepend_if(comments != [], comments: comments)
10951095
|> prepend_if(aliases != %{}, aliases: aliases)
10961096

10971097
[operation | cache]
@@ -2384,34 +2384,23 @@ defmodule Ecto.Query.Planner do
23842384
def attach_prefix(query, _), do: query
23852385

23862386
@doc """
2387-
Puts the label given via `opts` into the given query, if available.
2387+
Appends the comments given via the `:comments` option into the query.
23882388
2389-
The label is rendered by the adapter as a leading `/* ... */` SQL comment
2390-
and becomes part of the query cache key. It is embedded verbatim, so it may
2391-
not contain `/*`, `*/`, or null bytes.
2389+
The option is a keyword list of `[pre: string, post: string]` entries, which
2390+
share the representation of the query's `comments` field. They become part of
2391+
the query cache key and are rendered by the adapter as `/* ... */` comments.
2392+
Validating and escaping them is left to the adapter, since the comment syntax
2393+
(and therefore what is unsafe) depends on the adapter.
23922394
"""
2393-
def attach_label(%{label: nil} = query, opts) when is_list(opts) do
2394-
case Keyword.fetch(opts, :label) do
2395-
{:ok, label} -> %{query | label: validate_label!(label)}
2395+
def attach_comments(query, opts) when is_list(opts) do
2396+
case Keyword.fetch(opts, :comments) do
2397+
{:ok, comments} when is_list(comments) -> %{query | comments: query.comments ++ comments}
2398+
{:ok, other} -> raise ArgumentError, "the :comments option must be a keyword list of [pre: string, post: string], got: #{inspect(other)}"
23962399
:error -> query
23972400
end
23982401
end
23992402

2400-
def attach_label(query, _), do: query
2401-
2402-
defp validate_label!(label) when is_binary(label) do
2403-
if String.contains?(label, ["/*", "*/", <<0>>]) do
2404-
raise ArgumentError,
2405-
"a label cannot contain `/*`, `*/`, or null bytes, got: #{inspect(label)}. " <>
2406-
"Allowing them would let the label break out of the surrounding `/* */` comment"
2407-
end
2408-
2409-
label
2410-
end
2411-
2412-
defp validate_label!(other) do
2413-
raise ArgumentError, "a label must be a string, got: #{inspect(other)}"
2414-
end
2403+
def attach_comments(query, _), do: query
24152404

24162405
## Helpers
24172406

lib/ecto/repo.ex

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,17 @@ defmodule Ecto.Repo do
141141
in the cache and no cache update function will not be passed to the adapter. Note that
142142
this doesn't necessarily disable the database cache, it only affects Ecto's internal
143143
cache of normalized queries and adapter prepared statements. Defaults to `true`.
144-
* `:label` - A string embedded into the generated statement as a leading SQL comment,
145-
such as `/* import_users */ INSERT ...`, to identify the query in database logs and
146-
monitoring tools. It is rendered leading (rather than trailing) so it survives truncation
147-
of long statements in logs. The string is embedded verbatim and therefore cannot contain
148-
`/*`, `*/`, or null bytes. The label becomes part of the query cache key, so prefer a
149-
stable identifier per call site over highly dynamic values such as per-request ids.
144+
* `:comments` - A keyword list of `[pre: string, post: string]` entries embedded into the
145+
generated statement as `/* ... */` SQL comments to identify the query in database logs and
146+
monitoring tools. `:pre` comments are rendered before the statement (e.g.
147+
`/* import_users */ INSERT ...`) so they survive truncation of long statements in logs;
148+
`:post` comments are rendered after it. Strings are embedded verbatim and therefore cannot
149+
contain `/*`, `*/`, or null bytes. Comments become part of the query cache key, so prefer
150+
stable identifiers per call site over highly dynamic values such as per-request ids. If you
151+
do pass dynamic comments to a query operation (`all`/`update_all`/`delete_all`), combine them
152+
with `query_cache: false` so the unbounded set of comments does not grow Ecto's query cache
153+
and the adapter's prepared statements. For query expressions you can also use
154+
`Ecto.Query.pre_comment/2` and `Ecto.Query.post_comment/2`, which must be static.
150155
151156
## Adapter-Specific Errors
152157

lib/ecto/repo/queryable.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Ecto.Repo.Queryable do
66
alias Ecto.Query.Planner
77
alias Ecto.Query.SelectExpr
88

9-
import Ecto.Query.Planner, only: [attach_prefix: 2, attach_label: 2]
9+
import Ecto.Query.Planner, only: [attach_prefix: 2, attach_comments: 2]
1010

1111
require Ecto.Query
1212

@@ -37,7 +37,7 @@ defmodule Ecto.Repo.Queryable do
3737
|> Ecto.Query.Planner.ensure_select(true)
3838

3939
{query, opts} = repo.prepare_query(:stream, query, opts)
40-
query = query |> attach_prefix(opts) |> attach_label(opts)
40+
query = query |> attach_prefix(opts) |> attach_comments(opts)
4141

4242
query_cache? = Keyword.get(opts, :query_cache, true)
4343

@@ -222,7 +222,7 @@ defmodule Ecto.Repo.Queryable do
222222
%{adapter: adapter, cache: cache, repo: repo} = adapter_meta
223223

224224
{query, opts} = repo.prepare_query(operation, query, opts)
225-
query = query |> attach_prefix(opts) |> attach_label(opts)
225+
query = query |> attach_prefix(opts) |> attach_comments(opts)
226226

227227
query_cache? = Keyword.get(opts, :query_cache, true)
228228

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
Code.require_file "../../../support/eval_helpers.exs", __DIR__
2+
3+
defmodule Ecto.Query.Builder.CommentTest do
4+
use ExUnit.Case, async: true
5+
6+
import Ecto.Query.Builder.Comment
7+
doctest Ecto.Query.Builder.Comment
8+
9+
import Ecto.Query
10+
import Support.EvalHelpers
11+
12+
test "pre_comment with literal string" do
13+
query = %Ecto.Query{} |> pre_comment("list_posts")
14+
assert query.comments == [pre: "list_posts"]
15+
end
16+
17+
test "post_comment with literal string" do
18+
query = %Ecto.Query{} |> post_comment("list_posts")
19+
assert query.comments == [post: "list_posts"]
20+
end
21+
22+
test "pre_comment via keyword syntax" do
23+
query = from p in "posts", pre_comment: "list_posts"
24+
assert query.comments == [pre: "list_posts"]
25+
end
26+
27+
test "comments accumulate in order" do
28+
query = %Ecto.Query{} |> pre_comment("a") |> post_comment("b") |> pre_comment("c")
29+
assert query.comments == [{:pre, "a"}, {:post, "b"}, {:pre, "c"}]
30+
end
31+
32+
test "raises on interpolation (comments must be static)" do
33+
_report = "monthly"
34+
35+
assert_raise Ecto.Query.CompileError, ~r"interpolation is not allowed", fn ->
36+
quote_and_eval(%Ecto.Query{} |> pre_comment(^_report))
37+
end
38+
end
39+
40+
test "raises on a non-literal" do
41+
assert_raise Ecto.Query.CompileError, ~r"is not a valid comment", fn ->
42+
quote_and_eval(%Ecto.Query{} |> pre_comment(1))
43+
end
44+
end
45+
46+
test "raises on a comment containing */" do
47+
assert_raise Ecto.Query.CompileError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
48+
quote_and_eval(%Ecto.Query{} |> post_comment("evil */ DROP"))
49+
end
50+
end
51+
52+
test "raises on a comment containing /*" do
53+
assert_raise Ecto.Query.CompileError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
54+
quote_and_eval(%Ecto.Query{} |> pre_comment("evil /* nested"))
55+
end
56+
end
57+
58+
test "exclude resets the comments" do
59+
query = %Ecto.Query{} |> pre_comment("a") |> post_comment("b")
60+
assert Ecto.Query.exclude(query, :comments).comments == []
61+
end
62+
end

0 commit comments

Comments
 (0)