20  Віконні функції

Data Miorsh Ihor Miroshnychenko Youtube Monobank

Віконними називають функції, які обробляють виділені набори рядків (вікна чи партиції) та записують результати обчислень в окремому стовпці.

Одна з головних переваг віконних функцій полягає в тому, що вони повертають ту саму кількість записів, яку отримали на вхід.

Уявіть, що ви хочете розрахувати деяке значення для групи рядків, об’єднаних загальною ознакою (наприклад, ID користувача). Якби ви скористалися оператором GROUP BY, то на виході замість вхідної кількості рядків у групі отримали один рядок з результатом.

При групуванні так відбувається завжди - число рядків у результуючій таблиці завжди дорівнює кількості груп у вхідній таблиці. В той же час віконна функція дозволяє проводити ті ж розрахунки з агрегацією по групах, але при цьому зберігає структуру вихідної таблиці - для кожного запису, що належить певній групі, в окремому стовпчику просто вказується результат агрегації.

20.1 OVER - визначення вікна

Визначаються вікна за допомогою оператора OVER – у загальному вигляді його синтаксис виглядає так:

OVER (
     PARTITION BY column_1, column_2, ...   - визначаються партиції усередині вікна (аналог GROUP BY) 
     ORDER BY column_3, ...                 - вказується сортування записів у партиціях
     ROWS/RANGE BETWEEN ...                 - задаються межі вікна
)

Для проведення обчислень за заданим в OVER вікну використовуються різні функції. Наприклад, з агрегуючою функцією SUM запис може виглядати так:

SELECT SUM(column) OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS sum
FROM table

Тепер кілька слів про інструкції, які можна вказувати під час створення вікна. Усього їх три:

  • PARTITION BY
  • ORDER BY ASC/DESC
  • ROWS/RANGE BETWEEN

При цьому всі вони є необов’язковими.

20.2 PARTITION BY

Інструкція PARTITION BY визначає стовпець, яким дані ділитися на групи, які називаються партіціями. Наприклад, так як буде виглядати групування за user_id:

SELECT user_id, date, price, 
       SUM(price) OVER (PARTITION BY user_id) AS sum
FROM table

В результаті такого запиту для кожного запису в таблиці буде обчислено загальну суму всіх покупок даного користувача, а результат обчислень буде вписаний в стовпець sum:

user_id date price sum
Alex 09.01 500 3950
Alex 13.03 3000 3950
Alex 02.08 450 3950
Kate 25.07 100 900
Kate 17.09 800 900

20.3 ORDER BY

Інструкція ORDER BY визначає стовпець, яким значення всередині вікна будуть сортуватися при обробці. Наприклад, сортування по date всередині вікна задається так:

SELECT user_id, date, price, 
       SUM(price) OVER (PARTITION BY user_id ORDER BY date) AS sum
FROM table

У цьому випадку для кожного запису в таблиці буде обчислено суму поточної та всіх попередніх покупок користувача. Результат обчислень буде вписаний у стовпець sum:

user_id date price sum
Alex 09.01 500 500
Alex 13.03 3000 3500
Alex 02.08 450 3950
Kate 25.07 100 100
Kate 17.09 800 900

Чому ж рахується сума саме поточної та всіх попередніх, а не взагалі всіх покупок користувача?

Справа в тому, що при використанні у парі віконних та агрегатних функцій для кожного рядка визначається так звана рамка вікна - набір рядків у її партиції. Якщо в OVER вказати ORDER BY, то за замовчуванням рамка складатиметься з усіх рядків від початку партиції до поточного рядка (також у рамку будуть включені рядки, що дорівнюють поточному рядку за значенням вказаним у ORDER BY).

Саме тому в нашому прикладі сума вважається за кожним користувачем наростаючим підсумком.

Якщо ж ORDER BY не вказувати, то стандартна рамка буде складатися з усіх рядків партиції, тобто буде пораховано суму всіх покупок кожного користувача. Також можна не вказувати і PARTITION BY – тоді рамкою вікна стане вся таблиця, і ми просто порахуємо суму покупок усіх користувачів:

SELECT user_id, date, price, 
       SUM(price) OVER () AS sum
FROM table

20.4 ROWS/RANGE BETWEEN

Інструкції ROWS та RANGE можуть додатково задавати межі рамки вікна та обмежувати діапазон роботи функцій усередині партиції. Першим аргументом вказується початок рамки, другим - кінець рамки:

