Многие-ко-многим в контексте IndexedDB и Dexie.js реализуется не через встроенные ORM-механизмы, а через явное моделирование связей с помощью промежуточных таблиц (junction tables). Браузерное хранилище не поддерживает SQL-join, поэтому вся логика связывания сущностей переносится на уровень структуры данных и запросов.
Связь многие-ко-многим возникает, когда:
Типичные примеры:
В реляционных СУБД это решается через промежуточную таблицу. В Dexie.js используется тот же принцип, но без join-оператора — связь реализуется через отдельный store.
Основная ошибка при работе с Dexie.js — попытка хранить массивы связей внутри объектов без индексов. Такой подход приводит к невозможности эффективного поиска.
Правильная структура включает три хранилища:
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();
Здесь выполняются два запроса:
Получение пользователей по роли выполняется аналогично:
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.
При больших объёмах данных важно проектировать индексы под реальные запросы.
Пример улучшенной схемы:
db.version(2).stores({
userRoles: "++id, userId, roleId, [userId+roleId], [roleId+userId]"
});
Теперь возможны быстрые выборки в обе стороны без дополнительных фильтраций.
Dexie позволяет удалять записи через курсоры:
await db.userRoles
.where("userId")
.equals(1)
.delete();
Это более эффективный способ, чем предварительный
toArray().
При увеличении количества сущностей многие-ко-многим превращается в граф связей. В этом случае:
Пример расширения:
Каждая связь реализуется отдельной таблицей:
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 });
}
}
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 от бизнес-логики.