Сложные запросы
Те, ради которых обычно берутся за сырой SQL, — собранные, типизированные и по-прежнему одним запросом.
Каждый из них — один SQL-запрос с одним списком параметров. Ни один не склеен из строк.
Здесь используется API композиции, а не запрос по сущности, потому что нужно именно оно: производная таблица, CTE, LATERAL, операция над множествами. Словарь небольшой и повторяется. orm.Rows перечисляет колонки, которые отдаёт подзапрос, orm.Named даёт одной из них имя, orm.Sub превращает это в производную таблицу, orm.Ref читает именованную колонку обратно, а orm.Cond поднимает предикат по сущности в область составного запроса. Всё дальше — эти пять вещей и соединения.
Первые N в каждой группе#
Классика. Оконная функция внутри производной таблицы, фильтрация снаружи — потому что оконная функция не может стоять в WHERE.
rank := orm.Named("rn", orm.RowNumber().
PartitionBy(orm.Of(Orders.UserID)).
OrderBy(orm.Of(Orders.Placed).Desc()))
ranked := orm.Sub("ranked", orm.Rows(
orm.Named("id", orm.Of(Orders.ID)),
orm.Named("user_id", orm.Of(Orders.UserID)),
orm.Named("placed", orm.Of(Orders.Placed)),
rank,
).From(Orders.Source()))
rows, err := orm.Compose(pool, shape).
From(ranked).
Where(orm.Ref(ranked, rank).Lte(3)).
OrderBy(orm.Ref(ranked, userID).Asc()).
All(ctx)То же самое через LATERAL, что часто быстрее#
Когда родительское множество невелико, а у дочерней таблицы есть индекс по ключу соединения, LATERAL выигрывает у ранжирования всей дочерней таблицы с последующим выбрасыванием почти всего:
top := orm.Sub("top", orm.Rows(
orm.Named("id", orm.Of(Orders.ID)),
orm.Named("placed", orm.Of(Orders.Placed)),
).From(Orders.Source()).
Where(orm.Eq(Orders.UserID, Users.ID)).
OrderBy(orm.Of(Orders.Placed).Desc()).
Limit(3))
orm.Compose(pool, shape).From(Users.Source()).LeftJoinLateral(top)Два плана на один вопрос. Измеряйте, а не предполагайте — Explain под рукой.
Накопительный итог#
running := orm.Named("running", orm.SumInt64[orm.Composed, int64](orm.Of(Orders.Total)).Over(orm.Window().
OrderBy(orm.Of(Orders.Placed).Asc()).
Rows(orm.UnboundedPreceding(), orm.CurrentRow())))Накопительный итог, сбрасывающийся каждый месяц#
month := orm.DateTrunc(orm.Month, Orders.Placed)
perMonth := orm.Named("mtd", orm.SumInt64[orm.Composed](orm.Of(Orders.Total)).Over(orm.Window().
PartitionBy(month).
OrderBy(orm.Of(Orders.Placed).Asc()).
Rows(orm.UnboundedPreceding(), orm.CurrentRow())))Сброс — это раздел. Больше ничего не меняется.
Баланс после каждой проводки#
Форма, которая нужна банковской выписке: каждая строка несёт баланс на себя саму:
balance := orm.Named("balance", orm.SumInt64[orm.Composed](orm.Of(Entries.Cents)).Over(orm.Window().
PartitionBy(orm.Of(Entries.AccountID)).
OrderBy(orm.Of(Entries.At).Asc(), orm.Of(Entries.ID).Asc()).
Rows(orm.UnboundedPreceding(), orm.CurrentRow())))ID в сортировке здесь не для красоты. Две проводки в одну микросекунду иначе
получат порядок, который выбрал план, а выписка, где балансы меняются от запуска
к запуску, хуже, чем просто неверная.
Разрывы и острова#
Непрерывные периоды активности, найденные по разнице между номером строки и датой.
grp := orm.Named("grp", orm.Sub(
orm.Of(Events.Day),
orm.RowNumber().OrderBy(orm.Of(Events.Day).Asc()),
))
islands := orm.Sub("islands", orm.Rows(
orm.Named("day", orm.Of(Events.Day)),
grp,
).From(Events.Source()))
// then group by grp and take min(day), max(day)Длина серии, числом#
Группировка островов даёт длины серий — серия входов, окно бесперебойной работы, число дней подряд с отгрузками:
orm.Compose(pool, streaks).
From(islands).
GroupBy(orm.Ref(islands, grp)).
Having(orm.Count[orm.Composed]().Gte(3)).
OrderBy(orm.Ref(islands, grp).Asc())Разрывы: периоды, когда ничего не происходило#
Обратная сторона той же идеи: каждая строка в паре с предыдущей и расстояние между ними:
prev := orm.Named("prev", orm.Lag(Readings.At).Over(orm.Window().
PartitionBy(orm.Of(Readings.SensorID)).
OrderBy(orm.Of(Readings.At).Asc())))
gaps := orm.Sub("gaps", orm.Rows(
orm.Named("sensor_id", orm.Of(Readings.SensorID)),
orm.Named("at", orm.Of(Readings.At)),
prev,
).From(Readings.Source()))Датчик, который должен отчитываться каждую минуту и имеет двухчасовой разрыв, — это неисправность, и вот запрос, который её находит, не вытягивая в Go годовой объём измерений.
Рекурсивная иерархия#
Оргструктура любой глубины, одним запросом:
anchor := orm.Rows(
orm.Named("id", orm.Of(Employees.ID)),
orm.Named("manager_id", orm.Opt(Employees.ManagerID)),
orm.Named("depth", orm.Val(0)),
).From(Employees.Source()).Where(orm.Cond(Employees.ManagerID.IsNull()))
tree := orm.RecursiveCTE("tree", anchor, func(self *orm.Source) orm.Term {
return orm.Rows(
orm.Named("id", orm.Of(Employees.ID)),
orm.Named("manager_id", orm.Opt(Employees.ManagerID)),
orm.Named("depth", orm.Ref(self, depth).Add(1)),
).From(Employees.Source()).
Join(self, orm.Eq(Employees.ManagerID, orm.Ref(self, id)))
})UNION встречается здесь и только здесь: грамматика PostgreSQL требует его между якорем рекурсивного CTE и рекурсивной частью. Это не общая операция над множествами, она — UNION ALL.
Всё, что лежит под одним узлом#
Тот же обход, начатый не от корня: поддерево, папка, ветка комментариев:
anchor := orm.Rows(
orm.Named("id", orm.Of(Categories.ID)),
orm.Named("parent_id", orm.Opt(Categories.ParentID)),
).From(Categories.Source()).Where(orm.Cond(Categories.ID.Eq(root)))Материализованный путь, собираемый по дороге вниз#
Чтобы «хлебные крошки» не стоили по запросу на уровень:
tree := orm.RecursiveCTE("tree", anchor, func(self *orm.Source) orm.Term {
return orm.Rows(
orm.Named("id", orm.Of(Categories.ID)),
orm.Named("path", orm.Concat(orm.Ref(self, path), orm.Val(" / "), orm.Of(Categories.Name))),
).From(Categories.Source()).
Join(self, orm.Eq(Categories.ParentID, orm.Ref(self, id)))
})Спецификация изделия, с умножением количеств вниз#
Каждый шаг рекурсии умножает на количество родителя, поэтому число у листа — это то, что действительно нужно закупить:
tree := orm.RecursiveCTE("bom", anchor, func(self *orm.Source) orm.Term {
return orm.Rows(
orm.Named("part_id", orm.Of(Assemblies.ChildID)),
orm.Named("qty", orm.Ref(self, qty).Mul(orm.Of(Assemblies.Qty))),
).From(Assemblies.Source()).
Join(self, orm.Eq(Assemblies.ParentID, orm.Ref(self, partID)))
})Коррелированный подзапрос в списке выборки#
Самый свежий заказ каждого пользователя, без соединения:
last := orm.Scalar[User, time.Time](
db.Orders.Query().
Where(orm.Eq(Orders.UserID, Users.ID)).
OrderBy(Orders.Placed.Desc()).
Limit(1),
)
var shape = orm.Project2(
Users.Email, last,
func(email string, at *time.Time) Row { return Row{email, at} },
)Скалярный подзапрос всегда nullable, потому что «ни одной строки» даёт NULL. Тип это и говорит.
Счётчик рядом с каждой строкой#
n := orm.Scalar[User, int64](
db.Orders.Query().Where(orm.Eq(Orders.UserID, Users.ID)),
)Удобно — и по одному подзапросу на строку. Когда список длинный, один GROUP BY
с соединением даёт тот же ответ дешевле: эта форма оправдана на странице из
двадцати строк, а не на выгрузке из двухсот тысяч.
Два коррелированных значения без двух подзапросов#
stats := orm.Sub("stats", orm.Rows(
orm.Named("user_id", orm.Of(Orders.UserID)),
orm.Named("n", orm.Count[orm.Composed]()),
orm.Named("last", orm.Max(Orders.Placed)),
).From(Orders.Source()).GroupBy(orm.Of(Orders.UserID)))
orm.Compose(pool, shape).
From(Users.Source()).
LeftJoin(stats, orm.Eq(Users.ID, orm.Ref(stats, userID)))Антисоединение, двумя способами#
// NOT EXISTS — usually the planner's favourite
db.Users.Query().Where(orm.NotExists(
db.Orders.Query().Where(orm.Eq(Orders.UserID, Users.ID)),
))
// LEFT JOIN ... IS NULL, when you also want columns from the right side
orm.Compose(pool, shape).
From(Users.Source()).
LeftJoin(Orders.Source(), orm.Eq(Orders.UserID, Users.ID)).
Where(orm.Opt(Orders.ID).IsNull())Полусоединение: родители, у которых есть хотя бы одно совпадение, по разу каждый#
db.Users.Query().Where(orm.Exists[User](
db.Orders.Query().Where(orm.And(
orm.Eq(Orders.UserID, Users.ID),
Orders.Status.Eq("paid"),
)),
))Соединение дало бы по строке на каждый подходящий заказ. EXISTS даёт по строке
на пользователя — а это и значит «пользователи, которые платили».
Почему NOT IN — тот, которого стоит избегать#
db.Users.Query().Where(orm.NotExists[User](
db.Blocks.Query().Where(orm.Eq(Blocks.UserID, Users.ID)),
))Если подзапрос NOT IN вернёт хотя бы один NULL, весь результат окажется пуст —
молча и только в те дни, когда NULL в данных есть. У NOT EXISTS такого люка
нет, поэтому на этой странице показан он, а не первый.
LATERAL#
Два самых свежих заказа каждого пользователя: подзапрос коррелирует со строкой слева от него:
recent := orm.Sub("recent", orm.Rows(
orm.Named("id", orm.Of(Orders.ID)),
orm.Named("placed", orm.Of(Orders.Placed)),
).From(Orders.Source()).
Where(orm.Cond(orm.Eq(Orders.UserID, Users.ID))).
OrderBy(orm.Of(Orders.Placed).Desc()).
Limit(2))
orm.Compose(pool, shape).
From(Users.Source()).
LeftJoinLateral(recent)Агрегат на строку, который соединением не выразить#
Траты каждого клиента в окне, своём для каждой строки:
window := orm.Sub("window", orm.Rows(
orm.Named("spent", orm.SumInt64[orm.Composed](orm.Of(Orders.Total))),
).From(Orders.Source()).Where(orm.Cond(orm.And(
orm.Eq(Orders.UserID, Users.ID),
Orders.Placed.Between(from, to),
))))
orm.Compose(pool, shape).From(Users.Source()).LeftJoinLateral(window)Внутренний LATERAL, чтобы отбросить строки без совпадений#
orm.Compose(pool, shape).From(Users.Source()).JoinLateral(recent)LeftJoinLateral оставляет пользователей без заказов и даёт им NULL.
JoinLateral их отбрасывает. Выбор ровно тот же, что и у обычного соединения.
Разворот в колонки#
Счётчики по статусам — колонками, а не строками:
var pivot = orm.Project3(
Orders.UserID,
orm.Count[Order]().Filter(Orders.Status.Eq("paid")),
orm.Count[Order]().Filter(Orders.Status.Eq("refunded")),
func(id int64, paid, refunded int64) Pivot { return Pivot{id, paid, refunded} },
)
orm.Select(db.Orders, pivot).GroupBy(Orders.UserID)FILTER здесь лучше CASE WHEN: он говорит то, что имеется в виду, и планировщик читает его лучше.
Суммы по корзинам, а не только счётчики#
var revenue = orm.Project4(
Sales.Region,
orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("web")),
orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("retail")),
orm.SumInt32(Sales.Cents).Filter(Sales.Channel.Eq("partner")),
func(region string, web, retail, partner *int64) Revenue {
return Revenue{region, web, retail, partner}
},
)Набор колонок фиксирован на этапе компиляции, и это честное ограничение: SQL не умеет выдавать набор колонок, зависящий от данных, и типизированный API тоже не умеет. Если корзины выясняются во время выполнения, ответ — строки, а разворот происходит на стороне потребителя.
Композиция множеств поверх разных источников#
Таблица, представление и материализованное представление в одном результате — законно, потому что проекции совпадают:
shape := orm.Project2(
orm.Of(Users.ID), orm.Of(Users.Email),
func(id uuid.UUID, email string) Row { return Row{id, email} },
)
email := orm.Named("email", orm.Of(Users.Email))
rows, err := orm.UnionAll[Row](
orm.Compose(pool, shape).From(Users.Source()),
orm.Compose(pool, shape).From(ActiveUsers.Source()),
orm.Compose(pool, shape).From(UserSummaries.Source()),
).OrderBy(email.Asc()).Limit(50).All(ctx)Никакой особой обработки по виду источника. Источник для чтения — это источник для чтения.
Объединённая лента событий#
Три таблицы, у которых общего — только отметка времени и метка:
rows, err := orm.UnionAll[Item](
orm.Compose(pool, feed).From(Posts.Source()),
orm.Compose(pool, feed).From(Comments.Source()),
orm.Compose(pool, feed).From(Follows.Source()),
).OrderBy(at.Desc()).Limit(50).All(ctx)ORDER BY и LIMIT применяются к объединению, а не к ветви: одна отсортированная
лента, а не три отсортированных списка подряд.
Строки, которые есть в одной таблице и нет в другой, в обе стороны#
orm.UnionAll[Diff](
orm.Compose(pool, diff).From(Expected.Source()).Where(orm.NotExists[orm.Composed](
orm.Compose(pool, one).From(Actual.Source()).Where(orm.Eq(Actual.Key, Expected.Key)))),
orm.Compose(pool, diff).From(Actual.Source()).Where(orm.NotExists[orm.Composed](
orm.Compose(pool, one).From(Expected.Source()).Where(orm.Eq(Expected.Key, Actual.Key)))),
)Сверка — чего не хватает и что лишнее — одним запросом.
Самосоединение через псевдоним#
mgr := Employees.As("mgr")
orm.Compose(pool, shape).
From(Employees.Source()).
LeftJoin(mgr.Source(), orm.Eq(mgr.ID, Employees.ManagerID))As возвращает второй источник, и дескриптор, построенный от одного, нельзя применить к другому. Именно это делает самосоединение безопасным, а не просто упражнением в именовании.
Поиск дублей сравнением таблицы с собой#
other := Contacts.As("other")
orm.Compose(pool, pairs).
From(Contacts.Source()).
Join(other.Source(), orm.And(
orm.Eq(Contacts.Email, other.Email),
orm.Cond(Contacts.ID.Lt(other.ID)),
))Lt — то, что не даёт каждой паре появиться дважды, а каждой строке совпасть с самой собой.
Дедупликация#
Оставить самую свежую строку по ключу#
rank := orm.Named("rn", orm.RowNumber().
PartitionBy(orm.Of(Imports.ExternalID)).
OrderBy(orm.Of(Imports.SeenAt).Desc()))
deduped := orm.Sub("deduped", orm.Rows(
orm.Named("id", orm.Of(Imports.ID)),
orm.Named("external_id", orm.Of(Imports.ExternalID)),
rank,
).From(Imports.Source()))
orm.Compose(pool, shape).From(deduped).Where(orm.Ref(deduped, rank).Eq(1))Или через DISTINCT ON, что короче#
orm.Compose(pool, shape).
From(Imports.Source()).
DistinctOn(orm.Of(Imports.ExternalID)).
OrderBy(orm.Of(Imports.ExternalID).Asc(), orm.Of(Imports.SeenAt).Desc())Ответ тот же. Вариант с оконной функцией переносится на другие базы; этот быстрее и говорит то, что имеется в виду. Поскольку библиотека всё равно только под PostgreSQL, предпочитайте его.
Удалить дубли, а не отфильтровывать их#
db.Imports.Delete().Where(orm.InSub(
Imports.ID,
orm.Compose(pool, ids).From(deduped).Where(orm.Ref(deduped, rank).Gt(1)),
)).Exec(ctx)Аналитические формы#
Гистограмма#
bucket := orm.Named("bucket", orm.Fn[orm.Composed, int32]("width_bucket",
orm.ArgOf(Response.Millis), orm.ArgValue(0), orm.ArgValue(1000), orm.ArgValue(10)))
orm.Compose(pool, histogram).
From(Response.Source()).
GroupBy(bucket).
OrderBy(bucket.Asc())Когортное удержание#
cohort := orm.Named("cohort", orm.DateTrunc(orm.Month, Users.CreatedAt))
active := orm.Named("active", orm.DateTrunc(orm.Month, Events.At))
orm.Compose(pool, retention).
From(Users.Source()).
Join(Events.Source(), orm.Eq(Events.UserID, Users.ID)).
GroupBy(cohort, active).
OrderBy(cohort.Asc(), active.Asc())Два округления и группировка. Сетка, которую образует результат, и есть когортная таблица, а разворот в колонки — дело потребителя.
Ближайшие по расстоянию#
distance := postgis.OfGeog(Stops.Spot).Distance(postgis.GeogValue[Stop](here))
orm.Select(db.Stops, nearest).
Where(postgis.OfGeog(Stops.Spot).DWithin(postgis.GeogValue[Stop](here), 2000)).
OrderBy(distance.Asc()).
Limit(5)DWithin перед сортировкой — то, что позволяет индексу сделать работу.
Запись, составными запросами#
Удалить и прочитать удалённое, одним запросом#
archived := orm.WritingCTE("archived", db.Events.Delete().
Where(Events.At.Lt(cutoff)))
orm.Compose(pool, shape).With(archived).From(archived)Сделать это двумя запросами — значит дать строкам измениться между ними.
Узнать, какие строки действительно изменились#
changed, err := orm.UpdateReturning(
db.Prices.Update().
Set(Prices.Cents.Set(cents)).
Where(Prices.SKU.Eq(sku)).
Where(Prices.Cents.Ne(cents)),
priceShape,
).All(ctx)Хитрость во втором Where: обновление, которое ничего бы не изменило, ни во что
не попадает, поэтому вернувшиеся строки — ровно те, что сдвинулись. Именно по
этому множеству стоит публиковать события.
Пакетный upsert целого набора#
err := db.Tx(ctx, func(tx *domain.DB) error {
for chunk := range slices.Chunk(rows, 1000) {
if _, err := tx.Prices.InsertMany(ctx, chunk,
orm.OnConflict(Prices.SKU).DoUpdate(Prices.Amount),
); err != nil {
return err
}
}
return nil
})Прочитать план, прежде чем чему-либо здесь верить#
plan, err := q.Explain(ctx) // EXPLAIN, never runs the statement
plan, err := q.ExplainAnalyze(ctx) // runs it, and the name says so
report, err := q.PerformanceReport(ctx) // plan, shape and fingerprintИмена различаются, потому что опасно различается поведение. Здесь ничего не советуется: планирует PostgreSQL, а решение о том, что менять, требует всей нагрузки, а не одного запроса.
Сравнить два написания одного вопроса#
a, _ := withNotExists.Explain(ctx)
b, _ := withLeftJoin.Explain(ctx)У антисоединения выше две формы, и эта страница отказывается говорить, какая быстрее: это зависит от ваших объёмов и ваших индексов. Вот как выяснить — на своих данных, примерно за минуту.
Проверить, что SQL — тот самый#
sql, args, err := q.SQL()В эту строку не подставлено ни одно значение: аргументы возвращаются рядом, и это ровно то, что получает сервер.