Делаем фильтры «как в экселе» на ASP.NET Core

«Сделайте нам фильтры «как в экселе», — довольно популярный запрос на разработку. К сожалению, реализация запроса в общем виде «слегка» длинее, чем его лаконичная постановка. Если вдруг вы никогда не пользовались этими фильтрами, то вот пример. Основная фишка в том, что в строчке с названиям колонок появляются выпадающие списки со значениями из выбранного диапазона. Например в колонках А и B — 4000 строк и 3999 значений (первую строчку занимают названия колонок). Таким образом, в соответсвтующих выпадающих списках будет по 3999 значений. В колонке C — 220 строк и 219 значений в выпадающем списке соответственно.



ToDropdownOption


В .NET испокон веков существует прекрасный интерфейс IQuerable<T>, предоставляющий доступ к разнообразным источникам данных. Его и будем использовать. Определим метод-расширения ToDropdownOption поверх интерфейса.


public static IQueryable<DropdownOption<TValue>> ToDropdownOption<TQueryable, TValue, TDropdownOption>(
   this IQueryable<TQueryable> q,
   Expression<Func<TQueryable, string>> labelExpression,
   Expression<Func<TQueryable, TValue>> valueExpression)
   where TDropdownOption: DropdownOption<TValue>
{
   // Вызываем конструктор по умолчанию 
   // В Cache<TValue, TDropdownOption>.Constructor кешируется reflection
   var newExpression = Expression.New(Cache<TValue, TDropdownOption>.Constructor);

   // Подробнее об этой особой уличной магии здесь
   // https://habr.com/ru/company/jugru/blog/423891/#predicate-builder
   var e2Rebind = Rebind(valueExpression, labelExpression);
   var e1ExpressionBind = Expression.Bind(
       Cache<TValue, TDropdownOption>.LabelPropertyInfo, labelExpression.Body);
   var e2ExpressionBind = Expression.Bind(
       Cache<TValue, TDropdownOption>.ValuePropertyInfo, e2Rebind.Body);

   // Инициализируем значения Label и Value
   var result = Expression.MemberInit(
       newExpression, e1ExpressionBind, e2ExpressionBind);
   var lambda = Expression.Lambda<Func<TQueryable, DropdownOption<TValue>>>(
       result, labelExpression.Parameters);

   /*
   В итоге получим
   return q.Select(x => new DropdownOption<TValue>
   {
     Label = labelExpression
     Value = valueExpression
   });
   Но такой код не скомплируется,
   поэтому пришлось написть с помощью API Expression Trees
   */
   return q.Select(lambda);
}

Если код метода кажется непонятным, прочитайте расшифровку или посмотрите доклад Деревья выражений в enterprise-разработке. Станет гораздо понятнее.

Сами классы DropdownOption и DropdownOption<T> вылгядят следующим образом.


public class DropdownOption
{
   // Запрещаем программно создавать нетипизированные DropdownOption
   // за пределами сборки
   internal DropdownOption() {}

   internal DropdownOption(string label, object value)
   {
       Value = value ?? throw new ArgumentNullException(nameof(value));
       Label = label ?? throw new ArgumentNullException(nameof(label));
   }

   // Делаем свойства неизменяемыми за пределеами сборки
   public string Label { get; internal set; }

   public object Value { get; internal set; }
}

public class DropdownOption<T>: DropdownOption
{
    internal DropdownOption() {}

    // Типизированные опции создавать за пределами сборки
    public DropdownOption(string label, T value) : base(label, value)
    {
        _value = value;
    }

    private T _value;

    // Перекрываем базовое свойство типизированным
    public new virtual T Value
    {
        get => _value;
       internal set
       {
           _value = value;
           base.Value = value;
       }
    }
}

Трюк с internal-конструктором позволяет привести любой DropdownOption<T> к DropdownOption без generic-параметра, одновременно, не позволяя создавать экземпляры класса без generic-параметра за пределами сборки.


Будет здорово когда/если ковариантные возвращаемые типы будут реализованы. С ними можно избавиться от перекрытия через new. Пока имеем, что имеем.

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


