From 94dd0f872ad1877cf593dc8d391d91d847f0c92d Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 25 Sep 2026 10:50:38 +0200 Subject: [PATCH 1/5] Fix PostgreSQL column collation modification order --- lib/ecto/adapters/postgres/connection.ex | 6 +++--- test/ecto/adapters/postgres_test.exs | 27 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index b4d16e12..4b238a2a 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -1594,11 +1594,11 @@ if Code.ensure_loaded?(Postgrex) do quote_name(name), " TYPE ", reference_column_type(ref.type, opts), + collation_expr(collation), ", ADD ", reference_expr(ref, table, name), modify_null(name, opts), - modify_default(name, ref.type, opts), - collation_expr(collation) + modify_default(name, ref.type, opts) ] end @@ -1611,9 +1611,9 @@ if Code.ensure_loaded?(Postgrex) do quote_name(name), " TYPE ", modify_column_type(type, opts), + collation_expr(collation), modify_null(name, opts), modify_default(name, type, opts), - collation_expr(collation), modify_identity(name, type, opts) ] end diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index f3577fc7..2e8565ba 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -2799,6 +2799,33 @@ defmodule Ecto.Adapters.PostgresTest do ] end + test "alter column collation stays with the type when modifying null or default" do + assert execute_ddl( + {:alter, table(:posts), [{:modify, :name, :text, collation: "C", null: false}]} + ) == + [ + ~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "C", ALTER COLUMN "name" SET NOT NULL| + ] + + assert execute_ddl( + {:alter, table(:posts), [{:modify, :name, :text, collation: "C", default: "x"}]} + ) == + [ + ~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "C", ALTER COLUMN "name" SET DEFAULT 'x'| + ] + + assert execute_ddl( + {:alter, table(:posts), + [ + {:modify, :name, %Reference{table: :names, type: :text}, + collation: "C", null: false, default: "x"} + ]} + ) == + [ + ~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "C", ADD CONSTRAINT "posts_name_fkey" FOREIGN KEY ("name") REFERENCES "names"("id"), ALTER COLUMN "name" SET NOT NULL, ALTER COLUMN "name" SET DEFAULT 'x'| + ] + end + test "alter table with comments on table and columns" do alter = {:alter, table(:posts, comment: "table comment"), From b2d6a981218fb914952c372b54673ee6b2c7f9a6 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 25 Sep 2026 12:08:28 +0200 Subject: [PATCH 2/5] Place PostgreSQL add-column collation after type --- lib/ecto/adapters/postgres/connection.ex | 39 ++++++++++++++++-------- test/ecto/adapters/postgres_test.exs | 32 +++++++++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index 4b238a2a..de3e8db2 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -1532,7 +1532,7 @@ if Code.ensure_loaded?(Postgrex) do [ quote_name(name), ?\s, - reference_column_type(ref.type, opts), + add_reference_column_type(ref.type, opts), column_options(ref.type, opts), ", ", reference_expr(ref, table, name) @@ -1540,7 +1540,7 @@ if Code.ensure_loaded?(Postgrex) do end defp column_definition(_table, {:add, name, type, opts}) do - [quote_name(name), ?\s, column_type(type, opts), column_options(type, opts)] + [quote_name(name), ?\s, add_column_type(type, opts), column_options(type, opts)] end defp column_changes(table, columns) do @@ -1552,7 +1552,7 @@ if Code.ensure_loaded?(Postgrex) do "ADD COLUMN ", quote_name(name), ?\s, - reference_column_type(ref.type, opts), + add_reference_column_type(ref.type, opts), column_options(ref.type, opts), ", ADD ", reference_expr(ref, table, name) @@ -1560,7 +1560,13 @@ if Code.ensure_loaded?(Postgrex) do end defp column_change(_table, {:add, name, type, opts}) do - ["ADD COLUMN ", quote_name(name), ?\s, column_type(type, opts), column_options(type, opts)] + [ + "ADD COLUMN ", + quote_name(name), + ?\s, + add_column_type(type, opts), + column_options(type, opts) + ] end defp column_change(table, {:add_if_not_exists, name, %Reference{} = ref, opts}) do @@ -1568,7 +1574,7 @@ if Code.ensure_loaded?(Postgrex) do "ADD COLUMN IF NOT EXISTS ", quote_name(name), ?\s, - reference_column_type(ref.type, opts), + add_reference_column_type(ref.type, opts), column_options(ref.type, opts), ", ADD ", reference_expr(ref, table, name) @@ -1580,7 +1586,7 @@ if Code.ensure_loaded?(Postgrex) do "ADD COLUMN IF NOT EXISTS ", quote_name(name), ?\s, - column_type(type, opts), + add_column_type(type, opts), column_options(type, opts) ] end @@ -1675,9 +1681,8 @@ if Code.ensure_loaded?(Postgrex) do defp column_options(type, opts) do default = Keyword.fetch(opts, :default) null = Keyword.get(opts, :null) - collation = Keyword.fetch(opts, :collation) - [default_expr(default, type), null_expr(null), collation_expr(collation)] + [default_expr(default, type), null_expr(null)] end defp null_expr(false), do: " NOT NULL" @@ -1815,18 +1820,28 @@ if Code.ensure_loaded?(Postgrex) do defp options_expr(options), do: [?\s, options] - defp column_type(type, opts) do + defp add_column_type(type, opts) do + column_type(type, opts, collation_expr(Keyword.fetch(opts, :collation))) + end + + defp add_reference_column_type(type, opts) when type in [:serial, :bigserial, :identity] do + [reference_column_type(type, opts), collation_expr(Keyword.fetch(opts, :collation))] + end + + defp add_reference_column_type(type, opts), do: add_column_type(type, opts) + + defp column_type(type, opts, collation \\ []) do type_name = column_type_name(type, opts) case Keyword.get(opts, :generated) do nil when type == :identity -> - [type_name, identity_generated_expr(opts)] + [type_name, collation, identity_generated_expr(opts)] nil -> - type_name + [type_name, collation] expr when is_binary(expr) -> - [type_name, " GENERATED ", expr] + [type_name, collation, " GENERATED ", expr] other -> raise ArgumentError, diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index 2e8565ba..b3c70b78 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -2826,6 +2826,38 @@ defmodule Ecto.Adapters.PostgresTest do ] end + test "column collation precedes defaults and constraints when adding columns" do + opts = [collation: "C", default: "x", null: false] + + assert execute_ddl({:create, table(:posts), [{:add, :name, :text, opts}]}) == [ + ~s|CREATE TABLE "posts" ("name" text COLLATE "C" DEFAULT 'x' NOT NULL)| + ] + + assert execute_ddl({:alter, table(:posts), [{:add, :name, :text, opts}]}) == [ + ~s|ALTER TABLE "posts" ADD COLUMN "name" text COLLATE "C" DEFAULT 'x' NOT NULL| + ] + + assert execute_ddl({:alter, table(:posts), [{:add_if_not_exists, :name, :text, opts}]}) == [ + ~s|ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "name" text COLLATE "C" DEFAULT 'x' NOT NULL| + ] + + assert execute_ddl( + {:alter, table(:posts), + [{:add, :name, %Reference{table: :names, type: :text}, opts}]} + ) == + [ + ~s|ALTER TABLE "posts" ADD COLUMN "name" text COLLATE "C" DEFAULT 'x' NOT NULL, ADD CONSTRAINT "posts_name_fkey" FOREIGN KEY ("name") REFERENCES "names"("id")| + ] + + assert execute_ddl( + {:alter, table(:posts), + [{:add, :computed, :text, collation: "C", generated: "ALWAYS AS (name) STORED"}]} + ) == + [ + ~s|ALTER TABLE "posts" ADD COLUMN "computed" text COLLATE "C" GENERATED ALWAYS AS (name) STORED| + ] + end + test "alter table with comments on table and columns" do alter = {:alter, table(:posts, comment: "table comment"), From d241868b08c4b0cbdc64bc5b401beb728d04bc0b Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 25 Sep 2026 12:10:00 +0200 Subject: [PATCH 3/5] Quote PostgreSQL collation names safely --- lib/ecto/adapters/postgres/connection.ex | 12 +++++++++++- lib/ecto/migration.ex | 8 ++++++-- test/ecto/adapters/postgres_test.exs | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index de3e8db2..fa75a99f 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -1689,9 +1689,19 @@ if Code.ensure_loaded?(Postgrex) do defp null_expr(true), do: " NULL" defp null_expr(_), do: [] - defp collation_expr({:ok, collation_name}), do: " COLLATE \"#{collation_name}\"" + defp collation_expr({:ok, {schema, name}}) do + [" COLLATE ", quote_collation_name(schema), ?., quote_collation_name(name)] + end + + defp collation_expr({:ok, name}), do: [" COLLATE ", quote_collation_name(name)] defp collation_expr(_), do: [] + defp quote_collation_name(name) when is_atom(name), + do: quote_collation_name(Atom.to_string(name)) + + defp quote_collation_name(name) when is_binary(name), + do: [?", String.replace(name, "\"", "\"\""), ?"] + defp new_constraint_expr(%Constraint{check: check} = constraint) when is_binary(check) do [ "CONSTRAINT ", diff --git a/lib/ecto/migration.ex b/lib/ecto/migration.ex index 50a440c9..bc623f8d 100644 --- a/lib/ecto/migration.ex +++ b/lib/ecto/migration.ex @@ -1253,7 +1253,9 @@ defmodule Ecto.Migration do specified. * `:scale` - the scale of a numeric type. Defaults to `0`. * `:comment` - adds a comment to the added column. - * `:collation` - the collation of the text type. + * `:collation` - the collation of the text type. On PostgreSQL, use + `{schema, name}` for a schema-qualified collation; a string is treated as a + single collation name. * `:after` - positions field after the specified one. Only supported on MySQL, it is ignored by other databases. * `:generated` - a string representing the expression for a generated column. See @@ -1450,7 +1452,9 @@ defmodule Ecto.Migration do specified. * `:scale` - the scale of a numeric type. Defaults to `0`. * `:comment` - adds a comment to the modified column. - * `:collation` - the collation of the text type. + * `:collation` - the collation of the text type. On PostgreSQL, use + `{schema, name}` for a schema-qualified collation; a string is treated as a + single collation name. """ def modify(column, type, opts \\ []) when is_atom(column) and is_list(opts) do validate_column_opts!(opts, @modify_column_opts, "modify/3") diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index b3c70b78..6a5a1d66 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -2858,6 +2858,24 @@ defmodule Ecto.Adapters.PostgresTest do ] end + test "collation names are quoted as identifiers" do + assert execute_ddl({:alter, table(:posts), [{:modify, :name, :text, collation: "odd\"name"}]}) == + [~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "odd""name"|] + + assert execute_ddl( + {:alter, table(:posts), + [{:modify, :name, :text, collation: {"my.schema", "odd\"name"}}]} + ) == + [ + ~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "my.schema"."odd""name"| + ] + + assert execute_ddl( + {:alter, table(:posts), [{:add, :name, :text, collation: "name.with.dot"}]} + ) == + [~s|ALTER TABLE "posts" ADD COLUMN "name" text COLLATE "name.with.dot"|] + end + test "alter table with comments on table and columns" do alter = {:alter, table(:posts, comment: "table comment"), From 38a6a9d96e640a0e2af920ecbf836458e1a8baf4 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 25 Sep 2026 12:11:42 +0200 Subject: [PATCH 4/5] Document PostgreSQL collation reset on modify --- lib/ecto/migration.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ecto/migration.ex b/lib/ecto/migration.ex index bc623f8d..730ca8d5 100644 --- a/lib/ecto/migration.ex +++ b/lib/ecto/migration.ex @@ -1454,7 +1454,9 @@ defmodule Ecto.Migration do * `:comment` - adds a comment to the modified column. * `:collation` - the collation of the text type. On PostgreSQL, use `{schema, name}` for a schema-qualified collation; a string is treated as a - single collation name. + single collation name. PostgreSQL resets the collation to the type's default + when modifying a column without this option, even if the type is unchanged. + Specify the current collation to preserve it. """ def modify(column, type, opts \\ []) when is_atom(column) and is_list(opts) do validate_column_opts!(opts, @modify_column_opts, "modify/3") From 326c6e8d43d290fbae62d6567bcbc0d0d9040574 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Fri, 25 Sep 2026 12:48:28 +0200 Subject: [PATCH 5/5] Keep PostgreSQL collation option as a name string --- lib/ecto/adapters/postgres/connection.ex | 4 ---- lib/ecto/migration.ex | 12 ++++-------- test/ecto/adapters/postgres_test.exs | 10 +--------- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/lib/ecto/adapters/postgres/connection.ex b/lib/ecto/adapters/postgres/connection.ex index fa75a99f..a8d7d5c1 100644 --- a/lib/ecto/adapters/postgres/connection.ex +++ b/lib/ecto/adapters/postgres/connection.ex @@ -1689,10 +1689,6 @@ if Code.ensure_loaded?(Postgrex) do defp null_expr(true), do: " NULL" defp null_expr(_), do: [] - defp collation_expr({:ok, {schema, name}}) do - [" COLLATE ", quote_collation_name(schema), ?., quote_collation_name(name)] - end - defp collation_expr({:ok, name}), do: [" COLLATE ", quote_collation_name(name)] defp collation_expr(_), do: [] diff --git a/lib/ecto/migration.ex b/lib/ecto/migration.ex index 730ca8d5..18c0e777 100644 --- a/lib/ecto/migration.ex +++ b/lib/ecto/migration.ex @@ -1253,9 +1253,7 @@ defmodule Ecto.Migration do specified. * `:scale` - the scale of a numeric type. Defaults to `0`. * `:comment` - adds a comment to the added column. - * `:collation` - the collation of the text type. On PostgreSQL, use - `{schema, name}` for a schema-qualified collation; a string is treated as a - single collation name. + * `:collation` - the collation of the text type. * `:after` - positions field after the specified one. Only supported on MySQL, it is ignored by other databases. * `:generated` - a string representing the expression for a generated column. See @@ -1452,11 +1450,9 @@ defmodule Ecto.Migration do specified. * `:scale` - the scale of a numeric type. Defaults to `0`. * `:comment` - adds a comment to the modified column. - * `:collation` - the collation of the text type. On PostgreSQL, use - `{schema, name}` for a schema-qualified collation; a string is treated as a - single collation name. PostgreSQL resets the collation to the type's default - when modifying a column without this option, even if the type is unchanged. - Specify the current collation to preserve it. + * `:collation` - the collation of the text type. PostgreSQL resets the + collation to the type's default when modifying a column without this option, + even if the type is unchanged. Specify the current collation to preserve it. """ def modify(column, type, opts \\ []) when is_atom(column) and is_list(opts) do validate_column_opts!(opts, @modify_column_opts, "modify/3") diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs index 6a5a1d66..deb551f9 100644 --- a/test/ecto/adapters/postgres_test.exs +++ b/test/ecto/adapters/postgres_test.exs @@ -2858,18 +2858,10 @@ defmodule Ecto.Adapters.PostgresTest do ] end - test "collation names are quoted as identifiers" do + test "collation strings are quoted as single identifiers" do assert execute_ddl({:alter, table(:posts), [{:modify, :name, :text, collation: "odd\"name"}]}) == [~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "odd""name"|] - assert execute_ddl( - {:alter, table(:posts), - [{:modify, :name, :text, collation: {"my.schema", "odd\"name"}}]} - ) == - [ - ~s|ALTER TABLE "posts" ALTER COLUMN "name" TYPE text COLLATE "my.schema"."odd""name"| - ] - assert execute_ddl( {:alter, table(:posts), [{:add, :name, :text, collation: "name.with.dot"}]} ) ==