Как стать автором
Обновить

Идеальный инструмент для работы с СУБД без SQL для Node.js или Все, что вы хотели знать о Sequelize. Часть 3

Время на прочтение35 мин
Количество просмотров18K

Доброго времени суток, друзья!


Представляю вашему вниманию руководство по Sequelize.


Sequelize — это ORM (Object-Relational Mapping — объектно-реляционное отображение или преобразование) для работы с такими СУБД (системами управления (реляционными) базами данных, Relational Database Management System, RDBMS), как Postgres, MySQL, MariaDB, SQLite и MSSQL. Это далеко не единственная ORM для работы с названными базами данных (далее — БД), но, на мой взгляд, одна из самых продвинутых и, что называется, "battle tested" (проверенных временем).


ORM хороши тем, что позволяют взаимодействовать с БД на языке приложения (JavaScript), т.е. без использования специально предназначенных для этого языков (SQL). Тем не менее, существуют ситуации, когда запрос к БД легче выполнить с помощью SQL (или можно выполнить только c помощью него). Поэтому перед изучением настоящего руководства рекомендую бросить хотя бы беглый взгляд на SQL. Вот соответствующая шпаргалка.


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


Первая часть.
Вторая часть.


Я постараюсь быть максимально лаконичным (надеюсь, без ущерба для полноты изложения материала). Я также постараюсь излагать материал максимально простым языком. Большинство примеров, приводимых в руководстве, заимствованы из официальной документации.


Содержание



Область видимости ассоциаций


Область видимости ассоциаций (assosiation scopes) похожа на области видимости моделей в том, что обе автоматически применяют к запросам такие вещи, как предложение where; разница между ними состоит в том, что область модели применяется к вызовам статических методов для поиска, а область ассоциации — к вызовам поисковых методов экземпляра (таким как миксины).


Пример применения области ассоциации для отношений один-ко-многим:


// настройка
const Foo = sequelize.define('foo', { name: DataTypes.STRING })
const Bar = sequelize.define('bar', { status: DataTypes.STRING })
Foo.hasMany({Bar, {
  scope: {
    status: 'open'
  },
  as: 'openBars'
}})

await sequelize.sync()
const foo = await Foo.create({ name: 'Foo' })

await foo.getOpenBars()

Последний вызов приводит к генерации такого запроса:


SELECT
    `id`, `status`, `createdAt`, `updatedAt`, `fooId`
FROM `bars` AS `bar`
WHERE `bar`.`status` = 'open' AND `bar`.`fooId` = 1;

Мы видим, что область ассоциации { status: 'open' } была автоматически добавлена в предложение WHERE.


На самом деле, мы можем добиться такого же поведения с помощью стандартной области видимости:


Bar.addScope('open', {
  where: {
    status: 'open',
  },
})
Foo.hasMany(Bar)
Foo.hasMany(Bar.scope('open'), { as: 'openBars' })

После этого, вызов foo.getOpenBars() вернет аналогичный результат.


↑ Наверх

Полиморфные ассоциации


Полиморфная ассоциация (polymorphic assosiation) состоит из двух и более ассоциаций, взаимодействующих с одним внешним ключом.


Предположим, что у нас имеется три модели: Image, Video и Comment. Первые две модели — это то, что может разместить пользователь. Мы хотим разрешить комментирование этих вещей. На первый вгляд, может показаться, что требуются такие ассоциации:


  • ассоциация один-ко-многим между Image и Comment

Image.hasMany(Comment)
Comment.belongsTo(Image)

  • ассоциация один-ко-многим между Video и Comment

Video.hasMany(Comment)
Comment.belongsTo(Video)

Однако, это может привести к тому, что Sequelize создаст в таблице Comment два внешних ключа: imageId и videoId. Такая структура означает, что комментарий добавляется одновременно к одному изображению и одному видео, что не соответствует действительности. Нам нужно, чтобы Comment указывал на единичный Commentable, абстрактную полиморфную сущность, представляющую либо Image, либо Video.


Перед настройкой такой ассоциации, рассмотрим пример ее использования:


const image = await Image.create({ url: 'http://example.com' })
const comment = await image.createComment({ content: 'Круто!' })

copnsole.log(comment.commentableId === image.id) //  true

// Мы можем получать информацию о том, с каким типом `commentable` связан комментарий
console.log(comment.commentableType) // Image

// Мы можем использовать полиморфный метод для извлечения связанного `commentable`,
// независимо от того, чем он является, `Image` или `Video`
const associatedCommentable = await comment.getCommentable()
// Обратите внимание: `associatedCommentable` - это не тоже самое, что `image`

Создание полиассоциации один-ко-многим


Для настройки полиассоциации для приведенного выше примера (полиассоциации один-ко-многим) необходимо выполнить следующие шаги:


  • определить строковое поле commentableType в модели Comment
  • определить ассоциацию hasMany и belongsTo между Image / Video и Comment
    • отключить ограничения ({ constraints: false }), поскольку один и тот же внешний ключ будет ссылаться на несколько таблиц
    • определить соответствущую область видимости ассоциации
  • для поддержки ленивой загрузки — определить новый метод экземпляра getCommentable() в модели Comment, который под капотом будет вызывать правильный миксин для получения соответствующего commentable
  • для поддержки нетерпеливой загрузки — определить хук afterFind() в модели Comment, автоматически заполняющий поле commentable каждого экземпляра
  • для предотвращения ошибок при нетерпеливой загрузке, можно удалять поля image и video из экземпляров комментария в хуке afterFind(), оставляя в них только абстрактное поле commentable

// Вспомогательная функция
const capitilize = ([first, ...rest]) =>
  `${first.toUpperCase()}${rest.join('').toLowerCase()}`

const Image = sequelize.define('image', {
  title: DataTypes.STRING,
  url: DataTypes.STRING,
})
const Video = sequelize.define('video', {
  title: DataTypes.STRING,
  text: DataTypes.STRING,
})

// в данном случае нам необходимо создать статическое поле, поэтому мы используем расширение `Model`
class Comment extends Model {
  getCommentable(options) {
    if (!this.commentableType) return Promise.resolve(null)
    const mixinMethodName = `get${capitilize(this.commentableType)}`
    return this[mixinMethodName](options)
  }
}
Comment.init(
  {
    title: DataTypes.STRING,
    commentableId: DataTypes.INTEGER,
    commentableType: DataTypes.STRING,
  },
  { sequelize, modelName: 'comment' }
)

Image.hasMany(Comment, {
  foreignKey: 'commentableId',
  constraints: false,
  scope: {
    commentableType: 'image',
  },
})
Comment.belongsTo(Image, { foreignKey: 'commentableId', constraints: false })

Video.hasMany(Comment, {
  foreignKey: 'commentableId',
  constraints: false,
  scope: {
    commentableType: 'video',
  },
})
Comment.belongsTo(Video, { foreignKey: 'commentableId', constraints: false })

Comment.addHook('afterFind', (findResult) => {
  if (!Array.isArray(findResult)) findResult = [findResult]
  for (const instance of findResult) {
    if (instance.commentableType === 'image' && instance.image !== undefined) {
      instance.commentable = instance.image
    } else if (
      instance.commentableType === 'video' &&
      instance.video !== undefined
    ) {
      instance.commentable = instance.video
    }
    // Для предотвращения ошибок
    delete instance.image
    delete inctance.dataValues.image
    delete instance.video
    delete instance.dataValues.video
  }
})

Поскольку колонка commentableId ссылается на несколько таблиц (в данном случае две), мы не можем применить к ней ограничение REFERENCES. Поэтому мы указываем constraints: false.


