Оконные функции – то, что должен знать каждый T-SQL программист. Часть 1.

06.12.2018

Еще в 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 может быть задано с помощью методов:

  • CURRENT ROW – отображение текущей строки;
  • UNBOUNDED FOLLOWING – все записи после текущей;
  • UNBOUNDED PRECEDING – все предыдущие записи;
  • <целое число> PRECEDING – заданное число предыдущих строк;
  • <целое число> FOLLOWING – заданное число последующих записей.

Вы можете комбинировать эти функции для достижения желаемого результата, например:

  • ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING – в окно попадут текущая и одна следующая запись;
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 больше не с чем складывать.

  • ROWS BETWEEN 1 PRECEDING AND CURRENT ROW – одна предыдущая и текущая запись
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 может использовать опции:

  • CURRENT ROW – отображение текущей строки;
  • UNBOUNDED FOLLOWING – все записи после текущей;
  • UNBOUNDED PRECEDING – все предыдущие записи.

И не может:

  • <целое число> PRECEDING – заданное число предыдущих строк;
  • <целое число> FOLLOWING – заданное число последующих записей.

Примеры:

  • RANGE CURRENT ROW;
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.

  • RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW;
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 предыдущих строк с текущей.

  • RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING.
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р).

25 мая 2022

Создание отчетов с PostgreSQL в приложении .NET 5 под Debian 10

Пример отчёта с кодом на основе библиотеки FastReport.Core с использованием SQL баз данных на операционной системе Debian 10.
22 апреля 2021

Как выбрать топ значений в матрице

Создаем SQL запрос для вывода топ значений в отчетах.
3 февраля 2021

Превращаем данные из Баз Данных в документ в Delphi / Lazarus / C++ Builder

Как использовать данные более эффективно, придав им понятный и привлекательный вид структурированного документа