SELECT user_id, date, price, 
       SUM(price) OVER (PARTITION BY user_id ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS sum
FROM table

В результаті для кожного запису в таблиці буде обчислено суму поточної та попередньої покупок користувача, а результат буде знову вписаний в стовпець sum:

user_id date price sum
Alex 09.01 500 500
Alex 13.03 3000 3500
Alex 02.08 450 3450
Kate 25.07 100 100
Kate 17.09 800 900

Рамку можна встановити в двох режимах:

  • ROWS — початок та кінець рамки визначаються рядками щодо поточного рядка.
  • RANGE — початок та кінець рамки задаються різницею значень у стовпці з ORDER BY.

Початок і кінець рамки задаються одним із наступних способів:

UNBOUNDED PRECEDING
значення PRECEDING
CURRENT ROW
значення FOLLOWING
UNBOUNDED FOLLOWING
  • UNBOUNDED PRECEDING: вказує, що рамка починається з першого рядка партиції.
  • UNBOUNDED FOLLOWING: вказує, що рамка закінчується на останньому рядку партиції.
  • PRECEDING та FOLLOWING: вказують, що рамка починається або закінчується зі зсувом на задану кількість рядків щодо поточного рядка.
  • CURRENT ROW: вказує, що рамка починається або закінчується на поточному рядку.

Рамка завжди починається з початку рамки та закінчується кінцем рамки. Якщо кінець рамки не вказаний, мається на увазі CURRENT ROW.

За замовчуванням рамка визначається так:

RANGE UNBOUNDED PRECEDING

Це рівносильно розширеному визначенню рамки:

RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

Варіанти значення PRECEDING та значення FOLLOWING допускаються лише у режимі ROWS.

Наприклад, наступний запис означає створення рамки, що включає 3 рядки до поточної і 3 рядки після поточної (зрозуміло, поточний рядок також включається до рамки):

ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING

Якщо в інструкції ORDER BY знаходиться стовпець date з типом даних DATE, то рамку вікна можна задати так:

RANGE BETWEEN '3 days' PRECEDING AND '3 days' FOLLOWING

Це означатиме рамку, що включає 3 дні перед та 3 дні після поточної дати (включаючи поточну дату).

При вказівці рамки через RANGE обов’язковою умовою є лише один стовпчик в інструкції ORDER BY.

Як і решта інструкцій, інструкція ROWS/RANGE BETWEEN є необов’язковою.

20.5 Де та як можна використовувати віконні функції?

Також важливо знати, що віконні функції дозволяється використовувати у запиті лише у SELECT та ORDER BY. В інших операторах, включаючи WHERE, HAVING і GROUP BY, вони заборонені, оскільки логічно виконуються після звичайних агрегатних функцій.

Якщо потрібно відфільтрувати або згрупувати рядки після обчислення віконних функцій, можна використати вкладений запит:

SELECT user_id, date, price, sum
FROM (
    SELECT user_id, date, price, SUM(price) OVER (PARTITION BY user_id ORDER BY date) AS sum
    FROM table
) t
WHERE sum > 1000

Над результатом віконних функцій можна виконувати різні арифметичні операції. Також результат віконних функцій може виступати як аргумент інших функцій:

SELECT user_id, date, price, 1.15 * SUM(price) OVER (PARTITION BY user_id ORDER BY date) AS sum
FROM table

SELECT user_id, date, price, ROUND(AVG(price) OVER (PARTITION BY user_id ORDER BY date), 2) AS sum
FROM table

Також для визначення інструкцій усередині вікна можна використовувати розрахункові поля:

SELECT user_id, date, price, SUM(price) OVER (PARTITION BY DATE_TRUNC('month', date)) AS monthly_sum
FROM table  

Самі вікна також можна визначати через оператор WINDOW, а потім викликати по аліасу в операторі SELECT:

SELECT SUM(column) OVER w AS sum
FROM table
WHERE ...
GROUP BY ...
HAVING ...
WINDOW w AS (
    PARTITION BY ... 
    ORDER BY ...
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )
ORDER BY ...
LIMIT ...

20.6 Використання віконних функцій з іншими функціями

У парі з віконними функціями можна використовувати функції різних класів:

  1. Агрегатні функції SUM, AVG, MAX, MIN, COUNT

Всередині вікна до таких функцій можна застосовувати ORDER BY. Так, сортування дозволить отримати замість загальної суми наростаючу, а замість абсолютного максимуму — максимум серед значень до поточного.

  1. Ранжируючі функції:
  • ROW_NUMBER: проста нумерація (1, 2, 3, 4, 5).
  • RANK: нумерація з урахуванням повторюваних значень з пропуском рангів (1, 2, 2, 4, 5).
  • DENSE_RANK: нумерація з урахуванням повторюваних значень без пропуску рангів (1, 2, 2, 3, 4).

Зрозуміло, для функцій ранжирування завжди потрібно вказувати ORDER BY, інакше вони працюватимуть некоректно.

  1. Функції зміщення:
  • LAG, LEAD: значення попереднього чи наступного рядка.
  • FIRST_VALUE, LAST_VALUE: перше чи останнє значення у вікні.

Для функцій зміщення визначення правил сортування теж необхідне.

Примітка

Докладніше про віконні функції можна почитати у документації DuckDB.

Також рекомендуємо до прочитання статтю.

20.7 Ранжуючі функції

Почнемо знайомство з віконними функціями з найпростіших завдань. Для початку попрацюємо з ранжуючими функціями:

SELECT ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS row_number
FROM table

SELECT RANK() OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS rank
FROM table

SELECT DENSE_RANK() OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS dense_rank
FROM table

Завдання 20.1
Застосуйте віконні функції до таблиці products і за допомогою ранжирующих функцій упорядкуйте всі товари за ціною від найдорожчих до найдешевших. Додайте до таблиці наступні колонки:

  • Колонку product_number із порядковим номером товару (функція ROW_NUMBER).
  • Колонку product_rank із рангом товару з пропусками рангів (функція RANK).
  • Колонку product_dense_rank з рангом товару без перепусток рангів (функція DENSE_RANK).

Не забувайте вказувати у вікні сортування записів — без неї ранжуючі функції можуть давати некоректний результат, якщо таблиця заздалегідь не відсортована. Поділ на партиції всередині вікна зараз не потрібний. Сортувати записи в результуючій таблиці також не потрібно.

Поля в результуючій таблиці: product_id, name, price, product_number, product_rank, product_dense_rank

Рішення
%%sql
SELECT product_id,
       name,
       price,
       row_number() OVER (ORDER BY price desc) as product_number,
       rank() OVER (ORDER BY price desc) as product_rank,
       dense_rank() OVER (ORDER BY price desc) as product_dense_rank
FROM   products
product_id name price product_number product_rank product_dense_rank
0 13 caviar 800.0 1 1 1
1 37 mutton 559.0 2 2 2
2 15 olive oil 450.0 3 3 3
3 57 pork 450.0 4 3 3
4 43 decaffeinated coffee 400.0 5 5 4
... ... ... ... ... ... ...
82 6 crackers 25.0 83 83 49
83 5 coffee 3 in 1 15.0 84 84 50
84 73 cake 15.0 85 84 50
85 10 seeds 12.0 86 86 51
86 54 paper bag 1.0 87 87 52

87 rows × 6 columns

20.8 Агрегатні функції

З ранжуючими функціями розібралися, тепер давайте навчимося в парі з віконними і агрегуючі функції:

SELECT SUM(column) OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS sum
FROM table

Завдання 20.2
Застосуйте віконну функцію до таблиці products і за допомогою агрегатної функції в окремій колонці для кожного запису проставте ціну найдорожчого товару. Колонку із цим значенням назвіть max_price. Потім для кожного товару порахуйте частку його ціни у вартості найдорожчого товару - просто поділіть одну колонку на іншу. Отримані частки округліть до двох знаків після коми. Колонку із частками назвіть share_of_max.

Виведіть всю інформацію про товари, включаючи значення у нових колонках. Результат відсортуйте спочатку за спаданням ціни товару, потім за зростанням id товару.

Поля в результуючій таблиці: product_id, name, price, max_price, share_of_max

Пояснення:

У цьому вся задачі вікном виступає вся таблиця. Сортувати всередині вікна вказувати не потрібно.

З результатом агрегації з вікном можна проводити арифметичні та логічні операції. Також до нього можна застосовувати й інші функції - наприклад, округлення, як у цій задачі.

Рішення
%%sql
SELECT product_id,
       name,
       price,
       max(price) OVER () as max_price,
       round(price / max(price) OVER (), 2) as share_of_max
FROM   products
ORDER BY price desc, product_id
product_id name price max_price share_of_max
0 13 caviar 800.0 800.0 1.00
1 37 mutton 559.0 800.0 0.70
2 15 olive oil 450.0 800.0 0.56
3 57 pork 450.0 800.0 0.56
4 43 decaffeinated coffee 400.0 800.0 0.50
... ... ... ... ... ...
82 6 crackers 25.0 800.0 0.03
83 5 coffee 3 in 1 15.0 800.0 0.02
84 73 cake 15.0 800.0 0.02
85 10 seeds 12.0 800.0 0.02
86 54 paper bag 1.0 800.0 0.00

87 rows × 5 columns

20.9 Віконні функції та ORDER BY

А тепер давайте доповнимо наш попередній запит і вкажемо інструкцію ORDER BY для вікна, що працює в парі агрегатною функцією.


Завдання 20.3
Застосуйте дві віконні функції до таблиці products — одна з функцією MAX, а інша MIN — для обчислення максимальної та мінімальної ціни. Для двох вікон задайте інструкцію ORDER BY щодо зменшення ціни. Помістіть результат обчислень у дві колонки max_price та min_price.

Виведіть всю інформацію про товари, включаючи значення у нових колонках. Результат відсортуйте спочатку за спаданням ціни товару, потім за зростанням id товару.

Поля в результуючій таблиці: product_id, name, price, max_price, min_price

Після того, як вирішите завдання, проаналізуйте отриманий результат і подумайте, чому вийшли саме такі розрахунки. За потреби поверніться до першого кроку і ще раз уважно ознайомтеся з тим, як працює рамка вікна під час сортування.

Рішення
%%sql
SELECT product_id,
       name,
       price,
       max(price) OVER (ORDER BY price desc) as max_price,
       min(price) OVER (ORDER BY price desc) as min_price
FROM   products
ORDER BY price desc, product_id
product_id name price max_price min_price
0 13 caviar 800.0 800.0 800.0
1 37 mutton 559.0 800.0 559.0
2 15 olive oil 450.0 800.0 450.0
3 57 pork 450.0 800.0 450.0
4 43 decaffeinated coffee 400.0 800.0 400.0
... ... ... ... ... ...
82 6 crackers 25.0 800.0 25.0
83 5 coffee 3 in 1 15.0 800.0 15.0
84 73 cake 15.0 800.0 15.0
85 10 seeds 12.0 800.0 12.0
86 54 paper bag 1.0 800.0 1.0

87 rows × 5 columns


Тепер застосуємо віконну функцію з інструкцією ORDER BY для вирішення практичного завдання.

Як ми пам’ятаємо з першого кроку, вказівка сортування визначає рамку вікна від початку таблиці або партиції до поточного рядка. Давайте використовуємо цю особливість розрахунку кумулятивної суми, тобто зробимо так, щоб для кожного запису повертався результат додавання її значення зі значеннями всіх попередніх записів.


Завдання 20.4
Спочатку на основі таблиці orders сформуйте нову таблицю із загальною кількістю замовлень по дням. Підраховуючи кількість замовлень, не враховуйте скасовані замовлення (їх можна визначити за таблицею user_actions). Колонку з днями назвіть date, а колонку з числом замовлень orders_count.

Потім помістіть отриману таблицю в підзапит і застосуйте до неї віконну функцію в парі з функцією SUM для розрахунку кумулятивної суми числа замовлень. Не забудьте для вікна вказати інструкцію ORDER BY за датою.

Назвіть колонку з накопичувальною сумою orders_cum_count. В результаті такої операції значення кумулятивної суми для останнього дня має вийти рівним загальній кількості замовлень за весь період.

Сортувати результуючу таблицю робити не потрібно.

Поля в результуючій таблиці: date, orders_count, orders_cum_count

Пояснення:

Зверніть увагу, що віконні функції як результат повертають значення типу DECIMAL, незважаючи на те, що вхідне значення знаходиться у форматі INTEGER. Тому не забудьте отримане значення кумулятивної суми додатково привести до цілісного формату.

Рішення
%%sql
with t1 as (SELECT creation_time::date as date,
    COUNT(*) as orders_count
FROM orders
WHERE order_id NOT IN (SELECT order_id from user_actions WHERE action = 'cancel_order')
GROUP BY date)

select date,
    orders_count,
    SUM(orders_count) OVER (ORDER BY date)::integer as orders_cum_count
FROM t1

20.10 Віконні функції та PARTITION BY

У попередніх завданнях як вікно виступала вся таблиця. Тепер давайте навчимося додавати у параметри вікна поділ на партиції та попрацюємо з інструкцією PARTITION BY.


Завдання 20.5
Для кожного користувача у таблиці user_actions порахуйте порядковий номер кожного замовлення. Для цього застосовуйте віконну функцію ROW_NUMBER до колонки з часом замовлення. Не забудьте вказати поділ на партиції за користувачами та сортування усередині партицій. Скасовані замовлення не враховуйте. Нову колонку із порядковим номером замовлення назвіть order_number. Результат відсортуйте спочатку за зростанням ID користувача, потім за зростанням order_number. Додати LIMIT 1000.

Поля в результуючій таблиці: user_id, order_id, time, order_number

Рішення
%%sql
SELECT user_id,
       order_id,
       time,
       row_number() OVER (PARTITION BY user_id
                          ORDER BY time) as order_number
FROM   user_actions
WHERE  order_id not in (SELECT order_id
                        FROM   user_actions
                        WHERE  action = 'cancel_order')
ORDER BY user_id, order_number limit 1000
user_id order_id time order_number
0 1 1 2022-08-24 01:52:00 1
1 1 4683 2022-08-27 20:56:00 2
2 1 22901 2022-09-02 00:58:00 3
3 1 23149 2022-09-02 02:36:00 4
4 2 2 2022-08-24 06:37:00 1
... ... ... ... ...
995 248 13935 2022-08-30 17:13:00 8
996 248 15518 2022-08-31 02:25:00 9
997 249 287 2022-08-25 03:54:00 1
998 249 758 2022-08-25 15:19:00 2
999 249 7347 2022-08-28 19:27:00 3

1000 rows × 4 columns

20.11 Віконні функції та зміщення

Тепер давайте попрацюємо з функціями зміщення – у цьому теж немає нічого складного:

SELECT LAG(column, 1) OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS lag_value
FROM table

SELECT LEAD(column, 1) OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) AS lead_value
FROM table

