Aggregate Functions

Групування даних в MS SQL Server

Глибоке дослідження GROUP BY, HAVING, ROLLUP, CUBE і GROUPING SETS для багаторівневого аналізу даних

Групування даних в MS SQL Server

Проблема: Чому не можна просто додати стовпець до агрегату?

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

-- ❌ ЦЕ НЕ СПРАЦЮЄ!
SELECT
    GroupName,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id;

Помилка:

Column 'Groups.GroupName' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.

Чому виникає помилка?

Loading diagram...
graph LR
    subgraph Що хоче повернути GroupName
        A1[30PR11]
        A2[30PR12]
        A3[32SS11]
        A4[32SS12]
    end

    subgraph Що повертає COUNT
        B[Одне число: 10]
    end

    subgraph Проблема
        C[❌ 4 значення ≠ 1 значення]
    end

    A1 --> C
    A2 --> C
    A3 --> C
    A4 --> C
    B --> C

    style C fill:#ef4444,color:#fff

Пояснення: COUNT(*) повертає одне число (загальну кількість студентів), а GroupName хоче повернути багато значень (назви всіх груп). SQL не знає, як поєднати багато з одним.

Уявний неможливий результат:

GroupNamestudent_count
30PR11
30PR12
32SS11
32SS12
10
Конфлікт кардинальності: Агрегатна функція згортає N рядків у 1, а неагрегований стовпець залишає N рядків. Це логічна суперечність!

Рішення: GROUP BY

GROUP BY вирішує цю проблему, дозволяючи розділити дані на групи та застосувати агрегатну функцію до кожної групи окремо.

Концепція групування

Loading diagram...
graph TD
    A[Всі студенти<br/>10 записів] --> B{GROUP BY GroupName}

    B --> C[Група: 30PR11<br/>3 студенти]
    B --> D[Група: 30PR12<br/>3 студенти]
    B --> E[Група: 32SS11<br/>2 студенти]
    B --> F[Група: 32SS12<br/>2 студенти]

    C --> G[COUNT = 3]
    D --> H[COUNT = 3]
    E --> I[COUNT = 2]
    F --> J[COUNT = 2]

    style B fill:#3b82f6,color:#fff
    style G fill:#10b981,color:#fff
    style H fill:#10b981,color:#fff
    style I fill:#10b981,color:#fff
    style J fill:#10b981,color:#fff

Правильний запит з GROUP BY

-- ✅ ПРАВИЛЬНО
SELECT
    GroupName,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName;

Результат:

GroupNamestudent_count
30PR113
30PR123
32SS112
32SS122

Анатомія виконання:

  1. JOIN з'єднує таблиці Students і Groups
  2. GROUP BY GroupName розділяє рядки на групи по назві групи
  3. COUNT(*) рахує кількість студентів у кожній групі окремо
  4. Кожна група стає одним рядком у результаті

Як SQL Server виконує GROUP BY під капотом?

Візуалізація процесу

Loading diagram...
@startuml
skinparam backgroundColor #1e1e2e
skinparam defaultFontColor #cdd6f4
skinparam sequenceMessageAlign center

participant "SQL Query" as Q #89b4fa
participant "Scan Table" as S #a6e3a1
participant "Group Data" as G #f9e2af
participant "Aggregate" as A #f38ba8
participant "Result" as R #cba6f7

Q -> S: 1. Читання рядків
activate S
S -> S: Студент 1: GroupId=1
S -> S: Студент 2: GroupId=1
S -> S: Студент 3: GroupId=2
deactivate S

S -> G: 2. Формування груп
activate G
G -> G: Група 1: [Студент 1, Студент 2]
G -> G: Група 2: [Студент 3]
deactivate G

G -> A: 3. Агрегування кожної групи
activate A
A -> A: Група 1: COUNT(*) = 2
A -> A: Група 2: COUNT(*) = 1
deactivate A

A -> R: 4. Повернення результату
activate R
R -> R: Row 1: GroupName='30PR11', Count=2
R -> R: Row 2: GroupName='30PR12', Count=1
deactivate R

@enduml

Два алгоритми групування

SQL Server може використовувати різні фізичні алгоритми для GROUP BY:

Умова: Дані вже відсортовані по стовпцю групування (наприклад, є індекс)

Принцип роботи:

Відсортовані дані:
GroupName    | student_id
-------------|------------
30PR11       | 1          ← Група 1 почалася
30PR11       | 2          │
30PR11       | 3          ← Група 1 закінчилась, COUNT=3
30PR12       | 4          ← Група 2 почалася
30PR12       | 5          │
30PR12       | 6          ← Група 2 закінчилась, COUNT=3

Продуктивність:

  • ⚡ O(n) — дуже швидко
  • 💾 Мало пам'яті (обробка "на льоту")
Оптимізація: Створіть індекс на стовпці, по якому групуєте:
CREATE INDEX IX_Students_GroupId ON Students(GroupId);
-- Тепер SQL Server використає Stream Aggregate ⚡

Групування по кількох стовпцях

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

Приклад: Кількість студентів з однаковою стипендією в кожній групі

SELECT
    GroupName,
    Grants,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName, Grants;

Результат:

GroupNameGrantsstudent_count
30PR11NULL2
30PR1112001
30PR1211001
30PR1212562
32SS1110001
32SS1113001

Пояснення:

  • У групі 30PR11 є 2 студенти з Grants = NULL (обидва без стипендії)
  • У групі 30PR12 є 2 студенти з Grants = 1256
  • Інші групи: всі студенти мають різні стипендії → кожен утворює окрему групу
Важливо: GROUP BY трактує всі NULL як однакові значення. Тому два студенти з Grants = NULL потраплять в одну групу.

Візуалізація групування по двох стовпцях

Loading diagram...
graph TD
    A[10 студентів] --> B{GROUP BY GroupName, Grants}

    B --> C["Група: (30PR11, NULL)<br/>2 студенти"]
    B --> D["Група: (30PR11, 1200)<br/>1 студент"]
    B --> E["Група: (30PR12, 1100)<br/>1 студент"]
    B --> F["Група: (30PR12, 1256)<br/>2 студенти"]
    B --> G["Група: (32SS11, 1000)<br/>1 студент"]
    B --> H["..."]

    C --> I[COUNT = 2]
    D --> J[COUNT = 1]
    E --> K[COUNT = 1]
    F --> L[COUNT = 2]

    style B fill:#f59e0b,color:#000
    style I fill:#10b981,color:#fff
    style L fill:#10b981,color:#fff

WHERE vs HAVING: Порядок виконання

Найчастіша плутанина при роботі з GROUP BYколи використовувати WHERE, а коли HAVING?

Золоте правило

Ключова відмінність:
  • WHERE фільтрує окремі рядки ДО групування
  • HAVING фільтрує групи ПІСЛЯ агрегування

Порядок виконання SQL запиту

SQL виконує запит не в тому порядку, в якому ви його пишете!

Loading diagram...
sequenceDiagram
    participant Code as SQL Код (порядок написання)
    participant Exec as SQL Engine (порядок виконання)

    Code->>Exec: 1. SELECT
    Code->>Exec: 2. FROM
    Code->>Exec: 3. WHERE
    Code->>Exec: 4. GROUP BY
    Code->>Exec: 5. HAVING
    Code->>Exec: 6. ORDER BY

    Note over Exec: Реальний порядок виконання:
    Exec->>Exec: 1. FROM - з'єднання таблиць
    Exec->>Exec: 2. WHERE - фільтрація рядків
    Exec->>Exec: 3. GROUP BY - групування
    Exec->>Exec: 4. HAVING - фільтрація груп
    Exec->>Exec: 5. SELECT - вибірка стовпців
    Exec->>Exec: 6. ORDER BY - сортування

Приклад 1: WHERE — фільтрація ДО групування

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

SELECT
    GroupName,
    COUNT(*) AS students_with_grants
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
WHERE S.Grants IS NOT NULL  -- Фільтруємо РЯДКИ (окремих студентів)
GROUP BY GroupName;

Порядок виконання:

  1. FROM + JOIN — з'єднуємо таблиці (10 студентів)
  2. WHERE — залишаємо тільки студентів зі стипендією (8 студентів)
  3. GROUP BY — групуємо 8 студентів по групах
  4. COUNT(*) — рахуємо кількість у кожній групі

Результат:

GroupNamestudents_with_grants
30PR111
30PR123
32SS112
32SS122

Приклад 2: HAVING — фільтрація ПІСЛЯ групування

Завдання: Знайти групи, в яких більше 2 студентів.

SELECT
    GroupName,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName
HAVING COUNT(*) > 2;  -- Фільтруємо ГРУПИ (не окремих студентів!)

