Packages
ex_tempo_sql
0.1.0
Ecto types and migration helpers for persisting Tempo intervals and interval sets as PostgreSQL tstzrange/tstzmultirange values.
Current section
Files
Jump to
Current section
Files
lib/tempo/sql/conversion.ex
defmodule Tempo.SQL.Conversion do
@moduledoc """
Internal helpers that translate between `t:Tempo.Interval.t/0`
endpoints and the `DateTime` / `:unbound` values that
`Postgrex.Range` understands.
The storage contract this module enforces — see the README for
the full rationale:
* `%Tempo{}` endpoints must carry a fully anchored
year/month/day/hour/minute/second time slot (Tempo's highest
resolution). Partial values must be materialised via
`Tempo.to_interval/1` first.
* `:qualification`, `:qualifications`, and `:extended` metadata
are *dropped on storage* — round-tripping is lossy by design
for round 1.
* Non-Gregorian calendars are rejected.
* Multi-valued token slots (lists like `day_of_week: [1, 3, 5]`
or ranges like `day: 1..15`) are rejected.
* `Tempo.Interval` recurrence (`:recurrence`, `:repeat_rule`)
is rejected — callers must materialise recurring intervals to
a `Tempo.IntervalSet` via `Tempo.to_interval/1` and store
that as `tstzmultirange` instead.
"""
alias Tempo.Interval
alias Tempo.SQL.UnsupportedValueError
@gregorian_calendars [nil, Calendar.ISO, Calendrical.Gregorian]
@resolutions [:year, :month, :day, :hour, :minute, :second]
@time_components [:year, :month, :day, :hour, :minute, :second]
@doc """
The valid values for the `:resolution` option on the Ecto types.
"""
def valid_resolutions, do: @resolutions
@doc """
Convert a `t:Tempo.Interval.t/0` into a `%Postgrex.Range{}` with
`DateTime` bounds in UTC.
Returns `{:ok, range}` on success, `{:error, exception}` when the
interval violates the storage contract.
"""
@spec interval_to_range(Tempo.Interval.t()) ::
{:ok, Postgrex.Range.t()} | {:error, UnsupportedValueError.t()}
def interval_to_range(%Tempo.Interval{} = interval) do
with :ok <- validate_interval_shape(interval),
{:ok, lower} <- endpoint_to_datetime(interval.from),
{:ok, upper} <- endpoint_to_datetime(interval.to) do
{:ok,
%Postgrex.Range{
lower: lower,
upper: upper,
lower_inclusive: true,
upper_inclusive: false
}}
end
end
@doc """
Convert a `t:Tempo.Interval.t/0` into a `%Postgrex.Range{}`
tolerant enough for the composite `tempo_range` type.
Unlike `interval_to_range/1`, this variant accepts Tempo
endpoints the plain `tstzrange` encoder rejects — qualifications,
non-Gregorian calendars, multi-valued slots, ordinal/week dates
— because the composite's `meta` column carries the original
shape losslessly. The range is still populated from the
materialised endpoints so Postgres range-operator queries work.
Endpoints are materialised via `Tempo.to_interval/1` where
needed to produce usable DateTime bounds.
"""
@spec interval_to_queryable_range(Tempo.Interval.t()) ::
{:ok, Postgrex.Range.t()} | {:error, UnsupportedValueError.t()}
def interval_to_queryable_range(%Tempo.Interval{} = interval) do
with {:ok, lower} <- queryable_endpoint(interval.from),
{:ok, upper} <- queryable_endpoint(interval.to) do
{:ok,
%Postgrex.Range{
lower: lower,
upper: upper,
lower_inclusive: true,
upper_inclusive: false
}}
end
end
defp queryable_endpoint(:undefined), do: {:ok, :unbound}
defp queryable_endpoint(nil), do: {:ok, :unbound}
defp queryable_endpoint(%Tempo{} = tempo) do
case Tempo.to_interval(tempo) do
{:ok, %Tempo.Interval{from: %Tempo{} = materialised}} ->
tempo_to_datetime(materialised)
_ ->
tempo_to_datetime(tempo)
end
end
defp queryable_endpoint(%Tempo.Duration{}) do
{:error,
UnsupportedValueError.exception(
reason: :unsupported_endpoint,
message: "cannot derive a queryable range bound from a Tempo.Duration alone"
)}
end
@doc """
Convert a `%Postgrex.Range{}` back into a `t:Tempo.Interval.t/0`.
Unlike discrete range types (`int4range`, `daterange`), PostgreSQL
does **not** canonicalise `tstzrange` to `[lower, upper)` on
output — a range written as `[a, b]` round-trips as `[a, b]`.
This loader therefore normalises any non-half-open range to
Tempo's `[first, last)` convention by shifting the offending
endpoint one second:
* `[a, b]` → `[a, b + 1s)`
* `(a, b)` → `[a + 1s, b)`
* `(a, b]` → `[a + 1s, b + 1s)`
Tempo is second-resolution so the shift is exact — the loaded
interval covers the same instants as the stored range.
### Options
* `:resolution` truncates both endpoints to the given
component, dropping all sub-components. Must be one of
`:year`, `:month`, `:day`, `:hour`, `:minute`, or `:second`.
Defaults to `:second` (no truncation). See the storage
contract guide for semantics.
"""
@spec range_to_interval(Postgrex.Range.t(), keyword()) ::
{:ok, Tempo.Interval.t()} | {:error, UnsupportedValueError.t()}
def range_to_interval(range, options \\ [])
def range_to_interval(%Postgrex.Range{} = range, options) do
resolution = Keyword.get(options, :resolution, :second)
with {:ok, normalised} <- normalise_brackets(range),
{:ok, from} <- datetime_to_endpoint(normalised.lower, resolution),
{:ok, to} <- datetime_to_endpoint(normalised.upper, resolution) do
Interval.new(from: from, to: to)
end
end
defp normalise_brackets(%Postgrex.Range{} = range) do
lower = shift_lower_to_inclusive(range.lower, range.lower_inclusive)
upper = shift_upper_to_exclusive(range.upper, range.upper_inclusive)
{:ok,
%{
lower: lower,
upper: upper,
lower_inclusive: true,
upper_inclusive: false
}}
end
defp shift_lower_to_inclusive(:unbound, _), do: :unbound
defp shift_lower_to_inclusive(nil, _), do: :unbound
defp shift_lower_to_inclusive(value, true), do: value
defp shift_lower_to_inclusive(%DateTime{} = dt, false), do: DateTime.add(dt, 1, :second)
defp shift_lower_to_inclusive(%NaiveDateTime{} = ndt, false),
do: NaiveDateTime.add(ndt, 1, :second)
defp shift_upper_to_exclusive(:unbound, _), do: :unbound
defp shift_upper_to_exclusive(nil, _), do: :unbound
defp shift_upper_to_exclusive(value, false), do: value
defp shift_upper_to_exclusive(%DateTime{} = dt, true), do: DateTime.add(dt, 1, :second)
defp shift_upper_to_exclusive(%NaiveDateTime{} = ndt, true),
do: NaiveDateTime.add(ndt, 1, :second)
@doc """
Validate a `:resolution` option value. Returns the atom
unchanged or raises `ArgumentError`.
"""
def validate_resolution!(resolution) when resolution in @resolutions, do: resolution
def validate_resolution!(other) do
raise ArgumentError,
"expected :resolution to be one of #{inspect(@resolutions)}, got: #{inspect(other)}"
end
@doc """
Truncate a `t:Tempo.t/0` token list to the given resolution,
dropping all sub-resolution components.
"""
@spec truncate_tempo(Tempo.t(), atom()) :: Tempo.t()
def truncate_tempo(%Tempo{time: time} = tempo, resolution) do
%{tempo | time: truncate_time(time, resolution)}
end
defp truncate_time(time, resolution) do
keep =
@time_components
|> Enum.take_while(&(&1 != resolution))
|> Kernel.++([resolution])
Enum.filter(time, fn {key, _value} -> key in keep end)
end
# ------------------------------------------------------------------
# Endpoint ↔ DateTime
# ------------------------------------------------------------------
defp endpoint_to_datetime(:undefined), do: {:ok, :unbound}
defp endpoint_to_datetime(nil), do: {:ok, :unbound}
defp endpoint_to_datetime(%Tempo{} = tempo) do
with :ok <- validate_tempo_shape(tempo) do
tempo_to_datetime(tempo)
end
end
defp endpoint_to_datetime(other) do
{:error,
UnsupportedValueError.exception(
reason: :unsupported_endpoint,
value: other,
message: "interval endpoint must be a %Tempo{}, :undefined, or nil; got #{inspect(other)}"
)}
end
# `normalise_brackets/1` maps a `nil` bound to `:unbound`, so an
# endpoint reaching here is only ever `:unbound`, a `DateTime`, or a
# `NaiveDateTime` — a `nil` clause would be unreachable.
defp datetime_to_endpoint(:unbound, _resolution), do: {:ok, :undefined}
defp datetime_to_endpoint(%DateTime{} = dt, resolution) do
{:ok, truncate_tempo(Tempo.from_date_time(dt), resolution)}
end
defp datetime_to_endpoint(%NaiveDateTime{} = ndt, resolution) do
{:ok, truncate_tempo(Tempo.from_naive_date_time(ndt), resolution)}
end
defp datetime_to_endpoint(other, _resolution) do
{:error,
UnsupportedValueError.exception(
reason: :unsupported_endpoint,
value: other,
message: "cannot interpret range bound #{inspect(other)} as a Tempo endpoint"
)}
end
# Tempo values with no `:shift` are assumed to be UTC — the only
# safe default when storing a zone-aware range. When `:shift` is
# set, Tempo does not currently expose a direct `to_date_time/1`
# helper, so we build the NaiveDateTime from the token list and
# subtract the shift offset to land on UTC before handing it to
# Postgrex (which expects UTC DateTimes for tstzrange). Partial
# Tempo endpoints (year-only, year-month) are filled in with
# calendar-default zero values — this is correct because Tempo's
# implicit-span convention means a partial endpoint is always the
# *start* of its resolution's span.
defp tempo_to_datetime(%Tempo{shift: nil, time: time}) do
with {:ok, ndt} <- naive_from_tempo(time) do
DateTime.from_naive(ndt, "Etc/UTC")
end
end
defp tempo_to_datetime(%Tempo{shift: shift, time: time}) do
with {:ok, ndt} <- naive_from_tempo(time) do
offset_seconds = shift_to_seconds(shift)
utc_ndt = NaiveDateTime.add(ndt, -offset_seconds, :second)
DateTime.from_naive(utc_ndt, "Etc/UTC")
end
end
defp shift_to_seconds(shift) do
hours = Keyword.get(shift, :hour, 0)
minutes = Keyword.get(shift, :minute, 0)
hours * 3600 + minutes * 60
end
# ------------------------------------------------------------------
# Shape validation
# ------------------------------------------------------------------
defp validate_interval_shape(%Tempo.Interval{recurrence: n}) when n != 1 do
{:error, UnsupportedValueError.exception(reason: :recurrence, value: n)}
end
defp validate_interval_shape(%Tempo.Interval{repeat_rule: rule}) when not is_nil(rule) do
{:error, UnsupportedValueError.exception(reason: :repeat_rule, value: rule)}
end
defp validate_interval_shape(%Tempo.Interval{from: :undefined, to: :undefined} = iv) do
{:error,
UnsupportedValueError.exception(
reason: :fully_unbounded,
value: iv,
message: "interval is unbounded on both sides; refusing to store a `(,)` range"
)}
end
defp validate_interval_shape(%Tempo.Interval{}), do: :ok
defp validate_tempo_shape(%Tempo{qualification: q}) when not is_nil(q) do
{:error, UnsupportedValueError.exception(reason: :qualification, value: q)}
end
defp validate_tempo_shape(%Tempo{qualifications: qs}) when not is_nil(qs) and qs != [] do
{:error, UnsupportedValueError.exception(reason: :qualification, value: qs)}
end
defp validate_tempo_shape(%Tempo{calendar: calendar})
when calendar not in @gregorian_calendars do
{:error, UnsupportedValueError.exception(reason: :non_gregorian_calendar, value: calendar)}
end
defp validate_tempo_shape(%Tempo{time: time} = tempo) do
cond do
not has_year_month_day?(time) ->
{:error, UnsupportedValueError.exception(reason: :partial_resolution, value: tempo)}
has_multi_valued_slot?(time) ->
{:error, UnsupportedValueError.exception(reason: :multi_valued_slot, value: tempo)}
true ->
:ok
end
end
# Tempo endpoints from `to_interval/1` may carry a span-resolution
# token list — e.g. a year-only `%Tempo{time: [year: 2026]}`
# materialises to an Interval whose endpoints are
# `[year: 2026]` and `[year: 2027]`. Both represent the start of
# their respective spans (half-open `[from, to)`), so we require
# at minimum year/month (accepting year-only by filling calendar
# defaults) and fill hour/minute/second with zeros.
defp has_year_month_day?(time) do
Keyword.has_key?(time, :year)
end
defp has_multi_valued_slot?(time) do
Enum.any?(time, fn
{_, value} when is_integer(value) -> false
{_, _} -> true
end)
end
defp naive_from_tempo(time) do
year = Keyword.fetch!(time, :year)
month = Keyword.get(time, :month, 1)
day = Keyword.get(time, :day, 1)
hour = Keyword.get(time, :hour, 0)
minute = Keyword.get(time, :minute, 0)
second = Keyword.get(time, :second, 0)
# Ordinal date (`year + day`) and week date (`year + week + day_of_week`)
# require calendar conversion — reject them here; callers must
# materialise them via `Tempo.to_date/1` first.
if Keyword.has_key?(time, :week) or
(Keyword.has_key?(time, :day) and not Keyword.has_key?(time, :month)) do
{:error,
UnsupportedValueError.exception(
reason: :partial_resolution,
value: time,
message:
"ordinal or week-date Tempo endpoints must be materialised to a calendar date before storage"
)}
else
NaiveDateTime.new(year, month, day, hour, minute, second, {0, 0})
end
end
end