Packages
localize_ecto
0.1.0
Locale-aware PostgreSQL ICU collation for Ecto queries: COLLATE query expressions resolved from Localize language tags, and migration helpers for creating collations.
Current section
Files
Jump to
Current section
Files
lib/localize/ecto/collation.ex
defmodule Localize.Ecto.Collation do
@moduledoc """
Resolves a locale to the name of a PostgreSQL ICU collation.
PostgreSQL's `initdb` imports the full set of ICU collations into
`pg_catalog`, naming each one after its BCP 47 locale identifier with
an `-x-icu` suffix — for example `de-DE-x-icu`, `zh-Hant-TW-x-icu`
and the root collation `und-x-icu`.
This module maps a `t:Localize.LanguageTag.t/0` (or any locale
identifier accepted by `Localize.validate_locale/1`) onto the best
matching collation using the [CLDR Language Matching](https://www.unicode.org/reports/tr35/tr35.html#LanguageMatching)
algorithm implemented by `Localize.LanguageTag.best_match/3`. The
requested locale is matched against the set of collation locales
PostgreSQL derives from ICU, bundled with this library, and the root
collation `und-x-icu` is the fallback of last resort.
Because CLDR collation tailorings are defined per language (and
script) rather than per territory, a match to a broader locale — for
example `de-DE` matching the `de` collation locale — selects an
identical collator.
The primary public API is `collation_for/2` and `collation_for!/2`.
"""
alias Localize.LanguageTag
@external_resource Path.join(__DIR__, "../../../priv/localize_ecto/pg_icu_collations.txt")
@known_collations @external_resource
|> File.read!()
|> String.split("\n", trim: true)
@doc """
Returns the PostgreSQL ICU collation name for the given locale.
Resolutions against the default collation locales are cached, so
repeated calls for the same locale are inexpensive.
### Arguments
* `locale` is a `t:Localize.LanguageTag.t/0`, or any locale identifier
accepted by `Localize.validate_locale/1`. The default is
`Localize.get_locale/0`.
* `options` is a keyword list of options.
### Options
* `:available` is a list of BCP 47 locale strings (without the
`-x-icu` suffix) to match against. The default is the list bundled
with this library, generated from PostgreSQL's `pg_collation`
catalog.
### Returns
* `{:ok, collation_name}` where `collation_name` is a string such as
`"de-x-icu"`, or
* `{:error, exception}` if `locale` is not a valid locale.
### Examples
iex> Localize.Ecto.Collation.collation_for("de-DE")
{:ok, "de-x-icu"}
iex> Localize.Ecto.Collation.collation_for("zh-TW")
{:ok, "zh-Hant-x-icu"}
iex> Localize.Ecto.Collation.collation_for("xx")
{:error, %Localize.InvalidLocaleError{locale_id: "xx"}}
"""
@spec collation_for(Localize.locale() | String.t(), Keyword.t()) ::
{:ok, String.t()} | {:error, Exception.t()}
def collation_for(locale \\ Localize.get_locale(), options \\ []) do
with {:ok, language_tag} <- Localize.validate_locale(locale) do
case Keyword.fetch(options, :available) do
{:ok, available} -> {:ok, match_collation(language_tag, available)}
:error -> {:ok, cached_collation(language_tag)}
end
end
end
@doc """
Returns the PostgreSQL ICU collation name for the given locale or
raises.
### Arguments
* `locale` is a `t:Localize.LanguageTag.t/0`, or any locale identifier
accepted by `Localize.validate_locale/1`. The default is
`Localize.get_locale/0`.
* `options` is a keyword list of options. See `collation_for/2`.
### Returns
* A collation name string such as `"de-x-icu"`, or
* raises if `locale` is not a valid locale.
### Examples
iex> Localize.Ecto.Collation.collation_for!("sv")
"sv-x-icu"
iex> Localize.Ecto.Collation.collation_for!(:"en-GB")
"en-GB-x-icu"
"""
@spec collation_for!(Localize.locale() | String.t(), Keyword.t()) :: String.t()
def collation_for!(locale \\ Localize.get_locale(), options \\ []) do
case collation_for(locale, options) do
{:ok, collation} -> collation
{:error, exception} -> raise exception
end
end
@doc """
Resolves the argument of `Localize.Ecto.collate/2` to a collation
name.
This is the runtime companion of the `Localize.Ecto.collate/1,2`
macros and is not usually called directly.
### Arguments
* `locale_or_options` is a locale accepted by `collation_for!/2`, or
a keyword list.
### Options
* `:collation` is a collation name used verbatim, bypassing locale
resolution — for example a collation created with
`Localize.Ecto.Migration.create_collation/2` under a custom name.
* Any other options are passed to `collation_for!/2`.
### Returns
* A collation name string, or
* raises if the locale is not valid.
### Examples
iex> Localize.Ecto.Collation.resolve!("sv")
"sv-x-icu"
iex> Localize.Ecto.Collation.resolve!(collation: "german_phonebook")
"german_phonebook"
"""
@spec resolve!(Localize.locale() | String.t() | Keyword.t()) :: String.t()
def resolve!(locale_or_options \\ Localize.get_locale())
def resolve!(options) when is_list(options) do
case Keyword.pop(options, :collation) do
{nil, options} -> collation_for!(Localize.get_locale(), options)
{collation, _options} -> collation
end
end
def resolve!(locale) do
collation_for!(locale)
end
@doc """
Returns the list of locales for which a PostgreSQL ICU collation is
known to exist.
The list contains BCP 47 locale strings without the `-x-icu` suffix,
as recorded in the `colllocale` column of `pg_collation`.
### Returns
* A list of locale strings.
### Examples
iex> "de-DE" in Localize.Ecto.Collation.known_collations()
true
iex> "und" in Localize.Ecto.Collation.known_collations()
true
"""
@spec known_collations() :: [String.t()]
def known_collations do
@known_collations
end
# Matching against the full collation locale list takes a few
# milliseconds, which is significant on the query-build path, so
# resolutions against the default list are cached. The set of distinct
# locales an application resolves is bounded, and entries are written
# once, which suits :persistent_term.
defp cached_collation(%LanguageTag{canonical_locale_id: canonical_locale_id} = language_tag) do
key = {__MODULE__, canonical_locale_id}
case :persistent_term.get(key, nil) do
nil ->
collation = match_collation(language_tag, @known_collations)
:persistent_term.put(key, collation)
collation
collation ->
collation
end
end
# Matching is on canonical_locale_id rather than the language tag
# struct: the struct fields are likely-subtag maximized, which would
# turn a requested "und" into its maximization "en" and lose the
# root-collation request.
defp match_collation(%LanguageTag{} = language_tag, available) do
base =
case LanguageTag.best_match(language_tag.canonical_locale_id, available) do
{:ok, locale, _distance} -> locale
{:error, _} -> "und"
end
case collation_type(language_tag) do
nil -> base <> "-x-icu"
collation_type -> base <> "-u-co-" <> collation_type <> "-x-icu"
end
end
# The BCP 47 -u-co- collation type of the locale, in its short form
# (:phonebook encodes as "phonebk"), or nil when absent or the
# default. PostgreSQL does not preload keyword-tailored collations, so
# names carrying a collation type must be created in a migration with
# Localize.Ecto.Migration.create_collation/2.
defp collation_type(%LanguageTag{locale: %Localize.LanguageTag.U{co: co} = u_extension})
when co not in [nil, :standard] do
case List.keyfind(Localize.LanguageTag.U.encode(u_extension), "co", 0) do
{"co", collation_type} -> collation_type
nil -> nil
end
end
defp collation_type(%LanguageTag{}) do
nil
end
end