Тонкости Queryable Provider


Queryable Provider не справляется вот с этим:


 var result = _context.Humans
                      .Select(x => $"Name: {x.Name}  Age: {x.Age}")
                      .Where(x => x != "")
                      .ToList();

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


 var result = _context.Humans
                      .Select(x => "Name " +  x.Name + " Age " + x.Age)
                      .Where(x => x != "")
                      .ToList();

Особенно болезненно править баги после включение ClientEvaluation(исключениe при вычислении на клиенте), все профайлы автомаппера должны быть подвергнуты жесткому анализу, на поиск этой самой интерполяции. Давайте разберемся в чем дело и предложим свое решение проблемы


Исправляем


Интерполяция в Expression Tree транслируется так(это результат, метода ExpressionStringBuilder.ExpressionToString, он опустил некоторые узлы, но для нас это
не фатально):


// для x.Age требуется boxing
Format("Name:{0} Age:{1}", x.Name, Convert(x.Age, Object)))

Либо так, когда аргументов больше 3


Format("Name:{0} Age:{1}", new [] {x.Name, Convert(x.Age, Object)))

Можно сделать вывод, что провайдер просто не научили обрабатывать такие кейсы, но его могли научить сводить эти кейсы к старому доброму ToString(), который разбирается вот так:


((("Name: " + x.Name) + " Age: ") + Convert(x.Age, Object)))

Я хочу написать Visitor который будет идти по Expression Tree, а именно по узлам MethodCallExpression и заменить метод Format на конкатенацию. Если вы знакомы с Expression Trees, то знаете, что C# предлагает нам свой visitor для обхода дерева — ExpressionVisitor, для тех кто не знаком будет интересно.


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


Давайте разобьем задачу на несколько частей:


  1. Определить, что в VisitMethodCall "пришел" именно метод Format
  2. Заменить этот метод на конкатенацию строк
  3. Обработать все перегрузки метода Format, которые могут быть получены
  4. Написать Extension метод в котором будет вызывать наш visitor

Первая часть достаточно проста, у метода Format 4 перегрузки, которые будут построены
в Expression tree


 public static string Format(string format, object arg0)  
 public static string Format(string format, object arg0,object arg1)  
 public static string Format(string format, object arg0,object arg1,object arg2)
 public static string Format(string format, params object[] args)

Достанем используя рефлексию их MethodInfo


private IEnumerable<MethodInfo> FormatMethods =>
            typeof(string).GetMethods().Where(x => x.Name.Contains("Format"))

//первые три
private IEnumerable<MethodInfo> FormatMethodsWithObjects => 
   FormatMethods
         .Where(x => x.GetParameters()
         .All(xx=> xx.ParameterType == typeof(string) || 
                        xx.ParameterType == typeof(object))); 

//последний
private IEnumerable<MemberInfo> FormatMethodWithArrayParameter => 
   FormatMethods
        .Where(x => x.GetParameters()
                              .Any(xx => xx.ParameterType == typeof(object[])));

Класс, теперь мы можем определить, что метод Format "пришел" в MethodCallExpression.


При обходе дерева в VisitMethodCall могут "прийти":


  1. Метод Format с object аргументами
  2. Метод Format с object[] аргументом
  3. Не метод Format вовсе

Немного кастомного Pattern Maching

Пока условия всего 3 можно разрулить все с помощью if, но мы, предполагая, что в будущем нам придется расширять этот метод вынесем все кейсы в такую структуру данных:


 public class PatternMachingStructure
 {
    public Func<MethodInfo, bool> FilterPredicate { get; set; }
    public Func<MethodCallExpression, IEnumerable<Expression>> 
                                       SelectorArgumentsFunc { get; set; }
    public Func<MethodCallExpression, IEnumerable<Expression>, Expression> 
                                       ReturnFunc { get; set; }
 }

var patternMatchingList = new List<PatternMachingStructure>()

С помощью FilterPredicate определим с каким из 3 кейсов мы имеем дело SelectorArgumentFunc нужен для того, чтобы привести аргументы метода Format к единообразному виду, ReturnFunc метод, который вернет нам новый Expression.


Теперь попробуем заменить представление интерполяции на конкатенацию, для этого будем использовать такой метод:


