Пошук уроків, статей та іншого контенту
Налаштуєте Kysely і будуватимете типобезпечні SQL-запити з контролем над їхньою структурою та виконанням.
Kysely — це типобезпечний SQL query builder для Node.js і TypeScript. Він дає змогу:
будувати SQL-запити через TypeScript API;
отримувати помилки під час компіляції, якщо назва таблиці або колонки неправильна;
контролювати структуру запиту;
виконувати запит лише в потрібний момент;
використовувати транзакції;
працювати з SQL без приховування його основної структури.
Kysely не є ORM. Він не перетворює рядки бази даних на класи та не приховує SQL за моделями. Ви явно визначаєте таблиці, колонки, умови, сортування та спосіб виконання запиту.
Для прикладу використаємо SQLite і драйвер better-sqlite3.
npm install kysely better-sqlite3
npm install --save-dev typescript tsx @types/node @types/better-sqlite3Створіть файл tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}У package.json можна додати команду:
{
"scripts": {
"dev": "tsx src/index.ts"
}
}Kysely отримує тип бази даних як generic-параметр:
Kysely<DB>Тип DB описує таблиці та їхні колонки:
interface UserTable {
id: number;
email: string;
name: string;
age: number | null;
}
interface DB {
users: UserTable;
}Тепер Kysely знає:
що в базі є таблиця users;
які колонки доступні;
які типи мають значення;
який тип результату потрібно повернути для select.
Цей опис не створює таблицю автоматично. Він лише повідомляє TypeScript, якою має бути структура бази. Якщо реальна база відрізняється від типів, Kysely не виправить її автоматично.
Для SQLite потрібно створити діалект і передати йому підключення до бази:
import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
const sqlite = new Database(":memory:");
const db = new Kysely<DB>({
dialect: new SqliteDialect({
database: sqlite
})
});":memory:" створює базу даних у пам’яті. Вона зручна для прикладів і тестів, але всі дані буде втрачено після завершення процесу.
Для файлової бази можна передати шлях:
const sqlite = new Database("app.db");Kysely має schema builder для операцій зі схемою:
await db.schema
.createTable("users")
.ifNotExists()
.addColumn("id", "integer", (column) =>
column.primaryKey().autoIncrement()
)
.addColumn("email", "text", (column) =>
column.notNull().unique()
)
.addColumn("name", "text", (column) =>
column.notNull()
)
.addColumn("age", "integer")
.execute();Метод execute() фактично виконує SQL. До цього моменту builder лише описує операцію.
Основні модифікатори колонки:
notNull() — забороняє NULL;
unique() — вимагає унікального значення;
primaryKey() — робить колонку первинним ключем;
autoIncrement() — автоматично збільшує числовий ідентифікатор.
const insertedUser = await db
.insertInto("users")
.values({
email: "anna@example.com",
name: "Анна",
age: 28
})
.returning(["id", "email", "name", "age"])
.executeTakeFirst();Для SQLite підтримка returning залежить від версії SQLite. Якщо returning недоступний у конкретному середовищі, можна виконати вставку без нього:
const result = await db
.insertInto("users")
.values({
email: "anna@example.com",
name: "Анна",
age: 28
})
.executeTakeFirst();
console.log(result.insertId);Типи полів перевіряються під час компіляції. Наприклад, такі значення викличуть помилки TypeScript:
await db
.insertInto("users")
.values({
email: 123,
name: "Анна",
age: "28"
})
.execute();email має бути рядком, а age — числом або null.
const users = await db
.selectFrom("users")
.select(["id", "email", "name", "age"])
.orderBy("name", "asc")
.execute();Тип users буде виведено автоматично:
Array<{
id: number;
email: string;
name: string;
age: number | null;
}>Можна вибрати всі колонки:
const users = await db
.selectFrom("users")
.selectAll()
.execute();Але явний список колонок зазвичай кращий: він показує, які дані дійсно потрібні запиту.
const adults = await db
.selectFrom("users")
.selectAll()
.where("age", ">=", 18)
.orderBy("age", "desc")
.execute();Для кількох умов можна використовувати callback із expression builder:
const users = await db
.selectFrom("users")
.selectAll()
.where((expressionBuilder) =>
expressionBuilder.and([
expressionBuilder("age", ">=", 18),
expressionBuilder("name", "like", "А%")
])
)
.execute();Kysely параметризує значення під час генерації SQL. Не потрібно самостійно додавати значення в SQL-рядок.
executeTakeFirst() повертає перший результат або undefined:
const user = await db
.selectFrom("users")
.selectAll()
.where("email", "=", "anna@example.com")
.executeTakeFirst();
if (!user) {
console.log("Користувача не знайдено");
} else {
console.log(user.name);
}executeTakeFirstOrThrow() кидає помилку, якщо результату немає:
const user = await db
.selectFrom("users")
.selectAll()
.where("id", "=", 1)
.executeTakeFirstOrThrow();Вибирайте метод залежно від логіки програми:
executeTakeFirst() — запис може бути відсутнім;
executeTakeFirstOrThrow() — запис повинен існувати.
const result = await db
.updateTable("users")
.set({
name: "Анна Коваль",
age: 29
})
.where("email", "=", "anna@example.com")
.executeTakeFirst();
console.log(result.numUpdatedRows);Умова where важлива. Без неї оновляться всі записи таблиці.
const result = await db
.deleteFrom("users")
.where("email", "=", "anna@example.com")
.executeTakeFirst();
console.log(result.numDeletedRows);Так само, як і для update, відсутність where означає операцію над усіма записами.
Побудова запиту і його виконання — різні операції.
Цей код лише створює builder:
const query = db
.selectFrom("users")
.select(["id", "name"])
.where("age", ">=", 18);Запит до бази буде виконано лише після виклику:
const users = await query.execute();Залежно від потреби можна використати різні методи виконання:
execute() — отримати всі результати;
executeTakeFirst() — отримати перший результат;
executeTakeFirstOrThrow() — отримати перший результат або помилку.
Це дає змогу будувати запит поетапно:
let query = db
.selectFrom("users")
.selectAll();
query = query.where("age", ">=", 18);
const users = await query
.orderBy("name", "asc")
.execute();Для складніших виразів можна використати sql із Kysely:
import { sql } from "kysely";
const result = await db
.selectFrom("users")
.select(({ fn }) => [
fn.count<number>("id").as("totalUsers")
])
.executeTakeFirstOrThrow();
console.log(result.totalUsers);Для SQLite результат count може бути представлений як number, але конкретний тип залежить від драйвера та опису типу. Generic-параметр fn.count<number>() повідомляє TypeScript, який тип очікується у вашому коді.
Інший варіант — SQL-фрагмент:
const result = await db
.selectFrom("users")
.select([
"name",
sql<number>`length(name)`.as("nameLength")
])
.execute();sql варто використовувати для SQL-конструкцій, яких немає у зручному builder API. Значення потрібно передавати як параметри, а не вставляти в SQL через конкатенацію рядків.
Транзакція об’єднує кілька операцій. Якщо всередині callback виникне помилка, зміни буде скасовано.
await db.transaction().execute(async (transaction) => {
await transaction
.insertInto("users")
.values({
email: "olena@example.com",
name: "Олена",
age: 31
})
.execute();
await transaction
.updateTable("users")
.set({ age: 32 })
.where("email", "=", "olena@example.com")
.execute();
});Усередині транзакції потрібно використовувати об’єкт transaction, а не основний db. Інакше окрема операція може виконуватися поза транзакцією.
Файл src/index.ts:
import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
interface UserTable {
id: number;
email: string;
name: string;
age: number | null;
}
interface DB {
users: UserTable;
}
async function main(): Promise<void> {
const sqlite = new Database(":memory:");
const db = new Kysely<DB>({
dialect: new SqliteDialect({
database: sqlite
})
});
try {
await db.schema
.createTable("users")
.ifNotExists()
.addColumn("id", "integer", (column) =>
column.primaryKey().autoIncrement()
)
.addColumn("email", "text", (column) =>
column.notNull().unique()
)
.addColumn("name", "text", (column) =>
column.notNull()
)
.addColumn("age", "integer")
.execute();
await db
.insertInto("users")
.values([
{
email: "anna@example.com",
name: "Анна",
age: 28
},
{
email: "petro@example.com",
name: "Петро",
age: 17
},
{
email: "olena@example.com",
name: "Олена",
age: 35
}
])
.execute();
const adults = await db
.selectFrom("users")
.select(["id", "email", "name", "age"])
.where("age", ">=", 18)
.orderBy("name", "asc")
.execute();
console.log("Повнолітні користувачі:");
console.log(adults);
const user = await db
.selectFrom("users")
.selectAll()
.where("email", "=", "anna@example.com")
.executeTakeFirst();
console.log("Знайдений користувач:");
console.log(user);
const updateResult = await db
.updateTable("users")
.set({ age: 29 })
.where("email", "=", "anna@example.com")
.executeTakeFirst();
console.log(`Оновлено записів: ${updateResult.numUpdatedRows}`);
const usersAfterUpdate = await db
.selectFrom("users")
.selectAll()
.orderBy("id", "asc")
.execute();
console.log("Дані після оновлення:");
console.log(usersAfterUpdate);
} finally {
await db.destroy();
}
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});Запуск:
npm run devУ цьому прикладі:
описано тип DB;
створено підключення до SQLite;
створено таблицю;
додано кілька записів;
виконано типобезпечний select;
отримано один запис;
оновлено запис;
коректно закрито підключення через db.destroy().
Kysely перевіряє структуру запиту під час компіляції:
const users = await db
.selectFrom("users")
.select(["id", "name"])
.where("age", ">=", 18)
.execute();Якщо вказати неіснуючу таблицю:
db.selectFrom("customers");або неіснуючу колонку:
db.selectFrom("users").select("username");TypeScript повідомить про помилку ще до запуску програми.
Типобезпечність залежить від актуальності інтерфейсу DB. Якщо змінити таблицю в базі, потрібно також оновити типи. У реальних проєктах тип DB часто генерують зі схеми бази або підтримують разом із міграціями.
execute()Builder сам по собі не виконує запит:
const query = db
.selectFrom("users")
.selectAll();Щоб отримати результат, потрібно викликати:
const users = await query.execute();executeTakeFirst()Результат може бути undefined:
const user = await db
.selectFrom("users")
.selectAll()
.where("id", "=", 999)
.executeTakeFirst();Потрібно перевірити значення перед доступом до його властивостей або використати executeTakeFirstOrThrow(), якщо відсутність запису є помилкою.
Якщо в інтерфейсі вказати:
interface UserTable {
age: number;
}але в базі age може бути NULL, тип буде неправильним. У такому разі потрібно описати колонку як:
age: number | null;whereЦі операції можуть змінити всі записи:
await db
.deleteFrom("users")
.execute();Перед виконанням перевіряйте, чи справді потрібна операція над усією таблицею.
db усередині транзакціїУ callback транзакції використовуйте переданий об’єкт:
await db.transaction().execute(async (transaction) => {
await transaction
.updateTable("users")
.set({ age: 30 })
.where("id", "=", 1)
.execute();
});Не замінюйте transaction на db, якщо операція повинна бути частиною транзакції.
Kysely — типобезпечний SQL query builder для TypeScript.
Тип DB описує таблиці та колонки, доступні в запитах.
selectFrom, insertInto, updateTable і deleteFrom будують SQL-запити.
Запит виконується лише після виклику execute(), executeTakeFirst() або executeTakeFirstOrThrow().
Kysely перевіряє назви таблиць, колонок і типи значень під час компіляції.
Для складних SQL-виразів можна використовувати sql.
Пов’язані зміни потрібно виконувати через transaction().execute(...).
Типи TypeScript мають відповідати реальній схемі бази даних.