Пошук уроків, статей та іншого контенту
Порівняєте ORM і query builder, визначите їхні переваги та обмеження для різних сценаріїв роботи з даними.
Коли Node.js-застосунок працює з реляційною базою даних, він може виконувати SQL-запити різними способами:
безпосередньо через драйвер бази даних;
за допомогою query builder;
за допомогою ORM.
ORM (Object-Relational Mapping) зіставляє таблиці бази даних із програмними моделями та об’єктами.
Query builder надає програмний API для побудови SQL-запитів без ручного складання рядків SQL.
Обидва підходи зменшують кількість помилок порівняно з конкатенацією SQL-рядків, але працюють на різних рівнях абстракції.
Query builder дає змогу описувати SQL-запит за допомогою ланцюжка викликів методів.
Наприклад, для Knex.js запит:
const users = await knex("users")
.select(["id", "email", "created_at"])
.where("is_active", true)
.orderBy("created_at", "desc")
.limit(20);приблизно відповідає SQL:
SELECT id, email, created_at
FROM users
WHERE is_active = true
ORDER BY created_at DESC
LIMIT 20;Query builder не приховує структуру запиту: таблиці, поля, умови, сортування та об’єднання явно видно в коді.
Нижче наведено функцію, яка отримує активних користувачів разом із кількістю їхніх замовлень.
const knex = require("knex")({
client: "pg",
connection: process.env.DATABASE_URL,
});
async function getActiveUsersWithOrderCount() {
const rows = await knex("users")
.leftJoin("orders", "orders.user_id", "users.id")
.select("users.id", "users.email")
.count("orders.id as order_count")
.where("users.is_active", true)
.groupBy("users.id", "users.email")
.orderBy("order_count", "desc");
return rows.map((row) => ({
id: row.id,
email: row.email,
orderCount: Number(row.order_count),
}));
}
getActiveUsersWithOrderCount()
.then((users) => {
console.log(users);
})
.catch((error) => {
console.error("Помилка запиту:", error);
})
.finally(() => knex.destroy());У цьому прикладі розробник безпосередньо контролює:
тип об’єднання LEFT JOIN;
поля, які вибираються;
групування;
сортування;
перетворення результату.
Контроль над SQL. Видно, які таблиці та поля бере участь у запиті.
Гнучкість. Зручно будувати складні фільтри, JOIN, агрегатні запити та підзапити.
Ближчий до бази даних рівень. Легше оптимізувати конкретний SQL.
Менше прихованої поведінки. Розробник краще розуміє, який запит виконується.
Зручне параметризування. Значення передаються окремо, що зменшує ризик SQL-ін’єкцій.
Розробник сам працює зі структурою таблиць і зв’язками.
Перевірки типів зазвичай обмежені або залежать від конкретного інструмента.
Результат запиту часто має форму звичайних об’єктів без поведінки моделі.
Для складної предметної області доводиться самостійно організовувати репозиторії та перетворення даних.
Частину SQL-запитів усе одно може бути складно читати в ланцюжку методів.
ORM представляє таблиці та зв’язки між ними у вигляді моделей.
Наприклад, у Prisma схема може описувати користувача й замовлення так:
model User {
id Int @id @default(autoincrement())
email String @unique
isActive Boolean @map("is_active")
orders Order[]
@@map("users")
}
model Order {
id Int @id @default(autoincrement())
userId Int @map("user_id")
total Float
user User @relation(fields: [userId], references: [id])
@@map("orders")
}Після генерації Prisma Client можна виконувати запити через моделі:
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function getActiveUsers() {
const users = await prisma.user.findMany({
where: {
isActive: true,
},
select: {
id: true,
email: true,
_count: {
select: {
orders: true,
},
},
},
orderBy: {
email: "asc",
},
});
return users.map((user) => ({
id: user.id,
email: user.email,
orderCount: user._count.orders,
}));
}
getActiveUsers()
.then((users) => {
console.log(users);
})
.catch((error) => {
console.error("Помилка запиту:", error);
})
.finally(() => prisma.$disconnect());Тут код працює з моделями user та orders, а не безпосередньо з назвами таблиць у кожному запиті.
Вища абстракція. Код працює з моделями та зв’язками.
Зручні CRUD-операції. Створення, читання, оновлення та видалення часто мають однаковий API.
Модель предметної області. Зв’язки між сутностями описуються централізовано.
Перевірка типів. Деякі ORM генерують типізований клієнт або надають типи для моделей.
Менше повторюваного коду. Не потрібно щоразу вручну описувати типові об’єднання та перетворення.
Зручна робота зі зв’язками. Можна завантажувати пов’язані сутності через include, select або аналогічні механізми.
Прихований SQL. Простий на вигляд виклик може створити складний запит.
Не кожен SQL-запит природно виражається через ORM.
Ризик неефективних запитів. Наприклад, неправильне завантаження зв’язків може призвести до проблеми N+1.
Залежність від можливостей конкретної ORM. Складні оператори бази даних можуть бути недоступними або вимагати сирого SQL.
Додатковий шар абстракції. Для оптимізації іноді потрібно розуміти і ORM, і SQL.
Міграції не завжди автоматично розв’язують усі проблеми зі схемою. Їх потрібно перевіряти в середовищах розробки, тестування та production.
| Критерій | ORM | Query builder | |---|---|---| | Рівень абстракції | Високий | Середній | | Робота з моделями | Основний сценарій | Зазвичай організовується вручну | | Контроль над SQL | Частково обмежений | Високий | | Типові CRUD-операції | Дуже зручні | Потрібно описувати запити | | Складні SQL-запити | Можуть бути незручними | Зазвичай зручніші | | Робота зі зв’язками | Часто вбудована | Потрібно явно писати JOIN | | Ризик прихованих запитів | Вищий | Нижчий | | Оптимізація конкретного SQL | Може вимагати сирого SQL | Простішa | | Швидкість розробки CRUD | Висока | Середня | | Залежність від схеми моделей | Висока | Нижча |
ORM добре підходить, якщо:
застосунок має багато стандартних CRUD-операцій;
у базі є чітко визначені сутності та зв’язки;
важлива швидкість розробки;
команда хоче працювати з моделями, а не з SQL;
потрібна централізована схема даних;
більшість запитів не є аналітичними або нестандартними.
Типові приклади:
користувачі та ролі;
товари й категорії;
замовлення та платежі;
профілі та налаштування;
адміністративні CRUD-панелі.
Query builder зручніший, якщо:
застосунок має складні фільтри;
потрібно часто використовувати JOIN, агрегати або підзапити;
важливий точний контроль над SQL;
є специфічні можливості конкретної бази даних;
значна частина запитів має аналітичний характер;
команда добре розуміє SQL.
Типові приклади:
звіти;
пошук із багатьма необов’язковими умовами;
статистика та агрегати;
вибірки з кількох пов’язаних таблиць;
запити, які потрібно детально оптимізувати.
ORM і query builder не обов’язково взаємовиключні.
Практичний підхід:
ORM використовується для типових операцій із моделями;
query builder або сирий SQL — для складних запитів;
спільна логіка доступу до даних розміщується в сервісах або репозиторіях.
Наприклад, створення користувача може виконуватися через ORM:
const user = await prisma.user.create({
data: {
email: "anna@example.com",
isActive: true,
},
});А складний звіт — через спеціалізований SQL-запит або query builder.
Важливо не змішувати підходи без системи. Якщо частина коду працює через ORM, а частина напряму через базу, потрібно визначити:
де знаходяться запити;
як називаються методи репозиторіїв;
як перетворюються результати;
як виконуються транзакції;
хто відповідає за перевірку продуктивності.
Вибір ORM або query builder сам по собі не гарантує продуктивності.
Одна й та сама ORM може створити як ефективний, так і неефективний запит. Потрібно перевіряти:
який SQL фактично виконується;
чи використовуються індекси;
скільки рядків повертається;
чи не завантажуються зайві поля;
чи не виконується багато окремих запитів замість одного;
чи потрібна пагінація.
Для великих вибірок не варто безумовно завантажувати всі поля та всі пов’язані записи. Краще явно вказувати потрібні поля:
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
},
where: {
isActive: true,
},
take: 20,
});Чим більше даних повертає запит, тим більше пам’яті та часу може знадобитися Node.js-застосунку для їх обробки.
І ORM, і query builder зазвичай підтримують транзакції. Вони потрібні, коли кілька операцій мають виконатися як єдине ціле.
Наприклад, під час створення замовлення потрібно:
створити замовлення;
зменшити залишок товару;
записати операцію в журнал.
Якщо одна операція завершилася помилкою, зміни інших операцій не повинні залишитися в базі.
Приклад транзакції в Prisma:
async function createOrder(prisma, userId, productId, total) {
return prisma.$transaction(async (transaction) => {
const order = await transaction.order.create({
data: {
userId,
total,
},
});
await transaction.product.update({
where: {
id: productId,
},
data: {
stock: {
decrement: 1,
},
},
});
return order;
});
}Конкретний API транзакцій залежить від інструмента, але принцип однаковий: усі пов’язані зміни виконуються в межах однієї транзакції.
Популярність інструмента не означає, що він підходить для конкретних запитів.
Спочатку варто проаналізувати:
структуру даних;
частоту складних запитів;
вимоги до продуктивності;
навички команди;
потребу в типізації.
ORM не скасовує необхідність знати SQL. Без цього складно:
оцінити план виконання;
знайти зайвий JOIN;
зрозуміти причину повільного запиту;
правильно створити індекс;
уникнути надмірного завантаження даних.
Проблема N+1 виникає, коли спочатку виконується один запит для отримання списку, а потім окремий запит для пов’язаних даних кожного елемента.
Наприклад:
const users = await getUsers();
for (const user of users) {
user.orders = await getOrdersByUserId(user.id);
}Для 100 користувачів це може створити 101 запит: один для користувачів і по одному для кожного користувача.
Потрібно використовувати запит зі зв’язком, пакетне завантаження або інший механізм, який зменшує кількість звернень до бази.
Запит без обмеження полів може повернути конфіденційні або непотрібні дані:
const user = await prisma.user.findUnique({
where: { id: userId },
});Якщо для відповіді потрібен лише email, краще явно вказати select.
Небезпечно вставляти введення користувача безпосередньо в SQL:
// Небезпечно: введення користувача вставляється в SQL-рядок
const query = `SELECT * FROM users WHERE email = '${email}'`;Це може призвести до SQL-ін’єкції. Потрібно використовувати параметризовані запити, ORM або query builder.
Абстракція може приховати неефективний запит. Під час розробки та профілювання корисно переглядати SQL, який генерує інструмент, і перевіряти його план виконання.
Якщо кожен розробник сам вирішує, коли використовувати ORM, query builder або сирий SQL, код доступу до даних стає непослідовним.
Краще заздалегідь визначити правило:
стандартні операції — через ORM;
складні вибірки — через query builder;
виняткові специфічні запити — через параметризований SQL.
Перед вибором інструмента поставте такі запитання:
Чи складається більшість операцій зі стандартного CRUD?
Наскільки складні зв’язки між таблицями?
Чи потрібні складні агрегати та звіти?
Наскільки важливий контроль над згенерованим SQL?
Чи потрібна статична типізація моделей і результатів?
Чи має команда достатній досвід роботи з SQL?
Чи буде інструмент підтримуватися протягом усього життєвого циклу проєкту?
Якщо переважають CRUD-операції та модельні зв’язки, ORM зазвичай буде зручнішим.
Якщо переважають складні запити й контроль над SQL, краще розглянути query builder.
ORM працює на рівні моделей, сутностей і зв’язків.
Query builder працює ближче до SQL і дає більше контролю над запитом.
ORM зручніший для типових CRUD-операцій і швидкої розробки.
Query builder краще підходить для складних, аналітичних та оптимізованих запитів.
Обидва підходи не захищають від поганого проєктування запитів.
Навіть під час використання ORM потрібно розуміти SQL.
ORM і query builder можна поєднувати, якщо правила використання чітко визначені.
Остаточний вибір залежить від структури даних, складності запитів, вимог до продуктивності та досвіду команди.