private Expression InterpolationToStringConcat(MethodCallExpression node,
            IEnumerable<Expression> formatArguments)
{
  //выбираем первый аргумент
  //(example : Format("Name: {0} Age: {1}", x.Name,x.Age) -> 
  //"Name: {0} Age: {1}"
  var formatString = node.Arguments.First();
  // проходим по паттерну из метода Format и выбираем все 
  // строки между аргументами передаем их методу ExpressionConstant
  // example:->[Expression.Constant("Name: "),Expression.Constant(" Age: ")]
  var argumentStrings = Regex.Split(formatString.ToString(),RegexPattern)
                             .Select(Expression.Constant);
  // мерджим их со значениями formatArguments
  // example ->[ConstantExpression("Name: "),PropertyExpression(x.Name),
  // ConstantExpression("Age: "),
  // ConvertExpression(PropertyExpression(x.Age), Object)]
  var merge = argumentStrings.Merge(formatArguments, new ExpressionComparer());
  // склеиваем так, как QueryableProvider склеивает простую конкатенацию строк
  // example : -> MethodBinaryExpression 
  //(("Name: " + x.Name) + "Age: " + Convert(PropertyExpression(x.Age),Object))
  var result = merge.Aggregate((acc, cur) =>
                    Expression.Add(acc, cur, StringConcatMethod));
  return result;
 }

InterpolationToStringConcat будет вызываться из Visitor'a, он спрятан за ReturnFunc
(когда node.Method == string.Format)


protected override Expression VisitMethodCall(MethodCallExpression node)
{
  var pattern = patternMatchingList.First(x => x.FilterPredicate(node.Method));
  var arguments = pattern.SelectorArgumentsFunc(node);
  var expression = pattern.ReturnFunc(node, arguments);
  return expression;
}

Теперь мы должны написать логику для обработки разных перегрузок метода Format, она достаточно тривиальна и находится в patternMachingList


patternMatchingList = new List<PatternMachingStructure>
{
    // первые три перегрузки Format
   new PatternMachingStructure
   {
        FilterPredicate = x => FormatMethodsWithObjects.Contains(x),
        SelectorArgumentsFunc = x => x.Arguments.Skip(1),
        ReturnFunc = InterpolationToStringConcat
    },
    // последняя перегрузка Format, принимающая массив
    new PatternMachingStructure
    {
        FilterPredicate = x => FormatMethodWithArrayParameter.Contains(x),
        SelectorArgumentsFunc = x => ((NewArrayExpression) x.Arguments.Last())
                                                            .Expressions,
        ReturnFunc = InterpolationToStringConcat
     },
     // node.Method != Format
    new PatternMachingStructure()
    {
        FilterPredicate = x => FormatMethods.All(xx => xx != x),
        SelectorArgumentsFunc = x => x.Arguments,
         ReturnFunc = (node, _) => base.VisitMethodCall(node)
     }
};

Соответственно в методе VisitMethodCall мы будем проходить по этому листу до первого положительного FilterPredicate, далее преобразовывать аргументы (SelectorArgumentFunc) и выполнять ReturnFunc.


Напишем Extention, вызывая который мы сможем заменять интерполяцию.


Мы можем получить Expression, передать его нашему Visitor'у, а потом вызвать метод интерфейса IQuryableProvider CreateQuery, который подменит оригинальное дерево выражений нашим:


public static IQueryable<T> ReWrite<T>(this IQueryable<T> qu)
{
  var result = new InterpolationStringReplacer<T>().Visit(qu.Expression);
  var s = (IQueryable<T>) qu.Provider.CreateQuery(result);
  return s; 
}

Обратите внимание на Cast qu.Provider.CreateQuery(result) имеющего тип IQueryable в IQueryable, это вообще стандартная практика для c#(посмотрите на IEnumerable), она возникла из-за необходимости обрабатывать все generic интерфейсы в одном классе, который хочет принять IQueryable/IEnumerable, и обработать его используя общие методы интерфейса.
Этого можно было бы избежать, приведением T к базовому классу, это возможно с помощью ковариантности, но она тоже накладывает некоторые ограничения на методы интерфейса (подробнее про это будет в следующей статье).


Итог


Применим ReWrite к выражению в начале статьи


 var result = _context.Humans
                      .Select(x => $"Name: {x.Name}  Age: {x.Age}")
                      .Where(x => x != "")
                      .ReWrite()
                      .ToList();
// correct
// [Name: "Piter" Age: 19]

