diff --git a/integration_test/sql/logging.exs b/integration_test/sql/logging.exs index 0f92e9a7f..e0750bb1d 100644 --- a/integration_test/sql/logging.exs +++ b/integration_test/sql/logging.exs @@ -178,6 +178,74 @@ defmodule Ecto.Integration.LoggingTest do end end + describe ":comments option" do + test "comments query operations" do + assert capture_log(fn -> + 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]], comments: [pre: "reset_visits_q"], log: :error) + end) =~ "/* reset_visits_q */ UPDATE" + + assert capture_log(fn -> + TestRepo.delete_all(Post, comments: [pre: "purge_posts_q"], log: :error) + end) =~ "/* purge_posts_q */ DELETE" + end + + test "supports both :pre and :post" do + assert capture_log(fn -> + TestRepo.all(Post, comments: [pre: "before_q", post: "after_q"], log: :error) + end) =~ ~r{/\* before_q \*/ SELECT.* /\* after_q \*/} + end + + 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()}"], 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) + 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!(comments: [pre: "update_post_q"], log: :error) + end) =~ "/* update_post_q */ UPDATE" + + assert capture_log(fn -> + 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"}], comments: [pre: "bulk_insert_posts_q"], log: :error) + end) =~ "/* bulk_insert_posts_q */ INSERT INTO" + end + + 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"}, 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 @describetag :parameter_logging diff --git a/lib/ecto/adapters/myxql.ex b/lib/ecto/adapters/myxql.ex index 6026cb260..fe8359889 100644 --- a/lib/ecto/adapters/myxql.ex +++ b/lib/ecto/adapters/myxql.ex @@ -373,12 +373,10 @@ 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 {:ok, %{num_rows: 0}} -> diff --git a/lib/ecto/adapters/myxql/connection.ex b/lib/ecto/adapters/myxql/connection.ex index 1823a9d3a..55a0e2efa 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) + {pre_comments, post_comments} = SQL.comments(query.comments) [ + pre_comments, cte, select, from, @@ -137,7 +139,8 @@ if Code.ensure_loaded?(MyXQL) do combinations, order_by, limit, - offset | lock + offset, + lock | post_comments ] end @@ -151,6 +154,7 @@ if Code.ensure_loaded?(MyXQL) do sources = create_names(query, []) cte = cte(query, sources) + {pre_comments, post_comments} = SQL.comments(query.comments) {from, name} = get_source(query, sources, 0, source) fields = @@ -164,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) - [cte, prefix, fields | where] + [pre_comments, cte, prefix, fields, where | post_comments] end @impl true @@ -177,11 +181,12 @@ if Code.ensure_loaded?(MyXQL) do cte = cte(query, sources) {_, name, _} = elem(sources, 0) + {pre_comments, post_comments} = SQL.comments(query.comments) from = from(query, sources) join = join(query, sources) where = where(query, sources) - [cte, "DELETE ", name, ".*", from, join | where] + [pre_comments, cte, "DELETE ", name, ".*", from, join, where | post_comments] end @impl true diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index 95e893885..6f445fe93 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) + {pre_comments, post_comments} = Ecto.Adapters.SQL.comments(query.comments) [ + pre_comments, cte, select, from, @@ -216,7 +218,8 @@ if Code.ensure_loaded?(Postgrex) do combinations, order_by, limit, - offset | lock + offset, + lock | post_comments ] end @@ -225,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) - + {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) - [cte, prefix, fields, join, where | returning(query, sources)] + [pre_comments, cte, prefix, fields, join, where, returning(query, sources) | post_comments] end @impl true @@ -239,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) - + {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) - [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 diff --git a/lib/ecto/adapters/sql.ex b/lib/ecto/adapters/sql.ex index 4dac125c8..a80ccc4c9 100644 --- a/lib/ecto/adapters/sql.ex +++ b/lib/ecto/adapters/sql.ex @@ -985,12 +985,8 @@ 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) @@ -1171,6 +1167,69 @@ defmodule Ecto.Adapters.SQL do end end + @doc false + def wrap_comments(sql, opts) do + {pre, post} = comments(Keyword.get(opts, :comments, [])) + [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 + # 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 validate_comment!/1 also rejects those prefixes (defense in depth). + {pre, post} = + Enum.reduce(comments, {[], []}, fn + {: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) + + {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_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)}" + 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 + end + + defp validate_comment!(other) do + raise ArgumentError, "a comment must be a string, got: #{inspect(other)}" + end + @doc false def struct( adapter_meta, @@ -1184,12 +1243,8 @@ 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 {:ok, %{rows: nil, num_rows: 1}} -> diff --git a/lib/ecto/adapters/tds/connection.ex b/lib/ecto/adapters/tds/connection.ex index 170b832fa..206f238d5 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) + {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") - [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 @@ -188,8 +189,10 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) + {pre_comments, post_comments} = SQL.comments(query.comments) [ + pre_comments, cte, "UPDATE ", name, @@ -198,7 +201,8 @@ if Code.ensure_loaded?(Tds) do returning(query, 0, "INSERTED"), from, join, - where | lock + where, + lock | post_comments ] end @@ -213,8 +217,9 @@ if Code.ensure_loaded?(Tds) do join = join(query, sources) where = where(query, sources) lock = lock(query, sources) + {pre_comments, post_comments} = SQL.comments(query.comments) - [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 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 diff --git a/test/ecto/adapters/myxql_test.exs b/test/ecto/adapters/myxql_test.exs index 9170a945c..3912c3976 100644 --- a/test/ecto/adapters/myxql_test.exs +++ b/test/ecto/adapters/myxql_test.exs @@ -595,6 +595,21 @@ defmodule Ecto.Adapters.MyXQLTest do assert all(query) == ~s{SELECT TRUE FROM `schema` AS s0 UPDATE on s0} end + test "comments" do + query = Schema |> select([], true) |> plan() + 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 | comments: [pre: "upd_q"]}) == ~s{/* upd_q */ UPDATE `schema` AS s0 SET s0.`x` = 0} + + query = Schema |> plan(:delete_all) + assert delete_all(%{query | comments: [pre: "del_q"]}) == ~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..125cf4b4f 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -788,6 +788,21 @@ defmodule Ecto.Adapters.PostgresTest do assert all(query) == ~s{SELECT TRUE FROM "schema" AS s0 UPDATE on s0} end + test "comments" do + query = Schema |> select([], true) |> plan() + 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 | comments: [pre: "upd_q"]}) == ~s{/* upd_q */ UPDATE "schema" AS s0 SET "x" = 0} + + query = Schema |> plan(:delete_all) + assert delete_all(%{query | comments: [pre: "del_q"]}) == ~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..688c7be7f --- /dev/null +++ b/test/ecto/adapters/sql_test.exs @@ -0,0 +1,118 @@ +defmodule Ecto.Adapters.SQLTest do + use ExUnit.Case, async: true + + defp comments(list) do + {pre, post} = Ecto.Adapters.SQL.comments(list) + {IO.iodata_to_binary(pre), IO.iodata_to_binary(post)} + end + + defp wrap(sql, opts) do + sql |> Ecto.Adapters.SQL.wrap_comments(opts) |> IO.iodata_to_binary() + end + + describe "comments/1" do + test "empty list renders nothing" do + assert comments([]) == {"", ""} + end + + 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 + + test "preserves order and supports multiples" do + assert comments(pre: "a", pre: "b") == {"/* a */ /* b */ ", ""} + end + + 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.comments(pre: bad) + end + 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") + 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 + + 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 diff --git a/test/ecto/adapters/tds_test.exs b/test/ecto/adapters/tds_test.exs index 2e94de197..c006a9e60 100644 --- a/test/ecto/adapters/tds_test.exs +++ b/test/ecto/adapters/tds_test.exs @@ -646,6 +646,21 @@ defmodule Ecto.Adapters.TdsTest do assert all(query) == ~s{SELECT CAST(1 as bit) FROM [schema] AS s0 OPTION (UPDATE on s0)} end + test "comments" do + query = Schema |> select([], true) |> plan() + 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 | 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 | comments: [pre: "del_q"]}) == ~s{/* del_q */ DELETE s0 FROM [schema] AS s0} + end + test "string escape" do query = "schema" |> where(foo: "\'-- ") |> select([], true) |> plan()