Drizzle relations API: подводные камни, которые ловят на проде
Relations API в Drizzle я потрогал ещё на бете и долго относился к нему с прохладцей: казалось, удобнее писать select с join руками, чем учить новый dsl. Спустя год работы с ним на двух проектах могу сказать: оно решает довольно много, но и подкидывает свои проблемы. В статье — про эти проблемы и про то, как мы их у себя обходим.
Если ты ещё не пользовался relations, кратко: это вариант query-API, в котором ты описываешь связи между таблицами один раз, а потом дёргаешь их через db.query.posts.findMany({ with: { author: true } }). Похоже на Prisma include, только тип результата считается без отдельного кодогена.
Как оно выглядит
import { relations } from 'drizzle-orm';
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id').notNull().references(() => users.id),
title: text('title').notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));Дальше клиент принимает schema, и можно писать:
const list = await db.query.posts.findMany({
with: { author: true },
limit: 20,
});Тип list[number] — это пост со вложенным автором. Без any, без ручной типизации. Очень удобно. А теперь — про то, где это ломается.
1. По умолчанию это N+1
Самая частая засада. findMany({ with: { author: true } }) в Drizzle не делает один SQL с join. До версии 0.30 он делает по запросу на связанную таблицу: один select на посты, потом для каждого автора отдельный select. На limit: 20 это два запроса (один по постам, один по авторам с where in (...)), но добавь ещё одну связь — и запросов уже три.
Никаких варнингов это не выдаёт, поэтому ловится только мониторингом или ручным просмотром логов. Я нашёл первую такую проблему по графику pg_stat_statements: запросов на одну страницу страницу было больше, чем хотелось бы.
Что делаем:
- Если связь many-to-one и используем её всегда, переписываем на обычный
select().leftJoin(). Получаем один SQL. - Если связь one-to-many с большим раскрытием (типа поста с тегами), оставляем relations API: разница на N+1 vs join тут не в пользу join, потому что join раздувает результат.
- Если хочется сохранить relations DX, но избежать дополнительных запросов — смотрим на новые опции
relationLoadStrategy: 'join', которые появились в свежих версиях. Они переключают режим на один SQL с lateral join. Поведение чуть иное, но для большинства списков подходит.
2. where внутри with не делает то, что кажется
const list = await db.query.users.findMany({
with: {
posts: {
where: (p, { gt }) => gt(p.id, 100),
limit: 5,
},
},
});Логично ожидать, что limit: 5 вернёт по 5 постов на каждого пользователя. И это действительно работает в режиме отдельных запросов. А вот при switch на relationLoadStrategy: 'join' ты получишь общий лимит по всему результату. Документация про это говорит, но люди читают её уже после провала.
У нас в команде заведено правило: если внутри with есть limit, мы помечаем такой код комментом // uses subquery strategy, чтобы при глобальном переключении стратегии загрузки не сломать поведение.
3. columns и extras не дружат
В query API есть удобный columns: { id: true, title: true }, который ограничивает выборку. Хочется добавить вычисляемое поле через extras — и тут начинаются нюансы:
await db.query.posts.findMany({
columns: { id: true, title: true },
extras: {
titleLen: sql<number>`length(${posts.title})`.as('title_len'),
},
});Если в columns ты не выбрал поле, на которое ссылается extras, SQL всё равно подтянет его, чтобы посчитать. Иногда это нормально, иногда — лишний трафик из БД. На больших таблицах с jsonb я ловил, что итоговый запрос неожиданно тащил весь объект, потому что extras ссылался на одно его свойство.
Правило, которое мы у себя записали: extras либо считаешь от уже выбранных в columns полей, либо явно дублируешь нужное поле в columns, чтобы было видно, что данные едут.
4. Имя связи матчится по точному совпадению
Если у тебя две связи между одной парой таблиц (например, author и editor у поста), Drizzle требует relationName. Иначе будет cryptic ошибка про неоднозначность.
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
relationName: 'post_author',
}),
editor: one(users, {
fields: [posts.editorId],
references: [users.id],
relationName: 'post_editor',
}),
}));
export const usersRelations = relations(users, ({ many }) => ({
authoredPosts: many(posts, { relationName: 'post_author' }),
editedPosts: many(posts, { relationName: 'post_editor' }),
}));Без relationName или с разными значениями на двух сторонах оно молча отрабатывает странно: один из вложенных запросов вернёт пусто. Тоже не падение, тоже находится только тестами.
5. Транзакции
Внутри db.transaction(async (tx) => { ... }) ты получаешь объект tx, у которого нет tx.query. Эта особенность была долго и часто всплывала. В свежих версиях tx.query добавили, но если у тебя более ранняя — будь готов писать query API через db.query и осознанно держать в голове, что эти запросы идут вне транзакции. Чаще всего это не то, что хочется.
Я для себя выбрал такую тактику: внутри транзакций пишу обычные select-builder через tx.select(). Они точно ходят через tx. db.query оставляю для read-only сценариев.
6. Ошибки поднимаются не в момент описания, а в момент запуска
Описал связь не в ту сторону, перепутал fields и references, забыл объявить relations для одной из таблиц — TypeScript часто ничего не подсветит. Ошибка вылезет на первом запросе, прямо в рантайме, в виде «relation X not found in schema». Спасает только тест на каждый relations-запрос или хоть один сценарий вида «ходим в БД и читаем» в e2e-смоук.
7. Документация рассинхронизирована с реальностью
Это общее наблюдение по Drizzle: relations API меняется довольно активно, и в доке встречаются примеры из старых версий. Перед тем как тащить незнакомый паттерн на прод, я открываю исходники node_modules/drizzle-orm/relations.d.ts и смотрю текущую сигнатуру. Минут пять, зато не приходится потом ловить «откуда у меня в типах undefined».
Когда я всё-таки беру relations
Несмотря на список выше, на новых проектах я с relations чаще соглашаюсь, чем нет. Они выручают в типичной админке, где много CRUD-страниц с подгрузкой связанных сущностей. Где их брать осторожнее: в горячих list-эндпоинтах, в отчётах, в местах с lazy-связями глубже двух уровней. Там я по-прежнему пишу join руками — это короче и предсказуемее.
Главный совет, который вынесу из этих полутора лет: не доверяй магии. Включи логгер, посмотри SQL, который реально пошёл в БД. Drizzle относится к ORM, в которой это просто сделать, и грех этим не пользоваться.