27 ноября 2013 в 12:35

Не стреляйте себе в ногу, используя LINQ из песочницы

C#*, SQL*, .NET*
В статье я описал несколько примеров неочевидных моментов при использовании LINQ to SQL. Если вы гуру .NET, вам, возможно, покажется это скучным, остальным — добро пожаловать!
Начнем с такого примера. Допустим, у нас есть сущность «тип действия». У типа действия есть human-readable имя и системное имя — некий уникальный идентификатор, по которому с объектами этой сущности мы сможем работать из кода. Вот такая структура в виде объектов в коде:

class ActionType
{
	public int id;
	public string systemname;
	public string name;
}

var ActionTypes = new ActionType[] {
	new ActionType {
		id = 1,
		systemname = "Registration",
		name = "Регистрация"
	},
	new ActionType {
		id = 2,
		systemname = "LogOn",
		name = "Вход на сайт"
	},
	new ActionType {
		id = 3,
		systemname = null,
		name = "Некоторый тип действия без системного имени"
	}
};

Для такой же структуры с аналогичными данными создана таблица в БД и вспомогательные объекты для использования LINQ to SQL. Допустим, нам необходимо выяснить, существует ли у нас тип действия с системным именем NotExistingActionType. Вопрос в том, что будет выведено на экран после выполнения этих инструкций:

var resultForObjects = ActionTypes.All(actionType => actionType.systemname != "NotExistingActionType");
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.ActionTypes.All(actionType => actionType.SystemName != "NotExistingActionType");

Console.WriteLine("Result for objects: " + resultForObjects + "\nResult for Linq to sql: " + resultForLTS);
Console.ReadLine();

Ответ в данном случае на первый взгляд странный. Результатом работы приложения будет:
Result for objects: True
Result for LINQ to sql: False
Почему «один и тот же метод» возвращает разные значения для одних и тех же данных? Всё дело в том, что это совсем не один и тот же метод, а разные методы с одинаковыми названиями. Первый итерирует по объектам в памяти, второй же преобразуется в SQL запрос, который будет выполнен на сервере и вернет нам другой результат. Результаты отличаются из-за наличия среди наших типов действий одного с неопределенным системным именем. И в данном моменте проявляются специфические различия двух сред выполнения: для .NET выражение null != objRef — истина (если конечно objRef не null), а следовательно и значение выражения «системные имена всех типов действий не равны NotExistingActionType» в нашей ситуации будет истинным.
Но LINQ to SQL выражения преобразуются в SQL и выполняются на сервере, и в SQL сравнение с NULL работает по-другому. Значения выражений NULL == Something, NULL != Something и даже NULL == NULL всегда будут ложными, таков стандарт, поэтому выражение «системные имена всех типов действий не равны NotExistingActionType» и не будет истинной, так как NULL != 'NotExistingActionType' — ложь.

Теперь рассмотрим другой пример. Допустим, у нас есть пользователи с их текущим балансом:

class User
{
	public int id;
	public int balance;
	public string name;
}

var users = new User[] { 
	new User {                     
		id = 1, 
		name = "Василий",
		balance = 0
	},
	new User {                     
		id = 2, 
		name = "Георгий",
		balance = 0
	}
};

Вопрос в том, что должна возвращать сумма по пустому набору элементов. Для меня, например, очевидным значением является 0, но тут тоже не всё так просто. Выполним что-то типа такого:

var resultForObjects = users.Where(user => user.id < 0).Sum(user => user.balance);
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.Users.Where(user => user.Id < 0).Sum(user => user.Balance);

Console.WriteLine("Result for objects: " + resultForObjects + "\nResult for Linq to sql: " + resultForLTS);
Console.WriteLine(context.ActionTypes.First().Name);
Console.ReadLine();

Результат снова будет необычным. Вообще говоря, выполнить эти инструкции в таком виде мы не сможем, так как при выполнении возникнет исключение:
System.InvalidOperationException: «Значение NULL не может быть присвоено члену, который является типом System.Int32, не допускающим значения NULL.»

Причины происходящего снова в трансляции наших вызовов в SQL. Для обычного IEnumerable экстеншн Sum возвращает 0 для пустого набора, в чем легко убедиться, не вычисляя resultForLTS (ну или в конце концов прочитав это вот тут msdn.microsoft.com/ru-ru/library/bb549046). Однако СУБД вычисляет сумму пустого набора как NULL (правильно это или нет — вопрос довольно холиварный, но сейчас это просто факт), и LINQ, пытаясь вернуть null вместо целого числа, немедленно терпит фиаско. Починить это место крайне просто, но необходимо держать ухо востро:

var resultForLTS = context.Users.Where(user => user.Id < 0).Sum(user => (int?)user.Balance) ?? 0;

