Tempo.SQL.Conversion (Tempo SQL v0.2.0)

Copy Markdown View Source

Internal helpers that translate between 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.

Summary

Functions

Convert a Tempo.Interval.t/0 into a %Postgrex.Range{} tolerant enough for the composite tempo_range type.

Converts a Tempo.Interval.t/0 into a %Postgrex.Range{} with DateTime bounds in UTC.

Convert a %Postgrex.Range{} back into a Tempo.Interval.t/0.

Truncates a Tempo.t/0 token list to the given resolution, dropping all sub-resolution components.

Returns the valid values for the :resolution option on the Ecto types.

Validates a :resolution option value.

Functions

interval_to_queryable_range(interval)

@spec interval_to_queryable_range(Tempo.Interval.t()) ::
  {:ok, Postgrex.Range.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}

Convert a 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, and endpoints still carrying week or day-of-year tokens — 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.

Arguments

Returns

  • {:ok, range} where range is a Postgrex.Range.t/0. An open endpoint becomes :unbound, so an interval with no stated end is still queryable.

  • {:error, exception} when an endpoint has no position on the time line at all — a bare duration, for instance.

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, ongoing} = Tempo.Interval.new(from: ~o"2027-06-15T09:00:00", to: :undefined)
iex> {:ok, range} = Tempo.SQL.Conversion.interval_to_queryable_range(ongoing)
iex> range.upper
:unbound

interval_to_range(interval)

@spec interval_to_range(Tempo.Interval.t()) ::
  {:ok, Postgrex.Range.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}

Converts a Tempo.Interval.t/0 into a %Postgrex.Range{} with DateTime bounds in UTC.

The range is half-open — lower inclusive, upper exclusive — matching Tempo's [from, to) convention exactly, 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> uncertain = %Tempo.Interval{from: ~o"1984?", to: ~o"2004"}
iex> {:error, error} = Tempo.SQL.Conversion.interval_to_range(uncertain)
iex> error.reason
:qualification

range_to_interval(range, options \\ [])

@spec range_to_interval(
  Postgrex.Range.t(),
  keyword()
) :: {:ok, Tempo.Interval.t()} | {:error, Tempo.SQL.UnsupportedValueError.t()}

Convert a %Postgrex.Range{} back into a 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.

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). See the storage contract guide for semantics.

Returns

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

  • {:error, exception} when an endpoint is not a time, or when truncating to the requested resolution would collapse the span to zero extent.

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(interval.from), Tempo.hour(interval.to)}
{9, 17}

An inclusive upper bound is shifted out by a second, so the loaded interval covers the same instants as the stored range:

iex> closed = %Postgrex.Range{
...>   lower: ~U[2027-06-15 09:00:00Z],
...>   upper: ~U[2027-06-15 17:00:00Z],
...>   lower_inclusive: true,
...>   upper_inclusive: true
...> }
iex> {:ok, interval} = Tempo.SQL.Conversion.range_to_interval(closed)
iex> Tempo.second(interval.to)
1

truncate_tempo(tempo, resolution)

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

Truncates a Tempo.t/0 token list to the given resolution, dropping all sub-resolution components.

Arguments

Returns

  • A Tempo.t/0 carrying only components at or above resolution. 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()

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)

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