Дрізл чи Прізма? Перевірте тип з’єднання та записаний 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 у вигляді числа, та чи є 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, який з’єднує його з User:
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 просить отримати користувача та додає команду "include the invoices":
// 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. Функція join у Drizzle дає саме ту структуру, яка потрібна під час дебагування ситуацій, коли загальна сума подвоюється. Обидва інструменти корисні в різних ситуаціях, що є аргументом на користь використання кожного з них для окремої бази даних, а не одночасного використання обох для одних і тих самих таблиць.
Видимість SQL-запитів. Коли підсумки виглядають неправильно, журнал Drizzle допомагає швидше з’ясувати причину, оскільки запит є зрозумілим. Коли новому члену команди потрібно додати поле, файл схеми Prisma є швидшим рішенням. Це різні ситуації з різними переможцями.
Крок генерації. Prisma вимагає виконання команди prisma generate після кожної зміни схеми; Drizzle вимагає, щоб файл schema.ts залишався точним. Крок генерації легко забути в середовищі CI, а якщо клієнтська версія відстає від схеми, це призводить до заплутаних проблем. Нехай CI повертає помилку, якщо генерація пропущена.
Середовища виконання на краю мережі. Підтримка Drizzle середовищ на краю мережі є справжньою перевагою, але вона має значення лише тоді, коли ви розгортаєте програму саме там. Процес 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 записувати дані у одні й ті самі таблиці в продакшені.
Обробка енумерацій також ґрунтується на тій самій логіці: генеровані енумерації є кодом часу виконання, тому потрібно скомпілювати цей пакет та запустити результат з каталогу 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, увімкнувши опцію „Зберегти журнал“ у DevTools. Зафіксуйте поле фільтра разом із URL-адресою; саме ця пара часто є необхідними доказами пізніше.
Потім запустіть перевірку типів та виведіть її код завершення:
pnpm exec tsc --noEmit --pretty false
echo $?
Код завершення нуль не є функцією, а лише дозволом переходити до перевірок під час виконання. Після цього запустіть команди з розділу лабораторних завдань вище на власному комп’ютері, а не покладаючись на ці результати; апаратне забезпечення, навантаження на пам’ять та будь-які дії браузера можуть суттєво змінити використання пам’яті, тривалість перевірки типів та час завантаження, більше, ніж це може зробити оновлення мінорної версії фреймворку.
Також корисно залишити у примітках однорядковий запис про «невдалий спробу виправлення» у форматі «спробував X, все одно отримав Y». Цей рядок перетворює файл на достовірний звіт лабораторного експерименту, а не на просто інформаційний матеріал, і це найкорисніше, що можна передати колезі, який продовжує дослідження.
Помилки, яких варто уникати
Три поширені помилки під час такого порівняння:
- Використання обох інструментів міграції для роботи з однією й тією самою базою даних з метою їх порівняння, що призводить до наявності двох історій міграцій та однієї таблиці під двома назвами. Єдиний правильний спосіб відновлення — це відновлення з резервної копії.
- Імпорт створених Prisma enums у файл, який потім обробляється за допомогою функцій Node для видалення типів, що призводить до невдачі з причин, зазначених вище. Краще скомпілювати цей пакет.
- Оцінка бібліотек за кількістю завантажень, що не впливає на тип з’єднання даних.
Чек-лист перед додаванням ORM
- Один ORM на кожну базу даних.
- SQL-запит для ключового з’єднання має бути задокументований принаймні один раз.
- Значення
typeofдля стовпців з даними типу money має бути задокументоване принаймні один раз, а також після кожного оновлення драйвера. - Створений результат, що містить enums, має бути скомпільований, а не виконаний шляхом прямого видалення типів.
- У файлі README мають бути вказані назва обраної бібліотеки та причина її вибору.
Підсумок
Дві таблиці — це не продакшн-схема, і в цьому експерименті не перевіряли тисячі операцій об’єднання даних чи не впроваджували їх у режим роботи на периферії. Проте він демонструє, що ключові відмінності є конкретними та можна перевірити протягом одного дня: форма результату об’єднання, читабельність записаних SQL-запитів, витрати на крок генерації даних та те, чи надає драйвер числа чи рядки. Виберіть по одній бібліотеці для кожної бази даних, запишіть причини та робіть перевірку typeof total як обов’язкову практику після кожної зміни драйвера. Використання двох інструментів міграції для керування однією базою даних призводить до її відновлення, тому тримайте такий експеримент подалі від будь-яких систем, які обробляють реальні гроші.