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/ecto/tempo_range.ex
if Code.ensure_loaded?(Ecto.Type) do
defmodule Tempo.Ecto.TempoRange do
@moduledoc """
Ecto.ParameterizedType for the PostgreSQL composite type
`tempo_range` — a `tstzrange` paired with a Tempo-resolution
string and a `jsonb` metadata column that together preserve
the full Tempo shape on round-trip.
CREATE TYPE tempo_range AS (
range tstzrange,
resolution text,
meta jsonb
);
Use this type when you care about round-trip fidelity — the
stored value round-trips as the same `%Tempo.Interval{}`
including qualifications, non-Gregorian calendars,
recurrence rules, zone identifiers, and the
implicit-vs-explicit-span distinction.
## Setup
Run the DDL helpers once, early in the migration history:
import Tempo.SQL.Migration
create_tempo_types()
Then declare columns with `add_tempo_range/2` (or the raw
`add :window, :tempo_range`).
## Usage
schema "meetings" do
field :window, Tempo.Ecto.TempoRange
end
## Query API
The standard Postgres range operators (`@>`, `&&`) do not
apply directly to a composite column — they must reach into
`(column).range`. Use `Tempo.Ecto.QueryAPI.Composite` for
fragments that auto-unwrap the composite; the plain
`Tempo.Ecto.QueryAPI` does not work on these columns.
## Fidelity
Round-trip preserves:
* Token-list resolution (`~o"2026Y"` round-trips as
`~o"2026Y"`, not a materialised interval).
* Qualifications (`:uncertain`, `:approximate`).
* Non-Gregorian calendars.
* Recurrence rules and repeat rules.
* Zone identifiers (IANA names preserved via the meta
column, not just the UTC offset).
* `Tempo.Interval.metadata`, provided it is
JSON-serialisable.
"""
use Ecto.ParameterizedType
alias Ecto.ParameterizedType
alias Tempo.SQL.Conversion
alias Tempo.SQL.Meta
@doc "See `Tempo.Ecto.Interval.cast_type/1`."
def cast_type(options \\ []) do
ParameterizedType.init(__MODULE__, options)
end
@impl Ecto.ParameterizedType
def type(_params), do: :tempo_range
@impl Ecto.ParameterizedType
def init(options) do
resolution =
options
|> Keyword.get(:resolution, :second)
|> Conversion.validate_resolution!()
%{resolution: resolution}
end
@impl Ecto.ParameterizedType
def cast(nil, _params), do: {:ok, nil}
def cast(%Tempo.Interval{} = interval, _params), do: {:ok, interval}
def cast(%Tempo{} = tempo, _params) do
case Tempo.to_interval(tempo) do
{:ok, %Tempo.Interval{} = interval} -> {:ok, interval}
{:ok, %Tempo.IntervalSet{}} -> :error
_ -> :error
end
end
def cast(_, _params), do: :error
@impl Ecto.ParameterizedType
def load(value, loader \\ nil, params \\ %{resolution: :second})
def load(nil, _loader, _params), do: {:ok, nil}
# Postgrex decodes a composite type as a tuple:
# {range, resolution_string, meta_json_or_map}
def load({%Postgrex.Range{} = _range, _resolution, meta}, _loader, _params)
when not is_nil(meta) do
Meta.decode_interval(meta)
end
def load({%Postgrex.Range{} = range, _resolution, nil}, _loader, params) do
# Composite row missing the meta column — fall back to the plain
# range decoding. This happens for hand-crafted inserts that
# didn't populate meta.
Conversion.range_to_interval(range, resolution: params.resolution)
end
def load(_, _, _), do: :error
@impl Ecto.ParameterizedType
def dump(value, dumper \\ nil, params \\ %{resolution: :second})
def dump(nil, _, _), do: {:ok, nil}
def dump(%Tempo.Interval{} = interval, _, params) do
case Conversion.interval_to_queryable_range(interval) do
{:ok, range} ->
meta_json = interval |> Meta.encode_interval() |> IO.iodata_to_binary()
resolution_text = Atom.to_string(params.resolution)
{:ok, {range, resolution_text, meta_json}}
_ ->
:error
end
end
def dump(%Tempo{} = tempo, dumper, params) do
case cast(tempo, params) do
{:ok, interval} -> dump(interval, dumper, params)
:error -> :error
end
end
def dump(_, _, _), do: :error
@impl Ecto.ParameterizedType
def equal?(a, b, _params), do: a == b
@impl Ecto.ParameterizedType
def embed_as(_format, _params), do: :self
end
end