Логика пересечения множеств через intersect()

Метод intersect() в Dexie.js предназначен для пересечения результатов нескольких запросов. Он позволяет оставить только те записи, которые одновременно удовлетворяют нескольким условиям выборки.

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

Основная идея метода заключается в следующем:

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

Таким образом реализуется математическая операция пересечения множеств.


Проблема множественных условий

Предположим, существует таблица пользователей:

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

Содержимое:

[
    { id: 1, name: 'Анна', city: 'Москва', age: 25, role: 'admin' },
    { id: 2, name: 'Иван', city: 'Москва', age: 30, role: 'user' },
    { id: 3, name: 'Олег', city: 'Казань', age: 30, role: 'admin' },
    { id: 4, name: 'Мария', city: 'Москва', age: 35, role: 'admin' }
]

Необходимо получить пользователей:

  • из Москвы;
  • одновременно имеющих роль администратора.

Первое множество:

const moscowUsers =
    db.users.where('city').equals('Москва');

Второе множество:

const admins =
    db.users.where('role').equals('admin');

Требуется найти общие элементы этих двух выборок.


Базовый синтаксис

Общий вид метода:

collection.intersect(otherCollection)

где:

  • collection — основная коллекция;
  • otherCollection — коллекция для пересечения.

Пример:

const result = await db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.users
            .where('role')
            .equals('admin')
    )
    .toArray();

Результат:

[
    { id: 1, name: 'Анна', city: 'Москва', age: 25, role: 'admin' },
    { id: 4, name: 'Мария', city: 'Москва', age: 35, role: 'admin' }
]

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


Как работает intersect()

Внутренне Dexie выполняет следующие действия:

  1. Получает элементы второго набора.
  2. Создает внутренний список идентификаторов.
  3. Проходит по первой коллекции.
  4. Проверяет наличие каждой записи во втором множестве.
  5. Возвращает только совпадающие элементы.

Условно это можно представить следующим псевдокодом:

result = collectionA.filter(item =>
    collectionB.includes(item)
);

На практике механизм оптимизирован и работает через первичные ключи объектов.


Пересечение по первичному ключу

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

Например:

db.users.where('city')
    .equals('Москва')
    .intersect(
        db.users.where('age').above(28)
    );

Первая выборка:

[
    { id: 1, ... },
    { id: 2, ... },
    { id: 4, ... }
]

Вторая выборка:

[
    { id: 2, ... },
    { id: 3, ... },
    { id: 4, ... }
]

Пересечение:

[
    { id: 2, ... },
    { id: 4, ... }
]

Совпадение определяется по значениям id.


Использование с диапазонными запросами

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

Пример:

const result = await db.users
    .where('age')
    .between(25, 40)
    .intersect(
        db.users
            .where('city')
            .equals('Москва')
    )
    .toArray();

Логика запроса:

  • возраст от 25 до 40 лет;
  • город Москва.

Результат содержит записи, удовлетворяющие обоим критериям.


Комбинирование нескольких intersect()

Пересечения могут строиться цепочкой.

Пример:

const result = await db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.users.where('role')
            .equals('admin')
    )
    .intersect(
        db.users.where('age')
            .above(30)
    )
    .toArray();

Логика:

Москва
AND администратор
AND возраст > 30

Результат:

[
    {
        id: 4,
        name: 'Мария',
        city: 'Москва',
        age: 35,
        role: 'admin'
    }
]

Каждое новое пересечение дополнительно сокращает набор данных.


Пересечение результатов anyOf()

Очень распространённый сценарий — объединение anyOf() и intersect().

Например:

const result = await db.users
    .where('city')
    .anyOf(['Москва', 'Казань'])
    .intersect(
        db.users
            .where('role')
            .equals('admin')
    )
    .toArray();

Первый набор:

Москва ИЛИ Казань

Второй набор:

Только администраторы

Итог:

(Москва OR Казань)
AND admin

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


Реализация логического AND между индексами

Одним из важнейших применений метода является реализация логического оператора AND между различными индексами.

