TL;DR

Используй JSON для создания LINQ-выражений!

Содержание

  1. Решаемая проблема

  2. Почему не GraphQL

  3. Решение проблемы – JsonToLinq

  4. Преимущества JsonToLinq

  5. Как использовать JsonToLinq

  6. Операторы

  7. Конфигурация JsonToLinq

  8. Тестирование JsonToLinq

  9. Демонстрации и эксперименты

  10. Ограничения JsonToLinq

  11. Потенциал JsonToLinq

  12. Поддержать проект

Решаемая проблема

Существует категория проектов, UI которых на 90-100% состоит из гридов. Со временем пользователи предъявляют все новые требования к колонкам, связям между столбцами, ограничению доступа и поиску данных. Раньше, когда в моде был ASP.NET MVC, мы использовали Telerik for MVC, в котором из коробки доступен отличный функционал для гридов. После перехода к раздельной разработке API + SPA (Angular/React/Vue) для подобных сайтов появилась проблема управления данными с клиента: функционал обработки фильтров стал занимать большую часть инфраструктуры, а с увеличением UX все больше времени стало уходить на его поддержку.

Мы стали разрабатывать UI на Angular, используя Telerik for Angular, который имеет мощный и гибкий функционал для работы с гридами. Фильтры колонок автоматически синхронизируются и связываются в единый объект на TS. Однако управление через RxJS/NgRx требовало их дополнительной модификации перед отправкой на сервер. Универсального метода на C# для их разбора не было, из-за чего каждый фильтр проходил собственную цепочку преобразований и валидации перед применением в EF. Трудоемкость по сравнению с ASP.NET MVC выросла в разы, а серверная часть фильтрации оказалась жестко связана со спецификой фильтров Telerik. После того как наша компания запретила использование Telerik, мы были вынуждены перейти на Material Design. Эта библиотека содержит лишь базовые визуальные компоненты и не предоставляет инфраструктуры уровня Telerik, Prime, AG Grid или Syncfusion. Реализация сложных гридов на ее основе потребует разработки собственного механизма фильтрации с нуля. Что же делать с нашими тяжелыми гридами?..

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

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

  2. Чтобы текстовые выражения оставались читаемыми и не оформлялись каждым разработчиком по-своему, потребуется разработать правила форматирования. Но даже с выравниванием такой текст сложно воспринимать без подсветки синтаксиса. Его накопление в системе будет усложнять рефакторинг, поддержку и снижать привлекательность проекта для новых членов команды.

  3. Составление и разбор подобных строк потребуют отдельного функционала для валидации, экранирования значений и обработки ошибок.

  4. Front-End приложения не должны зависеть от конкретных серверных технологий вроде LINQ, иначе это нарушает разделение ответственности между клиентом и сервером. Ожидать от Front-End разработчиков знания LINQ и требовать от них реализации функционала по созданию таких текстовых выражений на JS/TS нецелесообразно и выходит за рамки их ответственности.

По этим же причинам не подходит и OData.

Зато все знают JSON!

Если черный ящик поддерживает входные сигналы в форме JSON, то им могут пользоваться самые разные клиенты!

Вот так и зародилась идея абстрагироваться и бэкам, и фронтам от этой рутины, вынеся ее в отдельный сервис, который будет автоматически транслировать любой валидный JSON в Expression tree.

Почему не GraphQL

GraphQL имеет готовый функционал фильтрации, но не является универсальным решением:

  1. GraphQL не использует стандартный JSON, требуя знания дополнительного синтаксиса. Это не только осложняет разработку, но и ухудшает интерфейс системы, повышая порог интеграции для клиентов – аналогично текстовым LINQ-выражениям и OData.

  2. GraphQL обеспечивает типобезопасность и IntelliSense запросов через схему (.graphql), которая зависит от серверной модели. Любое изменение модели требует обновления схемы, усложняя поддержку и усиливая связанность клиента с сервером.

Решение проблемы – JsonToLinq

