Привет, я Сергей Маркизов, разработчик диджитал-продакшна Далее. В наших проектах часто использую Drizzle — современную, типобезопасную ORM для TypeScript, которая не усложняет базовую задачу: читать и писать данные. В этой статье расскажу, чем библиотека отличается от других и как с ней работать.
Базы данных являются основным средством обеспечения персистентности современных приложений. Для работы с ними зачастую используются различные ORM-решения, ведь они позволяют избавиться от необходимости написания большого количества шаблонного кода при работе с БД.
Но, несмотря на все свои плюсы, ORM сталкиваются с постоянной критикой из-за своих минусов.
Плохая производительность и неспособность использовать многие возможности конкретных баз данных.
Большие размеры кодовой базы этих решений, как правило, приводят к долгоживущим багам, которые не исправляются годами.
Попытки сделать один инструмент для работы как с 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. Будет интересно узнать, как вы его используете и с какими задачами он справился или, наоборот, не справился.
bikishov
У drizzle плохо сделаны миграции, не возможно указать например timestamp или что-то кастомное, из-за чего при работе в команде возникают проблемы конфликты и одинаковые версии, но с разным sql кодом. И Kysely.js будет быстрее.