Основы использования оконных функций в PostgreSQL
3 октября 2022
Оконные функции (window functions) — это фича, позволяющая производить манипуляции между строками, возвращаемыми одним SQL-запросом. Похоже на GROUP BY
, но, в отличие от него, строки не объединяются в одну. Есть задачи, в которых оконные функции исключительно удобны. Например, когда нужно показать некие значения (выручку, посещаемость) за месяц, и рядом с ними — насколько это больше или меньше по сравнению с прошлым месяцем.
Для начала, выведем числа от 1 до 3:
x
===
1
2
3
Теперь перепишем запрос вот таким странным образом:
FROM generate_series(1,3) AS x;
x | array_agg
==============
1 | {1,2,3}
2 | {1,2,3}
3 | {1,2,3}
Каждый раз, когда вы видите синтаксис OVER (...)
, можете быть уверены — запрос использует оконные функции. В данном примере используется простой OVER ()
без указания чего-либо в скобочках. Поэтому функция array_agg()
видит все строки, возвращаемые запросом. Эти строки называются фреймом, а array_agg()
выступает в роли оконной функции.
Запрос имеет доступ только к тому, что было извлечено по WHERE условию:
FROM generate_series(1,3) AS x
WHERE x > 1;
x | array_agg
===============
2 | {2,3}
3 | {2,3}
Это правило действует всегда. Если нужно обратиться к чему-то, что не удовлетворяет WHERE-условию, необходимо использовать подзапросы.
Помимо array_agg()
можно использовать sum()
, count()
, и другие агрегаты:
FROM generate_series(1,3) AS x;
x | count | sum
=================
1 | 3 | 6
2 | 3 | 6
3 | 3 | 6
Кроме обычных агрегатов еще есть специализированные оконные функции. Некоторые из них будут рассмотрены далее.
OVER ()
с пустыми скобочками на самом деле эквивалентен:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) FROM generate_series(1,3) AS x;
x | array_agg
===============
1 | {1,2,3}
2 | {1,2,3}
3 | {1,2,3}
Чтобы несколько раз не писать OVER (...)
, можно воспользоваться таким синтаксисом:
FROM generate_series(1,3) AS x
WINDOW w AS (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
x | count | sum
=================
1 | 3 | 6
2 | 3 | 6
3 | 3 | 6
В рассмотренных примерах фрейм всегда содержал все возвращаемые строки. Это можно исправить:
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
);
x | array_agg
===============
1 | {1}
2 | {1,2}
3 | {1,2,3}
=# SELECT x, array_agg(x) OVER w
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
);
x | array_agg
===============
1 | {1,2,3}
2 | {2,3}
3 | {3}
Часть про ORDER BY x
не является обязательной. Ведь нам известно, что generate_series()
возвращает числа в порядке возрастания. Но в общем случае, при SELECT
‘е из таблицы, таких гарантий нет.
Внимательные читатели могли заметить, что вместо синтаксиса RANGE
здесь мы перешли на ROWS
. Это сделано не случайно. Дело в том, что вызов оконных функций может происходить в разных режимах. Они так и называются, RANGE mode и ROWS mode. Существует также GROUPS mode. RANGE mode немного запутанный. В частности, он переопределяет смысл CURRENT ROW
в зависимости от того, какая часть остального синтаксиса используется в запросе. Сделано так, по всей видимости, чтобы используемый по умолчанию RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
работал по принципу наименьшего удивления, и эту часть запроса можно было опускать. Читателям, желающим основательно во всем этом разобраться, стоит обратиться к официальной документации. Здесь же мы тактично обойдем стороной этот вопрос.
Рассмотрим еще примеры:
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN CURRENT ROW AND CURRENT ROW
);
x | array_agg
===============
1 | {1}
2 | {2}
3 | {3}
=# SELECT x, array_agg(x) OVER w
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
);
x | array_agg
===============
1 | {1}
2 | {1,2}
3 | {2,3}
=# SELECT x, array_agg(x) OVER w
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN CURRENT ROW AND 1 FOLLOWING
);
x | array_agg
===============
1 | {1,2}
2 | {2,3}
3 | {3}
Текущую строку не обязательно включать во фрейм:
FROM generate_series(1,3) AS x
WINDOW w AS (
ORDER BY x
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW
);
x | sum
=========
1 |
2 | 1
3 | 2
Смотрите, как удобно. Можно просто получить соседнее значение из выборки. Далее мы рассмотрим еще более простой способ это сделать.
Еще есть партиции. Рассмотрим их на более интересных данных:
"department" TEXT,
"name" TEXT,
"salary" INT);
=# INSERT INTO employees
SELECT 'dep_' || chr(d), 'emp_' || chr(d) || e, d*10 + e*1
FROM generate_series(ascii('a'), ascii('c')) AS d,
generate_series (1,3) AS e;
=# SELECT * FROM employees;
department | name | salary
==============================
dep_a | emp_a1 | 971
dep_a | emp_a2 | 972
dep_a | emp_a3 | 973
dep_b | emp_b1 | 981
dep_b | emp_b2 | 982
dep_b | emp_b3 | 983
dep_c | emp_c1 | 991
dep_c | emp_c2 | 992
dep_c | emp_c3 | 993
=# SELECT name, salary, array_agg(salary) OVER w
FROM employees
WINDOW w AS (PARTITION BY department);
name | salary | array_agg
=================================
emp_a1 | 971 | {971,972,973}
emp_a2 | 972 | {971,972,973}
emp_a3 | 973 | {971,972,973}
emp_b1 | 981 | {981,982,983}
emp_b2 | 982 | {981,982,983}
emp_b3 | 983 | {981,982,983}
emp_c1 | 991 | {991,992,993}
emp_c2 | 992 | {991,992,993}
emp_c3 | 993 | {991,992,993}
О партиции можно думать, как о такой специальной штуке, ограничивающей фрейм. Здесь мы партицируем данные по отделам. Ни один из фреймов не выходит за границы своей партиции. В остальном все работает так же, как раньше:
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW
);
name | salary | sum
=======================
emp_a1 | 971 |
emp_a2 | 972 | 971
emp_a3 | 973 | 972
emp_b1 | 981 |
emp_b2 | 982 | 981
emp_b3 | 983 | 982
emp_c1 | 991 |
emp_c2 | 992 | 991
emp_c3 | 993 | 992
Партицировать можно и по условию:
FROM generate_series(1,6) AS x
WINDOW w AS (
PARTITION BY x > 3
ORDER BY x
-- попробуйте убрать следующую строчку; объясните результат
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
);
INT4 | x | array_agg
======================
0 | 1 | {1,2,3}
0 | 2 | {1,2,3}
0 | 3 | {1,2,3}
1 | 4 | {4,5,6}
1 | 5 | {4,5,6}
1 | 6 | {4,5,6}
Выше говорилось о существовании специализированных оконных функций. Самая простая из них — это row_number()
:
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW
);
row_number | name | salary | sum
====================================
1 | emp_a1 | 971 |
2 | emp_a2 | 972 | 971
3 | emp_a3 | 973 | 972
1 | emp_b1 | 981 |
2 | emp_b2 | 982 | 981
3 | emp_b3 | 983 | 982
1 | emp_c1 | 991 |
2 | emp_c2 | 992 | 991
3 | emp_c3 | 993 | 992
Функция возвращает номер строки в партиции. Нумерация начиная с единицы.
Еще две полезные функции — это lag()
и lead()
:
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW
);
name | salary | lag | lead
==============================
emp_a1 | 971 | | 972
emp_a2 | 972 | 971 | 973
emp_a3 | 973 | 972 |
emp_b1 | 981 | | 982
emp_b2 | 982 | 981 | 983
emp_b3 | 983 | 982 |
emp_c1 | 991 | | 992
emp_c2 | 992 | 991 | 993
emp_c3 | 993 | 992 |
Функции позволяют подсмотреть вперед или назад на заданное число строк в рамках партиции.
Также есть first_value()
и last_value()
:
salary,
first_value(salary) OVER w,
last_value(salary) OVER w
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
);
name | salary | first_value | last_value
============================================
emp_a1 | 971 | 971 | 973
emp_a2 | 972 | 971 | 973
emp_a3 | 973 | 971 | 973
emp_b1 | 981 | 981 | 983
emp_b2 | 982 | 981 | 983
emp_b3 | 983 | 981 | 983
emp_c1 | 991 | 991 | 993
emp_c2 | 992 | 991 | 993
emp_c3 | 993 | 991 | 993
Заметьте, что условие BETWEEN ...
было переписано. Дело в том, что lag()
и lead()
работают на уровне партиции, и им не важно, какое условие было указано в BETWEEN ...
. Но функции first_value()
и last_value()
работают с фреймом и учитывают эти условия.
Еще существует nth_value()
. Функция также работает с фреймом:
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
);
name | salary | nth_value
=============================
emp_a1 | 971 | 972
emp_a2 | 972 | 972
emp_a3 | 973 | 972
emp_b1 | 981 | 982
emp_b2 | 982 | 982
emp_b3 | 983 | 982
emp_c1 | 991 | 992
emp_c2 | 992 | 992
emp_c3 | 993 | 992
Довольно часто используется функция rank()
:
=# SELECT name, salary, row_number() OVER w, rank() OVER w
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);
name | salary | row_number | rank
=====================================
emp_a1 | 970 | 1 | 1
emp_a3 | 970 | 2 | 1
emp_a2 | 970 | 3 | 1
emp_b3 | 983 | 1 | 1
emp_b2 | 982 | 2 | 2
emp_b1 | 981 | 3 | 3
emp_c3 | 993 | 1 | 1
emp_c2 | 992 | 2 | 2
emp_c1 | 991 | 3 | 3
Функция возвращает номер строки в соответствии с указанным порядком сортировки. Часть синтаксиса про ROWS BETWEEN ...
здесь была опущена, поскольку она не влияла на результат.
Наконец, никто не говорил, что нельзя указать несколько OVER (...)
:
salary,
rank() OVER w1 AS company_rank,
rank() OVER w2 AS department_rank
FROM employees
WINDOW w1 AS (ORDER BY salary DESC),
w2 AS (PARTITION BY department ORDER BY salary DESC);
name | salary | company_rank | department_rank
==================================================
emp_c3 | 993 | 1 | 1
emp_c2 | 992 | 2 | 2
emp_c1 | 991 | 3 | 3
emp_b3 | 983 | 4 | 1
emp_b2 | 982 | 5 | 2
emp_b1 | 981 | 6 | 3
emp_a1 | 970 | 7 | 1
emp_a2 | 970 | 7 | 1
emp_a3 | 970 | 7 | 1
Это далеко не все возможности оконных функций. Однако их должно хватать для очень многих практических задач.
Метки: PostgreSQL, СУБД.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.