Диапазоны и мультидиапазоны
Промежуток значений вместе с границами, а не две колонки, притворяющиеся одной.
Зачем нужен отдельный тип#
Пара концов не скажет, включена граница, исключена или бесконечна. [1,10) и
(1,10] содержат разные числа, а двум int-колонкам lo и hi негде записать,
что именно имелось в виду.
Range[T] несёт всю модель: два значения и два вида границ, плюс пустой
диапазон — а это не то же самое, что диапазон нулевой ширины.
Как построить#
orm.Closed(1, 10) // [1,10] оба конца включены
orm.ClosedOpen(1, 10) // [1,10) обычный вариант для дат и времени
orm.OpenClosed(1, 10) // (1,10]
orm.RangeFrom(t) // [t,) без верхней границы
orm.RangeUntil(t) // (,t) без нижней границы
orm.UnboundedRange[int]() // (,)
orm.EmptyRange[int]() // пустойNewRange — явная форма, когда границы вычисляются:
orm.NewRange(lo, orm.BoundInclusive, hi, orm.BoundExclusive)Чтение обратно:
lo, loKind := r.LowerBound()
hi, hiKind := r.UpperBound()
if r.IsEmpty() { /* ... */ }Объявление колонки#
//orm:table public.bookings
type Booking struct {
ID int64 `orm:"pk,identity"`
During orm.Range[time.Time] `orm:"pgtype:tstzrange"`
Prices *orm.Range[int32] `orm:"pgtype:int4range"`
}Какой именно тип у Range[time.Time] — daterange, tsrange или tstzrange —
берётся из каталога, а не угадывается по Go-типу, поэтому его называет тег.
Запросы#
db.Bookings.Query().Where(Bookings.During.Overlaps(r)) // &&
db.Bookings.Query().Where(Bookings.During.Contains(t)) // @> значение
db.Bookings.Query().Where(Bookings.During.ContainsRange(r)) // @> диапазон
db.Bookings.Query().Where(Bookings.During.ContainedBy(r)) // <@
db.Bookings.Query().Where(Bookings.During.Adjacent(r)) // -|-
db.Bookings.Query().Where(Bookings.During.StrictlyLeftOf(r)) // <<
db.Bookings.Query().Where(Bookings.During.StrictlyRightOf(r)) // >>
db.Bookings.Query().Where(Bookings.During.NotLeftOf(r)) // &>
db.Bookings.Query().Where(Bookings.During.NotRightOf(r)) // &<Contains принимает значение, ContainsRange — диапазон. В PostgreSQL это
разные операторы, и здесь это разные методы, поэтому вы получаете именно тот,
который имели в виду.
Сравнение с другой колонкой той же сущности:
Bookings.During.OverlapsCol(Bookings.Requested)
Bookings.During.ContainsCol(Bookings.Requested)Чтение границ в SQL#
Bookings.During.Lower() // lower(during) -> *T
Bookings.During.Upper() // upper(during) -> *T
Bookings.During.LowerInc() // lower_inc(during) -> bool
Bookings.During.LowerInf() // lower_inf(during) -> bool
Bookings.During.IsEmpty() // isempty(during) -> boolLower и Upper nullable, потому что у бесконечного конца нет значения.
Чтобы фильтровать по пустоте, используйте форму-предикат, а не сравнение значения:
db.Bookings.Query().Where(Bookings.During.IsEmptyIs(true))Мультидиапазоны#
Мультидиапазон — это упорядоченный набор непересекающихся диапазонов: то, что получается при объединении двух диапазонов, которые не соприкасаются.
//orm:table public.schedules
type Schedule struct {
Free orm.Multirange[time.Time] `orm:"pgtype:tstzmultirange"`
}Schedules.Free.Contains(t) // значение
Schedules.Free.ContainsRange(r) // один диапазон
Schedules.Free.ContainsMultirange(m) // целый мультидиапазон
Schedules.Free.Overlaps(m)
Schedules.Free.OverlapsRange(r)
Schedules.Free.Merge() // range_merge -> Range[T]
Schedules.Free.IsEmpty()Merge схлопывает мультидиапазон в один охватывающий диапазон — наименьший,
содержащий все элементы вместе с промежутками.
Что канонизирует PostgreSQL#
Дискретные диапазоны — int4range, int8range, daterange — возвращаются в
канонической форме, поэтому [1,10] приходит как [1,11). Мультидиапазоны
канонизируются все. Это нормализация сервера, а не пакета, и вы читаете именно
те значения, которые он держит.
Разобранные примеры#
Переговорная#
Двойное бронирование — это один предикат, а не пара сравнений, которые надо не перепутать:
wanted := orm.ClosedOpen(start, end)
clash, err := db.Bookings.Query().
Where(Bookings.RoomID.Eq(roomID)).
Where(Bookings.During.Overlaps(wanted)).
Exists(ctx)ClosedOpen — правильная форма для времени: бронь, кончающаяся в 10:00, и бронь,
начинающаяся в 10:00, не пересекаются, и [start, end) говорит именно это.
Цена с окном действия#
Цена, действующая на дату, и строки, у которых конца ещё нет:
current, err := db.Tariffs.Query().
Where(Tariffs.ProductID.Eq(id)).
Where(Tariffs.Valid.Contains(on)).
One(ctx)
open, err := db.Tariffs.Query().
Where(Tariffs.Valid.Overlaps(orm.RangeFrom(time.Now()))).
All(ctx)График смен#
Где покрытие кончается, а следующая смена ещё не началась, — смежность и промежутки:
// Смены, которые соприкасаются, но не пересекаются.
db.Shifts.Query().Where(Shifts.Hours.Adjacent(other))
// Всё, что целиком раньше границы.
db.Shifts.Query().Where(Shifts.Hours.StrictlyLeftOf(orm.RangeFrom(cutoff)))
// Границы, прочитанные в SQL.
var span = orm.Project2(
Shifts.Hours.Lower(), Shifts.Hours.Upper(),
func(from, to *time.Time) Span { return Span{from, to} },
)Обе границы nullable, потому что у открытой смены там нет значения.
Доступность как мультидиапазон#
// Какое-то из свободных окон целиком покрывает приём.
db.Calendars.Query().Where(Calendars.Free.ContainsRange(appointment))
// Промежуток от первой свободной минуты до последней, вместе с дырами.
var span = orm.Project1(
Calendars.Free.Merge(),
func(r orm.Range[time.Time]) orm.Range[time.Time] { return r },
)