Ни для кого не секрет, что хотя LINQ позиционируется как универсальный язык запросов к коллекциям разного происхождения (как коллекциям в памяти, так и различным удаленным источникам данных, например, базам данных), однако на деле результаты одинаковых запросов получаются разными в зависимости от того, к какой коллекции был запрос. В частности, при наличии null в свойстве Property1 используемом в выражении в методе .Select(c=>c.Property1.Property2) можно получить как NullReferenceException, так и null в качестве результата.
Пример:
Предположим у вас есть DbContext, предоставляющий вам доступ к таблице книг. При этом у каждой книги может быть указан автор, представленный в таблице авторов.
Соответственно, представление автора в коде выглядит как-то так:
Предположим, также, что кроме локальной бд в качестве источника данных мы используем так же веб-сервис, который предоставляет нам информацию откуда-то извне. Мы обернули его в адаптер, так что он предоставляет нам коллекцию точно таких же книг с авторами, однако, внутри она представляет собой List.
Для унификации обернем оба источника данных в интерфейс IBookSource:
Такая Generic реализация позволяет не забирать из бд всегда целиком сущность Book с ее навигационными свойствами, а получать запросом только нужную информацию. Реализация этого метода для DbContext тривиальна:
Что произойдет, если мы вызовем этот метод для книги у которой не указан автор следующим образом:
В переменной authorName окажется null.
Теперь попробуем реализовать этот же метод в том случае, когда источником данных на самом деле является коллекция в памяти (List booklist, который мы получили запросом к сервису поставщику данных):
Обратите внимание, что, поскольку books это IEnumerable а не IQueriable, нам необходимо скомпилировать выражение прежде чем передавать его в метод Select.
Что произойдет, если мы опять вызовем метод для книги у которой не указан автор следующим образом:
Мы получим NullReferenceException. Возникает вопрос, что же делать, мы ведь хотим унифицированно извлекать данные независимо от того что находится за интерфейсом IBookSource. Значит нам нужно свести поведение IQueriable и IEnumerable в вопросе доступа с свойствам класса к одинаковому поведению. Мне в данном случае больше нравится поведение IQueriable, поэтому я преобразую Expression, который выполняется над IEnumerable к такому же поведению.
Для этого нужно чтобы для каждого PropertyGetter в выражении будет проверяюлось не равно ли значение на котором вызывается PropertyGetter null, и если это так — вместо вызова PropertyGetter возвращалось null. Вот метод реализующий это поведение:
Однако, как добавить такое поведение в уже готовое выражение (Expression)?
На помощь приходит класс ExpressionVisitor. Это специальный класс, который позволяет пройти по всем узлам дерева выражений (ExpressionTree) и модифицировать их так как нам нужно.
Как использовать этот ExpressionVisitor? Очень просто:
Теперь если запросить имя автора у книги, для которой не указан автор, мы получим null, а не NullReferenceException, абсолютно точно так же как и при запросе в бд через DbContext.
Пример:
Предположим у вас есть DbContext, предоставляющий вам доступ к таблице книг. При этом у каждой книги может быть указан автор, представленный в таблице авторов.
Соответственно, представление автора в коде выглядит как-то так:
public class Book
{
public string Title{get;set;}
public string ISBN{get;set;}
public Author Author{get;set;}
...
}
public class Author
{
public string Name {get;set;}
...
}
Предположим, также, что кроме локальной бд в качестве источника данных мы используем так же веб-сервис, который предоставляет нам информацию откуда-то извне. Мы обернули его в адаптер, так что он предоставляет нам коллекцию точно таких же книг с авторами, однако, внутри она представляет собой List.
Для унификации обернем оба источника данных в интерфейс IBookSource:
public interface IBookSource
{
T GetBookData<T>(string isbn, Expression<Func<Book, T>> selector) where T:class;
}
Такая Generic реализация позволяет не забирать из бд всегда целиком сущность Book с ее навигационными свойствами, а получать запросом только нужную информацию. Реализация этого метода для DbContext тривиальна:
public T GetBookData<T>(string isbn, Expression<Func<Book, T>> selector) where T:class
{
return Set<Book>().Where(b=>b.ISBN == isbn).Select(selector).SingleOrDefault();
}
Что произойдет, если мы вызовем этот метод для книги у которой не указан автор следующим образом:
var authorName = GetBookData(isbn, b=>b.Author.Name);
В переменной authorName окажется null.
Теперь попробуем реализовать этот же метод в том случае, когда источником данных на самом деле является коллекция в памяти (List booklist, который мы получили запросом к сервису поставщику данных):
public T GetBookData<T>(string isbn, Expression<Func<Book, T>> selector) where T:class
{
List<Book> books = GetDataFromService();
return books.Where(b=>b.ISBN == isbn).Select(selector.Compile()).SingleOrDefault();
}
Обратите внимание, что, поскольку books это IEnumerable а не IQueriable, нам необходимо скомпилировать выражение прежде чем передавать его в метод Select.
Что произойдет, если мы опять вызовем метод для книги у которой не указан автор следующим образом:
var authorName = GetBookData(isbn, b=>b.Author.Name);
Мы получим NullReferenceException. Возникает вопрос, что же делать, мы ведь хотим унифицированно извлекать данные независимо от того что находится за интерфейсом IBookSource. Значит нам нужно свести поведение IQueriable и IEnumerable в вопросе доступа с свойствам класса к одинаковому поведению. Мне в данном случае больше нравится поведение IQueriable, поэтому я преобразую Expression, который выполняется над IEnumerable к такому же поведению.
Для этого нужно чтобы для каждого PropertyGetter в выражении будет проверяюлось не равно ли значение на котором вызывается PropertyGetter null, и если это так — вместо вызова PropertyGetter возвращалось null. Вот метод реализующий это поведение:
public static TResult With<TSource, TResult>(TSource source, Func<TSource, TResult> action) where TSource : class
{
if (source != default(TSource))
return action(source);
return default(TResult);
}
Однако, как добавить такое поведение в уже готовое выражение (Expression)?
На помощь приходит класс ExpressionVisitor. Это специальный класс, который позволяет пройти по всем узлам дерева выражений (ExpressionTree) и модифицировать их так как нам нужно.
public class AddMaybeVisitor : ExpressionVisitor
{
//Этот метод мы будем вызывать над выражением, которое нужно преобразовать.
public Expression<Func<T1, T2>> Modify<T1, T2>(Expression<Func<T1, T2>> expression)
{
return (Expression<Func<T1, T2>>)Visit(expression);
}
// Этот метод вызывается в том случае, если узлом дерева выражений является обращение к свойству или полю, как раз то, что нам и требуется.
protected override Expression VisitMember(MemberExpression node)
{
Visit(node.Expression);
var expressionType = node.Expression.Type;
var memberType = node.Type;
var withMethodinfo = typeof(AddMaybeVisitor)
.GetMethod("With")
.MakeGenericMethod(expressionType, memberType);
var p = Expression.Parameter(expressionType);
var l = Expression.Lambda(Expression.MakeMemberAccess(p, node.Member), p);
return Expression.Call(withMethodinfo,
node.Expression,
Expression.Constant(l.Compile(), typeof(Func<,>).MakeGenericType(expressionType, memberType))
);
}
public static TResult With<TSource, TResult>(TSource source, Func<TSource, TResult> action) where TSource : class
{
if (source != default(TSource))
return action(source);
return default(TResult);
}
}
Как использовать этот ExpressionVisitor? Очень просто:
public T GetBookData<T>(string isbn, Expression<Func<Book, T>> selector) where T:class
{
List<Book> books = GetDataFromService();
var modifiedSelector = new AddMaybeVisitor().Modify(selector);
return books.Where(b=>b.ISBN == isbn).Select(modifiedSelector.Compile()).SingleOrDefault();
}
Теперь если запросить имя автора у книги, для которой не указан автор, мы получим null, а не NullReferenceException, абсолютно точно так же как и при запросе в бд через DbContext.
Комментарии (8)
Alvaro
21.04.2015 18:11+1Было бы неплохо, если бы ваш пример демонстрировал работу в действительно унифицированном случае: то есть когда у нас есть IQueryable и мы не хотим знать, что за ним скрывается. То есть в случае если это некий источник данных, то оставляем Expression как есть, а вот в случае, если это IEnumerable — добавляем вызов With.
mird Автор
22.04.2015 09:59На самом деле, наверное это можно сделать. Для этого придется написать свой собственный extension method который будет использоваться для получения IQueriable из IEnumerable и написать свою реализацию IQueryProvider. Я попробую это сделать и сообщу о результатах.
PsyHaSTe
23.04.2015 18:00А что произойдет в вашем случае если мы захотим написать
var authorFirstLetter = GetBookData(isbn, b=>b.Author.Name[0]);
Ну и я так понял, что если лямбда чуть более сложная, то работать тоже ничего не будет
var nameOrFamily = GetBookData(isbn, b => !string.IsNullOrEmpty(b.Author.Name) ? b.Author.Name : b.Author.Family);
mird Автор
23.04.2015 23:16Ваш код нельзя вызывать на IQueryable поверх бд. Он не затрансферится в sql код.
Nagg
Так ведь в шарп уже завезли Null-conditional operators:
mird Автор
А они поддерживаются LINQ провайдерами, такими как EntityFramework? Если нет, то статья продолжает быть актуальной.