воскресенье, 1 ноября 2009 г.

LINQ dynamic expressions. WHERE <T> IN <Filters>

В LINQ есть конструкция, которая позволяет делать SQL запрос с выражением IN. Все примеры я буду приводить на всем известной базе данных Northwind.

Например чтоб получить запрос на SQL в виде


SELECT c.* 
FROM Customers c
WHERE c.Region IN ("WA", "SP", "BC")

нужно написать на LINQ следующий код

string[] filters = new [] { "WA", "SP", "BC" };
context.Customers.Where(p => filters.Contains(p.Region));

Данная конструкция нечитабельная, лично меня она напрягает. Мне хотелось бы чтоб она выглядела более естественно, как то так:

string[] filters = new [] { "WA", "SP", "BC" };
context.Customers.Where(p => p.Region.In(filters));

Конечно так написать нельзя и данных код нескомпилируется, но сделать запрос более естественным можно. Нам помогут Expression Trees, примеры которых вы могли видеть в предыдущих статьях. Далее будет рассказано, как сделать код вида:

string[] filters = new [] { "WA", "SP", "BC" };
var customers = context.Customers.In(p => p.Region, filters);

Погуглив, я нашел пример в котором делается примерно тоже самое, только проще и с обманом. В моем случае, на выходе мы получим SQL запрос с конструкцией WHERE IN, а в примере получается … AND (c.Region = “WA” OR c.Region = “SP” …). Я думаю парень не выдержал, задача все-таки нелегкая. Я тоже понервничал пока разобрался как транслировать в конструкцию IN. Вам будет проще :)

Начнем.
Ниже приведен код “In” Extenstion method’a и подробное его описание по шагам.

public static class LinqExtensions
{
     /// <summary>
     /// Filters the specified query with input filters.
     /// </summary>
     /// <typeparam name="T">The result entity type.</typeparam>
     /// <typeparam name="TKey">The type of column to filter.</typeparam>
     /// <param name="query" />The query.</param>
     /// <param name="column" />The column.</param>
     /// <param name="filters" />The in sequence.</param>
     public static IQueryable<T> In<T, TKey>(
          this IQueryable<T> query, 
          Expression<Func<T, TKey>> column,
          IEnumerable<TKey> filters)
     {
          MethodInfo containsMethod = typeof(System.Linq.Enumerable)
               .GetMethods()
               .Where(m => m.Name == "Contains" && m.GetParameters().Length == 2)
               .Single();

          containsMethod = containsMethod.MakeGenericMethod(new[] { typeof(TKey) });

          List<expression> values = new List<expression>();
          foreach (TKey value in filters)
          {
               values.Add(Expression.Constant(value));
          }

          NewArrayExpression filtersArray = Expression.NewArrayInit(typeof(TKey), values);

          List<expression> arguments = new List<expression>();
          arguments.Add(filtersArray);
          arguments.Add(column.Body);

          MethodCallExpression containsMethodCall = Expression.Call(containsMethod, arguments.ToArray());

          LambdaExpression lambda = Expression.Lambda(containsMethodCall, column.Parameters.ToArray());

          var result = Expression.Call(
               typeof(Queryable),
               "Where",
               new Type[] { query.ElementType },
               query.Expression,
               lambda);

          return query.Provider.CreateQuery<T>(result);
     }
}

Рассмотрим подробней:

  • Сначала нам надо найти метод Contains (17-20). Поскольку этот метод является Extenstion Method, то его нужно искать в классе, в котором хранятся эти расширения. В данном случае System.Linq.Enumerable. Методов Contains два:

    Contains>TSource<(IEnumerable>TSource<, TSource)
    Contains>TSource<(IEnumerable>TSource<, TSource, IEqualityComparer>TSource<) 
    

    нам нужен [1] с двумя параметрами.
  • После того как мы его нашли, у нас будет нетипизированный метод в виде Contains[TSource], а нам нужен Contains, который работает с типом поля, которое нам передается. В данном примере мы фильтровали по Region у которого тип System.String. Делаем с общего метода, типизированный (23). В результате получаем сигнатуру вида Contains<String>(<IEnumerable<String>, String>).
  • Затем нам нужен список значений, по которым будет фильтроваться выборка. Для этого создаем набор констант (24-28) и помещаем их в массив (30). В результате мы получаем динамическое выражение вида new[] { “WA”, “BC”, “SP”}.
  • Согласно сигнатуре метода Contains [1] мы должны передать в него набор фильтров (33) и параметр-поле (34), по которым будет фильтроваться выборка. Делаем вызов метода Contains (36). В результате мы получаем динамическое выражение вида new[] { “WA”, “BC”, “SP”}.Contains(p.Region). Тут мы как раз делаем саму IN конструкцию.
  • После этого нам нужно создать лямбда выражение (38), которое будет передаваться в конструкцию Where. В результате мы получаем динамическое выражение вида p => new[] { “WA”, “BC”, “SP”}.Contains(p.Region). Входящий параметр лямбда выражения будет таким, как вы его назовете при использовании нашего “In” Exstension method. В данном примере он называется p, т.к. я делал вызов вида context.Customers.In(p => p.Region, filters).
  • Все. Нам осталось добавить в существующую цепочку вызовов наш метод Where (40-45).

Думаю вы почерпнулия для себя что-то новое и полезное.

7 комментариев:

  1. Ох как ты будешь рад узнать, что в EF 1.0 нет поддержки метода Contains()...

    ОтветитьУдалить
  2. Этот комментарий был удален автором.

    ОтветитьУдалить
  3. И кстати полученный вариант не особо то читабельнее. Одна несчастная рюшечка, т.е. "syntax sugar" по-моему усилий не стоили.

    ОтветитьУдалить
  4. Ну так оно хоть выглядит более естественно, не с ног на голову. Мне данный вариант больше нравится чем существующий, в другом случае я б не делал пост. Если предложишь другой синтаксис - я рассмотрю.

    ОтветитьУдалить
  5. //context.Customers.In(p => p.Region, filters);

    я бы заменил на:
    context.Customers
    .Property(p => p.Region)
    .In(filters);

    Так хоть читая слева на право понятно, что происходит.

    ОтветитьУдалить
  6. Не получится. После .Property мы теряем всю цепочку вызова самого IQueryable. Из данного предложения мы возвращает максимум Func<T, S>, что в результате дает нам p => p.Region, а сам запрос context.Customers... мы тут потеряем.
    Отпадает

    ОтветитьУдалить
  7. Получится, если их "пропихнуть" через свой класс, который возвращается из Property() и содержит два свойства - "сам запрос" и FuncT, S>
    рабочее решение я тебе выслал =)

    ОтветитьУдалить