May 14, 2022

sequelize решение проблемы с отношением многие-ко-многим.

Для удобства работы с реляционными БД в node js, мы можем использовать sequelize. Но дополнительный уровень абстракции накладывает свои особенности. С одной такой особенностью я столкнулся при создании отношения многие-ко-многим.

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

// Создаем две таблицы
const Task = sequelize.define('task', {
  name: {
    type: DataTypes.STRING,
    allowNull: false,
  },
})
const User = sequelize.define('user', {
  name: {
    type: DataTypes.STRING,
    allowNull: false,
  },
})
// Создаем связь многие-ко-многим между таблицами
User.belongsToMany(Task, {
  through: Task_User,
})
Task.belongsToMany(User, {
  through: Task_User,
})

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

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

// Добавляем таблицу, где будем хранить рейтинг
const TaskRating = sequelize.define('taskRating', {
  rating: {
    type: DataTypes.INTEGER,
    allowNull: false,
    validate: { min: 0, max: 100 },
  },
})
// Наши таблицы с задачами и пользователями
const Task = sequelize.define('task', {
  name: {
    type: DataTypes.STRING,
    allowNull: false,
  },
})
const User = sequelize.define('user', {
  name: {
    type: DataTypes.STRING,
    allowNull: false,
  },
})
// Заменяем связь многие-ко-многим
// На один-ко-многим между user и taskRating
User.hasMany(TaskRating);
TaskRating.belongsTo(User);

// И один-ко-многим между task и taskRating
Task.hasMany(TaskRating);
TaskRating.belongsTo(Task);

Такая связка так же создаст внешние ключи taskId и userId в TaskRating и определит их как составной ключ.

Отлично! Теперь мы можем делать такие выборки:

User.findAll({ include: TaskRating });
Task.findAll({ include: TaskRating });
TaskRating.findAll({ include: User });
TaskRating.findAll({ include: Task });

Т.е. при запросе всех users и tasks мы подтягиваем все соответствующие taskRatings, а при запросе taskRatings, всех users или tasks.

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

User.findAll({ include: Task });
Task.findAll({ include: User });

Т.е. мы не можем для users запросить tasks, а для tasks запросить users. Да и звучит это странно, зачем нам делать выборку tasks для users или users для tasks из этой таблицы? Такой задачи мы себе не ставили.

Хорошо. Тогда давайте запишем нашу первую оценку:

// Считаем, что по одному пользователю и задачи у нас уже есть в БД
const userId = '1'
const taskId = '1'
// Пробуем сначала найти уже существующую оценку
const taskRating = await TaskRating.findOne({
  where: {
    userId,
    taskId,
  },
})
// Если нашли, то обновляем её, если не нашли, то создаем новую
if (taskRating) {
  await taskRating.update({
    rating,
  })
} else {
  await TaskRating.create({
    rating,
    userId,
    taskId,
  })
}

И это выглядит чрезмерно императивно, не находите? Так как нам упростить процесс записи данных?

Для решения этой задачи мы можем сделать связь супер многие-ко-многим:

// Создаем связь многие-ко-многим
User.belongsToMany(Task, {
  through: TaskRating,
})
Task.belongsToMany(User, {
  through: TaskRating,
})
// А так же один-к-одному между user и taskRating
User.hasMany(TaskRating);
TaskRating.belongsTo(User);

// И один-к-одному между task и taskRating
Task.hasMany(TaskRating);
TaskRating.belongsTo(Task);

Это позволяет делать все вышеперечисленные выборки. И работать с данными становится очень просто:

const user = await User.findOne({ where: { id: '1' } })
const task = await Task.findOne({ where: { id: '1' } })
// Ставим оценку
await user.addTask(task, { through: { rating: '80' } })
// Или так
await task.addUser(user, { through: { rating: '60' } })

Прекрасно! Теперь нам достаточно одной строчки кода для добавления или обновления рейтинга.

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

И для решения этой дилеммы, я решил добавить именование TaskWithRatings и RatingFromUsers:

await User.belongsToMany(Task, {as:'TaskWithRatings',through: TaskRating })
await Task.belongsToMany(User, {as:'RatingFromUsers',through: TaskRating })

И теперь команды выглядят более логично:

await user.addRatingForTask(task, { through: { rating: '80' } })
await task.addRatingFromUser(user, { through: { rating: '60' } })

И при этом, мы сохраняем возможность добавлять новые связки многие-ко-многим между users и tasks для других задач.

Спасибо что прочитали до конца😊! Если у Вас есть замечания или дополнения, я буду рад, если Вы поделитесь Вашим опытом в комментарии.