Порядок виконання:

  1. FROM + JOIN — з'єднуємо таблиці (10 студентів)
  2. GROUP BY — групуємо всіх студентів по групах
  3. COUNT(*) — рахуємо кількість студентів у кожній групі
  4. HAVING — залишаємо тільки групи, де COUNT(*) > 2

Результат:

GroupNamestudent_count
30PR113
30PR123

Приклад 3: WHERE І HAVING разом

Завдання: Знайти групи, в яких більше 1 студента має стипендію більше 1100 грн.

SELECT
    GroupName,
    COUNT(*) AS high_grant_students
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
WHERE S.Grants > 1100           -- 1. Фільтруємо РЯДКИ
GROUP BY GroupName
HAVING COUNT(*) > 1;             -- 2. Фільтруємо ГРУПИ

Покрокове виконання:

Крок 1: FROM + JOIN

10 студентів з'єднано з їх групами

Крок 2: WHERE S.Grants > 1100

Залишилось 6 студентів:
- 30PR11: 1200
- 30PR12: 1256, 1256
- 32SS11: 1300
- 32SS12: 1300, 1300

Крок 3: GROUP BY GroupName

Групи:
- 30PR11: 1 студент
- 30PR12: 2 студенти
- 32SS11: 1 студент
- 32SS12: 2 студенти

Крок 4: HAVING COUNT(*) > 1

Залишились тільки групи з COUNT > 1:
- 30PR12: 2 студенти
- 32SS12: 2 студенти

Результат:

GroupNamehigh_grant_students
30PR122
32SS122

Групування по обчисленим виразам

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

Приклад: Групування студентів по віковій категорії

SELECT
    CASE
        WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) < 20 THEN 'До 20 років'
        WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) BETWEEN 20 AND 25 THEN '20-25 років'
        ELSE 'Понад 25 років'
    END AS age_category,
    COUNT(*) AS student_count,
    AVG(Grants) AS avg_grant
FROM Students
GROUP BY
    CASE
        WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) < 20 THEN 'До 20 років'
        WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) BETWEEN 20 AND 25 THEN '20-25 років'
        ELSE 'Понад 25 років'
    END;
Важливо: Вираз у GROUP BY має точно збігатися з виразом у SELECT! Інакше буде помилка.

Альтернатива з CTE (Common Table Expression) — більш читабельно:

WITH StudentAges AS (
    SELECT
        *,
        CASE
            WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) < 20 THEN 'До 20 років'
            WHEN DATEDIFF(YEAR, BirthDate, GETDATE()) BETWEEN 20 AND 25 THEN '20-25 років'
            ELSE 'Понад 25 років'
        END AS age_category
    FROM Students
)
SELECT
    age_category,
    COUNT(*) AS student_count,
    AVG(Grants) AS avg_grant
FROM StudentAges
GROUP BY age_category;

ROLLUP: Багаторівневі підсумки

ROLLUP дозволяє створювати ієрархічні підсумки — від деталізованих до загальних.

Базовий приклад

SELECT
    GroupName,
    Grants,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY ROLLUP(GroupName, Grants);

Результат:

GroupNameGrantsstudent_countРівень підсумку
30PR11NULL2Деталізація: група + стипендія
30PR1112001
30PR11NULL3Підсумок по групі 30PR11
30PR1211001Деталізація: група + стипендія
30PR1212562
30PR12NULL3Підсумок по групі 30PR12
NULLNULL10ЗАГАЛЬНИЙ ПІДСУМОК ПО ВСІХ

Як працює ROLLUP?

ROLLUP(A, B, C) створює підсумки для:

  1. GROUP BY A, B, C (найдеталізованіший рівень)
  2. GROUP BY A, B (без C)
  3. GROUP BY A (без B і C)
  4. GROUP BY () (загальний підсумок, A=B=C=NULL)
Loading diagram...
graph TD
    A[ROLLUP GroupName, Grants] --> B[Рівень 1: GROUP BY GroupName, Grants]
    A --> C[Рівень 2: GROUP BY GroupName]
    A --> D[Рівень 3: GRAND TOTAL]

    B --> B1[30PR11 + 1200 = 1 студент]
    B --> B2[30PR11 + NULL = 2 студенти]

    C --> C1[30PR11 = 3 студенти]
    C --> C2[30PR12 = 3 студенти]

    D --> D1[Всього = 10 студентів]

    style A fill:#3b82f6,color:#fff
    style D fill:#ef4444,color:#fff

