PostgreSQL
SQL
16 October 2015

Как посчитать всё на свете одним SQL-запросом. Оконные функции PostgreSQL


Я с удивлением обнаружил, что многие разработчики, даже давно использующие postgresql, не понимают оконные функции, считая их какой-то особой магией для избранных. Ну или в лучшем случае «копипастят» со StackOverflow выражения типа «row_number() OVER ()», не вдаваясь в детали. А ведь оконные функции — полезнейший функционал PostgreSQL.
Попробую по-простому объяснить, как можно их использовать.



Для начала хочу сразу пояснить, что оконные функции не изменяют выборку, а только добавляют некоторую дополнительную информацию о ней. Т.е. для простоты понимания можно считать, что postgres сначала выполняет весь запрос (кроме сортировки и limit), а потом только просчитывает оконные выражения.


Синтаксис примерно такой:



функция OVER окно

Окно — это некоторое выражение, описывающее набор строк, которые будет обрабатывать функция и порядок этой обработки.
Причем окно может быть просто задано пустыми скобками (), т.е. окном являются все строки результата запроса.



Например, в этом селекте к обычным полям id, header и score просто добавится нумерация строк.



SELECT
    id,
    section,
    header,
    score,
    row_number() OVER ()  AS num
FROM news;

 id | section |  header   | score | num 
----+---------+-----------+-------+-----
  1 |       2 | Заголовок |    23 |   1
  2 |       1 | Заголовок |     6 |   2
  3 |       4 | Заголовок |    79 |   3
  4 |       3 | Заголовок |    36 |   4
  5 |       2 | Заголовок |    34 |   5
  6 |       2 | Заголовок |    95 |   6
  7 |       4 | Заголовок |    26 |   7
  8 |       3 | Заголовок |    36 |   8



В оконное выражение можно добавить ORDER BY, тогда можно изменить порядок обработки.



SELECT
    id,
    section,
    header,
    score,
    row_number() OVER (ORDER BY score DESC)  AS rating
FROM news
ORDER BY id;

 id | section |  header   | score | rating 
----+---------+-----------+-------+--------
  1 |       2 | Заголовок |    23 |      7
  2 |       1 | Заголовок |     6 |      8
  3 |       4 | Заголовок |    79 |      2
  4 |       3 | Заголовок |    36 |      4
  5 |       2 | Заголовок |    34 |      5
  6 |       2 | Заголовок |    95 |      1
  7 |       4 | Заголовок |    26 |      6
  8 |       3 | Заголовок |    36 |      3


Обратите внимание, что я добавил еще и в конце всего запоса ORDER BY id, при этом рейтинг посчитан все равно верно. Т.е. посгрес просто отсортировал результат вместе с результатом работы оконной функции, один order ничуть не мешает другому.



Дальше — больше. В оконное выражение можно добавить слово PARTITION BY [expression],
например row_number() OVER (PARTITION BY section), тогда подсчет будет идти в каждой группе отдельно:



SELECT
    id,
    section,
    header,
    score,
    row_number() OVER (PARTITION BY section ORDER BY score DESC)  AS rating_in_section
FROM news
ORDER BY section, rating_in_section;

 id | section |  header   | score | rating_in_section 
----+---------+-----------+-------+-------------------
  2 |       1 | Заголовок |     6 |                 1
  6 |       2 | Заголовок |    95 |                 1
  5 |       2 | Заголовок |    34 |                 2
  1 |       2 | Заголовок |    23 |                 3
  4 |       3 | Заголовок |    36 |                 1
  8 |       3 | Заголовок |    36 |                 2
  3 |       4 | Заголовок |    79 |                 1
  7 |       4 | Заголовок |    26 |                 2


Если не указывать партицию, то партицией является весь запрос.



Тут сразу надо немного сказать о функциях, которые можно использовать, так как есть очень важный нюанс.
В качестве функции можно использовать, так сказать, истинные оконные функции из мануала — это row_number(), rank(), lead() и т.д., а можно использовать функции-агрегаты, такие как: sum(), count() и т.д. Так вот, это важно, агрегатные функции работают слегка по-другому: если не задан ORDER BY в окне, идет подсчет по всей партиции один раз, и результат пишется во все строки (одинаков для всех строк партиции). Если же ORDER BY задан, то подсчет в каждой строке идет от начала партиции до этой строки.

Давайте посмотрим это на примере. Например, у нас есть некая (сферическая в вакууме) таблица пополнений балансов.



SELECT
    transaction_id,
    change
FROM balance_change 
ORDER BY transaction_id;

 transaction_id | change 
----------------+--------
              1 |   1.00
              2 |  -2.00
              3 |  10.00
              4 |  -4.00
              5 |   5.50


и мы хотим узнать заодно, как менялся остаток на балансе при этом:



SELECT
    transaction_id,
    change,
    sum(change) OVER (ORDER BY transaction_id) as balance
FROM balance_change 
ORDER BY transaction_id;

 transaction_id | change | balance 
----------------+--------+---------
              1 |   1.00 |    1.00
              2 |  -2.00 |   -1.00
              3 |  10.00 |    9.00
              4 |  -4.00 |    5.00
              5 |   5.50 |   10.50


Т.е. для каждой строки идет подсчет в отдельном фрейме. В данном случае фрейм — это набор строк от начала до текущей строки (если было бы PARTITION BY, то от начала партиции).



Если же мы для агрегатной фунции sum не будем использовать ORDER BY в окне, тогда мы просто посчитаем общую сумму и покажем её во всех строках. Т.е. фреймом для каждой из строк будет весь набор строк
от начала до конца партиции.



SELECT
    transaction_id,
    change,
    sum(change) OVER () as result_balance
FROM balance_change
ORDER BY transaction_id;

 transaction_id | change | result_balance 
----------------+--------+----------------
              1 |   1.00 |          10.50
              2 |  -2.00 |          10.50
              3 |  10.00 |          10.50
              4 |  -4.00 |          10.50
              5 |   5.50 |          10.50


Вот такая особенность агрегатных функций, если их использовать как оконные. На мой взгляд, это довольно-таки странный, интуитивно неочевидный момент SQL-стандарта.



Оконные функции можно использовать сразу по несколько штук, они друг другу ничуть не мешают, чтобы вы там в них не написали.



SELECT
    transaction_id,
    change,
    sum(change) OVER (ORDER BY transaction_id) as balance,
    sum(change) OVER () as result_balance,
    round(
        100.0 * sum(change) OVER (ORDER BY transaction_id)  /  sum(change) OVER (),
        2
    ) AS percent_of_result,
    count(*) OVER () as transactions_count
FROM balance_change
ORDER BY transaction_id;

 transaction_id | change | balance | result_balance | percent_of_result | transactions_count 
----------------+--------+---------+----------------+-------------------+--------------------
              1 |   1.00 |    1.00 |          10.50 |              9.52 |                  5
              2 |  -2.00 |   -1.00 |          10.50 |             -9.52 |                  5
              3 |  10.00 |    9.00 |          10.50 |             85.71 |                  5
              4 |  -4.00 |    5.00 |          10.50 |             47.62 |                  5
              5 |   5.50 |   10.50 |          10.50 |            100.00 |                  5


Если у вас много одинаковых выражений после OVER, то можно дать им имя и вынести отдельно с ключевым словом WINDOW, чтобы избежать дублирования кода. Вот пример из мануала:



SELECT
    sum(salary) OVER w,
    avg(salary) OVER w
FROM empsalary
WINDOW w AS (PARTITION BY depname ORDER BY salary DESC);

Здесь w после слова OVER идет без уже скобок.



Результат работы оконной функции невозможно отфильтровать в запросе с помощью WHERE, потому что оконные фунции выполняются после всей фильтрации и группировки, т.е. с тем, что получилось. Поэтому чтобы выбрать, например, топ 5 новостей в каждой группе, надо использовать подзапрос:



SELECT *
FROM (
    SELECT
        id,
        section,
        header,
        score,
        row_number() OVER (PARTITION BY section ORDER BY score DESC)  AS rating_in_section
    FROM news
    ORDER BY section, rating_in_section
) counted_news
WHERE rating_in_section <= 5;


Еще пример для закрепления. Помимо row_number() есть несколько других функций. Например lag, которая ищет строку перед последней строкой фрейма. К примеру мы можем найти насколько очков новость отстает от предыдущей в рейтинге:

SELECT
    id,
    section,
    header,
    score,
    row_number() OVER w        AS rating,
    lag(score) OVER w - score  AS score_lag
FROM news
WINDOW w AS (ORDER BY score DESC)
ORDER BY score desc;

 id | section |  header   | score | rating | score_lag 
----+---------+-----------+-------+--------+-----------
  6 |       2 | Заголовок |    95 |      1 |          
  3 |       4 | Заголовок |    79 |      2 |        16
  8 |       3 | Заголовок |    36 |      3 |        43
  4 |       3 | Заголовок |    36 |      4 |         0
  5 |       2 | Заголовок |    34 |      5 |         2
  7 |       4 | Заголовок |    26 |      6 |         8
  1 |       2 | Заголовок |    23 |      7 |         3
  2 |       1 | Заголовок |     6 |      8 |        17



Прошу в коментариях накидать примеров, где особенно удобно применять оконные фунции. А также, какие с ними могут возникнуть проблемы, если таковые имеются.

Подписывайтесь на подкаст о разработке "Цинковый прод", где мы обсуждаем базы данных, языки программирования и всё на свете!

+71
288.9k 786
Comments 51