Сделайте нам фильтры как в экселе, довольно популярный запрос на разработку. К сожалению, реализация запроса "слегка" длинее, чем его лаконичная постановка. Если вдруг вы никогда не пользовались этими фильтрами, то вот пример. Основная фишка в том, что в строчке с названиям колонок появляются выпадающие списки со значениями из выбранного диапазона. Например в колонках А и 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); // Подробнее об этой особой уличной магии здесь // http://personeltest.ru/aways/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)}