Kysely для TypeScript: полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Kysely для TypeScript: полное руководство по type-safe SQL query builder

1. Что это и зачем нужна

Kysely (произносится как "ки-се-ли") — это современный, type-safe SQL query builder для TypeScript и Node.js, который позволяет вам писать SQL-запросы с полной поддержкой типов на всех этапах: от построения запроса до получения результата. В отличие от традиционных ORM (Object-Relational Mapping), таких как TypeORM или Prisma, Kysely не абстрагирует SQL, а предоставляет удобный API для его написания, сохраняя при этом полный контроль над генерируемыми запросами. Это означает, что вы можете использовать всю мощь SQL (JOIN, подзапросы, оконные функции, CTE) без необходимости бороться с ограничениями ORM.

Основная проблема, которую решает Kysely — это безопасность типов в SQL-запросах. В обычном JavaScript/TypeScript коде SQL-запросы часто пишутся как строки, что приводит к ошибкам во время выполнения (runtime errors): опечатки в названиях колонок, несоответствие типов данных, неправильные JOIN. Kysely решает эту проблему на уровне компиляции: если вы попытаетесь обратиться к несуществующей колонке или передать неправильный тип данных, TypeScript выдаст ошибку еще до запуска приложения. Это значительно сокращает время отладки и повышает надежность кода.

Kysely идеально подходит для проектов, где требуется максимальная производительность и контроль над SQL, но при этом хочется иметь все преимущества статической типизации. Он поддерживает PostgreSQL, MySQL, SQLite и MSSQL, имеет встроенную защиту от SQL-инъекций (через параметризованные запросы), поддержку миграций и генерации типов из существующей схемы базы данных. Библиотека активно развивается, имеет хорошую документацию и сообщество.

2. Установка

Установка Kysely выполняется через npm (или yarn/pnpm). Вам потребуется установить саму библиотеку и драйвер для вашей базы данных.

# Основной пакет
npm install kysely

# Для PostgreSQL
npm install pg

# Для MySQL
npm install mysql2

# Для SQLite
npm install better-sqlite3

# Для MSSQL
npm install tedious

Если вы используете TypeScript, убедитесь, что у вас установлены типы для драйвера (обычно они идут в комплекте). Для лучшего опыта разработки рекомендуется также установить ts-node для запуска TypeScript файлов:

npm install -D ts-node typescript @types/node

Для работы с миграциями дополнительно установите:

npm install -D @kysely/cli

3. Быстрый старт — минимальный рабочий пример

Создадим простое приложение, которое подключается к SQLite базе данных, создает таблицу пользователей и выполняет несколько запросов.

Сначала определим типы для нашей базы данных:

// types.ts
export interface User {
  id: number;
  name: string;
  email: string;
  age: number | null;
  created_at: Date;
}

export interface Database {
  users: User;
  // Здесь можно добавить другие таблицы
}

Теперь создадим основное приложение:

// index.ts
import { Kysely, SqliteDialect } from 'kysely';
import Database from 'better-sqlite3';
import type { Database as MyDatabase } from './types';

// Создаем экземпляр Kysely
const db = new Kysely<MyDatabase>({
  dialect: new SqliteDialect({
    database: new Database('my_database.db'),
  }),
});

async function main() {
  // Создаем таблицу (если не существует)
  await db.schema
    .createTable('users')
    .ifNotExists()
    .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
    .addColumn('name', 'varchar(255)', (col) => col.notNull())
    .addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
    .addColumn('age', 'integer')
    .addColumn('created_at', 'datetime', (col) => col.defaultTo('now()'))
    .execute();

  // Вставляем данные
  await db
    .insertInto('users')
    .values([
      { name: 'Alice', email: 'alice@example.com', age: 30 },
      { name: 'Bob', email: 'bob@example.com', age: 25 },
      { name: 'Charlie', email: 'charlie@example.com', age: null },
    ])
    .execute();

  // Выбираем всех пользователей
  const users = await db.selectFrom('users').selectAll().execute();
  console.log('All users:', users);

  // Выбираем пользователя по id
  const user = await db
    .selectFrom('users')
    .select(['name', 'email'])
    .where('id', '=', 1)
    .executeTakeFirst();
  console.log('User with id 1:', user);

  // Обновляем возраст
  await db
    .updateTable('users')
    .set({ age: 31 })
    .where('name', '=', 'Alice')
    .execute();

  // Удаляем пользователя
  await db.deleteFrom('users').where('name', '=', 'Charlie').execute();

  // Закрываем соединение
  await db.destroy();
}

