Составной индекс в 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)
Это важно, поскольку все операции диапазонов работают строго в лексикографическом порядке.
Составной индекс задаётся в строке схемы через +:
db.version(1).stores({
orders: '++id, [userId+createdAt], status'
});
Особенности:
Пример второго индекса:
db.version(1).stores({
logs: '[level+timestamp], message'
});
Dexie.js позволяет обращаться к составному индексу напрямую через
where:
db.users
.where('[lastName+firstName]')
.equals(['Ivanov', 'Alex'])
.toArray();
Здесь ключ передаётся как массив значений, соответствующий структуре индекса.
db.users
.where('[lastName+firstName]')
.equals(['Ivanov', 'Boris']);
Запрос вернёт только точное совпадение пары.
Составные индексы поддерживают диапазонные запросы, но с важным ограничением: диапазон работает по всей кортежной структуре.
db.users
.where('[lastName+firstName]')
.between(
['Ivanov', 'A'],
['Ivanov', 'M'],
true,
true
)
.toArray();
Этот запрос:
lastName = IvanovfirstName от A до MОднако важно понимать: диапазон применяется к полному составному ключу, а не к отдельным полям независимо.
Составные индексы в Dexie.js имеют строгие ограничения, вытекающие из модели IndexedDB:
Индекс:
[userId + createdAt + status]
Нельзя эффективно фильтровать только по createdAt без
userId.
Запрос:
.where('[userId+createdAt]')
.between([10, 0], [10, 999999999])
работает корректно, потому что фиксирован первый компонент.
Но запрос:
.where('[userId+createdAt]')
.between([0, 0], [999999999, 0])
становится практически бессмысленным, так как охватывает весь диапазон userId.
При работе с составными индексами:
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])
Такой запрос не даёт ожидаемого результата и нарушает логику индекса.
Если требуется фильтрация по второму полю составного индекса, используется комбинация:
db.orders
.where('createdAt')
.equals(123456)
.filter(order => order.userId > 10)
.toArray();
Недостаток — загрузка в память.
Практика оптимизации:
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();
Результат всегда:
lastName (ASC)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']
Это влияет на:
[lastName+firstName] !== [firstName+lastName]
Это полностью разные индексы.
Невозможно эффективно сделать:
.where('[lastName+firstName]').equals([null, 'Alex'])
Составной индекс не равен двум отдельным индексам.
Составные индексы дают максимальный эффект, когда:
Неэффективны, если:
Dexie.js позволяет комбинировать составные индексы с простыми:
db.version(1).stores({
users: '++id, age, country, [country+age]'
});
Пример оптимального использования:
db.users.where('[country+age]')
.between(['Kazakhstan', 18], ['Kazakhstan', 30])
.toArray();
Составные индексы учитывают null и
undefined как отдельные значения в сортировке.
['Ivanov', null] < ['Ivanov', 'A']
Это может влиять на диапазонные запросы и требует аккуратного проектирования данных.