В якості першого аргументу функцій LAG і LEAD вказується колонка зі значеннями, в якості другого — те, скільки рядків проводити зміщення (назад і вперед відповідно). Другий аргумент можна не вказувати, за умовчанням його значення дорівнює 1.


Завдання 20.6
Доповніть запит із попереднього завдання та за допомогою віконної функції для кожного замовлення кожного користувача розрахуйте, скільки часу пройшло з попереднього замовлення.

Для цього спочатку в окремому стовпці за допомогою LAG зробіть зміщення по стовпцю часу на одне значення назад. Стовпець зі зміщеними значеннями назвіть time_lag. Потім відніміть від кожного значення колонці time нове значення зі зміщенням (або можете використовувати вже знайому функцію AGE). Назвіть колонку з отриманим інтервалом time_diff. Змінювати формат відображення значень не потрібно, вони повинні мати приблизно такий вигляд:

3 days, 12:18:22

Як і раніше, не враховуйте скасовані замовлення. Також залиште у запиті порядковий номер кожного замовлення, розрахований на минулому етапі. Результат відсортуйте спочатку за зростанням ID користувача, потім за зростанням порядкового номера замовлення. Додати LIMIT 1000.

Поля в результуючій таблиці: user_id, order_id, time, order_number, time_lag, time_diff

