Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions lib/ecto/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ defmodule Ecto.Query do
distinct: nil,
lock: nil,
windows: [],
with_ctes: nil
with_ctes: nil,
label: nil

defmodule FromExpr do
@moduledoc false
Expand Down Expand Up @@ -955,6 +956,7 @@ defmodule Ecto.Query do
Ecto.Query.exclude(query, :limit)
Ecto.Query.exclude(query, :offset)
Ecto.Query.exclude(query, :lock)
Ecto.Query.exclude(query, :label)
Ecto.Query.exclude(query, :preload)
Ecto.Query.exclude(query, :update)
Ecto.Query.exclude(query, :windows)
Expand Down Expand Up @@ -1024,6 +1026,7 @@ defmodule Ecto.Query do
defp do_exclude(%Ecto.Query{} = query, :limit), do: %{query | limit: nil}
defp do_exclude(%Ecto.Query{} = query, :offset), do: %{query | offset: nil}
defp do_exclude(%Ecto.Query{} = query, :lock), do: %{query | lock: nil}
defp do_exclude(%Ecto.Query{} = query, :label), do: %{query | label: nil}
defp do_exclude(%Ecto.Query{} = query, :preload), do: %{query | preloads: [], assocs: []}
defp do_exclude(%Ecto.Query{} = query, :update), do: %{query | updates: []}
defp do_exclude(%Ecto.Query{} = query, :windows), do: %{query | windows: []}
Expand Down Expand Up @@ -1150,7 +1153,7 @@ defmodule Ecto.Query do
end

@from_join_opts [:as, :prefix, :hints]
@no_binds [:union, :union_all, :except, :except_all, :intersect, :intersect_all]
@no_binds [:union, :union_all, :except, :except_all, :intersect, :intersect_all, :label]
@binds [:lock, :where, :or_where, :select, :distinct, :order_by, :group_by, :windows] ++
[:having, :or_having, :limit, :offset, :preload, :update, :select_merge, :with_ctes]

Expand Down Expand Up @@ -2537,6 +2540,40 @@ defmodule Ecto.Query do
Builder.Lock.build(query, binding, expr, __CALLER__)
end

@doc ~S"""
A label query expression.

Adds the given text to the generated statement as a leading SQL comment,
immediately before the statement keyword:

/* get_username_q */ SELECT ...

This is useful to tag and identify queries in database logs and monitoring
tools. The label is rendered *leading* (rather than trailing) so it survives
truncation of long statements in logs.

Because the label becomes part of the generated SQL, it is also part of the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should be in an admonition block. there have been a few times the past couple of years where we had to add workarounds for people whose cache's blew up without them expecting it.

maybe we should also highlight that if they want to use high cardinality values they can disable the cache for that query. i never used sql comments for logging though so i'm not too sure if this is some kind of extremely bad practice that we shouldn't be encouraging at all.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josevalim If comments are always prepended do we need to make them part of the cache? Can we just leave them out and then prepend them to the cached sql in the adapter.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They need to be part of the cache, yes!

query cache key. Avoid highly dynamic values (such as per-request ids) as they
would defeat Ecto's prepared-statement caching; prefer a stable identifier per
call site.

## Keywords example

from(p in Post, label: "get_post_titles_q", select: p.title)

## Expressions example

Post |> label("get_post_titles_q") |> select([p], p.title)

## Interpolation

report = "get_posts_q"
from(p in Post, label: ^report)
"""
defmacro label(query, expr) do
Builder.Label.build(query, expr, __CALLER__)
end

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

Expand Down
82 changes: 82 additions & 0 deletions lib/ecto/query/builder/label.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Kernel, except: [apply: 2]

defmodule Ecto.Query.Builder.Label do
@moduledoc false

alias Ecto.Query.Builder

@forbidden ["*/", "/*", <<0>>]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should let the adapters do the escaping in case some adapters use a different style?


@doc """
Escapes the label text.

iex> escape(quote(do: "my-query"))
"my-query"

"""
@spec escape(Macro.t()) :: Macro.t()
def escape(label) when is_binary(label) do
if String.contains?(label, @forbidden) do
Builder.error!(forbidden_message(label))
end

label
end

def escape({:^, _, [expr]}) do
quote do
Ecto.Query.Builder.Label.runtime!(unquote(expr))
end
end

def escape(other) do
Builder.error!(
"`#{Macro.to_string(other)}` is not a valid label. " <>
"For security reasons, a label must be a literal string or an interpolated string"
)
end

@doc """
Validates a label given at runtime via interpolation.
"""
@spec runtime!(term) :: String.t()
def runtime!(label) when is_binary(label) do
if String.contains?(label, @forbidden) do
raise ArgumentError, forbidden_message(label)
end

label
end

def runtime!(other) do
raise ArgumentError, "a label must be a string, got: `#{inspect(other)}`"
end

defp forbidden_message(label) do
"a label cannot contain `/*`, `*/`, or null bytes, got: `#{inspect(label)}`. "
end