public IEnumerable GetDropdowns(IQueryable<SomeData> q) =>
    q.ToDropdownOption(x => x.String, x => x.Id)

IDropdownProvider


Где вызывать этот метод расширения? Допустим, мы работаем с таким контроллером:


public IActionResult GetData(
    [FromServices] IQueryable<SomeData> q
    [FromQuery] SomeDataFilter filter) =>
    Ok(q
    .Filter(filter)
    .ToList());

Классы SomeData и SomeDataFilter определены следующим образом:


public class SomeDataFilter
{
   public int[] Number { get; set; }

   public DateTime[]? Date { get; set; }

   public string[]? String { get; set; }
}

public class SomeData
{
   public int Number { get; set; }

   public DateTime Date { get; set; }

   public string String { get; set; }
}

А метод Filter следующим образом:


public static IQueryable<SomeData> Filter(
    this IQueryable<SomeData> q,
    SomeDataFilter filter)
{
    if (filter.Number != null)
    {
        q = q.Where(x => filter.Number.Contains(x.Number));
    }

    if (filter.Date != null)
    {
        q = q.Where(x => filter.Date.Contains(x.Date));
    }

    if (filter.String != null)
    {
        q = q.Where(x => filter.String.Contains(x.String));
    }

    return q;
}

Для реальных проектов, этот метод можно сделать обобщенным Как именно описано здесь

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


public IActionResult GetSomeDataFilterDropdownOptions(
   [FromServices] IQueryable<SomeData> q)
{
   var number = q
       .ToDropdownOption(x => x.Number.ToString(), x => x.Number)
       .Distinct()
       .ToList();

   var date = q
       .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)
       .Distinct()
       .ToList();

   var @string = q
       .ToDropdownOption(x => x.String, x => x.String)
       .Distinct()
       .ToList();

   return Ok(new
   {
       number,
       date,
       @string
   });
}

Такой код может понадобится для любого типа фильтров, а не только SomeDataFilters, поэтому введем соответствующий интерфейс.


public interface IDropdownProvider<T>
{
  Dictionary<string, IEnumerable<DropdownOption>> GetDropdownOptions();
}

И перенесем код получения опций в класс, реализующий интерфейс:


public class SomeDataFiltersDropdownProvider: IDropdownProvider<SomeDataFilter>
{
   private readonly IQueryable<SomeData> _q;

   public SomeDataFiltersDropdownProvider(IQueryable<SomeData> q)
   {
       _q = q;
   }

   public Dictionary<string, IEnumerable<DropdownOption>> GetDropdownOptions()
   {
       return new Dictionary<string, IEnumerable<DropdownOption>>()
       {
           {
               "name", _q
               .ToDropdownOption(x => x.Number.ToString(), x => x.Number)
               .Distinct()
               .ToList();
           },
           {
               "date", _q
               .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)
               .Distinct()
               .ToList();           
           },
           {
               "string", _q
               .ToDropdownOption(x => x.String, x => x.String)
               .Distinct()
               .ToList();
           }
       };
   }
}

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


[HttpGet]
[Route("Dropdowns/{type}")]
public async IActionResult Dropdowns(
     string type, 
     [FromServices] IServiceProvider serviceProvider
     [TypeResolver] ITypeResolver typeResolver)
{
   var t = typeResolver(type);
   if (t == null)
   {
       return NotFound();
   }

   // Преобразование к dynamic, чтобы не париться с приведением типов.
   // T неизвестен, потому что метод контроллера не содержит дженерика.
   dynamic service = serviceProvider
       .GetService(typeof(IDropdownProvider<>)
       .MakeGenericType(t));

   if (service == null)
   {
       return NotFound();
   }

   var res = service.GetDropdownOptions();
   return Ok(res);
}


Одновременные запросы


