Проверка существования: first(), last()

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


Семантика first()

Метод first() возвращает первую запись из результата запроса. Порядок определяется индексом или естественным порядком сортировки ключей.

Базовое использование:

const user = await db.users
  .where('age')
  .above(18)
  .first();

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

  • выполняется индексный запрос по полю age
  • применяется фильтр > 18
  • извлекается только первый подходящий элемент

Если записей нет, результатом будет undefined.

Особенности поведения

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


Использование first() как проверки существования

Часто first() применяется не для получения данных, а для проверки факта наличия записи:

const exists = await db.users
  .where('email')
  .equals('test@example.com')
  .first()
  .then(Boolean);

Здесь:

  • запрос ищет пользователя по email
  • возвращается первый найденный объект
  • преобразование в Boolean даёт true или false

Альтернативная форма:

const exists = !!(await db.users
  .where('email')
  .equals('test@example.com')
  .first());

Поведение last()

Метод last() возвращает последнюю запись из результата запроса, учитывая порядок сортировки.

Пример:

const latestOrder = await db.orders
  .orderBy('createdAt')
  .last();

Здесь:

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

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


Отличие last() от reverse().first()

Метод last() может быть логически заменён конструкцией:

const latest = await db.orders
  .orderBy('createdAt')
  .reverse()
  .first();

Обе операции эквивалентны, но есть различия:

  • last() выполняет получение последнего элемента напрямую
  • reverse().first() разворачивает курсор и берёт первый элемент

Внутренняя реализация Dexie.js оптимизирует оба варианта до индексного чтения с ограничением одного элемента.


Проверка существования через last()

Хотя менее распространено, last() также может использоваться для проверки наличия записей:

const exists = await db.sessions
  .where('userId')
  .equals(42)
  .last()
  .then(Boolean);

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


Различие между first() и toArray()

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

await db.users.where('active').equals(1).first();   // один объект
await db.users.where('active').equals(1).toArray(); // массив всех объектов
  • first() — минимальная выборка, один элемент
  • toArray() — полная загрузка результата

При проверке существования использование toArray() является неэффективным, так как приводит к избыточной передаче данных.


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

Эффективность first() и last() напрямую зависит от наличия индексов:

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

При наличии индекса:

  • поиск выполняется через B-tree структуру IndexedDB
  • время доступа близко к O(log n)
  • извлекается только один элемент

Без индекса:

  • происходит перебор всех записей
  • first() фактически становится полным scan-оператором с остановкой на первом совпадении

Поведение с compound-запросами

При использовании составных условий:

const record = await db.logs
  .where('[userId+type]')
  .equals([42, 'error'])
  .first();

оператор first() возвращает первую запись в порядке составного индекса. Это особенно важно, когда ключ формируется из нескольких полей.


Работа с orderBy()

Метод orderBy() влияет на результат first() и last():

const min = await db.products
  .orderBy('price')
  .first();

const max = await db.products
  .orderBy('price')
  .last();
  • first() — минимальное значение
  • last() — максимальное значение

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


Использование в условиях гонки данных

При конкурентных изменениях данных:

const session = await db.sessions
  .where('active')
  .equals(1)
  .first();

возможна ситуация, когда запись уже удалена между моментом запроса и обработкой результата. Поэтому результат first() всегда должен рассматриваться как потенциально устаревший снимок состояния базы.


Ограничения и особенности

  • first() и last() всегда возвращают один объект или undefined
  • не поддерживают агрегацию
  • не изменяют порядок данных без orderBy()
  • зависят от индексов для оптимальной работы
  • не возвращают массивы даже при множественных совпадениях

Сравнение поведения

Метод Результат Объём данных Типичная задача
first() первый элемент минимальный проверка существования, выбор первого совпадения
last() последний элемент минимальный поиск последней записи, максимум диапазона
toArray() массив полный загрузка всех совпадений

Типичные сценарии применения

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

const exists = await db.users
  .where('username')
  .equals(name)
  .first();

Получение последнего события

const event = await db.events
  .orderBy('timestamp')
  .last();

Поиск минимального значения

const cheapest = await db.products
  .orderBy('price')
  .first();

Взаимодействие с цепочками Promise

Оба метода возвращают Promise:

db.users
  .where('role')
  .equals('admin')
  .first()
  .then(user => {
    if (user) {
      // обработка
    }
  });

или в async/await стиле:

const user = await db.users
  .where('role')
  .equals('admin')
  .first();

Особенности возврата undefined

Отсутствие результата выражается через undefined:

const item = await db.items.where('id').equals(999).first();

if (!item) {
  // запись не найдена
}

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


Использование в диапазонах

const firstInRange = await db.sales
  .where('amount')
  .between(100, 500)
  .first();

const lastInRange = await db.sales
  .where('amount')
  .between(100, 500)
  .last();

Здесь:

  • first() возвращает минимальное значение в диапазоне
  • last() возвращает максимальное значение в диапазоне

Оба метода позволяют реализовать границы диапазона без дополнительной обработки коллекций.