Диапазонные запросы с составными ключами

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

Схема таблицы с составным индексом задаётся через строку вида:

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

Индекс [customerId+createdAt] означает, что значения двух полей объединяются в один упорядоченный ключ, где сравнение происходит сначала по customerId, а затем по createdAt.


Лексикографический порядок в составных ключах

Составные ключи сравниваются по принципу лексикографического порядка, аналогично сортировке строк:

  1. Сначала сравнивается первый элемент ключа
  2. Если первые элементы равны — сравнивается второй
  3. При совпадении продолжается сравнение следующих элементов

Пример ключей:

[1, "2024-01-01"]
[1, "2024-02-01"]
[2, "2024-01-01"]

Порядок будет строго следовать первому элементу, а внутри него — второму.

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


Базовые диапазонные запросы по составному индексу

Dexie.js позволяет выполнять запросы через where() с указанием составного индекса:

db.orders
  .where('[customerId+createdAt]')
  .between([1, '2024-01-01'], [1, '2024-12-31'])
  .toArray();

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

Такой запрос эквивалентен:

  • фиксированное значение customerId = 1
  • диапазон по createdAt

Ограничение диапазона по первому элементу ключа

Наиболее эффективная стратегия работы с compound индексами заключается в фиксации первого элемента:

db.orders
  .where('[customerId+createdAt]')
  .between(
    [5, Dexie.minKey],
    [5, Dexie.maxKey]
  )
  .toArray();

Здесь используются специальные границы:

  • Dexie.minKey — минимально возможное значение
  • Dexie.maxKey — максимально возможное значение

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


Частичные диапазоны и поведение between

Метод between() работает не только с полными значениями, но и с частично ограниченными диапазонами:

db.orders
  .where('[customerId+createdAt]')
  .between([1, '2024-01-01'], [10, '2024-01-31'])
  .toArray();

В этом случае диапазон включает:

  • клиентов с customerId от 1 до 10
  • внутри каждого клиента — только записи в пределах указанного диапазона дат

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


Ограничения использования lower/upper bound методов

Dexie.js предоставляет методы above(), below(), aboveOrEqual(), belowOrEqual():

db.orders
  .where('[customerId+createdAt]')
  .above([5, '2024-01-01'])
  .toArray();

Для составных ключей это означает:

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

Пример результата:

  • [5, '2024-01-02'] попадёт
  • [5, '2023-12-31'] не попадёт
  • [6, '2020-01-01'] попадёт даже если дата меньше, потому что первый элемент больше

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


Фиксация префикса и эффективные диапазоны

Наиболее распространённый паттерн — фиксация префикса и диапазон по второму полю:

const start = [userId, new Date('2024-01-01')];
const end = [userId, new Date('2024-02-01')];

db.orders
  .where('[userId+createdAt]')
  .between(start, end)
  .toArray();

Такая структура позволяет:

  • использовать индекс полностью
  • избегать full scan таблицы
  • получать предсказуемый диапазон

Сравнение строковых и числовых компонентов

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

  • числа сравниваются как числа
  • строки — в Unicode-лексикографическом порядке

Проблема возникает при смешанных типах:

[1, "2"]
[1, "10"]

Строковое сравнение даст неожиданный результат:

"10" < "2"

Поэтому при проектировании диапазонов важно нормализовать типы:

  • даты → Date или timestamp
  • числа → только Number
  • строки с числами → с padding ("02", "10")

Работа с датами в составных ключах

Date-объекты в IndexedDB сравниваются как числовые timestamps:

db.events.where('[userId+date]')
  .between([1, new Date('2024-01-01')], [1, new Date('2024-01-31')])

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

Однако важно избегать:

  • строковых дат в одном индексе с Date-объектами
  • разных форматов хранения времени

Использование Dexie.minKey и Dexie.maxKey

Специальные значения позволяют расширять диапазоны до границ ключевого пространства:

db.orders
  .where('[customerId+createdAt]')
  .between(
    [5, Dexie.minKey],
    [5, Dexie.maxKey]
  )

Поведение:

  • Dexie.minKey включает все возможные значения второго элемента
  • Dexie.maxKey завершает диапазон максимально поздним значением

Это используется для:

  • выборки всех записей по первому ключу
  • построения пагинации внутри группы

Пагинация в пределах составного ключа

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

db.orders
  .where('[customerId+createdAt]')
  .between([5, lastSeenDate], [5, Dexie.maxKey])
  .limit(50)

Такой подход гарантирует:

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

Ограничения compound-диапазонов

Несмотря на гибкость, существуют ограничения:

1. Нельзя эффективно менять порядок полей

Индекс [a+b] не эквивалентен [b+a]. Запросы по второму полю без первого становятся неэффективными или невозможными:

.where('[customerId+createdAt]') // эффективно
.where('createdAt') // другой индекс нужен

2. Частичное игнорирование префикса невозможно

Запрос вида:

  • диапазон только по createdAt

не использует [customerId+createdAt] индекс.


3. Лексикографическая природа может искажать диапазоны

Например:

[1, 100]
[2, 1]

Второй элемент не определяет глобальный порядок без учёта первого.


Оптимизация диапазонных запросов

Для повышения эффективности:

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

Пример оптимальной схемы:

'userId, [userId+createdAt]'

Типовые шаблоны запросов

Все записи пользователя за период

db.logs.where('[userId+timestamp]')
  .between(
    [userId, startDate],
    [userId, endDate]
  )

Все записи пользователя

db.logs.where('[userId+timestamp]')
  .between(
    [userId, Dexie.minKey],
    [userId, Dexie.maxKey]
  )

Все записи после определённого момента

db.logs.where('[userId+timestamp]')
  .above([userId, lastSeen])

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

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

customerId ASC → createdAt ASC

Это гарантирует:

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