Internal helpers that translate between Tempo intervals and the
DateTime / :unbound values that Postgrex.Range understands.
The storage contract this module enforces — see the README for the full rationale:
An interval is stored as the instants it runs between (
Tempo.Interval.instants/1): from the instant it begins to the instant it ends, half-open. An end written to a date is read through it, so2026-06-15/2026-06-20is stored up to the 21st.A value with no zone is stored as though at UTC.
Qualifications (
:uncertain,:approximate) and calendars other than the Gregorian are rejected by the plain types, which cannot keep them; the composite types keep them in theirmetacolumn.A recurrence, or anything else of several spans, is stored by
Tempo.Ecto.IntervalSetas atstzmultirange, not by the plain interval types.
Summary
Types
A unit a loaded range's ends are written to.
Functions
The one interval an expression is: an interval itself, or a value as the interval it names.
Converts any expression into a %Postgrex.Range{} tolerant enough
for the composite tempo_range type.
Converts an interval into a %Postgrex.Range{} with DateTime
bounds in UTC.
The intervals an expression covers, each a span, in order.
Convert a %Postgrex.Range{} back into an interval.
Truncates a value to the given resolution, dropping all finer components.
Returns the valid values for the :resolution option on the Ecto
types.
Validates a :resolution option value.
Types
Functions
@spec as_interval(Tempo.t()) :: {:ok, Tempo.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}
The one interval an expression is: an interval itself, or a value as the interval it names.
Arguments
expressionis aTempo.t/0.
Returns
{:ok, interval}, aTempo.t/0interval.{:error, exception}for an expression of several spans (a value naming several, a recurrence, a set) or with none.
Examples
iex> Tempo.SQL.Conversion.as_interval(~o"2027-06")
{:ok, ~o"2027-06/2027-06"}
iex> {:error, error} = Tempo.SQL.Conversion.as_interval(~o"R3/2027-06-01/P1D")
iex> error.reason
:multi_valued_slot
@spec interval_to_queryable_range(Tempo.t() | Tempo.Duration.t()) :: {:ok, Postgrex.Range.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}
Converts any expression into a %Postgrex.Range{} tolerant enough
for the composite tempo_range type.
Unlike interval_to_range/1, this accepts what the plain tstzrange
encoder rejects — qualifications, calendars other than the Gregorian,
values that name several, recurrences — because the composite's
meta column carries the expression losslessly. The range is the
stretch the expression covers, from its first instant to its last, so
Postgres range operators still find it.
Arguments
expressionis aTempo.t/0.
Returns
{:ok, range}whererangeis aPostgrex.Range.t/0. An open end, or a recurrence with no end, becomes:unbound, so it is still queryable.{:error, exception}when the expression has no place on the time line at all — a bare duration, a value with no year, a span open at both ends, or a recurrence counted back with no start.
Examples
iex> workday = Tempo.Interval.new!(
...> from: ~o"2027-06-15T09:00:00",
...> to: ~o"2027-06-15T17:00:00"
...> )
iex> {:ok, range} = Tempo.SQL.Conversion.interval_to_queryable_range(workday)
iex> range.upper
~U[2027-06-15 17:00:00Z]An open-ended interval keeps a usable lower bound:
iex> {:ok, range} = Tempo.SQL.Conversion.interval_to_queryable_range(~o"2027-06-15T09:00:00/..")
iex> {range.lower, range.upper}
{~U[2027-06-15 09:00:00Z], :unbound}
@spec interval_to_range(Tempo.t()) :: {:ok, Postgrex.Range.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}
Converts an interval into a %Postgrex.Range{} with DateTime
bounds in UTC.
The range is half-open — lower inclusive, upper exclusive — from the instant the interval begins to the instant it ends, so no boundary is gained or lost in storage.
Arguments
intervalis aTempo.t/0interval.
Returns
{:ok, range}whererangeis aPostgrex.Range.t/0.{:error, exception}whereexceptionis aTempo.SQL.UnsupportedValueError.t/0, when the interval holds something atstzrangecannot — see the storage-contract guide.
Examples
iex> workday = Tempo.Interval.new!(
...> from: ~o"2027-06-15T09:00:00",
...> to: ~o"2027-06-15T17:00:00"
...> )
iex> {:ok, range} = Tempo.SQL.Conversion.interval_to_range(workday)
iex> {range.lower, range.upper}
{~U[2027-06-15 09:00:00Z], ~U[2027-06-15 17:00:00Z]}
iex> {range.lower_inclusive, range.upper_inclusive}
{true, false}
iex> {:ok, range} = Tempo.SQL.Conversion.interval_to_range(~o"2027-06-15/2027-06-20")
iex> {range.lower, range.upper}
{~U[2027-06-15 00:00:00Z], ~U[2027-06-21 00:00:00Z]}
iex> {:error, error} = Tempo.SQL.Conversion.interval_to_range(~o"1984?/2004")
iex> error.reason
:qualification
@spec intervals(Tempo.t()) :: {:ok, [Tempo.t()]} | {:error, Tempo.SQL.UnsupportedValueError.t()}
The intervals an expression covers, each a span, in order.
Arguments
expressionis aTempo.t/0with a last span.
Returns
{:ok, intervals}, a list ofTempo.t/0intervals.{:error, exception}for an expression with no end or no spans.
Examples
iex> Tempo.SQL.Conversion.intervals(~o"R2/2027-06-01/P1D")
{:ok, [~o"2027-06-01/2027-06-01", ~o"2027-06-02/2027-06-02"]}
@spec range_to_interval(Postgrex.Range.t(), keyword()) :: {:ok, Tempo.t()} | {:error, Exception.t()}
Convert a %Postgrex.Range{} back into an interval.
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
[lower, upper) by shifting the offending endpoint one second:
[a, b]→[a, b + 1s).(a, b)→[a + 1s, b).(a, b]→[a + 1s, b + 1s).
Arguments
rangeis thePostgrex.Range.t/0to convert.optionsis a keyword list of options.
Options
:resolutiontruncates 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). An end at a date resolution is read through it, so the range's upper bound becomes the last day, month or year it holds. See the storage contract guide for semantics.
Returns
{:ok, interval}whereintervalis aTempo.t/0interval.{:error, exception}when an endpoint is not a time, or when truncating to the requested resolution leaves the interval ending before it begins.
Examples
iex> range = %Postgrex.Range{
...> lower: ~U[2027-06-15 09:00:00Z],
...> upper: ~U[2027-06-15 17:00:00Z],
...> lower_inclusive: true,
...> upper_inclusive: false
...> }
iex> {:ok, interval} = Tempo.SQL.Conversion.range_to_interval(range)
iex> {Tempo.hour(Tempo.Interval.from(interval)), Tempo.hour(Tempo.Interval.to(interval))}
{9, 17}A range of whole days loaded at the day is the days it holds:
iex> days = %Postgrex.Range{
...> lower: ~U[2027-06-15 00:00:00Z],
...> upper: ~U[2027-06-21 00:00:00Z],
...> lower_inclusive: true,
...> upper_inclusive: false
...> }
iex> Tempo.SQL.Conversion.range_to_interval(days, resolution: :day)
{:ok, ~o"2027Y6M15DZ/2027Y6M20DZ"}
Truncates a value to the given resolution, dropping all finer components.
Arguments
tempois theTempo.t/0value to truncate.resolutionis the coarsest component to keep — one of those returned byvalid_resolutions/0.
Returns
- A
Tempo.t/0. A value already coarser than the requested resolution is returned unchanged.
Examples
iex> Tempo.SQL.Conversion.truncate_tempo(~o"2027-06-15T09:30:45", :day)
~o"2027Y6M15D"
iex> Tempo.SQL.Conversion.truncate_tempo(~o"2027Y", :second)
~o"2027Y"
@spec valid_resolutions() :: [resolution(), ...]
Returns the valid values for the :resolution option on the Ecto
types.
Returns
- A list of resolution atoms, coarsest first.
Examples
iex> Tempo.SQL.Conversion.valid_resolutions()
[:year, :month, :day, :hour, :minute, :second]
@spec validate_resolution!(term()) :: resolution()
Validates a :resolution option value.
Arguments
resolutionis the value to validate.
Returns
The resolution atom unchanged, when it is one of those returned by
valid_resolutions/0.Raises
ArgumentErrorotherwise, naming the value it was given.
Examples
iex> Tempo.SQL.Conversion.validate_resolution!(:day)
:day
iex> Tempo.SQL.Conversion.validate_resolution!(:fortnight)
** (ArgumentError) expected :resolution to be one of [:year, :month, :day, :hour, :minute, :second], got: :fortnight