Tempo.SQL.Conversion (Tempo SQL v0.3.0)

Copy Markdown View Source

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, so 2026-06-15/2026-06-20 is 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 their meta column.

  • A recurrence, or anything else of several spans, is stored by Tempo.Ecto.IntervalSet as a tstzmultirange, 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

resolution()

@type resolution() :: :year | :month | :day | :hour | :minute | :second

A unit a loaded range's ends are written to.

Functions

as_interval(expression)

@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

Returns

  • {:ok, interval}, a Tempo.t/0 interval.

  • {: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

interval_to_queryable_range(duration)

@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

Returns

  • {:ok, range} where range is a Postgrex.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}

interval_to_range(interval)

@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

Returns

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

intervals(expression)

@spec intervals(Tempo.t()) ::
  {:ok, [Tempo.t()]} | {:error, Tempo.SQL.UnsupportedValueError.t()}

The intervals an expression covers, each a span, in order.

Arguments

Returns

  • {:ok, intervals}, a list of Tempo.t/0 intervals.

  • {: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"]}

range_to_interval(range, options \\ [])

@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

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). 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} where interval is a Tempo.t/0 interval.

  • {: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"}

truncate_tempo(tempo, resolution)

@spec truncate_tempo(Tempo.t(), atom()) :: Tempo.t()

Truncates a value to the given resolution, dropping all finer components.

Arguments

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"

valid_resolutions()

@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]

validate_resolution!(resolution)

@spec validate_resolution!(term()) :: resolution()

Validates a :resolution option value.

Arguments

  • resolution is the value to validate.

Returns

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