Localize.Ecto.TaggedDecimal (Localize SQL v1.0.0)

Copy Markdown View Source

Describes a tagged decimal — a value that pairs a tag with a decimal amount — for serialization to a SQL database.

A tagged decimal is the shape shared by a money amount (a currency code and an amount), a unit (a unit name and a value), and anything else that is meaningless without the label travelling alongside the number. Storing the two together in one column keeps them inseparable, and lets the database itself refuse to add euros to yen.

This module holds the specification of one such type: the name of the composite type, the names of its fields, and the wording of the error raised when two tags do not match. Localize.Ecto.TaggedDecimal.DDL turns a specification into the SQL that creates the composite type, its aggregate functions and its operators, and Localize.Ecto.TaggedDecimal.Composite and Localize.Ecto.TaggedDecimal.Map use it to load and dump values through Ecto.

The specification exists because the SQL is otherwise identical between domains. ex_money_sql describes money_with_currency(currency_code, amount) and localize_sql describes cldr_unit(unit, value); the generated plpgsql differs only in those identifiers and in the error message.

Field naming

The field names are part of the specification rather than fixed because they are already committed to in deployed databases — a generated aggregate must name the same fields as the composite type it was created against. PostgreSQL's functional notation makes each field callable as a function of the composite (amount(col) is (col).amount), which is how the generated SQL and the Ecto query API read fields.

Examples

iex> Localize.Ecto.TaggedDecimal.new!(
...>   type_name: "money_with_currency",
...>   tag_field: "currency_code",
...>   value_field: "amount",
...>   function_prefix: "money",
...>   tag_description: "currency code"
...> ).type_name
"money_with_currency"

Summary

Functions

Returns the fields of the composite type as {name, sql_type} tuples.

Returns the name of a generated function for a specification.

Returns a tagged decimal specification.

Returns a tagged decimal specification or raises.

Types

sql_identifier()

@type sql_identifier() :: String.t()

t()

@type t() :: %Localize.Ecto.TaggedDecimal{
  argument_name: sql_identifier(),
  errcode: String.t(),
  extra_fields: [{sql_identifier(), String.t()}],
  function_names: %{required(atom()) => sql_identifier()},
  function_prefix: sql_identifier(),
  mismatch_hint: String.t(),
  mismatch_message: String.t(),
  tag_description: String.t(),
  tag_description_plural: String.t(),
  tag_field: sql_identifier(),
  tag_sql_type: String.t(),
  type_name: sql_identifier(),
  value_field: sql_identifier(),
  value_sql_type: String.t()
}

Functions

fields(spec)

@spec fields(t()) :: [{sql_identifier(), String.t()}]

Returns the fields of the composite type as {name, sql_type} tuples.

The tag and value fields come first, in that order, followed by any :extra_fields.

Arguments

Returns

  • A list of {name, sql_type} tuples.

Examples

iex> spec = Localize.Ecto.TaggedDecimal.new!(
...>   type_name: "cldr_unit_with_usage", tag_field: "unit",
...>   value_field: "value", function_prefix: "unit_with_usage",
...>   tag_description: "unit name", extra_fields: [{"usage", "varchar"}]
...> )
iex> Localize.Ecto.TaggedDecimal.fields(spec)
[{"unit", "varchar"}, {"value", "numeric"}, {"usage", "varchar"}]

function_name(spec, role)

@spec function_name(t(), atom()) :: sql_identifier()

Returns the name of a generated function for a specification.

Names follow <function_prefix>_<role>_function — for example money_sum_state_function — unless overridden by the :function_names option of new/1.

Arguments

  • spec is a t/0.

  • role is one of :sum_state, :sum_combine, :avg_state, :avg_combine, :avg_final, :min_state, :min_combine, :max_state, :max_combine, :add, :subtract or :negate.

Returns

  • The function name as a string.

Examples

iex> spec = Localize.Ecto.TaggedDecimal.new!(
...>   type_name: "money_with_currency", tag_field: "currency_code",
...>   value_field: "amount", function_prefix: "money",
...>   tag_description: "currency code"
...> )
iex> Localize.Ecto.TaggedDecimal.function_name(spec, :sum_state)
"money_sum_state_function"
iex> Localize.Ecto.TaggedDecimal.function_name(spec, :add)
"money_add"

new(options)

@spec new(Keyword.t()) :: {:ok, t()} | {:error, Exception.t()}

Returns a tagged decimal specification.

Arguments

  • options is a keyword list of options.

Options

  • :type_name is the name of the SQL composite type, such as "money_with_currency". Required.

  • :tag_field is the name of the composite field holding the tag, such as "currency_code". Required.

  • :value_field is the name of the composite field holding the decimal, such as "amount". Required.

  • :function_prefix is the prefix given to generated function names, such as "money" for money_sum_state_function. Required.

  • :tag_description is the human-readable name of the tag used in error messages, such as "currency code". Required.

  • :tag_sql_type is the SQL type of the tag field. The default is "varchar".

  • :value_sql_type is the SQL type of the value field. The default is "numeric".

  • :extra_fields is a list of {name, sql_type} tuples appended to the composite type after the tag and value, such as [{"usage", "varchar"}]. The default is [].

  • :argument_name is the name given to the incoming row in generated plpgsql. The default is the :function_prefix.

  • :function_names is a map of overrides for generated function names, keyed by :sum_state, :sum_combine, :avg_state, :avg_combine, :avg_final, :avg_state_type, :min_state, :min_combine, :max_state, :max_combine, :add, :subtract and :negate. The :avg_state_type key names the intermediate composite type the avg aggregate accumulates into rather than a function. Deployed databases name these functions in their aggregate definitions, so an existing name that does not follow the default pattern is preserved here. The default is %{}.

  • :tag_description_plural is the plural of :tag_description, used in generated error messages. The default appends "s".

  • :mismatch_message is the message of the exception raised when two tags differ. The default is derived from :tag_description.

  • :mismatch_hint is the hint attached to that exception. The default is derived from :tag_description.

  • :errcode is the SQLSTATE raised on a tag mismatch. The default is "22033", which is the code the aggregates of ex_money_sql and localize_units_sql have always raised and is therefore what deployed functions and any consumer rescuing them already use. Note that 22033 is invalid_sql_json_subscript in PostgreSQL 16 and later, not the invalid_parameter_value (22023) the wording of these messages suggests — pass :errcode explicitly to choose a different code for a new type.

Returns

  • {:ok, spec} where spec is a t/0, or

  • {:error, exception} if a required option is missing or an option has an invalid value.

Examples

iex> {:ok, spec} = Localize.Ecto.TaggedDecimal.new(
...>   type_name: "cldr_unit",
...>   tag_field: "unit",
...>   value_field: "value",
...>   function_prefix: "unit",
...>   tag_description: "unit name"
...> )
iex> spec.mismatch_message
"Incompatible unit names. Expected all unit names to be %"

iex> {:error, exception} = Localize.Ecto.TaggedDecimal.new(tag_field: "unit")
iex> is_exception(exception)
true

new!(options)

@spec new!(Keyword.t()) :: t() | no_return()

Returns a tagged decimal specification or raises.

Arguments

  • options is a keyword list of options. See new/1.

Returns

  • A t/0, or

  • raises if an option is missing or invalid.

Examples

iex> Localize.Ecto.TaggedDecimal.new!(
...>   type_name: "cldr_unit",
...>   tag_field: "unit",
...>   value_field: "value",
...>   function_prefix: "unit",
...>   tag_description: "unit name"
...> ).tag_field
"unit"