Уникальные и мультиэнтри-индексы

В Dexie.js индексы являются ключевым механизмом ускоренного доступа к данным в IndexedDB. Они определяют, по каким полям можно эффективно выполнять выборки, сортировку и фильтрацию без полного сканирования таблицы. В отличие от обычного перебора записей, индексированные запросы работают поверх внутренней B-tree структуры IndexedDB и обеспечивают предсказуемую производительность даже при больших объёмах данных.

Dexie.js расширяет базовую модель IndexedDB, добавляя декларативное описание схемы и удобный API для работы с индексами. Среди наиболее важных возможностей — уникальные индексы и мультиэнтри (multiEntry) индексы, которые позволяют моделировать как строгие ограничения целостности, так и работу с массивными полями.


Объявление индексов в схеме Dexie.js

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

const db = new Dexie("AppDatabase");

db.version(1).stores({
  users: "++id, email, username, age"
});

В этом примере:

  • ++id — автоинкрементный первичный ключ
  • email, username, age — обычные индексы

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

db.users.where("email").equals("test@mail.com").toArray();

Однако базовая схема не накладывает ограничений уникальности и не поддерживает сложные структуры данных. Для этого используются расширенные типы индексов.


Уникальные индексы (unique indexes)

Уникальный индекс гарантирует, что значение в указанном поле не будет повторяться в пределах таблицы. В Dexie.js это задаётся символом &.

db.version(1).stores({
  users: "++id, &email, username"
});

Поведение уникального индекса

Поле email теперь становится строго уникальным:

  • попытка добавить запись с уже существующим email приведёт к ошибке
  • IndexedDB обеспечивает проверку на уровне хранилища
await db.users.add({
  email: "user@mail.com",
  username: "user1"
});

// Ошибка: email уже существует
await db.users.add({
  email: "user@mail.com",
  username: "user2"
});

Отличие от ручной проверки

Без уникального индекса разработчик вынужден проверять существование значения вручную:

const exists = await db.users.where("email").equals(email).first();
if (!exists) {
  await db.users.add({ email, username });
}

Этот подход:

  • не атомарен
  • подвержен race condition
  • менее эффективен

Уникальный индекс решает проблему на уровне движка базы данных.

Составные уникальные индексы

Dexie.js поддерживает уникальность на основе комбинации полей:

db.version(1).stores({
  users: "++id, &[email+tenantId], username, tenantId"
});

Здесь:

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

Пример данных:

await db.users.add({ email: "a@mail.com", tenantId: 1 });
await db.users.add({ email: "a@mail.com", tenantId: 2 }); // допустимо
await db.users.add({ email: "a@mail.com", tenantId: 1 }); // ошибка

Внутренняя модель

Составной уникальный индекс хранится как единое значение ключа, где поля сериализуются в порядке объявления. Это позволяет IndexedDB строить единый B-tree по составному ключу без дополнительных структур.


Мультиэнтри индексы (multiEntry indexes)

Мультиэнтри индекс предназначен для индексации массивов. Он позволяет каждому элементу массива стать отдельной индексной записью.

Объявляется символом *.

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

Принцип работы multiEntry

Если поле содержит массив:

{
  id: 1,
  title: "Dexie guide",
  tags: ["js", "indexeddb", "dexie"]
}

то Dexie создаёт три индексные записи:

  • “js” → id 1
  • “indexeddb” → id 1
  • “dexie” → id 1

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


Запросы по multiEntry индексу

await db.posts.where("tags").equals("dexie").toArray();

Результат включает все посты, где массив tags содержит "dexie".

Пересечение условий

Можно комбинировать multiEntry индексы с другими условиями:

db.posts
  .where("tags")
  .equals("js")
  .and(post => post.title.includes("guide"))
  .toArray();

Важно учитывать, что часть условий выполняется уже после выборки из индекса.


Особенности хранения multiEntry

