Skip to main content

Sequelize

Sequelize - это ORM для работы с такими реляционными базами данных как Postgres, MySQL, MariaDB, SQLite и MSSQL. На сегодняшний день это самое популярное решений для Node.js.

Начало работы

Установка

yarn add sequelize
# или
npm i sequelize

Подключение к БД

const { Sequelize } = require('sequelize')

// Вариант 1: передача `URI` для подключения
const sequelize = new Sequelize('sqlite::memory:') // для `sqlite`
const sequelize = new Sequelize('postgres://user:pass@example.com:5432/dbname') // для `postgres`

// Вариант 2: передача параметров по отдельности
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'path/to/database.sqlite'
})

// Вариант 2: передача параметров по отдельности (для других диалектов)
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: /* 'mysql' | 'mariadb' | 'postgres' | 'mssql' */
})

Проверка подключения

try {
await sequelize.authenticate()
console.log('Соединение с БД было успешно установлено')
} catch (e) {
console.log('Невозможно выполнить подключение к БД: ', e)
}

По умолчанию после того, как установки соединения, оно остается открытым. Для его закрытия следует вызвать метод sequelize.close().

Модели

Модель - это абстракция, представляющая таблицу в БД.

Модель сообщает Sequelize несколько вещей о сущности (entity), которую она представляет: название таблицы, то, какие колонки она содержит (и их типы данных) и др.

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

Модели могут определяться двумя способами:

  • путем вызова sequelize.define(modelName, attributes, options)
  • путем расширения класса Model и вызова init(attributes, options)

После определения, модель доступна через sequelize.model + название модели.

В качестве примера создадим модель User с полями firstName и lastName.

sequelize.define

const { Sequelize, DataTypes } = require('sequelize')
const sequelize = new Sequelize('sqlite::memory:')

const User = sequelize.define(
'User',
{
// Здесь определяются атрибуты модели
firstName: {
type: DataTypes.STRING,
allowNull: false,
},
lastName: {
type: DataTypes.STRING,
// allowNull по умолчанию имеет значение true
},
},
{
// Здесь определяются другие настройки модели
}
)

// `sequelize.define` возвращает модель
console.log(User === sequelize.models.User) // true

Расширение Model

const { Sequelize, DataTypes, Model } = require('sequelize')
const sequelize = new Sequelize('sqlite::memory:')

class User extends Model {}

User.init(
{
// Здесь определяются атрибуты модели
firstName: {
type: DataTypes.STRING,
allowNull: false,
},
lastName: {
type: DataTypes.STRING,
},
},
{
// Здесь определяются другие настройки модели
sequelize, // Экземпляр подключения (обязательно)
modelName: 'User', // Название модели (обязательно)
}
)

console.log(User === sequelize.models.User) // true

sequelize.define под капотом использует Model.init.

В дальнейшем я буду использовать только первый вариант.

Автоматическую плюрализацию названия таблицы можно отключить с помощью настройки freezeTableName:

sequelize.define(
'User',
{
// ...
},
{
freezeTableName: true,
}
)

или глобально:

const sequelize = new Sequelize('sqlite::memory:', {
define: {
freeTableName: true,
},
})

В этом случае таблица будет называться User.

Название таблицы может определяться в явном виде:

sequelize.define(
'User',
{
// ...
},
{
tableName: 'Employees',
}
)

В этом случае таблица будет называться Employees.

Синхронизация модели с таблицей:

  • User.sync() - создает таблицу при отсутствии (существующая таблица остается неизменной)
  • User.sync({ force: true }) - удаляет существующую таблицу и создает новую
  • User.sync({ alter: true }) - приводит таблицу в соответствие с моделью

Пример:

// Возвращается промис
await User.sync({ force: true })
console.log('Таблица для модели `User` только что была создана заново!')

Синхронизация всех моделей:

await sequelize.sync({ force: true })
console.log('Все модели были успешно синхронизированы.')

Удаление таблицы:

await User.drop()
console.log('Таблица `User` была удалена.')

Удаление всех таблиц:

await sequelize.drop()
console.log('Все таблицы были удалены.')

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

// Выполняем синхронизацию только тех моделей, названия которых заканчиваются на `_test`
await sequelize.sync({ force: true, match: /_test$/ })

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

По умолчанию Sequelize автоматически добавляет в создаваемую модель поля createAt и updatedAt с типом DataTypes.DATE. Это можно изменить:

sequelize.define(
'User',
{
// ...
},
{
timestamps: false,
}
)

Названные поля можно отключать по отдельности и переименовывать:

sequelize.define(
'User',
{
// ...
},
{
timestamps: true,
// Отключаем `createdAt`
createdAt: false,
// Изменяем название `updatedAt`
updatedAt: 'updateTimestamp',
}
)

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

// до
sequelize.define('User', {
name: {
type: DataTypes.STRING,
},
})

// после
sequelize.define('User', {
name: DataTypes.STRING,
})

По умолчанию значением колонки является NULL. Это можно изменить с помощью настройки defaultValue (определив "дефолтное" значение):

sequelize.define('User', {
name: {
type: DataTypes.STRING,
defaultValue: 'John Smith',
},
})

В качестве дефолтных могут использоваться специальные значения:

sequelize.define('Foo', {
bar: {
type: DataTypes.DATE,
// Текущие дата и время, определяемые в момент создания
defaultValue: Sequelize.NOW,
},
})

Типы данных

Каждая колонка должна иметь определенный тип данных.

// Импорт встроенных типов данных
const { DataTypes } = require('sequelize')

// Строки
DataTypes.STRING // VARCHAR(255)
DataTypes.STRING(1234) // VARCHAR(1234)
DataTypes.STRING.BINARY // VARCHAR BINARY
DataTypes.TEXT // TEXT
DataTypes.TEXT('tiny') // TINYTEXT
DataTypes.CITEXT // CITEXT - только для `PostgreSQL` и `SQLite`

// Логические значения
DataTypes.BOOLEAN // BOOLEAN

// Числа
DataTypes.INTEGER // INTEGER
DataTypes.BIGINT // BIGINT
DataTypes.BIGINT(11) // BIGINT(11)

DataTypes.FLOAT // FLOAT
DataTypes.FLOAT(11) // FLOAT(11)
DataTypes.FLOAT(11, 10) // FLOAT(11, 10)

DataTypes.REAL // REAL - только для `PostgreSQL`
DataTypes.REAL(11) // REAL(11) - только для `PostgreSQL`
DataTypes.REAL(11, 12) // REAL(11,12) - только для `PostgreSQL`

DataTypes.DOUBLE // DOUBLE
DataTypes.DOUBLE(11) // DOUBLE(11)
DataTypes.DOUBLE(11, 10) // DOUBLE(11, 10)

DataTypes.DECIMAL // DECIMAL
DataTypes.DECIMAL(10, 2) // DECIMAL(10, 2)

// только для `MySQL`/`MariaDB`
DataTypes.INTEGER.UNSIGNED
DataTypes.INTEGER.ZEROFILL
DataTypes.INTEGER.UNSIGNED.ZEROFILL

// Даты
DataTypes.DATE // DATETIME для `mysql`/`sqlite`, TIMESTAMP с временной зоной для `postgres`
DataTypes.DATE(6) // DATETIME(6) для `mysql` 5.6.4+
DataTypes.DATEONLY // DATE без времени

// UUID
DataTypes.UUID

UUID может генерироваться автоматически:

{
type: DataTypes.UUID,
defaultValue: Sequelize.UUIDV4
}

Другие типы данных:

// Диапазоны (только для `postgres`)
DataTypes.RANGE(DataTypes.INTEGER) // int4range
DataTypes.RANGE(DataTypes.BIGINT) // int8range
DataTypes.RANGE(DataTypes.DATE) // tstzrange
DataTypes.RANGE(DataTypes.DATEONLY) // daterange
DataTypes.RANGE(DataTypes.DECIMAL) // numrange

// Буферы
DataTypes.BLOB // BLOB
DataTypes.BLOB('tiny') // TINYBLOB
DataTypes.BLOB('medium') // MEDIUMBLOB
DataTypes.BLOB('long') // LONGBLOB

// Перечисления - могут определяться по-другому (см. ниже)
DataTypes.ENUM('foo', 'bar')

// JSON (только для `sqlite`/`mysql`/`mariadb`/`postres`)
DataTypes.JSON

// JSONB (только для `postgres`)
DataTypes.JSONB

// другие
DataTypes.ARRAY(/* DataTypes.SOMETHING */) // массив DataTypes.SOMETHING. Только для `PostgreSQL`

DataTypes.CIDR // CIDR - только для `PostgreSQL`
DataTypes.INET // INET - только для `PostgreSQL`
DataTypes.MACADDR // MACADDR - только для `PostgreSQL`

