Skip to content
Closed
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
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions documentation/dsls/DSL-AshPostgres.DataLayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Postgres data layer configuration
* reference
* [check_constraints](#postgres-check_constraints)
* check_constraint
* [collations](#postgres-collations)
* collation


### Examples
Expand Down Expand Up @@ -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`





Expand Down
93 changes: 93 additions & 0 deletions documentation/topics/resources/collations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<!--
SPDX-FileCopyrightText: 2026 ash_postgres contributors

SPDX-License-Identifier: MIT
-->

# 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.
31 changes: 31 additions & 0 deletions lib/collation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/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
85 changes: 85 additions & 0 deletions lib/custom_collation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/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
41 changes: 39 additions & 2 deletions lib/data_layer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: """
Expand Down Expand Up @@ -266,7 +301,8 @@ defmodule AshPostgres.DataLayer do
@custom_statements,
@manage_tenant,
@references,
@check_constraints
@check_constraints,
@collations
],
modules: [
:repo
Expand Down Expand Up @@ -426,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
Expand Down
5 changes: 5 additions & 0 deletions lib/data_layer/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, [])
Expand Down
Loading
Loading