CUBE: Всі можливі комбінації підсумків

CUBE схожий на ROLLUP, але створює підсумки для ВСІХ можливих комбінацій стовпців.

Різниця між ROLLUP і CUBE

GROUP BY ROLLUP(A, B)

Створює 3 рівні:

  1. GROUP BY A, B
  2. GROUP BY A
  3. GROUP BY () (загальний підсумок)

Приклад: Звіт про продажі з підсумками по регіонах

Приклад CUBE

SELECT
    GroupName,
    CASE WHEN Grants IS NULL THEN 'Без стипендії' ELSE 'Зі стипендією' END AS has_grant,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY CUBE(GroupName, has_grant);

Результат включає:

  • Кількість студентів у кожній групі зі стипендією / без
  • Підсумок по кожній групі (незалежно від стипендії)
  • Підсумок по стипендіях (незалежно від групи) ← Це додає CUBE!
  • Загальний підсумок

GROUPING SETS: Гнучкий контроль підсумків

GROUPING SETS дозволяє вручну вказати, які саме комбінації підсумків потрібні.

Синтаксис

GROUP BY GROUPING SETS (
    (A, B),    -- Група 1
    (A),       -- Група 2
    ()         -- Загальний підсумок
)

Приклад: Вибіркові підсумки

Завдання: Отримати:

  • Кількість студентів у кожній групі
  • Кількість студентів з кожним розміром стипендії
  • Без загального підсумку
SELECT
    GroupName,
    Grants,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GROUPING SETS (
    (GroupName),  -- Тільки по групах
    (Grants)      -- Тільки по стипендіях
);

Результат:

GroupNameGrantsstudent_count
30PR11NULL3
30PR12NULL3
32SS11NULL2
NULLNULL2
NULL10001
NULL11001
NULL12002
Коли використовувати:
  • ROLLUP — ієрархічні дані (Рік → Квартал → Місяць)
  • CUBE — багатовимірний аналіз (всі комбінації)
  • GROUPING SETS — коли потрібні лише конкретні комбінації

GROUPING(): Визначення рівня підсумку

Функція GROUPING() допомагає відрізнити реальні NULL від NULL, які з'явились через ROLLUP/CUBE.

Проблема

SELECT
    GroupName,
    Grants,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY ROLLUP(GroupName, Grants);

У результаті є два види NULL:

  1. Реальний NULL — студент дійсно не має стипендії
  2. NULL від ROLLUP — це підсумковий рядок

Як їх відрізнити?

Рішення: GROUPING()

SELECT
    GroupName,
    Grants,
    COUNT(*) AS student_count,
    GROUPING(GroupName) AS is_group_subtotal,
    GROUPING(Grants) AS is_grant_subtotal
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY ROLLUP(GroupName, Grants);

Результат:

GroupNameGrantsstudent_countis_group_subtotalis_grant_subtotalЩо це означає?
30PR11NULL200Реальні дані
30PR111200100Реальні дані
30PR11NULL301Підсумок по групі 30PR11
NULLNULL1011Загальний підсумок

GROUPING() повертає:

  • 0 — це реальне значення стовпця
  • 1 — це NULL від підсумку (агрегації)

Використання для читабельності

SELECT
    CASE WHEN GROUPING(GroupName) = 1 THEN '=== ВСЬОГО ===' ELSE GroupName END AS GroupName,
    CASE WHEN GROUPING(Grants) = 1 THEN 'Всі стипендії' ELSE CAST(Grants AS NVARCHAR) END AS Grants,
    COUNT(*) AS student_count
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY ROLLUP(GroupName, Grants);

Результат тепер легше читати:

GroupNameGrantsstudent_count
30PR11NULL2
30PR1112001
30PR11Всі стипендії3
30PR1211001
=== ВСЬОГО ===Всі стипендії10

Продуктивність групування: Execution Plans

Benchmark: Stream vs Hash Aggregate

-- Не має індексу на GroupId
SELECT
    GroupId,
    COUNT(*) AS student_count
FROM Students
GROUP BY GroupId;

-- Execution Plan:
-- Hash Aggregate (Cost: 0.50)
--   └─ Table Scan (Cost: 0.50)
Рекомендації оптимізації:
  1. Створіть індекс на стовпці групування
  2. Для групування по кількох стовпцях — composite index
  3. Використовуйте WHERE замість HAVING де можливо (фільтрація раніше = менше даних для групування)

