Упрощаем работу с БД с помощью Drizzle ORM — как выжать максимум из инструмента

от автора

Привет, я Сергей Маркизов, разработчик диджитал-продакшна Далее. В наших проектах часто использую Drizzle — современную, типобезопасную ORM для TypeScript, которая не усложняет базовую задачу: читать и писать данные. В этой статье расскажу, чем библиотека отличается от других и как с ней работать.

Базы данных являются основным средством обеспечения персистентности современных приложений. Для работы с ними зачастую используются различные ORM-решения, ведь они позволяют избавиться от необходимости написания большого количества шаблонного кода при работе с БД.

Но, несмотря на все свои плюсы, ORM сталкиваются с постоянной критикой из-за своих минусов.

  1. Плохая производительность и неспособность использовать многие возможности конкретных баз данных.

  2. Большие размеры кодовой базы этих решений, как правило, приводят к долгоживущим багам, которые не исправляются годами. 

  3. Попытки сделать один инструмент для работы как с SQL, так и с NoSQL базами еще сильнее сужают полноценное использование возможностей СУБД.

Так, на проектах, где требуется построение сложных SQL-запросы, TypeORM быстро теряет все свои преимущества. Внутренние механизмы технологии дают большой оверхед и работают не всегда корректно — даже при использовании QueryBuilder. Более того, с применением этого конструктора увеличивается вероятность возникновения багов из-за отсутствия проверки типов. Особенно при запросах, результаты которых не укладываются в схему данных таблиц.

Все же не стоить хейтить все ORM, попробовав один-два инструмента. Существуют и достаточно легковесные решения для работы с БД. Один из них — Drizzle ORM.

Знакомство с Drizzle ORM и описание отличительных черт

Если вы устали от сложных ORM, которые прячут SQL за слоями абстракций, то вам стоит обратить внимание на Drizzle.

Это легковесный, типобезопасный и максимально близкий к SQL инструмент для работы с базами данных — с широкой поддержкой типизированных запросов с помощью TypeScript.

Drizzle позволяет строить SQL-запросы с проверкой типов на основе описания схемы базы данных и не имеет накладных расходов, присущих многим альтернативным инструментам для работы с БД.

Преимущества Drizzle в сравнении с другими ORM

Инструмент

Минусы в работе

Решения Drizzle ORM

Sequelize

Слабая поддержка TypeScript, устаревшая архитектура, дублирование описания схем и типов

В Drizzle типы формируются из схемы, описание структуры — в одном месте

TypeORM

Много скрытого поведения, нестабильная типизация, громоздкий API

В Drizzle все явно: схема — это код, запросы — читаемые, поведение — предсказуемое

Prisma

Высокий порог входа, генерация клиента, большой вес проекта

Drizzle не требует генерации, подключается за 2 строки, не увеличивает bundle

Knex

Нет встроенной типизации, всю логику нужно писать руками

Drizzle дает типы из коробки, но оставляет контроль над структурой и логикой

Поддерживаемые БД и создание соединения с базой

На текущий момент Drizzle поддерживает следующие SQL-СУБД: PostgreSQL, MySQL, SQLite.

Для создания соединения достаточно указать URL базы данных:

const connection = drizzle(process.env.DATABASE_URL);

Но для полноценного использования возможностей типизации TypeScript следует указать схему при создании соединения:

const connection = drizzle(process.env.DATABASE_URL!, {   casing: 'snake_case', // В случае различий в регистре   logger: true,         // Можно передать логгер или true для использования логгера по умолчанию   schema: {     ...schema,     ...relations,   }, });

Подробное описание схемы данных и ограничений

В основе описания структуры базы данных в Drizzle ORM лежат таблицы и отношения между ними:

const customBoolean = customType<{ data: boolean }>({   dataType() {     return 'boolean';   }, });  export const examples = pgTable("examples", {   id: uuid().primaryKey().defaultRandom(), // Первичный ключ   createdAt: timestamp().defaultNow().notNull(), // Временные метки создания можно определить таким образом    someVarchar: varchar({ length: 256 }).unique(),   someInteger: integer().notNull(),   someDecimal: decimal(),   someBoolean: boolean().default(true),   someArray: varchar({}).array(),   someJson: json(),   someJsonb: jsonb(),   someText: text(),   someTime: time(),   someTimestamp: timestamp(),   someUuid: uuid(),   someCustomBoolean: customBoolean(), }, table => [ // Индексы на несколько колонок можно добавить следующим образом   index('examples_some_idx').on(table.someInteger, table.someTimestamp),   unique('examples_unique').on(table.someBoolean, table.someText), ]);

One-to-one

export const players = pgTable('players', {   id: uuid().primaryKey().defaultRandom(),   firstName: varchar({ length: 255 }).notNull(),   lastName: varchar({ length: 255 }).notNull(), });  export const playerGameStates = pgTable('player_game_state', {   id: uuid().primaryKey().defaultRandom(),   playerId: uuid().notNull().references(() => players.id),   metadata: jsonb().notNull(), });  export const playersRelations = relations(players, ({ one }) => ({   state: one(playerGameStates, {     fields: [players.id],     references: [playerGameStates.playerId],   }), }));

One-to-many

export const positions = pgTable('positions', {   id: uuid().primaryKey().defaultRandom(),   name: varchar({ length: 256 }).unique().notNull(),   responsibilities: varchar({ length: 256 }).array().notNull(), });  export const employees = pgTable('employees', {   id: uuid().primaryKey().defaultRandom(),   firstName: varchar({ length: 256 }).unique().notNull(),   lastName: varchar({ length: 256 }).unique().notNull(),   positionId: uuid().notNull().references(() => positions.id), });  export const positionsRelations = relations(positions, ({ many }) => ({   employees: many(employees), }));  export const employeesRelations = relations(employees, ({ one }) => ({   position: one(positions, {     fields: [employees.positionId],     references: [positions.id],   }), }));

Many-to-many

export const customers = pgTable('customers', {   id: uuid().primaryKey().defaultRandom(),   firstName: varchar({ length: 256 }).unique().notNull(),   lastName: varchar({ length: 256 }).unique().notNull(), });  export const services = pgTable('services', {   id: uuid().primaryKey().defaultRandom(),   name: varchar({ length: 256 }).unique().notNull(), });  export const subscriptions = pgTable('subscriptions', {   createdAt: timestamp().defaultNow().notNull(),   isActive: boolean().notNull().default(true),   customerId: uuid().notNull().references(() => customers.id),   serviceId: uuid().notNull().references(() => services.id), }, table => [   primaryKey({ columns: [table.customerId, table.serviceId] }), ]);  export const customersToServicesSubscriptionRelation = relations(subscriptions, ({ one }) => ({   customer: one(customers, {     fields: [subscriptions.customerId],     references: [customers.id],   }),   service: one(services, {     fields: [subscriptions.serviceId],     references: [services.id],   }), }));

Миграции и построение схемы на основе существующей базы

Для работы с миграциями в Drizzle ORM используется утилита drizzle-kit. Ее нужно установить отдельно.

npm install drizzle-kit

Набор команд:

npx drizzle-kit migrate # Применить миграции npx drizzle-kit generate # Сгенерировать миграции на основе текущей схемы и структуры базы данных npx drizzle-kit push # Актуализировать базу данных в обход механизма миграций (удобно при прототипировании) npx drizzle-kit pull # Построить файлы схемы и отношений на основе существующей базы данных

Конфигурация инструмента описывается в файле drizzle.config.ts или drizzle.config.js.

import 'dotenv/config'; // Подгружаем переменные окружения import { defineConfig } from 'drizzle-kit';  export default defineConfig({   out: './drizzle', // Рабочая директория для миграций и генерации схемы   schema: './src/drizzle/schema.ts', // Схема, которая будет использоваться для генерации миграций   dialect: 'postgresql',   casing: 'snake_case',   dbCredentials: {     url: process.env.DATABASE_URL!,   }, });

Создание запросов

Для валидации передаваемых значений используется TypeScript, для валидации схем данных — расширение drizzle-zod.

Извлечение данных через ORM-запросы

Извлечение данных в Drizzle ORM можно осуществлять без построения запросов вручную. Для этого в нем предусмотрено два метода: findMany и findFirst.

const accounts = await postgres.query.accounts.findMany({     where: eq(schema.accounts.isActive, true),     limit: 5,     offset: 100,     orderBy: [desc(schema.accounts.createdAt)], });  const account = await postgres.query.accounts.findFirst({     where: and(         eq(schema.accounts.isActive, true),         isNotNull(schema.accounts.email),     ),     with: {         carts: true,     }, });

Извлечение данных через оператор SELECT

Для извлечения данных можно воспользоваться встроенным API построения SQL-запросов. По синтаксису он почти не отличается от других query builder’ов, за исключением типизации, которая здесь строго выведена из схемы.

postgres.select().from(schema.stores)  postgres.select({     id: schema.stores.id,     title: schema.stores.title,     }) .from(schema.stores) .where(eq(schema.stores.isActive, true))

Джоины описываются таким же образом, как и в большинстве других конструкторов запросов.

await postgres.select() .from(schema.customerCarts) .innerJoin(schema.orders, and(     eq(schema.orders.cartId, schema.customerCarts.id),     gte(schema.orders.totalCost, 100), ))

Псевдонимы колонок и SQL-выражения можно задать с помощью встроенного в Drizzle ORM оператора sql. Остальные его возможности мы рассмотрим позже.

postgres.select({   id: sql`${schema.accounts.id}`.as('someId'),   fullName: sql`${schema.accounts.firstName} || ' ' || ${schema.accounts.lastName}`.as('fullName'), }).from(schema.accounts)

Подзапросы и CTE описываются схожим образом:

const subquery = postgres.select().from(schema.stores);  const rowsFromSubquery = await postgres.select().from(schema.stores);   const statsCTE = postgres.$with('stats').as(     postgres.select({         accountId:  sql`${schema.accounts.id}`.as('accountId'),         storeId: sql`${schema.stores.id}`.as('storeId'),         totalCost: sql`sum(${schema.orders.totalCost})`.as('totalCost'),         count: sql`sum(${schema.customerCartProducts.count})`.as('count'),     }).from(schema.stores)     .innerJoin(schema.customerCarts, eq(schema.customerCarts.storeId, schema.stores.id))     .innerJoin(schema.accounts, eq(schema.accounts.id, schema.customerCarts.accountId))     .innerJoin(schema.customerCartProducts, eq(schema.customerCartProducts.cartId, schema.customerCarts.id))     .innerJoin(schema.orders, eq(schema.orders.cartId, schema.customerCarts.id))     .where(and(         inArray(schema.stores.title, stores),         eq(schema.customerCarts.wasOrdered, true)     ))     .groupBy(schema.stores.id, schema.accounts.id) );  const rowsWithCTE = await postgres.with(statsCTE).select().from(schema.accounts).innerJoin(statsCTE, eq(statsCTE.accountId, schema.accounts.id))

Добавление данных

Drizzle ORM дает возможность проверки корректности типов данных при добавлении в базу — на основе описанной схемы данных. Эти типы можно явно указывать в коде, что позволяет избежать ошибок из-за недостающих данных при подобных операциях. Также можно указать поведение в случае конфликта.

// Сохраним тип черновика, для явного указания type AccountDraft = typeof schema.accounts.$inferInsert;  const draft1: AccountDraft = {     firstName: 'John',     lastName: 'Doe', };  const draft2: AccountDraft = {     firstName: 'Robinson',     lastName: 'Crusoe', };  // const rows = await postgres.insert(schema.accounts).values(draft1).onConflictDoNothing({ target: schema.accounts.id }).returning();  const rows = await postgres.insert(schema.accounts).values([     draft1,     draft2 ]).returning().onConflictDoUpdate({     target: schema.accounts.id,     set: { firstName: 'Julius', lastName: 'Caesar' }, }); 

Обновление данных

Обновление данных в Drizzle ORM максимально приближено к SQL.

await postgres.update(schema.stores).set({     isActive: false, }).where(     gte(schema.stores.createdAt, new Date('2025-01-01')) ).returning();

Удаление данных

Удалять данные можно с помощью ранее указанных выражений.

await postgres.delete(schema.players).where(and(     eq(schema.players.firstName, 'John'),     eq(schema.players.lastName, 'Doe'), )).returning();

Работа с оператором sql

При работе с ORM-библиотекой могут возникнуть ситуации, когда написать конкретный запрос с использованием синтаксиса ORM оказывается сложно.

В подобных случаях стоит прибегнуть к использованию параметризованных сырых запросов raw queries и/или преобразованию объектов из TypeScript к SQL-подобному синтаксису — для указания в параметрах. Для этого в Drizzle ORM существует специальный оператор sql. Ниже рассмотрим основные варианты его использования.

Шаблон sql

Простой пример использования оператора sql — подстановка в сырой SQL-запрос наименований таблиц и колонок, а также параметров:

await postgres.execute(     sql`select ${schema.accounts.phone}, ${schema.accounts.email} from ${schema.accounts} where ${schema.accounts.id} = ${accountId}` )

sql``.mapWith()

Для указания правил, по которым стоит преобразовывать те или иные данные, можно воспользоваться конструкцией:

postgres.select({     id: schema.accounts.id,     count: sql<number>`count(*)`.mapWith(Number)  }).from(schema.accounts);

sql``.as()

Для того чтобы задать псевдоним для имени, можно взять следующую конструкцию:

sql`sum(store_products.cost)`.as('store_products_total_cost')

sql.raw()

Метод sql.raw() нужен тогда, когда требуется защита содержимого от экранирования или любых других преобразований.

Пример из документации:

sql`select * from ${usersTable} where id = ${12}`; // select * from "users" where id = $1; --> [12]  sql.raw(`select * from "users" where id = ${12}`); sql`select * from ${usersTable} where id = ${sql.raw(12)}`; // select * from "users" where id = 12;

sql.join()

Метод sql.join() поможет объединить несколько параметризованных запросов. 

sql.join([     sql`select id, first_name, last_name, 'account' from ${schema.accounts}`,     sql`select id, first_name, last_name, 'employee' from ${schema.employees}`, ], sql.raw(' union '))  // select id, first_name, last_name, 'account' from "accounts" union select id, first_name, last_name, 'employee' from "employees"

Работа с транзакциями

Работа с транзакциями в Drizzle ORM реализована на основе вызова callback`а с получением объекта транзакции для осуществления операций с базо

await postgres.transaction(async trx => {     const cart = await postgres.query.customerCarts.findFirst({         where: and(             eq(schema.customerCarts.accountId, accountId),             eq(schema.customerCarts.wasOrdered, true)         )     });      // Вложенные транзакции тоже поддерживаются     await trx.transaction(nestedTrx => nestedTrx.query.accounts.findFirst({ where: eq(schema.accounts.id, accountId) }));      // trx.rollback(); // Можно откатить транзакцию вручную     // throw new Error('I will rollback this transaction'); // Или через прикидывание ошибки }, {     isolationLevel: "read committed",      accessMode: "read write",     deferrable: true, }); ```

Репликация — в ручном режиме

На момент написания статьи Drizzle ORM не предоставляет встроенной поддержки работы с репликами read/write split. Тем не менее, ее можно реализовать вручную — например, через обертку над drizzle(...), создавая два подключения: одно для записи, другое для чтения.

Примерная структура:

const master = drizzle(process.env.WRITE_DB_URL); const replica = drizzle(process.env.READ_DB_URL);  // Пишем в master await master.insert(schema.logs).values({ message: 'hello' });  // Читаем из реплики const logs = await replica.select().from(schema.logs);

Переключение между инстансами базы придется реализовывать на уровне приложения, особенно при использовании транзакций — они должны всегда работать только на master.

Работа с расширениями для Drizzle ORM

Drizzle ORM поддерживает экосистему расширений, которые позволяют упростить интеграцию с другими библиотеками и улучшить developer experience при разработке. Ниже — два наиболее полезных.

drizzle-zod

Пакет drizzle-zod позволяет автоматически генерировать Zod-схемы на основе описанных в Drizzle таблиц. Это удобно для валидации входных данных, например, в API-обработчиках.

import { createInsertSchema } from 'drizzle-zod'; import { users } from './schema';  const insertUserSchema = createInsertSchema(users);

insertUserSchema— полноценная Zod-схема, которую можно использовать в API для проверки данных.

drizzle-orm/trpc

Для работы с tRPC можно использовать генерацию типов и схем напрямую из Drizzle — это позволяет избежать дублирования контрактов между бэкендом и фронтом.

import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';  type AppRouter = typeof appRouter;  type Inputs = inferRouterInputs<AppRouter>; type Outputs = inferRouterOutputs<AppRouter>;

Статически типизированные маршруты + строгие схемы данных на уровне API.

Интеграция с фреймворками

Drizzle можно легко использовать в любом современном TypeScript-приложении. Особенность — он не привязан к фреймворку, но спокойно работает с:

  • Next.js (App Router) — подключение через серверные обработчики;

  • Remix — через лоадеры и экшены;

  • NestJS — можно обернуть в провайдер или сервис, как любой другой клиент базы.

Гибкость Drizzle ORM позволяет внедрять инструмент поэтапно — без жестких требований к архитектуре приложения, но с полной типовой поддержкой на всех уровнях.

Ограничения или что стоит учитывать в работе с Drizzle ORM

Drizzle ORM решает многие проблемы классических ORM: он дает типы, остается ближе к SQL и не навязывает архитектуру. Но у него, как и у любого инструмента, есть свои ограничения. Они особенно заметны тем, кто приходит из мира Prisma или TypeORM.

Только SQL-базы

Drizzle работает только с реляционными СУБД: PostgreSQL, MySQL и SQLite. Если вы ищете поддержку MongoDB, Redis или другой NoSQL — этот инструмент не подойдет.

Больше контроля — меньше автоматизации

Drizzle не генерирует схемы «по моделям», не скрывает SQL и не предлагает магии. Это плюс, если вы хотите контролировать все вручную. Но если вы привыкли к удобству Prisma, например, когда можно не думать о связях и все описывается декларативно, то придется переключиться в другой режим работы.

Миграции — только через CLI

Применение миграций реализовано через drizzle-kit. В рантайме, при старте приложения, автоматическое применение миграций не предусмотрено. Это означает, что в CI/CD-процессе нужно явно учитывать миграции как отдельный шаг.

Нет встроенной репликации

Drizzle не управляет подключениями к репликам и не делает read/write split. Все переключение между инстансами баз, например, мастер и реплика — на стороне приложения.

Быстро развивающийся API

Проект активно развивается, и отдельные части экосистемы, например, drizzle-zod, drizzle-studio, могут меняться. Документация не всегда успевает за обновлениями — иногда придется смотреть в исходники.

Меньше готовых решений

Drizzle пока не может похвастаться большим количеством гайдов, Stack Overflow-ответов и туториалов. Для некоторых задач придется изобретать свое — зато часто это «чистый» TypeScript без лишней обвязки.


Drizzle ORM — хороший выбор, если вам нужен контроль, типизация и чистый SQL без лишнего слоя между вами и базой. Она не делает все за вас, но это понятный и стабильный инструмент, на который можно положиться в реальной работе.

Напишите в комментариях, если уже пробовали Drizzle. Будет интересно узнать, как вы его используете и с какими задачами он справился или, наоборот, не справился.


ссылка на оригинал статьи https://habr.com/ru/articles/940582/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *