К содержимому

Оконные функции

Ранжирование, соседние строки и накопительные итоги — по строке, не схлопывая их.

Что они делают#

Агрегат схлопывает строки: count(*) по десяти строкам вернёт одну. Оконная функция считает по строкам и сохраняет каждую — у каждой строки свой ответ, вычисленный по строкам вокруг неё.

go
rn := orm.RowNumber().Over(orm.Window().
    PartitionBy(orm.Of(Posts.AuthorID)).
    OrderBy(orm.Of(Posts.CreatedAt).Desc()))
sql
row_number() OVER (PARTITION BY author_id ORDER BY created_at DESC)

Читается так: начинать нумерацию заново для каждого автора и внутри автора сортировать от новых к старым.

Две половины#

Оконная функция — это всегда функция плюс окно:

go
orm.RowNumber().Over(orm.Window()...)
//  └ функция            └ окно, через которое она смотрит

Окно строит orm.Window():

МетодДобавляет
PartitionBy(...)PARTITION BY — начинать заново для каждой группы
OrderBy(...)ORDER BY — порядок внутри секции
Rows(start, end)рамку ROWS
Range(start, end)рамку RANGE
Groups(start, end)рамку GROUPS

Любое можно опустить. Over(orm.Window()) без настроек — одно окно на весь результат.

Как её использовать#

Оконная функция — это выражение, поэтому она идёт в проекцию как любое другое:

go
type Ranked struct {
    Title string
    N     int64
}

rn := orm.RowNumber().Over(orm.Window().
    PartitionBy(orm.Of(Posts.AuthorID)).
    OrderBy(orm.Of(Posts.CreatedAt).Desc()))

shape := orm.Project2(
    orm.Of(Posts.Title), rn,
    func(title string, n int64) Ranked { return Ranked{title, n} },
)

rows, err := orm.Compose(pool, shape).From(Posts.Source()).All(ctx)

Функции#

Ранжирование#

go
orm.RowNumber()    // 1, 2, 3, 4        -> int64
orm.Rank()         // 1, 2, 2, 4        -> int64  (при равенстве делят и пропускают)
orm.DenseRank()    // 1, 2, 2, 3        -> int64  (делят без пропуска)
orm.PercentRank()  // 0.0 … 1.0         -> float64
orm.CumeDist()     // накопленная доля  -> float64
orm.Ntile(4)       // номер квартиля    -> int32

Rank и DenseRank различаются только тем, что происходит после равенства, — и именно это чаще всего понимают неправильно: Rank оставляет дыру, DenseRank нет.

Доступ к другим строкам#

go
orm.Lag(Posts.Score)         // значение предыдущей строки
orm.LagN(Posts.Score, 3)     // на три строки назад
orm.Lead(Posts.Score)        // значение следующей строки
orm.LeadN(Posts.Score, 3)
orm.FirstValue(Posts.Score)  // первое в рамке
orm.LastValue(Posts.Score)   // последнее в рамке
orm.NthValue(Posts.Score, 2) // второе

Все они возвращают nullable-форму типа колонки. У первой строки нет предыдущей, у последней нет следующей — поэтому Lag по NOT NULL колонке всё равно *T, и тип это говорит, вместо того чтобы дать NULL прийти туда, где его негде держать.

Агрегаты как оконные функции#

Любой агрегат становится оконной функцией через Over:

go
running := orm.SumInt64[Order, int64](Orders.Total).Over(orm.Window().
    OrderBy(orm.Of(Orders.Placed).Asc()).
    Rows(orm.UnboundedPreceding(), orm.CurrentRow()))
sql
sum(total) OVER (ORDER BY placed ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

Это накопительный итог: каждая строка видит себя и всё, что было до неё.

Рамки#

Рамка сужает набор строк секции, которые видит функция. Границы:

go
orm.UnboundedPreceding()  // начало секции
orm.Preceding(3)          // на три строки назад
orm.CurrentRow()
orm.Following(3)
orm.UnboundedFollowing()  // конец секции
go
// скользящее среднее по семи строкам
orm.AvgInt64[Order, float64](Orders.Total).Over(orm.Window().
    OrderBy(orm.Of(Orders.Placed).Asc()).
    Rows(orm.Preceding(6), orm.CurrentRow()))

Rows считает строки. Range считает по значению, поэтому равные по ORDER BY попадают вместе. Groups считает группы равных. На данных с повторами это разные ответы — поэтому есть все три, а не одна.

Куда оконную функцию поставить нельзя#

Ни в WHERE, ни в HAVING. PostgreSQL вычисляет окна после этих конструкций, поэтому значения там ещё не существует.

Чтобы отфильтровать по нему, вычислите его в производной таблице и фильтруйте снаружи — это и есть рецепт Top-N:

go
rank := orm.Named("rn", orm.RowNumber().Over(orm.Window().
    PartitionBy(orm.Of(Orders.UserID)).
    OrderBy(orm.Of(Orders.Placed).Desc())))

ranked := orm.Sub("ranked", orm.Rows(
    orm.Named("id", orm.Of(Orders.ID)),
    rank,
).From(Orders.Source()))

rows, err := orm.Compose(pool, shape).
    From(ranked).
    Where(orm.Ref(ranked, rank).Lte(3)).   // три последних заказа каждого
    All(ctx)

Целиком этот рецепт — в разделе Сложные запросы.

Разобранные примеры#

Таблица лидеров с учётом равенства#

go
w := orm.Window().OrderBy(orm.Of(Scores.Points).Desc())

var board = orm.Project3(
    orm.Of(Scores.Player),
    orm.Rank().Over(w),        // 1, 2, 2, 4 — равенство оставляет дыру
    orm.DenseRank().Over(w),   // 1, 2, 2, 3 — не оставляет
    func(p string, r, d int64) Row { return Row{p, r, d} },
)

orm.Compose(pool, board).From(Scores.Source()).All(ctx)

Какой вариант верен, зависит от того, должно ли существовать «третье место», когда двое делят второе. Это продуктовое решение, и две функции позволяют его принять.

Изменение с прошлого показания#

go
w := orm.Window().
    PartitionBy(orm.Of(Meters.MeterID)).
    OrderBy(orm.Of(Meters.ReadAt).Asc())

previous := orm.Lag(Meters.Value).Over(w)

var deltas = orm.Project3(
    orm.Of(Meters.MeterID), orm.Of(Meters.Value), previous,
    func(id int64, now int32, before *int32) Delta { return Delta{id, now, before} },
)

before — указатель, потому что у первого показания каждого счётчика позади ничего нет. Секционирование начинает отсчёт заново на каждом счётчике.

Нарастающий баланс#

go
running := orm.SumInt32[orm.Composed](orm.Of(Entries.AmountCents)).
    Over(orm.Window().
        PartitionBy(orm.Of(Entries.AccountID)).
        OrderBy(orm.Of(Entries.PostedAt).Asc()).
        Rows(orm.UnboundedPreceding(), orm.CurrentRow()))

Скользящее среднее за семь дней#

go
avg := orm.AvgInt64[orm.Composed, float64](orm.Of(Daily.Total)).
    Over(orm.Window().
        OrderBy(orm.Of(Daily.Day).Asc()).
        Rows(orm.Preceding(6), orm.CurrentRow()))

Rows(6 preceding, current) — это семь строк вместе с текущей. Range вместо него сгруппировал бы дни с равными значениями, а скользящее среднее означает не это.