Ecto types and migration helpers that persist Tempo intervals and interval sets as PostgreSQL tstzrange / tstzmultirange values.
Tempo models time as intervals, not instants. PostgreSQL models the same shape natively — a tstzrange is a bounded span with the half-open [lower, upper) convention, and tstzmultirange (PG 14+) is a set of disjoint spans. tempo_sql is the adapter between them: a Tempo interval dumps straight to a Postgrex range, loads straight back, and participates in Postgres range queries (@>, &&, -|-) as first-class values.
Add ex_tempo_sql to your deps:
def deps do
[
{:ex_tempo, "~> 0.3"},
{:ex_tempo_sql, "~> 0.1"},
{:ecto_sql, "~> 3.13"},
{:postgrex, "~> 0.19"}
]
endRequires PostgreSQL 14 or later for tstzmultirange. tstzrange alone works on PostgreSQL 9.2+.
defmodule MyApp.Repo.Migrations.AddMeetings do
use Ecto.Migration
import Tempo.SQL.Migration
def change do
create table(:meetings) do
add :name, :string
add_interval :window, null: false
end
create_interval_index :meetings, :window
end
enddefmodule MyApp.Meeting do
use Ecto.Schema
schema "meetings" do
field :name, :string
field :window, Tempo.Ecto.Interval
end
endwindow = Tempo.Interval.new!(
from: Tempo.from_iso8601!("2026-06-15T09:00:00"),
to: Tempo.from_iso8601!("2026-06-15T10:00:00")
)
%MyApp.Meeting{name: "Standup", window: window} |> Repo.insert!()
import Ecto.Query
import Tempo.Ecto.QueryAPI
search = Tempo.Interval.new!(
from: Tempo.from_iso8601!("2026-06-15T09:30:00"),
to: Tempo.from_iso8601!("2026-06-15T09:45:00")
)
{:ok, search_range} = Tempo.Ecto.Interval.dump(search)
Repo.all(
from m in MyApp.Meeting,
where: overlaps(m.window, ^search_range),
select: m.name
)
#=> ["Standup"]Meetings whose window overlaps the search range — the pipeline reads like the English sentence.
Plain range — span only, lossy round-trip:
| Type | Stores | Postgres column |
|---|---|---|
Tempo.Ecto.Interval |
%Tempo.Interval{} |
tstzrange |
Tempo.Ecto.IntervalSet |
%Tempo.IntervalSet{} |
tstzmultirange |
Tempo.Ecto.Tempo |
bare %Tempo{} (implicit span) |
tstzrange |
Composite — full Tempo shape, byte-exact round-trip:
| Type | Stores | Postgres column |
|---|---|---|
Tempo.Ecto.TempoRange |
%Tempo.Interval{} |
tempo_range |
Tempo.Ecto.TempoMultirange |
%Tempo.IntervalSet{} |
tempo_multirange |
Use the composite types when the plain-range mode's losses hurt — recurrence rules, qualifications (:uncertain, :approximate), non-Gregorian calendars, zone identifiers, or the implicit-vs-explicit-span distinction. See the storage contract guide for setup and trade-offs.
Tempo.Ecto.Tempo materialises a partial Tempo value (~o"2026Y", ~o"2026Y-06M") to its explicit span before writing. On load you get back a %Tempo.Interval{} — the "it was just a year token" fact doesn't round-trip.
All three types accept a :resolution field option that truncates loaded endpoints to a named Tempo component — :year, :month, :day, :hour, :minute, or :second (the default):
schema "reports" do
field :reporting_year, Tempo.Ecto.Interval, resolution: :year
field :daily_window, Tempo.Ecto.Interval, resolution: :day
field :meeting_window, Tempo.Ecto.Interval # full second resolution
endA column declared resolution: :year always loads as year-resolution Tempos, regardless of what the underlying tstzrange stored — it's a caller-side assertion about column contents, not a heuristic. See the storage contract guide for the caveats.
Tempo.Ecto.QueryAPI gives you Allen-named fragments over Postgres range operators:
| Macro | Postgres operator | Meaning |
|---|---|---|
contains(a, b) |
@> |
a fully contains b |
overlaps(a, b) |
&& |
a and b share any instant |
meets(a, b) |
`- | -` |
strictly_before(a, b) |
<< |
a ends strictly before b starts (Allen precedes) |
strictly_after(a, b) |
>> |
a starts strictly after b ends |
Tempo.SQL.Migration exposes three macros:
add_interval :window # → add :window, :tstzrange
add_interval_set :busy_times # → add :busy_times, :tstzmultirange
create_interval_index :meetings, :window # GiST index — required for range-operator speedAll three are thin delegates — you can also write the raw add :window, :tstzrange form.
Not every Tempo value can be stored as a Postgres range. Tempo expresses things that tstzrange / tstzmultirange cannot: qualifications, recurrence rules, non-Gregorian calendars, multi-valued token slots. tempo_sql is explicit about this — rather than silently lose information, it returns :error from dump/1, which surfaces as an Ecto.ChangeError at insert time.
For the full mapping — what is retained, what is dropped, what is rejected, and how Tempo's resolution-by-omission convention interacts with Postgres ranges — see the Storage contract guide. The summary:
-
Fully-anchored
Tempo.Intervalvalues —fromandtoare%Tempo{}values whose token lists contain enough of year / month / day / hour / minute / second to be materialised to aNaiveDateTime. Sub-fields default to calendar zero (month 1, day 1, hour/minute/second 0), so~o"2026Y"used as an endpoint lands on2026-01-01T00:00:00Z. -
Partial open-ended intervals —
from: :undefinedorto: :undefined(but not both) map to unbounded range sides ((, upper]or[lower, )). -
Tempo.IntervalSetvalues with at least one member, where every member is itself storable under the rules above. The set's own:metadatafield is dropped. -
UTC or offset-shifted Tempo values — a
:shiftoffset is applied to land on UTC before handing the value to Postgres.
-
Intervals with
recurrence != 1or arepeat_rule— materialise them first viaTempo.to_interval/1into aTempo.IntervalSetand store that instead. -
Intervals that are unbounded on both sides.
-
Tempo endpoints with a
:qualification(:uncertain,:approximate) — Postgres ranges have no notion of uncertainty. -
Tempo endpoints on a non-Gregorian calendar — no automatic conversion; convert first.
-
Tempo endpoints with multi-valued token slots (
day_of_week: [1, 3, 5],day: 1..15) — these don't collapse to a single instant. -
Ordinal-date (
year: 2026, day: 75) or week-date (year: 2026, week: 10, day_of_week: 3) endpoints — materialise to a calendar date first viaTempo.to_date/1/Tempo.to_interval/1. -
Empty
Tempo.IntervalSetvalues — use a NULL column if you need "no set".
For the initial release, a stored value and a loaded value match only on their interval shape, not their metadata. Round-trip discards:
-
Tempo.Interval.metadata -
Tempo.IntervalSet.metadata -
Tempo
:extendedmetadata (zone_id, IXDTF tags) -
The distinction between an implicit span (
~o"2026Y") and its materialisation (2026-01-01..2027-01-01)
A loaded value is always Gregorian, UTC (shift [hour: 0], zone_id "Etc/UTC"). A future release will add a :text storage variant (ISO 8601 in a text column) that preserves the original Tempo shape byte-for-byte for callers who need perfect round-trip.
mix test runs the unit tests in-process (no DB). To exercise the integration tests in test/db_test.exs, configure config/test.exs to point at a PostgreSQL 14+ instance:
config :ex_tempo_sql, Tempo.SQL.Repo,
adapter: Ecto.Adapters.Postgres,
database: "tempo_sql_test",
username: "...",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
priv: "test/support"mix test is aliased to run ecto.drop / ecto.create / ecto.migrate before the suite.
Apache-2.0. See LICENSE.md.