main().catch(console.error);

Запустите код:

npx ts-node index.ts

Вы увидите в консоли результаты запросов. Обратите внимание, что все запросы полностью типизированы: если вы попытаетесь обратиться к несуществующей колонке users.non_existent, TypeScript выдаст ошибку компиляции.

4. Основные методы/функции/классы

4.1. selectFrom() — начало SELECT запроса

Сигнатура: selectFrom<TableName extends keyof DB>(table: TableName): SelectQueryBuilder

Что делает: Инициализирует SELECT запрос к указанной таблице. Возвращает построитель запроса, который позволяет добавлять условия WHERE, JOIN, ORDER BY и другие.

const users = await db
  .selectFrom('users')
  .select(['id', 'name', 'email'])
  .where('age', '>', 18)
  .orderBy('name', 'asc')
  .limit(10)
  .execute();

console.log(users); // Массив объектов User

4.2. insertInto() — вставка данных

Сигнатура: insertInto<TableName extends keyof DB>(table: TableName): InsertQueryBuilder

Что делает: Создает INSERT запрос для вставки одной или нескольких строк в таблицу. Поддерживает массовую вставку через массив объектов.

// Вставка одной записи
await db
  .insertInto('users')
  .values({ name: 'David', email: 'david@example.com', age: 35 })
  .execute();

// Массовая вставка
await db
  .insertInto('users')
  .values([
    { name: 'Eve', email: 'eve@example.com', age: 28 },
    { name: 'Frank', email: 'frank@example.com', age: 32 },
  ])
  .execute();

// Вставка с возвратом данных (PostgreSQL)
const inserted = await db
  .insertInto('users')
  .values({ name: 'Grace', email: 'grace@example.com', age: 27 })
  .returning(['id', 'name', 'email'])
  .executeTakeFirst();

console.log(inserted); // { id: 5, name: 'Grace', email: 'grace@example.com' }

4.3. updateTable() — обновление данных

Сигнатура: updateTable<TableName extends keyof DB>(table: TableName): UpdateQueryBuilder

Что делает: Создает UPDATE запрос для обновления записей в таблице. Позволяет задать условия WHERE и обновляемые поля.

// Простое обновление
await db
  .updateTable('users')
  .set({ age: 26 })
  .where('name', '=', 'Bob')
  .execute();

// Обновление с использованием выражений
await db
  .updateTable('users')
  .set({ age: (eb) => eb('age', '+', 1) }) // Увеличить возраст на 1
  .where('id', '=', 1)
  .execute();

// Обновление с возвратом данных
const updated = await db
  .updateTable('users')
  .set({ name: 'Alice Smith' })
  .where('id', '=', 1)
  .returning(['id', 'name', 'email'])
  .executeTakeFirst();

console.log(updated); // { id: 1, name: 'Alice Smith', email: 'alice@example.com' }

4.4. deleteFrom() — удаление данных

Сигнатура: deleteFrom<TableName extends keyof DB>(table: TableName): DeleteQueryBuilder

Что делает: Создает DELETE запрос для удаления записей из таблицы. Важно всегда указывать условие WHERE, чтобы случайно не удалить все данные.

// Удаление по условию
await db
  .deleteFrom('users')
  .where('age', 'is', null)
  .execute();

