Язык фильтрации для запросов к heap-таблицам

С помощью методов findAll, findBy, findOneBy и countBy репозитория heap-таблицы можно фильтровать целевой набор записей.

Ниже представлено описание мини-"языка", с помощью которого можно описать правила фильтрации от очень простых до довольно сложных.

Основы

Все примеры в данном руководстве работают с простой таблицей задач со следующей структурой:

const Tasks = Heap.Table('tasks', {
  title: Heap.String(),
  priority: Heap.Integer(),
  info: Heap.Object({
    contact: Heap.Nullable(Heap.String()),
    customer: Heap.Optional(Heap.RefLink('customers')),
    area: Heap.Tuple([
      Heap.Number(),
      Heap.Union([Heap.Literal('sqm'), Heap.Literal('sqf')])
    ]),
  }),
  labels: Heap.Array(Heap.NonEmptyString(), { uniqueItems: true }),
  customParams: Heap.Record(Heap.String(), Heap.String()),
})

Правила фильтрации задаются с помощью простого объекта. В простейшем варианте единственным ключом этого объекта является название одного из полей верхнего уровня heap-таблицы, а значением - значение этого поля, по которому мы хотим отфильтровать набор записей (используя точное соответствие - равенство). Пример: получить все задачи с приоритетом 5:

const tasks = await Tasks.findBy(ctx, { priority: 5 })

Если нужно отфильтровать по вложенному полю, то необходимо путь к нему отобразить в структуре объекта фильтра:

const tasks = await Tasks.findBy(ctx, { info: { contact: null } })

Если нужно отфильтровать сразу по нескольким полям, то в большинстве случаев достаточно просто скомбинировать несколько фильтров в одном объекте. Все фильтры будут объединены через правило AND (для сложных условий может понадобится напрямую использовать специальный оператор $and, о нём ниже):

const tasks = await Tasks.findBy(ctx, {
  priority: 5,
  info: { contact: null },
})

Обратите внимание, что фильтрация по точному соответствию (равенству) работает для простых типов, но для вложенных объектов происходит "частичная" фильтрация только по тем вложенным полям, которые указаны в фильтре.

Так, в примере выше, вложенный объект info фильтруется только по полю contact, при этом значение поля info.customer не учитывается.

Фильтрация по списку значений

Почти все типы полей, значения которых хранятся в виде простых скаляров, поддерживают фильтрацию сразу по списку из нескольких значений, которая работает аналогично оператору IN в SQL:

const tasks = await Tasks.findBy(ctx, { priority: [1, 2, 3] })

Такая фильтрация возможна только по простым скалярным значениям, или значениям, которые приводятся к скалярам (например, heap-объект приводится к своему идентификатору для фильтрации поля типа RefLink):

const tasks = await Tasks.findBy(ctx, {
  info: { customer: [user1, user2] },
})

Фильтрация по списку значений НЕ поддерживается для следующих типов полей:

Операторы сравнения ($gt, $lt, $gte, $lte)

Для многих естественно-ранжируемых типов полей поддерживается фильтрация не только по точному соответствию, но и с помощью операторов сравнения больше/меньше:

  • $gt - больше (greater than).
  • $lt - меньше (less than).
  • $gte - больше либо равно (greater than or equals).
  • $lte - меньше либо равно (less than or equals)

Для использования этих операторов, вместо значения поля с правой стороны необходимо подставить объект с единственным ключом, соответствующим нужному оператору, и значением, с которым необходимо сравнить для фильтрации:

const tasks = await Tasks.findBy(ctx, {
  priority: { $gt: 5 },
})

Список типов полей, поддерживающих операторы сравнения:

Логические операторы ($not, $and, $or)

Для формирования более сложных фильтров поддерживаются логические операторы $not, $and и $or, имеющие интуитивно-понятное предназначение. Все они представляют собой объект с единственным ключом, соответствующим оператору, и значением-фильтром, над которым требуется произвести логическую операцию.

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

{ $not: filter }

Инвертирует значение переданного условия.

Выборка всех задач, приоритет которых НЕ равен 5:

const tasks = await Tasks.findBy(ctx, {
  priority: { $not: 5 },
})

Выборка всех задач, приоритет которых НЕ больше 5:

const tasks = await Tasks.findBy(ctx, {
  priority: { $not: { $gt: 5 } },
})

Выборка всех задач, приоритет которых НЕ входит в заданный список значений:

const tasks = await Tasks.findBy(ctx, {
  priority: { $not: [1, 2, 3] },
})

{ $and: [filter1, filter2, ...] }

Пересекает переданный список условий, аналогично оператору AND в SQL.

Выборка всех задач, приоритет которых больше 3 И не равен 5:

const tasks = await Tasks.findBy(ctx, {
  priority: { $and: [
    { $gt: 3 },
    { $not: 5 },
  ]},
})

{ $or: [filter1, filter2, ...] }

Суммирует переданный список условий, аналогично оператору OR в SQL.

Выборка всех задач, приоритет которых больше 5 ИЛИ меньше 2:

const tasks = await Tasks.findBy(ctx, {
  priority: { $or: [
    { $gt: 5 },
    { $lt: 2 },
  ]},
})

Выборка всех задач, приоритет которых больше 5 ИЛИ задан контакт:

const tasks = await Tasks.findBy(ctx, {
  $or: [
    { info: { contact: { $not: null } } },
  ],
})

Вложенность

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

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

await Tasks.findBy(ctx, { priority: { $not: 5 } })
await Tasks.findBy(ctx, { $not: { priority: 5 } })
await Tasks.findBy(ctx, {
  priority: { $and: [
    { $gt: 3 },
    { $not: 5 },
  ]},
})
await Tasks.findBy(ctx, {
  $and: [
    { priority: { $gt: 3 } },
    { priority: { $not: 5 } },
  ],
})
await Tasks.findBy(ctx, {
  $or: [
    { info: { contact: { $not: null } } },
    { info: { customer: { $not: null } } },
  ],
})
await Tasks.findBy(ctx, {
  info: {
    $or: [
      { contact: { $not: null } },
      { customer: { $not: null } },
    ],
  },
})

Пустой оператор $noop

Достаточно часто возникает ситуация, когда условие фильтрации формируется динамически и часть условия фильтрации должна вставляться не всегда. Для удобства формулирования подобных ситуаций с помощью тернарного оператора поддерживается оператор { $noop: true }, который означает буквально "нет условия".

let onlyHighPriority = req.query?.onlyHighPriority ?? false
const tasks = await Tasks.findBy(ctx, {
  priority: onlyHighPriority ? { $gt: 5 } : { $noop: true },
  info: { contact: { $not: null } } },
})

Важно понимать, что пустое условие - это НЕ то же самое, что всегда TRUE или всегда FALSE.

Сравните
{ $and: [{ field: null }, { $noop: true }] } и
{ $and: [{ field: null }, falseCondition] }
- первое условие аналогично { field: null }, а второе будет всегда выдавать на выходе пустое множество.

Интерпретация null

Значение null, переданное в фильтр НЕ означает буквально "вернуть все записи со значением этого поля равным null", как может показаться. Такой фильтр вернёт как записи с явным значением null у поля, так и записи с отсутствующим (или undefined) значением.

С помощью языка фильтраций отличить null-значения от отсутствующих невозможно. Если такое понадобилось, то есть 2 выбора:

  • Пересмотреть схему данных. Смешивать Optional и Null в одном поле - плохая идея.
  • Определить разницу в уже полученных heap-объектах на уровне собственного js/ts-кода

Фильтрация по системным полям

В дополнение к полям, объявленным в heap-таблице, поддерживается фильтрация по системным полям, которые неявно присутствуют у любого heap-объекта:

  • id - уникальный идентификатор записи. Может быть полезно в сочетании с фильтрацией по списку значений.
  • createdAt/updatedAt - время создания/последнего изменения записи. Работают аналогично полям типа DateTime.
  • createdBy/updatedBy - пользователь создавший/последний изменивший запись. Работают аналогично полям типа RefLink.

Особенности фильтрации у разных типов полей

Оператор $ilike для строковых полей

Почти все типы полей, которое имеют строковое представление в хранилище, поддерживают регистронезависимую фильтрацию по шаблону с помощью оператора $ilike, который работает аналогично оператору ILIKE в PostgreSQL.

Если шаблон не содержит знаков процента и подчёркиваний, тогда шаблон представляет в точности строку и $ilike работает как оператор регистронезависимого сравнения. Подчёркивание (_) в шаблоне подменяет (вместо него подходит) любой символ, а знак процента (%) подменяет любую (в том числе и пустую) последовательность символов.