DataTypes.GEOMETRY // Пространственная колонка. Только для `PostgreSQL` (с `PostGIS`) или `MySQL`
DataTypes.GEOMETRY('POINT') // Пространственная колонка с геометрическим типом. Только для `PostgreSQL` (с `PostGIS`) или `MySQL`
DataTypes.GEOMETRY('POINT', 4326) // Пространственная колонка с геометрическим типом и `SRID`. Только для `PostgreSQL` (с `PostGIS`) или `MySQL`

Настройки колонки

const { DataTypes, Defferable } = require('sequelize')

sequelize.define('Foo', {
// Поле `flag` логического типа по умолчанию будет иметь значение `true`
flag: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },

// Дефолтным значением поля `myDate` будет текущие дата и время
myDate: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },

// Настройка `allowNull` со значением `false` запрещает запись в колонку нулевых значений (NULL)
title: { type: DataTypes.STRING, allowNull: false },

// Создание двух объектов с одинаковым набором значений, обычно, приводит к возникновению ошибки.
// Значением настройки `unique` может быть строка или булевое значение. В данном случае формируется составной уникальный ключ
uniqueOne: { type: DataTypes.STRING, unique: 'compositeIndex' },
uniqueTwo: { type: DataTypes.INTEGER, unique: 'compositeIndex' },

// `unique` используется для обозначения полей, которые должны содержать только уникальные значения
someUnique: { type: DataTypes.STRING, unique: true },

// Первичные или основные ключи будут подробно рассмотрены далее
identifier: { type: DataTypes.STRING, primaryKey: true },

// Настройка `autoIncrement` может использоваться для создания колонки с автоматически увеличивающимися целыми числами
incrementMe: { type: DataTypes.INTEGER, autoIncrement: true },

// Настройка `field` позволяет кастомизировать название колонки
fieldWithUnderscores: { type: DataTypes.STRING, field: 'field_with_underscores' },

// Внешние ключи также будут подробно рассмотрены далее
bar_id: {
type: DataTypes.INTEGER,

references: {
// ссылка на другую модель
model: Bar,

// название колонки модели-ссылки с первичным ключом
key: 'id',

// в случае с `postres`, можно определять задержку получения внешних ключей
deferrable: Deferrable.INITIALLY_IMMEDIATE
/*
`Deferrable.INITIALLY_IMMEDIATE` - проверка внешних ключей выполняется незамедлительно
`Deferrable.INITIALLY_DEFERRED` - проверка внешних ключей откладывается до конца транзакции
`Deferrable.NOT` - без задержки: это не позволит динамически изменять правила в транзакции
*/

// Комментарии можно добавлять только в `mysql`/`mariadb`/`postres` и `mssql`
commentMe: {
type: DataTypes.STRING,
comment: 'Комментарий'
}
}
}
}, {
// Аналог атрибута `someUnique`
indexes: [{
unique: true,
fields: ['someUnique']
}]
})

Экземпляры

Наш начальный код будет выглядеть следующим образом:

const { Sequelize, DataTypes } = require('sequelize')
const sequelize = new Sequelize('sqlite::memory:')

// Создаем модель для пользователя со следующими атрибутами
const User = sequelize.define('User', {
// имя
name: DataTypes.STRING,
// любимый цвет - по умолчанию зеленый
favouriteColor: {
type: DataTypes.STRING,
defaultValue: 'green',
},
// возраст
age: DataTypes.INTEGER,
// деньги
cash: DataTypes.INTEGER,
})

;(async () => {
// Пересоздаем таблицу в БД
await sequelize.sync({ force: true })
// дальнейший код
})()

Создание экземпляра:

// Создаем объект
const jane = User.build({ name: 'Jane' })
// и сохраняем его в БД
await jane.save()

// Сокращенный вариант
const jane = await User.create({ name: 'Jane' })
console.log(jane.toJSON())
console.log(JSON.stringify(jane, null, 2))

Обновление экземпляра:

const john = await User.create({ name: 'John' })
// Вносим изменение
john.name = 'Bob'
// и обновляем соответствующую запись в БД
await john.save()

Удаление экземпляра:

await john.destroy()

"Перезагрузка" экземпляра:

const john = await User.create({ name: 'John' })
john.name = 'Bob'

// Перезагрузка экземпляра приводит к сбросу всех полей к дефолтным значениям
await john.reload()
console.log(john.name) // John

Сохранение отдельных полей:

const john = await User.create({ name: 'John' })
john.name = 'Bob'
john.favouriteColor = 'blue'
// Сохраняем только изменение имени
await john.save({ fields: ['name'] })

await john.reload()
console.log(john.name) // Bob
// Изменение цвета не было зафиксировано
console.log(john.favouriteColor) // green

Автоматическое увеличение значения поля:

const john = await User.create({ name: 'John', age: 98 })

const incrementResult = await john.increment('age', { by: 2 })
// При увеличении значение на 1, настройку `by` можно опустить - increment('age')

// Обновленный пользователь будет возвращен только в `postres`, в других БД он будет иметь значение `undefined`

Автоматическое увеличения значений нескольких полей:

const john = await User.create({ name: 'John', age: 98, cash: 1000 })

await john.increment({
age: 2,
cash: 500,
})

Также имеется возможность автоматического уменьшения значений полей (decrement()).

Основы выполнения запросов

Создание экземпляра:

const john = await User.create({
firstName: 'John',
lastName: 'Smith',
})

Создание экземпляра с определенными полями:

const user = await User.create(
{
username: 'John',
isAdmin: true,
},
{
fields: ['username'],
}
)

console.log(user.username) // John
console.log(user.isAdmin) // false

Получение экземпляра:

// Получение одного (первого) пользователя
const firstUser = await User.find()

// Получение всех пользователей
const allUsers = await User.findAll() // SELECT * FROM ...;

Выборка полей:

// Получение полей `foo` и `bar`
Model.findAll({
attributes: ['foo', 'bar'],
}) // SELECT foo, bar FROM ...;

// Изменение имени поля `bar` на `baz`
Model.findAll({
attributes: ['foo', ['bar', 'baz'], 'qux'],
}) // SELECT foo, bar AS baz, qux FROM ...;

// Выполнение агрегации
// Синоним `n_hats` является обязательным
Model.findAll({
attributes: [
'foo',
[sequelize.fn('COUNT', sequelize.col('hats')), 'n_hats'],
'bar',
],
}) // SELECT foo, COUNT(hats) AS n_hats, bar FROM ...;
// instance.n_hats

// Сокращение - чтобы не перечислять все атрибуты при агрегации
Model.findAll({
attributes: {
include: [[sequelize.fn('COUNT', sequelize.col('hats')), 'n_hast']],
},
})

// Исключение поля из выборки
Model.findAll({
attributes: {
exclude: ['baz'],
},
})

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

// Выполняем поиск поста по идентификатору его автора
// предполагается `Op.eq`
Post.findAll({
where: {
authorId: 2,
},
}) // SELECT * FROM post WHERE authorId = 2;

// Полный вариант
const { Op } = require('sequelize')
Post.findAll({
where: {
authorId: {
[Op.eq]: 2,
},
},
})

// Фильтрация по нескольким полям
// предполагается `Op.and`
Post.findAll({
where: {
authorId: 2,
status: 'active',
},
}) // SELECT * FROM post WHERE authorId = 2 AND status = 'active';

// Полный вариант
Post.findAll({
where: {
[Op.and]: [{ authorId: 2 }, { status: 'active' }],
},
})

// ИЛИ
Post.findAll({
where: {
[Op.or]: [{ authorId: 2 }, { authorId: 3 }],
},
}) // SELECT * FROM post WHERE authorId = 12 OR authorId = 13;

// Одинаковые названия полей можно опускать
Post.destroy({
where: {
authorId: {
[Op.or]: [2, 3],
},
},
}) // DELETE FROM post WHERE authorId = 2 OR authorId = 3;

Операторы

const { Op } = require('sequelize')

