Dates and intervals
Truncating, extracting, and an interval type that refuses to lie about months.
Interval is not a Duration#
time.Duration is a count of nanoseconds. A PostgreSQL interval is three
independent parts — months, days and microseconds — and it keeps them apart
because they are not convertible:
- a month is 28 to 31 days
- a day is 23, 24 or 25 hours across a daylight-saving boundary
iv := orm.IntervalOf(months, days, micros)
iv := orm.IntervalFromDuration(90 * time.Minute) // no months, no daysConverting back only works when there is nothing calendar-shaped in it:
d, err := iv.Duration()
if errors.Is(err, orm.ErrCalendarInterval) {
// months or days are present; what they mean depends on when they start,
// and the library will not pick 30 days on your behalf
}That error is the whole design. A library that silently returned 720 hours for one month would be right most of the time and wrong at every month boundary.
Arithmetic#
orm.AddInterval(Events.At, orm.Val(iv)) // timestamp + interval
orm.SubInterval(Events.At, orm.Val(iv))
orm.IntervalPlus(a, b) // interval + interval
orm.IntervalMinus(a, b)
orm.IntervalTimes(a, 3) // interval * nEach has a …Null form for nullable inputs, because arithmetic with NULL is
NULL.
Truncating#
orm.DateTrunc(orm.Month, Events.At) // date_trunc('month', at)
orm.DateTrunc(orm.Day, Events.At)
orm.DateTrunc(orm.Hour, Events.At)The classic use is grouping a time series into buckets:
bucket := orm.DateTrunc(orm.Day, Events.At)
var perDay = orm.Project2(
bucket, orm.Count[Event](),
func(day time.Time, n int64) Bucket { return Bucket{day, n} },
)
orm.Select(db.Events, perDay).
Where(Events.At.Gte(since)).
GroupBy(bucket).
OrderBy(bucket.Asc()).
All(ctx)Group by the same expression you selected. Two DateTrunc calls with the
same arguments render the same SQL, so PostgreSQL matches them — but binding it
to a variable says so, and reads better.
Extracting#
orm.Extract(orm.Year, Events.At, orm.Integer) // -> int32
orm.Extract(orm.DayOfWeek, Events.At, orm.Integer) // 0 = Sunday
orm.Extract(orm.EpochSecond, Events.At, orm.BigInt) // -> int64The third argument is the type you want back, as a PGType value. PostgreSQL's
extract returns numeric, so something has to say what to cast it to, and
saying it here means the Go type is decided rather than asserted.
The fields:
orm.Year orm.Quarter orm.Month orm.Week orm.Day
orm.Hour orm.Minute orm.Second
orm.DayOfWeek orm.DayOfYear orm.EpochSecondComparing#
Timestamps are ordered columns, so the ordinary predicates apply:
db.Events.Query().Where(Events.At.Between(dayStart, dayEnd))
db.Events.Query().Where(Events.At.Gte(cutoff))
db.Events.Query().OrderBy(Events.At.Desc())For "within the last N", compute the boundary in Go rather than in SQL when you can — a bind parameter is a better plan input than an expression the planner has to evaluate per row.
Worked examples#
A daily signup chart#
day := orm.DateTrunc(orm.Day, Accounts.CreatedAt)
var perDay = orm.Project2(
day, orm.Count[Account](),
func(d time.Time, n int64) Point { return Point{d, n} },
)
rows, err := orm.Select(db.Accounts, perDay).
Where(Accounts.CreatedAt.Gte(since)).
GroupBy(day).
OrderBy(day.Asc()).
All(ctx)Monthly is the same query with one word changed, which is the point of naming the bucket.
Opening hours#
hour := orm.Extract(orm.Hour, Visits.At, orm.Integer)
dow := orm.Extract(orm.DayOfWeek, Visits.At, orm.Integer)
var heat = orm.Project3(
dow, hour, orm.Count[Visit](),
func(d, h int32, n int64) Cell { return Cell{d, h, n} },
)
orm.Select(db.Visits, heat).GroupBy(dow, hour).All(ctx)A trial that expires#
// Trials ending in the next three days.
soon := time.Now().Add(72 * time.Hour)
db.Trials.Query().Where(Trials.EndsAt.Between(time.Now(), soon))
// Extending one, in SQL, without reading it first.
db.Trials.Update().
Set(Trials.EndsAt.SetExpr(orm.AddInterval(Trials.EndsAt, orm.Val(orm.IntervalOf(0, 14, 0))))).
Where(Trials.ID.Eq(id)).
Exec(ctx)IntervalOf(0, 14, 0) is fourteen days, not 336 hours. Across a
daylight-saving boundary those are different instants, and the interval keeps
the distinction that a Duration would throw away.