12 мая 2009 г.

Генерирование LINQ-запросов в runtime

Большое удобство составления запросов в строку в том, что можно динамически добавлять условия и собственно генерировать логику отбора в рантайме, так можно поступать и с LINQ, причем дополнительные условия можно накладывать не в виде вызова методов, а так же – декларативно.

При этом для уточнения условий отбора нужно просто выполнить запрос к запросу. Благодаря тому, что запрос выполняется не сразу, а в момент обращения к его результату – будет выполнено уточнение начального запроса. Если при этом тип возвращаемого результата остается тем же (выбираем тот же тип данных), то запрос можно присвоить той же переменной.

Для примера создадим в MS-SQL таблицу Test со столбцом Value типа int и ts типа TimeStamp (чтобы полей было всё же больше, чем те, с которыми нужно работать). Нам нужно выбрать из этой таблицы все четные значения больше 5, отсортированные по возрастанию.

Это можно написать в обычном запросе вот так:

var q = from item in DB.tests
         where item.value % 2 == 0 && item.value > 5
         orderby item.value
         select item.value;
При этом мы получим IEnumerable<int> и в базу отправится запрос

exec sp_executesql N'SELECT [t0].[value] FROM [dbo].[test] AS [t0] WHERE (([t0].[value] % @p0) = @p1) AND ([t0].[value] > @p2) ORDER BY [t0].[value]',N'@p0 int,@p1 int,@p2 int',@p0=2,@p1=0,@p2=5

Это хорошо и удобно, но бывает что нужно уточнять параметры запроса в зависимости от каких-то условий или добавлять условия в цикле или что-то похожее, обычно это делается при формировании запроса в виде строки:

string query = "SELECT value FROM Test WHERE (1=1)";
query += " AND (Test.Value % 2 = [email protected])";
if (flag)
    query += " AND (Test.Value > 5)";

query += " ORDER BY Test.Value";

Такую же гибкость предоставляет и LINQ, при осуществлении запроса к запросу. Сделаем тот же самый запрос на LINQ в несколько шагов:

1. Выберем базовый набор элементов с которыми хотим работать, пусть это будут вся таблица Test

var q = from item in DB.tests
        select item;

Это преобразуется в запрос

SELECT [t0].[value], [t0].[ts] FROM [dbo].[test] AS [t0]

2. Но поскольку к результатам запроса мы еще не обращаемся, то к БД он не отправлен, уточняем наш запрос:

q = from item in q
    where item.value % 2 == 0
    select item;

Мы добавили условие четности и запрос соответственно уточнился:

SELECT [t0].[value], [t0].[ts] FROM [dbo].[test] AS [t0] WHERE ([t0].[value] % @p0) = @p1

Теперь уточним запрос, т.к. мы хотим работать только со столбцом value – выбираем его.

var nums = from num in q
           select num.value;

3. Запрос уточняется до

SELECT [t0].[value] FROM [dbo].[test] AS [t0]

т.е. если запросить данные сейчас, то выберется уже только один столбец, а не вся таблица. Но суть даже не в этом – раз мы выбрали только один целочисленный столбец мы теперь и работаем с ним просто как с коллекцией целых чилел. Обратите внимание – у нас поменялся тип результата – вместо всех столбцов мы выбираем только один, поэтому нам нужна новая переменная (используется новый тип данных)

4. Выбираем из них четные:

nums = from num in nums
       where num % 2 == 0
       select num;

и запрос снова уточняется:

SELECT [t0].[value] FROM [dbo].[test] AS [t0] WHERE ([t0].[value] % @p0) = @p1

Здесь и дальше мы уже работает только с коллекцией int’ов, так что используем ту же самую переменную.

5. Теперь уточняем запрос в зависимости от какого-то нашего условия:

if (flag)
    nums = from num in nums
           where num > 5
           select num;

У меня влаг выставлен в true, поэтому условие уточняется:

SELECT [t0].[value] FROM [dbo].[test] AS [t0] WHERE ([t0].[value] > @p0) AND (([t0].[value] % @p1) = @p2)

6. Сортируем полученный результат:

nums = from num in nums
       orderby num
       select num;

Запрос еще раз уточняется:

SELECT [t0].[value] FROM [dbo].[test] AS [t0] WHERE ([t0].[value] > @p0) AND (([t0].[value] % @p1) = @p2) ORDER BY [t0].[value]

И наконец обращаемся к результату:

foreach (var num in nums)
    Console.WriteLine(num);

Согласно SQL Profiler’у туда пришел запрос:

exec sp_executesql N'SELECT [t0].[value] FROM [dbo].[test] AS [t0] WHERE ([t0].[value] > @p0) AND (([t0].[value] % @p1) = @p2) ORDER BY [t0].[value]',N'@p0 int,@p1 int,@p2 int',@p0=5,@p1=2,@p2=0

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

В чем плюсы такого подхода?

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

2. Можем уточнять и добавлять новые условия запроса в зависимости от каких-то внешних условий, так же как при формировании запроса в виде строки.

3. Каждое уточнение выглядит человеко-читабельно: из уже выбраного выбираем то, что соответствует новому условию или условиям (можно доабвлять произвольное количество условий за раз).

4. Не смотря на такую конструкцию - запрос к запросу - реально в базу отправляется только один результирующий запрос, учитывающий сразу все условия, что собственно нам и нужно.

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

P.S. После написания поста стало интересно попробовать – а что если нам нужно будет выбрать из той же таблицы четные числа больше 5, но только среди чисел, которые идут с 5-го по 25-й если их отсортировать. Т.е. при переводе на русский это получается так:

Отсортировать числа в столбце value по возрастанию, взять из них элементы с 5-го по 25-й включительно и среди них выбрать четные числа больше 5.

Написал такой кусок кода:

var nums = from item in DB.tests
        orderby item.value
        select item.value;

nums = nums.Skip(4).Take(21);

nums = from num in nums
       where num % 2 == 0 && num > 5
       select num;

foreach (var num in nums)
    Console.WriteLine(num);

из чего сгенерировался запрос:

exec sp_executesql N'SELECT [t2].[value] FROM ( SELECT [t1].[value], [t1].[ROW_NUMBER] FROM ( SELECT ROW_NUMBER() OVER (ORDER BY [t0].[value]) AS [ROW_NUMBER], [t0].[value] FROM [dbo].[test] AS [t0] ) AS [t1] WHERE [t1].[ROW_NUMBER] BETWEEN @p0 + 1 AND @p0 + @p1 ) AS [t2] WHERE (([t2].[value] % @p2) = @p3) AND ([t2].[value] > @p4) ORDER BY [t2].[ROW_NUMBER]',N'@p0 int,@p1 int,@p2 int,@p3 int,@p4 int',@p0=4,@p1=21,@p2=2,@p3=0,@p4=5

Вот фиг бы я такой запрос вручную написал, да если и написал бы, то не слишком он читабельный и правибельный, в LINQ как-то проще.

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

  1. Спасибо за статью, интересно

    ОтветитьУдалить
  2. Благодарю за материал. Пригодится.

    ОтветитьУдалить