Post.findAll({
where: {
[Op.and]: [{ a: 1, b: 2 }], // (a = 1) AND (b = 2)
[Op.or]: [{ a: 1, b: 2 }], // (a = 1) OR (b = 2)
someAttr: {
// Основные
[Op.eq]: 3, // = 3
[Op.ne]: 4, // != 4
[Op.is]: null, // IS NULL
[Op.not]: true, // IS NOT TRUE
[Op.or]: [5, 6], // (someAttr = 5) OR (someAttr = 6)

// Использование диалекта определенной БД (`postgres`, в данном случае)
[Op.col]: 'user.org_id', // = 'user'.'org_id'

// Сравнение чисел
[Op.gt]: 6, // > 6
[Op.gte]: 6, // >= 6
[Op.lt]: 7, // < 7
[Op.lte]: 7, // <= 7
[Op.between]: [8, 10], // BETWEEN 8 AND 10
[Op.notBetween]: [8, 10], // NOT BETWEEN 8 AND 10

// Другие
[Op.all]: sequelize.literal('SELECT 1'), // > ALL (SELECT 1)

[Op.in]: [10, 12], // IN [1, 2]
[Op.notIn]: [10, 12] // NOT IN [1, 2]

[Op.like]: '%foo', // LIKE '%foo'
[Op.notLike]: '%foo', // NOT LIKE '%foo'
[Op.startsWith]: 'foo', // LIKE 'foo%'
[Op.endsWith]: 'foo', // LIKE '%foo'
[Op.substring]: 'foo', // LIKE '%foo%'
[Op.iLike]: '%foo', // ILIKE '%foo' (учет регистра, только для `postgres`)
[Op.notILike]: '%foo', // NOT ILIKE '%foo'
[Op.regexp]: '^[b|a|r]', // REGEXP/~ '^[b|a|r]' (только для `mysql`/`postgres`)
[Op.notRegexp]: '^[b|a|r]', // NOT REGEXP/!~ '^[b|a|r]' (только для `mysql`/`postgres`),
[Op.iRegexp]: '^[b|a|r]', // ~* '^[b|a|r]' (только для `postgres`)
[Op.notIRegexp]: '^[b|a|r]', // !~* '^[b|a|r]' (только для `postgres`)

[Op.any]: [2, 3], // ANY ARRAY[2, 3]::INTEGER (только для `postgres`)

[Op.like]: { [Op.any]: ['foo', 'bar'] } // LIKE ANY ARRAY['foo', 'bar'] (только для `postgres`)

// и т.д.
}
}
})

Передача массива в where приводит к неявному применению оператора IN:

Post.findAll({
where: {
id: [1, 2, 3], // id: { [Op.in]: [1, 2, 3] }
},
}) // ... WHERE 'post'.'id' IN (1, 2, 3)

Операторы Op.and, Op.or и Op.not могут использоваться для создания сложных операций, связанных с логическими сравнениями:

const { Op } = require('sequelize')

Foo.findAll({
where: {
rank: {
[Op.or]: {
[Op.lt]: 1000,
[Op.eq]: null
}
}, // rank < 1000 OR rank IS NULL
{
createdAt: {
[Op.lt]: new Date(),
[Op.gt]: new Date(new Date() - 24 * 60 * 60 * 1000)
}
}, // createdAt < [timestamp] AND createdAt > [timestamp]
{
[Op.or]: [
{
title: {
[Op.like]: 'Foo%'
}
},
{
description: {
[Op.like]: '%foo%'
}
}
]
} // title LIKE 'Foo%' OR description LIKE '%foo%'
}
})

// НЕ
Project.findAll({
where: {
name: 'Some Project',
[Op.not]: [
{ id: [1, 2, 3] },
{
description: {
[Op.like]: 'Awe%'
}
}
]
}
})
/*
SELECT *
FROM 'Projects'
WHERE (
'Projects'.'name' = 'Some Project'
AND NOT (
'Projects'.'id' IN (1, 2, 3)
OR
'Projects'.'description' LIKE 'Awe%'
)
)
*/

"Продвинутые" запросы:

Post.findAll({
where: sequelize.where(
sequelize.fn('char_length', sequelize.col('content')),
7
),
}) // WHERE char_length('content') = 7

Post.findAll({
where: {
[Op.or]: [
sequelize.where(sequelize.fn('char_length', sequelize.col('content')), 7),
{
content: {
[Op.like]: 'Hello%',
},
},
{
[Op.and]: [
{ status: 'draft' },
sequelize.where(
sequelize.fn('char_length', sequelize.col('content')),
{
[Op.gt]: 8,
}
),
],
},
],
},
})

/*
...
WHERE (
char_length("content") = 7
OR
"post"."content" LIKE 'Hello%'
OR (
"post"."status" = 'draft'
AND
char_length("content") > 8
)
)
*/

Длинное получилось лирическое отступление. Двигаемся дальше.

Обновление экземпляра:

// Изменяем имя пользователя с `userId = 2`
await User.update(
{
firstName: 'John',
},
{
where: {
userId: 2,
},
}
)

Удаление экземпляра:

// Удаление пользователя с `id = 2`
await User.destroy({
where: {
userId: 2,
},
})

// Удаление всех пользователей
await User.destroy({
truncate: true,
})

Создание нескольких экземпляров одновременно:

const users = await User.bulkCreate([{ name: 'John' }, { name: 'Jane' }])

// Настройка `validate` со значением `true` заставляет `Sequelize` выполнять валидацию каждого объекта, создаваемого с помощью `bulkCreate()`
// По умолчанию валидация таких объектов не проводится
const User = sequelize.define('User', {
name: {
type: DataTypes.STRING,
validate: {
len: [2, 10],
},
},
})

await User.bulkCreate([{ name: 'John' }, { name: 'J' }], { validate: true }) // Ошибка!

// Настройка `fields` позволяет определять поля для сохранения
await User.bulkCreate([{ name: 'John' }, { name: 'Jane', age: 30 }], {
fields: ['name'],
}) // Сохраняем только имена пользователей

Сортировка и группировка

Настройка order определяет порядок сортировки возвращаемых объектов:

Submodel.findAll({
order: [
// Сортировка по заголовку (по убыванию)
['title', 'DESC'],

// Сортировка по максимальному возврасту
sequelize.fn('max', sequelize.col('age')),

// Тоже самое, но по убыванию
[sequelize.fn('max', sequelize.col('age')), 'DESC'],

// Сортировка по `createdAt` из связанной модели
[Model, 'createdAt', 'DESC'],

// Сортировка по `createdAt` из двух связанных моделей
[Model, AnotherModel, 'createdAt', 'DESC'],

// и т.д.
],

// Сортировка по максимальному возврасту (по убыванию)
order: sequelize.literal('max(age) DESC'),

// Сортировка по максимальному возрасту (по возрастанию - направление сортировки по умолчанию)
order: sequelize.fn('max', sequelize.col('age')),

// Сортировка по возрасту (по возрастанию)
order: sequelize.col('age'),

// Случайная сортировка
order: sequelize.random(),
})

Model.findOne({
order: [
// возвращает `name`
['name'],
// возвращает `'name' DESC`
['name', 'DESC'],
// возвращает `max('age')`
sequelize.fn('max', sequelize.col('age')),
// возвращает `max('age') DESC`
[sequelize.fn('max', sequelize.col('age')), 'DESC'],

// и т.д.
],
})

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

Project.findAll({ group: 'name' }) // GROUP BY name

Настройки limit и offset позволяют ограничивать и/или пропускать определенное количество возвращаемых объектов:

// Получаем 10 проектов
Project.findAll({ limit: 10 })

// Пропускаем 5 первых объектов
Project.findAll({ offset: 5 })

// Пропускаем 5 первых объектов и возвращаем 10
Project.findAll({ offset: 5, limit: 10 })

Sequelize предоставляет несколько полезных утилит:

// Определяем число вхождений
console.log(
`В настоящий момент в БД находится ${await Project.count()} проектов.`
)

const amount = await Project.count({
where: {
projectId: {
[Op.gt]: 25,
},
},
})
console.log(
`В настоящий момент в БД находится ${amount} проектов с идентификатором больше 25.`
)

// max, min, sum
// Предположим, что у нас имеется 3 пользователя 20, 30 и 40 лет
await User.max('age') // 40
await User.max('age', { where: { age: { [Op.lt]: 31 } } }) // 30
await User.min('age') // 20
await User.min('age', { where: { age: { [Op.gt]: 21 } } }) // 30
await User.sum('age') // 90
await User.sum('age', { where: { age: { [op.gt]: 21 } } }) // 70

Поисковые запросы

Настройка raw со значением true отключает "оборачивание" ответа, возвращаемого SELECT, в экземпляр модели.

  • findAll() - возвращает все экземпляры модели
  • findByPk() - возвращает один экземпляр по первичному ключу
const project = await Project.findByPk(123)
  • findOne() - возвращает первый или один экземпляр модели (это зависит от того, указано ли условие для поиска)
const project = await Project.findOne({ where: { projectId: 123 } })
  • findOrCreate() - возвращает или создает и возвращает экземпляр, а также логическое значение - индикатор создания экземпляра. Настройка defaults используется для определения значений по умолчанию. При ее отсутствии, для заполнения полей используется значение, указанное в условии
// Предположим, что у нас имеется пустая БД с моделью `User`, у которой имеются поля `username` и `job`
const [user, created] = await User.findOrCreate({
where: { username: 'John' },
defaults: {
job: 'JavaScript Developer',
},
})
  • findAndCountAll() - комбинация findAll() и count. Может быть полезным при использовании настроек limit и offset, когда мы хотим знать точное число записей, совпадающих с запросом. Возвращает объект с двумя свойствами:
    • count - количество записей, совпадающих с запросом (целое число)
    • rows - массив объектов
const { count, rows } = await Project.findAndCountAll({
where: {
title: {
[Op.like]: 'foo%',
},
},
offset: 10,
limit: 5,
})

Геттеры, сеттеры и виртуальные атрибуты

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

Геттер - это функция get(), определенная для колонки:

const User = sequelize.define('User', {
username: {
type: DataTypes.STRING,
get() {
const rawValue = this.getDataValue(username)
return rawValue ? rawValue.toUpperCase() : null
},
},
})

Геттер вызывается автоматически при чтении поля.

Обратите внимание: для получения значения поля в геттере мы использовали метод getDataValue(). Если вместо этого указать this.username, то мы попадем в бесконечный цикл.

Сеттер - это функция set(), определенная для колонки. Она принимает значение для установки:

const User = sequelize.define('user', {
username: DataTypes.STRING,
password: {
type: DataTypes.STRING,
set(value) {
// Перед записью в БД пароли следует "хэшировать" с помощью криптографической функции
this.setDataValue('password', hash(value))
},
},
})

Сеттер вызывается автоматически при создании экземпляра.

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

const User = sequelize.define('User', {
username: DatTypes.STRING,
password: {
type: DataTypes.STRING,
set(value) {
// Используем значение поля `username`
this.setDataValue('password', hash(this.username + value))
},
},
})

Геттеры и сеттеры можно использовать совместно. Допустим, что у нас имеется модель Post с полем content неограниченной длины, и в целях экономии памяти мы решили хранить в БД содержимое поста в сжатом виде. Обратите внимание: многие современные БД выполняют сжатие (компрессию) данных автоматически.

const { gzipSync, gunzipSync } = require('zlib')

const Post = sequelize.define('post', {
content: {
type: DataTypes.TEXT,
get() {
const storedValue = this.getDataValue('content')
const gzippedBuffer = Buffer.from(storedValue, 'base64')
const unzippedBuffer = gunzipSync(gzippedBuffer)
return unzippedBuffer.toString()
},
set(value) {
const gzippedBuffer = gzipSync(value)
this.setDataValue('content', gzippedBuffer.toString('base64'))
},
},
})

Представим, что у нас имеется модель User с полями firstName и lastName, и мы хотим получать полное имя пользователя. Для этого мы можем создать виртуальный атрибут со специальным типом DataTypes.VIRTUAL:

const User = sequelize.define('user', {
firstName: DataTypes.STRING,
lastName: DataTypes.STRING,
fullName: {
type: DataTypes.VIRTUAL,
get() {
return `${this.firstName} ${this.lastName}`
},
set(value) {
throw new Error('Нельзя этого делать!')
},
},
})

В таблице не будет колонки fullName, однако мы сможем получать значение этого поля, как если бы оно существовало на самом деле.

Валидация и ограничения

Наша моделька будет выглядеть так:

const { Sequelize, Op, DataTypes } = require('sequelize')
const sequelize = new Sequelize('sqlite::memory:')

const User = sequelize.define('user', {
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
hashedPassword: {
type: DataTypes.STRING(64),
is: /^[0-9a-f]{64}$/i,
},
})

Отличие между выполнением валидации и применением или наложением органичение на значение поля состоит в следующем:

  • валидация выполняется на уровне Sequelize; для ее выполнения можно использовать любую функцию, как встроенную, так и кастомную; при провале валидации, SQL-запрос в БД не отправляется;
  • ограничение определяется на уровне SQL; примером ограничения является настройка unique; при провале ограничения, запрос в БД все равно отправляется

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

По умолчанию колонки таблицы могут быть пустыми (нулевыми). Настройка allowNull со значением false позволяет это запретить. Обратите внимание: без установки данной настройки хотя бы для одного поля, можно будет выполнить такой запрос: User.create({}).

Валидаторы позволяют проводить проверку в отношении каждого атрибута модели. Валидация автоматически выполняется при запуске методов create(), update() и save(). Ее также можно запустить вручную с помощью validate().

Как было отмечено ранее, мы можем определять собственные валидаторы или использовать встроенные (предоставляемые библиотекой validator.js).

sequelize.define('foo', {
bar: {
type: DataTypes.STRING,
validate: {
is: /^[a-z]+$/i, // определение совпадения с регулярным выражением
not: /^[a-z]+$/i, // определение отсутствия совпадения с регуляркой
isEmail: true,
isUrl: true,
isIP: true,
isIPv4: true,
isIPv6: true,
isAlpha: true,
isAlphanumeric: true,
isNumeric: true,
isInt: true,
isFloat: true,
isDecimal: true,
isLowercase: true,
isUppercase: true,
notNull: true,
isNull: true,
notEmpty: true,
equals: 'определенное значение',
contains: 'foo', // определение наличия подстроки
notContains: 'bar', // определение отсутствия подстроки
notIn: [['foo', 'bar']], // определение того, что значение НЕ является одним из указанных
isIn: [['foo', 'bar']], // определение того, что значение является одним из указанных
len: [2, 10], // длина строки должна составлять от 2 до 10 символов
isUUID: true,
isDate: true,
isAfter: '2021-06-12',
isBefore: '2021-06-15',
max: 65,
min: 18,
isCreditCard: true,

// Примеры кастомных валидаторов
isEven(value) {
if (parseInt(value) % 2 !== 0) {
throw new Error('Разрешены только четные числа!')
}
},
isGreaterThanOtherField(value) {
if (parseInt(value) < parseInt(this.otherField)) {
throw new Error(
`Значение данного поля должно быть больше значения ${otherField}!`
)
}
},
},
},
})

Для кастомизации сообщения об ошибке можно использовать объект со свойством msg:

isInt: {
msg: 'Значение должно быть целым числом!'
}

В этом случае для указания аргументов используется свойство args:

isIn: {
args: [['ru', 'en']],
msg: 'Язык должен быть русским или английским!'
}

Для поля, которое может иметь значение null, встроенные валидаторы пропускаются. Это означает, что мы, например, можем определить поле, которое либо должно содержать строку длиной 5-10 символов, либо должно быть пустым:

const User = sequelize.define('user', {
username: {
type: DataTypes.STRING,
allowNull: true,
validate: {
len: [5, 10],
},
},
})

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

const User = sequelize.define('user', {
age: DataTypes.INTEGER,
name: {
type: DataTypes.STRING,
allowNull: true,
validate: {
customValidator(value) {
if (value === null && this.age < 18) {
throw new Error('Нулевые значения разрешены только совершеннолетним!')
}
},
},
},
})

Мы можем выполнять валидацию не только отдельных полей, но и модели в целом. В следующем примере мы проверяем наличие или отсутствии как поля latitude, так и поля longitude (либо должны быть указаны оба поля, либо не должно быть указано ни одного):

const Place = sequelize.define(
'place',
{
name: DataTypes.STRING,
address: DataTypes.STRING,
latitude: {
type: DataTypes.INTEGER,
validate: {
min: -90,
max: 90,
},
},
longitude: {
type: DataTypes.INTEGER,
validate: {
min: -180,
max: 180,
},
},
},
{
validate: {
bothCoordsOrNone() {
if (!this.latitude !== !this.longitude) {
throw new Error(
'Либо укажите и долготу, и широту, либо ничего не указывайте!'
)
}
},
},
}
)

Необработанные запросы

sequelize.query() позволяет выполнять необработанные SQL-запросы (raw queries). По умолчанию данная функция возвращает массив с результатами и объект с метаданными, при этом, содержание последнего зависит от используемого диалекта.

const [results, metadata] = await sequelize.query(
"UPDATE users SET username = 'John' WHERE userId = 123"
)

Если нам не нужны метаданные, для правильного форматирования результата можно воспользоваться специальными типами запроса (query types):

const { QueryTypes } = require('sequelize')

const users = await sequelize.query('SELECT * FROM users', {
// тип запроса - выборка
type: QueryTypes.SELECT,
})

Для привязки результатов необработанного запроса к модели используются настройки model и, опционально, mapToModel:

const projects = await sequelize.query('SELECT * FROM projects', {
model: Project,
mapToModel: true,
})

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