Пояснення:

Не забувайте про поділ на партиції та сортування усередині вікна.

Також зверніть увагу, що в результаті зміщення перших замовлень кожного користувача в колонці time_lag вийшли пропущені значення. Для таких записів функція не знайшла попередніх значень та повернула NULL. Те саме сталося в записах користувачів з одним замовленням — усередині партиції з одним записом просто нема куди зміщатися.

Пропущені значення, що утворилися, прибирати з результату не потрібно.

Рішення
%%sql
SELECT user_id,
       order_id,
       time,
       row_number() OVER (PARTITION BY user_id
                          ORDER BY time) as order_number,
       lag(time, 1) OVER (PARTITION BY user_id
                          ORDER BY time) as time_lag,
       time - lag(time, 1) OVER (PARTITION BY user_id
                                 ORDER BY time) as time_diff
FROM   user_actions
WHERE  order_id not in (SELECT order_id
                        FROM   user_actions
                        WHERE  action = 'cancel_order')
ORDER BY user_id, order_number limit 1000
user_id order_id time order_number time_lag time_diff
0 1 1 2022-08-24 01:52:00 1 NaT NaT
1 1 4683 2022-08-27 20:56:00 2 2022-08-24 01:52:00 3 days 19:04:00
2 1 22901 2022-09-02 00:58:00 3 2022-08-27 20:56:00 5 days 04:02:00
3 1 23149 2022-09-02 02:36:00 4 2022-09-02 00:58:00 0 days 01:38:00
4 2 2 2022-08-24 06:37:00 1 NaT NaT
... ... ... ... ... ... ...
995 248 13935 2022-08-30 17:13:00 8 2022-08-30 11:04:00 0 days 06:09:00
996 248 15518 2022-08-31 02:25:00 9 2022-08-30 17:13:00 0 days 09:12:00
997 249 287 2022-08-25 03:54:00 1 NaT NaT
998 249 758 2022-08-25 15:19:00 2 2022-08-25 03:54:00 0 days 11:25:00
999 249 7347 2022-08-28 19:27:00 3 2022-08-25 15:19:00 3 days 04:08:00

1000 rows × 6 columns


Давайте для повноти картини порахуємо, скільки в середньому проходить між замовленнями кожного користувача.


Завдання 20.7
На основі запиту попереднього завдання для кожного користувача розрахуйте, скільки в середньому часу проходить між його замовленнями. Порахуйте цей показник лише для користувачів, які за весь час оформили більше одного замовлення.

Середній час між замовленнями виразіть у годинах, округливши значення до цілого числа. Колонку із середнім значенням часу назвіть hours_between_orders. Результат відсортуйте за зростанням id користувача.

Додайте в запит оператор LIMIT і включіть у результат лише перші 1000 записів.

Поля у результуючій таблиці: user_id, hours_between_orders

Пояснення:

Щоб перевести значення інтервалу в години, необхідно витягти з нього кількість секунд, а потім поділити на кількість секунд в одній годині. Для отримання кількості секунд з інтервалу можна скористатися такою конструкцією:

SELECT EXTRACT(epoch FROM INTERVAL '3 days, 1:21:32')

Результат:
264092  

Функція EXTRACT працює аналогічно до функції DATE_PART, яку ми розглядали раніше, але має дещо інший синтаксис. Спробуйте скористатися функцією EXTRACT у цій задачі.

В результаті всіх розрахунків для кожного користувача з більш ніж одним замовленням, у вас має вийти ціле число годин, яке в середньому проходить між його замовленнями. Подумайте, як отримати з даних користувачів з одним замовленням. За потреби додатково перетворіть середнє значення годин на цілий тип даних.

Повторювати всі попередні віконні функції з попереднього запиту не обов’язково — залиште найнеобхідніше.

Рішення
%%sql
SELECT user_id,
       avg(time_diff)::integer as hours_between_orders
FROM   (SELECT user_id,
               order_id,
               time,
               extract(epoch
        FROM   (time - lag(time, 1)
        OVER (
        PARTITION BY user_id
        ORDER BY time)))/3600 as time_diff
        FROM   user_actions
        WHERE  order_id not in (SELECT order_id
                                FROM   user_actions
                                WHERE  action = 'cancel_order')) t
WHERE  time_diff is not null
GROUP BY user_id
ORDER BY user_id limit 1000
user_id hours_between_orders
0 1 72
1 2 108
2 3 64
3 4 77
4 6 11
... ... ...
995 1125 35
996 1126 49
997 1127 37
998 1129 33
999 1130 20

1000 rows × 2 columns

20.12 Задачі з ROWS BETWEEN

Настав час трохи попрацювати з інструкцією ROWS BETWEEN, яку докладно розглядали раніше. Нагадаємо, що початок і кінець рамки задаються такими способами:

UNBOUNDED PRECEDING
значення PRECEDING
CURRENT ROW
значення FOLLOWING
UNBOUNDED FOLLOWING

Ось ще один приклад вказівки меж рамки:

