diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb2c09..28da6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.9.4 (2026-05-30) +- Escape double quotes, backticks, and backslashes in quoted ClickHouse identifiers https://github.com/plausible/ecto_ch/pull/283 - Support the `:comment` column option in migrations (`create`, `add`, and `modify`), which was previously silently ignored https://github.com/plausible/ecto_ch/pull/282 ## 0.9.3 (2026-05-07) diff --git a/lib/ecto/adapters/clickhouse/connection.ex b/lib/ecto/adapters/clickhouse/connection.ex index 7ae1b3b..94a6331 100644 --- a/lib/ecto/adapters/clickhouse/connection.ex +++ b/lib/ecto/adapters/clickhouse/connection.ex @@ -1024,6 +1024,7 @@ defmodule Ecto.Adapters.ClickHouse.Connection do names |> Enum.reject(&is_nil/1) |> intersperse_map(?., "e_name(&1, nil)) + |> escape_quoted(quoter) |> wrap_in(quoter) end @@ -1032,7 +1033,9 @@ defmodule Ecto.Adapters.ClickHouse.Connection do end def quote_name(name, quoter) do - wrap_in(name, quoter) + name + |> escape_quoted(quoter) + |> wrap_in(quoter) end defp quote_qualified_name(name, sources, ix) do @@ -1052,6 +1055,16 @@ defmodule Ecto.Adapters.ClickHouse.Connection do defp wrap_in(value, nil), do: value defp wrap_in(value, wrapper), do: [wrapper, value, wrapper] + defp escape_quoted(value, nil), do: value + defp escape_quoted(value, ?'), do: escape_string(IO.iodata_to_binary(value)) + + defp escape_quoted(value, quoter) when quoter in [?\", ?`] do + value + |> IO.iodata_to_binary() + |> :binary.replace("\\", "\\\\", [:global]) + |> :binary.replace(<>, "\\" <> <>, [:global]) + end + @doc false # TODO faster? def escape_string(value) when is_binary(value) do diff --git a/test/ecto/adapters/clickhouse/connection_test.exs b/test/ecto/adapters/clickhouse/connection_test.exs index 4ab9453..5dda7b8 100644 --- a/test/ecto/adapters/clickhouse/connection_test.exs +++ b/test/ecto/adapters/clickhouse/connection_test.exs @@ -774,6 +774,24 @@ defmodule Ecto.Adapters.ClickHouse.ConnectionTest do query = "schema" |> where(foo: "'") |> select([], true) assert all(query) == ~s[SELECT true FROM "schema" AS s0 WHERE (s0."foo" = '''')] + + query = "schema" |> select([], constant(^"let's \\ escape")) + assert all(query) == ~s[SELECT 'let''s \\\\ escape' FROM "schema" AS s0] + end + + test "quoted identifier escape" do + assert Connection.quote_name(~s{has"quote}) |> IO.iodata_to_binary() == + ~s{"has\\"quote"} + + assert Connection.quote_name(~s{has\\slash}) |> IO.iodata_to_binary() == + ~s{"has\\\\slash"} + + assert Connection.quote_name(~s{has`tick}, ?`) |> IO.iodata_to_binary() == + ~s{`has\\`tick`} + + query = insert(nil, ~s{schema"quoted}, [~s{field"quoted}], [], :raise, []) + + assert query == ~s{INSERT INTO "schema\\"quoted"("field\\"quoted")} end test "binary ops" do @@ -2510,6 +2528,14 @@ defmodule Ecto.Adapters.ClickHouse.ConnectionTest do end end + test "create table with escaped table comment" do + create = {:create, table(:posts, comment: "owner's \\\\ table"), []} + + assert execute_ddl(create) == [ + ~s{CREATE TABLE "posts" () ENGINE=TinyLog COMMENT 'owner''s \\\\\\\\ table'} + ] + end + test "create table with serial primary key" do create = {:create, table(:posts, engine: "MergeTree"),