sequelize.query('SELECT 1', {
// "логгирование" - функция или `false`
logging: console.log,

// если `true`, возвращается только первый результат
plain: false,

// если `true`, для выполнения запроса не нужна модель
raw: false,

// тип выполняемого запроса
type: QueryTypes.SELECT,
})

Если название атрибута в таблице содержит точки, то результирующий объект может быть преобразован во вложенные объекты с помощью настройки nest.

Без nest: true:

const records = await sequelize.query('SELECT 1 AS `foo.bar.baz`', {
type: QueryTypes.SELECT,
})
console.log(JSON.stringify(records[0], null, 2))
// { 'foo.bar.baz': 1 }

С nest: true:

const records = await sequelize.query('SELECT 1 AS `foo.bar.baz`', {
type: QueryTypes.SELECT,
nest: true,
})
console.log(JSON.stringify(records[0], null, 2))
/*
{
'foo': {
'bar': {
'baz': 1
}
}
}
*/

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

  • с помощью именованных параметров (начинающихся с :)
  • с помощью неименованных параметров (представленных ?)

Заменители (placeholders) передаются в настройку replacements в виде массива (для неименованных параметров) или в виде объекта (для именованных параметров):

  • если передан массив, ? заменяется элементами массива в порядке их следования
  • если передан объект, :key заменяются ключами объекта. При отсутствии в объекте ключей для заменяемых значений, а также в случае, когда ключей в объекте больше, чем заменяемых значений, выбрасывается исключение
sequelize.query('SELECT * FROM projects WHERE status = ?', {
replacements: ['active'],
type: QueryTypes.SELECT,
})

sequelize.query('SELECT * FROM projects WHERE status = :status', {
replacements: { status: 'active' },
type: QueryTypes.SELECT,
})

Продвинутые примеры замены:

// Замена производится при совпадении с любым значением из массива
sequelize.query('SELECT * FROM projects WHERE status IN(:status)', {
replacements: { status: ['active', 'inactive'] },
type: QueryTypes.SELECT,
})

// Замена выполняется для всех пользователей, имена которых начинаются с `J`
sequelize.query('SELECT * FROM users WHERE name LIKE :search_name', {
replacements: { search_name: 'J%' },
type: QueryTypes.SELECT,
})

Кроме замены, можно выполнять привязку (bind) параметров. Привязка похожа на замену, но заменители обезвреживаются (escaped) и вставляются в запрос, отправляемый в БД, а связанные параметры отправляются в БД по отдельности. Связанные параметры обозначаются с помощью $число или $строка:

  • если передан массив, $1 будет указывать на его первый элемент (bind[0])
  • если передан объект, $key будет указывать на object['key']. Каждый ключ объекта должен начинаться с буквы. $1 является невалидным ключом, даже если существует object['1']
  • в обоих случаях для сохранения знака $ может использоваться $$

Связанные параметры не могут быть ключевыми словами SQL, названиями таблиц или колонок. Они игнорируются внутри текста, заключенного в кавычки. Кроме того, в postgres может потребоваться указывать тип связываемого параметра в случае, когда он не может быть выведен на основании контекста - $1::varchar.

sequelize.query(
'SELECT *, "текст с литеральным $$1 и литеральным $$status" AS t FROM projects WHERE status = $1',
{
bind: ['active'],
type: QueryTypes.SELECT,
}
)

sequelize.query(
'SELECT *, "текст с литеральным $$1 и литеральным $$status" AS t FROM projects WHERE status = $status',
{
bind: { status: 'active' },
type: QueryTypes.SELECT,
}
)

Ассоциации

Sequelize поддерживает стандартные ассоциации или отношения между моделями: один-к-одному (One-To-One), один-ко-многим (One-To-Many), многие-ко-многим (Many-To-Many).

Существует 4 типа ассоциаций:

  • HasOne
  • BelongsTo
  • HasMany
  • BelongsToMany

Определение ассоциации

Предположим, что у нас имеется 2 модели, A и B. Вот как можно определить между ними связь:

const A = sequelize.define('A' /* ... */)
const B = sequelize.define('B' /* ... */)

A.hasOne(B)
A.belongsTo(B)
A.hasMany(B)
A.belongsToMany(B)

Все эти функции принимают объект с настройками (для первых трех он является опциональным, для последнего - обязательным). В настройках должно быть определено как минимум свойство through:

A.hasOne(B, {
/* настройки */
})
A.belongsTo(B, {
/* настройки */
})
A.hasMany(B, {
/* настройки */
})
A.belongsToMany(B, { through: 'C' /* другие настройки */ })

Порядок определения ассоциаций имеет принципиальное значение. В приведенных примерах A - это модель-источник (source), а B - это целевая модель (target). Запомните эти термины.

A.hasOne(B) означает, что между A и B существуют отношения один-к-одному, при этом, внешний ключ (foreign key) определяется в целевой модели (B).

A.belongsTo(B) - отношения один-к-одному, внешний ключ определяется в источнике (A).

A.hasMany(B) - отношения один-ко-многим, внешний ключ определяется в целевой модели (B).

В этих случаях Sequelize автоматически добавляет внешние ключи (при их отсутствии) в соответствующие модели (таблицы).

A.belongsToMany(B, { through: 'C' }) означает, что между A и B существуют отношения многие-ко-многим, таблица C выступает в роли связующего звена между ними через внешние ключи (например, aId и bId). Sequelize автоматически создает модель C при ее отсутствии, определяя в ней соответствующие ключи.

Определение стандартных отношений

Как правило, ассоциации используются парами:

  • для создания отношений один-к-одному используются hasOne() и belongsTo()
  • для один-ко-многим - hasMany() и belongsTo()
  • для многие-ко-многим - два belongsToMany(). Обратите внимание, что существуют также отношения "супер многие-ко-многим", где одновременно используется 6 ассоциаций

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

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

Предположим, что у нас имеется две модели, Foo и Bar. Мы хотим установить между ними отношения один-к-одному таким образом, чтобы Bar содержала атрибут fooId. Это можно реализовать так:

Foo.hasOne(Bar)
Bar.belongsTo(Foo)

Дальнейший вызов Bar.sync() приведет к отправке в БД следующего запроса:

CREATE TABLE IF NOT EXISTS "foos" (
/* ... */
);
CREATE TABLE IF NOT EXISTS "bars" (
/* ... */
"fooId" INTEGER REFERENCES "foos" ("id") ON DELETE SET NULL ON UPDATE CASCADE
/* ... */
);

При создании ассоциации могут использоваться некоторые настройки.

Пример кастомизации поведения при удалении и обновлении внешнего ключа:

Foo.hasOne(Bar, {
onDelete: 'RESTRICT',
onUpdate: 'RESTRICT'
})
Bar.belongsTo(Foo)

Возможными вариантами здесь являются: RESTRICT, CASCADE, NO ACTION, SET DEFAULT и SET NULL.

Название внешнего ключа, которое в приведенном примере по умолчанию имеет значение fooId, можно кастомизировать. Причем, это можно делать как в hasOne(), так и в belongsTo():

// 1
Foo.hasOne(Bar, {
foreignKey: 'myFooId'
})
Bar.belongsTo(Foo)

// 2
Foo.hasOne(Bar, {
foreignKey: {
name: 'myFooId'
}
})
Bar.belongsTo(Foo)

// 3
Foo.hasOne(Bar)
Bar.belongsTo(Foo, {
foreignKey: 'myFooId'
})

// 4
Foo.hasOne(Bar)
Bar.belongsTo(Foo, {
foreignKey: {
name: 'myFooId'
}
})

В случае кастомизации внешнего ключа с помощью объекта, можно определять его тип, значение по умолчанию, ограничения и т.д. Например, в качестве типа внешнего ключа можно использовать DataTypes.UUID вместо дефолтного INTEGER:

Foo.hasOne(Bar, {
foreignKey: {
type: DataTypes.UUID
}
})
Bar.belongsTo(Foo)

По умолчанию ассоциация является опциональной, т.е. fooId может иметь значение null - Bar может существовать без Foo. Для того, чтобы это изменить, можно установить такое ограничение:

Foo.hasOne(Bar, {
foreignKey: {
allowNull: false
}
})

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

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

Предположим, что у нас имеется две модели, Team и Player, и мы хотим определить между ними отношения один-ко-многим: в одной команде может быть несколько игроков, но каждый игрок может принадлежат только к одной команде.

Team.hasMany(Player)
Player.belongsTo(Team)

В данном случае в БД будет отправлен такой запрос:

CREATE TABLE IF NOT EXISTS "Teams" (
/* ... */
);
CREATE TABLE IF NOT EXISTS "Players" (
/* ... */
"TeamId" INTEGER REFERENCES "Teams" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
/* ... */
);

Как и в случае с отношениями один-к-одному, рассматриваемую ассоциацию можно настраивать различными способами.

Team.hasMany(Player, {
foreignKey: 'clubId'
})
Player.belongsTo(Team)

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

Обратите внимание: в отличие от первых двух ассоциаций, внешний ключ не может быть определен ни в одной из связанных таблиц. Для этого используется так называемая "соединительная таблица" (junction, join, through table).

Предположим, что у нас имеется две модели, Movie (фильм) и Actor (актер), и мы хотим определить между ними отношения многие-ко-многим: актер может принимать участие в съемках нескольких фильмов, а в фильме может сниматься несколько актеров. Соединительная таблица будет называться ActorMovies и содержать внешние ключи actorId и movieId.

const Movie = sequelize.define('Movie', { name: DataTypes.STRING })
const Actor = sequelize.define('Actor', { name: DataTypes.STRING })
Movie.belongsToMany(Actor, { through: 'ActorMovies' })
Actor.belongsToMany(Movie, { through: 'ActorMovies' })

В данном случае в postgres, например, будет отправлен такой запрос:

CREATE TABLE IF NOT EXISTS "ActorMovies" (
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"MovieId" INTEGER REFERENCES "Movies" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"ActorId" INTEGER REFERENCES "Actors" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY ("MovieId","ActorId")
);

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

const Movie = sequelize.define('Movie', { name: DataTypes.STRING })
const Actor = sequelize.define('Actor', { name: DataTypes.STRING })
const ActorMovies = sequelize.define('ActorMovies', {
MovieId: {
type: DataTypes.INTEGER,
references: {
model: Movie, // или 'Movies'
key: 'id'
}
},
ActorId: {
type: DataTypes.INTEGER,
references: {
model: Actor, // или 'Actors'
key: 'id'
}
}
})
Movie.belongsToMany(Actor, { through: ActorMovies })
Actor.belongsToMany(Movie, { through: ActorMovies })

В этом случае в БД будет отправлен такой запрос:

CREATE TABLE IF NOT EXISTS "ActorMovies" (
"MovieId" INTEGER NOT NULL REFERENCES "Movies" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"ActorId" INTEGER NOT NULL REFERENCES "Actors" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE ("MovieId", "ActorId"), -- Note: Sequelize generated this UNIQUE constraint but
PRIMARY KEY ("MovieId","ActorId") -- it is irrelevant since it's also a PRIMARY KEY
);

Выполнение запросов, включающих ассоциации

Предположим, что у нас имеется две модели, Ship (корабль) и Captain (капитан). Между этими моделями существуют отношения один-к-одному. Внешние ключи могут иметь значение null. Это означает, что корабль может существовать без капитана, и наоборот.

const Ship = sequelize.define(
'Ship',
{
name: DataTypes.STRING,
crewCapacity: DataTypes.INTEGER,
amountOfSails: DataTypes.INTEGER
},
{ timestamps: false }
)
const Captain = sequelize.define(
'Captain',
{
name: DataTypes.STRING,
skillLevel: {
type: DataTypes.INTEGER,
validate: { min: 1, max: 10 }
}
},
{ timestamps: false }
)
Captain.hasOne(Ship)
Ship.belongsTo(Captain)

Немедленная загрузка и отложенная загрузка

"Ленивая" (lazy) или отложенная загрузка позволяет получать ассоциации (т.е. связанные экземпляры) по мере необходимости, а "нетерпеливая" (eager) или немедленная загрузка предполагает получение всех ассоциаций сразу при выполнении запроса.

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

const awesomeCaptain = await Captain.findOne({
where: {
name: 'Jack Sparrow'
}
})
// выполняем какие-то операции с капитаном
// получаем его корабль
const hisShip = await awesomeCaptain.getShip()
// выполняем операции с кораблем

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

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

const awesomeCaptaint = await Captain.findOne({
where: {
name: 'Jack Sparrow'
},
// сразу получаем корабль, которым управляет данный капитан
include: Ship
})

Создание, обновление и удаление ассоциаций

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

Bar.create({
name: 'Bar',
fooId: 3
})
// создаем `Bar`, принадлежащую `Foo` c `id` = 3

так и специальные методы/миксины (микшины, примеси, mixins) (см. ниже).

Синонимы ассоциаций и кастомные внешние ключи

Немного упростим пример с кораблями и капитанами:

const Ship = sequelize.define(
'Ship',
{ name: DataTypes.STRING },
{ timestamps: false }
)
const Captain = sequelize.define(
'Captain',
{ name: DataTypes.STRING },
{ timestamps: false }
)

Вызов Ship.belongsTo(Captain) приводит к автоматическому созданию внешнего ключа и "геттеров":

Ship.belongsTo(Captain)

console.log((await Ship.findAll({ include: Captain })).toJSON())
// или
console.log((await Ship.findAll({ include: 'Captain' })).toJSON())

const ship = await Ship.findOne()
console.log((await ship.getCaptain()).toJSON())

Название внешнего ключа может быть указано при определении ассоциации:

Ship.belongsTo(Captain, { foreignKey: 'bossId' })

Внешний ключ также может быть определен в виде синонима:

Ship.belongsTo(Captain, { as: 'leader' })

// будет выброшено исключение
console.log((await Ship.findAll({ include: Captain })).toJSON())
// следует использовать синоним
console.log((await Ship.findAll({ include: 'leader' })).toJSON())

Синонимы могут использоваться, например, для определения двух разных ассоциаций между одними и теми же моделями. Например, если у нас имеются модели Mail и Person, может потребоваться связать их дважды для представления sender (отправителя) и receiver (получателя) электронной почты. Если мы этого не сделаем, то вызов mail.getPerson() будет двусмысленным. Благодаря синонимам мы получим два метода: mail.getSender() и mail.getReceiver().

При определении синонимов для ассоциаций hasOne() или belongsTo(), следует использовать сингулярную форму (единственное число), а для ассоциаций hasMany() или belongsToMany() - плюральную (множественное число).

Ничто не мешает нам использовать оба способа определения внешних ключей одновременно:

Ship.belongsTo(Captain, { as: 'leader', foreignKey: 'bossId' })

Специальные методы/миксины

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

Foo.hasOne(Bar)

  • foo.getBar()
  • foo.setBar()
  • foo.createBar()
const foo = await Foo.create({ name: 'foo' })
const bar = await Bar.create({ name: 'bar' })
const bar2 = await Bar.create({ name: 'another bar' })

console.log(await foo.getBar()) // null

await foo.setBar(bar)
console.log(await foo.getBar().name) // bar

await foo.createBar({ name: 'and another bar' })
console.log(await foo.getBar().name) // and another bar

await foo.setBar(null) // удаляем ассоциацию
console.log(await foo.getBar()) // null

Foo.belongsTo(Bar)

  • foo.getBar()
  • foo.setBar()
  • foo.createBar()

Foo.hasMany

  • foo.getBars()
  • foo.countBars()
  • foo.hasBar()
  • foo.hasBars()
  • foo.setBars()
  • foo.addBar()
  • foo.addBars()
  • foo.removeBar()
  • foo.removeBars()
  • foo.createBar()
const foo = await Foo.create({ name: 'foo' })
const bar = await Bar.create({ name: 'bar' })
const bar2 = await Bar.create({ name: 'another bar' })

console.log(await foo.getBars()) // []
console.log(await foo.countBars()) // 0
console.log(await foo.hasBar(bar)) // false

await foo.addBars([bar, bar2])
console.log(await foo.countBars) // 2

await foo.addBar(bar)
console.log(await foo.countBars()) // 2
console.log(await foo.hasBar(bar)) // true

await foo.removeBar(bar2)
console.log(await foo.countBars()) // 1

await foo.createBar({ name: 'and another bar' })
console.log(await foo.countBars()) // 2

await foo.setBars([])
console.log(await foo.countBars()) // 0

Геттеры принимают такие же настройки, что и обычные поисковые методы (такие как findAll()):

const easyTasks = await project.getTasks({
where: {
difficulty: {
[Op.lte]: 5
}
}
})

const taskTitles = (
await project.getTasks({
attributes: ['title'],
raw: true
})
).map((task) => task.title)

Foo.belongsToMany(Bar, { through: Baz })

  • foo.getBars()
  • foo.countBars()
  • foo.hasBar()
  • foo.hasBars()
  • foo.setBars()
  • foo.addBar()
  • foo.addBars()
  • foo.removeBar()
  • foo.removeBars()
  • foo.createBar()

Для формирования названий методов вместо названия модели может использоваться синоним, например:

Task.hasOne(User, { as: 'Author' })
  • task.getAuthor()
  • task.setAuthor()
  • task.createAuthor()

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

  • допустим, что мы определили только ассоциацию Foo.hasOne(Bar)
// это будет работать
await Foo.findOne({ include: Bar })

// а здесь будет выброшено исключение
await Bar.findOne({ include: Foo })
  • если мы определим пару ассоциаций, то все будет в порядке
Bar.belongsTo(Foo)

// работает
await Foo.findOne({ include: Bar })

// и это тоже
await Bar.findOne({ include: Foo })

Синонимы позволяют определять несколько ассоциаций между одними и теми же моделями:

Team.hasOne(Game, { as: 'HomeTeam', foreignKey: 'homeTeamId' })
Team.hasOne(Game, { as: 'AwayTeam', foreignKey: 'awayTeamId' })
Game.belongsTo(Team)

Создание ассоциаций с помощью полей, которые не являются первичными ключами

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

belongsTo()

Ассоциация A.belongsTo(B) приводит к созданию внешнего ключа в модели-источнике (A).

Снова вернемся к примеру с кораблями и ограничим уникальность имен капитанов:

const Ship = sequelize.define(
'Ship',
{ name: DataTypes.STRING },
{ timestamps: false }
)
const Captain = sequelize.define(
'Captain',
{
name: { type: DataTypes.STRING, unique: true }
},
{ timestamp: false }
)

Теперь в качестве внешнего ключа вместо captainId можно использовать captainName. Для этого в ассоциации необходимо определить настройки targetKey и foreignKey:

Ship.belongsTo(Captain, { targetKey: 'name', foreignKey: 'captainName' })

После этого мы можем делать так:

await Captain.create({ name: 'Jack Sparrow' })
const ship = Ship.create({ name: 'Black Pearl', captainName: 'Jack Sparrow' })
console.log((await ship.getCaptain()).name) // Jack Sparrow

hasOne() и hasMany()

В данном случае вместо targetKey определяется настройка sourceKey:

const Foo = sequelize.define(
'foo',
{
name: {
type: DataTypes.STRING,
unique: true
}
},
{
timestamps: false
}
)
const Bar = sequelize.define(
'bar',
{
title: {
type: DataTypes.STRING,
unique: true
}
},
{
timestamps: false
}
)
const Baz = sequelize.define(
'baz',
{
summary: DataTypes.STRING
},
{
timestamps: false
}
)
Foo.hasOne(Bar, { sourceKey: 'name', foreignKey: 'fooName' })
Bar.hasMany(Baz, { sourceKey: 'title', foreignKey: 'barTitle' })

await Bar.setFoo('Название для `Foo`')
await Bar.addBar('Название для `Bar`')

belongsToMany()

В данном случае необходимо определить два внешних ключа в соединительной таблице.

const Foo = sequelize.define(
'foo',
{
name: { type: DataTypes.STRING, unique: true }
},
{ timestamps: false }
)
const Bar = sequelize.define(
'bar',
{
title: { type: DataTypes.STRING, unique: true }
},
{ timestamps: false }
)

Далее выполняется один из следующих 4 шагов:

  • между Foo и Bar определяются отношения многие-ко-многим с помощью дефолтных первичных ключей
Foo.belongsToMany(Bar, { through: 'foo_bar' })
  • отношения определяются с помощью основного ключа для Foo и кастомного ключа для Bar
Foo.belongsToMany(Bar, { through: 'foo_bar', targetKey: 'title' })
  • отношения определяются с помощью кастомного ключа для Foo и первичного ключа для Bar
Foo.belongsToMany(Bar, { through: 'foo_bar', sourceKey: 'name' })
  • отношения определяются с помощью кастомных ключей для обеих моделей
Foo.belongsToMany(Bar, {
through: 'foo_bar',
sourceKey: 'name',
targetKey: 'title'
})

Еще раз в качестве напоминания:

  • A.belongsTo(B) - внешний ключ хранится в модели-источнике (A), ссылка (targetKey) - в целевой модели (B)
  • A.hasOne(B) и A.hasMany(B) - внешний ключ хранится в целевой модели (B), а ссылка (sourceKey) - в источнике (A)
  • A.belongsToMany(B) - используется соединительная таблица, в которой хранятся ключи для sourceKey и targetKey, sourceKey соответствует некоторому полю в источнике (A), targetKey - некоторому полю в целевой модели (B)

"Параноик"

Sequelize поддерживает создание так называемых "параноидальных" (paranoid) таблиц. Из таких таблиц данные по-настоящему не удаляются. Вместо этого, в них добавляется колонка deletedAt в момент выполнения запроса на удаление. Это означает, что в таких таблицах выполняется мягкое удаление (soft-deletion).

Для создания параноика используется настройка paranoid: true. Обратите внимание: для работы такой таблицы требуется фиксация времени создания и обновления таблицы. Поэтому для них нельзя устанавливать timestamps: false. Название поля deletedAt можно кастомизировать.

const Post = sequelize.define(
'post',
{
// атрибуты
},
{
paranoid: true,
deletedAt: 'destroyTime'
}
)

При вызове метода destroy() производится мягкое удаление:

await Post.destroy({
where: {
id: 1
}
}) // UPDATE "posts" SET "deletedAt"=[timestamp] WHERE "deletedAt" IS NULL AND "id" = 1;

Для окончательного удаления параноика следует использовать настройку force: true:

await Post.destroy({
where: {
id: 1
},
force: true
})

Для восстановления "удаленного" значения используется метод restore():

const post = await Post.create({ title: 'test' })
await post.destroy()
console.log('Пост удален мягко!')
await post.restore()
console.log('Пост восстановлен!')

// восстанавливаем "удаленные" посты, набравшие больше 100 лайков, с помощью статического метода `restore()`
await Post.restore({
where: {
likes: {
[Op.gt]: 100
}
}
})

По умолчанию запросы, выполняемые Sequelize, будут игнорировать "удаленные" записи. Это означает, что метод findAll(), например, вернет только "неудаленные" записи, а метод findByPk() при передаче ему первичного ключа "удаленной" записи, вернет null.

Для учета "удаленных" записей при выполнении запроса используется настройка paranoid: false:

await Post.findByPk(123) // null
await Post.findByPk(123, { paranoid: false }) // post

await Post.findAll({
where: { foo: 'bar' }
}) // []
await Post.findAll(
{
where: { foo: 'bar' }
},
{ paranoid: false }
) // [post]

Нетерпеливая загрузка

Нетерпеливая загрузка - это одновременная загрузка основной и связанных с ней моделей. На уровне SQL это означает одно или более соединение (join).

В Sequelize нетерпеливая загрузка, обычно, выполняется с помощью настройки include.

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

const User = sequelize.define('User', { name: DataTypes.STRING })
const Task = sequelize.define('Task', { name: DataTypes.STRING })
const Tool = sequelize.define(
'Tool',
{
name: DataTypes.STRING,
size: DataTypes.STRING
},
{ timestamps: false }
)
User.hasMany(Task)
Task.belongsTo(User)
User.hasMany(Tool, { as: 'Instruments' })

Получение одного связанного экземпляра

const tasks = await Task.findAll({ include: User })
console.log(JSON.stringify(tasks, null, 2))
/*
[{
"name": "A Task",
"id": 1,
"userId": 1,
"user": {
"name": "John Smith",
"id": 1
}
}]
*/

Получение всех связанных экземпляров

const users = await User.findAll({ include: Task })
console.log(JSON.stringify(users, null, 2))
/*
[{
"name": "John Smith",
"id": 1,
"tasks": [{
"name": "A Task",
"id": 1,
"userId": 1
}]
}]
*/

Получение ассоциации через синоним

В случае с синонимами, вместо include используются настройки model и as.

const users = await User.findAll({
include: { model: Tool, as: 'Instruments' }
})
console.log(JSON.stringify(users, null, 2))
/*
[{
"name": "John Doe",
"id": 1,
"Instruments": [{
"name": "Scissor",
"id": 1,
"userId": 1
}]
}]
*/

Существуют и другие способы получения ассоциаций через синонимы:

User.findAll({ include: 'Instruments' })
User.findAll({ include: { assosiation: 'Instruments' } })

Фильтрация с помощью нетерпеливой загрузки

Настройка required позволяет фильтровать результат выполняемого запроса - конвертировать OUTER JOIN в INNER JOIN. В следующем примере возвращаются только те пользователи, у которых есть задачи:

User.findAll({
include: {
model: Task,
required: true
}
})

Фильтрация на уровне связанной модели

Фильтрацию на уровне связанной модели можно выполнять с помощью настройки where. В следующем примере возвращаются только пользователи, у которых имеется хотя бы один инструмент НЕ маленького размера:

User.findAll({
include: {
model: Tool,
as: 'Instruments',
where: {
size: {
[Op.ne]: 'small'
}
}
}
})

Генерируемый SQL-запрос выглядит так:

SELECT
`user`.`id`,
`user`.`name`,
`Instruments`.`id` AS `Instruments.id`,
`Instruments`.`name` AS `Instruments.name`,
`Instruments`.`size` AS `Instruments.size`,
`Instruments`.`userId` AS `Instruments.userId`
FROM `users` AS `user`
INNER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId` AND
`Instruments`.`size` != 'small';

В следующем примере настройка where применяется для фильтрации значений связанной модели с помощью функции Sequelize.col():

Project.findAll({
include: {
model: Task,
where: {
state: Sequelize.col('project.state')
}
}
})

Сложная фильтрация с помощью where на верхнем уровне

Sequelize предоставляет специальный синтаксис $nested.column$ для реализации фильтрации значений вложенных колонок с помощью where:

User.findAll({
where: {
'$Instruments.size$': { [Op.ne]: 'small' }
},
include: [
{
model: Tool,
as: 'Instruments'
}
]
})

Генерируемый SQL-запрос выглядит так:

SELECT
`user`.`id`,
`user`.`name`,
`Instruments`.`id` AS `Instruments.id`,
`Instruments`.`name` AS `Instruments.name`,
`Instruments`.`size` AS `Instruments.size`,
`Instruments`.`userId` AS `Instruments.userId`
FROM `users` AS `user`
LEFT OUTER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId`
WHERE `Instruments`.`size` != 'small';

При этом, уровень вложенности фильтруемых колонок значения не имеет.

Для лучшего понимания разницы между использование внутреннего whereinclude) с настройкой required и без нее, а также использованием where на верхнем уровне с помощью синтаксиса $nested.column$, рассмотрим 4 примера:

// внутренний `where` с `required: true` по умолчанию
await User.findAll({
include: {
model: Tool,
as: 'Instruments',
where: {
size: {
[Op.ne]: 'small'
}
}
}
})

// внутренний `where` с `required: false`
await User.findAll({
include: {
model: Tool,
as: 'Instruments',
where: {
size: {
[Op.ne]: 'small'
},
required: false
}
}
})

// использование `where` на верхнем уровне с `required: false`
await User.findAll({
where: {
'$Instruments.size$': {
[Op.ne]: 'small'
}
},
include: {
model: Tool,
as: 'Instruments'
}
})

// использование `where` на верхнем уровне с `required: true`
await User.findAll({
where: {
'$Instruments.size$': {
[Op.ne]: 'small'
}
},
include: {
model: Tool,
as: 'Instruments',
required: true
}
})

Генерируемые SQL-запросы:

--
SELECT [...] FROM `users` AS `user`
INNER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId`
AND `Instruments`.`size` != 'small';

--
SELECT [...] FROM `users` AS `user`
LEFT OUTER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId`
AND `Instruments`.`size` != 'small';

--
SELECT [...] FROM `users` AS `user`
LEFT OUTER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId`
WHERE `Instruments`.`size` != 'small';

--
SELECT [...] FROM `users` AS `user`
INNER JOIN `tools` AS `Instruments` ON
`user`.`id` = `Instruments`.`userId`
WHERE `Instruments`.`size` != 'small';

include может принимать массив связанных моделей:

Foo.findAll({
include: [
{
model: Bar,
required: true
},
{
model: Baz,
where: {
/* ... */
}
},
Qux // сокращение для `{ model: Qux }`
]
})

Нетерпеливая загрузка в случае с отношениями многие-ко-многим

В данном случае Sequelize автоматически добавляет соединительную таблицу:

const Foo = sequelize.define('Foo', { name: DataTypes.STRING })
const Bar = sequelize.define('Bar', { name: DataTypes.STRING })
Foo.belongsToMany(Bar, { through: 'Foo_Bar' })
Bar.belongsToMany(Foo, { through: 'Foo_Bar' })

await sequelize.sync()
const foo = await Foo.create({ name: 'foo' })
const bar = await Bar.create({ name: 'bar' })
await foo.addBar(bar)
const fetchedFoo = Foo.findOne({ include: Bar })
copnsole.log(JSON.stringify(fetchedFoo, null, 2))
/*
{
"id": 1,
"name": "foo",
"Bars": [
{
"id": 1,
"name": "bar",
"Foo_Bar": {
"FooId": 1,
"BarId": 1
}
}
]
}
*/

Настройка attributes позволяет определять включаемые в ответ поля соединительной таблицы:

Foo.findAll({
include: [
{
model: Bar,
through: {
attributes: [
/* атрибуты соединительной таблицы */
]
}
}
]
})

В случае, когда нам не нужны такие поля, в attributes передается пустой массив:

Foo.findOne({
include: {
model: Bar,
attributes: []
}
})
/*
{
"id": 1,
"name": "foo",
"Bars": [
{
"id": 1,
"name": "bar"
}
]
}
*/

Включаемые поля соединительной таблицы можно фильтровать с помощью настройки where:

User.findAll({
include: [
{
model: Project,
through: {
where: {
completed: true
}
}
}
]
})

Генерируемый SQL-запрос (sqlite):

SELECT
`User`.`id`,
`User`.`name`,
`Projects`.`id` AS `Projects.id`,
`Projects`.`name` AS `Projects.name`,
`Projects->User_Project`.`completed` AS `Projects.User_Project.completed`,
`Projects->User_Project`.`UserId` AS `Projects.User_Project.UserId`,
`Projects->User_Project`.`ProjectId` AS `Projects.User_Project.ProjectId`
FROM `Users` AS `User`
LEFT OUTER JOIN `User_Projects` AS `Projects->User_Project` ON
`User`.`id` = `Projects->User_Project`.`UserId`
LEFT OUTER JOIN `Projects` AS `Projects` ON
`Projects`.`id` = `Projects->User_Project`.`ProjectId` AND
`Projects->User_Project`.`completed` = 1;

Для включения всех связанных моделей используются настройки all и nested:

// получаем все модели, связанные с `User`
User.findAll({ include: { all: true } })

// получаем все модели, связанные с `User`, вместе со связанными с ними моделями
User.findAll({ include: { all: true, nested: true } })

Сортировка связанных экземпляров при нетерпеливой загрузке

Для сортировки связанных экземпляров используется настройка order (на верхнем уровне):

Company.findAll({
include: Division,
order: [
// массив для сортировки начинается с модели
// затем следует название поля и порядок сортировки
[Division, 'name', 'ASC']
]
})

Company.findAll({
include: Division,
order: [[Division, 'name', 'DESC']]
})

Company.findAll({
// с помощью синонима
include: { model: Division, as: 'Div' },
order: [[{ model: Division, as: 'Div' }, 'name', 'DESC']]
})

Company.findAll({
// несколько уровней вложенности
include: {
model: Division,
include: Department
},
order: [[Division, Department, 'name', 'DESC']]
})

В случае с отношениями многие-ко-многим, у нас имеется возможность выполнять сортировку по атрибутам соединительной таблицы. Предположим, что между моделями Division и Department существуют такие отношения, а соединительная таблица между ними называется DepartmentDivision:

Company.findAll({
include: {
model: Division,
include: Department
},
order: [[Division, DepartmentDivision, 'name', 'ASC']]
})

Вложенная нетерпеливая загрузка

Вложенная нетерпеливая загрузка может использоваться для загрузки всех связанных экземпляров связанного экземпляра:

const users = await User.findAll({
include: {
model: Tool,
as: 'Instruments',
include: {
model: Teacher,
include: [
/* и т.д. */
]
}
}
})
console.log(JSON.stringify(users, null, 2))
/*
[{
"name": "John Smith",
"id": 1,
"Instruments": [{ // ассоциация 1:M и N:M
"name": "Scissor",
"id": 1,
"userId": 1,
"Teacher": { // ассоциация 1:1
"name": "Jimi Hendrix"
}
}]
}]
*/

Данный запрос выполняет внешнее соединение (OUTER JOIN). Применение настройки where к связанной модели произведет внутреннее соединение (INNER JOIN) - будут возвращены только экземпляры, которые имеют совпадающие подмодели. Для получения всех родительских экземпляров используется настройка required: false:

User.findAll({
include: [
{
model: Tool,
as: 'Instruments',
include: [
{
model: Teacher,
where: {
school: 'Woodstock Music School'
},
required: false
}
]
}
]
})

Данный запрос вернет всех пользователей и их инструменты, но только тех учителей, которые связаны с Woodstock Music School.

Утилита findAndCountAll() поддерживает include. При этом, только модели, помеченные как required, будут учитываться count.

User.findAndCountAll({
include: [{ model: Profile, required