Обратите внимание на следующее:


  • ассоциация Image -> Comment определяет область { commentableType: 'image' }
  • ассоциация Video -> Comment определяет область { commentableType: 'video' }

Эти области автоматически применяются при использовании ассоциативных функций. Несколько примеров:


  • image.getComments()

SELECT 'id', 'title', 'commentableType', 'commentableId', 'createdAt', 'updatedAt'
FROM 'comments' AS 'comment'
WHERE 'comment'.'commentableType' = 'image' AND 'comment'.'commentableId' = 1;

Мы видим, что 'comment'.'commentableType' = 'image' была автоматически добавлена в предложение WHERE. Это именно то, чего мы хотели добиться.


  • image.createComment({ title: 'Круто!' })

INSERT INTO 'comments' (
  'id', 'title', 'commentableType', 'commentableId', 'createdAt', 'updatedAt'
) VALUES (
  DEFAULT, 'Круто!', 'image', 1,
  '[timestamp]', '[timestamp]'
) RETURNING *;

  • image.addComment(comment)

UPDATE 'comments'
SET 'commentableId'=1, 'commentableType'='image', 'updatedAt'='2018-04-17 05:38:43.948 +00:00'
WHERE 'id' IN (1)

Полиморфная ленивая загрузка


Метод экземпляра getCommentable() предоставляет абстракцию для ленивой загрузки связанного commentable — комментария, принадлежащего Image или Video.


Это работает благодаря преобразованию строки commentableType в вызов правильного миксина (getImage() или getVideos(), соответственно).


Обратите внимание, что приведенная выше реализация getCommentable():


  • возвращает null при отсутствии ассоциации
  • позволяет передавать объект с настройками в getCommentable(options), подобно любому другому (стандартному) методу. Это может пригодиться, например, при определении условий или включений.

Полиморфная нетерпеливая загрузка


Теперь мы хотим выполнить полиморфную нетерпеливую загрузку связанных commentable для одного (или более) комментария:


const comment = await Comment.findOne({
  include: [
    /* Что сюда поместить? */
  ],
})
console.log(comment.commentable) // Наша цель

Решение состоит во включении Image и Video для того, чтобы хук afterFind() мог автоматически добавить поле commentable в экземпляр.


Например:


const comments = await Comment.findAll({
  include: [Image, Video],
})
for (const comment of comments) {
  const message = `Найден комментарий #${comment.id} с типом '${comment.commentableType}':\n`
  console.log(message, comment.commentable.toJSON())
}

Вывод:


Найден комментарий #1 с типом 'image':
{
  id: 1,
  title: 'Круто!',
  url: 'http://example.com',
  createdAt: [timestamp],
  updatedAt: [timestamp]
}

Настройка полиассоциации многие-ко-многим


Предположим, что вместо комментариев у нас имеются теги. Соответственно, вместо commentables у нас будут taggables. Один taggable может иметь несколько тегов, в то же время один тег может быть помещен в несколько taggable.


Для настройки рассматриваемой полиассоциации необходимо выполнить следующие шаги:


  • явно создать соединительную модель, определив в ней два внешних ключа: tagId и taggableId (данная таблица будет соединять Tag и taggable)
  • определить в соединительной таблице строковое поле taggableType
  • определить ассоциацию belongsToMany() между двумя моделями и Tag:
  • отключить ограничения ({ constraints: false }), поскольку один и тот же внешний ключ будет ссылаться на несколько таблиц
  • определить соответствующие области видимости ассоциаций
  • определить новый метод экземпляра getTaggables() в модели Tag, который под капотом будет вызывать правильный миксин для получения соответствующих taggables

class Tag extends Model {
  getTaggables(options) {
    const images = await this.getImages(options)
    const videos = this.getVideos(options)
    // Объединяем изображения и видео в один массив `taggables`
    return images.concat(videos)
  }
}
Tag.init({
  name: DataTypes.STRING
}, { sequelize, moelName: 'tag' })

// Явно определяем соединительную таблицу
const Tag_Taggable = sequelize.define('tag_taggable', {
  tagId: {
    type: DataTypes.INTEGER,
    unique: 'tt_unique_constraint'
  },
  taggableId: {
    type: DataTypes.INTEGER,
    unique: 'tt_unique_contraint'
  },
  taggableType: {
    type: DataTypes.STRING,
    unique: 'tt_unique_constraint'
  }
})

Image.belongsToMany(Tag, {
  through: {
    model: Tag_Taggable,
    unique: false,
    scope: {
      taggableType: 'image'
    }
  },
  foreignKey: 'taggableId',
  constraints: false
})
Tag.belongsToMany(Image, {
  through: {
    model: Tag_Taggable,
    unique: false,
    foreignKey: 'tagId',
    constraints: false
  }
})

Video.belongsToMany(Tag, {
  through: {
    model: Tag_Taggable,
    unique: false,
    scope: {
      taggableType 'video'
    }
  },
  foreignKey: 'taggableId',
  constraints: false
})
Tag.belongsToMany(Video, {
  through: {
    model: Tag_Taggable,
    unique: false
  },
  foreignKey: 'tagId',
  constraints: false
})

  • image.getTags()

SELECT
  `tag`.`id`,
  `tag`.`name`,
  `tag`.`createdAt`,
  `tag`.`updatedAt`,
  `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
FROM `tags` AS `tag`
INNER JOIN `tag_taggables` AS `tag_taggable` ON
  `tag`.`id` = `tag_taggable`.`tagId` AND
  `tag_taggable`.`taggableId` = 1 AND
  `tag_taggable`.`taggableType` = 'image';

  • tag.getTaggables()

SELECT
  `image`.`id`,
  `image`.`url`,
  `image`.`createdAt`,
  `image`.`updatedAt`,
  `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
FROM `images` AS `image`
INNER JOIN `tag_taggables` AS `tag_taggable` ON
  `image`.`id` = `tag_taggable`.`taggableId` AND
  `tag_taggable`.`tagId` = 1;