Для решения этой проблемы была создана .NET-библиотека JsonToLinq, которая конвертирует фильтры из JSON в LINQ-выражения напрямую без внешних зависимостей.

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

  1. Дружелюбность

    JSON – простой и широко известный формат, легко воспринимаемый не только разработчиками.

  2. Широта применения

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

  3. Универсальность

    Чтобы получить данные из .NET-приложения с EF, нужно лишь запросить их в формате JSON. Это делает его доступным для любого клиента, который знает названия и типы полей.

  4. Независимость клиента и сервера

    Для создания фильтров клиенту достаточно знать лишь названия и типы полей данных, что обычно уже есть в DTO.

  5. Гибкость

    Из коробки доступно добавление своих операторов, а сам функционал может послужить ядром для других проектов – например, для создания аналога HotChocolate со стандартным JSON и другими особенностями.

  6. Простота

    Установи NuGet-пакет и вставляй JSON-фильтры в Where() и другие методы.
    Это подключает не минимальный, а полный функционал фильтрации!

    Не нужно:

    1. Регистрировать что-либо в Program.cs.

    2. Создавать схему, отдельные DTO, фильтры, резолверы и пр.

    3. Генерировать схему для клиента.

    Все необходимое для фильтрации уже есть в DTO!

Как использовать JsonToLinq

  1. Установить NuGet-пакет.

  2. Передавать текст JSON внутрь Where() или других фильтрующих методов.

  3. Для использования с IQueryable сначала создать предикат через JsonLinq.ParseFilterExpression().

Быстрый старт

using Neomaster.JsonToLinq;

var users = source.Where(
  """
  {
    "Logic": "&&",
    "Rules": [
      { "Field": "balance", "Operator": "=", "Value": 0 },
      { "Field": "status", "Operator": "in", "Value": [ 1, 3 ] },
      { "Field": "country", "Operator": "as lower contains", "Value": "islands" },
      {
        "Logic": "||",
        "Rules": [
          { "Field": "lastVisitAt", "Operator": "=", "Value": null },
          { "Field": "lastVisitAt", "Operator": "<=", "Value": "2026-01-01T00:00:00Z" }
        ]
      }
    ]
  }
  """);

Аналогичный запрос на LINQ:

var users = source.Where(u =>
  (u.Balance == 0
  && new[] { 1, 3 }.Contains(u.Status)
  && u.Country.ToLower().Contains("islands"))
  &&
  (u.LastVisitAt == null
  || u.LastVisitAt <= JsonSerializer.Deserialize<DateTime?>("\"2026-01-01T00:00:00Z\"")));

Операторы

  1. Встроенные операторы и логика их обработки инкапсулированы в классе ExpressionOperatorMapper.

  2. Таблица соответствия операторов методам обработки доступна через свойство ExpressionOperatorMapper.Pairs.

  3. Ключи операторов чувствительны к регистру.

Встроенные операторы

Оператор

Описание

&

Побитовое И

|

Побитовое ИЛИ

&& / and

Логическое И

|| / or

Логическое ИЛИ

= / eq

Равно

!= / neq

Не равно

> / gt

Больше

>= / gte

Больше или равно

< / lt

Меньше

<= / lte

Меньше или равно

in

В коллекции

as lower in

Элемент в нижнем регистре в коллекции

as upper in

Элемент в верхнем регистре в коллекции

contains

Содержит подстроку

as lower contains

Элемент в нижнем регистре содержит подстроку

as upper contains

Элемент в верхнем регистре содержит подстроку

starts with

Начинается с

as lower starts with

Элемент в нижнем регистре начинается с

as upper starts with

Элемент в верхнем регистре начинается с

ends with

Заканчивается на

as lower ends with

Элемент в нижнем регистре заканчивается на

as upper ends with

Элемент в верхнем регистре заканчивается на

Отрицательные операторы

  • !in / not in

  • ! as lower in

  • ! as upper in

  • !contains

  • ! as lower contains

  • ! as upper contains

  • ! starts with

  • ! as lower starts with

  • ! as upper starts with

  • ! ends with

  • ! as lower ends with

  • ! as upper ends with

Слово not

Слово not – синоним !.

Оно облегчает читаемость, но подходит не для всех операторов:

  1. Операторы со словами будут еще длиннее.

  2. Употребление с not не всегда будет грамматически верным.

    not contains
    does not contain

Унарные операторы