// Удаление с возвратом данных
const deleted = await db
  .deleteFrom('users')
  .where('id', '=', 10)
  .returning(['id', 'name'])
  .executeTakeFirst();

console.log(deleted); // { id: 10, name: 'Charlie' } или undefined

4.5. where() — фильтрация запросов

Сигнатура: where<RE extends ReferenceExpression<DB, TB>>(lhs: RE, op: ComparisonOperator, rhs: OperandValueExpression<DB, TB, RE>): this

Что делает: Добавляет условие WHERE к запросу. Поддерживает все стандартные операторы сравнения: =, !=, >, <, >=, <=, in, is, like, between и другие.

// Простое условие
const adults = await db
  .selectFrom('users')
  .selectAll()
  .where('age', '>=', 18)
  .execute();

// Множественные условия (AND)
const specificUsers = await db
  .selectFrom('users')
  .selectAll()
  .where('age', '>', 18)
  .where('name', 'like', '%A%')
  .execute();

// Использование IN
const usersByIds = await db
  .selectFrom('users')
  .selectAll()
  .where('id', 'in', [1, 2, 3])
  .execute();

// Сложные условия с OR
const complexQuery = await db
  .selectFrom('users')
  .selectAll()
  .where((eb) => eb.or([
    eb('age', '<', 20),
    eb('age', '>', 60),
  ]))
  .execute();

4.6. join() — объединение таблиц

Сигнатура: join<TableName extends keyof DB>(table: TableName, on: JoinOnExpression<DB, TB, TableName>): this

Что делает: Добавляет JOIN к SELECT запросу. Поддерживает INNER JOIN (через join()), LEFT JOIN (через leftJoin()), RIGHT JOIN и FULL JOIN.

// Предположим, у нас есть таблица posts
interface Post {
  id: number;
  user_id: number;
  title: string;
  content: string;
}

interface Database {
  users: User;
  posts: Post;
}

// INNER JOIN
const usersWithPosts = await db
  .selectFrom('users')
  .innerJoin('posts', 'users.id', 'posts.user_id')
  .select([
    'users.id',
    'users.name',
    'posts.title',
    'posts.content',
  ])
  .execute();

// LEFT JOIN
const allUsersWithPosts = await db
  .selectFrom('users')
  .leftJoin('posts', 'users.id', 'posts.user_id')
  .select([
    'users.id',
    'users.name',
    'posts.title',
  ])
  .execute();

// JOIN с дополнительными условиями
const complexJoin = await db
  .selectFrom('users')
  .innerJoin('posts', (join) =>
    join
      .on('users.id', '=', 'posts.user_id')
      .on('posts.created_at', '>', new Date('2024-01-01'))
  )
  .select(['users.name', 'posts.title'])
  .execute();

4.7. orderBy() и limit() — сортировка и ограничение

Сигнатура: orderBy(ref: ReferenceExpression<DB, TB>, direction?: 'asc' | 'desc'): this
Сигнатура: limit(n: number): this

Что делает: orderBy() сортирует результаты по указанной колонке. limit() ограничивает количество возвращаемых строк. Часто используются вместе с offset() для пагинации.

// Сортировка по одному полю
const sortedUsers = await db
  .selectFrom('users')
  .selectAll()
  .orderBy('name', 'asc')
  .execute();

// Сортировка по нескольким полям
const multiSorted = await db
  .selectFrom('users')
  .selectAll()
  .orderBy('age', 'desc')
  .orderBy('name', 'asc')
  .execute();

// Пагинация
const page = 2;
const pageSize = 10;
const paginatedUsers = await db
  .selectFrom('users')
  .selectAll()
  .orderBy('id', 'asc')
  .limit(pageSize)
  .offset((page - 1) * pageSize)
  .execute();

4.8. executeTakeFirst() и executeTakeFirstOrThrow() — получение одной записи

Сигнатура: executeTake

Рекомендаций