vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

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

    Зміст

    Уяви три типові задачі QA. Перша: у таблиці історії статусів кожне замовлення має кілька рядків, а UI показує лише останній — треба перевірити, що застосунок бере саме найсвіжіший. Друга: у виписці користувача є «баланс наростаючим підсумком», і його треба звірити рядок за рядком. Третя: після кривої міграції в таблицю заповзли дублікати, і їх треба знайти та почистити, лишивши по одному. Усі три об'єднує одне — рахувати щось у розрізі груп, але не втрачаючи окремі рядки. Саме для цього існують віконні функції (window functions).

    Агрегація через GROUP BY тут не рятує: вона згортає групу в один рядок і викидає деталі (це канон розділу — /sql/ahrehatsiia-group-by-i-having). Віконна функція, навпаки, лишає всі рядки на місці й дописує до кожного ще одну колонку — ранг, порядковий номер, суму по групі, значення з попереднього рядка. На співбесідах рівня middle це улюблена пара питань: «як видалити дублікати, лишивши один?» і «чим ROW_NUMBER відрізняється від RANK?». Хто розуміє механізм вікна, відповідає за півхвилини; хто зубрив рецепт — плутається на першому уточненні.

    Механізм: рахувати, не згортаючи

    Звичайна агрегатна функція (COUNT, SUM, AVG) поверх GROUP BY бере групу рядків і повертає одне число — самі рядки зникають. Віконна функція бере той самий набір рядків (він і зветься «вікно», window) і повертає значення для кожного рядка окремо, нічого не згортаючи. Той самий SUM, але результат лягає поруч із деталями, а не замість них.

    Ключове слово, що вмикає віконний режим, — OVER:

    SELECT
      user_id,
      amount,
      SUM(amount) OVER (PARTITION BY user_id) AS user_total
    FROM payments;

    Кожен платіж лишається окремим рядком, але поруч з'являється сума всіх платежів цього користувача. Приберіть OVER (...) — і той самий SUM вимагатиме GROUP BY, згорнувши всі платежі користувача в один рядок. Одне слово OVER — і поведінка функції протилежна.

    Віконні функції — частина стандарту SQL і працюють у всіх серйозних СУБД: PostgreSQL підтримує їх давно, MySQL — з версії 8.0, SQLite — з 3.25. Тобто це не «фіча Postgres», а базовий інструмент, який очікують знати.

    OVER: PARTITION BY, ORDER BY і рамка

    OVER описує вікно — набір рядків, який функція «бачить» для поточного рядка. Усередині три частини, і всі необов'язкові:

    • PARTITION BY ділить таблицю на групи (партиції). Функція рахується незалежно в кожній партиції й «скидається» на її межі. Без PARTITION BY вся вибірка — одна велика партиція.
    • ORDER BY (усередині OVER, не плутати з ORDER BY усього запиту) задає порядок рядків у партиції. Для рангів і LAG/LEAD він визначає, хто «перший» і хто «попередній». Для агрегатів він вмикає режим наростаючого підсумку.
    • Рамка (frame)ROWS або RANGE BETWEEN ... — уточнює, які саме рядки партиції враховувати відносно поточного. Коли є ORDER BY, рамка за замовчуванням — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (усі рядки від початку партиції до поточного). Без ORDER BY рамка — вся партиція.

    Порівняйте два вікна на одній таблиці:

    ВікноЩо рахує SUM(amount)
    OVER (PARTITION BY user_id)Разом по користувачу — однакове число в усіх його рядках
    OVER (PARTITION BY user_id ORDER BY paid_at)Наростаючий підсумок: сума від першого платежу до поточного

    Різницю дає лише ORDER BY всередині вікна. Це найчастіше джерело здивування: «чому в мене замість тоталу пораховане щось інше на кожному рядку?» — бо з ORDER BY увімкнулася рамка «наростаючим підсумком».

    Де вікно рахується і чому не можна WHERE

    Головне обмеження, на якому валяться на співбесіді: віконні функції можна писати лише в SELECT і в ORDER BY запиту. Не в WHERE, не в GROUP BY, не в HAVING. Причина — у логічному порядку виконання запиту: вікно рахується вже після того, як WHERE і GROUP BY відпрацювали.

    FROM / JOIN

    WHERE

    GROUP BY

    HAVING

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

    SELECT / DISTINCT

    ORDER BY

    LIMIT / OFFSET

    FROM / JOIN

    WHERE

    GROUP BY

    HAVING

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

    SELECT / DISTINCT

    ORDER BY

    LIMIT / OFFSET

    Коли виконується WHERE, колонки з ROW_NUMBER() ще не існує — її порахують пізніше. Тому запит WHERE row_number = 1 не те що не працює, а взагалі не компілюється. Обхід один: обгорнути запит у підзапит або CTE (WITH), щоб віконна колонка стала «звичайною», і фільтрувати вже зовнішнім WHERE (механіка CTE — /sql/pidzapyty-i-cte-operatsii-z-mnozhynamy).

    Нумерація: ROW_NUMBER, RANK, DENSE_RANK

    Три функції нумерують рядки в партиції за ORDER BY. Різниця — тільки в тому, як вони поводяться на «нічиях» (ties), коли значення сортування однакові:

    ФункціяОднакові значенняНаступний номерПослідовність
    ROW_NUMBER()різні номери1, 2, 3, 4
    RANK()той самий рангз пропуском1, 2, 2, 4
    DENSE_RANK()той самий рангбез пропуску1, 2, 2, 3

    ROW_NUMBER() завжди дає унікальні номери, навіть коли значення рівні — просто розкидає їх у якомусь порядку. RANK() дає рівним рядкам однаковий ранг, а далі «перестрибує» (після двох других іде четвертий). DENSE_RANK() теж дає рівним однаковий ранг, але не пропускає (після двох других іде третій).

    SELECT
      category,
      product,
      price,
      ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn,
      RANK()       OVER (PARTITION BY category ORDER BY price DESC) AS rnk
    FROM products;

    Тут для кожної категорії окремо нумеруємо товари від найдорожчого. Хочете «топ-3 у кожній категорії» — фільтруєте rn <= 3 у зовнішньому запиті. Але обережно: якщо два товари мають однакову ціну на третьому місці, ROW_NUMBER лишить лише один із них (номери 3 і 4), а RANK — обидва (обидва з рангом 3). Вибір функції — це вже вимога до перевірки, а не деталь синтаксису.

    LAG і LEAD: погляд на сусідній рядок

    LAG і LEAD дістають значення з іншого рядка партиції відносно поточного, за порядком ORDER BY:

    • LAG(column, offset, default) — з попереднього рядка (на offset назад; за замовчуванням на 1).
    • LEAD(column, offset, default) — з наступного рядка.

    Це інструмент «порівняй із сусідом»: різниця між поточним і попереднім значенням, час між подіями, пошук розривів у послідовності.

    SELECT
      event_at,
      status,
      LAG(status) OVER (PARTITION BY order_id ORDER BY event_at) AS prev_status
    FROM order_events;

    Поруч із кожною подією тепер видно попередній статус того самого замовлення. Звідси легко перевірити коректність переходів станів: чи не стрибнув статус із created одразу в delivered, оминувши paid. Для першого рядка партиції попереднього немає, тому LAG поверне NULL (або те, що вказали третім аргументом як default). Пам'ятайте про тризначну логіку: порівняння з NULL дає не false, а NULL, тож умови навколо LAG/LEAD треба писати з оглядкою на це (пастки NULL/sql/funktsii-case-i-pastky-null).

    Агрегати як віконні функції

    Будь-який знайомий агрегат — SUM, COUNT, AVG, MIN, MAX — стає віконним, щойно ви додаєте OVER (...). Два типові режими:

    • Тотал по партиції (без ORDER BY у вікні): однакове агреговане значення в усіх рядках групи поруч із деталями. Зручно порахувати частку рядка від суми групи: amount / SUM(amount) OVER (PARTITION BY user_id).
    • Наростаючий підсумокORDER BY у вікні): SUM(amount) OVER (ORDER BY paid_at) дає баланс, що накопичується рядок за рядком — саме те, що показують у банківських виписках і дашбордах.

    Це найчастіший практичний привід дістати вікна в роботі QA: звірити «running total» з UI без експорту в Excel, порахувати ковзне середнє, перевірити відсоток від загального. Для сеньйорського рівня варто знати нюанс рамки: за замовчуванням із ORDER BY використовується RANGE, і рядки з однаковим значенням сортування трактуються як один «пік» — усі вони отримають однакову накопичену суму. Якщо потрібен строго порядковий підсумок «рядок за рядком», задавайте рамку явно через ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Ця відмінність RANGE vs ROWS — джерело тонких розбіжностей у сумах, які легко списати на баг застосунку, хоча винен запит перевірки.

    Дедуплікація через ROW_NUMBER

    Класичний прийом, який просять на співбесідах майже дослівно: «є дублікати, лишіть по одному». Ідея — пронумерувати рядки всередині кожної групи «однакових» за якимось порядком і лишити тільки номер 1.

    WITH ranked AS (
      SELECT
        id,
        email,
        ROW_NUMBER() OVER (
          PARTITION BY email
          ORDER BY created_at DESC, id DESC
        ) AS rn
      FROM users
    )
    DELETE FROM users
    WHERE id IN (SELECT id FROM ranked WHERE rn > 1);

    Розбір: PARTITION BY email збирає в групу всі рядки з однаковою поштою; ORDER BY created_at DESC ставить найсвіжіший першим; rn = 1 — той, що лишаємо, решта (rn > 1) — під видалення. Замість DELETE цей самий шаблон працює і як діагностика — просто SELECT ... WHERE rn > 1 покаже, скільки й яких дублікатів є, перш ніж щось чіпати (дисципліна змін даних — /sql/zmina-danykh-insert-update-delete).

    Критична деталь — детермінований ORDER BY. Якщо сортувати лише за неунікальним полем (наприклад, тільки за created_at, а дві копії створені в ту саму мілісекунду), СУБД лишить «якийсь» рядок на власний розсуд, і на різних прогонах це може бути різний рядок. Тому в ORDER BY майже завжди додають унікальний тайбрейкер (id), щоб результат був відтворюваним — інакше ваша чистка чи перевірка стають плаваючими, як флакі-тест.

    Віконні функції vs GROUP BY

    Це питання на співбесіді перевіряє, чи розумієте ви саму суть, а не синтаксис.

    АспектGROUP BYВіконна функція
    Кількість рядків на виходіОдин на групу (згортає)Стільки ж, скільки на вході
    Доступ до деталей рядкаВтрачаєтьсяЗберігається
    Де можна фільтрувати результатHAVINGТільки через підзапит/CTE
    Типова задача«Скільки/скільки разом на групу»«Ранг, попередній, наростаючий підсумок, дедуп»

    Правило вибору просте: якщо потрібен один підсумковий рядок на групу — GROUP BY. Якщо потрібно лишити всі рядки й дописати до кожного щось, пораховане по групі, — віконна функція. Часто вони й доповнюють одна одну: спершу згорнули агрегацією, потім проранжували результат вікном.

    Типові помилки

    • Виглядає як «фільтр не працює», а насправді вікно ще не пораховане. WHERE row_number = 1 не компілюється, бо WHERE виконується раніше за віконні функції. Лікується обгортанням у CTE/підзапит.
    • Виглядає як «топ-3», а насправді загубили нічиї. ROW_NUMBER з rn <= 3 мовчки викине рядки з однаковим значенням на межі. Якщо треба лишити всі рівні — це RANK/DENSE_RANK, а не ROW_NUMBER.
    • Виглядає як per-user тотал, а насправді глобальний. Забули PARTITION BY — і функція взяла всю таблицю за одну партицію. Сума «по користувачу» перетворюється на суму по всіх.
    • Виглядає як стабільна дедуплікація, а насправді лотерея. ORDER BY без унікального тайбрейкера лишає недетермінований рядок; на наступному прогоні зникне інша копія.
    • Виглядає як «вікно відфільтрувало зайве», а насправді ні. Віконна функція ніколи не зменшує кількість рядків. SUM(...) OVER (...) не згортає — щоб відкинути рядки, потрібен окремий WHERE/CTE.
    • Виглядає як баг у сумах застосунку, а насправді рамка. Різниця RANGE (за замовчуванням) і ROWS дає різний наростаючий підсумок на рядках з однаковим значенням сортування.

    Підсумок

    • Віконна функція рахує по набору рядків, не згортаючи їх: кожен рядок лишається, поруч з'являється порахована колонка. Вмикає режим ключове слово OVER.
    • PARTITION BY — це «групи», ORDER BY у вікні — порядок усередині групи (і вмикач наростаючого підсумку для агрегатів).
    • ROW_NUMBER — унікальні номери; RANK — однаковий ранг із пропуском; DENSE_RANK — однаковий ранг без пропуску.
    • Віконні функції живуть лише в SELECT і ORDER BY; щоб фільтрувати за їхнім результатом, потрібен підзапит або CTE.
    • Дедуплікація = ROW_NUMBER у партиції з детермінованим ORDER BY, лишаємо rn = 1.

    Що питають на співбесіді

    • «Чим ROW_NUMBER, RANK і DENSE_RANK відрізняються?» Інтерв'юер хоче почути саме поведінку на нічиях: унікальні номери проти однакового рангу, з пропуском чи без. Бонус — коли який доречний (топ-N з нічиями — RANK, дедуп — ROW_NUMBER).
    • «Як видалити дублікати, лишивши один рядок?» Очікують шаблон ROW_NUMBER + PARTITION BY ключ + ORDER BY + видалення rn > 1. Сильний кандидат сам згадає про детермінований порядок і про те, що спершу варто зробити SELECT, а не одразу DELETE.
    • «Різниця між віконною функцією і GROUP BY Ключове слово — «згортає / не згортає». Хто відповідає «вікно лишає всі рядки», той зрозумів суть.
    • «Чому не можна WHERE row_number = 1 Перевірка розуміння логічного порядку виконання. Правильна відповідь: WHERE відпрацьовує до того, як пораховане вікно; обгортаємо в CTE.
    • «Порахуйте наростаючий підсумок / знайдіть топ-N на групу / порівняйте рядок із попереднім.» Дивляться, чи потягнетеся ви за SUM ... OVER (ORDER BY ...), ROW_NUMBER/RANK і LAG — чи почнете городити корельовані підзапити.

    Джерела

    Тем баз даних немає в силабусі ISTQB CTFL 4.0 — це прикладна для QA навичка, тож канонічне джерело тут — офіційна документація СУБД, а не силабус.