Дождь или Призма? Проверьте тип соединения и зарегистрированный SQL перед выбором.
Создайте модели тех же таблиц пользователей и счетов в Drizzle и Prisma, сравните типы результатов объединения данных и записанный SQL-код, а также выявите настройки драйвера, приводящие к тому, что суммы преобразуются в строки.
Дискуссии об ORM обычно крутятся вокруг графиков загрузки и слоганов конференций, однако в реальных проектах возникает гораздо более простой вопрос: когда вы связываете пользователя с его счетами-фактурами, какой тип имеет total, и можно ли прочитать SQL-запрос, который его сгенерировал? В этом руководстве создаются две одинаковые таблицы в Drizzle и Prisma, выполняются операции вставки и соединения в каждой из них, после чего сравниваются типы данных TypeScript, записанные запросы, результаты миграций и поведение системы при выполнении кода на нативном TypeScript в Node. В итоге у вас будет краткий, повторяемый эксперимент, который позволит ответить на вопрос об ORM для собственного кодового базиса, а не опираться на чужие тесты. Чтобы получить более полную систему принятия решений, учитывающую также чистый SQL, ознакомьтесь с как выбрать слой базы данных между чистым SQL, Prisma и Drizzle.
Что оптимизирует каждый инструмент
Эти две библиотеки дают разные обещания. Drizzle предлагает код запросов, похожий на SQL, написанный на TypeScript, без отдельного процесса движка запросов и с дизайном, подходящим для работы в окраинных средах. Prisma предлагает подход, основанный на схеме, с отдельным файлом schema.prisma и генерируемым клиентом; в последних версиях его движок запросов постепенно переходит с Rust на TypeScript. На момент написания этого текста переход все еще продолжался, поэтому проверьте актуальные примечания к выпуску Prisma, чтобы узнать, какой движок используется в вашей версии.
Популярность тоже имеет двойственный эффект: Prisma по-прежнему лидирует по количеству установок, в то время как Drizzle доминирует в обсуждениях, связанных с ростом. Но всё это ничего не говорит о вашем выборе инструмента. Два критерия, используемые ниже, выбраны умышленно ограниченными и практичными: первый — это то, приходит ли значение total в формате number, а второй — соответствует ли SQL-код из логов тому, что вы бы без колебаний вставили в psql во время инцидента.
Рассмотрим небольшое приложение для счетоводства, где страница /invoices должна отображать общую сумму. Что-то в стеке технологий должно присвоить этой сумме определенный тип, и именно здесь начинается сравнение.
Два одинаковых таблицы, дважды
Создайте две отдельные папки проектов, работающие с одним и тем же экземпляром PostgreSQL, и присвойте каждой свое имя схемы. Общее использование таблиц между двумя ORM приводит к дублированию записей, которые выглядят как данные о производительности, но на самом деле являются багами.
В Drizzle схема находится в файле src/schema.ts в виде обычного TypeScript. Обратите внимание, что имена столбцов объявляются явно в формате snake_case (user_id), тогда как свойства используют формат camelCase (userId); кроме того, иностранный ключ представляет собой ссылку на функцию users.id:
import { integer, pgTable, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
});
export const invoices = pgTable("invoices", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id),
total: integer("total").notNull(),
});
В Prisma та же модель находится в файле prisma/schema.prisma. Связь объявляется с обеих сторон: у объекта User есть массив invoices, а у объекта Invoice имеется скалярное значение userId вместе с атрибутом @relation, который устанавливает связь обратно:
model User {
id String @id @default(uuid())
email String @unique
invoices Invoice[]
}
model Invoice {
id String @id @default(uuid())
userId String
total Int
user User @relation(fields: [userId], references: [id])
}
Теперь о самом важном запросе: получение счетов пользователя по электронной почте. В Drizzle это реализуется с помощью явного внутреннего соединения с использованием условия where, тогда как в Prisma достаточно запросить пользователя и указать «включить счета»:
// drizzle
const rows = await db
.select()
.from(invoices)
.innerJoin(users, eq(invoices.userId, users.id))
.where(eq(users.email, email));
// prisma
const user = await prisma.user.findUnique({
where: { email },
include: { invoices: true },
});
Типы результата отражают эти две концепции. Drizzle возвращает строки, соответствующие результату объединения таблиц, где в каждой строке присутствуют ключи users и invoices. Prisma возвращает объект типа User & { invoices: Invoice[] } — вложенный объект. Оба подхода верны. Структура данных, возвращаемая Drizzle, соответствует SQL-запросу; структура данных Prisma — странице, которую собираются отрендерить.
При включенном логировании запросов различия сохраняются. Результаты Drizzle представляют собой прямое объединение таблиц, которое разработчик может легко прочитать. Результаты Prisma также полностью пригодны к использованию, но это SQL-запросы, которые не стоит редактировать вручную.
Для такой небольшой схемы миграции прошли без проблем. Команда drizzle-kit generate сгенерировала файлы SQL, которые можно сохранить; команда prisma migrate создала собственную историю миграций, которую также можно сохранить. Ни один из инструментов не столкнулся с трудностями при работе с двумя таблицами, а схема такого размера не позволяет выявить более сложные случаи миграций, поэтому делать какие-либо выводы не стоит.
Воспроизведение лабораторной среды локально
Установите каждый набор инструментов в отдельную папку. Для Drizzle требуются ORM, драйвер (здесь postgres) и drizzle-kit для миграций; Prisma требует CLI и клиентскую часть, а также команда prisma init для создания структуры файла схемы:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
pnpm add prisma @prisma/client
pnpm exec prisma init
В каждой папке вставьте одного пользователя и две счета-фактуры, выполните операцию объединения один раз и выведите значение total первого счета-фактуры вместе с его типом при выполнении. Обратите внимание на различия в путях доступа: rows[0].invoices.total для строк после объединения в Drizzle по сравнению с user.invoices[0].total для вложенных объектов в Prisma.
console.log(rows[0]?.invoices.total, typeof rows[0]?.invoices.total);
console.log(user?.invoices[0]?.total, typeof user?.invoices[0]?.total);
Если один инструмент отображает значение типа string, а другой — number, причиной почти всегда является сопоставление типов драйвером базы данных, а не философия ORM. Драйверы PostgreSQL часто возвращают столбцы типов bigint и numeric в виде строк, чтобы избежать потери точности чисел в JavaScript, тогда как столбцы типа integer возвращаются в виде чисел. В результате сумма, выраженная как строка, приводит к тому, что операция "1200" + 50 на счете-фактуре тихо превращается в "120050". Запишите результат операции typeof перед тем, как выбирать библиотеку.
Запуск файла запроса с использованием нативного TypeScript
Далее проверьте, запускается ли код непосредственно с использованием встроенной функции Node для удаления типовых аннотаций, которая обрабатывает файлы с расширением .ts, удаляя типовые указания без отдельной процедуры сборки:
node src/query.ts
Модуль Drizzle, состоящий исключительно из функций и аннотаций типов, работал без проблем. Клиент Prisma, сгенерированный в папке node_modules, также функционировал при вызове из небольшого оберточного кода. Проблема возникла с файлом, который импортировал энумерации Prisma в старом стиле. Декларации enum в TypeScript — это не просто типы; они компилируются в объекты времени выполнения, и режим удаления лишнего кода в Node не может их устранить, поэтому выполнение сбрасывается. Это не дефект Prisma, а особенность сгенерированного кода времени выполнения. Если у вас версия Prisma использует более новый движок и генератор на основе TypeScript, проверьте, что на самом деле выводит команда prisma generate, прежде чем предполагать, что это всё ещё актуально, и укажите версию, которую вы тестировали.
Включение логирования запросов
Предположения относительно SQL приводят к затягиванию решения проблем. Обе библиотеки могут записывать каждый запрос: Drizzle — с помощью опции logger, а Prisma — через массив log на стороне клиента:
const db = drizzle(client, { logger: true });
const prisma = new PrismaClient({ log: ["query"] });
Разместите два записанных строковых представления SQL рядом с двумя результатами вычисления typeof total. Эти четыре строки и составляют весь набор данных, необходимый для этого эксперимента.
В чем заключается стоимость каждого инструмента
Недостатки проявились в пяти аспектах.
Типы данных. Функция include Prisma генерировала именно тот формат данных, который требовалась страница /invoices. Метод объединения данных в Drizzle давал именно тот результат, который нужен при отладке ситуаций, когда общая сумма удваивалась. Оба инструмента полезны в разных ситуациях, что является аргументом в пользу использования по одному для каждой базы данных, а не для работы с обеими над одинаковыми таблицами.
Видимость SQL-запросов. Когда сумма показывает неверные значения, логи Drizzle помогают быстрее разобраться в проблеме благодаря читаемости запроса. Когда новому сотруднику нужно добавить поле, файл схемы Prisma позволяет сделать это быстрее. Это разные ситуации, в которых имеют преимущество разные решения.
Этап генерации. Prisma требует выполнения команды prisma generate после каждой изменения схемы; Drizzle требует, чтобы файл schema.ts оставался точным. На этапе интеграционных тестов легко забыть о генерации, и если клиентская версия отстает от схемы, это приводит к путанице и сбоям. Необходимо настроить так, чтобы интеграционные тесты проваливались при пропуске этапа генерации.
Работа в режиме Edge. Способность Drizzle к работе в режиме Edge действительно является сильным преимуществом, но она имеет значение только в том случае, если приложение развернуто в среде Edge. Процесс Node, работающий рядом с PostgreSQL на VPS, не получает от этого никакой пользы, поэтому не стоит позволять этому аргументу влиять на выбор решения для серверно-хостингового приложения.
Границы пакетов. Ни один из ORM не должен находиться в компоненте клиента. Если хотя бы один из них импортируется в модуль "use client", например, для фильтрации интерактивной таблицы, граница клиента определяется слишком высоко, и драйвер базы данных попадает в браузер. Статья о правильном определении границы use client объясняет, как это исправить.
Подробный счёт
Более детальное разделение расходов:
- Время. Потери производительности у Drizzle возникали из-за структуры операции объединения:
rows[0].invoices.totalилиrows[0].total, в зависимости от написания запроса. У Prisma потери производительности возникали из-за необходимости повторной генерации кода после каждой правки схемы.
Выбор и что не стоит делать
Выбирайте Drizzle, когда вам нужно, чтобы SQL был виден в процессе ревью кода, и команда уже привыкла использовать операции объединения. Храните схему в файле schema.ts и убедитесь, что кто-то из команды умеет читать код с использованием innerJoin.
Выбирайте Prisma, если привычки команды основаны на использовании файла schema.prisma и операции include. Учтите необходимость выполнения шага генерации в CI и остановите работу пайплайна, если он не был запущен.
В любом случае избегайте следующего:
- Использования обоих ORM для работы с одинаковыми производственными таблицами «для сравнения». Из-за этого одни и те же данные могут быть записаны дважды, что заставляет сотрудников тратить время на сверку счетов с банковскими данными.
- Принятия решения на основе еженедельных отчетов. Решайте, какой тип результата объединения вы сможете быстро понять в условиях давления.
Общая сумма в виде строки, которая на самом деле была проблемой драйвера
Реальный сценарий сбоя показывает, почему важна проверка типа с помощью typeof. Команда моделирует две одинаковые таблицы в обоих инструментах, связывает пользователя с двумя счетами-фактурами и фиксирует тип значения total: в обоих случаях это number. Через неделю появляется другой драйвер, который преобразует числовую колонку в тип string, и отчет начинает объединять значения вместо того, чтобы складывать их, в результате чего показываемые цифры удваиваются.
Привлекательным решением кажется оборачивание Number(total) вокруг каждого места вызова. Это скрывает проблему, но не решает её, и следующий столбец с той же проблемой останется незамеченным. Надежное решение — записывать SQL-запросы и тип результатов по одному разу на библиотеку, фиксировать версию драйвера и никогда не позволять двум ORM записывать данные в одни и те же производственные таблицы.
Работа с enum следует той же логике: генерируемые enum представляют собой код, выполняющийся во время работы программы, поэтому необходимо скомпилировать соответствующий пакет и запустить результат из каталога dist/, вместо того чтобы напрямую выполнять генерированный TypeScript. Какая бы библиотека ни оказалась лучше, необходимо описать свой выбор и причины в файле README, чтобы никто позже не добавлял другую библиотеку просто ради эксперимента.
Запись состояния среды перед сравнением
Подобные результаты имеют смысл только в связи с версиями, которые их генерировали. В качестве эталонной среды здесь использовались Node 24, TypeScript 7 и Next.js 16.3 для запуска небольшого приложения с четырьмя маршрутами для работы с счетами-фактурами. Сохраняйте файл notes/lab.md в репозитории и сначала запишите информацию о трех версиях:
node -v
pnpm exec tsc -v
pnpm exec next --version
Запишите их в верхней части файла с записями. Если основная версия отличается от той, которую предполагает руководство, остановитесь и уточните ситуацию перед запуском чего-либо еще, поскольку последующие команды могут ввести вас в заблуждение более тонкими способами.
Затем запустите сервер разработки и пройдитесь по маршрутам:
pnpm exec next dev
Посетите адреса /, /invoices, /invoices/1, /settings, а затем снова /invoices, убедившись, что в инструментах разработчика включена опция «Сохранять лог». Запишите информацию из поля фильтра вместе с URL-адресом; именно эта пара часто бывает необходимыми доказательствами позже.
Затем запустите проверку типов и выведите её код завершения:
pnpm exec tsc --noEmit --pretty false
echo $?
Код завершения равный нулю не является особенностью, а лишь разрешением перейти к проверкам во время выполнения программы. После этого запустите команды из раздела лабораторных заданий выше на собственном компьютере, не полагаясь только на эти результаты; аппаратное обеспечение, нагрузка на память и действия браузера могут сильно изменить использование памяти, время выполнения проверки типов и времена загрузки, больше, чем это сделает обновление минорной версии фреймворка.
Также полезно оставить в примечаниях одну строку с информацией о неудачной попытке решения проблемы в формате «пробовал X, но всё равно получил Y». Эта строка превращает файл в достоверный отчёт о лабораторной работе, а не в просто рекламный материал, и это самое полезное, что можно передать коллеге, который будет продолжать исследование.
Ошибки, которых стоит избегать
Часто повторяются три ошибки, связанные с подобными сравнениями:
- Использование обоих инструментов миграции для работы с одной и той же базой данных с целью сравнения приводит к наличию двух историй миграций и одной таблицы с двумя именами. Единственный правильный способ восстановления — загрузка из резервной копии.
- Импорт генерируемых Prisma-энумераций в файл, который затем обрабатывается с помощью функции удаления типов в Node, что не удается по вышеуказанным причинам. Вместо этого следует скомпилировать соответствующий пакет.
- Оценка библиотек по количеству загрузок, что не влияет на тип объединения данных.
Чек-лист перед добавлением ORM
- Один ORM на каждую базу данных.
- SQL-запрос для ключевого объединения должен быть зарегистрирован хотя бы один раз.
- Значение
typeofдля столбцов с финансовыми данными должно быть зарегистрировано хотя бы один раз, а также после каждого обновления драйвера. - Генерируемый вывод, содержащий энумерации, должен быть скомпилирован, а не обработан с помощью метода удаления типов в сыром виде.
- В файле README должны быть указаны выбранная библиотека и причина её выбора.
Заключение
Два таблицы не являются производственной схемой, и в этом эксперименте не проводилось тестирование тысяч операций объединения данных или их развертывание в среде выполнения на периферии. Однако было показано, что решающие различия конкретны и можно проверить за один день: форма результата объединения, читаемость записанного SQL-кода, затраты на шаг генерации данных и то, предоставляет ли драйвер числа или строки. Выберите по одной библиотеке для каждой базы данных, запишите причины и сделайте проверку typeof total обязательной процедурой после каждого обновления драйвера. Передача управления одной базой данных двум инструментам миграции в конечном итоге приводит к необходимости восстановления данных, поэтому держите такие эксперименты подальше от любых систем, обрабатывающих реальные деньги.