Технически, мы бы могли изучить работу REST API без использования баз данных, сохраняя данные в памяти. Но в таком случае, понимание устройства таких сервисов было бы не полным. Поэтому для полноценного погружения понадобится взаимодействие с базой данных.
В Node.js с этим есть определенные сложности из-за разрозненности комьюнити. Есть десятки решений для работы с базой, начиная от простых драйверов, заканчивая навороченными ORM, среди которых нет одного явного лидера. Часть этих решений заточено под TS, часть под JS, но устарело, что-то является полноценным ORM, что-то нет. Некоторые даже вводят свои языки описания.
Из-за этого не так просто выбрать решение, которое бы подошло всем. На текущий момент, пожалуй, самыми перспективным является проект Drizzle. Именно его мы и будем использовать. В рамках этого урока мы выполним две задачи:
- Подключим его к проекту и настроим работу с ним.
- Научимся им пользоваться.
Drizzle хотя и называется ORM, фактически это продвинутый билдер запросов (query builder), который отлично работает и в JavaScript и в TypeScript. Drizzle поддерживает множество разных баз данных, предоставляя единообразный (почти) интерфейс для работы с ними. Поэтому для простоты, в этом курсе мы будем использовать базу данных sqlite в памяти. В таком случае нам не придется поднимать базу данных отдельно и заниматься ее поддержкой и очисткой. Особенно это удобно для тестов.
Установка и подключение к Fastify
Для начала установим нужные пакеты:
npm i drizzle-orm better-sqlite3
npm i -D drizzle-kit @faker-js/faker
Затем создадим конфигурационный файл drizzle.config.js:
export default {
dialect: 'sqlite',
schema: './db/schema.js',
}
Подключение Drizzle к Fastify делается через плагины. Добавьте файл plugins/drizzle.js с таким содержимым:
import fp from 'fastify-plugin'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
// Начальные данные для базы
import seed from '../db/seeds.js'
// Описание схемы базы данных
import * as schemas from '../db/schema.js'
export default fp(async function (fastify) {
const sqlite = new Database(':memory:')
const db = drizzle(sqlite, { schema: schemas })
// Автоматическое выполнение миграций
migrate(db, { migrationsFolder: 'drizzle' })
// Заполнение базы данных данными
await seed(db)
if (!fastify.db) {
fastify.decorate('db', db)
fastify.addHook('onClose', () => {
sqlite.close()
})
}
})
В этом коде выполняется подключение к базе данных, ее подготовка и добавление объекта db
в Fastify. Через этот объект, мы будем взаимодействовать с базой данных внутри обработчиков запросов, например:
export default async function (fastify, opts) {
const { db } = fastify
fastify.get('/users', async function (request, reply) {
const users = await db.query
.users
.findMany()
return users
})
}
Что входит в подготовку базы данных?
Применение миграций
Миграции, это файлы с sql-кодом, которые меняют схему базы данных приводя ее в нужное состояние. Миграции это способ, которым база данных изменяется со временем.
В Drizzle используется Code First подход. То есть схема данных описывается в коде, на основе которого генерируются миграции и, затем, применяются к базе данных.
Обычно, миграции применяются через явно вызываемую команду из командной строки. В нашем случае можно проще, так как мы работаем с базой данных в памяти, она будет уничтожена после остановки приложения.
Загрузка начальных данных
Для простоты, базу данных можно сразу заполнить данными упростив работу во время разработки и тестирования. Такие данные обычно называют сидами. Опять же, из-за работы с базой в памяти, мы можем грузить сиды при старте Fastify, так как после остановки база данных пропадает.
Создание структуры
Для работы с Drizzle нам нужно выполнить следующие шаги:
- Описать схему базы данных.
- Сгенерировать миграции на базе схемы.
- Описать сиды для заполнения базы данных.
Схема данных
В этом курсе мы будем работать с моделью данных описывающей пользователей, курсы и уроки, где мы можем создавать пользователей, пользователи могут создавать курсы с уроками и просматривать их.
Создайте файл db/schema.js со следующим содержимым:
import { sql } from 'drizzle-orm'
import {
text,
integer,
sqliteTable,
} from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey(),
fullName: text('full_name'),
email: text('email').notNull().unique(),
updatedAt: text('updated_at'),
createdAt: text('created_at')
.notNull()
.default(sql`(unixepoch())`),
})
export const courses = sqliteTable('courses', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
creatorId: integer('creator_id').references(() => users.id).notNull(),
description: text('description').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(unixepoch())`),
})
export const courseLessons = sqliteTable('course_lessons', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
courseId: integer('courseId').references(() => courses.id).notNull(),
body: text('body').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(unixepoch())`),
})
Каждая константа описывает таблицу в базе. В описании задается имя таблицы, название полей в JS (ключи) и название полей в базе (значение). Для базы задается тип поля и если нужно, ограничения.
Когда структура описана, мы можем создать миграцию:
npx drizzle-kit generate
3 tables
course_lessons 5 columns 0 indexes 1 fks
courses 5 columns 0 indexes 1 fks
users 5 columns 1 indexes 0 fks
Эта команда создает директорию drizzle, в которой хранятся служебные файлы и файлы миграций. Директорию нужно добавить в git, но работать с ней напрямую мы не будем. Если что-то пойдет не так, в нашем случае, ее можно удалить сгенерировать заново.
И последние, создание сидов. Заполните файл db/seeds.js таким содержимым:
import * as schemas from './schema.js'
import { buildCourse, buildCourseLesson, buildUser } from '../lib/data.js'
/**
* @param {import("drizzle-orm/better-sqlite3").BetterSQLite3Database<typeof schemas>} db
*/
export default async (db) => {
const [user1] = await db.insert(schemas.users).values(buildUser()).returning()
const [user2] = await db.insert(schemas.users).values(buildUser()).returning()
const [course1] = await db.insert(schemas.courses).values(
buildCourse({ creatorId: user2.id }),
).returning()
const [course2] = await db.insert(schemas.courses).values(
buildCourse({ creatorId: user2.id }),
).returning()
await db.insert(schemas.courseLessons).values(
buildCourseLesson({ courseId: course2.id }),
)
}
Здесь мы видим код использования Drizzle. Принципы работы с базой через Drizzle мы рассмотрим чуть позже, а сейчас добавим еще один файл, нужный для работы с сидами. Это файл с функциями генерирующими данные для табличек. Они нужны для устранения дублирования, когда мы начнем писать тесты и нам понадобится создавать сущности. Создайте файл lib/data.js и скопируйте туда код ниже:
import { faker } from '@faker-js/faker'
export function buildUser(params = {}) {
const user = {
fullName: faker.person.fullName(),
email: faker.internet.email(),
}
return Object.assign({}, user, params)
}
export function buildCourse(params = {}) {
const user = {
creatorId: null,
name: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
}
return Object.assign({}, user, params)
}
export function buildCourseLesson(params = {}) {
const lesson = {
courseId: null,
name: faker.lorem.sentence(),
body: faker.lorem.paragraph(),
}
return Object.assign({}, lesson, params)
}
Здесь мы используем faker, для генерации нужных данных с возможностью их переопределить через параметры функции. Эти функции используются в сидах и будут использоваться в тестах.
Работа с Drizzle
Drizzle пытается быть максимально близким к SQL. Запросы написанные с его помощью, работают именно так как написано, в отличие от ORM, в которых запросы часто скрыты за высокоуровневыми командами. У такого подхода есть и плюсы и минусы. Плюс в том, что нет никакой магии и для работы с Drizzle достаточно знать SQL, минус же, связан с необходимостью писать больше кода чем в классических ORM, которые автоматизируют многие задачи.
Соберем все вместе и посмотрим на то как выполняются запросы с помощью Drizzle.
Выборки
Для начала изучим выборку данных.
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { asc } from 'drizzle-orm'
// Описание схемы
const users = sqliteTable('users', {
id: integer('id').primaryKey(),
fullName: text('full_name'),
email: text('email').notNull().unique(),
updatedAt: text('updated_at'),
createdAt: text('created_at')
.notNull()
.default(sql`(unixepoch())`),
})
// Установка соединения
const sqlite = new Database(':memory:')
// Интерфейс работы с базой. Конфигурируется схемами
const db = drizzle(sqlite, { schema: [users] })
const users = await db.query
.users
.findMany({
orderBy: asc(schemas.users.id),
limit: 10,
offset: 2,
})
В этом коде выбираются пользователи отсортированные в порядке возрастания id. Что можно сказать глядя на код:
- Запросы к базе выполняются асинхронно.
db.query.users
- последний объект появляется внутри благодаря тому, что функцияdrizzle()
создает объектdb
на базе переданных схем таблиц.findMany()
возвращает коллекцию объектов, где каждый объект содержит поля соответствующие ключам в схеме (id
,fullName
,email
и т.п.)- Условия, сортировка и другие манипуляции с данными делаются с помощью объекта переданного в
findMany()
. Все параметры внутри него не обязательны
Самое интересное тут это язык описания используемый в сортировке. Он использует метод asc()
для указания способа сортировки, в который передается поле из схемы. Последнее содержит метаинформацию о поле, а не конкретное значение.
Очень похоже выглядит фильтрация:
import { eq } from 'drizzle-orm'
// eq - это предикат, который указывает как сравнивать данные
const users = await db.query.users.findMany({
where: eq(schemas.users.fullName, 'Jonny Depp'),
})
В примере выше извлекаются все пользователи с именем Jonny Depp. Если нам нужен один пользователь, то мы можем задать лимит:
// findMany возвращает коллекцию даже если элемент один
const [user] = await db.query.users.findMany({
where: eq(schemas.users.fullName, 'Jonny Depp'),
limit: 1,
})
Либо воспользоваться методом findFirst()
:
const user = await db.query.users.findFirst({
where: eq(schemas.users.fullName, 'Jonny Depp'),
})
Во всех этих случаях возможна ситуация, когда записей в базе нет. Это не приводит к ошибке, как и в случае с прямой работой с SQL.
Изменение данных
Создание, обновление и удаление данных выглядят очень похоже, поэтому рассмотрим их все вместе.
const data = { fullName: 'Tota', email: 'support@hexlet.io' }
const [user] = await db.insert(schemas.users)
.values(data)
.returning()
const [user] = await db.update(schemas.users)
.set(data)
.where(eq(schemas.users.id, userId))
.returning()
const [user] = await db.delete(schemas.users)
.where(eq(schemas.users.id, userId))
.returning()
Все три запроса имеют общие элементы. В каждый из них передается схема, с которой идет работа, в данном случае users. Каждый запрос ничего не возвращает, если не добавлен метод returning()
. Когда он добавлен, то возвращаются те строки, которые были затронуты запросом. И это всегда массив, даже если была затронута одна запись, поэтому во всех примерах используется деструктуризация.
Самостоятельная работа
- Выполните все шаги из урока по подготовке базы данных:
- Подключите базу данных
- Подготовьте схемы для пользователей, курсов и уроков. Сгенерируйте миграции
- Добавьте генерацию данных
- Запушьте изменения в репозиторий
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.