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
68 changes: 68 additions & 0 deletions integration_test/sql/logging.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 4 additions & 6 deletions lib/ecto/adapters/myxql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}} ->
Expand Down
11 changes: 8 additions & 3 deletions lib/ecto/adapters/myxql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -137,7 +139,8 @@ if Code.ensure_loaded?(MyXQL) do
combinations,
order_by,
limit,
offset | lock
offset,
lock | post_comments
]
end

Expand All @@ -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 =
Expand All @@ -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
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions lib/ecto/adapters/postgres/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -216,7 +218,8 @@ if Code.ensure_loaded?(Postgrex) do
combinations,
order_by,
limit,
offset | lock
offset,
lock | post_comments
]
end

Expand All @@ -225,25 +228,25 @@ 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
def delete_all(%{from: from} = query) 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
Expand Down
79 changes: 67 additions & 12 deletions lib/ecto/adapters/sql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -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}} ->
Expand Down
11 changes: 8 additions & 3 deletions lib/ecto/adapters/tds/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -198,7 +201,8 @@ if Code.ensure_loaded?(Tds) do
returning(query, 0, "INSERTED"),
from,
join,
where | lock
where,
lock | post_comments
]
end

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions test/ecto/adapters/myxql_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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` = '''\\\\ ')}
Expand Down
15 changes: 15 additions & 0 deletions test/ecto/adapters/postgres_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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\" = '''\\ ')}
Expand Down
Loading