SELECT SUM(column_3) OVER (PARTITION BY column_1 
                           ORDER BY column_2 
                           ROWS BETWEEN UNBOUNDED PRECEDING AND 3 FOLLOWING) AS sum
FROM table

Але в яких завданнях корисно вказувати рамку для розрахунків? Перше, що спадає на думку будь-якому аналітику, — ковзна середня.

Ковзне середнє - це показник, який обчислюється в кожній точці часового ряду як середнє значення за N попередніх періодів (днів, тижнів, місяців тощо в залежності від рівня агрегації даних). Ковзне середнє переміщається по часовому ряду і щоразу враховує фіксовану кількість значень - для проведення таких розрахунків якраз і потрібна рамка вікна, яка задається інструкцією ROWS BETWEEN.

Спробуймо провести такі розрахунки на наших даних.


Завдання 20.8
Спочатку на основі таблиці orders сформуйте нову таблицю із загальною кількістю замовлень щодня. Ви вже робили у завданні 20.4. Підраховуючи кількість замовлень, не враховуйте скасовані замовлення (їх можна визначити за таблицею user_actions). Назвіть колонку з кількістю замовлень orders_count.

Потім помістіть отриману таблицю в підзапит і застосуйте до неї віконну функцію в парі з функцією AVG для розрахунку ковзного середнього числа замовлень. Ковзне середнє для кожного запису рахуйте за трьома попередніми днями. Подумайте, як правильно встановити межі рамки, щоб отримати коректні розрахунки.

Отримані значення ковзного середнього округліть до двох знаків після коми. Колонку із розрахованим показником назвіть moving_avg. Сортувати результуючу таблицю робити не потрібно.

Поля у результуючій таблиці: date, orders_count, moving_avg

Пояснення:

При вирішенні завдання можете пробувати різні межі рамки та перевіряти себе вручну. Важливо для кожної дати врахувати у розрахунках саме 3 попередні дні.

Заповнювати пропущені значення у цій задачі не потрібно. Подумайте чому вони могли з’явитися.

Рішення
%%sql
SELECT date,
       orders_count,
       round(avg(orders_count) OVER (ORDER BY date rows between 3 preceding and 1 preceding),
             2) as moving_avg
FROM   (SELECT creation_time :: date as date,
               count(order_id) as orders_count,
               sum(count(order_id)) OVER (ORDER BY creation_time :: date) as orders_cum_count
        FROM   orders
        WHERE  order_id not in (SELECT order_id
                                FROM   user_actions
                                WHERE  action = 'cancel_order')
        GROUP BY date) as t1
date orders_count moving_avg
0 2022-08-24 138 NaN
1 2022-08-25 1059 138.00
2 2022-08-26 1447 598.50
3 2022-08-27 2141 881.33
4 2022-08-28 2998 1549.00
5 2022-08-29 3267 2195.33
6 2022-08-30 3371 2802.00
7 2022-08-31 3410 3212.00
8 2022-09-01 3688 3349.33
9 2022-09-02 5001 3489.67
10 2022-09-03 5709 4033.00
11 2022-09-04 6010 4799.33
12 2022-09-05 4675 5573.33
13 2022-09-06 3451 5464.67
14 2022-09-07 4777 4712.00
15 2022-09-08 5474 4301.00

20.13 Задача з CASE

Тепер навчимося разом із віконними функціями застосовувати конструкцію CASE. Приклад такого запису:

SELECT
    CASE
    WHEN SUM(column) OVER (...) > 100 THEN 'above 100'
    WHEN SUM(column) OVER (...) < 100 THEN 'below 100'
    ELSE 'equal 100'
    END AS sum_case
FROM table

Завдання 20.9
Позначте в окремій таблиці тих кур’єрів, які доставили у вересні 2022 замовлень більше, ніж у середньому всі кур’єри.

Спочатку для кожного кур’єра у таблиці courier_actions розрахуйте загальну кількість доставлених у вересні замовлень. Потім в окремому стовпці за допомогою віконної функції вкажіть, скільки в середньому замовлень доставили цього місяця всі кур’єри. Після цього порівняйте кількість замовлень, доставлених кожним кур’єром, із середнім значенням у новому стовпці. Якщо кур’єр доставив більше замовлень, ніж у середньому всі кур’єри, то окремому стовпці за допомогою CASE вкажіть число 1, інакше вкажіть 0.

Колонку з результатом порівняння назвіть is_above_avg, колонку з кількістю доставлених замовлень кожним кур’єром – delivered_orders, а колонку із середнім значенням – avg_delivered_orders. При розрахунку середнього значення округліть його до двох знаків після коми. Результат відсортуйте за зростанням id кур’єра.

Поля в результуючій таблиці: courier_id, delivered_orders, avg_delivered_orders, is_above_avg

Пояснення:

Таблицю з кур’єрами та числом доставлених замовлень сформуйте на основі таблиці courier_actions та перед застосуванням віконних функцій помістіть її у підзапит.

З цим завданням можна впоратися без конструкції CASE, якщо сконвертувати результат логічної операції (TRUE чи FALSE) у тип даних INT. Можете спробувати розв’язати завдання різними способами.

Рішення
%%sql
SELECT courier_id,
       delivered_orders,
       round(avg(delivered_orders) OVER (), 2) as avg_delivered_orders,
       case when delivered_orders > round(avg(delivered_orders) OVER (), 2) then 1
            else 0 end as is_above_avg
FROM   (SELECT courier_id,
               count(order_id) as delivered_orders
        FROM   courier_actions
        WHERE  action = 'deliver_order'
           and date_part('month', time) = 9
           and date_part('year', time) = 2022
        GROUP BY courier_id) t
ORDER BY courier_id

-- або
-- SELECT *,
--        round(avg(delivered_orders) OVER (), 2) as avg_delivered_orders,
--        (delivered_orders > avg(delivered_orders) OVER ())::integer as is_above_avg
-- FROM   (SELECT courier_id,
--                count(*) as delivered_orders
--         FROM   courier_actions
--         WHERE  date_part('month', time) = 09
--            and date_part('year', time) = 2022
--            and action = 'deliver_order'
--         GROUP BY courier_id) t1
courier_id delivered_orders avg_delivered_orders is_above_avg
0 1 20 13.76 1
1 2 14 13.76 1
2 3 23 13.76 1
3 4 9 13.76 0
4 5 23 13.76 1
... ... ... ... ...
2817 3163 4 13.76 0
2818 3164 3 13.76 0
2819 3165 2 13.76 0
2820 3166 2 13.76 0
2821 3167 3 13.76 0