MultiEntry индекс:

  • не хранит массив как единое значение
  • разбивает массив на отдельные ключи
  • дублирует ссылки на объект в индексе

Это влияет на:

  • размер индексной структуры
  • скорость записи (увеличивается число операций)
  • скорость поиска (значительно увеличивается)

Ограничения multiEntry индексов

1. Поддерживаются только массивы

Если поле не массив:

{
  tags: "js"
}

multiEntry индекс будет вести себя как обычный индекс с одним значением.


2. Нет вложенной индексации

Массив объектов не индексируется рекурсивно:

{
  tags: [{ name: "js" }, { name: "db" }]
}

Индекс не извлечёт name автоматически. Только примитивные значения массива участвуют в индексации.


3. Нельзя комбинировать multiEntry и unique

Dexie.js не поддерживает уникальные multiEntry индексы, поскольку массив создаёт множество значений на один объект, что противоречит модели уникальности.


Сравнение обычных, уникальных и multiEntry индексов

Обычный индекс

users: "username"
  • допускает дубликаты
  • индексирует одно значение
  • подходит для фильтрации и сортировки

Уникальный индекс

users: "&email"
  • запрещает дубликаты
  • обеспечивает целостность данных
  • оптимален для идентификаторов и ключевых полей

MultiEntry индекс

posts: "*tags"
  • индексирует массив как набор значений
  • позволяет искать по элементам массива
  • увеличивает объём индекса

Составные индексы с multiEntry и unique

Dexie.js позволяет комбинировать составные индексы с уникальностью:

db.version(1).stores({
  items: "++id, &[category+name], *tags"
});

Здесь:

  • [category+name] — уникальный составной индекс
  • tags — multiEntry индекс

Ограничения:

  • multiEntry всегда применяется к одному полю
  • составные multiEntry индексы не поддерживаются напрямую

Поведение при обновлении данных

Изменение массива в multiEntry индексе

При обновлении массива Dexie:

  1. удаляет старые индексные записи
  2. создаёт новые записи для обновлённого массива
await db.posts.update(1, {
  tags: ["new", "updated"]
});

Это важно учитывать при:

  • массовых обновлениях
  • частых изменениях тегов
  • синхронизации данных

Изменение уникального индекса

При изменении уникального поля:

await db.users.update(1, {
  email: "new@mail.com"
});

Dexie проверяет:

  • отсутствие конфликта
  • возможность атомарного обновления

Производительность индексов

Уникальные индексы

  • ускоряют поиск до O(log n)
  • уменьшают количество проверок целостности в приложении
  • добавляют минимальный overhead при записи

MultiEntry индексы

  • увеличивают индексный объём пропорционально среднему размеру массива
  • ускоряют поиск по элементам массива до O(log n)
  • могут замедлять массовые вставки

Практическая модель проектирования

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

  • email пользователей
  • логины
  • внешние идентификаторы API
  • комбинации бизнес-ключей

Когда использовать multiEntry индекс

  • теги
  • категории
  • списки идентификаторов связей
  • метки поиска

Антипаттерны

  • использование multiEntry для больших массивов (>1000 элементов)
  • создание избыточного числа индексов на одном поле
  • использование уникальных индексов там, где допустимы дубликаты с логической точки зрения

Внутренние особенности IndexedDB

Dexie.js опирается на IndexedDB, где:

  • каждый индекс — отдельная B-tree структура
  • multiEntry индекс создаёт множественные записи на уровне движка
  • уникальность обеспечивается транзакционно

Это означает, что:

  • все операции атомарны внутри транзакции
  • нарушение уникальности блокирует commit транзакции
  • индексные обновления синхронизированы с основными данными

Работа с where() и индексацией

Dexie выбирает индекс автоматически при запросах:

db.posts.where("tags").equals("js")

Если индекс multiEntry:

  • выполняется поиск по всем ключам массива

Если индекс уникальный:

  • используется прямое обращение к B-tree узлу

Если индекс отсутствует:

  • происходит full scan таблицы