Підзапити і CTE (WITH), операції з множинами
Зміст
Доти, доки запит зводиться до «дістань рядки з однієї таблиці» або «склей дві таблиці через JOIN», вистачає того, що вже вміє SELECT. Але щойно завдання звучить як «знайди клієнтів без жодного замовлення», «покажи товари, дорожчі за середню ціну», «звір, що список у UI збігається з двома джерелами разом» — простого SELECT замало. Треба вкласти один запит усередину іншого або скласти кілька результатів у стос. Саме про це підзапити, узагальнені табличні вирази (CTE) й операції з множинами.
Для QA це не академічна тема. Перевірки цілісності даних — «немає осиротілих рядків», «агрегат у звіті сходиться з сумою по деталях», «жоден запис не втратив пару після міграції» — це майже завжди підзапит або анти-з'єднання. А найпідступніша пастка розділу, NOT IN з NULL, вміє тихо зробити ваш перевірковий запит фальшиво зеленим: він поверне «порушень не знайдено» не тому, що їх немає, а тому, що зламався сам. Тому механіку треба розуміти, а не завчати шаблони.
Підзапити: запит усередині запиту
Підзапит (subquery) — це SELECT, вкладений в інший запит. Місце вкладення визначає, як він поводиться. Є три позиції.
У WHERE — найчастіший випадок. Підзапит повертає значення (чи набір значень), з якими фільтрують зовнішній запит.
-- Скалярний підзапит: повертає рівно одне значення
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- Підзапит-набір: повертає стовпець значень для IN
SELECT name
FROM products
WHERE category_id IN (SELECT id FROM categories WHERE archived = false);
Скалярний підзапит (scalar subquery) — той, що повертає один рядок і один стовпець. Якщо він раптом поверне більше рядків, СУБД впаде з помилкою на кшталт more than one row returned by a subquery used as an expression. Це корисно як сигнал: якщо ваш «унікальний» підзапит зненацька повертає кілька рядків — дані не такі унікальні, як ви думали.
У FROM — підзапит виступає похідною таблицею (derived table): проміжним результатом, з якого зовнішній запит вибирає далі. Похідна таблиця потребує аліаса: у MySQL, SQL Server і PostgreSQL до версії 16 без нього запит навіть не виконається (PostgreSQL 16 і Oracle дозволяють ім'я опустити — але не привчайтеся: з аліасом запит і переносний, і читабельний).
SELECT category_id, avg_price
FROM (
SELECT category_id, AVG(price) AS avg_price
FROM products
GROUP BY category_id
) AS category_stats
WHERE avg_price > 100;
У SELECT — скалярний підзапит як окремий обчислюваний стовпець, значення якого рахується для кожного рядка результату.
SELECT
c.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS orders_count
FROM customers c;
Останній приклад уже особливий: підзапит усередині посилається на c.id із зовнішнього запиту. Це корельований підзапит — про нього далі.
Корельовані підзапити
Звичайний (некорельований) підзапит самодостатній: його можна виконати окремо, він рахується один раз, а результат підставляється у зовнішній запит. Корельований підзапит (correlated subquery) так не вміє — він посилається на стовпці зовнішнього запиту, тому концептуально виконується заново для кожного зовнішнього рядка.
-- Працівники, що заробляють більше за середнє у СВОЄМУ відділі
SELECT e.name, e.salary, e.dept_id
FROM employees e
WHERE e.salary > (
SELECT AVG(salary)
FROM employees
WHERE dept_id = e.dept_id -- кореляція із зовнішнім e.dept_id
);
Ментальна модель: для кожного рядка e внутрішній запит рахує середнє саме по відділу цього рядка. «Концептуально» — бо на практиці оптимізатор нерідко вміє переписати таку конструкцію у з'єднання й не ганяти підзапит мільйон разів. Але писати й читати його треба саме як «для кожного рядка».
Корельований підзапит — природна пара до EXISTS, який без кореляції майже не має сенсу.
IN, EXISTS і пастка NOT IN з NULL
Два способи спитати «чи є цей рядок у наборі» виглядають взаємозамінними, але поводяться по-різному саме на межах — а межі для QA і є робота.
IN порівнює значення зі списком, який повернув підзапит. EXISTS перевіряє сам факт наявності хоча б одного рядка у корельованому підзапиті й зупиняється на першому знайденому.
-- Через IN: збери всі customer_id із orders, потім звір
SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders);
-- Через EXISTS: для кожного клієнта спитай «чи існує хоч одне замовлення»
SELECT name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Для позитивної перевірки обидва дають однаковий результат, і сучасні оптимізатори нерідко зводять їх до одного плану (semi-join). Старе правило «EXISTS завжди швидший за IN» сьогодні здебільшого міф — орієнтуйтеся на план виконання (EXPLAIN), а не на прикмети. У EXISTS після SELECT пишуть 1 чи * суто за звичкою: значення не використовується, важливий лише факт існування рядка.
А тепер головна пастка розділу — заперечення. NOT IN з підзапитом, серед значень якого трапляється NULL, не поверне жодного рядка. Ніколи. Незалежно від даних.
-- Виглядає як «керівники, які нікому не підпорядковані».
-- Якщо серед manager_id є хоч один NULL (а в найвищого боса він саме NULL) —
-- запит поверне 0 рядків завжди.
SELECT name FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
Чому так. x NOT IN (a, b, NULL) розкривається у x <> a AND x <> b AND x <> NULL. Порівняння з NULL дає не TRUE і не FALSE, а UNKNOWN (це тризначна логіка — three-valued logic, докладно в главі /sql/funktsii-case-i-pastky-null). А TRUE AND TRUE AND UNKNOWN дорівнює UNKNOWN, і рядок до результату не потрапляє. Отже, NOT IN з NULL-ом у наборі не може повернути TRUE для жодного рядка — тільки FALSE або UNKNOWN.
Безпечна заміна — NOT EXISTS, який на NULL не ведеться, бо перевіряє існування рядка, а не рівність значень:
SELECT name FROM employees e
WHERE NOT EXISTS (
SELECT 1 FROM employees m WHERE m.manager_id = e.id
);
Для QA висновок жорсткий: якщо ви пишете перевірковий запит «покажи записи-порушники» через NOT IN, а стовпець підзапиту може містити NULL — ваш тест ризикує завжди повертати порожньо й рапортувати «все чисто». Це найгірший тип фальшивого зеленого. За замовчуванням для заперечень беріть NOT EXISTS або анти-з'єднання (див. нижче).
CTE (WITH): коли підзапит стає нечитабельним
Узагальнений табличний вираз (common table expression, CTE) — це іменований проміжний результат, оголошений через WITH перед основним запитом. По суті це той самий підзапит із FROM, але винесений «нагору» і названий — тому запит читається згори вниз, як послідовність кроків, а не як матрьошка дужок.
WITH category_stats AS (
SELECT category_id, AVG(price) AS avg_price
FROM products
GROUP BY category_id
),
expensive_categories AS (
SELECT category_id FROM category_stats WHERE avg_price > 100
)
SELECT p.name, p.price
FROM products p
JOIN expensive_categories e ON e.category_id = p.category_id;
CTE не дає нової потужності, якої не було б у підзапитів, — його цінність суто в читабельності й повторному використанні. Один CTE можна згадати кілька разів у головному запиті, не дублюючи текст підзапиту. Коли логіка перевірки розростається на три-чотири кроки (порахувати агрегат, відфільтрувати, зіставити), CTE перетворює нечитабельну вкладеність на лінійний конспект — а перевірковий SQL, який читає інша людина на рев'ю, вартий цього.
Окрема здатність — рекурсивний CTE (WITH RECURSIVE) для ієрархій: дерево категорій, ланцюжок «керівник → підлеглий», граф залежностей. Це нішевий, але впізнаваний інструмент; глибоко його зазвичай не питають.
Важливий нюанс продуктивності, який любить спливати на співбесідах. Історично PostgreSQL завжди матеріалізував CTE — виконував його окремо й складав результат у проміжну таблицю (так званий «optimization fence»). Починаючи з PostgreSQL 12, простий CTE планувальник може вбудовувати (inline) у головний запит, як звичайний підзапит, — а примусову матеріалізацію тепер вмикають явно через WITH ... AS MATERIALIZED. Тобто «CTE завжди повільніший за підзапит» — застаріле твердження, прив'язане до конкретних версій СУБД.
Операції з множинами: UNION, INTERSECT, EXCEPT
Якщо JOIN клеїть таблиці вшир (додає стовпці), то операції з множинами (set operations) складають результати вгору — стосом, рядок під рядком. Обов'язкові умови: обидва запити мають однакову кількість стовпців і сумісні типи; імена стовпців результату беруться з першого запиту.
UNION проти UNION ALL — розрізняти обов'язково.
UNION ALLпросто зшиває рядки обох запитів як є, зокрема дублікати. Дешево.UNIONробить те саме, але потім прибирає дублікати (неявний DISTINCT), а це коштує сортування чи хешування всього результату.
-- Усі email-адреси з двох джерел, з можливими повторами
SELECT email FROM customers
UNION ALL
SELECT email FROM leads;
-- Ті самі адреси, але кожна рівно раз
SELECT email FROM customers
UNION
SELECT email FROM leads;
Правило за замовчуванням: беріть UNION ALL, доки вам справді не потрібне усунення дублікатів. Дуже часто програміст пише UNION за звичкою, платить за дедуплікацію на кожному прогоні, а дублікатів у даних і не буває. Для QA це ще й пастка звірки: якщо очікувана кількість рядків у звіті не сходиться з UNION-запитом — спершу перевірте, чи не з'їв UNION легітимні повтори, які насправді мали лишитися.
INTERSECT повертає рядки, присутні в обох запитах; EXCEPT — рядки з першого запиту, яких немає в другому (в Oracle ця операція зветься MINUS). Обидві за замовчуванням теж прибирають дублікати; варіанти INTERSECT ALL / EXCEPT ALL їх зберігають.
-- Користувачі, що є і в проді, і в staging (звірка синхронізації БД)
SELECT id FROM prod_users
INTERSECT
SELECT id FROM staging_users;
-- Користувачі з проду, яких немає в staging
SELECT id FROM prod_users
EXCEPT
SELECT id FROM staging_users;
EXCEPT — зручний спосіб порівняти два набори «що загубилося при перенесенні». Тонкість, про яку варто знати: у порівнянні рядків для операцій з множинами NULL вважається рівним NULL (на відміну від звичайного =, де NULL = NULL дає UNKNOWN). Тому два рядки з NULL в одному й тому ж стовпці UNION вважатиме дублікатами.
JOIN vs UNION vs підзапит
Три інструменти легко переплутати назвами, але вони відповідають на різні питання. Різниця — геометрична.
| Інструмент | Що робить | Коли брати |
|---|---|---|
| JOIN | Додає стовпці з іншої таблиці за умовою збігу | Треба зіставити пов'язані дані пліч-о-пліч |
| UNION / UNION ALL | Складає рядки кількох запитів у стос | Треба об'єднати однорідні набори в один список |
| Підзапит / CTE | Обчислює проміжний результат для фільтра чи стовпця | Треба «запит усередині запиту»: агрегат, набір для IN, перевірка існування |
Практична межа: одну задачу нерідко можна розв'язати різними способами. «Клієнти із замовленнями» — це і JOIN, і IN-підзапит, і EXISTS. Обирайте за читабельністю й наміром: EXISTS чесно каже «мене цікавить факт наявності», JOIN — «мені потрібні дані з обох таблиць». Механіку самих з'єднань дивіться в главі /sql/join-typy-ziednan-tablyts.
Класичні задачі співбесід
Дві задачі трапляються на співбесідах з SQL найчастіше — і обидві якраз про цей розділ.
Другий за величиною
«Знайдіть другу за величиною зарплату». Сильна відповідь починається з уточнення: друге унікальне значення чи другий рядок? Якщо двоє отримують максимум, друга за величиною зарплата — це наступне менше значення, а не той самий максимум удруге.
-- Надійний варіант: наступне значення після максимуму.
-- Природно ігнорує дублікати верхівки й повертає NULL, якщо другого немає.
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Через DISTINCT і зсув (синтаксис PostgreSQL / MySQL).
-- DISTINCT тут критичний, інакше дублікати верхівки з'їдять зсув.
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;
Перший варіант елегантно закриває крайові випадки: якщо в таблиці лише одне унікальне значення зарплати, внутрішній MAX відсіче всі рядки і зовнішній MAX поверне NULL, а не помилку. Варіант із LIMIT OFFSET у SQL Server пишеться інакше (OFFSET ... FETCH або TOP) — на це варто зважати, якщо інтерв'юер називає конкретну СУБД. Найчистіше «N-й за величиною» розв'язується віконними функціями (DENSE_RANK) — це тема глави /sql/vikonni-funktsii; згадка про них у відповіді показує кругозір.
Записи без пари (анти-з'єднання)
«Знайдіть клієнтів без жодного замовлення», «замовлення без платежу», «рядки-сироти після міграції». Це анти-з'єднання (anti-join) — «дай рядки з A, для яких у B немає відповідника». Три канонічні способи, і не всі однаково безпечні.
-- 1. LEFT JOIN + IS NULL: класичне анти-з'єднання
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- 2. NOT EXISTS: читається як умова, безпечний до NULL
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- 3. NOT IN: НЕБЕЗПЕЧНИЙ, якщо orders.customer_id буває NULL (див. пастку вище)
SELECT c.id, c.name
FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
Варіанти 1 і 2 еквівалентні за результатом і обидва коректні. Варіант 3 — та сама пастка NOT IN з NULL: якщо серед customer_id у замовленнях трапиться NULL, запит поверне порожньо й ваша перевірка цілісності мовчки збреше. Для QA це найчастіший робочий сценарій розділу: пошук осиротілих записів — базова перевірка після міграцій і в тестах цілісності даних (глава про тестування БД у цьому розділі). Тому дефолт для «записів без пари» — LEFT JOIN ... IS NULL або NOT EXISTS, а не NOT IN.
Типові помилки
Виглядає як «немає порушень», а насправді запит зламався. NOT IN з NULL у підзапиті повертає 0 рядків завжди. Перевірка цілісності на NOT IN — фальшиво зелена. Ліки: NOT EXISTS або анти-з'єднання.
Виглядає як безпечний UNION, а насправді втрата рядків. UNION тихо прибирає дублікати. Якщо ви звіряєте кількість і легітимні повтори мали лишитися — потрібен UNION ALL. І навпаки: UNION ALL там, де чекали унікальності, роздує результат.
Виглядає як робочий підзапит у FROM, а насправді синтаксична помилка. Похідна таблиця без аліаса в більшості СУБД не виконається (PostgreSQL 16 і Oracle — винятки). Давайте кожному підзапиту в FROM ім'я — запит буде переносним.
Виглядає як скалярний підзапит, а насправді кілька рядків. = (SELECT ...), що зненацька повертає більше одного рядка, валить запит помилкою. Часто це сигнал, що дані не унікальні там, де ви припускали унікальність.
Виглядає як «CTE завжди повільніший», а насправді залежить від СУБД і версії. Твердження про «optimization fence» стосується старих версій PostgreSQL; від PG 12 простий CTE вбудовується. Не діагноз, а привід глянути EXPLAIN.
Підсумок
- Підзапит живе у WHERE (фільтр/набір), FROM (похідна таблиця, потрібен аліас) або SELECT (обчислюваний стовпець); скалярний повертає рівно одне значення.
- Корельований підзапит посилається на зовнішній рядок і концептуально виконується для кожного з них;
EXISTS— його природна пара. NOT INзNULLу наборі повертає нуль рядків завжди — для заперечень берітьNOT EXISTSчиLEFT JOIN ... IS NULL.UNIONприбирає дублікати (дорого),UNION ALL— ні; за замовчуваннямUNION ALL, доки дедуплікація справді не потрібна.INTERSECT/EXCEPTпорівнюють набори і теж дедуплікують.- CTE (
WITH) не додає потужності — додає читабельність і повторне використання; «другий за величиною» і «записи без пари» — дві задачі, які треба вміти писати не думаючи.
Що питають на співбесіді
- «Чим EXISTS відрізняється від IN? Що швидше?» Інтерв'юер хоче почути, що семантично для позитивної перевірки вони близькі,
EXISTSзупиняється на першому збігу, а «швидше» вирішує план виконання, а не забобон. Плюс бали за згадкуNOT IN+NULL. - «Що поверне
NOT INз підзапитом, у якому є NULL?» Пряма перевірка на тризначну логіку. Правильна відповідь — «нуль рядків, боx <> NULLдає UNKNOWN» — і одразу безпечна альтернативаNOT EXISTS. - «UNION чи UNION ALL — і чому це впливає на продуктивність?» Дивляться, чи розумієте, що
UNIONдоплачує за дедуплікацію, і чи беретеUNION ALLза замовчуванням. - «Знайдіть другу за величиною зарплату.» Класика. Цінують уточнення про дублікати й
NULL, робочий запит і згадку віконних функцій як альтернативи. - «Знайдіть записи без пари» / «клієнтів без замовлень». Перевіряють, чи знаєте всі три способи анти-з'єднання і чому
NOT INтут ризикований. - «Навіщо CTE, якщо є підзапити?» Правильний акцент — читабельність і повторне використання, а не міфічна швидкість; бонус за нюанс матеріалізації.
Джерела
- PostgreSQL: WITH Queries (Common Table Expressions) — CTE, рекурсія,
MATERIALIZED. - PostgreSQL: Combining Queries (UNION, INTERSECT, EXCEPT) — операції з множинами і правила дедуплікації.
- PostgreSQL: Subquery Expressions —
EXISTS,IN,NOT IN,ANY/ALLі їхня семантика зNULL. - MySQL: Subqueries — та сама механіка в діалекті MySQL, корисно для порівняння.
- Стандарт мови — ISO/IEC 9075 (SQL); підзапити,
WITHі операції з множинами описані стандартом і в основних СУБД поводяться майже однаково — відмінності діалектів дрібні (MINUSв Oracle, синтаксисLIMIT/OFFSET, необов'язковість аліаса).
Що таке підзапит і в яких трьох позиціях він може стояти?
Підзапит (subquery) — це SELECT, вкладений усередину іншого запиту; поведінка залежить від того, куди саме його вклали. У WHERE він постачає значення або набір для фільтра зовнішнього запиту — це найтиповіше місце. У FROM він грає роль похідної таблиці (derived table): тимчасового результату, з яким зовнішній запит далі працює як зі звичайною таблицею. У SELECT він стає обчислюваним стовпцем, і його значення рахується окремо для кожного рядка видачі. Практичний наслідок: перше, що варто визначити, дивлячись на підзапит, — його позицію, бо саме вона диктує, що він мусить повертати: одне значення, стовпець чи цілу таблицю.
Що таке скалярний підзапит і що станеться, якщо він поверне більше одного рядка?
Скалярний підзапит (scalar subquery) повертає рівно один рядок з одним стовпцем — тобто одне значення. Саме тому його можна поставити всюди, де очікується скаляр: праворуч від > у WHERE, обчислюваним стовпцем у SELECT. Якщо рядків раптом виявиться кілька, СУБД не братиме «перший-ліпший» мовчки — запит зупиниться помилкою (у PostgreSQL це more than one row returned by a subquery used as an expression). Для QA така помилка — не збій, а знахідка: «унікальний» підзапит, що зненацька віддає кілька рядків, означає, що унікальності в даних насправді немає.
Чому підзапит у FROM потребує аліаса?
Підзапит у FROM — це похідна таблиця, і щоб посилатися на її стовпці, їй потрібне ім'я. У MySQL, SQL Server і PostgreSQL до версії 16 аліас обов'язковий: без нього запит навіть не почне виконуватися — помилка синтаксична, а не логічна (PostgreSQL 16 і Oracle дозволяють ім'я опустити). Правило механічне: кожному підзапиту в FROM — аліас () AS category_stats), навіть якщо прямо на нього начебто ніхто не посилається. Так запит і переносний між СУБД, і читабельніший; а забутий аліас — один із найчастіших «дурних» багів, коли підзапит переїжджає з WHERE у FROM.
Чим корельований підзапит відрізняється від звичайного?
Некорельований підзапит — самостійна одиниця: виконай його окремо, отримай результат, підстав у зовнішній запит — і все, один прохід. Корельований (correlated subquery) без зовнішнього запиту не живе: всередині нього є посилання на стовпці зовнішнього рядка, тож концептуально його доводиться перераховувати для кожного такого рядка. Ментальна модель: «для кожного рядка e порахуй щось, що залежить саме від цього рядка». На практиці оптимізатор нерідко вміє розгорнути таку конструкцію у з'єднання й не ганяти підзапит мільйон разів, але читати й писати корельований підзапит треба саме як «для кожного рядка». Його природний напарник — EXISTS, який без кореляції майже не має сенсу.
Чим відрізняються IN і EXISTS? І що з них швидше?
IN відповідає на питання «чи є це значення у списку, який віддав підзапит»; EXISTS питає інше — «чи знайдеться в корельованому підзапиті хоч один рядок» — і припиняє пошук на першому збігу. Для позитивної перевірки («клієнти, у яких є замовлення») результат однаковий, і сучасні оптимізатори часто будують для обох той самий план (semi-join). Тож «EXISTS завжди швидший за IN» — правило з минулої епохи: сьогодні вирішує план виконання, дивіться EXPLAIN, а не вірте прикметам. Те, що після SELECT в EXISTS стоїть 1 чи *, — просто конвенція: саме значення нікуди не йде, СУБД цікавить лише факт існування рядка. Справжня, а не міфічна різниця між ними виринає на запереченні — у NOT IN проти NOT EXISTS.
Що поверне NOT IN з підзапитом, серед значень якого є NULL?
Нуль рядків — завжди, і це головна пастка розділу. Механізм: x NOT IN (a, b, NULL) — це насправді ланцюжок x <> a AND x <> b AND x <> NULL. Але будь-яке порівняння з NULL дає не TRUE і не FALSE, а UNKNOWN (тризначна логіка), і кон'юнкція, в якій є UNKNOWN, уже ніколи не стане TRUE — щонайбільше FALSE або UNKNOWN. Рядок потрапляє в результат лише при TRUE, тож не потрапляє жоден. Для QA висновок жорсткий: перевірка «покажи порушників» через NOT IN над стовпцем, де може бути NULL, здатна вічно повертати порожньо — «все чисто» не тому, що порушень немає, а тому, що запит зламався. Гірший різновид фальшивого зеленого важко придумати.
Як безпечно написати заперечення замість NOT IN?
Два безпечні варіанти: NOT EXISTS та анти-з'єднання LEFT JOIN ... WHERE ... IS NULL. NOT EXISTS до NULL байдужий: він не порівнює значення, а питає про існування рядка, і відсутність такого рядка — це чесне «ні», а не UNKNOWN. LEFT JOIN ... IS NULL працює інакше, але так само коректно: приєднуємо праву таблицю, і рядки без пари впізнаються за NULL у її стовпцях. Обидва дають правильну відповідь навіть на даних із NULL. Дефолт для будь-якого «знайди записи без пари» — саме ці два; NOT IN лишайте тільки там, де NULL у наборі гарантовано неможливий.
Навіщо потрібен CTE (WITH), якщо є підзапити?
Узагальнений табличний вираз (common table expression, CTE) — проміжний результат, якому дали ім'я через WITH перед основним запитом. Технічно це той самий підзапит із FROM, тільки піднятий нагору, тому запит читається зверху вниз, крок за кроком, а не розгортається матрьошкою дужок зсередини. Нової виразної сили CTE не приносить — усе те саме можна зібрати підзапитами; його вигода — читабельність і повторне використання: до одного CTE можна звернутися кілька разів, не дублюючи його текст. Коли перевірка розростається до трьох-чотирьох кроків (порахувати агрегат → відфільтрувати → зіставити), саме CTE перетворює вкладеність на лінійний конспект, який колега на рев'ю зрозуміє з першого проходу.
Чи правда, що CTE завжди повільніший за підзапит?
Ні — це твердження прив'язане до старих версій конкретної СУБД. У PostgreSQL до 12-ї версії CTE був «optimization fence»: його завжди виконували окремо й складали результат у проміжну таблицю, тому CTE справді міг програвати еквівалентному підзапиту. З PostgreSQL 12 планувальник вміє вбудувати (inline) простий CTE в основний запит, а хто хоче гарантовану стару поведінку — пише WITH ... AS MATERIALIZED явно. Тож правильна відповідь на співбесіді: «залежить від СУБД і версії, дивлюся EXPLAIN», а не універсальне правило. В інших системах поведінка своя, і переносити «постгресівський» висновок на всі СУБД не варто.
Що таке рекурсивний CTE і навіщо він?
Рекурсивний CTE (WITH RECURSIVE) — це CTE, який посилається сам на себе, щоб пройти ієрархію крок за кроком: дерево категорій, ланцюжок «керівник → підлеглий», граф залежностей. Він складається з якірної частини (стартові рядки) і рекурсивної (що додає наступний рівень, спираючись на попередній), поки не закінчаться нові рядки. Це нішевий, але впізнаваний інструмент — його добре мати в арсеналі для обходу деревовидних даних, які звичайним JOIN не візьмеш, бо глибина наперед невідома. На співбесідах глибоко його зазвичай не питають; достатньо знати, що він існує і для чого.
Чим UNION відрізняється від UNION ALL і чому це впливає на продуктивність?
UNION ALL складає рядки обох запитів у стос без жодної обробки — разом із повторами, зате дешево. UNION після склеювання ще й дедуплікує результат (неявний DISTINCT), а за дедуплікацію платять сортуванням чи хешуванням усього набору. Розумний дефолт — UNION ALL; на UNION переходьте лише тоді, коли усунення дублікатів — свідома вимога. Типова картина в реальному коді: UNION написаний за звичкою, СУБД щопрогону оплачує дедуплікацію, а повторів у даних і не буває. Для QA тут ще й пастка звірки: коли кількість рядків у звіті не б'ється з очікуваною, спершу з'ясуйте, чи не викинув UNION легітимні повтори, які за контрактом звіту мали лишитися.
Що роблять INTERSECT і EXCEPT?
INTERSECT лишає тільки рядки, що трапляються в обох запитах; EXCEPT віднімає: бере рядки першого запиту й викидає ті, що знайшлися в другому (в Oracle той самий оператор історично зветься MINUS). За замовчуванням обидва дедуплікують результат так само, як UNION; форми INTERSECT ALL / EXCEPT ALL повтори зберігають. Для QA це готові інструменти звірки: INTERSECT відповідає на «хто є і в проді, і в staging», а prod EXCEPT staging — на «хто не доїхав при перенесенні». Вимога та сама, що й для UNION: однакова кількість стовпців і сумісні типи в обох запитах.
Як поводиться NULL в операціях з множинами?
Інакше, ніж в операторі порівняння. Коли UNION, INTERSECT чи EXCEPT зіставляють рядки, два NULL вважаються одним і тим самим значенням — тоді як звичайне NULL = NULL дає UNKNOWN. Наслідок: рядки, однакові з точністю до NULL у тому самому стовпці, UNION зіллє в один, а INTERSECT зарахує як спільні. Це та сама тризначна логіка, що ламає NOT IN, тільки тут її обійдено: для множинних операцій NULL — просто ще одне значення, яке дорівнює саме собі. Тримайте це в голові, коли звіряєте набори, в яких трапляються пропущені значення.
Коли брати JOIN, коли UNION, а коли підзапит?
Найпростіше думати геометрично. JOIN розширює результат управо: до рядків таблиці додаються стовпці іншої за умовою збігу — беріть його, коли треба бачити пов'язані дані поряд. Операції з множинами нарощують результат униз: рядки другого запиту стають під рядками першого — це для об'єднання однорідних наборів в один список. Підзапит чи CTE копає вглиб: рахує проміжний результат, який зовнішній запит використовує як фільтр, набір чи стовпець. Межа умовна — «клієнтів із замовленнями» можна дістати і JOIN-ом, і через IN, і через EXISTS. Тоді обирайте за наміром і читабельністю: EXISTS каже читачеві «мені важливий сам факт наявності», JOIN — «мені потрібні дані з обох таблиць».
Як знайти другу за величиною зарплату?
Почніть з уточнення — це вже половина балів: потрібне друге унікальне значення чи другий рядок? Коли максимум ділять двоє, «другою за величиною» логічно вважати наступне менше значення, а не повторений максимум. Робочий і стійкий варіант — «максимум серед менших за максимум»: SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees). Дублікати верхівки він ігнорує сам собою, а коли другого значення не існує, чесно віддає NULL замість помилки. Альтернатива — SELECT DISTINCT salary ... ORDER BY salary DESC LIMIT 1 OFFSET 1, і DISTINCT тут не прикраса: без нього зсув з'їдять повторені максимуми. У SQL Server замість LIMIT буде OFFSET ... FETCH або TOP, а «N-те за величиною» найелегантніше розв'язує DENSE_RANK — згадка віконних функцій у відповіді завжди плюс.
Як знайти записи без пари — і які способи безпечні?
Це задача про анти-з'єднання (anti-join): відбери рядки з A, яким у B не знайшлося відповідника — «клієнти без замовлень», «замовлення без платежу», «сироти після міграції». Способів три. LEFT JOIN ... WHERE b.id IS NULL: приєднуємо B і лишаємо тільки рядки, де пари не було. NOT EXISTS (SELECT 1 FROM b WHERE ...): читається як звичайна умова і стійкий до NULL. NOT IN (SELECT ...): третій спосіб і єдиний небезпечний — досить одного NULL у стовпці підзапиту, щоб він назавжди повертав порожньо, і перевірка мовчки збрехала. Перші два еквівалентні за результатом і обидва коректні; саме вони — дефолт для QA.
Чому пастка NOT IN — це найгірший тип фальшивого зеленого для QA?
Бо провал непомітний. Звичайний зламаний тест червоніє — і ви йдете його лагодити. А перевірковий запит на NOT IN при NULL у наборі повертає нуль рядків, тобто рапортує «порушень не знайдено» — той самий результат, що й у здорової системи без порушень. Ви дивитесь на зелене й вірите, що цілісність даних ціла, хоча запит навіть не перевірив її. Це особливо небезпечно для перевірок після міграцій — «немає осиротілих рядків», «жоден запис не втратив пару», — де саме NULL у зовнішніх ключах і з'являється найчастіше. Тому для будь-якого заперечення в перевірковому SQL дефолт — NOT EXISTS чи LEFT JOIN ... IS NULL, а NOT IN над стовпцем, що може бути NULL, — заборонений патерн.
Три кейси, де підзапит вирішує, чи ваша перевірка чесна: перевірка цілісності, що бреше через NOT IN з NULL; пошук осиротілих записів після міграції трьома способами з обгорткою в автотест; і звірка звіту, де UNION тихо зʼїв легітимні рядки. Скрізь — що дивитися і чому.
Кейс 1. Перевірка цілісності, що завжди зелена
Задача типова для QA після релізу: переконатися, що кожне замовлення має платіж — тобто в orders немає рядків без жодного запису в payments. Хтось пише «очевидний» запит:
-- "Замовлення, за яке ніхто не заплатив". Виглядає правильно.
SELECT o.id, o.total
FROM orders o
WHERE o.id NOT IN (SELECT order_id FROM payments);
Запит повертає нуль рядків. Перевірка зелена, тікет закривають — «всі замовлення оплачені». За тиждень фінанси знаходять замовлення без жодного платежу — саме ті, яких запит мав зловити.
Що сталося. У payments не кожен рядок привʼязаний до замовлення: повернення й кредити на рахунок пишуться з order_id = NULL. Досить одного такого рядка, щоб серед значень підзапиту зʼявився NULL. А x NOT IN (..., NULL) розкривається у ... AND x <> NULL; порівняння з NULL дає UNKNOWN, увесь предикат стає UNKNOWN, і жоден рядок не проходить фільтр. Запит повертає порожньо не тому, що неоплачених замовлень немає, а тому, що зламався сам.
Як зловити й полагодити:
-- Діагностика: чи є NULL серед значень підзапиту?
-- Якщо так — NOT IN вище буде фальшиво зеленим.
SELECT count(*) FROM payments WHERE order_id IS NULL;
-- Правильна перевірка через NOT EXISTS — безпечна до NULL.
SELECT o.id, o.total
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM payments p WHERE p.order_id = o.id
);
Що дивитися і чому:
- Порожній результат перевірки — не доказ чистоти.
NOT INнад стовпцем, що може бутиNULL, повертає нуль рядків незалежно від даних. Перш ніж вірити зеленому, спитайте: чи міг стовпець підзапиту міститиNULL? NOT EXISTSдає ту саму семантику без пастки. Він перевіряє існування рядка, а не рівність значень, тожNULLйого не ламає. Це дефолт для будь-якого «знайди записи-порушники».- Тест на пастку варто зробити самому. Заведіть у тестову БД один рядок із
NULLу ключі й переконайтеся, що ваша перевірка цілісності на ньому червоніє. Якщо вона лишається зеленою — вона написана черезNOT INі їй не можна вірити.
Кейс 2. Осиротілі записи після міграції: три способи і автотест
Після міграції даних стандартна перевірка — «жоден рядок не втратив пару». Три канонічні способи анти-зʼєднання, і не всі однаково безпечні:
-- 1. LEFT JOIN + IS NULL: класичне анти-зʼєднання
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- 2. NOT EXISTS: читається як умова, безпечний до NULL
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- 3. NOT IN: НЕБЕЗПЕЧНИЙ, якщо orders.customer_id буває NULL
SELECT c.id, c.name
FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
Варіанти 1 і 2 еквівалентні за результатом і обидва коректні. Варіант 3 — та сама пастка з першого кейсу. У тесті цілісності цю перевірку зазвичай загортають так, щоб очікуваний результат був порожнім, а будь-який рядок означав дефект даних:
import { test, expect } from '@playwright/test';
import { Client } from 'pg';
test('після міграції немає осиротілих клієнтів без жодного замовлення', async () => {
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
// Безпечний варіант: NOT EXISTS, а не NOT IN
const { rows } = await db.query(`
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
)
`);
await db.end();
// очікуємо порожньо; якщо ні — друкуємо порушників у звіт
expect(rows, `осиротілі клієнти: ${JSON.stringify(rows)}`).toHaveLength(0);
});
Що дивитися і чому:
- Оракул тут — порожній набір. Перевірка проходить, лише коли рядків немає; будь-який рядок — це список конкретних порушників, який одразу йде у звіт. Тому важливо друкувати самі рядки, а не лише їхню кількість.
NOT INу такому тесті дає хибний зелений навіть у CI. Якщо мігрованийcustomer_idдесь ставNULL,NOT IN-версія завжди поверне порожньо — тест вічно зелений, а дані биті. ВикористовуйтеNOT EXISTSабоLEFT JOIN ... IS NULL.- Перевіряти треба обидва напрямки. «Клієнти без замовлень» і «замовлення без клієнта» — це два різні анти-зʼєднання; міграція може зламати будь-який бік, тож пишіть обидва запити.
Кейс 3. Звірка звіту, де UNION зʼїв рядки
Звіт має показати всі email-контакти з двох джерел — customers і leads. Розробник пише запит через UNION, а тест на кількість рядків раптом червоніє: у звіті менше контактів, ніж сумарно в обох таблицях.
-- Звіт: усі email з двох джерел
SELECT email FROM customers
UNION
SELECT email FROM leads;
Проблема не в даних і не в тесті — у виборі оператора. UNION неявно робить DISTINCT: якщо той самий email є і серед клієнтів, і серед лідів (звична ситуація — лід став клієнтом), він порахується один раз. Якщо бізнес-вимога — «показати кожен контакт стільки разів, скільки він трапляється у джерелах», легітимні повтори втрачено.
-- Якщо повтори мають лишитися — UNION ALL
SELECT email FROM customers
UNION ALL
SELECT email FROM leads;
-- Діагностика розбіжності: скільки саме зʼїв UNION
SELECT
(SELECT count(*) FROM customers) + (SELECT count(*) FROM leads) AS with_all,
(SELECT count(*) FROM (
SELECT email FROM customers UNION SELECT email FROM leads
) AS u) AS with_union;
Що дивитися і чому:
- Розбіжність кількості — спершу підозрюйте
UNION. Якщо очікувана сума рядків не сходиться з результатом, перевірте, чи не з'ївUNIONлегітимні повтори. Дуже частоUNIONстоїть за звичкою там, де мав бутиUNION ALL. UNION ALL— правильний дефолт. Він дешевший (без сортування/хешування на дедуплікацію) і зберігає всі рядки. Перемикайтесь наUNION, лише коли усунення дублікатів — свідома вимога.- І навпаки —
UNION ALLтам, де чекали унікальності, роздує результат. Тому перед асертом на кількість зафіксуйте, яка семантика правильна за контрактом звіту, і звіряйтеся саме з нею.
Підзапити
- Знаю три позиції підзапиту:
WHERE(фільтр/набір),FROM(похідна таблиця),SELECT(обчислюваний стовпець) — і що позиція диктує, що він має повертати. - Розумію, що скалярний підзапит повертає рівно один рядок і стовпець, а «зайвий» рядок валить запит помилкою
more than one row— і що це сигнал про неунікальні дані. - Памʼятаю, що підзапит у
FROMпотребує аліаса: у MySQL, SQL Server і PostgreSQL до 16 без нього — синтаксична помилка (PostgreSQL 16 і Oracle дозволяють опустити).
Корельовані підзапити, IN та EXISTS
- Можу пояснити різницю: некорельований підзапит рахується один раз, корельований посилається на зовнішній рядок і концептуально виконується для кожного з них.
- Знаю, що
EXISTSзупиняється на першому знайденому рядку, а1чи*після нього — суто звичка, бо важливий лише факт існування. - Розумію, що «EXISTS завжди швидший за IN» — застарілий міф: для позитивної перевірки план часто однаковий (semi-join), вирішує
EXPLAIN. - Знаю головну пастку:
NOT INзNULLу наборі повертає нуль рядків завжди, боx <> NULLдаєUNKNOWN. - Можу назвати безпечну заміну заперечення —
NOT EXISTSабоLEFT JOIN ... IS NULL, які наNULLне ведуться.
CTE (WITH)
- Розумію, що CTE не додає потужності понад підзапити — лише читабельність і повторне використання (один CTE згадують кілька разів без дублювання).
- Знаю, що рекурсивний CTE (
WITH RECURSIVE) призначений для ієрархій: дерево, «керівник → підлеглий», граф залежностей. - Можу спростувати «CTE завжди повільніший»: до PostgreSQL 12 був optimization fence, з PG 12 простий CTE вбудовується, матеріалізацію вмикають явно через
AS MATERIALIZED.
Операції з множинами
- Знаю різницю
UNION(прибирає дублікати, дорого) vsUNION ALL(лишає як є, дешево) і що дефолт —UNION ALL, доки дедуплікація справді не потрібна. - Розумію, що
INTERSECTдає спільні рядки,EXCEPT— рядки першого набору без других (в Oracle цеMINUS), і обидва теж дедуплікують за замовчуванням. - Памʼятаю умову сумісності: однакова кількість стовпців і сумісні типи; імена стовпців беруться з першого запиту.
- Знаю, що в операціях з множинами
NULLвважається рівнимNULL(на відміну від=), тож два рядки зNULLUNIONзіллє в один.
Класичні задачі і QA-практика
- Можу написати «другу за величиною» через
MAX(... ) WHERE salary < (SELECT MAX ...)і знаю, що варто уточнити «друге унікальне значення чи другий рядок». - Знаю три способи анти-зʼєднання («записи без пари») і що безпечні —
LEFT JOIN ... IS NULLтаNOT EXISTS, аNOT INризикований. - Розумію, чому пастка
NOT IN— найгірший фальшивий зелений: перевірка цілісності мовчки рапортує «все чисто», хоча зламалась сама. - Можу пояснити геометрію: JOIN клеїть вшир (стовпці), UNION складає вгору (рядки), підзапит/CTE рахує проміжний результат.
Що поверне NOT IN з підзапитом, серед значень якого є хоча б один NULL?
Питання
Три позиції підзапиту в запиті