2822 rows × 4 columns

20.14 Задача з FILTER

У цьому завданні розглянемо ще більш сучасний функціонал і навчимося разом з віконними функціями застосовувати оператор FILTER, з яким ми вже працювали у завданні 17.16.

Якщо до визначення віконної функції додати пропозицію FILTER, то у вікно потраплять лише ті вхідні рядки, для яких умова фільтра буде обчислена як дійсна.

При цьому пропозиція FILTER допускається тільки для агрегатних віконних функцій.

У загальному вигляді вся конструкція виглядає так:

SELECT agg_function(column) FILTER (WHERE [condition]) OVER (...)
FROM table

Приклад:

SELECT SUM(column_1) FILTER (WHERE column_2 > 100) OVER (PARTITION BY column_3 ORDER BY column_4) AS sum
FROM table

Завдання 20.10
Застосуйте віконну функцію до таблиці products та за допомогою агрегуючої функції в окремій колонці для кожного запису проставте середню ціну всіх товарів. Колонку із цим значенням назвіть avg_price. Потім за допомогою віконної функції та оператора FILTER в окремій колонці розрахуйте середню ціну товарів без урахування найдорожчого. Колонку із цим середнім значенням назвіть avg_price_filtered. Отримані середні значення в колонках avg_price та avg_price_filtered округліть до двох знаків після коми.

Виведіть всю інформацію про товари, включаючи значення у нових колонках. Результат відсортуйте спочатку за спаданням ціни товару, потім за зростанням id товару.

Поля в результуючій таблиці: product_id, name, price, avg_price, avg_price_filtered

Пояснення:

У цьому завданні вікном знову виступає вся таблиця. Сортувати всередині вікна не потрібно.

Рішення
%%sql
SELECT product_id,
       name,
       price,
       round(avg(price) OVER (), 2) as avg_price,
       round(avg(price) filter (WHERE price != (SELECT max(price)
                                         FROM   products))
OVER (), 2) as avg_price_filtered
FROM   products
ORDER BY price desc, product_id
product_id name price avg_price avg_price_filtered
0 13 caviar 800.0 133.54 125.79
1 37 mutton 559.0 133.54 125.79
2 15 olive oil 450.0 133.54 125.79
3 57 pork 450.0 133.54 125.79
4 43 decaffeinated coffee 400.0 133.54 125.79
... ... ... ... ... ...
82 6 crackers 25.0 133.54 125.79
83 5 coffee 3 in 1 15.0 133.54 125.79
84 73 cake 15.0 133.54 125.79
85 10 seeds 12.0 133.54 125.79
86 54 paper bag 1.0 133.54 125.79

87 rows × 5 columns


А тепер ще одне завдання на фільтрацію по вікну — цього разу складніше.

Завдання 20.11
Для кожного запису в таблиці user_actions за допомогою віконних функцій та FILTER порахуйте скільки замовлень зробив і скільки скасував кожен користувач на момент здійснення нової дії.

Іншими словами, для кожного користувача в кожний момент часу порахуйте дві накопичувальні суми - числа оформлених та відмінених замовлень. Якщо користувач оформляє замовлення, кількість оформлених ним замовлень збільшуйте на 1, якщо скасовує — збільшуйте на 1 кількість скасуваних.

Колонки з накопичувальними сумами числа оформлених та скасованих замовлень назвіть відповідно created_orders та canceled_orders. На основі цих двох колонок для кожного запису користувача порахуйте показник cancel_rate, тобто частку скасованих замовлень у загальній кількості оформлених замовлень. Значення показника округліть до двох знаків після коми. Назвіть колонку з ним cancel_rate.

В результаті у вас повинні вийти три нові колонки з динамічними показниками, які змінюються в часі з кожною новою дією користувача.

У результуючій таблиці відобразіть усі колонки з вихідної таблиці разом із новими колонками. Відсортуйте результат за колонками user_id, order_id, time – за зростанням значень у кожній.

Додайте в запит оператор LIMIT та виведіть лише перші 1000 рядків результуючої таблиці.

