Зарегистрируйтесь, чтобы продолжить обучение

Интеграция с базой данных JS: REST API (Fastify)

Технически, мы бы могли изучить работу 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-кодом, которые меняют схему базы данных приводя ее в нужное состояние. Миграции это способ, которым база данных изменяется со временем.

Миграции в Dizzle

В 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(). Когда он добавлен, то возвращаются те строки, которые были затронуты запросом. И это всегда массив, даже если была затронута одна запись, поэтому во всех примерах используется деструктуризация.


Самостоятельная работа

  1. Выполните все шаги из урока по подготовке базы данных:
    1. Подключите базу данных
    2. Подготовьте схемы для пользователей, курсов и уроков. Сгенерируйте миграции
    3. Добавьте генерацию данных
  2. Запушьте изменения в репозиторий

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff