Представляю вашему вниманию руководство по 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. Вот соответствующая шпаргалка.


Это вторая из 3 частей руководства, в которой мы поговорим о простых и продвинутых ассоциациях (отношениях между моделями), "параноике", нетерпеливой и ленивой загрузке, а также о многом другом.


Вот ссылка на первую часть.


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


Содержание



Ассоциации


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 может иметь значение nullBar может существовать без 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: true }],
  limit: 3
})

В данном случае мы получим 3 пользователей, у которых есть профили. Если мы опустим required, то получим 3 пользователей, независимо от того, имеется у них профиль или нет. Включение where в include автоматически делает его обязательным.


User.findAndCountAll({
  include: [{ model: Profile, where: { active: true } }],
  limit: 3
})

? Наверх

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


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


belongsTo(), hasMany(), hasOne()


Рассмотрим пример:


const Product = sequelize.define('product', {
  title: DataTypes.STRING
})
const User = sequelize.define('user', {
  firstName: DataTypes.STRING,
  lastName: DataTypes.STRING
})
const Address = sequelize.define('address', {
  type: DataTypes.STRING,
  line1: DataTypes.STRING,
  line2: DataTypes.STRING,
  city: DataTypes.STRING,
  state: DataTypes.STRING,
  zip: DataTypes.STRING
})
// сохраняем значения, возвращаемые при создании ассоциации для дальнейшего использования
Product.User = Product.belongsTo(User)
User.Address = User.hasMany(Address)

Новый Product, User и один или более Address могут быть созданы одновременно:


const product = await Product.create(
  {
    title: 'Product',
    user: {
      firstName: 'John',
      lastName: 'Smith',
      addresses: [
        {
          type: 'home',
          line1: 'street',
          city: 'city',
          state: 'state',
          zip: '12345'
        }
      ]
    }
  },
  {
    include: [
      {
        assosiation: Product.User,
        include: [User.Addresses]
      }
    ]
  }
)

Обратите внимание: наша модель называется user с маленькой буквы u. Это означает, что свойство объекта также должно называться user. Если бы мы определили модель как User, то для соответствующего свойства нужно было бы использовать User. Тоже самое касается addresses, учитывая плюрализацию (перевод во множетвенное число).


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


const Creator = Product.belongsTo(User, { as: 'creator' })

const product = await Product.create(
  {
    title: 'Chair',
    creator: {
      firstName: 'John',
      lastName: 'Smith'
    }
  },
  {
    include: [Creator]
  }
)

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


const Tag = sequelize.define('tag', {
  name: DataTypes.STRING
})
Product.hasMany(Tag)
// или `belongsToMany()`

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


const product = await Product.create(
  {
    id: 1,
    title: 'Chair',
    tags: [{ name: 'Alpha' }, { name: 'Beta' }]
  },
  {
    include: [Tag]
  }
)

И с поддержкой синонимов:


const Categories = Product.hasMany(Tag, { as: 'categories' })

const product = awaity Product.create({
  id: 1,
  title: 'Chair',
  categories: [
    { id: 1, name: 'Alpha' },
    { id: 2, name: 'Beta' }
  ]
}, {
  include: [{
    association: Categories,
    as: 'categories'
  }]
})

? Наверх

Продвинутые ассоциации M:N


Начнем с создания ассоциации многие-ко-многим между моделями User и Profile:


const User = sequelize.define(
  'user',
  {
    username: DataTypes.STRING,
    points: DataTypes.INTEGER
  },
  { timestamps: false }
)
const Profile = sequelize.define(
  'profile',
  {
    name: DataTypes.STRING
  },
  { timastamps: false }
)

User.belongsToMany(Profile, { through: 'User_Profiles' })
Profile.belongsToMany(User, { through: 'User_Profiles' })

Передавая строку в through, мы просим Sequelize автоматически создать модель — соединительную таблицу User_Profiles, содержащую 2 колонки: userId и profileId. В эти колонки будут записываться уникальные композиционные (unique composite) ключи.


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


const User_Profile = sequelize.define(
  'User_Profile',
  {
    selfGranted: DataTypes.BOOLEAN
  },
  { timestamps: false }
)

После этого мы можем получать дооплнительную информацию из соединительной таблицы. Например, при вызове user.addProfile() мы можем передавать значения для дополнительной колонки с помощью настройки through:


const amidala = await User.create({ username: 'p4dm3', points: 1000 })
const queen = await Profile.create({ name: 'Queen' })
await amidala.addProfile(queen, { through: { selfGranted: false } })

Как отмечалось, все отношения могут быть определены в одном вызове create().


const amidala = await User.create(
  {
    username: 'p4dm3',
    points: 1000,
    profiles: [
      {
        name: 'Queen',
        User_Profile: {
          selfGranted: true
        }
      }
    ]
  },
  {
    include: Profile
  }
)

Вероятно, вы заметили, что в таблице User_Profiles отсутствует поле id. Дело в том, что такая таблица содержит уникальный композиционный ключ, название которого генерируется автоматически, но это можно изменить с помощью настройки uniqueKey:


User.belongsToMany(Profile, {
  through: User_Profile,
  uniqueKey: 'customUnique'
})

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


const User_Profile = sequelize.define(
  'User_Profile',
  {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
      allowNull: false
    },
    selfGranted: DataTypes.BOOLEAN
  },
  { timestamps: false }
)

Ассоциация "супер многие-ко-многим"


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


const User = sequelize.define(
  'user',
  {
    username: DataTypes.STRING,
    points: DataTypes.INTEGER
  },
  { timestamps: false }
)

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

// соединительная таблица
const Grant = sequelize.define(
  'grant',
  {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true,
      allowNull: false
    },
    selfGranted: DataTypes.BOOLEAN
  },
  { timestamps: false }
)

Определяем отношения многие-ко-многим между моделями User и Profile через модель Grant:


User.belongsToMany(Profile, { through: Grant })
Profile.belongsToMany(User, { through: Grant })

Что если вместо определения отношения многие-ко-многим мы сделаем так?


// определяем отношение один-ко-многим между `User` и `Grant`
User.hasMany(Grant)
Grant.belongsTo(User)

// определяем отношение один-ко-многим между `Profile` и `Grant`
Profile.hasMany(Grant)
Grant.belongsTo(Profile)

Результат будет таким же! Это объясняется тем, что User.hasMany(Grant) и Profile.hasMany(Grant) запишут userId и profileId в Grant.


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


// многие-ко-многим позволяет делать так
User.findAll({ include: Profile })
Profile.findAll({ include: User })
// но не так
User.findAll({ include: Grant })
Profile.findAll({ include: Grant })
Grant.findAll({ include: User })
Grant.findAll({ include: Profile })

// с другой стороны, пара ассоциаций один-ко-многим позволяет делать следующее
User.findAll({ include: Grant })
Profile.findAll({ include: Grant })
Grant.findAll({ include: User })
Grant.findAll({ include: Profile })
// но не так
User.findAll({ include: Profile })
Profile.findAll({ include: User })

// хотя мы можем имитировать нечто похожее
User.findAll({
  include: {
    model: Grant,
    include: Profile
  }
})
/*
  Это похоже на `User.findAll({ include: Profile })`, но
  структура результирующего объекта будет немного другой.
  Вместо `user.profiles[].grant` мы получим `user.grants[].profiles[]`
*/

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


User.belongsToMany(Profile, { through: Grant })
Profile.belongsToMany(User, { through: Grant })
User.hasMany(Grant)
Grant.belongsTo(User)
Profile.hasMany(Grant)
Grant.belongsTo(Profile)

// все работает
User.findAll({ include: Profile })
Profile.findAll({ include: User })
User.findAll({ include: Grant })
Profile.findAll({ include: Grant })
Grant.findAll({ include: User })
Grant.findAll({ include: Profile })

Это позволяет выполнять все виды вложенных включений:


User.findAll({
  include: [
    {
      model: Grant,
      include: [User, Profile]
    },
    {
      model: Profile,
      include: {
        model: User,
        include: {
          model: Grant,
          include: [User, Profile]
        }
      }
    }
  ]
})

Синонимы и кастомные названия для ключей


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


Product.belongsToMany(Category, { as: 'groups', through: 'product_categories' })
Category.belongsToMany(Product, { as: 'items', through: 'product_categories' })

// НЕ работает
await Product.findAll({ include: Category })

// работает
await Product.findAll({
  include: {
    model: Category,
    as: 'groups'
  }
})

// это тоже работает
await Product.findAll({ include: 'groups' })

Вот как выглядит SQL-запрос на создание таблицы product_categories:


CREATE TABLE IF NOT EXISTS `product_categories` (
  `createdAt` DATETIME NOT NULL,
  `updatedAt` DATETIME NOT NULL,
  `productId` INTEGER NOT NULL REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  `categoryId` INTEGER NOT NULL REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  PRIMARY KEY (`productId`, `categoryId`)
);

Как мы видим, внешними ключами являются productId и categoryId. Для изменения этих названий используются настройки foreignKey и otherKey, соответственно (foreignKey определяет ключ для модели-источника, а otherKey — для целевой модели).


Product.belongsToMany(Category, {
  through: 'product_categories',
  foreignKey: 'objectId', // заменяет `productId`
  otherKey: 'typeIf' // заменяет `categoryId`
})
Category.belongsToMany(Product, {
  through: 'product_categories',
  foreignKey: 'typeId', //  заменяет `categoryId`
  otherKey: 'objectId' //  заменяет `productId`
})