На этом можно было бы и закончить, но, как говорится, есть нюанс. В примере сверху запросы к БД выполняются последовательно, хотя они не зависят друг от друга. Чем больше колонок с фильтрами, тем больший выигрыш можно получить за счет параллельного выполнения запросов. Реализации IQueryable чаще всего базируются на той или иной ORM, а реализации Unit Of Work ORM часто не потокобезопасны (иначе слишком сложно было бы реализовать change tracking). Поэтому будем использовать отдельные области видимости (scope) ServiceProvider и асинхронные версии методов.


public static async Task<TResult> InScopeAsync<TService, TResult>(
    this IServiceProvider serviceProvider,
    Func<TService, IServiceProvider, Task<TResult>> func)
{
    using var scope = serviceProvider.CreateScope();
     return await func(
        scope.ServiceProvider.GetService<TService>(),
        scope.ServiceProvider);
}

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


public async Task<Dictionary<string, IEnumerable<DropdownOption>>>
   GetDropdownOptionsAsync()
{
    var dict = new Dictionary<string, IEnumerable<DropdownOption>>();
    var name = sp.InScopeAsync<IQueryable<SomeData>>(q => q
        .ToDropdownOption(x => x.Number.ToString(), x => x.Number)
        .Distinct()
        .ToListAsync());

    var date = sp.InScopeAsync<IQueryable<SomeData>>(q => q
        .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)
        .Distinct()
        .ToListAsync());   

    var @string = sp.InScopeAsync<IQueryable<SomeData>>(q => q
        .ToDropdownOption(x => x.String, x => x.String)
        .Distinct()
        .ToListAsync());

    // Теперь все запросы выполняются параллельно
    await Task.WhenAll(new []{name, date, @string}});
    dict["name"] = await name;
    dict["date"] = await date;
    dict["string"] = await @string;
    return dict;
}

Осталось прибрать код, устранить дублирование и предоставить более удобное API. Для этого хорошо подойдет шаблон проектирования строитель. Я опущу детали реализации. Пытливый читатель наверняка сможет спроектировать аналогичный API самостоятельно.


public async Task<Dictionary<string, IEnumerable<DropdownOption>>>
    GetDropdownOptionsAsync()
{
     return sp
        .DropdownsFor<SomeDataFilters>

        .With(x => x.Number)
        .As<SomeData, int>(GetNumbers)

        .With(x => x.Date)
        .As<SomeData, DateTime>(GetDates)

        .With(x => x.String)
        .As<SomeData, string>(GetStrings)
}
@marshinov
18.02.2021 02:00 UTC
Первоисточник

Комментарии

@somurzakov
18.02.2021 03:13 UTC
-3
ух ты, интересная и очень долгая имплементация «SELECT DISTINCT columname» используя новейшие фичи из C# 8.0
@dopusteam
18.02.2021 04:26 UTC
0

++ непонятно в чем, собственно, проблема и зачем так усложнять всё

18.02.2021 05:57 UTC
+1

Давайте посмотрим как сделать проще. Представьте, что фильтров таких у вас в системе сотни, а программистов работают десятки.


  • Класс DropdownOption понадобится в любом случае для сериализации
  • Инициализаторы полей нужны, потому что в случае использования конструктора в проекции .Select(x => new DropdownOption(/*...*/)) не будет работать сортировка .OrderBy(/*...*/).
  • Оставить публичные { get; set; } поля можно, но для таких конструкций q.ToList().Select(x => new DropdownOption(/*...*/) {/*...*/}) не получится гарантировать инициализацию полей Label и Value. О проблемах частичной инициализации вы можете прочитать здесь
  • Использование .ToDropDownOption в связке с типобезопасным конструктором позволяет гарантировать верную инициализацию объекта в обоих сценариях: IQueryable и IEnumerable соответственно
  • «SELECT DISTINCT columname» где-то нужно написать и передать на фронтенд. Каждый разработчик будет писать метод самостоятельно? Не DRY: удачи с поддержкой консистентного API.
  • Если в таблице 10-20 колонок и по каждому нужен фильтр, то параллельное выполнение уже не кажется такой плохой идеей
  • Чтобы выполнить запросы параллельно потребуется по экземпляру DbContext/ISession/Другая абстракция для работы с бд
  • Строить параллельные запросы самому каждый раз хлопотно, вот вам строитель. Предложите вариант проще, я его с радостью буду использовать.
