From 472ac21df0878f920bb760b85840fb24f4b4c512 Mon Sep 17 00:00:00 2001 From: weljoda Date: Fri, 5 Jun 2026 14:40:18 +0200 Subject: [PATCH 1/4] feat: support declarative column collations on attributes --- .formatter.exs | 1 + .../dsls/DSL-AshPostgres.DataLayer.md | 43 +++++++++ lib/collation.ex | 31 ++++++ lib/data_layer.ex | 38 +++++++- lib/data_layer/info.ex | 5 + .../migration_generator.ex | 22 +++++ lib/migration_generator/operation.ex | 19 +++- test/migration_generator_test.exs | 94 +++++++++++++++++++ 8 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 lib/collation.ex diff --git a/.formatter.exs b/.formatter.exs index 87187457..3f886d5d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -10,6 +10,7 @@ spark_locals_without_parens = [ check_constraint: 2, check_constraint: 3, code?: 1, + collation: 2, concurrently: 1, create?: 1, create_table_options: 1, diff --git a/documentation/dsls/DSL-AshPostgres.DataLayer.md b/documentation/dsls/DSL-AshPostgres.DataLayer.md index a6742b91..59c39ec7 100644 --- a/documentation/dsls/DSL-AshPostgres.DataLayer.md +++ b/documentation/dsls/DSL-AshPostgres.DataLayer.md @@ -20,6 +20,8 @@ Postgres data layer configuration * reference * [check_constraints](#postgres-check_constraints) * check_constraint + * [collations](#postgres-collations) + * collation ### Examples @@ -387,6 +389,47 @@ check_constraint :price, "price_must_be_positive", check: "price > 0", message: Target: `AshPostgres.CheckConstraint` +### postgres.collations +A section for configuring the collations applied to a table's columns. + +This section is only relevant if you are using the migration generator with this resource. +Otherwise, it has no effect. + +### Nested DSLs + * [collation](#postgres-collations-collation) + +### Examples +``` +collations do + collation :name, "natural_sort" +end +``` +### postgres.collations.collation +```elixir +collation attribute, collation +``` + +Applies a collation to an attribute's column when generating migrations. + +The collation can be a built-in collation (e.g `"de_AT"`, `"und-x-icu"`) or one created via the repo's `installed_collations/0` callback. Setting a collation makes it the column's default, so ordinary sorts and comparisons on that column use it. + +### Examples +``` +collation :name, "natural_sort" +``` + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`attribute`](#postgres-collations-collation-attribute){: #postgres-collations-collation-attribute .spark-required} | `atom` | | The attribute to apply the collation to. | +| [`collation`](#postgres-collations-collation-collation){: #postgres-collations-collation-collation .spark-required} | `String.t` | | The name of the collation to use for the column. Can be a built-in collation (e.g `"de_AT"`, `"und-x-icu"`) or one created via the repo's `installed_collations/0` callback. | + +### Introspection + +Target: `AshPostgres.Collation` + + diff --git a/lib/collation.ex b/lib/collation.ex new file mode 100644 index 00000000..aaedbb3e --- /dev/null +++ b/lib/collation.ex @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Collation do + @moduledoc "Represents the collation applied to an attribute's column in generated migrations." + @fields [ + :attribute, + :collation + ] + + defstruct @fields ++ [:__spark_metadata__] + + def fields, do: @fields + + @schema [ + attribute: [ + type: :atom, + required: true, + doc: "The attribute to apply the collation to." + ], + collation: [ + type: :string, + required: true, + doc: + "The name of the collation to use for the column. Can be a built-in collation (e.g `\"de_AT\"`, `\"und-x-icu\"`) or one created via the repo's `installed_collations/0` callback." + ] + ] + + def schema, do: @schema +end diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 5c082a5b..8e79bf86 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -221,6 +221,41 @@ defmodule AshPostgres.DataLayer do entities: [@check_constraint] } + @collation %Spark.Dsl.Entity{ + name: :collation, + describe: """ + Applies a collation to an attribute's column when generating migrations. + + The collation can be a built-in collation (e.g `"de_AT"`, `"und-x-icu"`) or one created + via the repo's `installed_collations/0` callback. Setting a collation makes it the column's + default, so ordinary sorts and comparisons on that column use it. + """, + examples: [ + "collation :name, \"natural_sort\"" + ], + args: [:attribute, :collation], + target: AshPostgres.Collation, + schema: AshPostgres.Collation.schema() + } + + @collations %Spark.Dsl.Section{ + name: :collations, + describe: """ + A section for configuring the collations applied to a table's columns. + + This section is only relevant if you are using the migration generator with this resource. + Otherwise, it has no effect. + """, + examples: [ + """ + collations do + collation :name, "natural_sort" + end + """ + ], + entities: [@collation] + } + @references %Spark.Dsl.Section{ name: :references, describe: """ @@ -266,7 +301,8 @@ defmodule AshPostgres.DataLayer do @custom_statements, @manage_tenant, @references, - @check_constraints + @check_constraints, + @collations ], modules: [ :repo diff --git a/lib/data_layer/info.ex b/lib/data_layer/info.ex index 7411cc9e..0bcb32ec 100644 --- a/lib/data_layer/info.ex +++ b/lib/data_layer/info.ex @@ -112,6 +112,11 @@ defmodule AshPostgres.DataLayer.Info do |> Enum.find(&(&1.relationship == relationship)) end + @doc "The configured collations for a resource" + def collations(resource) do + Extension.get_entities(resource, [:postgres, :collations]) + end + @doc "A keyword list of customized migration types" def migration_types(resource) do Extension.get_opt(resource, [:postgres], :migration_types, []) diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index db1433f9..667f1281 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -1038,6 +1038,7 @@ defmodule AshPostgres.MigrationGenerator do source: source, type: merge_types(Enum.map(attributes, & &1.type), source, table), size: size, + collation: merge_collations(Enum.map(attributes, & &1[:collation]), source, table), default: merge_defaults(Enum.map(attributes, & &1.default)), allow_nil?: Enum.any?(attributes, & &1.allow_nil?) || Enum.count(attributes) < count, generated?: Enum.any?(attributes, & &1.generated?), @@ -1122,6 +1123,22 @@ defmodule AshPostgres.MigrationGenerator do end end + defp merge_collations(collations, name, table) do + collations + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> case do + [] -> + nil + + [collation] -> + collation + + collations -> + raise "Conflicting collations for table `#{table}.#{name}`: #{inspect(collations)}" + end + end + defp merge_defaults(defaults) do defaults |> Enum.uniq() @@ -4135,6 +4152,9 @@ defmodule AshPostgres.MigrationGenerator do repo = AshPostgres.DataLayer.Info.repo(resource, :mutate) ignored = AshPostgres.DataLayer.Info.migration_ignore_attributes(resource) || [] + collations = + Map.new(AshPostgres.DataLayer.Info.collations(resource), &{&1.attribute, &1.collation}) + resource |> Ash.Resource.Info.attributes() |> Enum.reject(&(&1.name in ignored)) @@ -4172,6 +4192,7 @@ defmodule AshPostgres.MigrationGenerator do |> Map.put(:size, size) |> Map.put(:type, type) |> Map.put(:source, attribute.source || attribute.name) + |> Map.put(:collation, collations[attribute.name]) |> Map.drop([:name, :constraints]) |> then(fn map -> if precision do @@ -4669,6 +4690,7 @@ defmodule AshPostgres.MigrationGenerator do |> Map.put_new(:size, nil) |> Map.put_new(:precision, nil) |> Map.put_new(:scale, nil) + |> Map.put_new(:collation, nil) |> Map.put_new(:default, "nil") |> Map.update!(:default, &(&1 || "nil")) |> Map.update!(:references, fn diff --git a/lib/migration_generator/operation.ex b/lib/migration_generator/operation.ex index a4103ece..2b154187 100644 --- a/lib/migration_generator/operation.ex +++ b/lib/migration_generator/operation.ex @@ -143,6 +143,9 @@ defmodule AshPostgres.MigrationGenerator.Operation do def maybe_add_scale(nil), do: nil def maybe_add_scale(scale), do: "scale: #{scale}" + + def maybe_add_collation(nil), do: nil + def maybe_add_collation(collation), do: "collation: #{inspect(collation)}" end defmodule CreateTable do @@ -299,6 +302,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do maybe_add_precision(attribute[:precision]), maybe_add_scale(attribute[:scale]), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), maybe_add_null(attribute.allow_nil?) ] @@ -342,6 +346,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do maybe_add_precision(attribute[:precision]), maybe_add_scale(attribute[:scale]), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), maybe_add_null(attribute.allow_nil?) ] @@ -366,6 +371,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do "add #{inspect(attribute.source)}", inspect(attribute.type), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), size, maybe_add_precision(attribute[:precision]), @@ -393,6 +399,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do "add #{inspect(attribute.source)}", inspect(attribute.type), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), size, maybe_add_precision(attribute[:precision]), @@ -438,6 +445,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do ], ")", maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), maybe_add_null(attribute.allow_nil?) ] @@ -486,6 +494,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do maybe_add_precision(attribute[:precision]), maybe_add_scale(attribute[:scale]), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), maybe_add_null(attribute.allow_nil?) ] @@ -527,6 +536,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do maybe_add_precision(attribute[:precision]), maybe_add_scale(attribute[:scale]), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), maybe_add_primary_key(attribute.primary_key?), maybe_add_null(attribute.allow_nil?) ] @@ -574,6 +584,7 @@ defmodule AshPostgres.MigrationGenerator.Operation do "#{inspect(attribute.type)}", maybe_add_null(attribute.allow_nil?), maybe_add_default(attribute.default), + maybe_add_collation(attribute[:collation]), size, maybe_add_precision(attribute[:precision]), maybe_add_scale(attribute[:scale]), @@ -699,7 +710,13 @@ defmodule AshPostgres.MigrationGenerator.Operation do end end - "#{null}#{default}#{size}#{precision}#{scale}#{primary_key}" + collation = + if Map.get(attribute, :collation) != Map.get(old_attribute, :collation) && + attribute[:collation] do + ", collation: #{inspect(attribute.collation)}" + end + + "#{null}#{default}#{size}#{precision}#{scale}#{collation}#{primary_key}" end def up(%{ diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 92f83925..8e95964d 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -396,6 +396,100 @@ defmodule AshPostgres.MigrationGeneratorTest do end end + describe "collations" do + test "a collation declared on an attribute is added to the column", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defposts do + postgres do + collations do + collation(:title, "natural_sort") + end + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + end + + defdomain([Post]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + file = + Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.at(0) + + assert File.read!(file) =~ ~S[add :title, :text, collation: "natural_sort"] + end + + test "changing a collation generates a modify with the new collation", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defposts do + postgres do + collations do + collation(:title, "natural_sort") + end + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + end + + defdomain([Post]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + defposts do + postgres do + collations do + collation(:title, "de_AT") + end + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + end + + defdomain([Post]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [_file1, file2] = + Enum.sort(Path.wildcard("#{migration_path}/**/*_migrate_resources*.exs")) + |> Enum.reject(&String.contains?(&1, "extensions")) + + assert File.read!(file2) =~ ~S[modify :title, :text, collation: "de_AT"] + end + end + describe "creating initial snapshots with native uuidv7 on PG 18" do setup %{snapshot_path: snapshot_path, migration_path: migration_path} do prev_pg_version_env = System.fetch_env("PG_VERSION") From 4fb094e160991d4fd9d77b2c478fde33bb6a7cf6 Mon Sep 17 00:00:00 2001 From: weljoda Date: Fri, 5 Jun 2026 15:40:53 +0200 Subject: [PATCH 2/4] feat: define custom collation objects via repo installed_collations/0 --- lib/custom_collation.ex | 85 ++++++++++ .../migration_generator.ex | 150 +++++++++++++++++- lib/repo.ex | 9 ++ test/migration_generator_test.exs | 52 ++++++ test/support/dev_test_repo.ex | 4 + 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 lib/custom_collation.ex diff --git a/lib/custom_collation.ex b/lib/custom_collation.ex new file mode 100644 index 00000000..a65a90df --- /dev/null +++ b/lib/custom_collation.ex @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.CustomCollation do + @moduledoc """ + Represents a custom PostgreSQL collation object created via a repo's + `c:AshPostgres.Repo.installed_collations/0` callback. + + `ash_postgres` assembles the `CREATE COLLATION` statement from its fields: + + %AshPostgres.CustomCollation{ + name: "natural_sort", + provider: :icu, + locale: "en-u-kn-true", + deterministic: true + } + + Because `installed_collations/0` is an ordinary function, structs can be built + dynamically there (e.g. branching on `min_pg_version/0`) without any extra wrapper. + """ + + @fields [ + :name, + :provider, + :locale, + :lc_collate, + :lc_ctype, + :from, + :rules, + :version + ] + + defstruct @fields ++ [deterministic: true] + + @type t :: %__MODULE__{ + name: String.t(), + provider: :icu | :libc | :builtin | nil, + locale: String.t() | nil, + lc_collate: String.t() | nil, + lc_ctype: String.t() | nil, + from: String.t() | nil, + rules: String.t() | nil, + version: String.t() | nil, + deterministic: boolean() + } + + @doc false + @spec create_sql(t()) :: String.t() + def create_sql(%__MODULE__{name: name, from: from}) when not is_nil(from) do + "CREATE COLLATION IF NOT EXISTS #{quote_ident(name)} FROM #{quote_ident(from)}" + end + + def create_sql(%__MODULE__{name: name} = collation) do + options = + [ + option("provider", collation.provider), + option("locale", collation.locale), + option("lc_collate", collation.lc_collate), + option("lc_ctype", collation.lc_ctype), + option("deterministic", collation.deterministic), + option("rules", collation.rules), + option("version", collation.version) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join(", ") + + "CREATE COLLATION IF NOT EXISTS #{quote_ident(name)} (#{options})" + end + + @doc false + @spec drop_sql(t()) :: String.t() + def drop_sql(%__MODULE__{name: name}) do + "DROP COLLATION IF EXISTS #{quote_ident(name)}" + end + + # `provider` and `deterministic` are bare keywords/booleans; everything else is a quoted literal. + defp option(_key, nil), do: nil + defp option("provider", provider), do: "provider = #{provider}" + defp option("deterministic", value) when is_boolean(value), do: "deterministic = #{value}" + defp option(key, value), do: "#{key} = #{quote_literal(value)}" + + defp quote_ident(name), do: ~s|"#{String.replace(to_string(name), "\"", "\"\"")}"| + defp quote_literal(value), do: "'#{String.replace(to_string(value), "'", "''")}'" +end diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index 667f1281..b9582454 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -74,13 +74,17 @@ defmodule AshPostgres.MigrationGenerator do extension_migration_files = create_extension_migrations(repos, opts) + collation_migration_files = + create_collation_migrations(repos, opts) + tenant_migration_files = create_migrations(tenant_snapshots, opts, true, snapshots, unmanaged_tenant_snapshots) migration_files = create_migrations(snapshots, opts, false, [], unmanaged_snapshots) - case extension_migration_files ++ tenant_migration_files ++ migration_files do + case extension_migration_files ++ + collation_migration_files ++ tenant_migration_files ++ migration_files do [] -> if !opts.check || opts.dry_run do Mix.shell().info( @@ -439,6 +443,150 @@ defmodule AshPostgres.MigrationGenerator do |> List.flatten() end + defp create_collation_migrations(repos, opts) do + for repo <- repos, repo.migrate_extensions?() do + snapshot_path = snapshot_path(opts, repo) + repo_name = repo_name(repo) + + snapshot_file = + snapshot_path + |> Path.join(repo_name) + |> Path.join("collations.json") + + installed = + if File.exists?(snapshot_file) do + snapshot_file + |> File.read!() + |> Jason.decode!(keys: :atoms!) + |> case do + list when is_list(list) -> list + %{installed: installed} -> installed + end + else + [] + end + + requested = repo.installed_collations() + + requested_names = Enum.map(requested, & &1.name) + installed_names = Enum.map(installed, & &1.name) + + to_install = Enum.reject(requested, &(&1.name in installed_names)) + + to_drop = + installed + |> Enum.reject(&(&1.name in requested_names)) + |> Enum.map(&decode_collation/1) + + if Enum.empty?(to_install) && Enum.empty?(to_drop) do + [] + else + dev = if opts.dev, do: "_dev" + + count = + opts + |> migration_path(repo) + |> Path.join("*_migrate_resources_collations*") + |> Path.wildcard() + |> length() + |> Kernel.+(1) + + base_name = + if opts.name do + "#{opts.name}_collations_#{count}" + else + require_name!(opts) + "migrate_resources_collations_#{count}" + end + + migration_name = "#{timestamp()}_#{base_name}#{dev}" + module_name = Module.concat([repo, Migrations, Macro.camelize(base_name)]) + + migration_file = + opts + |> migration_path(repo) + |> Path.join(migration_name <> ".exs") + + up = + Enum.map_join(to_install ++ to_drop, "\n", fn collation -> + sql = + if collation in to_install do + AshPostgres.CustomCollation.create_sql(collation) + else + AshPostgres.CustomCollation.drop_sql(collation) + end + + "execute(#{inspect(sql)})" + end) + + down = + Enum.map_join(to_install ++ to_drop, "\n", fn collation -> + sql = + if collation in to_install do + AshPostgres.CustomCollation.drop_sql(collation) + else + AshPostgres.CustomCollation.create_sql(collation) + end + + "# execute(#{inspect(sql)})" + end) + + contents = """ + defmodule #{inspect(module_name)} do + @moduledoc \"\"\" + Creates any collations that are mentioned in the repo's `installed_collations/0` callback + + This file was autogenerated with `mix ash_postgres.generate_migrations` + \"\"\" + + use Ecto.Migration + + def up do + #{up} + end + + def down do + # Uncomment this if you actually want to reverse the collation changes + # when this migration is rolled back: + #{down} + end + end + """ + + snapshot_contents = + requested + |> Enum.sort_by(& &1.name) + |> Enum.map(&encode_collation/1) + |> Jason.encode!(pretty: true) + + contents = format(migration_file, contents, opts) + + [ + {snapshot_file, snapshot_contents}, + {migration_file, contents} + ] + end + end + |> List.flatten() + end + + defp encode_collation(%AshPostgres.CustomCollation{} = collation) do + collation + |> Map.from_struct() + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp decode_collation(map) do + provider = + case map[:provider] do + provider when is_binary(provider) -> String.to_atom(provider) + provider -> provider + end + + struct(AshPostgres.CustomCollation, Map.put(map, :provider, provider)) + end + defp set_ash_functions(snapshot, installed_extensions) do if "ash-functions" in installed_extensions do Map.put( diff --git a/lib/repo.ex b/lib/repo.ex index dd297e47..286a529a 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -57,6 +57,13 @@ defmodule AshPostgres.Repo do @doc "Use this to inform the data layer about what extensions are installed" @callback installed_extensions() :: [String.t() | module()] + @doc """ + Use this to inform the data layer about what custom collation objects should be created. + + See `AshPostgres.CustomCollation` for the available options. + """ + @callback installed_collations() :: [AshPostgres.CustomCollation.t()] + @doc "Configure the version of postgres that is being used." @callback min_pg_version() :: Version.t() @@ -152,6 +159,7 @@ defmodule AshPostgres.Repo do end def installed_extensions, do: [] + def installed_collations, do: [] def tenant_migrations_path, do: nil def migrate_extensions?, do: true def migrations_path, do: nil @@ -327,6 +335,7 @@ defmodule AshPostgres.Repo do defoverridable init: 2, on_transaction_begin: 1, installed_extensions: 0, + installed_collations: 0, migrate_extensions?: 0, all_tenants: 0, default_constraint_match_type: 2, diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 8e95964d..5247e87b 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -488,6 +488,58 @@ defmodule AshPostgres.MigrationGeneratorTest do assert File.read!(file2) =~ ~S[modify :title, :text, collation: "de_AT"] end + + test "a collation defined via installed_collations generates a create collation migration", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + Application.put_env(:ash_postgres, :installed_collations, [ + %AshPostgres.CustomCollation{ + name: "natural_sort", + provider: :icu, + locale: "en-u-kn-true" + } + ]) + + on_exit(fn -> Application.delete_env(:ash_postgres, :installed_collations) end) + + defposts do + attributes do + uuid_primary_key(:id) + end + end + + defdomain([Post]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + file = + Path.wildcard("#{migration_path}/**/*_collations*.exs") + |> Enum.at(0) + + assert file, "expected a collations migration to be generated" + + contents = File.read!(file) + assert contents =~ "CREATE COLLATION IF NOT EXISTS" + assert contents =~ "natural_sort" + assert contents =~ "provider = icu" + assert contents =~ "locale = 'en-u-kn-true'" + assert contents =~ "deterministic = true" + + # the object definition is recorded in its own snapshot for future diffing + snapshot_file = + Path.wildcard("#{snapshot_path}/**/collations.json") + |> Enum.at(0) + + assert snapshot_file + assert File.read!(snapshot_file) =~ "natural_sort" + end end describe "creating initial snapshots with native uuidv7 on PG 18" do diff --git a/test/support/dev_test_repo.ex b/test/support/dev_test_repo.ex index 04d39046..99befcf2 100644 --- a/test/support/dev_test_repo.ex +++ b/test/support/dev_test_repo.ex @@ -20,6 +20,10 @@ defmodule AshPostgres.DevTestRepo do Application.get_env(:ash_postgres, :no_extensions, []) end + def installed_collations do + Application.get_env(:ash_postgres, :installed_collations, []) + end + def min_pg_version do case System.get_env("PG_VERSION") do nil -> From 5d1d13736169dbf986a16a28294462517c23f20b Mon Sep 17 00:00:00 2001 From: weljoda Date: Sat, 6 Jun 2026 10:33:07 +0200 Subject: [PATCH 3/4] feat: verify collations reference existing string attributes --- lib/data_layer.ex | 3 +- lib/verifiers/validate_collations.ex | 60 ++++++++++++++++++++++++++++ test/migration_generator_test.exs | 50 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 lib/verifiers/validate_collations.ex diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 8e79bf86..09d21c2b 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -462,7 +462,8 @@ defmodule AshPostgres.DataLayer do AshPostgres.Verifiers.ValidateCheckConstraints, AshPostgres.Verifiers.PreventAttributeMultitenancyAndNonFullMatchType, AshPostgres.Verifiers.EnsureTableOrPolymorphic, - AshPostgres.Verifiers.ValidateIdentityIndexNames + AshPostgres.Verifiers.ValidateIdentityIndexNames, + AshPostgres.Verifiers.ValidateCollations ] def migrate(args) do diff --git a/lib/verifiers/validate_collations.ex b/lib/verifiers/validate_collations.ex new file mode 100644 index 00000000..fa0f482d --- /dev/null +++ b/lib/verifiers/validate_collations.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshPostgres.Verifiers.ValidateCollations do + @moduledoc false + use Spark.Dsl.Verifier + alias Spark.Dsl.Verifier + + # Ash storage types that map to collatable (text-based) PostgreSQL columns. + @collatable_storage_types [:string, :ci_string, :citext, :text] + + def verify(dsl) do + resource = Verifier.get_persisted(dsl, :module) + + dsl + |> AshPostgres.DataLayer.Info.collations() + |> Enum.each(fn collation -> + attribute = Ash.Resource.Info.attribute(dsl, collation.attribute) + + cond do + is_nil(attribute) -> + raise Spark.Error.DslError, + path: [:postgres, :collations, collation.attribute], + module: resource, + message: """ + Collation `#{collation.collation}` references attribute `#{collation.attribute}`, but no such attribute exists on resource `#{inspect(resource)}`. + + Available attributes: #{dsl |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name) |> inspect()} + """, + location: Spark.Dsl.Transformer.get_section_anno(dsl, [:postgres, :collations]) + + not collatable?(attribute) -> + raise Spark.Error.DslError, + path: [:postgres, :collations, collation.attribute], + module: resource, + message: """ + Collation `#{collation.collation}` is configured for attribute `#{collation.attribute}` of type `#{inspect(attribute.type)}`, but collations can only be applied to string-based (text) columns. + """, + location: Spark.Dsl.Transformer.get_section_anno(dsl, [:postgres, :collations]) + + true -> + :ok + end + end) + + :ok + end + + defp collatable?(attribute) do + storage_type = + try do + Ash.Type.storage_type(attribute.type, attribute.constraints || []) + rescue + _ -> nil + end + + storage_type in @collatable_storage_types + end +end diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 5247e87b..5b510eb4 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -8,6 +8,7 @@ defmodule AshPostgres.MigrationGeneratorTest do @moduletag :tmp_dir import ExUnit.CaptureLog + import Spark.Test setup %{tmp_dir: tmp_dir} do current_shell = Mix.shell() @@ -540,6 +541,55 @@ defmodule AshPostgres.MigrationGeneratorTest do assert snapshot_file assert File.read!(snapshot_file) =~ "natural_sort" end + + test "a collation on a non-existent attribute raises a DslError" do + err = + assert_dsl_error do + defmodule Elixir.AshPostgres.CollationMissingAttr do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + postgres do + table("collation_missing_attr") + repo(AshPostgres.TestRepo) + + collations do + collation(:does_not_exist, "natural_sort") + end + end + + attributes do + uuid_primary_key(:id) + end + end + end + + assert Exception.message(err) =~ "no such attribute exists" + end + + test "a collation on a non-string attribute raises a DslError" do + err = + assert_dsl_error do + defmodule Elixir.AshPostgres.CollationBadType do + use Ash.Resource, domain: nil, data_layer: AshPostgres.DataLayer + + postgres do + table("collation_bad_type") + repo(AshPostgres.TestRepo) + + collations do + collation(:count, "natural_sort") + end + end + + attributes do + uuid_primary_key(:id) + attribute(:count, :integer, public?: true) + end + end + end + + assert Exception.message(err) =~ "string-based (text) columns" + end end describe "creating initial snapshots with native uuidv7 on PG 18" do From 4d31e3f79971070fcb6f08f089f078ddfe0bd0ad Mon Sep 17 00:00:00 2001 From: weljoda Date: Sat, 6 Jun 2026 11:35:45 +0200 Subject: [PATCH 4/4] docs: add collations guide and add drop-collation migration test --- documentation/topics/resources/collations.md | 93 ++++++++++++++++++++ mix.exs | 1 + test/migration_generator_test.exs | 49 +++++++++++ 3 files changed, 143 insertions(+) create mode 100644 documentation/topics/resources/collations.md diff --git a/documentation/topics/resources/collations.md b/documentation/topics/resources/collations.md new file mode 100644 index 00000000..81dbd68a --- /dev/null +++ b/documentation/topics/resources/collations.md @@ -0,0 +1,93 @@ + + +# Collations + +A collation determines how text values in a column are sorted and compared. By declaring collations in your resources, both the *use* of a collation on a column and the *definition* of any custom collation object are represented in code and generated by `mix ash.codegen` — instead of living in hand-written custom migrations. + +The motivating example is "natural sort": ordering `item2` before `item10` instead of the default lexicographic `item10` before `item2`. PostgreSQL can do this with an ICU collation that enables numeric ordering (`en-u-kn-true`). + +There are two distinct concerns, and they live in two different places: + +- **Applying a collation to a column** — per attribute, in the `postgres` block of a resource. +- **Defining a custom collation object** (`CREATE COLLATION ...`) — a database-global object, declared once at the repo level (mirroring `installed_extensions/0`). + +## Applying a collation to a column + +Use the `collations` section inside the `postgres` block to set the collation for an attribute: + +```elixir +postgres do + table "documents" + repo MyApp.Repo + + collations do + collation :name, "natural_sort" + end +end +``` + +This sets the collation as the column's default, so ordinary sorts — including Ash sorts like `sort: [name: :asc]` — use it automatically, with no special query syntax required. + +The collation name can be any collation that exists in the database. That includes: + +- **Built-in collations** such as `"de_AT"` or `"und-x-icu"`, which need no further setup. +- **Custom collation objects** that you define via the repo's `installed_collations/0` callback. + +> ### Why a column collation rather than an index? {: .info} +> +> A column collation becomes the column default, so `ORDER BY name` — and thus a plain `sort: [name: :asc]` — uses it automatically with no extra query syntax. A collated index like `CREATE INDEX ... (name COLLATE "natural_sort")` is only used when the query writes the collation explicitly, which a plain Ash sort does not do. +> +> You *can* opt into collated ordering per query with an expression calculation that applies the collation in a `fragment`, and a matching expression index will back it: +> +> ```elixir +> require Ash.Expr +> +> Ash.Query.sort(query, [ +> {Ash.Expr.calc(fragment("? COLLATE \"natural_sort\"", name), type: :string), :asc} +> ]) +> ``` +> +> Reach for that when you want the collation only on specific queries. Use a column collation when you want *every* ordinary sort and comparison on the column to be natural by default. + +All supported DSL options can be found in the [DSL documentation](https://hexdocs.pm/ash_postgres/dsl-ashpostgres-datalayer.html#postgres-collations). + +## Defining a custom collation object + +Built-in collations work out of the box. The `natural_sort` example, however, needs a custom object: `'en-u-kn-true'` carries a BCP-47 tailoring (`kn` = numeric ordering) for which PostgreSQL ships no predefined collation, and `COLLATE` only accepts an existing collation name. A one-time `CREATE COLLATION` is therefore unavoidable. + +Define such objects in your repo's `installed_collations/0` callback, mirroring `installed_extensions/0`. Each entry is an `AshPostgres.CustomCollation` struct: + +```elixir +defmodule MyApp.Repo do + use AshPostgres.Repo, otp_app: :my_app + + def installed_collations do + [ + %AshPostgres.CustomCollation{ + name: "natural_sort", + provider: :icu, + locale: "en-u-kn-true", + deterministic: true + } + ] + end +end +``` + +`ash_postgres` assembles the `CREATE COLLATION` statement from the struct's fields. The available fields are `:name`, `:provider`, `:locale`, `:lc_collate`, `:lc_ctype`, `:from`, `:rules`, `:version`, and `:deterministic` (defaults to `true`). Use `:from` to copy an existing collation, or `:provider`/`:locale` to define a new one. Because `installed_collations/0` is an ordinary function, you can build the list dynamically (for example, branching on `min_pg_version/0`). + +> ### `deterministic` and uniqueness {: .info} +> +> Numeric ordering affects sort and comparison only, not equality, so a collation like `natural_sort` can stay `deterministic: true` without changing `=` or uniqueness semantics. + +## Migrations + +When you run `mix ash.codegen`, the collation objects are diffed against a `collations.json` snapshot and emitted into a standalone migration that runs **after** extensions and **before** the table migrations that reference them. As with extensions, the generated `down` is commented out by default — uncomment it if you actually want a rollback to drop the collation object. + +Collation migrations are gated on the same `migrate_extensions?/0` flag as extensions; no separate flag is required. + +Multitenancy needs no special handling: the collation object is created once in the global migration (like extensions) and referenced unqualified by columns, while the column-level collation is plain, tenant-agnostic column DDL. diff --git a/mix.exs b/mix.exs index 2daeface..5632ea1f 100644 --- a/mix.exs +++ b/mix.exs @@ -108,6 +108,7 @@ defmodule AshPostgres.MixProject do "documentation/tutorials/set-up-with-existing-database.md", "documentation/topics/about-ash-postgres/what-is-ash-postgres.md", "documentation/topics/resources/references.md", + "documentation/topics/resources/collations.md", "documentation/topics/resources/polymorphic-resources.md", "documentation/topics/resources/working-with-existing-databases.md", "documentation/topics/development/migrations-and-tasks.md", diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 5b510eb4..59b5ad05 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -542,6 +542,55 @@ defmodule AshPostgres.MigrationGeneratorTest do assert File.read!(snapshot_file) =~ "natural_sort" end + test "removing a collation from installed_collations generates a drop collation migration", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + Application.put_env(:ash_postgres, :installed_collations, [ + %AshPostgres.CustomCollation{ + name: "natural_sort", + provider: :icu, + locale: "en-u-kn-true" + } + ]) + + on_exit(fn -> Application.delete_env(:ash_postgres, :installed_collations) end) + + defposts do + attributes do + uuid_primary_key(:id) + end + end + + defdomain([Post]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + # drop the object from the repo's installed_collations and regenerate + Application.put_env(:ash_postgres, :installed_collations, []) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true + ) + + assert [_create_file, drop_file] = + Enum.sort(Path.wildcard("#{migration_path}/**/*_collations*.exs")) + + contents = File.read!(drop_file) + assert contents =~ "DROP COLLATION IF EXISTS" + assert contents =~ "natural_sort" + end + test "a collation on a non-existent attribute raises a DslError" do err = assert_dsl_error do