Агрегація: GROUP BY і HAVING
Зміст
Майже кожна звірка QA рано чи пізно впирається в питання «а скільки їх?». Скільки замовлень у користувача, скільки рядків із таким статусом, чи збігається кількість у списку на екрані з кількістю в базі, чи немає в таблиці двох акаунтів на один email. На всі ці питання відповідає агрегація — механізм, який згортає багато рядків в один підсумковий.
Тема має високу вагу на співбесідах з двох причин. По-перше, вона всюди в реальній роботі: звіти, дашборди, метрики, пошук дублікатів — усе це агрегати. По-друге, тут легко «попливти»: різниця між COUNT(*) і COUNT(col), правило GROUP BY, вибір між WHERE і HAVING — це саме ті деталі, на яких видно, чи людина розуміє, як база обчислює запит, чи вгадує синтаксис. Спершу розберемо самі агрегатні функції, потім групування, потім логічний порядок виконання запиту — ключ, який пояснює половину «дивних» помилок, — і завершимо двома прикладними сюжетами: пошуком дублікатів і агрегацією поверх JOIN.
Агрегатні функції: COUNT, SUM, AVG, MIN, MAX
Агрегатна функція (aggregate function) бере набір рядків і повертає одне значення. П'ять базових:
| Функція | Що повертає |
|---|---|
COUNT | кількість |
SUM | суму числових значень |
AVG | середнє арифметичне |
MIN | найменше значення |
MAX | найбільше значення |
MIN і MAX працюють не лише з числами: для рядків це алфавітний порядок, для дат — хронологічний, тож MAX(created_at) — це «остання за часом». Без GROUP BY агрегат згортає всю таблицю в один рядок:
SELECT COUNT(*), SUM(amount), AVG(amount), MIN(amount), MAX(amount)
FROM orders;
Головна деталь, яку треба засвоїти раз і назавжди: агрегати ігнорують NULL. SUM, AVG, MIN, MAX і COUNT(col) пропускають рядки, де значення — NULL, ніби їх немає. Це логічне продовження того, що NULL — це «невідомо» (докладно — у главі про NULL): невідоме не можна ні додати, ні усереднити.
Найпідступніший наслідок — AVG. Він ділить суму на кількість не-NULL значень, а не на кількість рядків:
-- Колонка discount: значення 10, 20 і NULL (три рядки)
SELECT AVG(discount) FROM orders; -- 15, а не 10
Інтуїція «третій рядок без знижки, отже знижка нуль, середнє = 30/3 = 10» — хибна. База бачить два відомих значення й одне невідоме, тож рахує 30 / 2 = 15. Якщо за бізнес-логікою відсутня знижка — це саме нуль, NULL треба замінити явно: AVG(COALESCE(discount, 0)). Ця різниця — типове джерело розбіжності «середнє в SQL не збігається із середнім в UI».
Друга пастка тієї ж родини — агрегат поверх порожнього набору. COUNT над нулем рядків поверне 0, а от SUM, AVG, MIN, MAX повернуть NULL, а не нуль:
SELECT SUM(amount) FROM orders WHERE customer_id = 999; -- немає таких → NULL, не 0
Тому у звітах суму часто загортають у COALESCE(SUM(amount), 0), щоб порожній результат показувався як нуль, а не як пусто.
COUNT(*) vs COUNT(col) vs COUNT(DISTINCT)
Три форми COUNT рахують різні речі, і плутанина між ними — класика і в багах, і на співбесідах.
| Форма | Що рахує |
|---|---|
COUNT(*) | усі рядки, разом із тими, де скрізь NULL |
COUNT(col) | рядки, де col не NULL |
COUNT(DISTINCT col) | різні не-NULL значення col |
Розглянемо таблицю users із 10 рядків, де в колонці phone заповнено 7, а 3 — NULL, і серед заповнених два номери однакові:
SELECT
COUNT(*) AS rows_total, -- 10: усі рядки
COUNT(phone) AS phones_filled, -- 7: не-NULL значення
COUNT(DISTINCT phone) AS phones_unique; -- 6: унікальні номери
COUNT(*) — це «скільки рядків узагалі»; NULL його не обходить, бо він рахує рядки, а не значення. COUNT(phone) — «у скількох є телефон». COUNT(DISTINCT phone) — «скільки різних телефонів». Практична цінність для QA: коли кількість у SQL не збігається з UI, перше питання — яку саме кількість рахує кожна сторона. UI показує «7 користувачів з телефоном», а ти рахуєш COUNT(*) і отримуєш 10 — розбіжність не в даних, а в тому, що ти рахуєш не те.
COUNT(1) — те саме, що COUNT(*): рахує рядки. Розмови «COUNT(1) швидший за COUNT(*)» — міф; сучасні оптимізатори обробляють їх однаково.
GROUP BY: одне значення на групу
Агрегат над усією таблицею — це один підсумок. Але зазвичай потрібен підсумок у розрізі: скільки замовлень у кожного користувача, сума по кожній валюті. Для цього є GROUP BY: він розбиває рядки на групи за однаковим значенням ключа, і агрегат рахується окремо в кожній групі. На виході — по одному рядку на групу.
SELECT status, COUNT(*) AS cnt
FROM orders
GROUP BY status;
Групувати можна за кількома колонками одразу — тоді група утворюється на кожну унікальну комбінацію значень:
SELECT status, currency, COUNT(*), SUM(amount)
FROM orders
GROUP BY status, currency;
Тепер головне правило, яке ламає більшість перших запитів із GROUP BY:
Кожна колонка у SELECT має бути або в GROUP BY, або всередині агрегатної функції.
Причина — механічна, а не формальна. Один рядок на виході відповідає цілій групі. Якщо ти просиш колонку, якої немає в ключі групування й немає під агрегатом, база не знає, яке з багатьох значень групи показати:
-- Помилка: email не в GROUP BY і не під агрегатом
SELECT status, email, COUNT(*)
FROM users
GROUP BY status;
-- У групі 'active' сотні різних email — котрий вивести?
PostgreSQL на такий запит чесно віддасть помилку column "users.email" must appear in the GROUP BY clause or be used in an aggregate function. І тут — важлива пастка діалектів: MySQL історично цього не забороняв і мовчки повертав довільне значення з групи. Починаючи з версії 5.7.5 MySQL за замовчуванням вмикає режим ONLY_FULL_GROUP_BY, який приводить поведінку до стандарту, але на старих базах або з вимкненим режимом ти можеш отримати запит, що «працює» й тихо бреше — показує email якогось випадкового рядка групи. Якщо бачиш такий SELECT — це не «коротший стиль», це потенційно неправильний звіт.
Виняток, який варто знати: якщо в GROUP BY є первинний ключ таблиці, решту її колонок можна вибирати без агрегату — вони функціонально залежні від ключа, тож двозначності немає. І PostgreSQL, і MySQL з ONLY_FULL_GROUP_BY таке розпізнають: SELECT c.id, c.name, COUNT(*) … GROUP BY c.id — легальний запит. Утім, переносніше й читабельніше явно перелічити колонки в GROUP BY.
Ще дрібниця, яка витікає з семантики NULL: у GROUP BY усі NULL потрапляють в одну групу. Попри те що NULL = NULL дає UNKNOWN, групування використовує не рівність, а «не-різність», тож рядки з NULL-ключем збираються разом в один рядок результату.
HAVING проти WHERE
Коли треба відфільтрувати не рядки, а групи — наприклад, «покажи лише статуси, яких понад 100», — постає питання, куди писати умову. Відповідь: WHERE фільтрує рядки до групування, HAVING фільтрує групи після агрегації.
SELECT customer_id, COUNT(*) AS orders_cnt
FROM orders
WHERE status = 'paid' -- відсіює рядки ДО групування
GROUP BY customer_id
HAVING COUNT(*) > 5; -- відсіює групи ПІСЛЯ підрахунку
Цей запит читається так: «серед оплачених замовлень знайди клієнтів, у яких таких понад п'ять». WHERE прибирає неоплачені рядки ще до того, як утворилися групи; HAVING працює вже над готовими групами й може посилатися на агрегат.
Звідси — головне обмеження, яке щоразу спантеличує новачків: в WHERE не можна вжити агрегатну функцію.
-- Помилка: агрегат у WHERE
SELECT customer_id FROM orders WHERE COUNT(*) > 5 GROUP BY customer_id;
-- COUNT(*) на момент WHERE ще не існує — групи не створені
На момент, коли працює WHERE, груп ще немає, отже й COUNT(*) рахувати нема над чим. Умову на агрегат приймає лише HAVING. Дзеркально: умову на окремий рядок (status = 'paid') технічно можна написати й у HAVING, але це помилка за змістом — тоді база спершу згрупує зайві рядки й лише потім їх відкине. Правило просте:
| WHERE | HAVING | |
|---|---|---|
| Коли працює | до GROUP BY | після GROUP BY |
| Над чим | над окремими рядками | над групами |
| Можна агрегат | ні | так |
| Типова умова | status = 'paid' | COUNT(*) > 5 |
Практичний наслідок для швидкодії: умову, яку можна поставити у WHERE, треба ставити у WHERE — так база відкидає рядки раніше й групує менше. Фільтрувати у HAVING те, що стосується окремого рядка, — марна робота.
(HAVING можна вжити й без GROUP BY: тоді вся таблиця — одна група, і SELECT COUNT(*) FROM orders HAVING COUNT(*) > 1000 поверне рядок лише якщо замовлень понад тисячу.)
Логічний порядок виконання запиту
Ми пишемо запит у порядку SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY, але база обчислює його в іншому порядку. Розуміння цього логічного порядку (logical query processing order) — той самий ключ, що пояснює і чому агрегат заборонений у WHERE, і чому аліас із SELECT не завжди видно в інших частинах запиту.
Це логічний порядок: він описує, який результат має вийти, а не те, як фізично працює двигун — оптимізатор вільний переставляти кроки, доки відповідь така сама. Але саме з цієї послідовності випливають правила, які інакше здаються сваволею:
- Агрегат не можна в WHERE, бо WHERE (крок 2) відпрацьовує раніше за GROUP BY (крок 3) — груп, а отже й
COUNT(*), ще не існує. - Аліас із SELECT не видно у WHERE, GROUP BY і HAVING, бо SELECT (крок 5) обчислюється пізніше.
WHERE cnt > 5, деcnt— аліасCOUNT(*), у стандарті не спрацює. - Аліас видно в ORDER BY, бо сортування (крок 7) іде після SELECT.
ORDER BY cnt DESC— коректно.
Останні два пункти мають нюанс діалектів: MySQL і PostgreSQL як розширення дозволяють аліас у GROUP BY (а MySQL — і в HAVING), але переносний, гарантований стандартом варіант — аліас лише в ORDER BY. Якщо запит із аліасом у GROUP BY «не завівся» на іншій СУБД — причина саме тут.
Пошук дублікатів через HAVING COUNT(*) > 1
Це, мабуть, найчастіший практичний прийом агрегації в роботі QA. Питання «чи немає в таблиці двох записів там, де має бути один» розв'язується одним патерном: згрупувати за колонкою, яка мусить бути унікальною, і лишити групи, де рядків більше одного.
-- Чи є два акаунти на один email?
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
Порожній результат — унікальність тримається; будь-які рядки — дублікати, з якими треба розбиратися. Це найшвидший спосіб перевірити те, що начебто гарантує обмеження UNIQUE, але могло зламатися при міграції даних, імпорті чи гонці на рівні застосунку (докладніше про перевірку цілісності — у главі про тестування БД).
Дублікат «за комбінацією полів» шукається так само — просто ключем стає кілька колонок:
-- Два бронювання на той самий номер і ту саму дату
SELECT room_id, booking_date, COUNT(*)
FROM bookings
GROUP BY room_id, booking_date
HAVING COUNT(*) > 1;
Один нюанс, який відрізняє впевнену відповідь на співбесіді: цей запит показує, які значення задубльовані й скільки разів, але не самі рядки-дублікати (з їхніми id). Щоб дістати конкретні рядки — наприклад, аби вирішити, який лишити, — потрібен підзапит або віконна функція ROW_NUMBER; це вже територія глави про підзапити й CTE і глави про віконні функції.
Агрегація поверх JOIN
Найтонші помилки з агрегатами трапляються тоді, коли рахуєш не по одній таблиці, а поверх з'єднання. Причина одна: JOIN зі зв'язком «один до багатьох» розмножує рядки, і агрегат рахує вже роздутий набір.
Уяви customers і orders у зв'язку 1:N. Якщо в клієнта 3 замовлення, після JOIN рядок цього клієнта повторюється тричі. Тепер небезпечні запити:
-- Скільки клієнтів зробили замовлення?
SELECT COUNT(*) FROM customers c JOIN orders o ON o.customer_id = c.id;
-- НЕ кількість клієнтів, а кількість замовлень: рядки розмножені JOIN-ом
COUNT(*) тут рахує рядки після з'єднання, тобто замовлення, а не клієнтів. Правильно — рахувати унікальних клієнтів:
SELECT COUNT(DISTINCT c.id) FROM customers c JOIN orders o ON o.customer_id = c.id;
Ось де COUNT(DISTINCT) з абстрактної форми стає щоденним інструментом: він знімає дублікати, які створив JOIN.
Друга, ще підступніша ситуація — LEFT JOIN і підрахунок по кожному клієнту. Порахуймо замовлення на кожного клієнта, включно з тими, хто не зробив жодного:
SELECT c.id, c.name, COUNT(o.id) AS orders_cnt
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
Ключова деталь — COUNT(o.id), а не COUNT(*). При LEFT JOIN клієнт без замовлень усе одно дає один рядок, але колонки з orders у ньому — NULL. COUNT(o.id) пропускає цей NULL і чесно повертає 0. А COUNT(*) порахував би той рядок-заглушку й повернув би 1 — «клієнт без замовлень має одне замовлення». Ось де три форми COUNT із початку глави сходяться з поведінкою JOIN в один робочий приклад: правильний вибір COUNT(col) замість COUNT(*) — це різниця між правдивим і брехливим звітом.
Абзац глибини — подвійний рахунок при двох JOIN-ах (fan-out, «пастка віяла»). Якщо приєднати до orders одночасно дві дочірні таблиці 1:N — скажімо, order_items і payments, — рядки перемножаться, і SUM(payments.amount) порахує кожен платіж стільки разів, скільки в замовлення позицій. Сума роздується, а помилку майже не видно оком. Захист — агрегувати кожну гілку окремо (підзапитом чи CTE) перед з'єднанням, або звіряти SUM із контрольним значенням. Якщо сума в SQL «трохи більша», ніж має бути, а в даних усе гаразд — перша підозра саме на подвійний JOIN.
Типові помилки
- Виглядає як «
AVGпорахує середнє по всіх рядках», а насправді він ділить на кількість не-NULL значень: рядки з NULL не входять у знаменник, і середнє виходить вищим, ніж «сума / кількість рядків». - Виглядає як «
SUMнад порожньою вибіркою дасть 0», а насправді він дасть NULL (нуль повертає лишеCOUNT); щоб у звіті був нуль, потрібенCOALESCE(SUM(x), 0). - Виглядає як «
COUNT(*)іCOUNT(col)— те саме», а насправдіCOUNT(*)рахує всі рядки, аCOUNT(col)— лише ті, де колонка не NULL; звідси й розбіжність із кількістю в UI. - Виглядає як «можна вибрати будь-яку колонку разом з GROUP BY», а насправді незгрупована колонка без агрегату — це або помилка (PostgreSQL), або довільне значення з групи (старий MySQL без
ONLY_FULL_GROUP_BY). - Виглядає як «фільтр на
COUNT(*)можна поставити у WHERE», а насправді агрегат у WHERE неможливий — груп ще немає; умова на агрегат живе тільки в HAVING. - Виглядає як «
WHERE cnt > 5спрацює, боcnt— це мійCOUNT(*)», а насправді аліас із SELECT у WHERE не видно: SELECT обчислюється пізніше за WHERE у логічному порядку. - Виглядає як «
COUNT(*)після JOIN порахує клієнтів», а насправді він рахує роздуті 1:N-рядки, тобто замовлення; клієнтів даєCOUNT(DISTINCT c.id). - Виглядає як «при LEFT JOIN клієнт без замовлень матиме нуль через
COUNT(*)», а насправдіCOUNT(*)порахує рядок-заглушку як1; нуль дає лишеCOUNT(o.id), який пропускає NULL.
Підсумок
- Агрегати
SUM,AVG,MIN,MAX,COUNT(col)ігнорують NULL; над порожнім набором вони повертають NULL, і тількиCOUNTповертає нуль. COUNT(*)рахує всі рядки,COUNT(col)— не-NULL значення,COUNT(DISTINCT col)— різні не-NULL значення; плутанина між ними — головне джерело розбіжностей із UI.- Правило GROUP BY: кожна колонка у SELECT має бути в GROUP BY або під агрегатом — інакше база не знає, яке зі значень групи показати.
- WHERE фільтрує рядки до групування й не приймає агрегатів; HAVING фільтрує групи після агрегації — це прямий наслідок логічного порядку виконання запиту (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT).
- Поверх JOIN 1:N рядки розмножуються: рятує
COUNT(DISTINCT), а при LEFT JOIN —COUNT(col)замістьCOUNT(*), щоб рядки без пари не рахувалися як одиниця.
Що питають на співбесіді
- «Чим відрізняються
COUNT(*),COUNT(col)іCOUNT(DISTINCT col)?» — базовий фільтр. Сильна відповідь не переказує означення, а пояснює наслідок:COUNT(col)менший заCOUNT(*)рівно на кількість NULL, аDISTINCTще й прибирає повтори. - «Яка різниця між WHERE і HAVING?» — очікують не «HAVING для агрегатів», а механізм: WHERE працює до групування над рядками, HAVING — після, над групами; тому агрегат у WHERE неможливий.
- «У якому порядку виконується SQL-запит?» — перевірка глибини. Треба назвати логічний порядок (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT) і пояснити хоч один наслідок: чому агрегат не можна у WHERE або чому аліас із SELECT там не видно.
- «Як знайти дублікати за email у таблиці users?» — практичне завдання; чекають
GROUP BY email HAVING COUNT(*) > 1. Плюс кандидату — згадка, що це показує задубльовані значення, а не самі рядки-дублікати. - «Порахуй кількість замовлень по кожному клієнту, включно з тими, у кого замовлень немає» — тест на розуміння LEFT JOIN + агрегація. Пастка —
COUNT(*), що дасть таким клієнтам1; правильноCOUNT(o.id). - «Чому середнє в SQL не збігається із середнім у застосунку?» — перевірка на зрілість: чи згадаєш, що
AVGігнорує NULL, а застосунок міг рахувати NULL як нуль.
Інтерв'юер на цій темі відрізняє кандидата, який пише агрегати «поки цифра не зійдеться», від того, хто передбачає результат до запуску: знає, що NULL випаде з AVG, що JOIN роздує COUNT, що фільтр на групу — це HAVING. Найсильніше враження справляє не знання синтаксису, а вміння пояснити, чому саме вийшла та цифра, яку повернула база.
Джерела
- PostgreSQL: Aggregate Functions — офіційний перелік агрегатів і їхня поведінка з NULL.
- PostgreSQL: GROUP BY and HAVING Clauses — правило групування й різниця HAVING vs WHERE.
- MySQL: Aggregate Function Descriptions — COUNT, SUM, AVG та інші у діалекті MySQL.
- MySQL: Handling of GROUP BY (ONLY_FULL_GROUP_BY) — режим строгого групування й наслідки його вимкнення.
Що таке агрегатна функція і які базові треба знати?
Агрегатна функція (aggregate function) згортає набір рядків в одне підсумкове значення. Базових пʼять: COUNT (кількість), SUM (сума числових значень), AVG (середнє арифметичне), MIN (найменше) і MAX (найбільше). MIN і MAX не обмежені числами: текст вони порівнюють за алфавітом, дати — хронологічно, тому MAX(created_at) дає найсвіжіший запис. Якщо GROUP BY у запиті немає, групою вважається вся таблиця: SELECT COUNT(*), AVG(amount) FROM orders поверне рівно один рядок. Для QA це базовий інструмент звірки: майже кожна перевірка «а скільки їх?» — це агрегат.
Як агрегатні функції поводяться з NULL?
Правило одне, і воно головне: для агрегатів NULL наче не існує. SUM, AVG, MIN, MAX і COUNT(col) просто не беруть такі рядки в розрахунок. Це прямий наслідок семантики NULL як «невідомо»: невідоме значення нема як врахувати ні в сумі, ні в середньому, ні в порівнянні. Виняток — COUNT(*): він рахує рядки, а не значення, тому NULL на нього не впливає. Практичний наслідок: різниця між COUNT(*) і COUNT(col) — це рівно кількість NULL у колонці, і саме вона часто пояснює, чому кількість у SQL не зійшлася з кількістю в UI.
Чому середнє в SQL часто не збігається із середнім у застосунку?
Бо знаменник у AVG — кількість не-NULL значень, а не кількість рядків. Візьми колонку discount зі значеннями 10, 20 і NULL: здається, що третій рядок «без знижки» мав би зайти нулем і дати 30/3 = 10, але для бази це не нуль, а невідоме значення — воно випадає з розрахунку, і виходить 30/2 = 15. Застосунок же міг витягти всі три рядки й порахувати NULL як нуль — звідси розбіжність. Коли за бізнес-логікою відсутня знижка означає нульову, це треба сказати базі явно: AVG(COALESCE(discount, 0)). Класичне джерело багів у звірці метрик.
Що поверне SUM над порожньою вибіркою — нуль чи NULL?
NULL. З базових агрегатів лише COUNT над порожнім набором дає 0; SUM, AVG, MIN, MAX віддають NULL. Запит SELECT SUM(amount) FROM orders WHERE customer_id = 999, де таких замовлень немає, поверне NULL, а не 0. У звітних запитах це лікують обгорткою COALESCE(SUM(amount), 0) — тоді «нічого не знайшлося» відображається нулем, а не порожнім місцем. У тесті це теж пастка: асерт expect(sum).toBe(0) почервоніє на NULL, хоча за змістом «нічого не продали» — це справді нуль.
Чим відрізняються COUNT(*), COUNT(col) і COUNT(DISTINCT col)?
Три форми рахують три різні речі: COUNT(*) — скільки рядків узагалі (разом із тими, де скрізь NULL), COUNT(col) — у скількох рядках поле заповнене (не NULL), COUNT(DISTINCT col) — скільки серед заповнених різних значень. На таблиці з 10 рядків, де телефон заповнено в 7, а два номери однакові, отримаємо відповідно 10, 7 і 6. Для QA це перший чекпоінт у звірках: перш ніж заводити баг «кількість не збігається», зʼясуй, яку з цих кількостей рахує UI, а яку — твій запит. Дашборд показує «7 користувачів з телефоном», твій COUNT(*) дає 10 — це не втрачені дані, а різна семантика підрахунку.
Чи правда, що COUNT(1) швидший за COUNT(*)?
Ні, це міф. Обидві форми рахують рядки й дають ідентичний результат, а СУБД виконують їх однаково — MySQL для InnoDB прямо пише в документації, що різниці у швидкості немає. Аргумент «зірочка змушує читати всі колонки, а одиниця ні» до COUNT не стосується: тут немає читання колонок, є підрахунок рядків. Справжня різниця семантики — між COUNT(col) і COUNT(*), через NULL. Якщо на співбесіді пропонують «оптимізувати» заміною COUNT(*) на COUNT(1) — це не оптимізація.
Що робить GROUP BY і що виходить на виході?
GROUP BY ділить рядки на групи за значенням ключа, і агрегат обчислюється всередині кожної групи окремо; результат — один рядок на групу. SELECT status, COUNT(*) FROM orders GROUP BY status поверне по рядку на кожен статус із його кількістю. Ключем може бути й кілька колонок — тоді окремий підсумок отримує кожна унікальна комбінація значень: GROUP BY status, currency дасть рядок на кожну пару «статус + валюта». Власне, це й є різниця між «один підсумок на всю таблицю» (агрегат без GROUP BY) і «підсумок у розрізі».
Чому кожна колонка у SELECT має бути або в GROUP BY, або під агрегатом?
Це не формальна причіпка стандарту, а механіка: після групування один вихідний рядок представляє цілу групу. Колонка, якої немає ні в ключі групування, ні під агрегатом, має всередині групи багато різних значень — і базі нізвідки взяти правило, котре з них показати. Запит SELECT status, email, COUNT(*) FROM users GROUP BY status саме тому неоднозначний: у групі active сотні різних email. PostgreSQL відповість помилкою column "users.email" must appear in the GROUP BY clause or be used in an aggregate function. Отже: все, що не під агрегатом, — у ключ групування, інакше запит просить неможливе.
Один запит із незгрупованою колонкою падає в PostgreSQL, але «працює» в MySQL. Чому?
Це пастка діалектів. Старі версії MySQL такий запит пропускали й мовчки підставляли в результат довільне значення з групи. З версії 5.7.5 режим ONLY_FULL_GROUP_BY увімкнений за замовчуванням, і MySQL теж відхиляє такий запит, як вимагає стандарт. Але на старій базі чи з вимкненим режимом запит «працюватиме» й тихо брехатиме: покаже email випадкового рядка групи, причому від запуску до запуску значення може змінюватись. Для QA висновок прямий: незгрупована колонка без агрегату — не «коротший стиль», а звіт, якому не можна вірити.
У яку групу потрапляють рядки з NULL у ключі GROUP BY?
Усі NULL збираються в одну групу. Виглядає суперечливо: NULL = NULL дає не TRUE, а UNKNOWN, тож NULL наче не мав би дорівнювати сам собі. Але групування порівнює ключі не через рівність, а через «не-різність» (is not distinct from) — за цим правилом два NULL вважаються одним і тим самим. Тому SELECT phone, COUNT(*) FROM users GROUP BY phone поверне один спільний рядок для всіх користувачів без телефону, а не окремий на кожен NULL. Корисно, коли рахуєш «скільки записів із незаповненим полем».
Яка різниця між WHERE і HAVING?
WHERE фільтрує рядки до групування, HAVING фільтрує групи після агрегації. Це не синтаксична умовність «HAVING для агрегатів», а наслідок порядку виконання: коли працює WHERE, груп ще немає, тож він оперує окремими рядками; HAVING запускається вже над готовими групами, тому може посилатися на агрегат. Запит WHERE status = 'paid' ... GROUP BY customer_id HAVING COUNT(*) > 5 означає: «серед оплачених замовлень — клієнти, у яких таких понад пʼять». Для швидкодії висновок простий: усе, що стосується окремого рядка, фільтруй у WHERE — тоді до групування дійде менше даних.
Чому не можна вжити агрегатну функцію в WHERE?
Бо WHERE виконується до GROUP BY: груп у цей момент ще не існує, отже агрегату нема що рахувати. Запит SELECT customer_id FROM orders WHERE COUNT(*) > 5 GROUP BY customer_id неможливий саме тому — COUNT(*) на кроці WHERE ще не має значення. Умову на агрегат приймає лише HAVING, який працює після групування. Зворотний бік: рядкову умову на кшталт status = 'paid' синтаксис HAVING пропустить, але сенсу в цьому немає — база даремно згрупує рядки, які однаково буде відкинуто; такій умові місце у WHERE.
У якому порядку насправді виконується SQL-запит?
Пишемо ми SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY, а обчислюється запит інакше: FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. Порядок називають логічним (logical query processing order), бо він визначає семантику результату, а не фактичні дії двигуна: оптимізатор має право виконувати кроки по-своєму, аби відповідь не змінилась. Ця послідовність пояснює правила, які без неї виглядають довільними: агрегат заборонений у WHERE (WHERE іде раніше за GROUP BY), аліас із SELECT не видно у WHERE, GROUP BY і HAVING (SELECT обчислюється пізніше за них). Назвати порядок і вивести з нього хоч один наслідок — стандартна перевірка глибини на співбесіді.
Чому аліас із SELECT не видно у WHERE, але видно в ORDER BY?
Бо SELECT у логічному порядку стоїть після WHERE, але перед ORDER BY. Коли база фільтрує рядки, аліасу cnt для COUNT(*) AS cnt ще не існує — тож WHERE cnt > 5 за стандартом не спрацює: доведеться повторити вираз або перенести умову в HAVING. Натомість ORDER BY cnt DESC коректний: на момент сортування SELECT уже обчислив аліас. Діалекти тут щедріші за стандарт: MySQL і PostgreSQL пропускають аліас і в GROUP BY, а MySQL — навіть у HAVING, але в переносному запиті покладатися на це не варто — стандарт гарантує аліас лише в ORDER BY.
Як знайти дублікати за email у таблиці users?
Класичний патерн: SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1 — групуємо за колонкою, що мусить бути унікальною, і залишаємо групи, де рядків більше одного. Нічого не повернулось — дублів немає; кожен повернутий рядок — email, який зустрічається понад раз. Так за секунди перевіряють інваріант, який формально мав би тримати UNIQUE, але який ламають імпорти, міграції старих даних і гонки на рівні застосунку. Дублікат за комбінацією полів шукається тим самим патерном зі складеним ключем: GROUP BY room_id, booking_date HAVING COUNT(*) > 1. Плюс кандидату — застереження, що запит показує задубльовані значення і їхню кількість, але не самі рядки з id: по конкретні рядки треба йти підзапитом або ROW_NUMBER.
Чому COUNT(*) після JOIN «один до багатьох» не дає кількість клієнтів?
Бо зʼєднання 1:N розмножує рядки, і COUNT(*) рахує вже роздутий набір. Клієнт із трьома замовленнями після customers JOIN orders присутній у результаті трьома рядками, тож COUNT(*) фактично рахує замовлення. Кількість клієнтів дає COUNT(DISTINCT c.id): DISTINCT прибирає повтори, які породило зʼєднання, і залишає унікальних людей. Помилка з підступних: цифра виходить більшою за очікувану, хоча самі дані в порядку — проблема не в них, а в тому, що рахунок ведеться після зʼєднання.
Треба порахувати замовлення на кожного клієнта, включно з тими, у кого замовлень немає. Де пастка?
Їх дві: тип JOIN і форма COUNT. Основа — LEFT JOIN, інакше клієнти без замовлень взагалі випадуть із результату: SELECT c.id, COUNT(o.id) FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id. Друга пастка тонша: LEFT JOIN зберігає клієнта без замовлень як рядок, у якому всі поля з orders — NULL. Для COUNT(o.id) цей NULL невидимий, тож такий клієнт отримує чесний 0; а COUNT(*) порахував би сам рядок і приписав би клієнту замовлення, якого немає. Тут різниця форм COUNT перестає бути теорією: один символ у запиті вирішує, правдивий звіт чи ні.
Що таке пастка віяла (fan-out) при двох JOIN-ах?
Це подвійний рахунок, який виникає, коли до однієї таблиці приєднують одразу дві дочірні у звʼязку 1:N — наприклад, до orders водночас order_items і payments. Рядки двох гілок перемножаються: кожен платіж зустрінеться в результаті стільки разів, скільки в замовленні позицій, і SUM(payments.amount) чесно підсумує всі повтори. Небезпека в правдоподібності: сума не виглядає абсурдною, вона просто завищена, і оком цього майже не видно. Рятує агрегація кожної дочірньої гілки окремо — підзапитом чи CTE — ще до зʼєднання, а також звірка суми з незалежним контрольним числом. Симптом для діагностики: SQL дає «трохи більше», ніж має бути, а дані чисті — перша підозра на подвійний JOIN.
Три сюжети, де агрегати вирішують, чи звірка зійдеться: розбіжність кількості між дашбордом і базою, перевірка унікальності через пошук дублікатів (плюс як дістати самі рядки й обернути це на автотест цілісності), і підрахунок поверх JOIN, де один невірний символ перетворює правдивий звіт на брехливий. Скрізь — що дивитися і чому.
Кейс 1. Дашборд каже 1000, база каже 1240 — хто бреше?
Продакт скаржиться: на дашборді «Користувачі з телефоном» — 1000, а тестувальник у базі бачить 1240 і заводить баг «дашборд втрачає записи». Перш ніж писати баг, треба відповісти на одне питання: яку саме кількість рахує кожна сторона.
SELECT
COUNT(*) AS rows_total, -- 1240: усі рядки
COUNT(phone) AS phones_filled, -- 1000: не-NULL телефони
COUNT(DISTINCT phone) AS phones_unique -- 998: різні номери
FROM users;
Що дивитися і чому:
- Розбіжність не в даних, а в тому, що рахуємо. Тестувальник рахував
COUNT(*)— усі рядки, разом із 240 користувачами без телефону. Дашборд рахуєCOUNT(phone)— лише тих, у кого телефон заповнено. Обидві цифри правильні, баг фантомний: перш ніж заводити дефект на розбіжність кількості, звіряють семантику підрахунку з обох боків. COUNT(DISTINCT phone)менший навіть за заповнені. 998 проти 1000 означає, що два номери задубльовані — окрема знахідка, яку варто перевірити, якщо телефон мав бути унікальним.- NULL — корінь усього.
COUNT(*)рахує рядки й не помічає NULL;COUNT(phone)пропускає 240 порожніх. Та сама пастка ламаєAVG: якби питання було про «середню знижку»,AVG(discount)ділив би на кількість не-NULL знижок, а не на всіх користувачів, і теж «не зійшовся» б з UI, який рахує порожню знижку за нуль.
Кейс 2. Перевірка унікальності: знайти дублікати й дістати рядки
UNIQUE-обмеження на email мало б гарантувати відсутність дублів, але після імпорту старих даних чи гонки на рівні застосунку воно могло не спрацювати (наприклад, різний регістр або обмеження додали вже поверх брудних даних). Найшвидша перевірка — згрупувати за ключем і лишити групи, де більше одного рядка.
-- Чи є два акаунти на один email?
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
Порожній результат — унікальність тримається. Але цей запит показує лише які email задубльовані й скільки разів, а не самі рядки з їхніми id. Щоб вирішити, який запис лишити, потрібні конкретні рядки — через підзапит:
-- Самі рядки-дублікати з id
SELECT id, email, created_at
FROM users
WHERE email IN (
SELECT email FROM users GROUP BY email HAVING COUNT(*) > 1
)
ORDER BY email, created_at;
Ту саму перевірку природно тримати автотестом цілісності даних, який ганяють на staging після міграцій: база даних мусить повертати нуль дублікатів.
import { test, expect } from '@playwright/test';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DB_URL });
test('немає дублікатів email у users', async () => {
const { rows } = await pool.query(`
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1
`);
// порожній результат = унікальність ціла; будь-які рядки — це дублі
expect(rows, `знайдено дублікати: ${JSON.stringify(rows)}`).toHaveLength(0);
});
Що дивитися і чому:
HAVING COUNT(*) > 1, а не WHERE. Умова стосується групи («рядків більше одного»), а не окремого рядка, тож живе тільки в HAVING — на момент WHERE груп ще немає йCOUNT(*)рахувати нема над чим.- Дублікат за комбінацією полів — той самий патерн. Два бронювання на однаковий номер і дату шукають через
GROUP BY room_id, booking_date HAVING COUNT(*) > 1: ключем стає кілька колонок. - Асерт-повідомлення показує самі дублі. Кинути в текст асерту
JSON.stringify(rows)дешево, а при падінні одразу видно, які значення задубльовані, без повторного походу в базу.
Кейс 3. Підрахунок поверх JOIN: один символ між правдою і брехнею
Завдання зі співбесіди й з реального звіту одночасно: порахувати кількість замовлень на кожного клієнта, включно з тими, хто не зробив жодного. Тут дві незалежні пастки — тип JOIN і форма COUNT.
-- Правильно: LEFT JOIN + COUNT(o.id)
SELECT c.id, c.name, COUNT(o.id) AS orders_cnt
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY orders_cnt DESC;
А ось два способи тихо зіпсувати цей звіт:
-- Пастка 1: COUNT(*) замість COUNT(o.id)
SELECT c.id, COUNT(*) AS orders_cnt -- клієнт без замовлень отримає 1, а не 0
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
-- Пастка 2: скільки клієнтів мають замовлення?
SELECT COUNT(*) FROM customers c JOIN orders o ON o.customer_id = c.id; -- рахує ЗАМОВЛЕННЯ
SELECT COUNT(DISTINCT c.id) FROM customers c JOIN orders o ON o.customer_id = c.id; -- рахує КЛІЄНТІВ
Що дивитися і чому:
COUNT(o.id), а неCOUNT(*). При LEFT JOIN клієнт без замовлень усе одно дає один рядок, але колонки зordersу ньому — NULL.COUNT(o.id)пропускає цей NULL і повертає0;COUNT(*)порахує рядок-заглушку як1— «клієнт без замовлень має одне замовлення».- JOIN 1:N розмножує рядки. У клієнта з трьома замовленнями рядок повторюється тричі, тож
COUNT(*)над зʼєднанням рахує замовлення, а не клієнтів. Унікальних клієнтів даєCOUNT(DISTINCT c.id)— саме тут ця форма COUNT стає щоденним інструментом. - Пастка віяла при двох JOIN-ах. Якщо додати ще й
LEFT JOIN payments, рядки перемножаться, іSUM(payments.amount)порахує кожен платіж стільки разів, скільки в замовлення позицій. Сума роздується непомітно — коли SQL дає «трохи більше», ніж має бути, а дані в порядку, перша підозра саме на подвійний JOIN; захист — агрегувати гілки окремо через підзапит або CTE.
Агрегатні функції і NULL
- Знаю пʼять базових агрегатів (
COUNT,SUM,AVG,MIN,MAX): без GROUP BY згортають усю таблицю в один рядок, аMIN/MAXпрацюють і з рядками (алфавітно), і з датами (хронологічно), тожMAX(created_at)— «остання за часом». - Знаю головне правило:
SUM,AVG,MIN,MAXіCOUNT(col)ігнорують NULL, аCOUNT(*)— ні. - Можу пояснити, чому
AVGділить на кількість не-NULL значень, а не рядків, і коли требаAVG(COALESCE(col, 0)). - Памʼятаю, що над порожнім набором
COUNTдає0, аSUM/AVG/MIN/MAX— NULL; звідсиCOALESCE(SUM(x), 0)у звітах.
Три форми COUNT
- Знаю різницю
COUNT(*)(усі рядки) vsCOUNT(col)(не-NULL значення) vsCOUNT(DISTINCT col)(різні не-NULL значення). - Розумію, що
COUNT(col)менший заCOUNT(*)рівно на кількість NULL — і це часте пояснення розбіжності SQL vs UI. - Знаю, що
COUNT(1)=COUNT(*), а «COUNT(1)швидший» — міф; оптимізатор обробляє їх однаково.
GROUP BY
- Розумію, що GROUP BY дає один рядок на групу, а групувати можна за комбінацією кількох колонок.
- Знаю правило: кожна колонка у SELECT має бути в GROUP BY або під агрегатом, і можу пояснити чому (база не знає, яке значення групи показати).
- Можу пояснити пастку діалектів: PostgreSQL дає помилку, старий MySQL без
ONLY_FULL_GROUP_BYмовчки повертає довільне значення з групи. - Памʼятаю, що всі NULL при GROUP BY потрапляють в одну групу (правило «не-різності», не рівності).
WHERE проти HAVING
- Знаю різницю: WHERE фільтрує рядки до групування, HAVING — групи після агрегації.
- Можу пояснити, чому агрегат не можна вжити у WHERE (груп ще немає) і чому фільтр на окремий рядок ставлять у WHERE, а не HAVING (менше груп — швидше).
Логічний порядок виконання
- Можу назвати логічний порядок: FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT.
- Розумію, що це логічний, а не фізичний порядок: оптимізатор може переставляти кроки, доки результат той самий.
- Можу вивести наслідки: агрегат заборонений у WHERE, аліас із SELECT не видно у WHERE/GROUP BY/HAVING, а гарантовано (за стандартом) видно лише в ORDER BY (у GROUP BY — розширення MySQL/PostgreSQL, у HAVING — лише MySQL).
Пошук дублікатів і агрегація поверх JOIN
- Знаю патерн пошуку дублікатів
GROUP BY <ключ> HAVING COUNT(*) > 1(за однією колонкою чи комбінацією) і памʼятаю, що він показує задубльовані значення, а не самі рядки з id — для рядків потрібен підзапит абоROW_NUMBER. - Розумію, що JOIN 1:N розмножує рядки, тож клієнтів дає
COUNT(DISTINCT c.id), а неCOUNT(*). - Знаю, що при LEFT JOIN кількість по клієнту рахують через
COUNT(o.id), а неCOUNT(*), інакше клієнт без замовлень отримає1. - Можу пояснити пастку віяла (fan-out): два JOIN-и 1:N перемножують рядки й роздувають
SUM; захист — агрегувати гілки окремо або звіряти з контрольним значенням.
Що робить агрегатна функція, застосована без GROUP BY?
Питання
Що таке агрегатна функція (aggregate function)?