18.02.2021 06:14 UTC
0
Начал писать ответ, понял, что не понимаю, чего Вы пытаетесь добиться.
Обозначьте проблему, почему обычный

DbContext
.Items
.Select(item => item.Date)
.Distinct()
.OrderBy(date => date)
.Select(date=> new DropDownOption(date, date.ToString('d')))
.ToList()


Обёрнутый в метод сервиса\репозитория\ещё кого то не решает задачу?
18.02.2021 07:53 UTC
0

Вы проигнорировали все пункты моего предыдущего комментария, начиная с пятого. Обозначаю проблемы, указанные в пятом пункте еще раз в явном виде: без дополнительных абстракций будет много повторов и неконсистентное api. IDropdownProvider<T> — это и есть "обертка" в вашей терминологии. Отдельный обобщенный интерфейс лучше, чем горсть репозиториев/сервисов, потому что один интерфейс лучше, чем пачка.

@brager17
19.02.2021 20:12 UTC
+2
Будет здорово когда/если ковариантные возвращаемые типы будут реализованы. С ними можно избавиться от перекрытия через new. Пока имеем, что имеем.


Способ на самом деле удобный. Кажется, что ковариантные возвращаемые значения все-равно не спасут, потому что у свойства еще сеттер есть. Его вроде можно сделать private/protected, если кроме EF Core никто не будет его использовать.
@marshinov
20.02.2021 11:40 UTC
0
public interface IDropdownOption
{
    public object Value { get; }  

    public string Label { get; }  
}

public class DropdownOption<T>: IDropdownOption
{
    public TValue Value { get; internal init; }  

    public string Label { get; internal init; }  

    public DropdownOption(T value, string label)
    {
        Label = label;
        Value = value;
    }
}

Как-то так, видимо, будет

@morozyan
23.02.2021 07:50 UTC
+1
Интересное решение, спасибо за статью.
Не совсем понятно, зачем нужен generic DropdownOption? Кажется, что если все данные отдаются на фронт, то T не используется, и можно обойтись DropdownOption.

А как обстоят дела с перфомансом? Где границы применимости данного решения, при переходе которых база будет загружена (количество пользователей, одновременно запрашивающих фильтры/ количество колонок в таблице / количество записей в таблице)?

В примере GetDropdownOptionsAsync возвращает все доступные фильтры для таблицы.
Насколько усложнится код, если нужны разные наборы фильтров для одной таблицы? Например, из-за разных прав доступа.
@marshinov
23.02.2021 08:04 UTC
+1
Не совсем понятно, зачем нужен DropdownOption? Кажется, что если все данные отдаются на фронт, то T не используется, и можно обойтись DropdownOption.

Если LINQ вида q.Where(/**/).ToDropdownOption(/**/) нельзя использовать, потому что не транслируется или слишком тормозит, есть вариант получить данные без LINQ. Тогда сигнатура метода будет что-то вроде Task<IEnumerable<DropdownOption>>. При этом тип T мы скорее всего знаем. Поэтому базовый класс без T используется только для полиморфизма, а в методе получения данных используется типобезопасная версия.


А как обстоят дела с перфомансом? Где границы применимости данного решения, при переходе которых база будет загружена (количество пользователей, одновременно запрашивающих фильтры/ количество колонок в таблице / количество записей в таблице)?

Границы определяет стресс-тестирование на целевом железе. Здесь скорее вопрос usability. Если в каждом фильтре по 1000 значений, то гнать по сети дропдауны не лучшая затея. В этом случае нужно добавлять автокомплиты.


В примере GetDropdownOptionsAsync возвращает все доступные фильтры для таблицы.
Насколько усложнится код, если нужны разные наборы фильтров для одной таблицы? Например, из-за разных прав доступа.

Можно разграничить права вот так. В этом случае в код статьи не нужно будет вообще вносить изменений.