@doc """
Builds a quoted expression.

The quoted expression should evaluate to a query at runtime.
If possible, it does all calculations at compile time to avoid
runtime work.
"""
@spec build(Macro.t(), Macro.t(), Macro.Env.t()) :: Macro.t()
def build(query, expr, env) do
Builder.apply_query(query, __MODULE__, [escape(expr)], env)
end

@doc """
The callback applied by `build/3` to build the query.
"""
@spec apply(Ecto.Queryable.t(), term) :: Ecto.Query.t()
def apply(%Ecto.Query{} = query, value) do
%{query | label: value}
end

def apply(query, value) do
apply(Ecto.Queryable.to_query(query), value)
end
end
2 changes: 2 additions & 0 deletions lib/ecto/query/inspect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ defimpl Inspect, for: Ecto.Query do
updates = kw_exprs(:update, query.updates, names)

lock = kw_inspect(:lock, query.lock)
label = kw_inspect(:label, query.label)
offset = kw_expr(:offset, query.offset, names)
select = kw_expr(:select, query.select, names)
distinct = kw_expr(:distinct, query.distinct, names)
Expand All @@ -140,6 +141,7 @@ defimpl Inspect, for: Ecto.Query do
limit,
offset,
lock,
label,
distinct,
updates,
select,
Expand Down
6 changes: 4 additions & 2 deletions lib/ecto/query/planner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule Ecto.Query.Planner do
LimitExpr
}

if map_size(%Ecto.Query{}) != 21 do
if map_size(%Ecto.Query{}) != 22 do
raise "Ecto.Query match out of date in builder"
end

Expand Down Expand Up @@ -1073,7 +1073,8 @@ defmodule Ecto.Query.Planner do
end

defp finalize_cache(query, operation, cache) do
%{assocs: assocs, prefix: prefix, lock: lock, select: select, aliases: aliases} = query
%{assocs: assocs, prefix: prefix, lock: lock, label: label, select: select, aliases: aliases} =
query
aliases = Map.delete(aliases, @parent_as)

cache =
Expand All @@ -1090,6 +1091,7 @@ defmodule Ecto.Query.Planner do
|> prepend_if(assocs != [], assocs: assocs)
|> prepend_if(prefix != nil, prefix: prefix)
|> prepend_if(lock != nil, lock: lock)
|> prepend_if(label != nil, label: label)
|> prepend_if(aliases != %{}, aliases: aliases)

[operation | cache]
Expand Down
21 changes: 21 additions & 0 deletions lib/ecto/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,13 @@ defmodule Ecto.Repo do
in the schema. For more information see the ["Query Prefix"](`m:Ecto.Query#module-query-prefix`) section of the
`Ecto.Query` documentation.

* `:label` - A string to tag the generated `UPDATE` with a leading SQL
comment, such as `/* bump_visits */ UPDATE ...`, to identify the statement
in database logs and monitoring tools. The string is embedded verbatim into
the SQL and therefore cannot contain `/*`, `*/`, or null bytes. Prefer a
stable identifier per call site, as the label becomes part of the query
cache key. See `Ecto.Query.label/2` to set it on the query itself instead.

