Еще в Microsoft SQL Server 2005 появился интересный функционал – оконные функции. Это функции, которые позволяют осуществлять вычисления в заданном диапазоне строк внутри предложения Select. Для тех, кто не сталкивался с этими функциями возникает вопрос – «Что значит оконные?». Окно – значит набор строк, в рамках которого происходит вычисление. Оконная функция позволяет разбивать весь набор данных на такие окна.
Конечно, все что могут оконные функции возможно реализовать и без них. Однако оконные функции обладают большим преимуществом перед регулярными агрегатными функциями: нет нужды группировать набор данных для расчетов., что позволяет сохранить все строки набора с их уникальными идентификаторами. При этом результаты работы оконных функций просто добавляются к результирующей выборке как еще одно поле.
Основное преимущество использования оконных функций над регулярными агрегатными функциями заключается в следующем: оконные функции не приводят к группированию строк в одну строку вывода, строки сохраняют свои отдельные идентификаторы, а агрегированное значение добавляется к каждой строке.
Окно определяется с помощью инструкции OVER(). Давайте рассмотрим синтаксис этой инструкции:
Оконная функция (столбец для вычислений) OVER ([PARTITION BY столбец для группировки] [ORDER BY столбец для сортировки] [ROWS или RANGE выражение для ограничения строк в пределах группы])
Ассортимент функций мы рассмотрим во второй части статьи. Скажу лишь, что они разделяются на: агрегирующие, ранжирующие, смещения.
Для демонстрации работы оконных функций предлагаю на тестовой таблице:
1 2 3 4 5 6 7 |
CREATE TABLE ForWindowFunc (ID INT, GroupId INT, Amount INT) GO INSERT INTO ForWindowFunc (ID, GroupId, Amount) VALUES(1, 1, 100), (1, 1, 200), (1, 2, 150), (2, 1, 100), (2, 1, 300), (2, 2, 200), (2, 2, 50), (3, 1, 150), (3, 2, 200), (3, 2, 10); |
ID |
GroupId |
Amount |
1 |
1 |
100 |
1 |
1 |
200 |
1 |
2 |
150 |
2 |
1 |
100 |
2 |
1 |
300 |
2 |
2 |
200 |
2 |
2 |
50 |
3 |
1 |
150 |
3 |
2 |
200 |
Как видно, здесь три группы в колонке ID и две подгруппы в колонке GroupId с разным количеством элементов в группе.
Чаще всего используется функция суммирования, поэтому демонстрацию проведем именно на ней. Давайте посмотрим, как работает инструкция OVER:
1 2 3 |
SELECT ID, Amount, SUM(Amount) OVER() AS SUM FROM ForWindowFunc |
ID |
Amount |
Sum |
1 |
100 |
1310 |
1 |
200 |
1310 |
2 |
100 |
1310 |
2 |
300 |
1310 |
2 |
200 |
1310 |
2 |
50 |
1310 |
3 |
150 |
1310 |
3 |
200 |
1310 |
3 |
10 |
1310 |
Мы использовали инструкцию OVER() без предложений. В таком варианте окном будет весь набор данных и никакая сортировка не применяется. Нам просто повезло, что данные вывелись в том же порядке, в котором были вставлены в таблицу, но SQL Server может поменять порядок отображения, если нет явно заданной сортировки. Поэтому инструкцию OVER() практически никогда не применяют без предложений. Но, обратим наше внимание на новый столбец SUM. Для каждой строки выводится одно и то же значение 1310. Это сквозная сумма всех значений колонки Amount.
Предложение PARTITION BY
Предложение PARTITION BY определяет столбец, по которому будет производиться группировка, и он является ключевым в разбиении набора строк на окна.
Изменим наш запрос, написанный ранее, так:
1 2 3 |
SELECT ID, Amount, SUM(Amount) OVER(PARTITION BY ID) AS SUM FROM ForWindowFunc |
ID |
Amount |
Sum |
1 |
100 |
300 |
1 |
200 |
300 |
2 |
100 |
650 |
2 |
300 |
650 |
2 |
200 |
650 |
2 |
50 |
650 |
3 |
150 |
360 |
3 |
200 |
360 |
3 |
10 |
360 |
Предложение PARTITION BY сгруппировало строки по полю ID. Теперь для каждой группы рассчитывается своя сумма значений Amount. Вы можете создавать окна по нескольким полям. Тогда в PARTITION BY нужно писать поля для группировки через запятую (например, PARTITION BY ID, Amount).
Предложение ORDER BY
Вместе с PARTITION BY может применяться предложение ORDER BY, которое определяет порядок сортировки внутри окна. Порядок сортировки очень важен, ведь оконная функция будет обрабатывать данные согласно этому порядку. Если вы не используете предложение PARTITION BY, а только ORDER BY, то окном будет весь набор данных.
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(PARTITION BY id ORDER BY Amount) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
100 |
1 |
2 |
150 |
250 |
1 |
1 |
200 |
450 |
2 |
2 |
50 |
50 |
2 |
1 |
100 |
150 |
2 |
2 |
200 |
350 |
2 |
1 |
300 |
650 |
3 |
2 |
10 |
10 |
3 |
1 |
150 |
160 |
3 |
2 |
200 |
360 |
К предложению PARTITION BY добавилось ORDER BY по полю Amount. Таким образом мы указали, что хотим видеть сумму не всех значений Amount в окне, а для каждого значения Amount сумму со всеми предыдущими. Такое суммирование часто называют нарастающий итог или накопительный итог.
Вы заметили, что в выборке появилось поле GpoupId. Это поле позволит показать, как изменится нарастающий итог, в зависимости от сортировки. Изменим запрос:
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId, Amount) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
100 |
1 |
1 |
200 |
300 |
1 |
2 |
150 |
450 |
2 |
1 |
100 |
100 |
2 |
1 |
300 |
400 |
2 |
2 |
50 |
450 |
2 |
2 |
200 |
650 |
3 |
1 |
150 |
150 |
3 |
2 |
10 |
160 |
3 |
2 |
200 |
360 |
3 |
2 |
200 |
360 |
И мы получаем совсем другое поведение. И хоть в итоге для последнего значения в окне значения сходятся с предыдущим примером, но сумма для всех остальных отличается. Поэтому важно четко понимать, что вы хотите получить в итоге.
Предложение ROWS/RANG
Еще два предложения ROWS и RANGE применяются в инструкции OVER. Этот функционал появился в MS SQL Server 2012.
Предложение ROWS ограничивает строки в окне, указывая фиксированное количество строк, предшествующих или следующих за текущей. Оба предложения ROWS и RANGE используются вместе с ORDER BY.
Предложение ROWS может быть задано с помощью методов:
Вы можете комбинировать эти функции для достижения желаемого результата, например:
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId, Amount ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING ) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
300 |
1 |
1 |
200 |
350 |
1 |
2 |
150 |
150 |
2 |
1 |
100 |
400 |
2 |
1 |
300 |
350 |
2 |
2 |
50 |
250 |
2 |
2 |
200 |
200 |
3 |
1 |
150 |
160 |
3 |
2 |
10 |
210 |
3 |
2 |
200 |
200 |
Здесь, сумма рассчитывается по текущей и следующей ячейке в окне. А последняя в окне строка имеет то же значение, что и Amount. Посмотрим на первое окно, выделенное голубым цветом. Сумма 300 рассчитана сложением 100 и 200. Для следующего значения ситуация аналогичная. А последняя в окне сумма имеет значение 150, потому что текущий Amount больше не с чем складывать.
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId, Amount ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
100 |
1 |
1 |
200 |
300 |
1 |
2 |
150 |
350 |
2 |
1 |
100 |
100 |
2 |
1 |
300 |
400 |
2 |
2 |
50 |
350 |
2 |
2 |
200 |
250 |
3 |
1 |
150 |
150 |
3 |
2 |
10 |
160 |
3 |
2 |
200 |
210 |
В этом запросе мы получаем сумму путем сложения текущего значения Amount и предыдущего. Первая строка имеет значение 100, т.к. предыдущего значения Amount не существует.
Предложение RANGE предназначено также для ограничения набора строк. В отличие от ROWS, оно работает не с физическими строками, а с диапазоном строк в предложении ORDER BY. Это означает, что одинаковые по рангу строки в контексте предложения ORDER BY будут считаться как одна текущая строка для функции CURRENT ROW. А в предложении ROWS текущая строка – это одна, текущая строка набора данных.
Предложение RANGE может использоваться только с опциями CURRENT ROW, UNBOUNDED PRECEDING и UNBOUNDED FOLLOWING.
Предложение RANGE может использовать опции:
И не может:
Примеры:
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId RANGE CURRENT ROW) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
300 |
1 |
1 |
200 |
300 |
1 |
2 |
150 |
150 |
2 |
1 |
100 |
400 |
2 |
1 |
300 |
400 |
2 |
2 |
200 |
250 |
2 |
2 |
50 |
250 |
3 |
1 |
150 |
150 |
3 |
2 |
200 |
210 |
3 |
2 |
10 |
210 |
Предложение Range настроено на текущую строку. Но, как мы помним, для Range текущая строка, это все строки, соответствующие одному значению сортировки. Сортировка в данном случае по полю GroupId. Первые две строки первого окна имеют значение GroupId равное 1 – следовательно оба эти значения удовлетворяют ограничению RANGE CURRENT ROW. Поэтому Sum для каждой из этих строк равна общей сумме Amount по ним - 300.
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
1 |
100 |
300 |
1 |
1 |
200 |
300 |
1 |
2 |
150 |
450 |
2 |
1 |
100 |
400 |
2 |
1 |
300 |
400 |
2 |
2 |
200 |
650 |
2 |
2 |
50 |
650 |
3 |
1 |
150 |
150 |
3 |
2 |
200 |
360 |
3 |
2 |
10 |
360 |
В этом случае ограничение по всем предыдущим строкам и текущей. Для первой и второй строки это правило работает как предыдущее (вспоминаем CURRENT ROW), а для третьей как сумма Amount предыдущих строк с текущей.
1 2 3 4 |
SELECT ID, GroupId, Amount, SUM(Amount) OVER(Partition BY id ORDER BY GroupId RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS SUM FROM ForWindowFunc |
ID |
GroupId |
Amount |
Sum |
1 |
2 |
150 |
150 |
1 |
1 |
100 |
450 |
1 |
1 |
200 |
450 |
2 |
2 |
200 |
250 |
2 |
2 |
50 |
250 |
2 |
1 |
100 |
650 |
2 |
1 |
300 |
650 |
3 |
2 |
200 |
210 |
3 |
2 |
10 |
210 |
3 |
1 |
150 |
360 |
Это ограничение позволило нам получить сумму из текущей строки и всех предыдущих в рамках одного окна. Так как вторая и третья строка у нас в одной GroupId, то эти значения и есть Current Row. Поэтому они просуммированы сразу.
На этом закончим первую часть статьи. А напоследок - пример использования оконной функции из реальной практики.
Лучше всего понять суть оконных функций можно на примере. Допустим у вас есть данные о платежах абонентов. Платеж поступает на договор. Но у этого договора есть дочерние договора, на которых отрицательный баланс. И мы хотим распределить поступившие средства на погашение долга дочерних договоров.
Таким образом, нам нужно определить сколько же денег мы спишем со счета основного договора и сколько переведем на дочерний. Давайте посмотрим на таблицу:
ContractId |
ChildContractId |
PayId |
CustAccount |
PayAmount |
1000000000 |
1000000002 |
1000000752 |
-200,00 |
800,00 |
1000000000 |
1000000003 |
1000000753 |
-1000,00 |
800,00 |
Где, ContractId – идентификатор основного договора,
ChildContractId – идентификатор дочернего договора,
PayId – идентификатор платежа,
CustAccount – баланс дочернего договора,
PayAmount – платеж.
Из таблицы видно, что для каждого дочернего договора сумма платежа 800. Это из-за того, что платеж на родительском договоре.
Так что наша задача рассчитать суммы переносов денег с родительского на дочерние договора.
Для этого суммируем CustAccount и PayAmount. Однако, простая сумма баланса и платежа нас не устраивает. Ведь на погашение долга на втором дочернем договоре мы должны учитывать остаток от суммы баланса первого договора и платежа.
Как мы можем действовать в этой ситуации? Мы могли бы выбрать:
1 2 3 4 5 6 7 8 |
SELECT ContractId, ChildContractId, PayId, CustAccount, PayAmount, PayAmount + (SELECT SUM(CustAccount) FROM dbo.Pays p2 WHERE p1.PayId = p2.PayId AND p2.ChildContractId <= p1.ChildContractId) AS [SUM] FROM dbo.Pays p1 |
Этот запрос решает поставленную задачу, но подзапрос портит всю картину – увеличивает время выполнения запроса. Применим оконную функцию сложения:
1 2 3 4 5 6 7 8 |
SELECT ContractId, ChildContractId, PayId, CustAccount, PayAmount, PayAmount + SUM(CustAccount) OVER (ORDER BY ChildContractId) AS [SUM] FROM dbo.Pays p1 |
Этот вариант работает быстрее и выглядит лаконичнее. В нашем случае мы получаем сумму по полю CustAccount в окне, которое формируется по полю ChildContractId.
Результатом этих запросов будет таблица:
ContractId |
ChildContractId |
PayId |
CustAccount |
PayAmount |
Sum |
1000000000 |
1000000002 |
1000000752 |
-200,00 |
800,00 |
600 |
1000000000 |
1000000003 |
1000000753 |
-1000,00 |
800,00 |
-400 |
Исходя из полученных данных в колонке Sum мы определяем сумму, которую нужно перенести с родительского договора на дочерний. Для договора 1000000002 мы погасили долг полностью, так что сумма платежа 200р. Для договора 1000000003 долг погашен частично – сумма платежа равна сумме баланса и остатка от платежа после расчета для первой записи (-1000 + 600 = -400р).