lenec ru

← все посты

Drizzle relations API: подводные камни, которые ловят на проде

10K

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, в которой это просто сделать, и грех этим не пользоваться.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.