Programming
.NET
C#
March 3

Помогаем Queryable Provider разобраться с интерполированными строками

From Sandbox

Тонкости 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<T>, это вообще стандартная практика для c#(посмотрите на IEnumerable<T>), она возникла из-за необходимости обрабатывать все 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


+37
5k 47
Comments 18
Top of the day