Встроенных унарных операторов (!, !&&, is null, is not empty и пр.) пока нет. При необходимости их можно добавить самостоятельно. Возможно, они появятся в будущих версиях.

Транслируемость в SQL

Все встроенные операторы спроектированы так, чтобы их можно было напрямую передавать LINQ-провайдерам (например, EF) без промежуточной обработки.

Регистр и нормализация строк

  1. Сравнение строк чувствительно к регистру и зависит от collation в БД, а не от операторов.

  2. Регистр строковых значений в фильтре никогда не меняется автоматически.

  3. Нормализация строк – ответственность клиента.

  4. Операторы с as lower / as upper изменяют регистр проверяемого элемента перед вычислением, не трогая значения в фильтре. Значения в фильтре всегда используются так, как указаны в JSON.

Коллекции в фильтре

  1. Коллекция в фильтре может быть пустой, но не null.

  2. Коллекция в фильтре и ее элементы никогда не изменяются автоматически.

  3. Операторы с as lower / as upper изменяют регистр каждого элемента исходной коллекции перед вычислением, не трогая коллекцию в фильтре.

Добавить свои операторы

Только свои

JsonLinq.Configure(options =>
{
  options.OperatorMapper = new ExpressionOperatorMapper()
    .Add("=", Expression.Equal)
      .WithAliases("eq", "EQ")
    .AddNot("!=", "=")
      .WithAliases("neq", "NEQ")
    .AddAlias("==", "=")
      .WithNot("<>");
});

Расширить набор встроенных

