Ручные джойны через Promise.all()

В IndexedDB отсутствует механизм SQL-подобных JOIN, поэтому объединение данных из разных таблиц в Dexie.js строится на уровне приложения. Основной подход заключается в ручной агрегации сущностей через асинхронные запросы и последующую синхронизацию результатов. Ключевым инструментом для этого становится Promise.all(), позволяющий параллельно загружать связанные данные и минимизировать общее время ожидания.

Dexie.js предоставляет удобный API поверх IndexedDB, но не скрывает фундаментального ограничения: каждая таблица остаётся независимой, а связи реализуются через ключи и индексы.


Отсутствие JOIN как архитектурное решение

IndexedDB изначально проектировался как низкоуровневое хранилище с ограниченной реляционной логикой. Dexie.js не добавляет SQL-слой, чтобы сохранить:

  • предсказуемую производительность операций;
  • прозрачность доступа к индексам;
  • контроль над количеством чтений из хранилища;
  • возможность масштабирования без скрытых затрат.

Поэтому объединение данных переносится в JavaScript-слой, где разработчик сам определяет стратегию агрегации.


Базовая структура данных для джойнов

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

db.version(1).stores({
  users: '++id, name',
  orders: '++id, userId, productId',
  products: '++id, title, price'
});

Связь:

  • orders.userId → users.id
  • orders.productId → products.id

Это классическая схема, где один заказ связан с пользователем и товаром.


Проблема N+1 при наивной агрегации

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

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

const result = [];

for (const order of orders) {
  const user = await db.users.get(order.userId);
  const product = await db.products.get(order.productId);

  result.push({ ...order, user, product });
}

Проблема:

  • 1 запрос за заказами
  • N запросов за пользователями
  • N запросов за товарами

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


Использование Promise.all() для параллельной загрузки

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

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

const result = await Promise.all(
  orders.map(async (order) => {
    const [user, product] = await Promise.all([
      db.users.get(order.userId),
      db.products.get(order.productId)
    ]);

    return {
      ...order,
      user,
      product
    };
  })
);

Преимущества:

  • запросы внутри каждой итерации выполняются параллельно;
  • общее время сокращается до максимального времени одного набора зависимых запросов;
  • код остаётся линейным и читаемым.

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


Батчинг ключей как следующий уровень оптимизации

Более эффективный подход заключается в предварительном сборе всех ключей:

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

const userIds = [...new Set(orders.map(o => o.userId))];
const productIds = [...new Set(orders.map(o => o.productId))];

const [users, products] = await Promise.all([
  db.users.bulkGet(userIds),
  db.products.bulkGet(productIds)
]);

Здесь используется bulkGet, позволяющий извлекать сразу множество записей за один запрос на таблицу.


Построение Map для быстрого связывания данных

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

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

const result = orders.map(order => ({
  ...order,
  user: userMap.get(order.userId),
  product: productMap.get(order.productId)
}));

Преимущества:

  • отсутствие повторных обращений к IndexedDB;
  • линейная сложность агрегации;
  • стабильная производительность при больших объёмах данных.

Ручные джойны через Promise.all() с предварительной нормализацией

Комбинированный подход включает:

  1. первичный запрос основной таблицы;
  2. извлечение всех внешних ключей;
  3. пакетную загрузку связанных сущностей;
  4. финальную сборку через Map.
const orders = await db.orders.toArray();

const userIds = new Set();
const productIds = new Set();

for (const o of orders) {
  userIds.add(o.userId);
  productIds.add(o.productId);
}

const [users, products] = await Promise.all([
  db.users.bulkGet([...userIds]),
  db.products.bulkGet([...productIds])
]);

const usersById = Object.fromEntries(users.filter(Boolean).map(u => [u.id, u]));
const productsById = Object.fromEntries(products.filter(Boolean).map(p => [p.id, p]));

const joined = orders.map(o => ({
  ...o,
  user: usersById[o.userId],
  product: productsById[o.productId]
}));

Глубокие джойны с несколькими уровнями связей

При наличии цепочек связей (например, orders → users → addresses) применяется многоуровневая агрегация:

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

const userIds = orders.map(o => o.userId);
const users = await db.users.bulkGet(userIds);

const addressIds = users.map(u => u.addressId);
const addresses = await db.addresses.bulkGet(addressIds);

const addressMap = new Map(addresses.map(a => [a.id, a]));
const userMap = new Map(users.map(u => [
  u.id,
  { ...u, address: addressMap.get(u.addressId) }
]));

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

Сложность возрастает линейно по уровням, но остаётся управляемой при правильном батчинге.


Асинхронная агрегация с Promise.all на уровнях связей

Для более динамических структур используется каскадный Promise.all():

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

const result = await Promise.all(
  orders.map(async (order) => {
    const userPromise = db.users.get(order.userId);
    const productPromise = db.products.get(order.productId);

    const user = await userPromise;

    const addressPromise = db.addresses.get(user.addressId);

    const [product, address] = await Promise.all([
      productPromise,
      addressPromise
    ]);

    return {
      ...order,
      user: {
        ...user,
        address
      },
      product
    };
  })
);

Такой подход балансирует между читаемостью и контролем над параллелизмом.


Типичные ошибки при ручных джойнах

Избыточные параллельные запросы

Чрезмерное использование Promise.all() без батчинга приводит к перегрузке IndexedDB.

Отсутствие дедупликации ключей

Повторяющиеся userId или productId вызывают дублирование загрузок.

Потеря ссылочной целостности

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

user: usersById[o.userId] ?? null

Поведение при частичных данных

IndexedDB не гарантирует наличие связанных записей. Поэтому при джойнах важно учитывать:

  • удалённые сущности;
  • устаревшие ссылки;
  • несогласованные состояния между таблицами.

Практика предполагает безопасную агрегацию:

const safeUser = userMap.get(order.userId) || { id: null, name: 'Unknown' };

Производительность и масштабирование

Эффективность ручных джойнов в Dexie.js определяется:

  • количеством IndexedDB операций;
  • степенью батчинга ключей;
  • размером промежуточных структур;
  • уровнем параллелизма Promise.all().

Оптимальная стратегия:

  • минимизировать число get операций;
  • использовать bulkGet вместо множественных get;
  • строить Map для O(1) доступа;
  • избегать вложенных Promise.all без необходимости.

Гибридные паттерны агрегации

В реальных приложениях часто комбинируются разные подходы:

  • предварительный bulkGet для основных связей;
  • локальный Promise.all для вторичных данных;
  • кэширование результатов в памяти;
  • ленивые догрузки дополнительных связей.

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