See the ["Shared options"](#module-shared-options) section at the module
documentation for remaining options.

Expand Down Expand Up @@ -1630,6 +1637,13 @@ defmodule Ecto.Repo do
in the schema. For more information see the ["Query Prefix"](`m:Ecto.Query#module-query-prefix`) section of the
`Ecto.Query` documentation.

* `:label` - A string to tag the generated `DELETE` with a leading SQL
comment, such as `/* purge_stale */ DELETE ...`, to identify the statement
in database logs and monitoring tools. The string is embedded verbatim into
the SQL and therefore cannot contain `/*`, `*/`, or null bytes. Prefer a
stable identifier per call site, as the label becomes part of the query
cache key. See `Ecto.Query.label/2` to set it on the query itself instead.

See the ["Shared options"](#module-shared-options) section at the module
documentation for remaining options.

Expand Down Expand Up @@ -1732,6 +1746,13 @@ defmodule Ecto.Repo do
* `:placeholders` - A map with placeholders. This feature is not supported
by all databases. See the ["Placeholders" section](#c:insert_all/3-placeholders) for more information.

* `:label` - A string to tag the generated `INSERT` with a leading SQL
comment, such as `/* import_users */ INSERT ...`, to identify the statement
in database logs and monitoring tools. The string is embedded verbatim into
the SQL and therefore cannot contain `/*`, `*/`, or null bytes. Prefer a
stable identifier per call site, as the label becomes part of the query
cache key.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should support it as a repo option and query option, let's go with one or the other.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reads more like a query option than a repo option:

from(s in Site, label: "foobar") |> Repo.all()   vs   from(s in Site) |> Repo.all(label: "foobar")

But Repo.insert/update/delete get a struct/changeset and insert_all gets a list of maps, which is why we ended up with the mixed version.

We could switch to the repo option everywhere (same as :prefix). What are your thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have :comment on Postgrex, so if that's your goal, we can add :comment to MyXQL and TDS, and there is no need to change Ecto? :)

Copy link
Copy Markdown
Author

@alesasnouski alesasnouski Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truth is that the comment should be at the beginning of the query. By default in Postgres (for instance) track_activity_query_size == 1024 bytes, long queries are truncated (in pg_stat_statements, pg_stat_activity and, as a result, in GCP Query Insights console). Changing track_activity_query_size requires a DB server restart.
GCP allows you to change query length up to 4500 bytes, for instance (+ server restart needed).
image
Also adding a comment at the end (in the current implementation) disables the query cache (prep statements).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is at the end because that’s what sqlcommenter uses. Does it also work at the beginning? Another option is to have a flag to control it.

In any case, the disabling of query caching is not related to the position. If we implement it in Ecto and make it part of query cache, then it can work regardless. One option is to support precomment and postcomment options, and deprecate the Postgres comment one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did similar change some time ago master...dkuku:ecto:comments_in_query

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With sqlcommenter, comments are dynamic, which is why the prepared-statement cache must be disabled.
So we have three variables: the comment, its position (head/tail), and the cache (on/off) ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With sqlcommenter, comments are dynamic, which is why the prepared-statement cache must be disabled.

They are dynamic in some sense but, once the code is compiled, you have a limited set of callers to Ecto.Repo. So it could go through the cache. Unless, of course, you start adding dynamic content to it (such as user_id). Then the amount of combinations are just too high and I think it makes sense to disable cache.

One option is for us to add two macros: pre_comment and post_comment, that comment before and after (although we store it in a single field called comments: [{:pre, string}, {:post, string}]. And yeah, I'd force them to be static (so no repo option).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding a :cache option ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did work on this some time ago, and one idea I had was to allow caching the query when the comment is:

  • an atom e.g. :get_all_users
  • keyword list where values are only atoms such as [app: :my_app, context: :rpc]
  • compile-time string, e.g. label(query, "my_app") which the macro system is aware of when used as a Query macro.

This way, by design, you have a limited set of tags for a single query. If you are generating atoms dynamically, you probably have bigger problems in your application, and standard code inspection tools would catch it.


See the ["Shared options"](#module-shared-options) section at the module
documentation for remaining options.

Expand Down
93 changes: 93 additions & 0 deletions test/ecto/query/builder/label_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
Code.require_file "../../../support/eval_helpers.exs", __DIR__

defmodule Ecto.Query.Builder.LabelTest do
use ExUnit.Case, async: true

import Ecto.Query.Builder.Label
doctest Ecto.Query.Builder.Label

import Ecto.Query
import Support.EvalHelpers

test "label with literal string" do
query = %Ecto.Query{} |> label("my-report")
assert query.label == "my-report"
end

test "label via keyword syntax" do
query = from p in "posts", label: "list-posts"
assert query.label == "list-posts"
end

test "label with interpolated string" do
report = "monthly-report"
query = %Ecto.Query{} |> label(^report)
assert query.label == "monthly-report"
end

test "overrides on duplicated label" do
query = %Ecto.Query{} |> label("FOO") |> label("BAR")
assert query.label == "BAR"
end

test "raises on non-string at compile time" do
assert_raise Ecto.Query.CompileError, ~r"`1` is not a valid label", fn ->
quote_and_eval(%Ecto.Query{} |> label(1))
end
end

test "raises on literal containing */" do
assert_raise Ecto.Query.CompileError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
quote_and_eval(%Ecto.Query{} |> label("evil */ DROP TABLE"))
end
end

test "raises on literal containing /*" do
assert_raise Ecto.Query.CompileError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
quote_and_eval(%Ecto.Query{} |> label("evil /* nested"))
end
end

test "raises on literal containing a null byte" do
assert_raise Ecto.Query.CompileError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
quote_and_eval(%Ecto.Query{} |> label("nul\0byte"))
end
end

test "raises on interpolated value containing a null byte" do
evil = "nul\0byte"

assert_raise ArgumentError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
%Ecto.Query{} |> label(^evil)
end
end

test "raises on interpolated value containing */" do
evil = "evil */ DROP TABLE"

assert_raise ArgumentError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
%Ecto.Query{} |> label(^evil)
end
end

test "raises on interpolated value containing /*" do
evil = "evil /* nested"

assert_raise ArgumentError, ~r"cannot contain `/\*`, `\*/`, or null bytes", fn ->
%Ecto.Query{} |> label(^evil)
end
end

test "raises on interpolated non-string" do
not_a_string = 123

assert_raise ArgumentError, ~r"must be a string", fn ->
%Ecto.Query{} |> label(^not_a_string)
end
end

test "exclude resets the label" do
query = %Ecto.Query{} |> label("FOO")
assert Ecto.Query.exclude(query, :label).label == nil
end
end