JsonLinq.Configure(options =>
{
  options.OperatorMapper = ExpressionOperatorMapper.OnDefault()
    .Add(...
});

Отрицательные операторы

Отрицательные (инверсные) операторы можно задавать без явного указания ключа.
В этом случае ключ генерируется автоматически через NegatedKeyProvider.
Этот провайдер можно задать через метод SetNegatedKeyProvider().

new ExpressionOperatorMapper()

// Провайдер по умолчанию
.Add("a", ...).WithNot()   // "!a"
.Add("b c", ...).WithNot() // "! b c"

// Свой провайдер
.SetNegatedKeyProvider(key => (key.Contains(' ') ? "~ " : "~") + key)
.Add("x", ...).WithNot()   // "~x"
.Add("y z", ...).WithNot() // "~ y z"

SQL-операторы

Библиотека реализована на netstandard2.1 и не зависит от EF или других ORM.
Для сравнения строк без учета регистра используйте встроенные операторы с as lower / as upper. Они отличаются только предпочтительным регистром значений в фильтре.

JsonLinq.Configure(options =>
{
  options.OperatorMapper = ExpressionOperatorMapper.OnDefault()
    .Add("like", (element, pattern) =>
      Expression.Call(
        typeof(DbFunctionsExtensions).GetMethod(
          nameof(DbFunctionsExtensions.Like),
          [typeof(DbFunctions), typeof(string), typeof(string)]),
        Expression.Constant(EF.Functions),
        element,
        pattern))
    .Add("ilike", (element, pattern) =>
      Expression.Call(
        typeof(NpgsqlDbFunctionsExtensions).GetMethod(
          nameof(NpgsqlDbFunctionsExtensions.ILike),
          [typeof(DbFunctions), typeof(string), typeof(string)]),
        Expression.Constant(EF.Functions),
        element,
        pattern));
});

Конфигурация JsonToLinq

Вы можете задавать свои настройки через JsonLinq.Configure().
Для сброса вызывается JsonLinq.ResetConfiguration(), что необходимо при тестировании.

JsonLinq.Configure(options =>
{
  // Имена свойств в JSON-фильтре
  options.LogicOperatorPropertyName = "?";
  options.RulesPropertyName = "⚖️";
  options.OperatorPropertyName = "⚡";
  options.FieldPropertyName = "?";
  options.ValuePropertyName = "?";

  // Определения и синонимы операторов
  options.OperatorMapper = ExpressionOperatorMapper.OnDefault()
    .AddAlias("does not contain", "!contains");

  // Логика обработки выражений с null
  options.BindBuilder = ExpressionBindBuilders.NullAsFalse;
  
  // Как имена свойств C# будут выглядеть в JSON
  options.ConvertPropertyNameForJson = JsonNamingPolicy.SnakeCaseUpper.ConvertName;
});

Актуальными кажутся настройки структуры JSON-фильтра.
Например, добавлять сахар:
"Logic": "&&", "Rules": [...] -> "&&": [...].
Или фиксировать порядок свойств для использования TONL-фильтров.
Возможно, это будет реализовано в будущих версиях.

Тестирование JsonToLinq

Юнит-тесты покрывают:

  1. Все, что задействовано в разборе JSON-фильтров

  2. Операторы с кастомными выражениями (in, contains и др.)

  3. Методы конфигурации

  4. Методы расширения IEnumerable

Полное покрытие юнит-тестами будет актуально после обкатки библиотеки в реальных проектах.

Демонстрации и эксперименты

Репозиторий содержит проект JsonToLinq.Demo с рабочими примерами. Это удобно для обратной связи и взаимодействия -
вы можете сделать PR со своими демонстрациями интересных примеров, багов или новых возможностей.

Для обозначения фильтров используется специальная нотация.

Нотация фильтра

Синтаксис

expr = logic[expr(, expr)*]
  • expr - одно условие или комбинация условий

  • logic - оператор для комбинирования условий, например, &&, &, ||, |, или свой собственный

Примеры

  1. &&[x = null]

  2. &&[a < 0, b > 0]

  3. &&[x = null, ||[a < 0, b > 0]]

Демонстрационный проект

Проект содержит примеры работы библиотеки с EF и PostgreSQL.

  1. Реальная БД используется вместо in-memory хранилища для чистоты и реалистичности экспериментов.

  2. Вы можете добавить свои примеры с другими БД и ORM через PR.

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

Демонстрационный проект
Демонстрационный проект

Ограничения JsonToLinq

  1. IDE не подсвечивает синтаксис аргумента с JSON-фильтром в методах LINQ

    Библиотека реализована на netstandard2.1, который не поддерживает StringSyntaxAttribute.
    Нужно быть внимательным при написании фильтров вручную.
    На практике это не критично, т.к. фильтры будут прилетать из клиентских приложений.

  2. Нет IntelliSense для написания JSON-фильтров

    Без подсказок труднее контролировать правильность фильтров.

  3. Не поддерживаются поля агрегатов

    Пока рекомендуется использовать плоские DTO или DB view.

    {
      "Rules": [
        { "Field": "✅ department_id", "Operator": "=", "Value": 123 },
        { "Field": "❌ department.id", "Operator": "=", "Value": 123 }
      ]
    }
    

Возможно, все это будет реализовано в следующих версиях...

Потенциал JsonToLinq

Фильтрация – только первая стадия развития инфраструктуры JSON-LINQ.
Что может быть дальше:

  1. Выборка данных – JSON для Select()

  2. Группировка данных – JSON для GroupBy()

  3. Движок GraphQL со стандартным JSON (в отличие от HotChocolate)

  4. Серверные аналоги RxJS/NgRx для реактивной обработки данных

  5. Интерактивные студии построения запросов – визуальные конструкторы с canvas, drag-n-drop, flowcharts, node-based UI

  6. Семантический поиск и NLP

  7. Новый стандарт обмена данными между сервисами

Поддержать проект

Вы можете поддержать развитие проекта одним из способов:

  1. ⭐ Поставить звезду на GitHub

  2. Поделиться статьей с коллегами и друзьями

  3. Сообщить о багах или предложить улучшения

  4. Сделать PR в JsonToLinq.Demo с демонстрацией примера, бага или новой фичи

Комментарии (2)


  1. Evgeniy_Van
    01.01.2026 17:36

    Мне кажется вы изобрели велосипед, есть проект ucast - UCAST - Universal Conditions AST
    - GitHub - stalniy/ucast: Conditions query translator for everything
    - GitHub - open-policy-agent/ucast-linq: UCAST integration for LINQ. Uses LINQ Expression ASTs for efficient queries.


  1. dprav
    01.01.2026 17:36

    Даже если вы создали велосипед, можно продолжать улучшать свой проект и однажды ты поймешь, что ты создашь конкурента. Этот путь тернист и для сильных людей. Нужно приложить немало усилий и идти до конца.
    Удачи вам.