GitHub

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


  1. AgentFire
    03.03.2019 22:55

    Ого, наконец-то годная статейка.


    Надо полагать, автор заодно захочет похвастаться знанием, как переопределить штатный QueryProvider у старого доброго Linq-2-Sql? Чтобы это добро все у меня заработало из коробки.


    1. brager17 Автор
      04.03.2019 09:40

      Я не думаю, что можно переопределить штатный провайдер, вы можете написать обертку для IQueryable которая при вызове ToList/First/Count/… будет переписывать оригинальный Expression, и в этой обертке добавлять часть общих бизнес правил, если тема представляет интерес, сделаю статью, потому что в реализации есть подводные камни


      1. AgentFire
        04.03.2019 13:29

        пожалуйста, будьте добры.


  1. mike114
    04.03.2019 00:42
    +1

    Для тех, кто не совсем в теме, можете пояснить что значит «не справляется»? Выдает неправильный результат? В общем, было бы здорово увидеть что ожидается и что получается. И зачем там проверка на пустую строку?


    1. AgentFire
      04.03.2019 01:10

      штатный провайдер просто не умеет конвертировать вызовы к Format в нужную форму для обращения к БД


      1. mike114
        04.03.2019 01:35

        Он не может понять, что нужно выбрать всего два поля и вместо этого выбирает все поля? Или просто ничего не выбирает? А зачем проверка на пустую строку?


        1. AgentFire
          04.03.2019 01:36

          он в принципе не умеет работать с string.Format. а это именно то, во что разворачивается интерполирование.


          1. mike114
            04.03.2019 02:47
            +1

            Я так и не понял что будет, если оставить пример как есть, ошибка рантайма, полностью неверный запрос в базу или верный, но просто неоптимальный.


            1. FreeBa
              04.03.2019 04:13

              Будет ошибка рантайма — EF (да и весь IQueryable) вобще капризный зверек когда дело касается вызова внешних методов в лямбдах (даже если они часть дотнета).


    1. brager17 Автор
      04.03.2019 10:17

      Одна из задач Queryable Provider'a транслировать запрос из Expression Tree(который строится, когда вы пишете LINQ к интерфейсу IQueryable) в SQL и делать запрос к базе данных.

      Но тут есть проблемы:
      1) Не весь LINQ будет транслирован в Expression Tree, например такой не будет:

        context.Select(x = > {
           if(x.Age < 18)
              return "Underage";
           else
             return "Adult";
         }
      

      2) LINQ который удастся транслировать в Expression Tree, может быть не транслируем в SQL, в таких случаях EF Core 2.1 сделает запрос к базе данных с тем SQL запросом, который смог транслировать, а остальное обработает в памяти, но это поведение можно переопределить и он станет падать каждый раз, когда не может полностью разобрать Expression Tree.

      Кстати ReSharper выделяет самые очевидные «проблемные» места запроса.


  1. AxeLWeaver
    04.03.2019 10:24
    -1

    x => x != ""

    Серьёзно?
    а как же
    string.IsNullOrEmpty(x)

    ?


  1. mayorovp
    04.03.2019 10:39

    Ваше решение подвержено проблеме с методом Include:


    _context.Humans
      .Select(x => $"Name: {x.Name}  Age: {x.Age}")
      .Include(x => x.Foo) // Теперь выражение, переданное в Select, спрятано внутри константы
      .ReWrite() // И ReWrite его не видит

    При этом решение этой проблемы осложнено тем, что каждый DbSet в EF — это ConstantExpression ссылающийся сам на себя. Решается эта проблема как-то так:


        private readonly HashSet<IQueryable> visiting = new HashSet<IQueryable>();
    
        protected override Expression VisitConstant(ConstantExpression node)
        {
            var value = node.Value as IQueryable;
            if (value == null) return node;
    
            if (!visiting.Add(value)) return node;
            try
            {
                var expr = value.Expression;
                var newExpr = Visit(expr);
                if (expr == newExpr) return node;
    
                var newValue = value.Provider.CreateQuery(newExpr);
                return Expression.Constant(newValue, node.Type);
            }
            finally
            {
                visiting.Remove(value);
            }
        }


  1. crocodile2u
    04.03.2019 14:33

    Позанудствую… не помешала бы пара строк в начале, о том для кого статья и что мы из нее узнаем. Да, есть теги, и секунд за 20 понимаешь, что это про C#, которого ты в глаза никогда не видел, но все же..


  1. 96467840
    04.03.2019 17:00
    -2

    имхо, такие задачи стоит решать на expression trees я как-то делал фильтрацию с его помощью для замены рефлексии (саму задачу уже не помню)