Соответствующий SQL-запрос:


CREATE TABLE IF NOT EXISTS `product_categories` (
  `createdAt` DATETIME NOT NULL,
  `updatedAt` DATETIME NOT NULL,
  `objectId` INTEGER NOT NULL REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  `typeId` INTEGER NOT NULL REFERENCES `categories` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  PRIMARY KEY (`objectId`, `typeId`)
);

Обратите внимание: настройки foreignKey и otherKey должны определяться в обоих вызовах. Если определить их только в одном вызове, поведение Sequelize будет непредсказуемым.


Sequelize также поддерживает циклические отношения многие-ко-многим:


Person.belongsToMany(Person, { as: 'Children', through: 'PersonChildren' })
// это создаст таблицу `PersonChildren` с идентификаторами объектов

Определение возвращаемых атрибутов соединительной таблицы


По умолчанию при нетерпеливой загрузке в случае с ассоциацией многие-ко-многим возвращается такой объект (User.findOne({ include: Profile })):


{
  "id": 4,
  "username": "p4dm3",
  "points": 1000,
  "profiles": [
    {
      "id": 6,
      "name": "queen",
      "grant": {
        "userId": 4,
        "profileId": 6,
        "selfGranted": false
      }
    }
  ]
}

Внешний объект — это User, у этого объекта есть поле profiles — массив Profile, у каждого Profile есть дополнительное поле grant — экземпляр Grant.


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


User.findOne({
  include: {
    model: Profile,
    through: {
      attributes: ['selfGranted']
    }
  }
})
/*
{
  "id": 4,
  "username": "p4dm3",
  "points": 1000,
  "profiles": [
    {
      "id": 6,
      "name": "queen",
      "grant": {
        "selfGranted": false
      }
    }
  ]
}
*/

Для исключения поля grant из результатов запроса можно указать attributes: [].


При использовании миксинов (например, user.getProfiles()), вместо методов для поиска (например, User.findAll()), для фильтрации полей соединительной таблицы используется настройка joinTableAttributes:


user.getProfiles({ joinTableAttributes: ['selfGranted'] })
/*
[
  {
    "id": 6,
    "name": "queen",
    "grant": {
      "selfGranted": false
    }
  }
]
*/

Ассоциация многие-ко-многим-ко-многим и т.д.


Предположим, что мы моделируем игру. У нас есть игроки и команды. Команды играют в игры. Игроки могут менять команды в середине чемпионата (но не в середине игры). В одной игре участвует две команды, в каждой команде имеется свой набор игроков (для текущей игры).


Начнем с определения соответствующих моделей:


const Player = sequelize.define('Player', { username: DataTypes.STRING })
const Team = sequelize.define('Team', { name: DataTypes.STRING })
const Game = sequelize.define('Game', { name: DataTypes.INTEGER })

Вопрос: как определить ассоциацию между этими моделями?


Первое, что можно заметить:


  • одна игра имеет несколько связанных с ней команд (тех, которые играют в этой игре)
  • одна команда может принимать участие в нескольких играх

Это означает, что между моделями Game и Team должны существовать отношения многие-ко-многим. Реализуем супер-вариант названной ассоциации (как в предыдущем примере):


const GameTeam = sequelize.define('GameTeam', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  }
})
Team.belongsToMany(Game, { through: GameTeam })
Game.belongsToMany(Team, { through: GameTeam })
GameTeam.belongsTo(Game)
GameTeam.belongsTo(Team)
Game.hasMany(GameTeam)
Team.hasMany(GameTeam)

С игроками все несколько сложнее. Набор игроков, формирующих команду, зависит не только от команды, но также от того, в какой игре данная команда участвует. Поэтому нам не нужна ассоциация многие-ко-многим между Player и Team. Нам также не нужна ассоциация многие-ко-многим между Player и Game. Вместо привязки Player к одной из указанных моделей, нам требуется ассоциация между Player и чем-то вроде "парного ограничения команда-игра", поскольку именно пара (команда + игра) определяет набор игроков. Внезапно, то, что мы искали, оказывается соединительной таблицей GameTeam! Учитывая, что конкретная пара команда-игра определяет несколько игроков и один игрок может участвовать в нескольких парах, нам требуется ассоциация многие-ко-многим между Player и GameTeam.


Для обеспечения максимальной гибкости снова прибегнем к супер-версии M:N:


const PlayerGameTeam = sequelize.define('PlayerGameTeam', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  }
})
Player.belongsToMany(GameTeam, { through: PlayerGameTeam })
GameTeam.belongsToMany(Player, { through: PlayerGameTeam })
PlayerGameTeam.belongsTo(Player)
PlayerGameTeam.belongsTo(GameTeam)
Player.hasMany(PlayerGameTeam)
GameTeam.hasMany(PlayerGameTeam)

Эта ассоциация делает именно то, что мы хотим.


Полный пример выглядит так:


const { Sequelize, Op, Model, DataTypes } = require('sequelize')

const sequelize = new Sequelize('sqlite::memory:', {
  define: { timestamps: false } // Просто, чтобы не повторяться
})

const Player = sequelize.define('Player', { username: DataTypes.STRING })
const Team = sequelize.define('Team', { name: DataTypes.STRING })
const Game = sequelize.define('Game', { name: DataTypes.INTEGER })

// Ассоциация супер-многие-ко-многим между `Game` и `Team`
const GameTeam = sequelize.define('GameTeam', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  }
})
Team.belongsToMany(Game, { through: GameTeam })
Game.belongsToMany(Team, { through: GameTeam })
GameTeam.belongsTo(Game)
GameTeam.belongsTo(Team)
Game.hasMany(GameTeam)
Team.hasMany(GameTeam)

// Ассоциация супер-многие-ко-многим между `Player` и `GameTeam`
const PlayerGameTeam = sequelize.define('PlayerGameTeam', {
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true,
    allowNull: false
  }
})
Player.belongsToMany(GameTeam, { through: PlayerGameTeam })
GameTeam.belongsToMany(Player, { through: PlayerGameTeam })
PlayerGameTeam.belongsTo(Player)
PlayerGameTeam.belongsTo(GameTeam)
Player.hasMany(PlayerGameTeam)
GameTeam.hasMany(PlayerGameTeam)
;(async () => {
  await sequelize.sync()
  // Создаем игроков
  await Player.bulkCreate([
    { username: 's0me0ne' },
    { username: 'empty' },
    { username: 'greenhead' },
    { username: 'not_spock' },
    { username: 'bowl_of_petunias' }
  ])
  // Создаем игры
  await Game.bulkCreate([
    { name: 'The Big Clash' },
    { name: 'Winter Showdown' },
    { name: 'Summer Beatdown' }
  ])
  // Создаем команды
  await Team.bulkCreate([
    { name: 'The Martians' },
    { name: 'The Earthlings' },
    { name: 'The Plutonians' }
  ])

  // Начнем с определения того, какая команда в какой игре участвует. Это можно сделать
  // несколькими способами, например, посредством вызова `setTeams()` для каждой игры.
  // Однако, для чистоты эксперимента, мы используем явные вызовы `create()`
  await GameTeam.bulkCreate([
    { GameId: 1, TeamId: 1 }, // эта `GameTeam` получит `id` 1
    { GameId: 1, TeamId: 2 }, // и т.д.
    { GameId: 2, TeamId: 1 },
    { GameId: 2, TeamId: 3 },
    { GameId: 3, TeamId: 2 },
    { GameId: 3, TeamId: 3 }
  ])

  // Теперь определим игроков.
  // Сделаем это только для второй игры (Winter Showdown).
  await PlayerGameTeam.bulkCreate([
    { PlayerId: 1, GameTeamId: 3 }, // s0me0ne играет за The Martians
    { PlayerId: 3, GameTeamId: 3 }, // и т.д.
    { PlayerId: 4, GameTeamId: 4 },
    { PlayerId: 5, GameTeamId: 4 }
  ])

  // После этого мы можем выполнять запросы!
  const game = await Game.findOne({
    where: {
      name: 'Winter Showdown'
    },
    include: {
      model: GameTeam,
      include: [
        {
          model: Player,
          through: { attributes: [] } // Скрываем нежелательные вложенные объекты `PlayerGameTeam` из результатов
        },
        Team
      ]
    }
  })

  console.log(`Обнаружена игра: "${game.name}"`)
  for (let i = 0; i < game.GameTeams.length; i++) {
    const team = game.GameTeams[i].Team
    const players = game.GameTeams[i].Players
    console.log(
      `- Команда "${team.name}" играет в игру "${game.name}" со следующими игроками:`
    )
    console.log(players.map((p) => `--- ${p.username}`).join('\n'))
  }
})()

Вывод:


Обнаружена игра: "Winter Showdown"
- Команда "The Martians" играет в игру "Winter Showdown" со следующими игроками:
--- s0me0ne
--- greenhead
- Команда "The Plutonians" играет в игру "Winter Showdown" со следующими игроками:
--- not_spock
--- bowl_of_petunias

? Наверх

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


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




Аренда облачного сервера с быстрыми NVMе-дисками и посуточной оплатой у хостинга Маклауд.