Моделирование отношений многие-ко-многим

Многие-ко-многим в контексте IndexedDB и Dexie.js реализуется не через встроенные ORM-механизмы, а через явное моделирование связей с помощью промежуточных таблиц (junction tables). Браузерное хранилище не поддерживает SQL-join, поэтому вся логика связывания сущностей переносится на уровень структуры данных и запросов.


Связь многие-ко-многим возникает, когда:

  • одна сущность может быть связана с несколькими другими
  • и каждая из этих связанных сущностей также может иметь множество связей обратно

Типичные примеры:

  • пользователи и роли
  • статьи и теги
  • студенты и курсы
  • товары и категории

В реляционных СУБД это решается через промежуточную таблицу. В Dexie.js используется тот же принцип, но без join-оператора — связь реализуется через отдельный store.


Проектирование схемы базы данных

Основная ошибка при работе с Dexie.js — попытка хранить массивы связей внутри объектов без индексов. Такой подход приводит к невозможности эффективного поиска.

Правильная структура включает три хранилища:

  • основная сущность A
  • основная сущность B
  • таблица связей A_B

Пример: пользователи и роли

const db = new Dexie("app");

db.version(1).stores({
  users: "++id, name",
  roles: "++id, name",
  userRoles: "++id, userId, roleId, [userId+roleId]"
});

Ключевой момент:

  • userRoles — это junction table
  • составной индекс [userId+roleId] предотвращает дубликаты связей и ускоряет проверку наличия связи

Создание связей

Связь создаётся через вставку записи в промежуточную таблицу.

await db.userRoles.add({
  userId: 1,
  roleId: 3
});

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

await db.userRoles.bulkAdd([
  { userId: 1, roleId: 2 },
  { userId: 1, roleId: 3 },
  { userId: 2, roleId: 3 }
]);

Важно учитывать, что Dexie.js не проверяет целостность внешних ключей автоматически. Проверка существования userId и roleId выполняется вручную или через транзакции.


Получение связанных данных

Так как join отсутствует, выборка выполняется в несколько шагов.

Получение ролей пользователя

const userId = 1;

const links = await db.userRoles
  .where("userId")
  .equals(userId)
  .toArray();

const roleIds = links.map(l => l.roleId);

const roles = await db.roles
  .where("id")
  .anyOf(roleIds)
  .toArray();

Здесь выполняются два запроса:

  1. получение связей
  2. выборка сущностей по списку идентификаторов

Обратная выборка

Получение пользователей по роли выполняется аналогично:

const roleId = 3;

const links = await db.userRoles
  .where("roleId")
  .equals(roleId)
  .toArray();

const userIds = links.map(l => l.userId);

const users = await db.users
  .where("id")
  .anyOf(userIds)
  .toArray();

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

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

userRoles: "++id, userId, roleId, [userId+roleId]"

Проверка существования связи:

const exists = await db.userRoles
  .where("[userId+roleId]")
  .equals([1, 3])
  .first();

if (!exists) {
  await db.userRoles.add({ userId: 1, roleId: 3 });
}

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


Поддержание целостности данных

Отсутствие внешних ключей требует явного контроля целостности.

Удаление пользователя с очисткой связей

await db.transaction("rw", db.users, db.userRoles, async () => {
  const userId = 1;

  await db.users.delete(userId);

  const links = await db.userRoles
    .where("userId")
    .equals(userId)
    .toArray();

  const linkIds = links.map(l => l.id);

  await db.userRoles.bulkDelete(linkIds);
});

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


Каскадное обновление связей

При изменении идентификаторов (что обычно не рекомендуется) требуется обновление junction table:

await db.transaction("rw", db.userRoles, async () => {
  const oldUserId = 1;
  const newUserId = 10;

  const links = await db.userRoles
    .where("userId")
    .equals(oldUserId)
    .toArray();

  await db.userRoles.bulkPut(
    links.map(l => ({
      ...l,
      userId: newUserId
    }))
  );
});

Агрегация связей в прикладной модели

Часто требуется вернуть сущности уже с вложенными связями.

async function getUsersWithRoles() {
  const users = await db.users.toArray();
  const links = await db.userRoles.toArray();
  const roles = await db.roles.toArray();

  const roleMap = new Map(roles.map(r => [r.id, r]));

  const grouped = users.map(user => {
    const userRoleIds = links
      .filter(l => l.userId === user.id)
      .map(l => l.roleId);

    return {
      ...user,
      roles: userRoleIds.map(id => roleMap.get(id))
    };
  });

  return grouped;
}

Этот подход переносит join-логику на уровень JavaScript, что типично для Dexie.js.


Использование compound keys для ускорения выборок

При больших объёмах данных важно проектировать индексы под реальные запросы.

Пример улучшенной схемы:

db.version(2).stores({
  userRoles: "++id, userId, roleId, [userId+roleId], [roleId+userId]"
});

Теперь возможны быстрые выборки в обе стороны без дополнительных фильтраций.


Удаление связей без предварительной загрузки

Dexie позволяет удалять записи через курсоры:

await db.userRoles
  .where("userId")
  .equals(1)
  .delete();

Это более эффективный способ, чем предварительный toArray().


Масштабирование модели

При увеличении количества сущностей многие-ко-многим превращается в граф связей. В этом случае:

  • junction tables становятся центральным элементом
  • индексы определяют производительность
  • агрегация выполняется на уровне сервисного слоя

Пример расширения:

  • users ↔︎ roles
  • roles ↔︎ permissions
  • users ↔︎ teams

Каждая связь реализуется отдельной таблицей:

userRoles
rolePermissions
userTeams

Работа с уникальностью связей

Так как IndexedDB не поддерживает UNIQUE constraints, контроль дубликатов выполняется через индекс:

[ userId + roleId ]

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

async function addUserRole(userId, roleId) {
  const exists = await db.userRoles
    .where("[userId+roleId]")
    .equals([userId, roleId])
    .first();

  if (!exists) {
    await db.userRoles.add({ userId, roleId });
  }
}

Частые ошибки моделирования

  • хранение массивов id внутри объектов вместо отдельной таблицы связей
  • отсутствие составных индексов
  • попытка выполнять join через частые filter() без индексов
  • игнорирование транзакций при каскадных изменениях
  • отсутствие стратегии удаления зависимых связей

Структурирование запросов через промежуточный слой

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

const UserRepository = {
  async getWithRoles(id) {
    const user = await db.users.get(id);

    const links = await db.userRoles
      .where("userId")
      .equals(id)
      .toArray();

    const roleIds = links.map(l => l.roleId);

    const roles = await db.roles
      .where("id")
      .anyOf(roleIds)
      .toArray();

    return { ...user, roles };
  }
};

Такой слой изолирует специфику Dexie.js от бизнес-логики.


Итоговая модель поведения many-to-many в Dexie.js

  • связь всегда выражается через отдельный store
  • индексы определяют эффективность доступа
  • join заменяется последовательностью запросов
  • транзакции обеспечивают целостность
  • агрегация выполняется в JavaScript-слое