Тут возвращаемое значение функции Sum становится не int, а nullable int (этого можно добиться и явным указанием типа generic'а), что дает возможность LINQ вернуть null, а оператор ?? превратит этот null в 0.

Ну и последний пример. Удивительно, но трансляция в SQL дает нам немного синтаксического сахара. Рассмотрим вот какой пример. Добавим объект Location, и у пользователей теперь будет ссылка на их город:

class User
{
	public int id;
	public int balance;
	public string name;
	public Location location;
}

class Location
{
	public int id;
	public string Name;
}

Не будем создавать никаких объектов Location и изменять пользователей, интерес представляет вот такой код:

var resultForObjects = users.Select(user => 
	user.location == null ? 
		"Локация не указана" : user.location.Name == null ? 
			"Локация не указана" : user.location.Name)
	.First();
var context = new LinqForHabr.DataClasses1DataContext();
var resultForLTS = context.Users.Select(user => 
	user.Location == null ? 
		"Локация не указана" : user.Location.name == null ? 
			"Локация не указана" : user.Location.name)
	 .First();

В обоих случаях результатом будет строка «Локация не указана», так как она действительно не указана, но что будет, если написать вот так:

var resultForLTS = context.Users.Select(user => user.Location.name ?? "Локация не указана");

Вы можете подумать, что так это не будет работать, так как тут присутствует явный NullReferenceException (ни один пользователь не имеет объекта Location априори, мы их не создавали, в базу не записывали), но не забываем, что этот код не будет запущен в окружении .NET, а будет транслирован в SQL и запущен СУБД. На самом деле, запрос, который получится из этого кода, будет выглядеть так (LINQPad в помощь):

SELECT COALESCE([t1].[name],@p0) AS [value]
FROM [Users] AS [t0]
LEFT OUTER JOIN [Locations] AS [t1] ON [t1].[Id] = [t0].[LocationId]

Этот «трюк» позволяет нам не писать дикое количество тернарных операторов в запросах на LINQ.

Вывод:


Когда мы пишем код, мы постоянно полагаемся на функции более низкого уровня и считаем, что эти функции работают верно. Прекрасно, что есть такой способ сокращения сложности, но нужно всегда отдавать себе отчет в том, достаточно хорошо ли мы понимаем, что сделает та или иная функция, которую мы используем. А для этого — RTFM!
Тёма Рудневский @timramone
карма
25,0
рейтинг 4,0
.NET developer
Самое читаемое Разработка

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

  • +2
    «необходимо выяснить, существует ли у нас тип действия с системным именем NotExistingActionType»

    В такой формулировке естественнее бы выглядел запрос в виде context.ActionTypes.Any(actionType => actionType.SystemName == «NotExistingActionType»); и ошибки бы не было.
    • +1
      Вы совершенно правы, но во-первых, статья как раз и называется «не стреляйте себе в ногу», а во-вторых, возможно, это не лучший пример, но довольно показательный.
      • 0
        Кстати, такой вопрос по второму примеру — метод Linq-а «Max()» в этой ситуации упадет. А что будет у Linq2Sql?
        • 0
          docs.oracle.com/cd/B14117_01/server.101/b10759/functions001.htm#sthref1044
          All aggregate functions except COUNT(*) and GROUPING ignore nulls. You can use the NVL function in the argument to an aggregate function to substitute a value for a null. COUNT never returns null, but returns either a number or zero. For all the remaining aggregate functions, if the data set contains no rows, or contains only rows with nulls as arguments to the aggregate function, then the function returns null.

          Короче, произойдет то же самое, что и при попытке получить сумму пустого набора — вы получите null (ну или эксепшн, если вы использовали не тот generic)
  • +7
    Автор, возможно, имеет смысл рассказать сперва о том, что при использовании Linq2SQL или EntityFramework программист работает с методами уже не IEnumerable(T), а IQueriable(T) интерфейса, и, что даже передаваемые параметры имеют разные типы. Например,

    var resultForObjects = users.Where(user => user.id < 0).Sum(user => user.balance);
    var context = new LinqForHabr.DataClasses1DataContext();
    var resultForLTS = context.Users.Where(user => user.Id < 0).Sum(user => user.Balance);
    

    В первом случае буде вызван метод IEnumerable.Where и параметр будет типа Func(T, TK)
    Во втором случае будет IQueriable.Where и параметр будет Expression(Func(T, TK))
    То есть, при одинаковом на вид коде, вызываются методы разных интерфейсов и по сути разных сигнатур, а результат (в случае работы с БД) будет зависеть от реализации провайдера (IQueriable) и реакции конкретной БД на запрос.
    Чтобы обойти эти ограничения, нужно вызвать метод AsEnumerable(T) (он определен в IEnumerable и соотв доступен в IQueriable), но, важно понимать, что все операторы после вызова этого метода (при перечислении результата) будут отрабатывать уже не в БД, а на клиенте.
    • 0
      IQueryable конечно
    • 0
      Еще поправка: методы Where находятся в классах Enumerable и Queryable, а в интерфейсах таких методов нет.
      • 0
        Да, безусловно, я имел ввиду методы расширения, спасибо за уточнение :)
  • +5
    А теперь внимание, вопрос: поведение Linq2Sql и Entity Framework в этих вопросах одинаково или нет?
  • 0
    Спасибо за красочную метафору в заголовке: кратко, понятно и завлекающе.
  • 0
    Значения выражений NULL == Something, NULL != Something и даже NULL == NULL всегда будут раны NULL или Unknown, что транслируется в False

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.