Поля в результуючій таблиці: user_id, order_id, action, time, created_orders, canceled_orders, cance`l_rate

Пояснення:

Подумайте, як правильно встановити вікна і які фільтри в них потрібно вказати.

Не забудьте змінити тип даних під час поділу двох цілих значень.

Рішення
%%sql
SELECT user_id,
       order_id,
       action,
       time,
       created_orders,
       canceled_orders,
       round(canceled_orders::decimal / created_orders, 2) as cancel_rate
FROM   (SELECT user_id,
               order_id,
               action,
               time,
               count(order_id) filter (WHERE action != 'cancel_order') OVER (PARTITION BY user_id
                                                                             ORDER BY time) as created_orders,
               count(order_id) filter (WHERE action = 'cancel_order') OVER (PARTITION BY user_id
                                                                            ORDER BY time) as canceled_orders
        FROM   user_actions) t
ORDER BY user_id, order_id, time limit 1000
user_id order_id action time created_orders canceled_orders cancel_rate
0 1 1 create_order 2022-08-24 01:52:00 1 0 0.0
1 1 4683 create_order 2022-08-27 20:56:00 2 0 0.0
2 1 22901 create_order 2022-09-02 00:58:00 3 0 0.0
3 1 23149 create_order 2022-09-02 02:36:00 4 0 0.0
4 2 2 create_order 2022-08-24 06:37:00 1 0 0.0
... ... ... ... ... ... ... ...
995 227 12572 create_order 2022-08-30 08:36:00 4 0 0.0
996 227 12578 create_order 2022-08-30 08:38:00 5 0 0.0
997 227 23268 create_order 2022-09-02 04:03:00 6 0 0.0
998 227 25873 create_order 2022-09-02 17:52:00 7 0 0.0
999 227 25885 create_order 2022-09-02 17:54:00 8 0 0.0

1000 rows × 7 columns


Завдання 20.12
З таблиці courier_actions відберіть топ 10% кур’єрів за кількістю доставлених за весь час замовлень. Виведіть id кур’єрів, кількість доставлених замовлень та порядковий номер кур’єра відповідно до кількості доставлених замовлень.

У кур’єра, який доставив найбільше замовлень, порядковий номер повинен дорівнювати 1, а кур’єра з найменшою кількістю замовлень — числу, що дорівнює десяти відсоткам від кількості кур’єрів у таблиці courier_actions.

Під час розрахунку номера останнього кур’єра округляйте значення до цілого числа.

Колонки з кількістю доставлених замовлень та порядковим номером назвіть відповідно orders_count та courier_rank. Результат відсортуйте за зростанням порядкового номера кур’єра.

Поля у результуючій таблиці: courier_id, orders_count, courier_rank

Пояснення:

Якщо у двох кур’єрів виявилося однакове число доставлених замовлень, вищий ранг ми надамо кур’єру з меншим id. Наприклад, якщо у кур’єрів з id 10 і 80 виявилася максимальна кількість замовлень, то перший ранг ми надамо кур’єру з id 10.

Рішення
%%sql
with courier_count as (SELECT count(distinct courier_id)
                       FROM   courier_actions)
SELECT courier_id,
       orders_count,
       courier_rank
FROM   (SELECT courier_id,
               count(distinct order_id) as orders_count,
               row_number() OVER (ORDER BY count(distinct order_id) desc, courier_id) as courier_rank
        FROM   courier_actions
        WHERE  action = 'deliver_order'
        GROUP BY courier_id
        ORDER BY orders_count desc, courier_id) as t1
WHERE  courier_rank <= round((SELECT *
                              FROM   courier_count)*0.1)

Завдання 20.13
За допомогою віконної функції відберіть із таблиці courier_actions всіх кур’єрів, які працюють у нашій компанії 10 і більше днів. Також розрахуйте скільки замовлень вони вже встигли доставити за весь час роботи.

Вважатимемо, що наш сервіс пропонує найвигідніші умови праці і тому за весь аналізований період жоден кур’єр не звільнився з компанії. Можливих перерв між змінами не враховуйте — для нас важлива лише різниця в часі між першою дією кур’єра і поточною позначкою часу.

Поточною відміткою часу, щодо якої необхідно розраховувати тривалість роботи кур’єра, вважайте час останньої дії таблиці courier_actions. Враховуйте лише цілі дні, що пройшли з моменту першого виходу кур’єра на роботу (години та хвилини не враховуйте).

У результат включіть три колонки: id кур’єра, тривалість роботи в днях та кількість доставлених замовлень. Дві нові колонки назвіть відповідно days_employed та delivered_orders. Результат відсортуйте спочатку за зменшенням кількості відпрацьованих днів, потім за зростанням id кур’єра.

Поля в результуючій таблиці: courier_id, days_employed, delivered_orders

Пояснення:

Для вирішення задачі крім віконної функції вам можуть стати у нагоді функція DATE_PART та оператор FILTER.

Число днів, які відпрацював кур’єр - це кількість днів, що пройшли з першого прийнятого замовлення до часу останнього запису в таблиці courier_actions. Простежте, щоб кількість днів у результуючій таблиці було виражено цілим числом.

Рішення
%%sql
SELECT courier_id,
       days_employed,
       delivered_orders
FROM   (SELECT courier_id,
               delivered_orders,
               date_part('days', max(max_time) OVER() - min_time)::integer as days_employed
        FROM   (SELECT courier_id,
                       count(distinct order_id) filter (WHERE action = 'deliver_order') as delivered_orders,
                       min(time) as min_time,
                       max(time) as max_time
                FROM   courier_actions
                GROUP BY courier_id) t1) t2
WHERE  days_employed >= 10
ORDER BY days_employed desc, courier_id
courier_id days_employed delivered_orders
0 1 15 45
1 2 15 35
2 3 15 45
3 5 15 43
4 7 15 40
... ... ... ...
1059 1194 10 21
1060 1198 10 35
1061 1203 10 20
1062 1204 10 26
1063 1205 10 18

1064 rows × 3 columns


Завдання 20.14
За допомогою інформації в таблицях orders і products розрахуйте вартість кожного замовлення, щоденний виторг сервісу та частку вартості кожного замовлення в щоденній виручці, виражену у відсотках. У результаті включіть наступні колонки: id замовлення, час створення замовлення, вартість замовлення, виручку за день, в який було здійснено замовлення, а також частку вартості замовлення у виручці за день, що виражається у відсотках.

При розрахунку часток округляйте їх до трьох знаків після коми.

Результат відсортуйте спочатку за спаданням дати здійснення замовлення (саме дати, а не часу), потім за зменшенням частки замовлення у виручці за день, потім за зростанням id замовлення.

Під час проведення розрахунків скасовані замовлення не враховуйте.

Поля в результуючій таблиці: order_id, creation_time, order_price, daily_revenue, percentage_of_daily_revenue

Рішення
%%sql
SELECT
  order_id,
  creation_time,
  order_price,
  sum(order_price) OVER(PARTITION BY creation_time::date) as daily_revenue,
  round(
    100 * order_price :: decimal / sum(order_price) OVER(PARTITION BY creation_time::date),
    3
  ) as percentage_of_daily_revenue
FROM
  (
    SELECT
      order_id,
      creation_time,
      sum(price) as order_price
    FROM
      (
        SELECT
          order_id,
          creation_time,
          product_ids,
          unnest(product_ids) as product_id
        FROM
          orders
        WHERE
          order_id not in (
            SELECT
              order_id
            FROM
              user_actions
            WHERE
              action = 'cancel_order'
          )
      ) t3
      LEFT JOIN products using(product_id)
    GROUP BY
      order_id,
      creation_time
  ) t
ORDER BY
  creation_time::date desc,
  percentage_of_daily_revenue desc,
  order_id

Завдання 20.15
За допомогою інформації в таблицях orders та products розрахуйте щоденну виручку сервісу та відобразіть її в колонці daily_revenue. Потім за допомогою віконних функцій та функцій зміщення порахуйте щоденний приріст виручки. Приріст виручки відобразіть як у абсолютних значеннях, і у % щодо попереднього дня. Колонку з абсолютним приростом назвіть revenue_growth_abs, а колонку з відносним – revenue_growth_percentage.

Для першого дня вкажіть приріст рівним 0 в обох колонках. Під час проведення розрахунків скасовані замовлення не враховуйте. Результат відсортуйте по колонці з датами зростання.

Метрики daily_revenue, revenue_growth_abs, revenue_growth_percentage округліть до одного знака за допомогою ROUND().

Поля в результуючій таблиці: date, daily_revenue, revenue_growth_abs, revenue_growth_percentage

Рішення
%%sql
with t1 as (
    SELECT order_id,
                       creation_time,
                       product_ids,
                       unnest(product_ids) as product_id
                FROM   orders
                WHERE  order_id not in (SELECT order_id
                                        FROM   user_actions
                                        WHERE  action = 'cancel_order')
    ),
    t2 as (
    SELECT creation_time::date as date,
               sum(price) as daily_revenue
        FROM t1
        LEFT JOIN products using(product_id)
        GROUP BY date
    )
    
SELECT date,
       round(daily_revenue, 1) as daily_revenue,
       round(coalesce(daily_revenue - lag(daily_revenue, 1) OVER (ORDER BY date), 0), 1) as revenue_growth_abs,
       round(coalesce(round((daily_revenue - lag(daily_revenue, 1) OVER (ORDER BY date))::decimal / lag(daily_revenue, 1) OVER (ORDER BY date) * 100, 2), 0), 1) as revenue_growth_percentage
FROM t2
ORDER BY date

І наприкінці давайте спробуємо застосувати віконні функції на вирішення нетривіальної задачі.

Насправді при розрахунку середніх значень аналітикам часто доводиться мати справу з екстремально малими чи екстремально великими значеннями у вибірках. Тому замість звичайного середнього іноді доводиться рахувати медіану.

Медіана - це таке значення в деякому впорядкованому за зростанням наборі чисел, що одна половина елементів набору не менше за нього, а інша половина не більше. Тобто це таке число, що знаходиться десь у середині цього набору.

Наведемо приклад. Допустимо, у нас є набір наступних чисел:

1, 5, 2, 12, 17, 8, 50, 12, 9, 1, 3

Якщо ми впорядкуємо цей набір за зростанням, то отримаємо наступний числовий ряд:

1, 1, 2, 3, 5, 8, 9, 12, 12, 17, 50

Давайте пронумеруємо елементи впорядкованого набору:

Числовий ряд:
1, 1, 2, 3, 5, 8, 9, 12, 12, 17, 50

Номер елемента:
1, 2, 3, 4, 5, 6, 7,  8,  9, 10, 11  

Тепер добре видно, що центральним елементом ряду є число 8 (елемент під номером 6), оскільки одна половина значень (елементи під номерами 1-5) не більша за 8, а друга половина (елементи під номерами 7-11) не менше 8. Таким чином, медіана нашого ряду дорівнює 8.

Але це працює, коли ряд складається з непарної кількості елементів (у нас їх 11). Якщо ж у вибірці парне число елементів, тоді як медіану вважають середнє значення двох елементів, що у середині ряду.

Давайте додамо до нашого ряду число 0 і подивимося, що в результаті вийде:

Числовий ряд:
0, 1, 1, 2, 3, 5, 8, 9, 12, 12, 17, 50

Номер елемента:
1, 2, 3, 4, 5, 6, 7, 8,  9, 10, 11, 12  

Тепер центральними є числа 5 та 8 (елементи під номерами 6 та 7). Медіана у цьому випадку дорівнює: \[(5+8)/2=6.5\]

Знову половина значень (елементи під номерами 1-5) не більше 6.5, а друга половина (елементи під номерами 7-11) не менше 6.5.

Трапляються випадки, коли в ряді, що складається з парного числа елементів, граничними є два однакові значення. Давайте замінимо у нашому ряді число 8 на число 5:

Числовий ряд:
0, 1, 1, 2, 3, 5, 5, 9, 12, 12, 17, 50

Номер елемента:
1, 2, 3, 4, 5, 6, 7, 8,  9, 10, 11, 12  

Тепер медіаною буде число 5, оскільки: \[(5+5)/2=5\]

На цьому наш невеликий лікнеп добігає кінця, і ми повертаємося до практики.


Завдання 20.16
За допомогою віконної функції розрахуйте медіанну вартість всіх замовлень із таблиці orders, оформлених у нашому сервісі. Як результат виведіть одне число. Назвіть колонку з ним median_price. Скасовані замовлення не враховуйте.

Поле у результуючій таблиці: median_price

Пояснення:

Запит повинен враховувати два можливі сценарії: для парного та непарного числа замовлень. Вбудовані функції для розрахунку квантилей застосовувати не можна.

Для розрахунку медіани необхідно впорядкувати та пронумерувати вартість замовлень, а потім вибрати необхідний елемент/елементи послідовності.

Рішення
%%sql
WITH orders_prods AS (SELECT *,
                             unnest(product_ids) AS product_id
                      FROM orders),
                      
total AS (SELECT order_id,
                SUM(p.price) AS order_price,
                ROW_NUMBER() OVER(ORDER BY SUM(p.price)) AS number
          FROM orders_prods AS o
          JOIN products AS p ON o.product_id = p.product_id
          
          WHERE order_id NOT IN (SELECT order_id
                             FROM user_actions
                             WHERE action = 'cancel_order')
          GROUP BY order_id),
          
median_numbers AS (SELECT CASE MAX(number) % 2 WHEN 0
                                               THEN MAX(number) / 2
                                               ELSE (MAX(number) +1) / 2 END AS number_1,
                       
                          CASE MAX(number) % 2 WHEN 0
                                                THEN MAX(number) / 2 + 1    
                                                END AS number_2
                    FROM total),
                    
number_one AS (SELECT number_1
                FROM median_numbers),
                

number_two AS (SELECT number_2
                FROM median_numbers)
                
SELECT AVG(order_price) AS median_price
FROM total

WHERE number IN (SELECT *
                FROM number_one
                UNION SELECT *
                FROM number_two)

--- або
-- WITH main_table AS (
--   SELECT
--     order_price,
--     ROW_NUMBER() OVER (
--       ORDER BY
--         order_price
--     ) AS row_number,
--     COUNT(*) OVER() AS total_rows
--   FROM
--     (
--       SELECT
--         SUM(price) AS order_price
--       FROM
--         (
--           SELECT
--             order_id,
--             product_ids,
--             UNNEST(product_ids) AS product_id
--           FROM
--             orders
--           WHERE
--             order_id NOT IN (
--               SELECT
--                 order_id
--               FROM
--                 user_actions
--               WHERE
--                 action = 'cancel_order'
--             )
--         ) t3
--         LEFT JOIN products USING(product_id)
--       GROUP BY
--         order_id
--     ) t1
-- )
-- SELECT
--   AVG(order_price) AS median_price
-- FROM
--   main_table
-- WHERE
--   row_number BETWEEN total_rows / 2.0
--   AND total_rows / 2.0 + 1
median_price

Data Miorsh Ihor Miroshnychenko Youtube Monobank