Загрузка связанных записей по внешнему ключу

В Dexie.js отсутствует встроенный механизм SQL-подобных JOIN-операций, однако поддержка связей между таблицами реализуется через комбинацию индексов, выборок по ключам и пакетных операций чтения. Основной принцип построения связей — хранение внешнего ключа в записи и последующее явное разрешение связей на уровне кода.

Базовая модель данных с внешними ключами

Типичный пример — связь пользователей и постов:

const db = new Dexie("AppDatabase");

db.version(1).stores({
  users: "++id, name",
  posts: "++id, userId, title"
});

Здесь posts.userId выступает в роли внешнего ключа, указывающего на users.id.

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

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

const post = await db.posts.get(1);
const user = await db.users.get(post.userId);

Подход прост, но при массовой обработке данных приводит к проблеме N+1 запросов.


Проблема N+1 запросов при загрузке связей

При выборке списка постов и авторов:

const posts = await db.posts.toArray();

for (const post of posts) {
  post.user = await db.users.get(post.userId);
}

Каждый пост инициирует отдельный запрос к таблице пользователей. При большом объёме данных это становится узким местом, особенно в IndexedDB, где операции асинхронны и имеют накладные расходы.


Пакетная загрузка через bulkGet

Dexie.js предоставляет эффективный механизм массового чтения по ключам:

const posts = await db.posts.toArray();
const userIds = posts.map(p => p.userId);

const users = await db.users.bulkGet(userIds);

Однако bulkGet возвращает массив с возможными undefined значениями, если ключ отсутствует. Поэтому требуется нормализация:

const userMap = new Map(users.map(u => [u.id, u]));

const enrichedPosts = posts.map(post => ({
  ...post,
  user: userMap.get(post.userId)
}));

Этот подход устраняет N+1 проблему и снижает количество обращений к IndexedDB до двух операций.


Оптимизация через группировку ключей

Если количество связанных сущностей велико, полезно предварительно дедуплицировать ключи:

const userIds = [...new Set(posts.map(p => p.userId))];
const users = await db.users.bulkGet(userIds);

Это особенно важно при повторяющихся внешних ключах.


Использование where + primary key индекса

Если внешний ключ проиндексирован, можно использовать where:

const posts = await db.posts.where("userId").equals(5).toArray();

Для получения пользователей по списку ID:

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

Метод anyOf часто используется как альтернатива bulkGet, когда требуется более гибкая фильтрация.


Реализация one-to-many связей

Связь «один ко многим» является наиболее частым сценарием.

Загрузка пользователя с его постами

const user = await db.users.get(1);
const posts = await db.posts.where("userId").equals(user.id).toArray();

user.posts = posts;

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


Many-to-one: обогащение списка сущностей

Противоположный случай — добавление родительских сущностей к дочерним:

const posts = await db.posts.toArray();

const userIds = posts.map(p => p.userId);
const users = await db.users.bulkGet([...new Set(userIds)]);

const userMap = new Map(users.map(u => [u.id, u]));

const result = posts.map(post => ({
  ...post,
  user: userMap.get(post.userId)
}));

Many-to-many через таблицу связей

Dexie.js не навязывает схему many-to-many, поэтому используется промежуточная таблица:

db.version(1).stores({
  users: "++id, name",
  groups: "++id, title",
  userGroups: "++id, userId, groupId"
});

Загрузка групп пользователя

const links = await db.userGroups.where("userId").equals(1).toArray();
const groupIds = links.map(l => l.groupId);

const groups = await db.groups.bulkGet(groupIds);

Обратная загрузка пользователей группы

const links = await db.userGroups.where("groupId").equals(10).toArray();
const userIds = links.map(l => l.userId);

const users = await db.users.bulkGet(userIds);

Ускорение через индексы

Критически важно индексировать внешние ключи:

db.version(1).stores({
  posts: "++id, userId",
});

Без индекса where("userId") превращается в полное сканирование таблицы.

Для сложных запросов полезны составные индексы:

db.version(1).stores({
  posts: "++id, userId, [userId+createdAt]"
});

Это позволяет эффективно фильтровать посты пользователя по времени.


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

При консистентной выборке нескольких таблиц применяется transaction:

const result = await db.transaction("r", db.users, db.posts, async () => {
  const user = await db.users.get(1);
  const posts = await db.posts.where("userId").equals(1).toArray();

  return {
    user,
    posts
  };
});

Транзакция гарантирует согласованное состояние данных на момент чтения.


Паттерн “ручных JOIN-объектов”

На практике часто формируется слой агрегации данных:

async function getPostsWithUsers() {
  const posts = await db.posts.toArray();

  const userIds = [...new Set(posts.map(p => p.userId))];
  const users = await db.users.bulkGet(userIds);

  const userMap = new Map(users.map(u => [u.id, u]));

  return posts.map(post => ({
    ...post,
    user: userMap.get(post.userId)
  }));
}

Этот паттерн становится базовой альтернативой SQL JOIN.


Каскадная загрузка нескольких уровней связей

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

const posts = await db.posts.where("userId").equals(1).toArray();

const postIds = posts.map(p => p.id);
const comments = await db.comments.where("postId").anyOf(postIds).toArray();

const commentsByPost = new Map();

for (const c of comments) {
  if (!commentsByPost.has(c.postId)) {
    commentsByPost.set(c.postId, []);
  }
  commentsByPost.get(c.postId).push(c);
}

const result = posts.map(post => ({
  ...post,
  comments: commentsByPost.get(post.id) || []
}));

Такой подход позволяет расширять глубину связей без усложнения запросов к IndexedDB.


Избежание избыточной загрузки данных

При работе с внешними ключами важно ограничивать поля:

const posts = await db.posts
  .where("userId")
  .equals(1)
  .toArray(post => ({
    id: post.id,
    title: post.title,
    userId: post.userId
  }));

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


Кэширование разрешённых связей

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

const userCache = new Map();

async function getUser(id) {
  if (userCache.has(id)) return userCache.get(id);

  const user = await db.users.get(id);
  userCache.set(id, user);

  return user;
}

Этот подход особенно полезен при итеративной обработке коллекций.


Ленивое разрешение связей

Иногда связи не загружаются сразу, а вычисляются по требованию:

function attachUser(post) {
  Object.defineProperty(post, "user", {
    get: async () => db.users.get(post.userId)
  });

  return post;
}

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