IndexedDB обычно эффективно работает с одним индексом за запрос.

Например:

.where('city').equals('Москва')

или

.where('role').equals('admin')

Но запрос вида:

city = Москва
AND role = admin

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

Метод intersect() решает эту проблему:

await db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.users
            .where('role')
            .equals('admin')
    )
    .toArray();

Фактически происходит пересечение результатов двух индексных запросов.


Сравнение intersect() и filter()

Тот же результат можно получить через фильтрацию:

const result = await db.users
    .where('city')
    .equals('Москва')
    .filter(user => user.role === 'admin')
    .toArray();

Однако между подходами существует существенная разница.

Через filter()

.where('city')
.equals('Москва')
.filter(...)

Преимущества:

  • простой синтаксис;
  • произвольная логика.

Недостатки:

  • часть фильтрации выполняется в JavaScript;
  • возможно чтение большого количества записей.

Через intersect()

.where('city')
.equals('Москва')
.intersect(...)

Преимущества:

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

Недостатки:

  • требуется создание дополнительной коллекции.

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

Рассмотрим схему:

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

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

db.users
    .where('[city+role]')
    .equals(['Москва', 'admin']);

Но иногда составной индекс отсутствует либо условия слишком разнообразны.

Тогда применяется пересечение:

db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.users
            .where('role')
            .equals('admin')
    );

Такой подход обеспечивает гибкость без необходимости создавать большое количество составных индексов.


Работа с большими наборами данных

Следует учитывать, что intersect() требует обработки двух коллекций.

Например:

db.orders
    .where('status')
    .equals('completed')
    .intersect(
        db.orders
            .where('country')
            .equals('Germany')
    );

Если каждая выборка содержит десятки тысяч записей, потребуется:

  • получить оба множества;
  • определить общие ключи;
  • сформировать результат.

Поэтому эффективность зависит от:

  • качества индексов;
  • размера выборок;
  • количества пересечений.

Чем уже исходные коллекции, тем быстрее выполняется операция.


Практический пример поиска товаров

Структура таблицы:

db.version(1).stores({
    products: '++id, category, brand, price'
});

Необходимо найти:

  • электронику;
  • бренда Samsung.

Решение:

const products = await db.products
    .where('category')
    .equals('electronics')
    .intersect(
        db.products
            .where('brand')
            .equals('Samsung')
    )
    .toArray();

Логическая модель:

category = electronics
AND brand = Samsung

Практический пример поиска заказов

Таблица:

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

Запрос:

const orders = await db.orders
    .where('status')
    .equals('completed')
    .intersect(
        db.orders
            .where('country')
            .equals('Germany')
    )
    .intersect(
        db.orders
            .where('customerId')
            .equals(15)
    )
    .toArray();

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

status = completed
AND country = Germany
AND customerId = 15

Ограничения метода

Пересекаются только коллекции одной таблицы

Корректный вариант:

db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.users
            .where('role')
            .equals('admin')
    );

Некорректный вариант:

db.users
    .where('city')
    .equals('Москва')
    .intersect(
        db.orders
            .where('status')
            .equals('completed')
    );

Пересечение объектов разных таблиц не имеет смысла, поскольку первичные ключи принадлежат различным наборам данных.


Не заменяет составные индексы

Если запрос постоянно использует одинаковый набор условий:

city + role

то составной индекс обычно будет эффективнее:

'[city+role]'

Метод intersect() особенно полезен тогда, когда комбинации условий динамически меняются и заранее определить все составные индексы невозможно.


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

Наиболее распространённые случаи использования:

  • поиск пользователей по нескольким независимым критериям;
  • фильтрация товаров по категории и бренду;
  • отбор заказов по статусу и региону;
  • построение сложных логических условий;
  • реализация запросов вида AND между разными индексами;
  • комбинирование результатов equals(), between(), above(), below(), anyOf() и других методов выборки.

Метод intersect() является важным инструментом построения сложных запросов в Dexie.js, позволяя выполнять пересечение нескольких индексных выборок и получать только те записи, которые одновременно присутствуют во всех участвующих множествах данных.