Фильтрация по составному индексу

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

В Dexie.js составной индекс задаётся в схеме таблицы через квадратные скобки:

const db = new Dexie('AppDB');

db.version(1).stores({
  users: '++id,[lastName+firstName],age,country'
});

Здесь индекс [lastName+firstName] означает, что Dexie создаёт составной индекс, где сначала сортировка идёт по lastName, а затем внутри каждого lastName — по firstName.

Принцип формирования составного ключа

Составной индекс не хранит поля отдельно. Вместо этого формируется упорядоченная структура ключа:

[lastName, firstName]

Каждая запись в индексе становится кортежем значений. Например:

lastName firstName
Ivanov Alex
Ivanov Boris
Petrov Anna

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

(Ivanov, Alex)
(Ivanov, Boris)
(Petrov, Anna)

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


Определение составного индекса в Dexie.js

Составной индекс задаётся в строке схемы через +:

db.version(1).stores({
  orders: '++id, [userId+createdAt], status'
});

Особенности:

  • порядок полей критичен
  • индекс всегда лексикографический
  • изменение порядка полей создаёт другой индекс
  • нельзя «переставить» порядок на уровне запроса

Пример второго индекса:

db.version(1).stores({
  logs: '[level+timestamp], message'
});

Основной способ фильтрации: where() по составному индексу

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

db.users
  .where('[lastName+firstName]')
  .equals(['Ivanov', 'Alex'])
  .toArray();

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

Полное совпадение

db.users
  .where('[lastName+firstName]')
  .equals(['Ivanov', 'Boris']);

Запрос вернёт только точное совпадение пары.


Лексикографические диапазоны

Составные индексы поддерживают диапазонные запросы, но с важным ограничением: диапазон работает по всей кортежной структуре.

between()

db.users
  .where('[lastName+firstName]')
  .between(
    ['Ivanov', 'A'],
    ['Ivanov', 'M'],
    true,
    true
  )
  .toArray();

Этот запрос:

  • ограничивает lastName = Ivanov
  • и диапазон firstName от A до M

Однако важно понимать: диапазон применяется к полному составному ключу, а не к отдельным полям независимо.


Ограничения составных индексов

Составные индексы в Dexie.js имеют строгие ограничения, вытекающие из модели IndexedDB:

1. Нельзя пропускать поля в середине

Индекс:

[userId + createdAt + status]

Нельзя эффективно фильтровать только по createdAt без userId.


2. Диапазон работает слева направо

Запрос:

.where('[userId+createdAt]')
  .between([10, 0], [10, 999999999])

работает корректно, потому что фиксирован первый компонент.

Но запрос:

.where('[userId+createdAt]')
  .between([0, 0], [999999999, 0])

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


3. Левый компонент обязателен для эффективного поиска

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

  • первый элемент — основной якорь
  • последующие уточняют порядок внутри группы

startsWith для составных индексов

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

db.users
  .where('[lastName+firstName]')
  .startsWith(['Ivanov'])
  .toArray();

Этот запрос вернёт всех пользователей с фамилией Ivanov независимо от имени.

Логически это эквивалентно:

(lastName = 'Ivanov' AND firstName >= '')

Работа с частично заданными кортежами

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

Пример: фильтрация только по первому полю

db.orders
  .where('[userId+createdAt]')
  .equals([5])
  .toArray();

Это эквивалентно:

userId = 5

все createdAt попадают в выборку.


Попытка фильтрации по второму полю невозможна напрямую

.where('[userId+createdAt]')
  .equals([undefined, 123456])

Такой запрос не даёт ожидаемого результата и нарушает логику индекса.


Альтернативные методы фильтрации

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

1. Простой where + filter

db.orders
  .where('createdAt')
  .equals(123456)
  .filter(order => order.userId > 10)
  .toArray();

Недостаток — загрузка в память.


2. Дублирование индексов

Практика оптимизации:

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

Теперь можно:

db.orders.where('createdAt').equals(123456)

или

db.orders.where('[userId+createdAt]').between([5, 0], [5, Infinity])

Сортировка и порядок результатов

Составной индекс автоматически задаёт порядок сортировки.

db.users.where('[lastName+firstName]').toArray();

Результат всегда:

  1. по lastName (ASC)
  2. затем по firstName (ASC)

Это поведение нельзя переопределить через orderBy, если используется индекс.


Многокомпонентные диапазоны

Составные индексы особенно полезны при диапазонных запросах по временным данным.

Пример:

db.logs
  .where('[level+timestamp]')
  .between(
    ['error', 1700000000],
    ['error', 1800000000]
  )
  .toArray();

Логика:

  • фиксируем уровень логов
  • ограничиваем временной интервал

Практический сценарий: фильтрация заказов

db.orders.where('[userId+status]')
  .equals([42, 'paid'])
  .toArray();

или диапазон:

db.orders.where('[userId+createdAt]')
  .between([42, 0], [42, Date.now()])
  .toArray();

Такие запросы позволяют полностью избежать full scan таблицы.


Особенности сравнения значений

Dexie.js использует лексикографический порядок:

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

Пример:

['Ivanov', 'A'] < ['Ivanov', 'B']
['Ivanov', 'B'] < ['Petrov', 'A']

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

  • between
  • startsWith
  • range queries

Типичные ошибки при работе с составными индексами

Ошибка 1: неправильный порядок полей

[lastName+firstName] !== [firstName+lastName]

Это полностью разные индексы.


Ошибка 2: попытка фильтрации по “внутреннему” полю

Невозможно эффективно сделать:

.where('[lastName+firstName]').equals([null, 'Alex'])

Ошибка 3: ожидание независимых условий

Составной индекс не равен двум отдельным индексам.


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

Составные индексы дают максимальный эффект, когда:

  • фильтр всегда включает левую часть индекса
  • диапазоны ограничены по первым полям
  • запросы повторяются часто и предсказуемы

Неэффективны, если:

  • используется только второе поле
  • порядок запросов нестабилен
  • индексы перегружены избыточными комбинациями

Комбинации с другими индексами

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

db.version(1).stores({
  users: '++id, age, country, [country+age]'
});

Пример оптимального использования:

db.users.where('[country+age]')
  .between(['Kazakhstan', 18], ['Kazakhstan', 30])
  .toArray();

Поведение с undefined и null

Составные индексы учитывают null и undefined как отдельные значения в сортировке.

['Ivanov', null] < ['Ivanov', 'A']

Это может влиять на диапазонные запросы и требует аккуратного проектирования данных.