Пример: получить все задачи, в заголовке которых встречается important:

const tasks = await Tasks.findBy(ctx, {
  title: { $ilike: '%important%' },
})

Оператор $ilike поддерживается для следующих типов полей: Any, Enum, KeyOf, String, Unknown.

Оператор $includes для типа Array

Для полей типа Array поддерживается специальный оператор $includes, который позволяет проверить наличие заданного значения в массиве. Он работает только для массивов, элементы которых хранятся в виде простых скалярных значений.

Пример: получить все задачи, для которых проставлена метка vip:

const tasks = await Tasks.findBy(ctx, {
  labels: { $includes: 'vip' },
})

Оператор $has для типов Intersect и Record

Для полей динамических объектов (Record и Intersect), у которых набор ключей не прописан жёстко, поддерживается специальный оператор $has, который позволяет проверить наличие заданного ключа у хранящегося объекта.

Пример: получить все задачи, для которых задан параметр budget:

const tasks = await Tasks.findBy(ctx, {
  customParams: { $has: 'budget' },
})

Обратите внимание, что оператор $has проверяет только наличие заданного ключа, безотносительно значения, соответствующего этому ключу - значение может быть пустым.

Псевдо-поля для фильтрации у GenericLink и Money

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

Фильтрация по элементам Tuple

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

Пример: получить все задачи, для которых площадь задана в квадратных футах:

const tasks = await Tasks.findBy(ctx, {
  info: { area: { 1: 'sqf' } },
})

Однако, если в качестве значения фильтра передать массив, то это интерпретируется как "отфильтровать по всем элементам кортежа в соответствии с переданным массивом фильтров попозиционно". Например, получить все задачи с площадью больше 10 квадратных метров, заданной в квадратных метрах:

const tasks = await Tasks.findBy(ctx, {
  info: { area: [{ $gt: 10 }, 'sqm'] },
})

Ограничения фильтрации по Union полям

Поля с типом Union из-за своей потенциальной сложности накладывают ограничения на возможности фильтрации по ним.

Когда значение, по которому фильтруем - это постой скалярный тип (string, number или boolean), либо null, фильтрация работает как обычно, поскольку не нужно "понимать", какая из альтернатив, перечисленных в Union имеется в виду.

Однако, когда входящее значение более сложное - например, фильтр по вложенному объекту, то работают далеко не все варианты фильтрации. Ниже перечислены правила, по которым работает фильтрация по Union-полям для сложных входящих значений фильтра:

  • Если значение фильтра - массив, то он интерпретируется как
    • Фильтр по Tuple, если одна из альтернатив - это Tuple.
    • Фильтр по Money, если одна из альтернатив - это Money.
    • Список простых значений по правилу IN - в остальных случаях.
  • Если значение фильтра - экземпляр класса RefLink, то подставляется значение поля .id.
  • Если значение фильтра - экземпляр класса GenericLink, то подставляется фильтр по второму элементу кортежа, хранящему значение GenericLink (идентификатору) со значением поля .id. Другими словами - произойдёт фильтрация по идентификатору из GenericLink.
  • Если значение фильтра - экземпляр класса js Date, то произойдёт фильтрация с учётом семантики переданной даты-времени.
  • Если значение фильтра - экземпляр класса Money, подставится фильтр по точному соответствию суммы и валюты в формате кортежа денежной суммы.
  • Если значение фильтра - экземпляр одного из вспомогательных классов, представляющих файл, загруженный в хранилище: StorageFile, StorageImageFile, StorageVideoFile или StorageAudioFile, то вместо него подставится хеш файла из поля .hash.
  • В остальных случаях объект будет проверен - является ли он одним из поддерживаемых операторов и если является, то значение фильтра для операторов будет обработано по тем же правилам рекурсивно.

Исключение: специальное поведение для Nullable

Тип Nullable является частным случаем Union, но достаточно простым, чтобы обойти вышеперечисленные ограничения фильтрации по Union.

Если фильтр по Nullable-полю отличается от null, то поле фильтруется по правилам, соответствующим "значимому" типу. Другими словами, поле, объявленное как, Heap.Nullable(Heap.Object({ ... })) может быть отфильтровано по вложенным полям как обычно.

Это исключение касается не только Nullable, но и любого Union-типа с двумя альтернативами, одной из которых является Null.

❤️ Made with love on Chatium

ООО "Чатиум"

Информация о компании