Tempo.SQL.Conversion (Tempo SQL v0.4.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 column is on one frame: a tstzrange holds instants on the UTC line, a value in a zone or at a time shift; a tsrange holds floating instants, a value with neither. A value of the other frame is refused, never placed on this one.

  • 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

The line a column's instants lie on: :utc for a timestamptz range, whose values are in a zone or at a time shift, or :floating for a timestamp range, whose values have neither.

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 and tempo_floating_range types.

Converts an interval into a %Postgrex.Range{} on a frame: DateTime bounds at UTC for a tstzrange, or NaiveDateTime bounds for a tsrange.

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

Checks that an expression lies on a column's frame: on the UTC line, in a zone or at a time shift, for :utc, or floating, with a year and neither, for :floating.

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 :frame option on the Ecto types.

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

Validates a :frame option.

Validates a :resolution option value.

Types

frame()

@type frame() :: :utc | :floating

The line a column's instants lie on: :utc for a timestamptz range, whose values are in a zone or at a time shift, or :floating for a timestamp range, whose values have neither.

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(expression, frame \\ :utc)

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

Converts any expression into a %Postgrex.Range{} tolerant enough for the composite tempo_range and tempo_floating_range types.

Unlike interval_to_range/2, this accepts what the plain range 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

  • expression is a Tempo.t/0.

  • frame is :utc (the default) for an expression in a zone or at a time shift, or :floating for one with neither.

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 — or, with the reason :frame, when it is of the other frame.

  • {:error, %ArgumentError{}} when frame is not one of valid_frames/0.

Examples

iex> workday = Tempo.Interval.new!(
...>   from: ~o"2027-06-15T09:00:00Z",
...>   to: ~o"2027-06-15T17:00:00Z"
...> )
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/..", :floating)
iex> {range.lower, range.upper}
{~N[2027-06-15 09:00:00], :unbound}

interval_to_range(interval, frame \\ :utc)

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

Converts an interval into a %Postgrex.Range{} on a frame: DateTime bounds at UTC for a tstzrange, or NaiveDateTime bounds for a tsrange.

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

  • interval is a Tempo.t/0 interval.

  • frame is :utc (the default) for an interval in a zone or at a time shift, or :floating for one with neither.

Returns

  • {:ok, range} where range is a Postgrex.Range.t/0.

  • {:error, exception} where exception is a Tempo.SQL.UnsupportedValueError.t/0, when the interval holds something the range cannot — see the storage-contract guide. Its reason is :frame for an interval of the other frame.

  • {:error, %ArgumentError{}} when frame is not one of valid_frames/0.

Examples

iex> workday = Tempo.Interval.new!(
...>   from: ~o"2027-06-15T09:00:00Z",
...>   to: ~o"2027-06-15T17:00:00Z"
...> )
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", :floating)
iex> {range.lower, range.upper}
{~N[2027-06-15 00:00:00], ~N[2027-06-21 00:00:00]}

iex> {:error, error} = Tempo.SQL.Conversion.interval_to_range(~o"2027-06-15/2027-06-20")
iex> error.reason
:frame

iex> {:error, error} = Tempo.SQL.Conversion.interval_to_range(~o"1984?/2004", :floating)
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"]}

on_frame(expression, frame)

@spec on_frame(Tempo.t(), frame()) ::
  :ok | {:error, Tempo.SQL.UnsupportedValueError.t() | Exception.t()}

Checks that an expression lies on a column's frame: on the UTC line, in a zone or at a time shift, for :utc, or floating, with a year and neither, for :floating.

The frame is read from the instants the expression would be stored as, so a value passes as interval_to_queryable_range/2 would place it. An expression with no place on the time line, such as one with no year, has no frame to check; the encoders refuse it when it is stored.

Arguments

  • expression is a Tempo.t/0.

  • frame is :utc or :floating.

Returns

Examples

iex> Tempo.SQL.Conversion.on_frame(~o"2027-06-15T09:00Z", :utc)
:ok

iex> {:error, error} = Tempo.SQL.Conversion.on_frame(~o"2027-06-15T09:00", :utc)
iex> error.reason
:frame

iex> Tempo.SQL.Conversion.on_frame(~o"T09/T17", :floating)
:ok

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_frames()

@spec valid_frames() :: [frame(), ...]

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

Returns

  • A list of frame atoms, the default first.

Examples

iex> Tempo.SQL.Conversion.valid_frames()
[:utc, :floating]

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_frame!(frame)

@spec validate_frame!(term()) :: frame()

Validates a :frame option.

Arguments

  • frame is the value given as the :frame option.

Returns

  • The frame atom unchanged, when it is one of those returned by valid_frames/0.

  • Raises ArgumentError otherwise, naming the value it was given.

Examples

iex> Tempo.SQL.Conversion.validate_frame!(:floating)
:floating

iex> Tempo.SQL.Conversion.validate_frame!(:local)
** (ArgumentError) expected :frame to be one of [:utc, :floating], got: :local

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