From b2305413b2717b8bbffc1311b08634ec97f40b3b Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Wed, 3 Jun 2026 15:04:26 +0200 Subject: [PATCH 1/7] Add leading SQL comment support via :label for queries and insert/update/delete --- integration_test/sql/logging.exs | 30 +++++++++++++++++ lib/ecto/adapters/myxql.ex | 4 +++ lib/ecto/adapters/myxql/connection.ex | 11 +++++-- lib/ecto/adapters/postgres/connection.ex | 13 +++++--- lib/ecto/adapters/sql.ex | 29 +++++++++++++++++ lib/ecto/adapters/tds/connection.ex | 11 +++++-- test/ecto/adapters/myxql_test.exs | 11 +++++++ test/ecto/adapters/postgres_test.exs | 11 +++++++ test/ecto/adapters/sql_test.exs | 41 ++++++++++++++++++++++++ test/ecto/adapters/tds_test.exs | 11 +++++++ 10 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 test/ecto/adapters/sql_test.exs diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index 0f92e9a7f..5a3123601 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -178,6 +178,36 @@ defmodule Ecto.Integration.LoggingTest do end end + describe ":label option" do + test "prepends a leading comment to insert/update/delete/insert_all" do + assert capture_log(fn -> + TestRepo.insert!(%Post{title: "1"}, label: "insert_create_post_q", log: :error) + end) =~ "/* insert_create_post_q */ INSERT INTO" + + post = TestRepo.insert!(%Post{title: "x"}) + + assert capture_log(fn -> + post + |> Ecto.Changeset.change(title: "y") + |> TestRepo.update!(label: "update_post_q", log: :error) + end) =~ "/* update_post_q */ UPDATE" + + assert capture_log(fn -> + TestRepo.delete!(post, label: "delete_post_q", log: :error) + end) =~ "/* delete_post_q */ DELETE" + + assert capture_log(fn -> + TestRepo.insert_all(Post, [%{title: "a"}], label: "bulk_insert_posts_q", log: :error) + end) =~ "/* bulk_insert_posts_q */ INSERT INTO" + end + + test "rejects a label that could break out of the comment block" do + assert_raise ArgumentError, ~r/cannot contain/, fn -> + TestRepo.insert!(%Post{title: "1"}, label: "evil */ DROP TABLE posts") + end + end + end + describe "parameter logging" do @describetag :parameter_logging diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index 6026cb260..12a86d076 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -380,6 +380,10 @@ defmodule Ecto.Adapters.MyXQL do opts end + # This adapter overrides insert/6 instead of going through + # Ecto.Adapters.SQL.struct/10, so prepend the `:label` comment here too. + {sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts) + case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do {:ok, %{num_rows: 0}} -> # With INSERT IGNORE (insert_mode: :ignore), 0 rows means the row diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 1823a9d3a..32c36741b 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -124,8 +124,10 @@ if Code.ensure_loaded?(MyXQL) do limit = limit(query, sources) offset = offset(query, sources) lock = lock(query, sources) + label = label(query) [ + label, cte, select, from, @@ -151,6 +153,7 @@ if Code.ensure_loaded?(MyXQL) do sources = create_names(query, []) cte = cte(query, sources) + label = label(query) {from, name} = get_source(query, sources, 0, source) fields = @@ -164,7 +167,7 @@ if Code.ensure_loaded?(MyXQL) do prefix = prefix || ["UPDATE ", from, " AS ", name, join, " SET "] where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [cte, prefix, fields | where] + [label, cte, prefix, fields | where] end @impl true @@ -177,11 +180,12 @@ if Code.ensure_loaded?(MyXQL) do cte = cte(query, sources) {_, name, _} = elem(sources, 0) + label = label(query) from = from(query, sources) join = join(query, sources) where = where(query, sources) - [cte, "DELETE ", name, ".*", from, join | where] + [label, cte, "DELETE ", name, ".*", from, join | where] end @impl true @@ -642,6 +646,9 @@ if Code.ensure_loaded?(MyXQL) do end) end + defp label(%{label: nil}), do: [] + defp label(%{label: label}), do: ["/* ", label, " */ "] + defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary] defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)] diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index 95e893885..8d1bd9bc2 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -203,8 +203,10 @@ if Code.ensure_loaded?(Postgrex) do limit = limit(query, sources) offset = offset(query, sources) lock = lock(query, sources) + label = label(query) [ + label, cte, select, from, @@ -225,13 +227,13 @@ if Code.ensure_loaded?(Postgrex) do sources = create_names(query, []) cte = cte(query, sources) {from, name} = get_source(query, sources, 0, source) - + label = label(query) prefix = prefix || ["UPDATE ", from, " AS ", name | " SET "] fields = update_fields(query, sources) {join, wheres} = using_join(query, :update_all, "FROM", sources) where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [cte, prefix, fields, join, where | returning(query, sources)] + [label, cte, prefix, fields, join, where | returning(query, sources)] end @impl true @@ -239,11 +241,11 @@ if Code.ensure_loaded?(Postgrex) do sources = create_names(query, []) cte = cte(query, sources) {from, name} = get_source(query, sources, 0, from) - + label = label(query) {join, wheres} = using_join(query, :delete_all, "USING", sources) where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [cte, "DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)] + [label, cte, "DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)] end @impl true @@ -892,6 +894,9 @@ if Code.ensure_loaded?(Postgrex) do end) end + defp label(%{label: nil}), do: [] + defp label(%{label: label}), do: ["/* ", label, " */ "] + defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary] defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)] diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 4dac125c8..b128305e7 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -992,6 +992,8 @@ defmodule Ecto.Adapters.SQL do opts end + {sql, opts} = prepend_label(sql, opts) + all_params = placeholders ++ Enum.reverse(params, conflict_params) %{num_rows: num, rows: rows} = query!(adapter_meta, sql, all_params, [source: source] ++ opts) @@ -1171,6 +1173,31 @@ defmodule Ecto.Adapters.SQL do end end + @doc false + def prepend_label(sql, opts) do + case Keyword.get(opts, :label) do + nil -> + {sql, opts} + + label -> + label = validate_label!(label) + {["/* ", label, " */ ", sql], opts} + end + end + + defp validate_label!(label) when is_binary(label) do + if String.contains?(label, ["/*", "*/", <<0>>]) do + raise ArgumentError, + "a label cannot contain `/*`, `*/`, or null bytes, got: #{inspect(label)}. " + end + + label + end + + defp validate_label!(other) do + raise ArgumentError, "a label must be a string, got: #{inspect(other)}" + end + @doc false def struct( adapter_meta, @@ -1191,6 +1218,8 @@ defmodule Ecto.Adapters.SQL do opts end + {sql, opts} = prepend_label(sql, opts) + case query(adapter_meta, sql, values, [source: source] ++ opts) do {:ok, %{rows: nil, num_rows: 1}} -> {:ok, []} diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index 170b832fa..c5f4d58d5 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -170,11 +170,12 @@ if Code.ensure_loaded?(Tds) do # limit = is handled in select (TOP X) offset = offset(query, sources) lock = lock(query, sources) + label = label(query) if query.offset != nil and query.order_bys == [], do: error!(query, "ORDER BY is mandatory when OFFSET is set") - [cte, select, from, join, where, group_by, having, combinations, order_by, lock | offset] + [label, cte, select, from, join, where, group_by, having, combinations, order_by, lock | offset] end @impl true @@ -188,8 +189,10 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) + label = label(query) [ + label, cte, "UPDATE ", name, @@ -213,8 +216,9 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) + label = label(query) - [cte, delete, returning(query, 0, "DELETED"), from, join, where | lock] + [label, cte, delete, returning(query, 0, "DELETED"), from, join, where | lock] end @impl true @@ -662,6 +666,9 @@ if Code.ensure_loaded?(Tds) do defp hints([_ | _] = hints), do: [" WITH (", Enum.intersperse(hints, ", "), ?)] defp hints([]), do: [] + defp label(%{label: nil}), do: [] + defp label(%{label: label}), do: ["/* ", label, " */ "] + defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [" OPTION (", binary, ?)] defp lock(%{lock: expr} = query, sources), do: [" OPTION (", expr(expr, sources, query), ?)] diff --git a/test/ecto/adapters/myxql_test.exs b/test/ecto/adapters/myxql_test.exs index 9170a945c..c9ebefa58 100644 --- a/test/ecto/adapters/myxql_test.exs +++ b/test/ecto/adapters/myxql_test.exs @@ -595,6 +595,17 @@ defmodule Ecto.Adapters.MyXQLTest do assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 UPDATE on s0} end + test "label" do + query = Schema |> label("myquery") |> select([], true) |> plan() + assert all(query) == ~s{/* myquery */ SELECT TRUE FROM `schema` AS s0} + + query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(query) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} + + query = Schema |> label("del_q") |> plan(:delete_all) + assert delete_all(query) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0} + end + test "string escape" do query = "schema" |> where(foo: "'\\ ") |> select([], true) |> plan() assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 WHERE (s0.`foo` = '''\\\\ ')} diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index 764c0a4c2..6c62a6862 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -788,6 +788,17 @@ defmodule Ecto.Adapters.PostgresTest do assert all(query) == ~s{SELECT TRUE FROM "schema" AS s0 UPDATE on s0} end + test "label" do + query = Schema |> label("myquery") |> select([], true) |> plan() + assert all(query) == ~s{/* myquery */ SELECT TRUE FROM "schema" AS s0} + + query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(query) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} + + query = Schema |> label("del_q") |> plan(:delete_all) + assert delete_all(query) == ~s{/* del_q */ DELETE FROM "schema" AS s0} + end + test "string escape" do query = "schema" |> where(foo: "'\\ ") |> select([], true) |> plan() assert all(query) == ~s{SELECT TRUE FROM \"schema\" AS s0 WHERE (s0.\"foo\" = '''\\ ')} diff --git a/test/ecto/adapters/sql_test.exs b/test/ecto/adapters/sql_test.exs new file mode 100644 index 000000000..ddea3219f --- /dev/null +++ b/test/ecto/adapters/sql_test.exs @@ -0,0 +1,41 @@ +defmodule Ecto.Adapters.SQLTest do + use ExUnit.Case, async: true + + defp prepend(sql, opts) do + {sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts) + {IO.iodata_to_binary(sql), opts} + end + + describe "prepend_label/2" do + test "without a label, passes sql and opts through unchanged" do + assert prepend("SELECT 1", cache_statement: "ecto_all_posts") == + {"SELECT 1", [cache_statement: "ecto_all_posts"]} + end + + test "prepends a leading comment block" do + {sql, _opts} = prepend("INSERT INTO posts ...", label: "create_post_q") + assert sql == "/* create_post_q */ INSERT INTO posts ..." + end + + test "leaves opts untouched so prepared-statement caching is preserved" do + opts = [label: "tag_q", cache_statement: "ecto_insert_posts_3", timeout: 5000] + {_sql, returned_opts} = prepend("INSERT INTO posts ...", opts) + + assert returned_opts == opts + end + + test "rejects label-delimiter sequences and null bytes" do + for bad <- ["evil */ DROP", "evil /* nest", "with\0null"] do + assert_raise ArgumentError, ~r/cannot contain/, fn -> + Ecto.Adapters.SQL.prepend_label("X", label: bad) + end + end + end + + test "rejects non-string labels" do + assert_raise ArgumentError, ~r/must be a string/, fn -> + Ecto.Adapters.SQL.prepend_label("X", label: 123) + end + end + end +end diff --git a/test/ecto/adapters/tds_test.exs b/test/ecto/adapters/tds_test.exs index 2e94de197..48823bc14 100644 --- a/test/ecto/adapters/tds_test.exs +++ b/test/ecto/adapters/tds_test.exs @@ -646,6 +646,17 @@ defmodule Ecto.Adapters.TdsTest do assert all(query) == ~s{SELECT CAST(1 as bit) FROM [schema] AS s0 OPTION (UPDATE on s0)} end + test "label" do + query = Schema |> label("myquery") |> select([], true) |> plan() + assert all(query) == ~s{/* myquery */ SELECT CAST(1 as bit) FROM [schema] AS s0} + + query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(query) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0} + + query = Schema |> label("del_q") |> plan(:delete_all) + assert delete_all(query) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} + end + test "string escape" do query = "schema" |> where(foo: "\'-- ") |> select([], true) |> plan() From 6af8efa965e251c51648e1d0b8742e4c79ac1e34 Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Thu, 4 Jun 2026 11:09:40 +0200 Subject: [PATCH 2/7] Switched :label to Repo option insteand of a query expression --- integration_test/sql/logging.exs | 14 ++++++++++++++ test/ecto/adapters/myxql_test.exs | 12 ++++++------ test/ecto/adapters/postgres_test.exs | 12 ++++++------ test/ecto/adapters/tds_test.exs | 12 ++++++------ 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index 5a3123601..c39fbd028 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -179,6 +179,20 @@ defmodule Ecto.Integration.LoggingTest do end describe ":label option" do + test "prepends a leading comment to query operations" do + assert capture_log(fn -> + TestRepo.all(Post, label: "list_posts_q", log: :error) + end) =~ "/* list_posts_q */ SELECT" + + assert capture_log(fn -> + TestRepo.update_all(Post, [set: [visits: 0]], label: "reset_visits_q", log: :error) + end) =~ "/* reset_visits_q */ UPDATE" + + assert capture_log(fn -> + TestRepo.delete_all(Post, label: "purge_posts_q", log: :error) + end) =~ "/* purge_posts_q */ DELETE" + end + test "prepends a leading comment to insert/update/delete/insert_all" do assert capture_log(fn -> TestRepo.insert!(%Post{title: "1"}, label: "insert_create_post_q", log: :error) diff --git a/test/ecto/adapters/myxql_test.exs b/test/ecto/adapters/myxql_test.exs index c9ebefa58..77868495d 100644 --- a/test/ecto/adapters/myxql_test.exs +++ b/test/ecto/adapters/myxql_test.exs @@ -596,14 +596,14 @@ defmodule Ecto.Adapters.MyXQLTest do end test "label" do - query = Schema |> label("myquery") |> select([], true) |> plan() - assert all(query) == ~s{/* myquery */ SELECT TRUE FROM `schema` AS s0} + query = Schema |> select([], true) |> plan() + assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT TRUE FROM `schema` AS s0} - query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(query) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} + query = Schema |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} - query = Schema |> label("del_q") |> plan(:delete_all) - assert delete_all(query) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0} + query = Schema |> plan(:delete_all) + assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0} end test "string escape" do diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index 6c62a6862..91376fd30 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -789,14 +789,14 @@ defmodule Ecto.Adapters.PostgresTest do end test "label" do - query = Schema |> label("myquery") |> select([], true) |> plan() - assert all(query) == ~s{/* myquery */ SELECT TRUE FROM "schema" AS s0} + query = Schema |> select([], true) |> plan() + assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT TRUE FROM "schema" AS s0} - query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(query) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} + query = Schema |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} - query = Schema |> label("del_q") |> plan(:delete_all) - assert delete_all(query) == ~s{/* del_q */ DELETE FROM "schema" AS s0} + query = Schema |> plan(:delete_all) + assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE FROM "schema" AS s0} end test "string escape" do diff --git a/test/ecto/adapters/tds_test.exs b/test/ecto/adapters/tds_test.exs index 48823bc14..82a3f2662 100644 --- a/test/ecto/adapters/tds_test.exs +++ b/test/ecto/adapters/tds_test.exs @@ -647,14 +647,14 @@ defmodule Ecto.Adapters.TdsTest do end test "label" do - query = Schema |> label("myquery") |> select([], true) |> plan() - assert all(query) == ~s{/* myquery */ SELECT CAST(1 as bit) FROM [schema] AS s0} + query = Schema |> select([], true) |> plan() + assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT CAST(1 as bit) FROM [schema] AS s0} - query = Schema |> label("upd_q") |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(query) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0} + query = Schema |> update([], set: [x: 0]) |> plan(:update_all) + assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0} - query = Schema |> label("del_q") |> plan(:delete_all) - assert delete_all(query) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} + query = Schema |> plan(:delete_all) + assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} end test "string escape" do From 39ea348e69a1b8d8c986fe5e12d90e14de6a4ce7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Tue, 16 Jun 2026 15:38:39 +0200 Subject: [PATCH 3/7] Support pre_comment and post_comment options in ecto. --- integration_test/sql/logging.exs | 36 ++++++++++----- lib/ecto/adapters/myxql.ex | 4 +- lib/ecto/adapters/myxql/connection.ex | 18 ++++---- lib/ecto/adapters/postgres/connection.ex | 18 ++++---- lib/ecto/adapters/sql.ex | 45 +++++++++++------- lib/ecto/adapters/tds/connection.ex | 18 ++++---- test/ecto/adapters/myxql_test.exs | 12 +++-- test/ecto/adapters/postgres_test.exs | 12 +++-- test/ecto/adapters/sql_test.exs | 58 +++++++++++++++--------- test/ecto/adapters/tds_test.exs | 12 +++-- 10 files changed, 139 insertions(+), 94 deletions(-) diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index c39fbd028..01845dce6 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -178,24 +178,36 @@ defmodule Ecto.Integration.LoggingTest do end end - describe ":label option" do - test "prepends a leading comment to query operations" do + describe ":comments option" do + test "comments query operations" do assert capture_log(fn -> - TestRepo.all(Post, label: "list_posts_q", log: :error) + TestRepo.all(Post, comments: [pre: "list_posts_q"], log: :error) end) =~ "/* list_posts_q */ SELECT" assert capture_log(fn -> - TestRepo.update_all(Post, [set: [visits: 0]], label: "reset_visits_q", log: :error) + TestRepo.update_all(Post, [set: [visits: 0]], comments: [pre: "reset_visits_q"], log: :error) end) =~ "/* reset_visits_q */ UPDATE" assert capture_log(fn -> - TestRepo.delete_all(Post, label: "purge_posts_q", log: :error) + TestRepo.delete_all(Post, comments: [pre: "purge_posts_q"], log: :error) end) =~ "/* purge_posts_q */ DELETE" end - test "prepends a leading comment to insert/update/delete/insert_all" do + test "supports both :pre and :post" do assert capture_log(fn -> - TestRepo.insert!(%Post{title: "1"}, label: "insert_create_post_q", log: :error) + TestRepo.all(Post, comments: [pre: "before_q", post: "after_q"], log: :error) + end) =~ ~r{/\* before_q \*/ SELECT.* /\* after_q \*/} + end + + test "renders with query_cache: false (the escape hatch for dynamic comments)" do + assert capture_log(fn -> + TestRepo.all(Post, comments: [pre: "dyn_#{System.unique_integer()}"], query_cache: false, log: :error) + end) =~ ~r{/\* dyn_-?\d+ \*/ SELECT} + end + + test "comments insert/update/delete/insert_all" do + assert capture_log(fn -> + TestRepo.insert!(%Post{title: "1"}, comments: [pre: "insert_create_post_q"], log: :error) end) =~ "/* insert_create_post_q */ INSERT INTO" post = TestRepo.insert!(%Post{title: "x"}) @@ -203,21 +215,21 @@ defmodule Ecto.Integration.LoggingTest do assert capture_log(fn -> post |> Ecto.Changeset.change(title: "y") - |> TestRepo.update!(label: "update_post_q", log: :error) + |> TestRepo.update!(comments: [pre: "update_post_q"], log: :error) end) =~ "/* update_post_q */ UPDATE" assert capture_log(fn -> - TestRepo.delete!(post, label: "delete_post_q", log: :error) + TestRepo.delete!(post, comments: [pre: "delete_post_q"], log: :error) end) =~ "/* delete_post_q */ DELETE" assert capture_log(fn -> - TestRepo.insert_all(Post, [%{title: "a"}], label: "bulk_insert_posts_q", log: :error) + TestRepo.insert_all(Post, [%{title: "a"}], comments: [pre: "bulk_insert_posts_q"], log: :error) end) =~ "/* bulk_insert_posts_q */ INSERT INTO" end - test "rejects a label that could break out of the comment block" do + test "rejects a comment that could break out of the comment block" do assert_raise ArgumentError, ~r/cannot contain/, fn -> - TestRepo.insert!(%Post{title: "1"}, label: "evil */ DROP TABLE posts") + TestRepo.insert!(%Post{title: "1"}, comments: [pre: "evil */ DROP TABLE posts"]) end end end diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index 12a86d076..b12f4a2c4 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -381,8 +381,8 @@ defmodule Ecto.Adapters.MyXQL do end # This adapter overrides insert/6 instead of going through - # Ecto.Adapters.SQL.struct/10, so prepend the `:label` comment here too. - {sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts) + # Ecto.Adapters.SQL.struct/10, so wrap the `:comments` here too. + sql = Ecto.Adapters.SQL.wrap_comments(sql, opts) case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do {:ok, %{num_rows: 0}} -> diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 32c36741b..55a0e2efa 100644 --- a/lib/ecto/adapters/myxql/connection.ex +++ b/lib/ecto/adapters/myxql/connection.ex @@ -124,10 +124,10 @@ if Code.ensure_loaded?(MyXQL) do limit = limit(query, sources) offset = offset(query, sources) lock = lock(query, sources) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) [ - label, + pre_comments, cte, select, from, @@ -139,7 +139,8 @@ if Code.ensure_loaded?(MyXQL) do combinations, order_by, limit, - offset | lock + offset, + lock | post_comments ] end @@ -153,7 +154,7 @@ if Code.ensure_loaded?(MyXQL) do sources = create_names(query, []) cte = cte(query, sources) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) {from, name} = get_source(query, sources, 0, source) fields = @@ -167,7 +168,7 @@ if Code.ensure_loaded?(MyXQL) do prefix = prefix || ["UPDATE ", from, " AS ", name, join, " SET "] where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [label, cte, prefix, fields | where] + [pre_comments, cte, prefix, fields, where | post_comments] end @impl true @@ -180,12 +181,12 @@ if Code.ensure_loaded?(MyXQL) do cte = cte(query, sources) {_, name, _} = elem(sources, 0) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) from = from(query, sources) join = join(query, sources) where = where(query, sources) - [label, cte, "DELETE ", name, ".*", from, join | where] + [pre_comments, cte, "DELETE ", name, ".*", from, join, where | post_comments] end @impl true @@ -646,9 +647,6 @@ if Code.ensure_loaded?(MyXQL) do end) end - defp label(%{label: nil}), do: [] - defp label(%{label: label}), do: ["/* ", label, " */ "] - defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary] defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)] diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index 8d1bd9bc2..6f445fe93 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -203,10 +203,10 @@ if Code.ensure_loaded?(Postgrex) do limit = limit(query, sources) offset = offset(query, sources) lock = lock(query, sources) - label = label(query) + {pre_comments, post_comments} = Ecto.Adapters.SQL.comments(query.comments) [ - label, + pre_comments, cte, select, from, @@ -218,7 +218,8 @@ if Code.ensure_loaded?(Postgrex) do combinations, order_by, limit, - offset | lock + offset, + lock | post_comments ] end @@ -227,13 +228,13 @@ if Code.ensure_loaded?(Postgrex) do sources = create_names(query, []) cte = cte(query, sources) {from, name} = get_source(query, sources, 0, source) - label = label(query) + {pre_comments, post_comments} = Ecto.Adapters.SQL.comments(query.comments) prefix = prefix || ["UPDATE ", from, " AS ", name | " SET "] fields = update_fields(query, sources) {join, wheres} = using_join(query, :update_all, "FROM", sources) where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [label, cte, prefix, fields, join, where | returning(query, sources)] + [pre_comments, cte, prefix, fields, join, where, returning(query, sources) | post_comments] end @impl true @@ -241,11 +242,11 @@ if Code.ensure_loaded?(Postgrex) do sources = create_names(query, []) cte = cte(query, sources) {from, name} = get_source(query, sources, 0, from) - label = label(query) + {pre_comments, post_comments} = Ecto.Adapters.SQL.comments(query.comments) {join, wheres} = using_join(query, :delete_all, "USING", sources) where = where(%{query | wheres: wheres ++ query.wheres}, sources) - [label, cte, "DELETE FROM ", from, " AS ", name, join, where | returning(query, sources)] + [pre_comments, cte, "DELETE FROM ", from, " AS ", name, join, where, returning(query, sources) | post_comments] end @impl true @@ -894,9 +895,6 @@ if Code.ensure_loaded?(Postgrex) do end) end - defp label(%{label: nil}), do: [] - defp label(%{label: label}), do: ["/* ", label, " */ "] - defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [?\s | binary] defp lock(%{lock: expr} = query, sources), do: [?\s | expr(expr, sources, query)] diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index b128305e7..1aff7beec 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -992,7 +992,7 @@ defmodule Ecto.Adapters.SQL do opts end - {sql, opts} = prepend_label(sql, opts) + sql = wrap_comments(sql, opts) all_params = placeholders ++ Enum.reverse(params, conflict_params) @@ -1174,28 +1174,39 @@ defmodule Ecto.Adapters.SQL do end @doc false - def prepend_label(sql, opts) do - case Keyword.get(opts, :label) do - nil -> - {sql, opts} - - label -> - label = validate_label!(label) - {["/* ", label, " */ ", sql], opts} - end + def wrap_comments(sql, opts) do + {pre, post} = comments(Keyword.get(opts, :comments, [])) + [pre, sql | post] + end + + @doc false + def comments(comments) when is_list(comments) do + {pre, post} = + Enum.reduce(comments, {[], []}, fn + {:pre, c}, {pre, post} -> {[["/* ", escape_comment!(c), " */ "] | pre], post} + {:post, c}, {pre, post} -> {pre, [[" /* ", escape_comment!(c), " */"] | post]} + other, _ -> raise ArgumentError, "expected {:pre, string} or {:post, string}, got: #{inspect(other)}" + end) + + {Enum.reverse(pre), Enum.reverse(post)} + end + + def comments(other) do + raise ArgumentError, + "comments must be a keyword list of [pre: string, post: string], got: #{inspect(other)}" end - defp validate_label!(label) when is_binary(label) do - if String.contains?(label, ["/*", "*/", <<0>>]) do + defp escape_comment!(comment) when is_binary(comment) do + if String.contains?(comment, ["/*", "*/", <<0>>]) do raise ArgumentError, - "a label cannot contain `/*`, `*/`, or null bytes, got: #{inspect(label)}. " + "a comment cannot contain `/*`, `*/`, or null bytes, got: #{inspect(comment)}. " end - label + comment end - defp validate_label!(other) do - raise ArgumentError, "a label must be a string, got: #{inspect(other)}" + defp escape_comment!(other) do + raise ArgumentError, "a comment must be a string, got: #{inspect(other)}" end @doc false @@ -1218,7 +1229,7 @@ defmodule Ecto.Adapters.SQL do opts end - {sql, opts} = prepend_label(sql, opts) + sql = wrap_comments(sql, opts) case query(adapter_meta, sql, values, [source: source] ++ opts) do {:ok, %{rows: nil, num_rows: 1}} -> diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index c5f4d58d5..206f238d5 100644 --- a/lib/ecto/adapters/tds/connection.ex +++ b/lib/ecto/adapters/tds/connection.ex @@ -170,12 +170,12 @@ if Code.ensure_loaded?(Tds) do # limit = is handled in select (TOP X) offset = offset(query, sources) lock = lock(query, sources) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) if query.offset != nil and query.order_bys == [], do: error!(query, "ORDER BY is mandatory when OFFSET is set") - [label, cte, select, from, join, where, group_by, having, combinations, order_by, lock | offset] + [pre_comments, cte, select, from, join, where, group_by, having, combinations, order_by, lock, offset | post_comments] end @impl true @@ -189,10 +189,10 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) [ - label, + pre_comments, cte, "UPDATE ", name, @@ -201,7 +201,8 @@ if Code.ensure_loaded?(Tds) do returning(query, 0, "INSERTED"), from, join, - where | lock + where, + lock | post_comments ] end @@ -216,9 +217,9 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) - label = label(query) + {pre_comments, post_comments} = SQL.comments(query.comments) - [label, cte, delete, returning(query, 0, "DELETED"), from, join, where | lock] + [pre_comments, cte, delete, returning(query, 0, "DELETED"), from, join, where, lock | post_comments] end @impl true @@ -666,9 +667,6 @@ if Code.ensure_loaded?(Tds) do defp hints([_ | _] = hints), do: [" WITH (", Enum.intersperse(hints, ", "), ?)] defp hints([]), do: [] - defp label(%{label: nil}), do: [] - defp label(%{label: label}), do: ["/* ", label, " */ "] - defp lock(%{lock: nil}, _sources), do: [] defp lock(%{lock: binary}, _sources) when is_binary(binary), do: [" OPTION (", binary, ?)] defp lock(%{lock: expr} = query, sources), do: [" OPTION (", expr(expr, sources, query), ?)] diff --git a/test/ecto/adapters/myxql_test.exs b/test/ecto/adapters/myxql_test.exs index 77868495d..3912c3976 100644 --- a/test/ecto/adapters/myxql_test.exs +++ b/test/ecto/adapters/myxql_test.exs @@ -595,15 +595,19 @@ defmodule Ecto.Adapters.MyXQLTest do assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 UPDATE on s0} end - test "label" do + test "comments" do query = Schema |> select([], true) |> plan() - assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT TRUE FROM `schema` AS s0} + assert all(%{query | comments: [pre: "q"]}) == ~s{/* q */ SELECT TRUE FROM `schema` AS s0} + assert all(%{query | comments: [post: "q"]}) == ~s{SELECT TRUE FROM `schema` AS s0 /* q */} + + assert all(%{query | comments: [pre: "a", post: "b"]}) == + ~s{/* a */ SELECT TRUE FROM `schema` AS s0 /* b */} query = Schema |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} + assert update_all(%{query | comments: [pre: "upd_q"]}) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} query = Schema |> plan(:delete_all) - assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0} + assert delete_all(%{query | comments: [pre: "del_q"]}) == ~s{/* del_q */ DELETE s0.* FROM `schema` AS s0} end test "string escape" do diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index 91376fd30..125cf4b4f 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -788,15 +788,19 @@ defmodule Ecto.Adapters.PostgresTest do assert all(query) == ~s{SELECT TRUE FROM "schema" AS s0 UPDATE on s0} end - test "label" do + test "comments" do query = Schema |> select([], true) |> plan() - assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT TRUE FROM "schema" AS s0} + assert all(%{query | comments: [pre: "q"]}) == ~s{/* q */ SELECT TRUE FROM "schema" AS s0} + assert all(%{query | comments: [post: "q"]}) == ~s{SELECT TRUE FROM "schema" AS s0 /* q */} + + assert all(%{query | comments: [pre: "a", post: "b"]}) == + ~s{/* a */ SELECT TRUE FROM "schema" AS s0 /* b */} query = Schema |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} + assert update_all(%{query | comments: [pre: "upd_q"]}) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} query = Schema |> plan(:delete_all) - assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE FROM "schema" AS s0} + assert delete_all(%{query | comments: [pre: "del_q"]}) == ~s{/* del_q */ DELETE FROM "schema" AS s0} end test "string escape" do diff --git a/test/ecto/adapters/sql_test.exs b/test/ecto/adapters/sql_test.exs index ddea3219f..8ce0ac09b 100644 --- a/test/ecto/adapters/sql_test.exs +++ b/test/ecto/adapters/sql_test.exs @@ -1,41 +1,57 @@ defmodule Ecto.Adapters.SQLTest do use ExUnit.Case, async: true - defp prepend(sql, opts) do - {sql, opts} = Ecto.Adapters.SQL.prepend_label(sql, opts) - {IO.iodata_to_binary(sql), opts} + defp comments(list) do + {pre, post} = Ecto.Adapters.SQL.comments(list) + {IO.iodata_to_binary(pre), IO.iodata_to_binary(post)} end - describe "prepend_label/2" do - test "without a label, passes sql and opts through unchanged" do - assert prepend("SELECT 1", cache_statement: "ecto_all_posts") == - {"SELECT 1", [cache_statement: "ecto_all_posts"]} - end + defp wrap(sql, opts) do + sql |> Ecto.Adapters.SQL.wrap_comments(opts) |> IO.iodata_to_binary() + end - test "prepends a leading comment block" do - {sql, _opts} = prepend("INSERT INTO posts ...", label: "create_post_q") - assert sql == "/* create_post_q */ INSERT INTO posts ..." + describe "comments/1" do + test "empty list renders nothing" do + assert comments([]) == {"", ""} end - test "leaves opts untouched so prepared-statement caching is preserved" do - opts = [label: "tag_q", cache_statement: "ecto_insert_posts_3", timeout: 5000] - {_sql, returned_opts} = prepend("INSERT INTO posts ...", opts) + test "renders :pre leading and :post trailing" do + assert comments(pre: "list_users") == {"/* list_users */ ", ""} + assert comments(post: "list_users") == {"", " /* list_users */"} + assert comments(pre: "a", post: "b") == {"/* a */ ", " /* b */"} + end - assert returned_opts == opts + test "preserves order and supports multiples" do + assert comments(pre: "a", pre: "b") == {"/* a */ /* b */ ", ""} end - test "rejects label-delimiter sequences and null bytes" do - for bad <- ["evil */ DROP", "evil /* nest", "with\0null"] do + test "rejects comment-delimiter sequences and null bytes" do + for bad <- ["evil */ x", "evil /* x", "x\0y"] do assert_raise ArgumentError, ~r/cannot contain/, fn -> - Ecto.Adapters.SQL.prepend_label("X", label: bad) + Ecto.Adapters.SQL.comments(pre: bad) end end end - test "rejects non-string labels" do - assert_raise ArgumentError, ~r/must be a string/, fn -> - Ecto.Adapters.SQL.prepend_label("X", label: 123) + test "rejects bad shapes" do + assert_raise ArgumentError, ~r/expected \{:pre/, fn -> + Ecto.Adapters.SQL.comments(foo: "bar") + end + + assert_raise ArgumentError, ~r/keyword list/, fn -> + Ecto.Adapters.SQL.comments("nope") end end end + + describe "wrap_comments/2" do + test "wraps the sql with pre/post from the :comments option" do + assert wrap("INSERT INTO posts ...", comments: [pre: "create_post", post: "v2"]) == + "/* create_post */ INSERT INTO posts ... /* v2 */" + end + + test "is a no-op without the :comments option" do + assert wrap("INSERT INTO posts ...", timeout: 5000) == "INSERT INTO posts ..." + end + end end diff --git a/test/ecto/adapters/tds_test.exs b/test/ecto/adapters/tds_test.exs index 82a3f2662..c006a9e60 100644 --- a/test/ecto/adapters/tds_test.exs +++ b/test/ecto/adapters/tds_test.exs @@ -646,15 +646,19 @@ defmodule Ecto.Adapters.TdsTest do assert all(query) == ~s{SELECT CAST(1 as bit) FROM [schema] AS s0 OPTION (UPDATE on s0)} end - test "label" do + test "comments" do query = Schema |> select([], true) |> plan() - assert all(%{query | label: "myquery"}) == ~s{/* myquery */ SELECT CAST(1 as bit) FROM [schema] AS s0} + assert all(%{query | comments: [pre: "q"]}) == ~s{/* q */ SELECT CAST(1 as bit) FROM [schema] AS s0} + assert all(%{query | comments: [post: "q"]}) == ~s{SELECT CAST(1 as bit) FROM [schema] AS s0 /* q */} + + assert all(%{query | comments: [pre: "a", post: "b"]}) == + ~s{/* a */ SELECT CAST(1 as bit) FROM [schema] AS s0 /* b */} query = Schema |> update([], set: [x: 0]) |> plan(:update_all) - assert update_all(%{query | label: "upd_q"}) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0} + assert update_all(%{query | comments: [pre: "upd_q"]}) == ~s{/* upd_q */ UPDATE s0 SET s0.[x] = 0 FROM [schema] AS s0} query = Schema |> plan(:delete_all) - assert delete_all(%{query | label: "del_q"}) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} + assert delete_all(%{query | comments: [pre: "del_q"]}) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} end test "string escape" do From 7971489c1cff3b2f85cbacf1509c2f410fa44c9c Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Sat, 29 Aug 2026 11:58:18 +0200 Subject: [PATCH 4/7] Skip the default cache_statement when :comments are given --- integration_test/sql/logging.exs | 10 +++++++-- lib/ecto/adapters/myxql.ex | 8 +------ lib/ecto/adapters/sql.ex | 30 ++++++++++++++------------ test/ecto/adapters/sql_test.exs | 37 ++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index 01845dce6..c51021f03 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -199,12 +199,18 @@ defmodule Ecto.Integration.LoggingTest do end) =~ ~r{/\* before_q \*/ SELECT.* /\* after_q \*/} end - test "renders with query_cache: false (the escape hatch for dynamic comments)" do + test "dynamic comments render and skip the query cache by default" do assert capture_log(fn -> - TestRepo.all(Post, comments: [pre: "dyn_#{System.unique_integer()}"], query_cache: false, log: :error) + TestRepo.all(Post, comments: [pre: "dyn_#{System.unique_integer()}"], log: :error) end) =~ ~r{/\* dyn_-?\d+ \*/ SELECT} end + test "query_cache: true opts back into caching for static comments" do + assert capture_log(fn -> + TestRepo.all(Post, comments: [pre: "static_q"], query_cache: true, log: :error) + end) =~ "/* static_q */ SELECT" + end + test "comments insert/update/delete/insert_all" do assert capture_log(fn -> TestRepo.insert!(%Post{title: "1"}, comments: [pre: "insert_create_post_q"], log: :error) diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index b12f4a2c4..fe8359889 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -373,15 +373,9 @@ defmodule Ecto.Adapters.MyXQL do insert_opts = if opts[:insert_mode], do: [insert_mode: opts[:insert_mode]], else: [] sql = @conn.insert(prefix, source, fields, [fields], on_conflict, [], [], insert_opts) - opts = - if is_nil(Keyword.get(opts, :cache_statement)) do - [{:cache_statement, "ecto_insert_#{source}_#{length(fields)}"} | opts] - else - opts - end - # This adapter overrides insert/6 instead of going through # Ecto.Adapters.SQL.struct/10, so wrap the `:comments` here too. + opts = Ecto.Adapters.SQL.put_default_cache_statement(opts, "ecto_insert_#{source}_#{length(fields)}") sql = Ecto.Adapters.SQL.wrap_comments(sql, opts) case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 1aff7beec..f0cd1738d 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -985,13 +985,7 @@ defmodule Ecto.Adapters.SQL do sql = conn.insert(prefix, source, header, rows, on_conflict, returning, placeholders, opts) - opts = - if is_nil(Keyword.get(opts, :cache_statement)) do - [{:cache_statement, "ecto_insert_all_#{source}"} | opts] - else - opts - end - + opts = put_default_cache_statement(opts, "ecto_insert_all_#{source}") sql = wrap_comments(sql, opts) all_params = placeholders ++ Enum.reverse(params, conflict_params) @@ -1179,6 +1173,20 @@ defmodule Ecto.Adapters.SQL do [pre, sql | post] end + # Comments become part of the statement text, so a varying comment under a + # fixed cache name would make the driver close and re-prepare the statement + # on every call (drivers compare the cached text). Skip the default statement + # cache whenever comments are given; an explicit :cache_statement still wins, + # which keeps caching available for callers with static comments. + @doc false + def put_default_cache_statement(opts, name) do + if is_nil(Keyword.get(opts, :cache_statement)) and Keyword.get(opts, :comments, []) == [] do + [{:cache_statement, name} | opts] + else + opts + end + end + @doc false def comments(comments) when is_list(comments) do {pre, post} = @@ -1222,13 +1230,7 @@ defmodule Ecto.Adapters.SQL do returning, opts ) do - opts = - if is_nil(Keyword.get(opts, :cache_statement)) do - [{:cache_statement, "ecto_#{operation}_#{source}_#{length(params)}"} | opts] - else - opts - end - + opts = put_default_cache_statement(opts, "ecto_#{operation}_#{source}_#{length(params)}") sql = wrap_comments(sql, opts) case query(adapter_meta, sql, values, [source: source] ++ opts) do diff --git a/test/ecto/adapters/sql_test.exs b/test/ecto/adapters/sql_test.exs index 8ce0ac09b..d1d847c0f 100644 --- a/test/ecto/adapters/sql_test.exs +++ b/test/ecto/adapters/sql_test.exs @@ -54,4 +54,41 @@ defmodule Ecto.Adapters.SQLTest do assert wrap("INSERT INTO posts ...", timeout: 5000) == "INSERT INTO posts ..." end end + + describe "put_default_cache_statement/2" do + test "sets the default name" do + opts = Ecto.Adapters.SQL.put_default_cache_statement([timeout: 5000], "ecto_insert_posts") + assert Keyword.get(opts, :cache_statement) == "ecto_insert_posts" + end + + test "honors an explicit :cache_statement" do + opts = Ecto.Adapters.SQL.put_default_cache_statement([cache_statement: "mine"], "default") + assert Keyword.get(opts, :cache_statement) == "mine" + end + + test "skips the default when comments are given" do + opts = + Ecto.Adapters.SQL.put_default_cache_statement( + [comments: [pre: "dyn_123"]], + "ecto_insert_posts" + ) + + assert Keyword.get(opts, :cache_statement) == nil + end + + test "an explicit :cache_statement wins even with comments" do + opts = + Ecto.Adapters.SQL.put_default_cache_statement( + [comments: [pre: "static_tag"], cache_statement: "mine"], + "default" + ) + + assert Keyword.get(opts, :cache_statement) == "mine" + end + + test "an empty :comments list still gets the default" do + opts = Ecto.Adapters.SQL.put_default_cache_statement([comments: []], "ecto_insert_posts") + assert Keyword.get(opts, :cache_statement) == "ecto_insert_posts" + end + end end From fff6dd1a21178a883baf917ae23a9079b1fe9599 Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Sat, 29 Aug 2026 12:10:31 +0200 Subject: [PATCH 5/7] Reject comment prefixes MySQL/MariaDB treat as executable. --- integration_test/sql/logging.exs | 6 ++++++ lib/ecto/adapters/sql.ex | 15 ++++++++++++++- test/ecto/adapters/sql_test.exs | 24 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index c51021f03..e0750bb1d 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -238,6 +238,12 @@ defmodule Ecto.Integration.LoggingTest do TestRepo.insert!(%Post{title: "1"}, comments: [pre: "evil */ DROP TABLE posts"]) end end + + test "rejects a comment that MySQL/MariaDB would treat as executable" do + assert_raise ArgumentError, ~r/cannot start with/, fn -> + TestRepo.insert!(%Post{title: "1"}, comments: [pre: "!40000 DROP TABLE posts"]) + end + end end describe "parameter logging" do diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index f0cd1738d..55c8c4307 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -1189,6 +1189,10 @@ defmodule Ecto.Adapters.SQL do @doc false def comments(comments) when is_list(comments) do + # The space after `/*` is load-bearing: MySQL executable comments (`/*!`), + # MariaDB executable comments (`/*M!`), and optimizer hints (`/*+`) only + # take effect when the marker immediately follows `/*`. Keep the space even + # though escape_comment!/1 also rejects those prefixes (defense in depth). {pre, post} = Enum.reduce(comments, {[], []}, fn {:pre, c}, {pre, post} -> {[["/* ", escape_comment!(c), " */ "] | pre], post} @@ -1207,7 +1211,16 @@ defmodule Ecto.Adapters.SQL do defp escape_comment!(comment) when is_binary(comment) do if String.contains?(comment, ["/*", "*/", <<0>>]) do raise ArgumentError, - "a comment cannot contain `/*`, `*/`, or null bytes, got: #{inspect(comment)}. " + "a comment cannot contain `/*`, `*/`, or null bytes, got: #{inspect(comment)}" + end + + # Placed right after `/*`, these prefixes would form MySQL/MariaDB + # executable comments (`/*!...*/`, `/*M!...*/`) or optimizer hints + # (`/*+...*/`), turning the comment into SQL that executes. + if String.starts_with?(comment, ["!", "+", "M!"]) do + raise ArgumentError, + "a comment cannot start with `!`, `+`, or `M!`, as MySQL and MariaDB " <> + "treat such comments as executable SQL or optimizer hints, got: #{inspect(comment)}" end comment diff --git a/test/ecto/adapters/sql_test.exs b/test/ecto/adapters/sql_test.exs index d1d847c0f..688c7be7f 100644 --- a/test/ecto/adapters/sql_test.exs +++ b/test/ecto/adapters/sql_test.exs @@ -33,6 +33,30 @@ defmodule Ecto.Adapters.SQLTest do end end + test "rejects prefixes that MySQL/MariaDB treat as executable comments or hints" do + for bad <- ["!40000 DROP TABLE posts", "+MAX_EXECUTION_TIME(1)", "M!100000 DROP"] do + assert_raise ArgumentError, ~r/cannot start with/, fn -> + Ecto.Adapters.SQL.comments(pre: bad) + end + + assert_raise ArgumentError, ~r/cannot start with/, fn -> + Ecto.Adapters.SQL.comments(post: bad) + end + end + end + + # Regression: the space after `/*` is load-bearing. MySQL/MariaDB executable + # comments (`/*!`, `/*M!`) and optimizer hints (`/*+`) only take effect when + # the marker immediately follows `/*`, so the rendered form must always keep + # a space between the delimiter and the comment text. + test "always renders a space between /* and the comment text" do + {pre, post} = comments(pre: "tag", post: "tag") + assert pre == "/* tag */ " + assert post == " /* tag */" + refute pre =~ "/*t" + refute post =~ "/*t" + end + test "rejects bad shapes" do assert_raise ArgumentError, ~r/expected \{:pre/, fn -> Ecto.Adapters.SQL.comments(foo: "bar") From 03535d2412d8a2bc278034b832f477e4b7788512 Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Sat, 29 Aug 2026 12:19:21 +0200 Subject: [PATCH 6/7] Rename escape_comment! to validate_comment! --- lib/ecto/adapters/sql.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 55c8c4307..a80ccc4c9 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -1192,11 +1192,11 @@ defmodule Ecto.Adapters.SQL do # The space after `/*` is load-bearing: MySQL executable comments (`/*!`), # MariaDB executable comments (`/*M!`), and optimizer hints (`/*+`) only # take effect when the marker immediately follows `/*`. Keep the space even - # though escape_comment!/1 also rejects those prefixes (defense in depth). + # though validate_comment!/1 also rejects those prefixes (defense in depth). {pre, post} = Enum.reduce(comments, {[], []}, fn - {:pre, c}, {pre, post} -> {[["/* ", escape_comment!(c), " */ "] | pre], post} - {:post, c}, {pre, post} -> {pre, [[" /* ", escape_comment!(c), " */"] | post]} + {:pre, c}, {pre, post} -> {[["/* ", validate_comment!(c), " */ "] | pre], post} + {:post, c}, {pre, post} -> {pre, [[" /* ", validate_comment!(c), " */"] | post]} other, _ -> raise ArgumentError, "expected {:pre, string} or {:post, string}, got: #{inspect(other)}" end) @@ -1208,7 +1208,7 @@ defmodule Ecto.Adapters.SQL do "comments must be a keyword list of [pre: string, post: string], got: #{inspect(other)}" end - defp escape_comment!(comment) when is_binary(comment) do + defp validate_comment!(comment) when is_binary(comment) do if String.contains?(comment, ["/*", "*/", <<0>>]) do raise ArgumentError, "a comment cannot contain `/*`, `*/`, or null bytes, got: #{inspect(comment)}" @@ -1226,7 +1226,7 @@ defmodule Ecto.Adapters.SQL do comment end - defp escape_comment!(other) do + defp validate_comment!(other) do raise ArgumentError, "a comment must be a string, got: #{inspect(other)}" end From bf814954dde8e3b54cc2b08a9f07a5aa1cc14fee Mon Sep 17 00:00:00 2001 From: Aliaksandr Sasnouski Date: Sat, 29 Aug 2026 12:30:57 +0200 Subject: [PATCH 7/7] Temporarily point ecto dep at fork for CI (revert before merge). --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index fcc8392fb..0a9ec9515 100644 --- a/mix.exs +++ b/mix.exs @@ -84,7 +84,7 @@ defmodule EctoSQL.MixProject do if path = System.get_env("ECTO_PATH") do {:ecto, path: path} else - {:ecto, git: "https://github.com/elixir-ecto/ecto.git"} + {:ecto, git: "https://github.com/alesasnouski/ecto.git", branch: "master"} end end