Skip to content

Commit 6e163fe

Browse files
committed
Add Migration.collated/2 for building collated indexes with index/3
1 parent b90c3b0 commit 6e163fe

6 files changed

Lines changed: 121 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ Initial release.
1414

1515
* Locale-to-collation resolution via CLDR language matching, including BCP 47 `-u-co-` collation types and direct collation names.
1616

17-
* `Localize.Ecto.Migration.create_collation/2` and `drop_collation/2` for creating ICU collations reversibly in migrations.
17+
* `Localize.Ecto.Migration.create_collation/2` and `drop_collation/2` for creating ICU collations reversibly in migrations, and `collated/2` for building collated indexes with `Ecto.Migration.index/3`.
1818

1919
See the [README](https://hexdocs.pm/localize_ecto/readme.html) for full documentation.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ If your server's set differs materially from the snapshot, pass your own list wi
6060

6161
## Performance considerations
6262

63-
Linguistic comparison is more expensive than PostgreSQL's default byte-order comparison, and an `ORDER BY ... COLLATE` clause can only use an index that was created with the same collation. For hot queries, create an expression index with the collation you sort by:
63+
Linguistic comparison is more expensive than PostgreSQL's default byte-order comparison, and an `ORDER BY ... COLLATE` clause can only use an index that was created with the same collation. For hot queries, create an index with the collation you sort by using [Localize.Ecto.Migration.collated/2](https://hexdocs.pm/localize_ecto/Localize.Ecto.Migration.html#collated/2):
6464

65-
```sql
66-
CREATE INDEX products_name_de ON products (name COLLATE "de-x-icu");
65+
```elixir
66+
create index("products", [collated(:name, "de")])
6767
```
6868

69-
Nondeterministic collations carry an additional performance penalty. See the [PostgreSQL collation documentation](https://www.postgresql.org/docs/current/collation.html) for the details of collation selection, index compatibility, and the trade-offs between providers.
69+
If a PostgreSQL upgrade links a newer ICU library whose collation data changed — uncommon, but it happens — PostgreSQL warns of a collation version mismatch and indexes built with that collation must be reindexed. Nondeterministic collations carry an additional performance penalty. See the [PostgreSQL collation documentation](https://www.postgresql.org/docs/current/collation.html) for the details of collation selection, index compatibility, and the trade-offs between providers.
7070

7171
## Guides
7272

guides/collations_in_postgres.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,21 @@ Collation is the set of rules that determines how text sorts and compares. This
66

77
Every PostgreSQL database has a default collation, fixed at `CREATE DATABASE` time, that applies to every text comparison and sort that does not name a collation explicitly. It also determines the behavior of case conversion (`upper`, `lower`, `ILIKE`) through its character-classification (ctype) side.
88

9-
Our recommendation is to keep the database default simple and stable, and to apply linguistic collation explicitly in queries with `COLLATE` — which is exactly what this library does. Two defaults fit that recommendation:
9+
Our recommendation: make the database default the builtin `C.UTF-8` locale (PostgreSQL 17 and later), falling back to the `C` locale on older PostgreSQL releases, and apply linguistic collation explicitly in queries with `COLLATE` — which is exactly what this library does.
1010

11-
* The `C` locale. Sorting is plain byte order: fast, immutable across operating system and library upgrades, and free of surprises in indexes. The trade-off is that its ctype is ASCII-only, so `upper('öl')` returns `öL` — the `ö` is untouched. If you use case conversion on non-ASCII text, apply a collation to the expression (`upper('öl' COLLATE "de-x-icu")` returns `ÖL`) or prefer the next option.
11+
* `C.UTF-8` (provider `builtin`, PostgreSQL 17+) sorts in Unicode code-point order — fast and permanently stable — while its ctype is full Unicode, so case conversion (`upper`, `lower`, `ILIKE`) works for all scripts without naming a collation.
1212

13-
* The builtin `C.UTF-8` locale (PostgreSQL 17 and later, provider `builtin`). Sorting is Unicode code-point orderequally fast and stable — while ctype is full Unicode, so case conversion works for all scripts without an explicit collation.
13+
* `C` is the fallback for PostgreSQL 16 and earlier. Sorting is plain byte order, equally fast and stable, but its ctype is ASCII-only: `upper('öl')` returns `öL` with the `ö` untouched. Where you need case conversion on non-ASCII text, apply a collation to the expression — `upper('öl' COLLATE "de-x-icu")` returns `ÖL`.
1414

15-
A word on why stability matters: a database default drawn from an operating system locale (the libc provider, for example `en_US.UTF-8`) changes behavior when the operating system's locale data changes. Because indexes are built in collation order, a changed sort order can silently corrupt index correctness after an OS upgrade. Byte order and code-point order never change.
15+
The reason for this recommendation is stability. A database default is fixed at `CREATE DATABASE` time, every index on text is built in its sort order, and a sort order that changes underneath an existing index silently corrupts the index's correctness. Each external collation provider carries exactly that risk:
16+
17+
* Operating system releases. A libc default such as `en_US.UTF-8` sorts according to the OS locale data, which changes with OS upgrades — the classic cause of index corruption after a glibc update.
18+
19+
* ICU releases. An ICU default would tie the database's sort order to the ICU library version, which changes as CLDR data evolves; PostgreSQL records collation versions and warns of mismatches, but the remedy is still reindexing the affected database.
20+
21+
* PostgreSQL releases. Byte order and code-point order are defined by Unicode itself, not by any library's tailoring data, so a `C` or `C.UTF-8` default behaves identically across PostgreSQL upgrades and never demands a reindex.
22+
23+
Scoping linguistic collation to query expressions confines the versioned, changeable part of collation to the places that opt into it — and any index created with an explicit ICU collation (see `Localize.Ecto.Migration.collated/2`) is a known, listed object that can be reindexed deliberately when the ICU version moves.
1624

1725
Neither `C` nor `C.UTF-8` sorts linguistically — `Zebra` sorts before `apple` because `Z` has a smaller code point than `a`. That is the point of the division of labor: the default collation keeps storage and indexes fast and stable, and queries opt into linguistic ordering per expression:
1826

guides/using_localize_ecto.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,16 @@ Localize.Ecto.Collation.collation_for!("de-AT", available: locales)
133133

134134
## Indexes
135135

136-
An `ORDER BY ... COLLATE` clause only uses an index created with the same collation. If a collated sort is on a hot path, add a matching expression index in a migration:
136+
An `ORDER BY ... COLLATE` clause only uses an index created with the same collation. If a collated sort is on a hot path, add a matching index in a migration with `Localize.Ecto.Migration.collated/2`, which builds a collated column expression for `Ecto.Migration.index/3`:
137137

138138
```elixir
139-
execute(
140-
~s[CREATE INDEX products_name_de ON products (name COLLATE "de-x-icu")],
141-
~s[DROP INDEX products_name_de]
142-
)
139+
import Localize.Ecto.Migration
140+
141+
create index("products", [collated(:name, "de")], name: :products_name_de)
142+
143+
create index("products", [collated(:name, collation: "german_phonebook")], name: :products_name_phonebook)
143144
```
145+
146+
With such an index in place, `order_by: collate(p.name, "de")` is satisfied directly by an index scan with no sort step. All of `index/3`'s options compose as usual — `unique: true`, `concurrently: true`, `where:` and so on. Passing an explicit `:name` is recommended, since Ecto derives index names poorly from expression columns.
147+
148+
One maintenance note: ICU collation data occasionally changes between ICU releases. If a PostgreSQL upgrade links a newer ICU, PostgreSQL warns of a collation version mismatch, and indexes built with that collation should be rebuilt — `REINDEX INDEX products_name_de` followed by `ALTER COLLATION "de-x-icu" REFRESH VERSION` clears the warning. This is uncommon, and it is the trade-off accepted by scoping linguistic collation to explicit expressions rather than the database default.

lib/localize/ecto/migration.ex

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,63 @@ if Code.ensure_loaded?(Ecto.Migration) do
190190
"DROP COLLATION #{if_exists}#{quote_name(name, options[:schema])}"
191191
end
192192

193+
@doc """
194+
Returns a collated column expression for use in an index definition.
195+
196+
PostgreSQL only uses an index for a collated sort or comparison when
197+
the index was created with the same collation. This function builds
198+
the column expression for such an index, for use with
199+
`Ecto.Migration.index/3`:
200+
201+
create index("products", [collated(:name, "de")])
202+
203+
create index("products", [collated(:name, collation: "german_phonebook")])
204+
205+
Note that if a PostgreSQL upgrade links a newer ICU library whose
206+
collation data changed — uncommon, but it happens — PostgreSQL warns
207+
of a collation version mismatch and indexes built with that
208+
collation must be reindexed (`REINDEX INDEX index_name`, then
209+
`ALTER COLLATION collation_name REFRESH VERSION`).
210+
211+
### Arguments
212+
213+
* `column` is the column name as an atom or string.
214+
215+
* `locale_or_options` is a locale accepted by
216+
`Localize.Ecto.Collation.collation_for!/2`, or a keyword list with
217+
a `:collation` option naming a collation directly. The default is
218+
the current locale from `Localize.get_locale/0`.
219+
220+
### Returns
221+
222+
* A column expression string such as `"name" COLLATE "de-x-icu"`.
223+
224+
### Examples
225+
226+
iex> Localize.Ecto.Migration.collated(:name, "de")
227+
~s["name" COLLATE "de-x-icu"]
228+
229+
iex> Localize.Ecto.Migration.collated(:name, collation: "german_phonebook")
230+
~s["name" COLLATE "german_phonebook"]
231+
232+
"""
233+
@spec collated(atom() | String.t(), Localize.locale() | String.t() | Keyword.t()) ::
234+
String.t()
235+
def collated(column, locale_or_options \\ Localize.get_locale()) do
236+
column = to_string(column)
237+
collation = Collation.resolve!(locale_or_options)
238+
239+
if String.contains?(column, ~s(")) do
240+
raise ArgumentError, "column name #{inspect(column)} contains a double quote"
241+
end
242+
243+
if String.contains?(collation, ~s(")) do
244+
raise ArgumentError, "collation name #{inspect(collation)} contains a double quote"
245+
end
246+
247+
~s("#{column}" COLLATE "#{collation}")
248+
end
249+
193250
defp collation_sql_pair(locale, options) do
194251
{create_collation_sql(locale, options), drop_collation_sql(locale, options)}
195252
end

test/localize/ecto/migration_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,43 @@ defmodule Localize.Ecto.MigrationTest do
5959
end
6060
end
6161

62+
describe "collated/2" do
63+
test "builds a collated column expression from a locale" do
64+
assert Migration.collated(:name, "de") == ~s["name" COLLATE "de-x-icu"]
65+
66+
assert Migration.collated("name", "de-u-co-phonebk") ==
67+
~s["name" COLLATE "de-u-co-phonebk-x-icu"]
68+
end
69+
70+
test "builds a collated column expression from a collation name" do
71+
assert Migration.collated(:name, collation: "german_phonebook") ==
72+
~s["name" COLLATE "german_phonebook"]
73+
end
74+
75+
test "defaults to the current locale" do
76+
{:ok, _} = Localize.put_locale("sv")
77+
assert Migration.collated(:name) == ~s["name" COLLATE "sv-x-icu"]
78+
after
79+
Localize.put_locale("en")
80+
end
81+
82+
test "raises for an invalid locale" do
83+
assert_raise Localize.InvalidLocaleError, fn ->
84+
Migration.collated(:name, "zzzz")
85+
end
86+
end
87+
88+
test "rejects identifiers containing double quotes" do
89+
assert_raise ArgumentError, fn ->
90+
Migration.collated(~s(bad"name), "de")
91+
end
92+
93+
assert_raise ArgumentError, fn ->
94+
Migration.collated(:name, collation: ~s(bad"collation))
95+
end
96+
end
97+
end
98+
6299
describe "drop_collation_sql/2" do
63100
test "drops by the resolver-symmetric name" do
64101
assert Migration.drop_collation_sql("de-u-co-phonebk") ==

0 commit comments

Comments
 (0)