SELECT
  `video`.`id`,
  `video`.`url`,
  `video`.`createdAt`,
  `video`.`updatedAt`,
  `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
FROM `videos` AS `video`
INNER JOIN `tag_taggables` AS `tag_taggable` ON
  `video`.`id` = `tag_taggable`.`taggableId` AND
  `tag_taggable`.`tagId` = 1;

Применение области ассоциации к целевой модели


Область ассоциации может применяться не только к соединительной таблице, но и к целевой модели.


Добавим тегам статус. Для получения всех тегов со статусом pending определим еще одну ассоциацию belongsToMany() между Image и Tag, применив область ассоциации как к соединительной таблице, так и к целевой модели:


Image.belongsToMany(Tag, {
  through: {
    model: Tag_Taggable,
    unique: false,
    scope: {
      taggableType: 'image',
    },
  },
  scope: {
    status: 'pending',
  },
  as: 'pendingTags',
  foreignKey: 'taggableId',
  constraints: false,
})

После этого, вызов image.getPendingTags() приведет к генерации такого запроса:


SELECT
  `tag`.`id`,
  `tag`.`name`,
  `tag`.`status`,
  `tag`.`createdAt`,
  `tag`.`updatedAt`,
  `tag_taggable`.`tagId` AS `tag_taggable.tagId`,
  `tag_taggable`.`taggableId` AS `tag_taggable.taggableId`,
  `tag_taggable`.`taggableType` AS `tag_taggable.taggableType`,
  `tag_taggable`.`createdAt` AS `tag_taggable.createdAt`,
  `tag_taggable`.`updatedAt` AS `tag_taggable.updatedAt`
FROM `tags` AS `tag`
INNER JOIN `tag_taggables` AS `tag_taggable` ON
  `tag`.`id` = `tag_taggable`.`tagId` AND
  `tag_taggable`.`taggableId` = 1 AND
  `tag_taggable`.`taggableType` = 'image'
WHERE (
  `tag`.`status` = 'pending'
);

Мы видим, что обе области были автоматически применены:


  • в INNER JOIN было добавлено tag_taggable.taggableType = 'image'
  • в WHEREtag.status = 'pending'

↑ Наверх

Транзакции


Sequelize поддерживает выполнение двух видов транзакций:


  1. Неуправляемые (unmanaged): завершение транзакции и отмена изменений выполняются вручную (путем вызова соответствующих методов)
  2. Управляемые (managed): при возникновении ошибки изменения автоматически отменяются, а при успехе транзакции автоматически выполняется фиксация (commit) изменений

Неуправляемые транзакции


Начнем с примера:


// Сначала мы запускаем транзакцию и сохраняем ее в переменную
const t = await sequelize.transaction()

try {
  // Затем при выполнении операций передаем транзакцию в качестве соответствующей настройки
  const user = await User.create(
    {
      firstName: 'John',
      lastName: 'Smith',
    },
    { transaction: t }
  )

  await user.addSibling(
    {
      firstName: 'Jane',
      lastName: 'Air',
    },
    { transaction: t }
  )

  // Если выполнение кода достигло этой точки,
  // значит, выполнение операций завершилось успешно -
  // фиксируем изменения
  await t.commit()
} catch (err) {
  // Если выполнение кода достигло этой точки,
  // значит, во время выполнения операций возникла ошибка -
  // отменяем изменения
  await t.rollback()
}

Управляемые тразакции


Для выполнения управляемой транзакции в sequelize.transaction() передается функция обратного вызова. Далее происходит следующее:


  • Sequelize автоматически запускает транзакцию и создает объект t
  • Затем выполняется переданный колбэк, которому передается t
  • При возникновении ошибки, изменения автоматически отменяются
  • При успехе транзакции, изменения автоматически фиксируются

Таким образом, sequelize.transaction() либо разрешается с результатом, возвращаемым колбэком, либо отклоняется с ошибкой.


try {
  const result = await sequelize.transaction(async (t) => {
    const user = await User.create(
      {
        firstName: 'John',
        lastName: 'Smith',
      },
      { transaction: t }
    )

    await user.addSibling(
      {
        firstName: 'Jane',
        lastName: 'Air',
      },
      { transaction: t }
    )

    return user
  })

  // Если выполнение кода достигло этой точки,
  // значит, выполнение операций завершилось успешно -
  // фиксируем изменения
} catch (err) {
  // Если выполнение кода достигло этой точки,
  // значит, во время выполнения операций возникла ошибка -
  // отменяем изменения
}

Обратите внимание: при выполнении управляемой транзакции, нельзя вручную вызывать методы commit() и rollback().


Автоматическая передача транзакции во все запросы


В приведенных примерах транзакция передавалась вручную — { transaction: t }. Для автоматической передачи транзакции во все запросы необходимо установить модуль cls-hooked (CLS — Continuation Local Storage, "длящееся" локальное хранилище) и инстанцировать пространство имен (namespace):


const cls = require('cls-hooked')
const namespace = cls.createNamespace('my-namespace')

Затем следует использовать это пространство имен следующим образом:


const Sequelize = require('sequelize')
Sequelize.useCLS(namespace)

new Sequelize(/* ... */)

Обратите внимание: мы вызываем метод useCLS() на конструкторе, а не на экземпляре. Это означает, что пространство имен будет доступно всем экземплярам, а также, что CLS — это "все или ничего", нельзя включить его только для некоторых экземпляров.


CLS представляет собой что-то вроде локального хранилища в виде потока для колбэков. На практике это означает, что разные цепочки из колбэков могут использовать локальные переменные из одного пространства CLS. После включения CLS, t автоматически передается при создании транзакции. Поскольку переменные являются частными для цепочки колбэков, одновременно может выполняться несколько транзакций:


sequelize.transaction((t1) => {
  console.log(namespace.get('transaction') === t1) // true
})

sequelize.transaction((t2) => {
  console.log(namespace.get('transaction') === t2) // true
})

В большинстве случаев, в явном вызове namespace.get('transaction') нет необходимости, поскольку все запросы автоматически получают транзакцию из пространства имен:


sequelize.transaction((t1) => {
  // С включенным CLS пользователь будет создан внутри транзакции
  return User.create({ name: 'John' })
})

Параллельные/частичные транзакции


С помощью последовательности запросов можно выполнять параллельные транзакции. Также имеется возможность исключать запросы из транзакции. Для управления тем, каким транзакциям принадлежит запрос, используется настройка transaction (обратите внимание: SQLite не поддерживает одновременное выполнение более одной транзакции).


С включенным CLS:


sequelize.transaction((t1) => {
  return sequelize.transaction((t2) => {
    // С включенным `CLS` все запросы здесь по умолчанию будут использовать `t2`
    // Настройка `transaction` позволяет это изменить
    return Promise.all([
      User.create({ name: 'John' }, { transaction: null }),
      User.create({ name: 'Jane' }, { transaction: t1 }),
      User.create({ name: 'Alice' }), // этот запрос будет использовать `t2`
    ])
  })
})

Уровни изоляции


Возможные уровни изоляции при запуске транзакции:


const { Transaction } = require('sequelize')

Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED
Transaction.ISOLATION_LEVELS.READ_COMMITTED
Transaction.ISOLATION_LEVELS.REPEATABLE_READ
Transaction.ISOLATION_LEVELS.SERIALIZABLE

По умолчанию Sequelize использует уровень изоляции БД. Для изменения уровня изоляции используется настройка isolationLevel:


const { Transaction } = require('sequelize')

await sequelize.transaction(
  {
    isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE,
  },
  async (t) => {
    // ...
  }
)

Или на уровне всего приложения:


const sequelize = new Sequelize('sqlite::memory:', {
  isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE,
})

Использование транзакции совместно с другими методами


Обычно, transaction передается в метод вместе с другими настройками в качестве первого аргумента.


Для методов, принимающих значения, таких как create(), update() и т.п., transaction передается в качестве второго аргумента.


await User.create({ name: 'John' }, { transaction: t })

await User.findAll({
  where: {
    name: 'Jane',
  },
  transaction: t,
})

Хук afterCommit()


Объект transaction позволяет регистрировать фиксацию изменений. Хук afterCommit() может быть добавлен как к управляемым, так и к неуправляемым объектам транзакции:


// Управляемая транзакция
await sequelize.transaction(async (t) => {
  t.afterCommit(() => {
    // ...
  })
})

// Неуправляемая транзакция
const t = await sequelize.transaction()
t.afterCommit(() => {
  // ...
})
await t.commit()

Колбэк, передаваемый в afterCommit(), является асинхронным. В данном случае:


  • для управляемой транзакции: вызов sequelize.transaction() будет ждать его завершения
  • для неуправляемой транзакции: вызов t.commit() будет ждать его завершения

Обратите внимание на следующее:


  • afterCommit() не запускается при отмене изменений
  • он не модифицирует значение, возвращаемое транзакцией (в отличие от других хуков)

Хук afterCommit() можно использовать в дополнение к хукам модели для определения момента сохранения экземпляра и его доступности за пределами транзакции:


User.afterSave((instance, options) => {
  if (options.transaction) {
    // Ожидаем фиксации изменений для уведомления подписчиков о сохранении экземпляра
    options.transaction.afterCommit(() => /* Уведомление */)
    return
  }
})

↑ Наверх

Хуки


Хуки или события жизненного цикла (hooks) — это функции, которые вызываются до или после вызова методов Sequelize. Например, для установки значения модели перед ее сохранением можно использовать хук beforeUpdate().


Обратите внимание: хуки могут использоваться только на уровне моделей.


Доступные хуки


Sequelize предоставляет большое количество хуков. Их полный список можно найти здесь. Порядок вызова наиболее распространенных хуков следующий:


(1)
  beforeBulkCreate(instances, options)
  beforeBulkDestroy(options)
  beforeBulkUpdate(options)
(2)
  beforeValidate(instance, options)

[... здесь выполняется валидация ...]

(3)
  afterValidate(instance, options)
  validationFailed(instance, options, error)
(4)
  beforeCreate(instance, options)
  beforeDestroy(instance, options)
  beforeUpdate(instance, options)
  beforeSave(instance, options)
  beforeUpsert(values, options)

[... здесь выполняется создание/обновление/удаление ...]

(5)
  afterCreate(instance, options)
  afterDestroy(instance, options)
  afterUpdate(instance, options)
  afterSave(instance, options)
  afterUpsert(created, options)
(6)
  afterBulkCreate(instances, options)
  afterBulkDestroy(options)
  afterBulkUpdate(options)

Определение хуков


Аргументы в хуки передаются по ссылкам. Это означает, что мы можем модифицировать значения и это отразится на соответствующих инструкциях. Хук может содержать асинхронные операции — в этом случае функция должна возвращать промис.


Существует три способа программного добавления хуков:


// 1) через метод `init()`
class User extends Model {}
User.init(
  {
    username: DataTypes.STRING,
    mood: {
      type: DataTypes.ENUM,
      values: ['счастливый', 'печальный', 'индифферентный'],
    },
  },
  {
    hooks: {
      beforeValidate: (user, options) => {
        user.mood = 'счастливый'
      },
      afterValidate: (user, options) => {
        user.username = 'Ванька'
      },
    },
    sequelize,
  }
)

// 2) через метод `addHook()`
User.addHook('beforeValidate', (user, options) => {
  user.mood = 'счастливый'
})

User.addHook('afterValidate', 'someCustomName', (user, options) => {
  return Promise.reject(
    new Error('К сожалению, я не могу позволить вам этого сделать.')
  )
})

// 3) напрямую
User.beforeCreate(async (user, options) => {
  const hashedPassword = await hashPassword(user.password)
  user.password = hashedPassword
})

User.afterValidate('myAfterHook', (user, options) => {
  user.username = 'Ванька'
})

Обратите внимание, что удаляться могут только именованные хуки:


const Book = sequelize.define('book', {
  title: DataTypes.STRING,
})

Book.addHook('afterCreate', 'notifyUsers', (book, options) => {
  // ...
})
Book.removeHook('afterCreate', 'notifyUsers')

Глобальные/универсальные хуки


Глобальными называются хуки, которые выполняются для всех моделей. Особенно полезными такие хуки являются в плагинах. Они определяются двумя способами:


  • в настройках конструктора (хуки по умолчанию)

const sequelize = new Sequelize(/*...*/, {
  define: {
    hooks: {
      beforeCreate() {
        // ...
      }
    }
  }
})

// Дефолтные хуки запукаются при отсутствии в модели аналогичных хуков
const User = sequelize.define('user', {})
const Project = sequelize.define('project', {}, {
  hooks: {
    beforeCreate() {
      // ...
    }
  }
})

await User.create({}) // запускается глобальный хук
await Project.create({}) // запускается локальный хук

  • с помощью sequelize.addHook() (постоянные хуки)

sequelize.addHook('beforeCreate', () => {
  // ...
})

// Такой хук запускается независимо от наличия у модели аналогичного хука
const User = sequelize.define('user', {})
const Project = sequelize.define(
  'project',
  {},
  {
    hooks: {
      beforeCreate() {
        // ...
      },
    },
  }
)

await User.create({}) // запускается глобальный хук
await Project.create({}) // сначала запускается локальный хук, затем глобальный

Хуки, связанные с подключением к БД


Существует 4 хука, выполняемые до и после подключения к БД и отключения от нее:


  • sequelize.beforeConnect(callback) — колбэк имеет сигнатуру async (config) => {}
  • sequelize.afterConnect(callback)async (connection, config) => {}
  • seuqelize.beforeDisconnect(callback)async (connection) => {}
  • sequelize.afterDisconnect(callback)async (connection) => {}

Эти хуки могут использоваться для асинхронного получения полномочий (credentials) для доступа к БД или получения прямого доступа к низкоуровневому соединению с БД после его установки.


Например, мы можем асинхронно получить пароль от БД из хранилища токенов и модифицировать объект с настройками:


sequelize.beforeConnect(async (config) => {
  config.password = await getAuthToken()
})

Рассматриваемые хуки могут быть определены только как глобальные, поскольку соединение является общим для всех моделей.


Хуки экземпляров


Следующие хуки будут запускаться при редактировании единичного объекта:


  • beforeValidate
  • afterValidate/validationFailed
  • beforeCreate/beforeUpdate/beforeSave/beforeDestroy
  • afterCreate/afterUpdate/afterSave/afterDestroy

User.beforeCreate((user) => {
  if (user.accessLevel > 10 && user.username !== 'Сенсей') {
    throw new Error(
      'Вы не можете предоставить этому пользователю уровень доступа выше 10'
    )
  }
})

// Будет выброшено исключение
try {
  await User.create({ username: 'Гуру', accessLevel: 20 })
} catch (err) {
  console.error(err) // Вы не можете предоставить этому пользователю уровень доступа выше 10
}

// Ок
const user = await User.create({
  username: 'Сенсей',
  accessLevel: 20,
})

Хуки моделей


При вызове методов bulkCreate(), update() и destroy() запускаются следующие хуки:


  • beforeBulkCreate(callback) — колбэк имеет сигнатуру (instances, options) => {}
  • beforeBulkUpdate(callback)(options) => {}
  • beforeBulkDestroy(callback)(options) => {}
  • afterBulkCreate(callback)(instances, options) => {}
  • afterBulkUpdate(callback)(options) => {}
  • afterBulkDestroy(callback)(options) => {}

Обратите внимание: вызов методов моделей по умолчанию приводит к запуску только хуков с префиксом bulk. Это можно изменить с помощью настройки { individualHooks: true }, но имейте ввиду, что это может крайне негативно сказаться на производительности.


await Model.destroy({
  where: { accessLevel: 0 },
  individualHooks: true,
})

await Model.update(
  { username: 'John' },
  {
    where: { accessLevel: 0 },
    individualHooks: true,
  }
)

Хуки и ассоциации


Один-к-одному и один-ко-многим


  • при использовании миксинов add/set запускаются хуки beforeUpdate() и afterUpdate()
  • хуки beforeDestroy() и afterDestroy() запускаются только при наличии у ассоциаций onDelete: 'CASCADE' и hooks: true

const Project = sequelize.define('project', {
  title: DataTypes.STRING,
})
const Task = sequelize.define('task', {
  title: DataTypes.STRING,
})
Project.hasMany(Task, { onDelete: 'CASCADE', hooks: true })
Task.belongsTo(Project)

По умолчанию Sequelize пытается максимально оптимизировать запросы. Например, при вызове каскадного удаления Sequelize выполняет:


DELETE FROM `table` WHERE associatedIdentifier = associatedIdentifier.primaryKey;

Однако, добавление hooks: true отключает оптимизации. В этом случае Sequelize сначала выполняет выборку связанных объектов с помощью SELECT и затем уничтожает каждый экземпляр по одному для обеспечения вызова соответствующих хуков с правильными параметрами.


Многие-ко-многим


  • при использовании миксинов add для отношений belongsToMany() (когда в соединительной таблице создается как минимум одна запись) запускаются хуки beforeBulkCreate() и afterBulkCreate() соединительной таблицы
  • если указано { individualHooks: true }, то также вызываются индивидуальные хуки
  • при использовании миксинов remove запускаются хуки beforeBulkDestroy() и afterBulkDestroy(), а также индивидуальные хуки при наличии { individualHooks: true }

Хуки и транзакции


Если в оригинальном вызове была определена транзакция, она будет передана в хук вместе с другими настройками:


User.addHook('afterCreate', async (user, options) => {
  // Мы можем использовать `options.transaction` для выполнения другого вызова
  // с помощью той же транзакции, которая запустила данный хук
  await User.update(
    { mood: 'печальный' },
    {
      where: {
        id: user.id,
      },
      transaction: options.transaction,
    }
  )
})

await sequelize.transaction(async (t) => {
  await User.create({
    username: 'Ванька',
    mood: 'счастливый',
    transaction: t,
  })
})

Если мы не передадим транзакцию в вызов User.update(), обновления не произойдет, поскольку созданный пользователь попадет в БД только после фиксации транзакции.


Важно понимать, что Sequelize автоматически использует транзакции при выполнении некоторых операций, таких как Model.findOrCreate(). Если хуки выполняют операции чтения или записи на основе объекта из БД или модифицируют значения объекта как в приведенном выше примере, всегда следует определять { transaction: options.transaction }.


↑ Наверх

Интерфейс запросов


Каждый экземпляр использует интерфейс запросов (query interface) для взаимодействия с БД. Методы этого интерфейса являются низкоуровневыми в сравнении с обычными методами. Но, разумеется, по сравнению с запросами SQL, они являются высокоуровневыми.


Рассмотрим несколько примеров использования методов названного интерфейса (полный список методов можно найти здесь).


Получение интерфейса запросов:


const { Sequelize, DataTypes } = require('sequelize')
const sequelize = new Sequelize(/* ... */)
const queryInterface = sequelize.getQueryInterface()

Создание таблицы:


queryInterface.createTable('Person', {
  name: DataTypes.STRING,
  isBetaMember: {
    type: DataTypes.BOOLEAN,
    defaultValue: false,
    allowNull: false,
  },
})

Генерируемый SQL:


CREATE TABLE IF NOT EXISTS `Person` (
  `name` VARCHAR(255),
  `isBetaMember` TINYINT(1) NOT NULL DEFAULT 0
);

Добавление в таблицу новой колонки:


queryInterface.addColumn('Person', 'petName', { type: DataTypes.STRING })

SQL:


ALTER TABLE `Person` ADD `petName` VARCHAR(255);

Изменение типа данных колонки:


queryInterface.changeColumn('Person', 'foo', {
  type: DataTypes.FLOAT,
  defaultValue: 3.14,
  allowNull: false,
})

SQL:


ALTER TABLE `Person` CHANGE `foo` `foo` FLOAT NOT NULL DEFAULT 3.14;

Удаление колонки:


queryInterface.removeColumn('Person', 'petName', {
  /* настройки */
})

SQL:


ALTER TABLE 'public'.'Person' DROP COLUMN 'petName';

↑ Наверх

Стратегии именования


Sequelize предоставляет настройку underscored для моделей. Когда эта настройка имеет значение true, значение настройки field (название поля) всех атрибутов приводится к snake_case. Это также справедливо по отношению к внешним ключам и другим автоматически генерируемым полям.


const User = sequelize.define(
  'user',
  { username: DataTypes.STRING },
  {
    underscored: true,
  }
)
const Task = sequelize.define(
  'task',
  { title: DataTypes.STRING },
  {
    underscored: true,
  }
)
User.hasMany(Task)
Task.belongsTo(User)

У нас имеется две модели, User и Task, обе с настройками underscored. Между этими моделями установлена ассоциация один-ко-многим. Также, поскольку настройка timestamps по умолчанию имеет значение true, в обеих таблицах будут автоматически созданы поля createdAt и updatedAt.


Без настройки underscored произойдет автоматическое создание:


  • атрибута createdAt для каждой модели, указывающего на колонку createdAt каждой таблицы
  • атрибута updatedAt для каждой модели, указывающего на колонку updatedAt каждой таблицы
  • атрибута userId в модели Task, указыващего на колонку userId таблицы task

С настройкой underscored будут автоматически созданы:


  • атрибут createdAt для каждой модели, указывающего на колонку created_at каждой таблицы
  • атрибут updatedAt для каждой модели, указывающего на колонку updated_at каждой таблицы
  • атрибут userId в модели Task, указыващего на колонку user_id таблицы task

Обратите внимание: в обоих случаях названия полей именуются в стиле camelCase.


Во втором случае вызов sync() приведет к генерации такого SQL:


CREATE TABLE IF NOT EXISTS "users" (
  "id" SERIAL,
  "username" VARCHAR(255),
  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
  PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "tasks" (
  "id" SERIAL,
  "title" VARCHAR(255),
  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
  "user_id" INTEGER REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
  PRIMARY KEY ("id")
);

Использование единственного и множественного чисел


При определении моделей:


// При определении модели должно использоваться единственное число
sequelize.define('foo', { name: DataTypes.STRING })

В данном случае названием соответствующей таблицы будет foos.


При определении ссылок в модели:


sequelize.define('foo', {
  name: DataTypes.STRING,
  barId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: {
      model: 'bars',
      key: 'id',
    },
    onDelete: 'CASCADE',
  },
})

При извлечении данных при нетерпеливой загрузке.


При добавлении в запрос include, в возвращаемом объекте создается дополнительное поле согласно следующим правилам:


  • при включении данных из единичной ассоциации (hasOne() или belongsTo()) — название поля указывается в единственном числе
  • при включении данных из множественной ассоциации (hasMany() или belongsToMany()) — название поля указывается во множественном числе

// Foo.hasMany(Bar)
const foo = Foo.findOne({ include: Bar })
// foo.bars будет массивом

// Foo.hasOne(Bar)
const foo = Foo.findOne({ include: Bar })
// foo.bar будет объектом

// и т.д.

Кастомизация названий при определении синонимов


При определении синонима для ассоциации вместо { as: 'myAlias' } можно передать объект с единичной и множественной формами таблицы:


Project.belongsToMany(User, {
  as: {
    singular: 'líder',
    plural: 'líderes',
  },
})

Если модель будет использовать один и тот же синоним во всех ассоциациях, формы можно указать прямо в модели:


const User = sequelize.define(
  'user',
  {
    /* ... */
  },
  {
    name: {
      singular: 'líder',
      plural: 'líderes',
    },
  }
)
Project.belongsToMany(User)

При этом, в миксинах будут использоваться правильные формы, например, getLíder(), setLíderes() и т.д.


Обратите внимание: при использовании as для изменения названия ассоциации, также будет изменено название внешнего ключа. Поэтому в данном случае также рекомендуется явно определять название внешнего ключа, причем, в обоих вызовах:


Invoice.belongsTo(Subscription, {
  as: 'TheSubscription',
  foreignKey: 'subscription_id',
})
Subscription.hasMany(Invoice, { foreignKey: 'subscription_id' })

↑ Наверх

Области видимости


Области видимости (scopes) (далее — области) облегчают повторное использование кода. Они позволяют определить часто используемые настройки, такие как where, include, limit и т.д.


Определение


Области определяются при создании модели и могут быть поисковыми объектами или функциями, возвращающими такие объекты, за исключением дефолтной области, которая может быть только объектом:


const Project = sequelize.define(
  'project',
  {
    /* ... */
  },
  {
    defaultScope: {
      where: {
        active: true,
      },
    },
    scopes: {
      deleted: {
        where: {
          deleted: true,
        },
      },
      activeUsers: {
        include: [
          {
            model: User,
            where: { active: true }
          }
        ],
      },
      random() {
        return {
          where: {
            someNum: Math.random(),
          },
        }
      },
      accessLevel(value) {
        return {
          where: {
            accesLevel: {
              [Op.gte]: value,
            },
          },
        }
      },
    },
  }
)

Области также могут определяться с помощью метода Model.addScope(). Это может быть полезным при определении областей для включений, когда связанная модель может быть не определена в момент создания основной модели.


Дефолтная область применяется всегда. Это означает, что в приведенном примере вызов Project.findAll() сгенерирует такой запрос:


SELECT * FROM projects WHERE active = true;

Дефолтная область может быть удалена с помощью unscoped(), scope(null) или посредством вызова другой области:


await Project.scope('deleted').findAll()

SELECT * FROM projects WHERE deleted = true;

Также имеется возможность включать модели из области в определение области. Это позволяет избежать дублирования include, attributes или where:


Project.addScope('activeUsers', {
  include: [
    { model: User.scope('active') }
  ],
})

Использование


Области применяются путем вызова метода scope(scopeName). Этот метод возвращает полнофункциональный экземпляр модели со всеми обычными методами: findAll(), update(), count(), destroy() и т.д.


const DeletedProjects = Project.scope('deleted')
await DeletedProjects.findAll()

// Это эквивалентно следующему
await Project.findAll({
  where: {
    deleted: true,
  },
})

Области применяются к find(), findAll(), count(), update(), increment() и destroy().


Области-функции могут вызываться двумя способами. Если область не принимает аргументов, она вызывается как обычно. Если область принимает аргументы, ей передается объект:


await Project.scope('random', { method: ['accessLevel', 10] })

SQL:


SELECT * FROM projects WHERE someNum = 42 AND accessLevel >= 10;

Объединение областей


Объединяемые области указываются через запятую или передаются в виде массива:


await Project.scope('deleted', 'activeUsers').findAll()
await Project.scope(['deleted', 'activeUsers']).findAll()

SQL:


SELECT * FROM projects
INNER JOIN users ON projects.userId = users.id
WHERE projects.deleted = true
AND users.active = true;

Объединение дефолтной и кастомной областей:


await Project.scope('defaultScope', 'deleted').findAll()

При вызове нескольких областей, ключи последующих областей перезаписывают ключи предыдущих областей (по аналогии с Object.assign()), за исключением where и include, в которых ключи объединяются. Рассмотрим две области:


Model.addScope('scope1', {
  where: {
    firstName: 'John',
    age: {
      [Op.gt]: 20,
    },
  },
  limit: 20,
})
Model.addScope('scope2', {
  where: {
    age: {
      [Op.gt]: 30,
    },
  },
  limit: 10,
})

Вызов scope('scope1', 'scope2') приведет к генерации такого предложения WHERE:


WHERE firstName = 'John' AND age > 30 LIMIT 10;

Атрибуты limit и age были перезаписаны, а firstName сохранен.


При объединении ключей атрибутов из нескольких областей предполагается attributes.exclude(). Это обеспечивает учет регистра полей при объединении, т.е. сохранение чувствительных полей в финальном результате.


Такая же логика объединения используется при прямой передаче поискового объекта в findAll() (и аналогичные методы):


Project.scope('deleted').findAll({
  where: {
    firstName: 'Jane',
  },
})

SQL:


WHERE deleted = true AND firstName = 'Jane';

Если мы передадим { firstName: 'Jane', deleted: false }, область deleted будет перезаписана.


Объединение включений


Включения объединяются рекурсивно на основе включаемых моделей.


Предположим, что у нас имеются такие модели с ассоциацией один-ко-многим:


const Foo = sequelize.define('Foo', { name: DataTypes.STRING })
const Bar = sequelize.define('Bar', { name: DataTypes.STRING })
const Baz = sequelize.define('Baz', { name: DataTypes.STRING })
const Qux = sequelize.define('Qux', { name: DataTypes.STRING })
Foo.hasMany(Bar, { foreignKey: 'fooId' })
Bar.hasMany(Baz, { foreignKey: 'barId' })
Baz.hasMany(Qux, { foreignKey: 'bazId' })

Далее, предположим, что мы определили для модели Foo такие области:


Foo.adScope('includeEverything', {
  indluce: {
    model: Bar,
    include: [
      {
        model: Baz,
        include: Qux,
      },
    ],
  },
})

Foo.addScope('limitedBars', {
  include: [
    {
      model: Bar,
      limit: 2,
    },
  ],
})

Foo.addScope('limitedBazs', {
  include: [
    {
      model: Bar,
      include: [
        {
          model: Baz,
          limit: 2,
        },
      ],
    },
  ],
})

Foo.addScope('excludedBazName', {
  include: [
    {
      model: Bar,
      include: [
        {
          model: Baz,
          attributes: {
            exclude: ['name'],
          },
        },
      ],
    },
  ],
})

Эти 4 области легко (и глубоко) объединяются. Например, вызов Foo.scope('includeEverything', 'limitedBars', 'limitedBazs', 'excludedBazName') эквивалентен следующему:


await Foo.findAll({
  include: {
    model: Bar,
    limit: 2,
    include: [
      {
        model: Baz,
        limit: 2,
        attributes: {
          exclude: ['name'],
        },
        include: Qux,
      },
    ],
  },
})

↑ Наверх

Подзапросы


Предположим, что у нас имеется две модели, Post и Reaction, с ассоциацией один-ко-многим:


const Post = sequelize.define(
  'Post',
  {
    content: DataTypes.STRING,
  },
  { timestamps: false }
)
const Reaction = sequelize.define(
  'Reaction',
  {
    type: DataTypes.STRING,
  },
  { timestamps: false }
)

Post.hasMany(Reaction)
Reaction.belongsTo(Post)

Заполним эти таблицы данными:


const createPostWithReactions = async (content, reactionTypes) => {
  const post = await Post.create({ content })
  await Reaction.bulkCreate(
    reactionTypes.map((type) => ({ type, postId: post.id }))
  )
  return post
}

await createPostWithReactions('My First Post', [
  'Like',
  'Angry',
  'Laugh',
  'Like',
  'Like',
  'Angry',
  'Sad',
  'Like',
])
await createPostWithReactions('My Second Post', [
  'Laugh',
  'Laugh',
  'Like',
  'Laugh',
])

Допустим, что мы хотим вычислить laughReactionsCount для каждого поста. С помощью подзапроса SQL это можно сделать так:


SELECT *, (
  SELECT COUNT(*)
  FROM reactions AS reaction
  WHERE
    reaction.postId = post.id
    AND
    reaction.type = 'Laugh'
) AS laughReactionsCount
FROM posts AS post;

Результат:


[
  {
    "id": 1,
    "content": "My First Post",
    "laughReactionsCount": 1
  },
  {
    "id": 2,
    "content": "My Second Post",
    "laughReactionsCount": 3
  }
]

Sequelize предоставляет специальную утилиту literal() для работы с подзапросами. Данная утилита принимает подзапрос SQL. Т.е. Sequelize помогает с основным запросом, но подзапрос должен быть реализован вручную:


Post.findAll({
  attributes: {
    include: [
      [
        sequelize.literal(`(
          SELECT COUNT(*)
          FROM reactions AS reaction
          WHERE
            reaction.postId = post.id
            AND
            reaction.type = 'Laugh'
        )`),
        'laughReactionsCount',
      ],
    ],
  },
})

Результат выполнения данного запроса будет таким же, как при выполнении SQL-запроса.


При использовании подзапросов можно выполнять группировку возвращаемых значений:


Post.findAll({
  attributes: {
    include: [
      [
        sequelize.literal(`(
          SELECT COUNT(*)
          FROM reactions AS reaction
          WHERE
            reaction.postId = post.id
            AND
            reaction.type = 'Laugh'
        )`),
        'laughReactionsCount',
      ],
    ],
  },
  order: [
    [sequelize.literal('laughReactionsCount'), 'DESC']
  ],
})

Результат:


[
  {
    "id": 2,
    "content": "My Second Post",
    "laughReactionsCount": 3
  },
  {
    "id": 1,
    "content": "My First Post",
    "laughReactionsCount": 1
  }
]

↑ Наверх

Ограничения и циклические ссылки


Добавление ограничений между таблицами означает, что таблицы должны создаваться в правильном порядке при использовании sequelize.sync(). Если Task содержит ссылку на User, тогда таблица User должна быть создана первой. Иногда это может привести к циклическим ссылкам, когда Sequelize не может определить порядок синхронизации. Предположим, что у нас имеются документы и версии. Документ может иметь несколько версий. Он также может содержать ссылку на текущую версию.


const Document = sequelize.define('Document', {
  author: DataTypes.STRING,
})
const Version = sequelize.define('Version', {
  timestamp: DataTypes.STRING,
})
Document.hasMany(Version)
Document.belongsTo(Version, {
  as: 'Current',
  foreignKey: 'currentVersionId',
})

Однако, это приведет к возникновению ошибки:


Cyclic dependency found. documents is dependent of itself. Dependency chain: documents -> versions => documents

Для решения этой проблемы необходимо передать constraints: false в одну из ассоциаций:


Document.hasMany(Version)
Document.belongsTo(Version, {
  as: 'Current',
  foreignKey: 'currentVersionId',
  constraints: false,
})

Иногда может потребоваться указать ссылку на другую таблицу без создания ограничений или ассоциаций. В этом случае ссылку и отношения между таблицами можно определить явно:


const Trainer = sequelize.define('Trainer', {
  firstName: DataTypes.STRING,
  lastName: DataTypes.STRING,
})

// `Series` будет содержать внешнюю ссылку `trainerId = Trainer.id`
// после вызова `Trainer.hasMany(series)`
const Series = sequelize.define('Series', {
  title: DataTypes.STRING,
  subTitle: DataTypes.STRING,
  description: DataTypes.TEXT,
  // Определяем отношения один-ко-многим с `Trainer`
  trainerId: {
    type: DataTypes.INTEGER,
    references: {
      model: Trainer,
      key: 'id',
    },
  },
})

// `Video` будет содержэать внешнюю ссылку `seriesId = Series.id`
// после вызова `Series.hasOne(Video)`
const Video = sequelize.define('Video', {
  title: Sequelize.STRING,
  sequence: Sequelize.INTEGER,
  description: Sequelize.TEXT,
  // Устанавливаем отношения один-ко-многим с `Series`
  seriesId: {
    type: DataTypes.INTEGER,
    references: {
      model: Series, // Может быть как строкой, представляющей название таблицы, так и моделью
      key: 'id',
    },
  },
})

Series.hasOne(Video)
Trainer.hasMany(Series)

↑ Наверх

Индексы


Sequelize поддерживает индексирование моделей:


const User = sequelize.define(
  'User',
  {
    /* атрибуты */
  },
  {
    indexes: [
      // Создаем уникальный индекс для адреса электронной почты
      {
        unique: true,
        fields: ['email'],
      },

      // Создание обратного индекса для данных с помощью оператора `jsonb_path_ops`
      {
        fields: ['data'],
        using: 'gin',
        operator: 'jsonb_path_ops',
      },

      // По умолчанию название индекса будет иметь вид `[table]_[fields]`
      // Создаем частичный индекс для нескольких колонок
      {
        name: 'public_by_author',
        fields: ['author', 'status'],
        where: {
          status: 'public',
        },
      },

      // Индекс `BTREE` с сортировкой
      {
        name: 'title_index',
        using: 'BTREE',
        fields: [
          'author',
          {
            attribute: 'title',
            collate: 'en_US',
            order: 'DESC',
            length: 5,
          },
        ],
      },
    ],
  }
)

↑ Наверх

Пул соединений


Подключение к БД с помощью одного процесса означает создание одного экземпляра Sequelize. При инициализации Sequelize создает пул соединений (connection pool). Этот пул может быть настроен с помощью настройки pool:


const sequelize = new Sequelize(/* ... */, {
  // ...
  pool: {
    min: 0,
    max: 5,
    acquire: 30000,
    idle: 10000
  }
})

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


↑ Наверх

Миграции


Подобно тому, как вы используете систему контроля версий, такую как Git, для контроля за изменениями кодовой базы, миграции (migrations) позволяют контролировать изменения, вносимые в БД. Миграции позволяют переводить БД из одного состояния в другое и обратно: изменения состояния сохраняются в файлах миграции, описывающих, как получить новое состояние или как отменить изменения для того, чтобы вернуться к предыдущему состоянию.


Для работы с миграциями, а также для генерации шаблона проекта, используется интерфейс командной строки Sequelize.


Миграция — это JS-файл, из которого экспортируется 2 функции, up и down, описывающие выполнение миграции и ее отмену, соответственно. Эти функции определяются вручную, но вызываются с помощью CLI. В функциях указываются необходимые запросы с помощью sequelize.query() или других методов.


Установка CLI:


yarn add sequelize-cli
# или
npm i sequelize-cli

Генерация шаблона


Для создания пустого проекта используется команда init:


sequelize-cli init

Будут созданы следующие директории:


  • config — файл с настройками подключения к БД
  • models — модели для проекта
  • migrations — файлы с миграциями
  • seeders — файлы для заполнения БД начальными (фиктивными) данными

Настройка


Далее нам нужно сообщить CLI, как подключиться к БД. Для этого откроем файл config/config.json. Он выглядит примерно так:


{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

Редактируем этот файл, устанавливая правильные ключи для доступа к БД и диалект SQL.


Обратите внимание: для создания БД можно выполнить команду db:create.


Создание первой модели и миграции


После настройки CLI можно приступать к созданию миграций. Мы создадим ее с помощью команды model:generate. Это команда принимает 2 настройки:


  • name: название модели
  • attributes: список атрибутов модели

sequelize-cli model:generate --name User --attributes firstName:string,lastName:string, email:string

Данная команда создаст:


  • файл user.js в директории models
  • файл XXXXXXXXXXXXXX-create-user.js в директории migrations

Запуск миграций


Для создания таблицы в БД используется команда db:migrate:


sequelize-cli db:migrate

Данная команда выполняет следующее:


  • создает в БД таблицу SequelizeMeta. Это таблица используется для записи миграций, выполняемых для текущей БД
  • выполняет поиск файлов с миграциями, которые еще не запускались. В нашем случае будет запущен файл XXXXXXXXXXXXXX-create-user.js
  • создается таблица Users с колонками, определенными в миграции

Отмена миграций


Для отмены миграций используется команда db:migrate:undo:


sequelize-cli db:migrate:undo

Для отмены всех миграций используется команда db:migrate:undo:all, а для отката к определенной миграции — db:migrate:undo:all --to XXXXXXXXXXXXXX-create-posts.js.


Создание скрипта для наполнения БД начальными данными


Предположим, что мы хотим создать дефолтного пользователя в таблице Users. Для управления миграциями данных можно использовать сеятелей (seeders). Засеивание файла — это наполнение таблицы начальными или тестовыми данными.


Создадим файл с кодом, при выполнении которого будет выполняться создание дефолтного пользователя в таблице Users.


sequelize-cli seed:generate --name demo-user

После выполнения этой команды в директории seeders появится файл XXXXXXXXXXXXXX-demo-user.js. В нем используется такая же семантика up / down, что и в файлах миграций.


Отредактриуем этот файл:


module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.bulkInsert('Users', [
      {
        firstName: 'John',
        lastName: 'Smith',
        email: 'john@mail.com',
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ])
  },
  down: (queryInterface, Sequelize) =>
    queryInterface.bulkDelete('Users', null, {}),
}

Для запуска сеятеля используется команда db:seed:all:


sequelize-cli db:seed:all

Для отмены последнего сеятеля используется команда db:seed:undo, для отмены определенного сеятеля — db:seed:undo --seed seedName, для отмены всех сеятелей — db:seed:undo:all.


Обратите внимание: отменяемыми являются только те сеятели, которые используют какое-либо хранилище (см. ниже; в отличие от миграций, информация о сеятелях не сохраняется в таблице SequelizeMeta).


Шаблон миграции


Шаблон миграции выглядит так:


module.exports = {
  up: (queryInterface, Sequelize) => {
    // Логика перехода к новому состоянию
  },
  down: (queryIntarface, Sequelize) => {
    // Логика отмены изменений
  },
}

Мы можем создать этот файл с помощью migration:generate. Эта команда создаст файл xxx-migration-skeleton.js в директории для миграций.


sequelize-cli migration:generate --name migrationName

Объект queryInterface используется для модификации БД. Объект Sequelize содержит доступные типы данных, такие как STRING или INTEGER. Функции up() и down() должны возвращать промис. Рассмотрим простой пример создания/удаления таблицы User:


module.exports = {
  up: (queryInterface, { DataTypes }) =>
    queryInterface.createTable('User', {
      name: DataTypes.STRING,
      isBetaMember: {
        type: DataTypes.BOOLEAN,
        defaultValue: false,
        allowNull: false,
      },
    }),
  down: (queryInterface) => queryInterface.dropTable('User'),
}

В следующем примере миграция производит два изменения в БД (добавляет две колонки в таблицу User) с помощью управляемой транзакции, обеспечивающей успешное выполнение всех операций или отмену изменений при возникновении ошибки:


module.exports = {
  up: (queryInterface, { DataTypes }) =>
    queryInterface.sequelize.transaction((transaction) =>
      Promise.all([
        queryInterface.addColumn('User', 'petName', {
          DataTypes.STRING
        }, { transaction }),
        queryInterface.addColumn('User', 'favouriteColor', {
          type: DataTypes.STRING
        }, { transaction })
      ])
    ),
  down: (queryInterface) =>
    queryInterface.sequelize.transaction((transaction) =>
      Promise.all([
        queryInterface.removeColumn('User', 'petName', { transaction }),
        queryInterface.removeColumn('User', 'favouriteColor', { transaction })
      ])
    )
}

Следующий пример демонстрирует использование в миграции внешнего ключа:


module.exports = {
  up: (queryInterface, { DataTypes }) =>
    queryInterface.createTable('Person', {
      name: DataTypes.STRING,
      isBetaMember: {
        type: DataTypes.BOOLEAN,
        defaultValue: false,
        allowNull: false,
      },
      userId: {
        type: DataTypes.INTEGER,
        references: {
          model: {
            tableName: 'users',
            schema: 'schema',
          },
          key: 'id',
        },
        allowNull: false,
      },
    }),
  down: (queryInterface) => queryInterface.dropTable('Person'),
}

Следующий пример демонстирует использование синтаксиса async/await, создание уникального индекса для новой колонки и неуправляемой транзакции:


module.exports = {
  async up(queryInterface, { DataTypes }) {
    const transaction = await queryInterface.sequelize.transaction()
    try {
      await queruyInterface.addColumn(
        'Person',
        'petName',
        { type: DataTypes.STRING },
        { transaction }
      )
      await queryInterface.addIndex(
        'Person',
        'petName',
        {
          fields: 'petName',
          unique: true,
          transaction
        }
      )
      await transaction.commit()
    } catch (err) {
      await transaction.rollback()
      throw err
    }
  },
  async down(queryInterface) {
    const transaction = await queryInterface.sequelize.transaction()
    try {
      await queryInterface.removeColumn('Person'. 'petName', { transaction })
      await transaction.commit()
    } catch (err) {
      await transaction.rollback()
      throw err
    }
  }
}

Следующий пример демонстрирует создание уникального индекса на основе композиции из нескольких полей с условием, которое позволяет отношениям существовать много раз, но только одно будет удовлетворять условию:


modulex.exports = {
  up: (queryInterface, { DataTypes }) =>
    queryInterface
      .createTable('Person', {
        name: DataTypes.STRING,
        bool: {
          type: DataTypes.BOOLEAN,
          defaultValue: false,
        },
      })
      .then((queryInterface) =>
        queryInterface.addIndex('Person', ['name', 'bool'], {
          indicesType: 'UNIQUE',
          where: { bool: true },
        })
      ),
  down: (queryInterface) => queryInterface.dropTable('Person'),
}

Хранилище


Существует три вида хранилища:


  • sequelize — хранит миграции и сеятелей в таблице в БД
  • json — хранит миграции и сеятелей в JSON-файле
  • none — ничего не хранит

Хранилище миграций


По умолчанию CLI создает в БД таблицу SequelizeMeta для хранения записей о миграциях. Для изменения этого поведения существует 3 настройки, которые можно добавить в файл конфигурации .sequelizerc. Тип хранилища указывается в настройке migrationStorage. При выборе типа json, путь к файлу можно указать в настройке migrationStoragePath (по умолчанию данные будут записываться в файл sequelize-meta.json). Для изменения названия таблицы для хранения информации о миграциях в БД используется настройка migrationStorageTableName. Свойства migrationStorageTableSchema позволяет изменить используемую таблицей SequelizeMeta схему.


{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql",

    // Используем другой тип хранилища. По умолчанию: sequelize
    "migrationStorage": "json",

    // Используем другое название файла. По умолчанию: sequelize-meta.json
    "migrationStoragePath": "sequelizeMeta.json",

    // Используем другое название таблицы. По умолчанию: SequelizeMeta
    "migrationStorageTableName": "sequelize_meta",

    // Используем другую схему для таблицы `SequelizeMeta`
    "migrationStorageTableSchema": "custom_schema"
  }
}

Хранилище сеятелей


По умолчанию Sequelize не хранит информацию о сеятелях. Настройки файла конфигурации, которые позволяют это изменить:


  • seederStorage — тип хранилища
  • seederStoragePath — путь к хранилищу (по умолчанию sequelize-data.json)
  • seederStorageTableName — название таблицы (по умолчанию SequelizeData)

{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql",
    // Определяем другой тип хранилища. По умолчанию: none
    "seederStorage": "json",
    // Определяем другое название для файла. По умолчанию: sequelize-data.json
    "seederStoragePath": "sequelizeData.json",
    // Определяем другое название для таблицы. По умолчанию: SequelizeData
    "seederStorageTableName": "sequelize_data"
  }
}

↑ Наверх

На этом третья часть руководства завершена, как и руководство в целом. Мы с вами рассмотрели почти все возможности, предоставляемые Sequelize. Если вам все же потребуются более подробные сведения, то остается только официальная документация.


Благодарю за внимание и хорошего дня!

Теги:
Хабы:
0
Комментарии0

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн