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, "~> 1.1"},
{: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+.

Requires Erlang/OTP 27 or later, inherited from ex_tempo — OTP 26 miscompiles Tempo's ISO 8601 tokenizer, so it refuses to build there. Elixir 1.17 and later are supported.

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.