Типові помилки та Anti-Patterns

❌ Помилка 1: Неагрегований стовпець не в GROUP BY

-- ❌ ПОМИЛКА
SELECT
    GroupName,
    FirstName,      -- Цей стовпець НЕ в GROUP BY і НЕ в агрегаті!
    COUNT(*)
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName;

Помилка:

Column 'Students.FirstName' is invalid in the select list
because it is not contained in either an aggregate function
or the GROUP BY clause.

Рішення:

-- ✅ ПРАВИЛЬНО: Додати FirstName в GROUP BY
GROUP BY GroupName, FirstName;

-- АБО використати агрегат, наприклад STRING_AGG (SQL Server 2017+)
SELECT
    GroupName,
    STRING_AGG(FirstName, ', ') AS all_names,
    COUNT(*)
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName;

❌ Помилка 2: Агрегат у WHERE замість HAVING

-- ❌ НЕ ПРАЦЮЄ
SELECT GroupName, COUNT(*) AS cnt
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY GroupName
WHERE COUNT(*) > 2;  -- Помилка: агрегати не можна у WHERE!

Рішення:

-- ✅ ПРАВИЛЬНО
HAVING COUNT(*) > 2;

❌ Anti-Pattern: Зайве GROUP BY

-- ❌ НЕОПТИМАЛЬНО: Групування де не потрібно
SELECT
    product_id,
    product_name,
    price
FROM Products
GROUP BY product_id, product_name, price;

Проблема: Якщо product_id — PRIMARY KEY, то кожен рядок унікальний. GROUP BY тут нічого не робить, але сповільнює запит!

Рішення:

-- ✅ ПРАВИЛЬНО: Просто SELECT
SELECT product_id, product_name, price
FROM Products;

Best Practices

1. Використовуйте псевдоніми для читабельності

-- ✅ Добре
SELECT
    G.GroupName,
    COUNT(*) AS student_count,
    AVG(S.Grants) AS avg_grant
FROM Students AS S
JOIN Groups AS G ON S.GroupId = G.Id
GROUP BY G.GroupName;

2. WHERE перед GROUP BY де можливо

-- ✅ Швидше: фільтрація 10000 → 5000 рядків ДО групування
SELECT category, COUNT(*)
FROM Products
WHERE price > 1000  -- Відсікаємо половину ПЕРЕД групуванням
GROUP BY category;

-- ❌ Повільніше: групуємо всі 10000, потім фільтруємо
SELECT category, COUNT(*)
FROM Products
GROUP BY category
HAVING AVG(price) > 1000;  -- Якщо можна у WHERE, не використовуйте HAVING

3. Індекси на стовпці групування

CREATE INDEX IX_Products_Category ON Products(category);
-- Прискорює GROUP BY category в 5-10 разів

4. ROLLUP/CUBE тільки коли потрібно

-- Якщо не потрібні проміжні підсумки:
GROUP BY category, region  -- Швидше

-- Якщо потрібні підсумки:
GROUP BY ROLLUP(category, region)  -- Повільніше, але корисно

Практичні завдання

Базове групування

Завдання: У базі даних Orders знайдіть:

  1. Кількість замовлень кожного клієнта
  2. Загальну суму замовлень кожного клієнта
  3. Середній чек кожного клієнта

WHERE + HAVING

Завдання: Знайдіть клієнтів, які зробили більше 5 замовлень у 2024 році з середнім чеком > 1000 грн

ROLLUP для звітності

Завдання: Створіть звіт продажів з підсумками по регіонах та категоріях


Резюме

Ключові висновки:
  1. GROUP BY розділяє дані на групи та застосовує агрегатні функції до кожної групи
  2. WHERE фільтрує рядки ДО групування, HAVING — групи ПІСЛЯ
  3. Порядок виконання: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
  4. ROLLUP створює ієрархічні підсумки
  5. CUBE створює всі можливі комбінації підсумків
  6. GROUPING SETS дозволяє вибрати конкретні комбінації
  7. GROUPING() відрізняє реальні NULL від NULL підсумків
  8. Індекси прискорюють групування у 5-10 разів
Що далі?У наступному розділі ми вивчимо:
  • Підзапити з агрегатними функціями
  • Correlated subqueries
  • Common Table Expressions (CTE)
  • Window Functions як сучасна альтернатива
Перейти до розділу "Підзапити з агрегатами" →

Додаткові ресурси

Copyright © 2026