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.ex
defmodule Localize.Ecto do
@moduledoc """
Locale-aware query expressions for Ecto on PostgreSQL.
The primary public API is the `collate/1` and `collate/2` macros,
which apply a PostgreSQL `COLLATE` clause to a query expression — or
to a comparison between two expressions — using the ICU collation
that best matches a locale. The collation name is resolved by
`Localize.Ecto.Collation.resolve!/1` and emitted as a quoted
identifier via Ecto's `literal/1` fragment, so it is never
interpolated into the SQL as text.
Import this module (or `import Localize.Ecto, only: [collate: 1, collate: 2]`)
alongside `Ecto.Query` and use `collate/1,2` anywhere a query
expression is accepted — `order_by`, `select`, `where`, `distinct`
and so on:
import Ecto.Query
import Localize.Ecto
# Collate using the current locale (Localize.get_locale/0)
from p in Product, order_by: collate(p.name)
# Collate using an explicit locale
from p in Product, order_by: collate(p.name, "sv")
# Locale determined at runtime
def sorted_products(locale) do
from p in Product, order_by: collate(p.name, ^locale)
end
# Collate a comparison: name < 'münchen' under German collation
from p in Product, where: collate(p.name < "münchen", "de"), select: p.name
# Use a collation created in a migration, by name
from p in Product, order_by: collate(p.name, collation: "german_phonebook")
Collations for locales carrying a BCP 47 collation type, such as
`de-u-co-phonebk`, are not preloaded by PostgreSQL. Create them once
in a migration with `Localize.Ecto.Migration.create_collation/2` and
they resolve automatically thereafter.
"""
@comparison_operators [:<, :>, :<=, :>=, :==, :!=]
@doc """
Applies a `COLLATE` clause for the current locale.
The collation is resolved from `Localize.get_locale/0` at the time
the query is built.
### Arguments
* `expression` is any Ecto query expression that evaluates to a
string value, or a comparison (`<`, `<=`, `>`, `>=`, `==`, `!=`)
between two such expressions.
### Returns
* A query fragment `expression COLLATE "collation"`, or for a
comparison `left OP right COLLATE "collation"`.
### Examples
iex> import Ecto.Query
iex> query = from p in "products", order_by: collate(p.name), select: p.name
iex> match?(%Ecto.Query{}, query)
true
"""
defmacro collate(expression) do
build_collate(expression, quote(do: Localize.Ecto.Collation.resolve!()))
end
@doc """
Applies a `COLLATE` clause for the given locale or collation.
### Arguments
* `expression` is any Ecto query expression that evaluates to a
string value, or a comparison (`<`, `<=`, `>`, `>=`, `==`, `!=`)
between two such expressions.
* `locale_or_options` is a `t:Localize.LanguageTag.t/0`, any locale
identifier accepted by `Localize.validate_locale/1`, or a keyword
list of options. A pinned expression (`^locale`) is also accepted,
so runtime locale values read naturally in query syntax.
### 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
`Localize.Ecto.Collation.collation_for!/2`.
### Returns
* A query fragment `expression COLLATE "collation"`, or for a
comparison `left OP right COLLATE "collation"`.
### Examples
iex> import Ecto.Query
iex> query = from p in "products", order_by: collate(p.name, "sv"), select: p.name
iex> match?(%Ecto.Query{}, query)
true
iex> import Ecto.Query
iex> query = from p in "products", select: collate(p.name < p.description, "de")
iex> match?(%Ecto.Query{}, query)
true
"""
defmacro collate(expression, locale_or_options) do
locale_or_options = unpin(locale_or_options)
build_collate(
expression,
quote(do: Localize.Ecto.Collation.resolve!(unquote(locale_or_options)))
)
end
# A comparison collates its right-hand operand; PostgreSQL applies
# the collation to the comparison since COLLATE binds tighter than
# any operator.
defp build_collate({operator, _meta, [left, right]}, resolver)
when operator in @comparison_operators do
sql = "? #{sql_operator(operator)} ? COLLATE ?"
quote do
fragment(unquote(sql), unquote(left), unquote(right), literal(^unquote(resolver)))
end
end
defp build_collate(expression, resolver) do
quote do
fragment("? COLLATE ?", unquote(expression), literal(^unquote(resolver)))
end
end
defp sql_operator(:==), do: "="
defp sql_operator(:!=), do: "<>"
defp sql_operator(operator), do: Atom.to_string(operator)
# The locale argument is evaluated as an ordinary expression, not as a
# query expression, so a pin is not required. Accept one anyway since
# pinning runtime values is idiomatic inside Ecto queries.
defp unpin({:^, _meta, [expression]}), do: expression
defp unpin(expression), do: expression
end