вторник, 17 мая 2011 г.

Linq To Entities vs. Linq To Objects на примере группировки

LINQ - удобная, красивая, но при этом довольно коварная абстракция. Самые неожиданные вещи обычно происходят на стыке какой-либо реализации LINQ и LINQ To Objects. Сегодня на одном примере я рассмотрю совместную работу LINQ To Entities (Entity Framework) и LINQ To Objects.

За основу возьмем метод репозитория, который принимает на вход список идентификаторов клиентов и возвращает сгруппированный по этим идентификаторам набор заказов (таблица Orders содержит поля OrderId, OrderDate и CustomerId):

public IDictionary<long, List<Order>> GetOrdersByCustomersIds(IList<long> customersIds)
{
using (var ctx = new RepositoryContext())
{
return ctx.Orders.
Where(o => customersIds.Contains(o.Id)).
GroupBy(o => o.CustomerId).
ToDictionary(o => o.Key, o => o.ToList());
}
}


Минуточку! А как это работает? Ведь при выполнении GROUP BY запроса мы можем выбрать лишь поля, по которым происходит группировка, а также агрегированные значения. Стандартным решением этой проблемы является JOIN данных таблицы и результатов группировки. Примерно так:

SELECT o1.*, MinTotal
FROM Orders as o1
INNER JOIN
(SELECT o2.CustomerId, Min(o2.Total) as MinTotal
FROM Orders o2
GROUP BY o2.CustomerId) as o3
ON o1.CustomerId = o3.CustomerId
Where o1.CustomerId in (1, 2, 3, 4, 5)

Что-то в этом духе и должен сгенерировать EF-провайдер. Давайте убедимся в этом. У меня под рукой был MySQL .NET Connector (официальный ADO.NET-провайдер для MySQL), поэтому я воспользовался им и получил следующий сгенерированный запрос (передав на вход список из идентификаторов от 1 до 5):

SELECT `Project2`.`C1`,
`Project2`.`CustomerId`,
`Project2`.`C2`,
`Project2`.`CustomerId1`,
`Project2`.`Id`,
`Project2`.`OrderDate`
FROM
(SELECT `Distinct1`.`CustomerId`,
1 AS `C1`,
`Extent2`.`CustomerId` AS `CustomerId1`,
`Extent2`.`Id`,
`Extent2`.`OrderDate`,
CASE WHEN (`Extent2`.`CustomerId` IS NULL) THEN (NULL) ELSE (1) END AS `C2`
FROM
(SELECT DISTINCT `Extent1`.`CustomerId`
FROM `orders` AS `Extent1`
WHERE ((1 = `Extent1`.`Id`) OR (2 = `Extent1`.`Id`)) OR (((3 = `Extent1`.`Id`) OR (4 = `Extent1`.`Id`)) OR (5 = `Extent1`.`Id`))) AS `Distinct1`
LEFT OUTER JOIN `orders` AS `Extent2`
ON (((1 = `Extent2`.`Id`) OR (2 = `Extent2`.`Id`)) OR (((3 = `Extent2`.`Id`) OR (4 = `Extent2`.`Id`)) OR (5 = `Extent2`.`Id`))) AND (`Distinct1`.`CustomerId` = `Extent2`.`CustomerId`)) AS `Project2`
ORDER BY `CustomerId` ASC, `C2` ASC

Немного хуже ручной реализации, но в целом прослеживается озвученная выше мысль.

Стоп! А зачем мы используем группировку на уровне базы данных? Группировка оправдана в случае использования функций агреграции (как в приведенной выше ручной реализации запроса). В нашем же случае группировка - лишь удобное представление полученных данных. Давайте слегка модифицируем метод репозитория и перенесем процесс группировки на уровень LINQ To Objects:

public IDictionary<long, List<Order>> GetOrdersByCustomersIds(IList<long> customersIds)
{
using (var ctx = new RepositoryContext())
{
return ctx.Orders.
Where(o => customersIds.Contains(o.Id)).
AsEnumerable().
GroupBy(o => o.CustomerId).
ToDictionary(o => o.Key, o => o.ToList());
}
}

Для полноты картины посмотрим, какой запрос сгенерирует EF-провайдер:

SELECT `Extent1`.`CustomerId`,
`Extent1`.`Id`,
`Extent1`.`OrderDate`
FROM `orders` AS `Extent1`
WHERE ((1 = `Extent1`.`Id`) OR (2 = `Extent1`.`Id`)) OR (((3 = `Extent1`.`Id`) OR (4 = `Extent1`.`Id`)) OR (5 = `Extent1`.`Id`))

Определенно этот запрос эффективнее предыдущего.

Вот, собственно, и все. Ничего особенного - лишь хотел заострить ваше внимание на коварности перехода от LINQ To X к LINQ To Objects после того, как сам попал в эту ловушку. Будьте бдительны!

P. S. Несмотря на то, что я использовал MySQL .NET Connector, категорически не рекомендую применять этот провайдер в продакшене: это не провайдер, а коцентрированный сгусток багов, которые не фиксятся годами.

PP. S. Кросспост на Хабре: http://habrahabr.ru/blogs/net/119624/

4 комментария:

yuriy комментирует...

Меня до сих пор интересует вопрос:

оправдано ли создание контекста каждый раз создании запроса?

Shaddix комментирует...

Вот в частности за это мне и нравится NHibernate. За то, что он даёт удобный способ написания старых-добрых SQL-запросов, а не предлагает некую "магию". Таким образом, когда пишешь сложный запрос к БД, задумываешься о том, как он будет выглядеть на SQL.
А в случае простых запросов - ну он такой же, как ЕФ :)

На NH пример выше выглядел бы как:
session.QueryOver()
.WhereRestrictionOn(x => x.Customer.Id).IsIn( customersIds)
.List()
.GroupBy(x => x.Customer.Id)
.ToDictionary(x => x.Key, x => x.ToList());

@yuriy: в NHibernate точно не оправдано, за ЕФ пока не скажу :)

Shaddix комментирует...

как при этом будет выглядеть SQL-запрос, думаю, очевидно.

SELECT * FROM orders WHERE order.customerId IN (1, 2, 3)

что, имхо, еще и чуть-чуть-чуть оптимальнее, чем через OR :)

Idsa комментирует...

yuriy, не могу дать однозначного ответа. Быть может, в ближайшем будущем сформулирую свои мысли на этот счет в виде поста.

Shaddix, интересное разделение синтаксиса. Но в общем случае, думаю, я бы предпочел подход EF: LINQ прекрасен своим единообразием - не хочу изучать отдельный синтаксис LINQ под каждый ORM.

Во что сконвертить Contains - задача провайдера. Так что использование OR vs. In не является преимуществом той или иной ORM.