Tempo SQL

Hex.pm

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 tstzrangeis 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.

Installation

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"}
]
end

Requires PostgreSQL 14 or later for tstzmultirange. tstzrange alone works on PostgreSQL 9.2+.

Quick start

1. Migration

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
end

2. Schema

defmodule MyApp.Meeting do
use Ecto.Schema
schema "meetings" do
field :name, :string
field :window, Tempo.Ecto.Interval
end
end

3. Insert and query

window = 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.

Ecto types

Plain range — span only, lossy round-trip:

TypeStoresPostgres column
Tempo.Ecto.Interval%Tempo.Interval{}tstzrange
Tempo.Ecto.IntervalSet%Tempo.IntervalSet{}tstzmultirange
Tempo.Ecto.Tempobare %Tempo{} (implicit span)tstzrange

Composite — full Tempo shape, byte-exact round-trip:

TypeStoresPostgres 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.

The :resolution field option

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
end

A 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.

Query API

Tempo.Ecto.QueryAPI gives you Allen-named fragments over Postgres range operators:

MacroPostgres operatorMeaning
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

Migration helpers

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 speed

All three are thin delegates — you can also write the raw add :window, :tstzrange form.

Storage contract

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:

What is storable

What is not storable (returns :error)

What is lossy on round-trip (round 1)

For the initial release, a stored value and a loaded value match only on their interval shape, not their metadata. Round-trip discards:

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.

Testing against a live database

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.

License

Apache-2.0. See LICENSE.md.