SELECT: вибірка, фільтрація, сортування
Зміст
UI показує «120 активних замовлень», API віддає список із 118, а де правда — знає лише база. Єдиний спосіб дістатися цієї правди — запит на вибірку (query) через SELECT. Це найчастіша SQL-операція в роботі QA: звірити цифру з дашборда, знайти запис, який «загубився» після реєстрації, підготувати дані для баг-репорту. І це ж — переважна більшість SQL-задач на співбесідах рівня trainee/junior: «вибери», «відфільтруй», «відсортуй».
Приємний бонус: SELECT нічого не змінює в даних, тож це та команда, яку можна виконувати на спільному стенді без страху щось зламати (про read-only дисципліну — у главі Бази даних у роботі QA). Далі в прикладах працюємо з таблицею orders: колонки id, user_id, status (значення new, paid, cancelled), amount, promo_code (може бути порожнім) і created_at. Що таке таблиця, рядок і ключ — у попередній главі про реляційну модель.
SELECT і FROM: що і звідки
Мінімальний запит складається з двох частин: що вибрати (SELECT) і звідки (FROM):
SELECT id, status, amount
FROM orders;
SELECT * вибирає всі колонки. Для розвідки в консолі — нормально; для звірок і автотестів — погана звичка: коли в таблицю додадуть колонку, «все» стане іншим «все», і перевірка почне порівнювати не те, що ти думаєш. Явний список колонок — це ще й документація наміру: одразу видно, що саме перевіряється.
Колонкам і таблицям можна давати аліаси (alias) — тимчасові імена в межах одного запиту:
SELECT o.id AS order_id, o.amount AS total
FROM orders o;
Слово AS для колонок необов'язкове, для таблиці його зазвичай опускають. Поки таблиця одна, аліас — питання зручності; коли таблиць стане кілька, він стане необхідністю, бо колонки з однаковими іменами треба буде розрізняти (це чекає в главі JOIN: типи з'єднань таблиць).
Логічний порядок виконання запиту
Запит пишеться в порядку SELECT → FROM → WHERE → ORDER BY → LIMIT, але виконується логічно інакше — і саме з цього розсинхрону ростуть половина «дивних» помилок:
Практичні наслідки:
- Аліас із
SELECTне видно уWHERE.WHEREвиконується до того, якSELECTдав колонці нове ім'я, томуWHERE total > 100впаде з помилкою «column does not exist» — фільтрувати треба заamount. - А в
ORDER BYаліас видно — сортування відбувається вже післяSELECT, тожORDER BY total DESCпрацює. LIMITріже вже відсортоване. База спершу знаходить і сортує всі відповідні рядки, і лише потім віддає перші N — а не «бере перші N, що трапилися».
Між WHERE і SELECT у цьому конвеєрі є ще дві стадії — GROUP BY і HAVING, але це тема окремої глави про агрегацію.
WHERE: фільтрація рядків
WHERE залишає лише рядки, для яких умова істинна. Оператори порівняння стандартні: =, <> (нерівність; у більшості СУБД працює і синонім !=), <, >, <=, >=. Рядкові значення — завжди в одинарних лапках:
SELECT id, amount
FROM orders
WHERE status = 'paid' AND amount >= 1000;
Умови комбінуються через AND, OR, NOT, і тут живе класична пастка — пріоритет операторів: NOT виконується першим, потім AND, і лише потім OR. Запит «оплачені замовлення з України або Польщі» новачок пише так:
-- ПОМИЛКА: дужок немає
WHERE status = 'paid' AND country = 'UA' OR country = 'PL'
Через пріоритет це читається як «(оплачені з UA) або (будь-які з PL)» — у вибірку потраплять і скасовані польські замовлення. Правильно:
WHERE status = 'paid' AND (country = 'UA' OR country = 'PL')
Робоче правило: змішуєш AND і OR — став дужки завжди, навіть коли «і так зрозуміло». Ціна зайвих дужок — нуль; ціна пропущених — звірка, яка тихо порівнює не ту множину рядків.
IN, BETWEEN, LIKE
IN — перевірка входження у список, компактна заміна ланцюжка OR:
WHERE status IN ('new', 'paid')
-- те саме, що: status = 'new' OR status = 'paid'
BETWEEN — діапазон, включно з обома межами: amount BETWEEN 100 AND 500 еквівалентно amount >= 100 AND amount <= 500. Із числами все прозоро, а от із датами-часом ховається улюблена пастка звірок «за місяць»:
-- Виглядає як «все за липень», а насправді губить майже весь останній день
WHERE created_at BETWEEN '2026-07-01' AND '2026-07-31'
Якщо created_at — це дата з часом (timestamp), рядок '2026-07-31' перетворюється на 2026-07-31 00:00:00 — і всі замовлення 31 липня після півночі випадають з вибірки. Надійний шаблон для діапазонів дат — закрита ліва межа і відкрита права:
WHERE created_at >= '2026-07-01' AND created_at < '2026-08-01'
LIKE — пошук за шаблоном (pattern): % означає будь-яку послідовність символів (зокрема порожню), _ — рівно один символ:
WHERE email LIKE '%@gmail.com' -- закінчується на @gmail.com
WHERE promo_code LIKE 'SALE_' -- SALE1, SALEX, але не SALE чи SALE10
Два нюанси, які варто знати до того, як вони вкусять. Перший — чутливість до регістру залежить від СУБД: у PostgreSQL LIKE регістрозалежний (для незалежного є розширення ILIKE), у MySQL з типовими колаціями — нечутливий до регістру. Той самий запит на двох стендах з різними СУБД дасть різні результати. Другий — якщо треба знайти літеральні % чи _ (наприклад, промокод 50%OFF), символ-джокер екранують через ESCAPE:
WHERE promo_code LIKE '50\%%' ESCAPE '\'
NULL: чому «= NULL» мовчки повертає нічого
NULL — це не нуль і не порожній рядок, а «значення невідоме». Із цього випливає головний сюрприз SQL: порівняння з NULL не дає ні «істину», ні «хибу» — воно дає третій стан, UNKNOWN (тризначна логіка, three-valued logic). А WHERE пропускає лише рядки, де умова істинна, — UNKNOWN відсіюється так само, як хиба.
-- Обидва запити повернуть НУЛЬ рядків — завжди, незалежно від даних
SELECT * FROM orders WHERE promo_code = NULL;
SELECT * FROM orders WHERE promo_code <> NULL;
Для перевірки на NULL існують спеціальні предикати:
WHERE promo_code IS NULL -- замовлення без промокоду
WHERE promo_code IS NOT NULL -- замовлення з промокодом
Підступніший варіант тієї самої пастки — NULL у запереченні. Запит WHERE promo_code <> 'SALE10' не поверне рядки, де промокод відсутній: порівняння NULL <> 'SALE10' — це UNKNOWN, рядок відсіяно. Хочеш «усі, крім SALE10, включно з порожніми» — пиши явно:
WHERE promo_code <> 'SALE10' OR promo_code IS NULL
Для звірок це критично: «замовлення без промокоду» в UI і <> '' у твоєму запиті — різні множини, якщо в базі там NULL. Тризначна логіка тягне за собою ще кілька пасток (зокрема у функціях і в NOT IN) — їм присвячена окрема глава Функції, CASE і пастки NULL.
ORDER BY: сортування і місце NULL
Без ORDER BY порядок рядків не гарантований. Крапка. Навіть якщо «воно завжди приходило за id» — це деталь реалізації, яка зміниться після оновлення СУБД, перебудови таблиці чи зміни плану запиту. Будь-яка перевірка, що покладається на порядок, зобов'язана мати ORDER BY — інакше це закладений флак.
SELECT id, status, amount
FROM orders
ORDER BY created_at DESC, id DESC;
За замовчуванням сортування висхідне (ASC), DESC — низхідне. Ключів може бути кілька: другий працює як tie-breaker, коли перший дав однакові значення. Це важливіше, ніж здається: якщо сортувати лише за created_at, а два замовлення створені в одну секунду, їхній взаємний порядок знову-таки не визначений — і звірка списку з UI «іноді» падатиме.
Окреме питання — куди сортується NULL. Стандарт SQL віддає це на розсуд СУБД, і реалізації розійшлися:
| СУБД | ORDER BY col ASC | Керування |
|---|---|---|
| PostgreSQL | NULL в кінці (вважається «більшим» за все) | NULLS FIRST / NULLS LAST |
| MySQL | NULL на початку (вважається «меншим» за все) | синтаксису немає; обхід — ORDER BY col IS NULL, col |
Тому «відсортуй за датою знижки» у PostgreSQL і MySQL дасть різні списки, якщо в частини рядків знижки немає. Коли порівнюєш сортування UI з базою, спершу з'ясуй очікування: де мають бути порожні значення — угорі чи внизу? Це рішення продукту, а не «як вийде».
LIMIT і OFFSET: зріз вибірки і перевірка пагінації
LIMIT обмежує кількість рядків у відповіді, OFFSET пропускає перші N:
-- Сторінка 3 при розмірі сторінки 10: пропустити 20, взяти 10
SELECT id, status, amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 20;
Формула: сторінка N розміром S — це LIMIT S OFFSET (N-1)*S. Синтаксис LIMIT/OFFSET підтримують PostgreSQL, MySQL і SQLite; стандарт SQL і, наприклад, SQL Server використовують форму OFFSET n ROWS FETCH NEXT m ROWS ONLY. Суть однакова.
Пагінація (pagination) — одна з найчастіших речей, які QA перевіряє через SQL, бо баги тут типові й добре ловляться звіркою з базою:
- кількість рядків на повній сторінці дорівнює розміру сторінки, остання сторінка — неповна;
- сума рядків за всіма сторінками дорівнює
COUNT(*)з тим самимWHERE; - сторінка за межами даних повертає порожній список, а не помилку;
- між сусідніми сторінками немає ні дублікатів, ні пропусків.
Останній пункт — найцікавіший, бо саме там живуть два неочевидні джерела «багів», які насправді не завжди баги:
Недетермінований порядок. Якщо API сортує за неунікальним полем (та сама created_at без tie-breaker), база має право віддати рядки з однаковим значенням у різному порядку на різних запитах. Результат: запис із кінця сторінки 1 «переїжджає» на початок сторінки 2 — виглядає як дублікат у видачі. Лікування на боці продукту — унікальний хвіст сортування (ORDER BY created_at DESC, id DESC); на боці тестувальника — перевірити, що він там є, перш ніж заводити баг.
Живі дані. OFFSET рахується від поточного стану таблиці. Якщо між запитом сторінки 1 і сторінки 2 хтось створив нове замовлення, яке сортується вище, — вся вибірка зсувається на один рядок: останній запис сторінки 1 повторюється на сторінці 2. На спільному стенді це класика: тест «спіймав баг пагінації», а насправді паралельний прогін підкинув даних. Ізольовані тестові дані — тема глави про тестові дані в автотестах цього ж розділу.
У автотесті звірка сторінки з базою виглядає прямолінійно:
// Playwright: друга сторінка API проти очікування, знятого з БД
// SQL: SELECT id FROM orders ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10
const response = await request.get('/api/orders?page=2&per_page=10');
const apiIds = (await response.json()).items.map((item) => item.id);
expect(apiIds).toEqual(expectedIdsFromDb);
І одне спостереження на виріст: OFFSET 100000 не «перестрибує» рядки — база все одно читає й відкидає перші сто тисяч. Тому глибокі сторінки помітно повільніші за перші, і це варто знати, коли тестуєш пагінацію під навантаженням чи бачиш таймаут саме на далеких сторінках. Чому так і що з цим роблять — у главі Індекси і швидкодія запитів.
DISTINCT: унікальні значення і звірка з UI
DISTINCT прибирає з результату повні дублікати рядків:
SELECT DISTINCT status FROM orders;
-- new, paid, cancelled — кожен статус один раз
Важлива механіка: DISTINCT застосовується до комбінації всіх колонок у SELECT, а не до першої. SELECT DISTINCT user_id, status поверне унікальні пари — той самий user_id з'явиться двічі, якщо має замовлення в різних статусах.
Типовий QA-сценарій — звірка з UI, який показує унікальні сутності поверх неунікальних рядків. Дропдаун «країна» на сторінці фільтрів проти бази:
SELECT DISTINCT country FROM users ORDER BY country;
Або розсинхрон, що виглядає як баг: дашборд каже «клієнтів: 120», а SELECT COUNT(*) FROM orders WHERE status = 'paid' дає 350. Суперечності немає — рядків-замовлень 350, а унікальних клієнтів серед них 120: рахувати треба було COUNT(DISTINCT user_id). Деталі агрегатних функцій — у главі про GROUP BY і HAVING.
Зворотний бік: DISTINCT — інструмент з подвійним дном. Він однаково старанно прибирає і «легітимні» повтори, і дублікати, які самі по собі є багом (два однакові записи через відсутність унікального обмеження). Якщо запит «не працює без DISTINCT» — це привід не додати DISTINCT, а розібратися, звідки дублікати: часто їх розмножує з'єднання таблиць (про це — у главі про JOIN), а шукати справжні дублікати в даних зручніше через GROUP BY ... HAVING.
Типові помилки
- Виглядає як «даних немає», а насправді —
= NULLу фільтрі. Запит зWHERE col = NULLповертає нуль рядків на будь-яких даних. Перевірка на відсутність значення — тількиIS NULL. - Виглядає як правильний фільтр, а насправді
ORбез дужок зʼїв умову.ANDмає вищий пріоритет заOR, і частина вибірки проходить повз головну умову. ЗмішаніAND/OR— завжди в дужках. - Виглядає як «звіт за місяць», а насправді
BETWEENзагубив останній день. Для timestamp-колонокBETWEEN ... AND '2026-07-31'обрізається на півночі 31-го. Діапазони дат — через>=і<наступного дня. - Виглядає як баг пагінації, а насправді — сортування без унікального tie-breaker. Рядки з однаковим значенням ключа сортування «мігрують» між сторінками. Спершу перевір
ORDER BY, потім заводь баг. - Виглядає як стабільний порядок, а насправді його ніхто не обіцяв. Без
ORDER BYпорядок рядків — випадковість реалізації. Тест, що покладається на нього, — флак із відкладеним запуском. - Виглядає як «фільтр “усі, крім X” працює», а насправді він викинув і NULL.
col <> 'X'не пропускає рядки зNULL— множина «крім X» у базі менша, ніж в очікуваннях. - Виглядає як зайва помилка бази, а насправді — аліас у
WHERE.WHEREвиконується доSELECT, тож іменіtotalще не існує. Фільтрувати — за оригінальною колонкою або виразом.
Підсумок
WHEREпропускає лише рядки з істинною умовою: і хиба, і UNKNOWN (результат будь-якого порівняння зNULL) відсіюються. Тому= NULLне працює ніколи, аIS NULL— єдиний правильний спосіб.- Логічний порядок виконання —
FROM → WHERE → SELECT → DISTINCT → ORDER BY → LIMIT— пояснює і чому аліас не видно уWHERE, і чомуLIMITріже вже відсортований результат. - Без
ORDER BYпорядок рядків не гарантований; для стабільної пагінації потрібен ще й унікальний tie-breaker у сортуванні. BETWEENвключає обидві межі, і для дат із часом це означає «до півночі останнього дня» — діапазони надійніше писати через>=і<.DISTINCTпрацює з комбінацією всіх вибраних колонок і легко маскує дублікати-баги: потреба в ньому — привід спитати «звідки повтори?», а не рефлекторно його дописати.
Що питають на співбесіді
- «Вибери всі оплачені замовлення за липень, відсортовані від нових до старих». Базова перевірка синтаксису. Інтерв'юер дивиться, чи поставиш ти лапки навколо рядка, чи не загубиш останній день місяця в діапазоні дат і чи згадаєш
DESC. - «Чому
WHERE col = NULLне повертає рядків і як правильно?» Питання-маркер: відповідь «бо треба IS NULL» — прохідний мінімум, а пояснення через тризначну логіку («порівняння з невідомим — невідоме, а WHERE пропускає лише істину») — рівень, який відрізняє зазубрене від зрозумілого. - «Що поверне запит без ORDER BY? А в якому порядку?» Ловлять на «за id» — правильна відповідь: порядок не гарантований, це деталь реалізації.
- «Як ти перевіриш пагінацію на боці API/БД?» Очікують структуру: розмір сторінок, остання сторінка, сума = COUNT, дублікати/пропуски на межах сторінок — і бонусом розуміння, чому без унікального сортування пагінація «пливе».
- «Чим
LIKE 'a%'відрізняється відLIKE '%a%'? Регістр важливий?» Перевіряють знання джокерів і чесність щодо СУБД-залежності: сильна відповідь згадує, що чутливість до регістру різна в PostgreSQL і MySQL. - «Навіщо DISTINCT? Коли він шкодить?» Дивляться, чи розумієш, що він працює по всіх колонках вибірки, і чи бачиш ризик замаскувати ним справжні дублікати.
Джерела
- PostgreSQL: SELECT — повний довідник команди з усіма клозами.
- PostgreSQL: Sorting Rows (ORDER BY) — сортування і поведінка NULL.
- PostgreSQL: LIMIT and OFFSET — включно з попередженням про недетермінований порядок без ORDER BY.
- MySQL Reference Manual: SELECT Statement — синтаксис і діалектні особливості MySQL.
- MySQL Reference Manual: Working with NULL Values — чому порівняння з NULL не працює.
Що робить SELECT і чому це найчастіша SQL-команда в роботі QA?
SELECT — це запит на вибірку (query): він дістає рядки з таблиць і нічого в них не змінює. Саме тому це та команда, яку безпечно виконувати навіть на спільному стенді — вона read-only, зламати дані нею не вийде. Для QA це головний інструмент звірки: перевірити показник дашборда проти реального стану бази, розшукати запис, що не з'явився після реєстрації, зібрати дані для баг-репорту. Синтаксичний мінімум — дві частини: список колонок після SELECT і таблиця після FROM. А на співбесідах рівня trainee/junior саме вибірка, фільтрація і сортування складають левову частку SQL-задач.
Чому SELECT * — погана звичка в автотестах і звірках?
SELECT * вибирає всі колонки таблиці, і для швидкої розвідки в консолі це нормально. Проблема виникає, коли запит закладено в перевірку: після додавання нової колонки під * опиняється вже інший набір даних — і звірка мовчки порівнює не той зріз, який ти в неї закладав. Явний список колонок (SELECT id, status, amount) вирішує одразу дві задачі: він стабільний до змін схеми і водночас документує намір — з нього одразу видно, що саме перевіряється. Тому в тестах правило просте: перелічуй колонки явно, а * лишай для ручної розвідки.
Розкажи про логічний порядок виконання запиту. Навіщо його знати?
Порядок написання (SELECT → FROM → WHERE → ORDER BY → LIMIT) і логічний порядок виконання — різні речі. База концептуально йде так: спершу FROM (взяти рядки), потім WHERE (відфільтрувати), потім SELECT (обрати колонки й порахувати вирази), потім DISTINCT, далі ORDER BY і насамкінець LIMIT/OFFSET. Більшість «дивних» помилок новачка — саме з цієї розбіжності між «як пишу» і «як виконується». Наприклад, стає зрозуміло, чому аліас із SELECT не видно у WHERE (фільтр відпрацював раніше, ніж колонка отримала нове ім'я), а в ORDER BY видно (сортування йде вже після SELECT). І чому LIMIT ріже вже відсортований результат, а не «бере перші N, що трапилися».
Чому WHERE total > 100 падає з помилкою, якщо total — це аліас із SELECT?
Бо WHERE виконується до SELECT у логічному конвеєрі, а саме SELECT дає колонці нове ім'я через AS total. У момент, коли працює WHERE, імені total ще не існує — тому база каже «column does not exist». Фільтрувати треба за оригінальною колонкою або виразом: WHERE amount > 100. Цікаво, що в ORDER BY total DESC той самий аліас працює без проблем, бо сортування відбувається вже після SELECT. Це класична пастка, яку люблять на співбесідах: вона перевіряє, чи розумієш ти порядок виконання, а не просто синтаксис.
Чим небезпечне змішування AND і OR без дужок?
Пріоритетом операторів: NOT виконується першим, потім AND, і лише потім OR. Тому запит WHERE status = 'paid' AND country = 'UA' OR country = 'PL' читається базою як «(оплачені з UA) АБО (будь-які з PL)» — і у вибірку тихо потраплять навіть скасовані польські замовлення. Помилки синтаксису немає, запит виконається — просто відфільтрує не ту множину рядків, і звірка буде брехати непомітно. Правильно взяти OR-групу в дужки: WHERE status = 'paid' AND (country = 'UA' OR country = 'PL'). Практичне правило: щойно в умові з'являються обидва оператори — бери OR-групу в дужки, не покладаючись на пріоритети; зайві дужки нічого не коштують, а пропущені обертаються хибною перевіркою.
Що таке IN і BETWEEN, і яка головна пастка BETWEEN для QA?
IN перевіряє входження у список і компактно замінює ланцюжок OR: status IN ('new', 'paid') замість status = 'new' OR status = 'paid'. BETWEEN задає діапазон і включає обидві межі — тобто amount BETWEEN 100 AND 500 розгортається в amount >= 100 AND amount <= 500. З числами все прозоро, а пастка ховається в датах-часі. Якщо created_at — це timestamp (дата з часом), то BETWEEN '2026-07-01' AND '2026-07-31' фактично означає «до 2026-07-31 00:00:00» — і всі замовлення 31 липня після півночі випадають з вибірки, хоча запит виглядає як «звіт за липень». Безпечніше формулювати діапазон дат напіввідкрито: ліва межа через >=, права — строго < наступного дня: created_at >= '2026-07-01' AND created_at < '2026-08-01'.
Як працює LIKE і на що звернути увагу з регістром?
LIKE — пошук за шаблоном (pattern): % підставляється замість будь-якої кількості символів (навіть нуля), _ — замість рівно одного. Тобто email LIKE '%@gmail.com' знаходить усе, що закінчується на @gmail.com, а promo_code LIKE 'SALE_' матчить SALE1 чи SALEX, але не SALE і не SALE10. Головний нюанс для звірок — чутливість до регістру залежить від СУБД: у PostgreSQL LIKE регістрозалежний (для незалежного є ILIKE), а в MySQL з типовими колаціями — нечутливий. Той самий запит на двох стендах з різними СУБД може дати різні результати. Окремо: щоб знайти літеральні % чи _ (як у промокоді 50%OFF), символ-джокер екранують через ESCAPE.
Чому WHERE promo_code = NULL завжди повертає нуль рядків?
Бо NULL означає «невідомо», а результат будь-якого порівняння з невідомим — теж невідомий: не істина й не хиба, а третій стан UNKNOWN (тризначна логіка, three-valued logic). WHERE же лишає тільки рядки, де умова дала істину, — рядки з UNKNOWN він відкидає нарівні з хибними. Через це і promo_code = NULL, і promo_code <> NULL повертають нуль рядків завжди, незалежно від даних. Для перевірки на NULL існують спеціальні предикати: IS NULL і IS NOT NULL. Для QA це критично: «замовлення без промокоду» в UI і = NULL у запиті — це гарантовано порожня вибірка, а не те, що ти хотів перевірити.
Чому фільтр WHERE promo_code <> 'SALE10' може «загубити» рядки, і як це виправити?
Це підступніший бік тризначної логіки — NULL у запереченні. Для рядків, де promo_code дорівнює NULL, порівняння NULL <> 'SALE10' дає UNKNOWN, а не «істину» — тому такі рядки WHERE відсіює. У результаті множина «усі, крім SALE10» у базі виходить меншою, ніж ти очікуєш: у ній немає замовлень без промокоду взагалі. Якщо потрібні «усі, крім SALE10, включно з порожніми», умову треба дописати явно: WHERE promo_code <> 'SALE10' OR promo_code IS NULL. Для звірок це прямий ризик: «крім X» у твоєму запиті і «крім X» у логіці продукту можуть виявитися різними множинами саме через NULL.
Що поверне запит без ORDER BY і в якому порядку?
Формально він поверне ті самі рядки, але порядок їх не гарантований — крапка. «Воно завжди приходило за id» — ілюзія: так склалася поточна реалізація, і оновлення СУБД, перебудова таблиці чи інший план запиту її зруйнують. На співбесіді на цьому ловлять: правильна відповідь — «порядок не визначений», а не «за первинним ключем». Практичний наслідок: будь-яка перевірка, що покладається на порядок рядків, зобов'язана мати ORDER BY — інакше це закладений флак, який рано чи пізно почне падати «на рівному місці» без змін у коді.
Навіщо в ORDER BY другий ключ сортування (tie-breaker)?
Другий ключ визначає порядок серед рядків, де перший ключ дав однакове значення. Якщо сортувати лише за created_at, а два замовлення мають однакову секунду створення, їхній взаємний порядок ніде не зафіксований — база має право віддати їх по-різному на різних запитах, і порівняння списку з UI падатиме час від часу. Додавши унікальний хвіст, наприклад ORDER BY created_at DESC, id DESC, ти робиш порядок детермінованим: id унікальний, тож нічиєї не лишається. Це не косметика, а вимога для будь-якого стабільного тесту на список — особливо для пагінації, де недетермінований порядок виглядає як «дублікати» між сторінками.
Куди сортується NULL у ORDER BY і чому це залежить від СУБД?
Позицію NULL у сортуванні стандарт SQL не фіксує — кожна СУБД вирішує сама, і рішення вийшли протилежні: PostgreSQL за замовчуванням ставить NULL у кінець при ASC (вважає його «більшим» за все), а MySQL — на початок (вважає «меншим»). Керується це по-різному: у PostgreSQL є явні NULLS FIRST / NULLS LAST, а в MySQL такого синтаксису немає — обхід через ORDER BY col IS NULL, col. Наслідок для звірок: «відсортуй за датою знижки» у PostgreSQL і MySQL дасть різні списки, якщо в частини рядків знижки немає. Коли порівнюєш сортування UI з базою, спершу з'ясуй очікування продукту — де мають бути порожні значення, вгорі чи внизу, — бо це рішення, а не «як вийде».
Як влаштована пагінація через LIMIT і OFFSET?
LIMIT обмежує кількість рядків у відповіді, OFFSET пропускає перші N. Формула: сторінка N розміром S — це LIMIT S OFFSET (N-1)*S; наприклад, третя сторінка по 10 записів — LIMIT 10 OFFSET 20. Важливо, що LIMIT ріже вже відсортований результат, тому пагінація без ORDER BY не має сенсу — база спершу знаходить і сортує всі відповідні рядки, і лише потім віддає потрібний шматок. Синтаксис LIMIT/OFFSET підтримують PostgreSQL, MySQL і SQLite; стандарт SQL і SQL Server використовують форму OFFSET n ROWS FETCH NEXT m ROWS ONLY, але суть однакова.
Як ти перевіриш пагінацію на боці API/БД?
Структурно, за кількома інваріантами. По-перше, кількість рядків на повній сторінці дорівнює розміру сторінки, а остання сторінка — неповна. По-друге, сума рядків за всіма сторінками дорівнює COUNT(*) з тим самим WHERE. По-третє, сторінка за межами даних повертає порожній список, а не помилку. І найцікавіше — між сусідніми сторінками не має бути ні дублікатів, ні пропусків. Технічно це робиться так: береш очікувані id з бази (SELECT id FROM orders ORDER BY ... LIMIT 10 OFFSET 10) і звіряєш із тим, що віддав API на ту саму сторінку. Бонус на співбесіді — згадати, що без унікального сортування пагінація «пливе».
Тест спіймав «дублікат» між сторінками пагінації. Це завжди баг продукту?
Ні, і саме тут два неочевидні джерела, які часто не баги. Перше — недетермінований порядок: якщо API сортує за неунікальним полем (та сама created_at без tie-breaker), рядки з однаковим значенням ключа база може повертати то в одному, то в іншому порядку — і запис із хвоста сторінки 1 випливає на початку сторінки 2. Лікування — унікальний хвіст сортування на боці продукту; від тестувальника — спершу перевірити, що він там є. Друге — живі дані: OFFSET рахується від поточного стану таблиці, тож якщо між запитом сторінки 1 і сторінки 2 хтось створив запис, що сортується вище, вся вибірка зсувається й останній рядок сторінки 1 повторюється. На спільному стенді це класика: паралельний прогін підкинув даних, а тест «спіймав баг».
Чому OFFSET 100000 повільний, хоча ми беремо лише 10 рядків?
Бо OFFSET не «перестрибує» рядки — база все одно читає й відкидає перші сто тисяч, перш ніж віддати наступні 10. Тобто вартість запиту росте з глибиною сторінки: глибокі сторінки помітно повільніші за перші, навіть при однаковому LIMIT. Для QA це практично важливо у двох ситуаціях: коли тестуєш пагінацію під навантаженням і коли бачиш таймаут саме на далеких сторінках — це не обов'язково баг, а природа OFFSET. Продакшн-рішення цієї проблеми — keyset-пагінація (за значенням останнього ключа замість OFFSET), а деталі того, чому так, — у темі про індекси і швидкодію запитів.
Як саме працює DISTINCT — до якої колонки він застосовується?
DISTINCT прибирає з результату повні дублікати рядків, і ключове: він застосовується до комбінації всіх колонок у SELECT, а не до першої. Тому SELECT DISTINCT user_id, status поверне унікальні пари (user_id, status) — той самий user_id з'явиться двічі, якщо має замовлення в різних статусах. Це часте джерело плутанини: люди пишуть DISTINCT user_id, status, очікуючи унікальних користувачів, а отримують унікальні комбінації. Типовий коректний сценарій для QA — звірка дропдауна з базою: SELECT DISTINCT country FROM users ORDER BY country дає перелік значень, який має збігтися з фільтром на сторінці.
Дашборд каже «клієнтів: 120», а SELECT COUNT(*) FROM orders WHERE status = 'paid' дає 350. Це баг?
Не обов'язково — найімовірніше, суперечності немає, а рахували різні речі. COUNT(*) рахує рядки-замовлення, і їх справді 350, а дашборд показує унікальних клієнтів серед цих замовлень, яких 120. Один клієнт міг зробити кілька оплачених замовлень, тому число рядків більше за число клієнтів. Правильний запит під метрику дашборда — COUNT(DISTINCT user_id), а не COUNT(*). Перш ніж заводити баг «цифри не б'ються», варто спитати, що саме рахує кожна сторона: сутності чи рядки. Це типова пастка звірки, де «баг» насправді в різному визначенні того, що рахуємо.
Коли DISTINCT шкодить, а не допомагає?
Коли ним маскують справжню проблему. DISTINCT однаково старанно прибирає і легітимні повтори, і дублікати, які самі по собі є багом — наприклад, два однакові записи через відсутність унікального обмеження. Тому якщо запит «не працює без DISTINCT» — це привід не дописати його рефлекторно, а розібратися, звідки взялися дублікати. Часто їх розмножує з'єднання таблиць (JOIN), і тоді DISTINCT лікує симптом, а не причину. Шукати справжні дублікати в даних зручніше через GROUP BY ... HAVING, який покаже, скільки разів кожне значення повторюється, а не просто сховає повтори. Сильна відповідь на співбесіді показує, що кандидат бачить у DISTINCT інструмент з подвійним дном.
Три кейси, де SELECT вирішує, чи звірка з базою скаже правду: «звіт за місяць», який мовчки губить останній день, автотест пагінації проти БД разом із розбором «псевдодубліката», і розсинхрон «дашборд проти бази», де баг насправді в тому, що рахували різні речі. Скрізь працюємо з таблицею orders (id, user_id, status, amount, promo_code, created_at).
Кейс 1. «Звіт за липень», який загубив останній день
QA готує звірку: скільки оплачених замовлень за липень показує адмінка і скільки їх у базі. Природний перший запит виглядає правильним і навіть повертає щось схоже на правду:
-- Виглядає як «усе за липень», а насправді обрізається на півночі 31-го
SELECT COUNT(*)
FROM orders
WHERE status = 'paid'
AND created_at BETWEEN '2026-07-01' AND '2026-07-31';
Різниця з адмінкою — 40 замовлень, і легко завести баг «дашборд рахує неправильно». Але баг тут у запиті. created_at — це timestamp, а рядок '2026-07-31' СУБД трактує як 2026-07-31 00:00:00. BETWEEN включає обидві межі, тож усе, що сталося 31 липня після півночі, у діапазон не потрапляє — весь останній день, крім рівно опівнічної миті, випадає.
Надійний шаблон — закрита ліва межа і відкрита права:
SELECT COUNT(*)
FROM orders
WHERE status = 'paid'
AND created_at >= '2026-07-01' AND created_at < '2026-08-01';
Що дивитися і чому:
- Тип колонки вирішує все. Якби
created_atбула чистоюDATEбез часу,BETWEENспрацював би коректно. Пастка активується саме наtimestamp— а це майже завжди в реальних таблицях подій. Перед звіркою «за період» перевір тип колонки. - Різниця рівно на «хвіст доби» — це маркер. Коли база й UI розходяться саме на записи останнього дня, першим підозрюваним має бути закрита права межа діапазону, а не логіка дашборда.
>= ... < наступний деньмасштабується. Той самий шаблон працює для дня, тижня, кварталу — і не залежить від того,DATEце чиtimestamp, бо не спирається на «остання секунда включно».
Кейс 2. Автотест пагінації проти БД і розбір «псевдодубліката»
Пагінація — одна з найчастіших речей, які QA перевіряє через SQL, бо баги тут типові. Оракул беремо з бази, а перевіряємо відповідь API. Ключове — сортування в SQL має точно збігатися з тим, що застосовує бекенд, і мати унікальний хвіст:
-- Очікувані id другої сторінки по 10 записів
SELECT id
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 10;
import { test, expect } from '@playwright/test';
test('друга сторінка API збігається з очікуванням із БД', async ({ request }) => {
// expectedIdsFromDb — результат SQL вище, знятий заздалегідь
const response = await request.get('/api/orders?status=paid&page=2&per_page=10');
expect(response.status()).toBe(200);
const apiIds = (await response.json()).items.map((item) => item.id);
// порядок важливий: порівнюємо послідовності, а не множини
expect(apiIds).toEqual(expectedIdsFromDb);
});
Окремо варто закрити інваріанти пагінації одним набором перевірок:
test('інваріанти пагінації', async ({ request }) => {
const perPage = 10;
const total = await totalPaidFromDb(); // SELECT COUNT(*) FROM orders WHERE status='paid'
// повна сторінка має рівно perPage записів
const p1 = await (await request.get(`/api/orders?status=paid&page=1&per_page=${perPage}`)).json();
expect(p1.items).toHaveLength(perPage);
// сторінка за межами даних — порожній список, а не помилка
const far = await request.get(`/api/orders?status=paid&page=99999&per_page=${perPage}`);
expect(far.status()).toBe(200);
expect((await far.json()).items).toHaveLength(0);
// сума рядків за всіма сторінками = COUNT(*) з тим самим WHERE
expect(await sumOfAllPages(perPage)).toBe(total);
});
Тест «спіймав» дублікат: запис із кінця сторінки 1 з'явився на початку сторінки 2. Перш ніж заводити баг, розбираємо два невинні джерела.
Що дивитися і чому:
- Недетермінований порядок. Якщо API сортує лише за
created_atбез унікального tie-breaker, база має право віддати рядки з однаковим значенням у різному порядку на різних запитах — запис «мігрує» між сторінками. Лікування на боці продукту — унікальний хвіст (ORDER BY created_at DESC, id DESC); від тестувальника — спершу переконатися, що він там є, і тільки потім писати баг. - Живі дані.
OFFSETрахується від поточного стану таблиці. Якщо між запитом сторінки 1 і сторінки 2 паралельний прогін створив замовлення, що сортується вище, вся вибірка зсувається на рядок — останній запис сторінки 1 повторюється. На спільному стенді це класика хибного падіння; лікується ізоляцією тестових даних, а не переписуванням продукту. OFFSETна глибині — не баг, а природа. Таймаут саме на далеких сторінках означає, що база читає й відкидає всі попередні рядки. Це очікувано; для великих таблиць продукт переходить на keyset-пагінацію.
Кейс 3. «Клієнтів: 120» на дашборді проти COUNT(*) = 350
Приходить репорт: дашборд показує 120 клієнтів, а проста перевірка дає інше число.
-- 350 — але це рядки-замовлення, а не клієнти
SELECT COUNT(*) FROM orders WHERE status = 'paid';
Суперечності немає: оплачених замовлень справді 350, а унікальних клієнтів серед них — 120, бо один клієнт міг оплатити кілька разів. Дашборд рахує сутності, а COUNT(*) рахує рядки. Правильний запит під метрику:
SELECT COUNT(DISTINCT user_id) FROM orders WHERE status = 'paid';
-- 120 — збігається з дашбордом
Той самий DISTINCT доречний і для звірки дропдаунів: перелік значень фільтра на сторінці має збігтися з унікальними значеннями в базі.
SELECT DISTINCT country FROM users ORDER BY country;
Що дивитися і чому:
- Спершу спитай, що рахує кожна сторона. «Цифри не б'ються» найчастіше означає, що UI рахує унікальні сутності, а перевірка — рядки. Це не баг продукту, а різні визначення.
COUNT(*)протиCOUNT(DISTINCT user_id)— перше, що варто розвести. DISTINCT— по всіх колонках вибірки.SELECT DISTINCT user_id, statusдасть унікальні пари, а не унікальних користувачів: той самийuser_idповернеться двічі, якщо має замовлення в різних статусах. Для дропдауна «країна» бери рівно одну колонку.- Обережно з
NULLу фільтрі звірки. Якщо звіряєш «клієнтів без промокоду»,WHERE promo_code = ''іWHERE promo_code IS NULL— різні множини. У базі порожній промокод часто зберігається якNULL, і тоді= ''тихо поверне не тих. Перевірка на відсутність значення — тількиIS NULL. - Потреба в
DISTINCT— сигнал, а не рішення. Якщо звірка «працює лише зDISTINCT», спершу з'ясуй, звідки повтори: часто їх розмножує майбутнійJOIN, і тодіDISTINCTховає справжній дублікат-баг. Порахувати реальні повтори зручніше черезGROUP BY user_id HAVING COUNT(*) > 1.
SELECT, FROM і порядок виконання
- Знаю різницю між
SELECT *і явним списком колонок і чому в тестах/звірках*— пастка (нова колонка тихо змінює «все»). - Можу пояснити, що таке аліас (alias) колонки й таблиці та навіщо
AS. - Пам'ятаю логічний порядок виконання:
FROM → WHERE → SELECT → DISTINCT → ORDER BY → LIMIT, а не порядок запису. - Можу пояснити, чому аліас із
SELECTне видно уWHERE, але видно вORDER BY, і чомуLIMITріже вже відсортований результат, а не «перші N, що трапилися».
WHERE і логіка умов
- Знаю оператори порівняння, зокрема
<>(і синонім!=), і що рядки завжди в одинарних лапках. - Пам'ятаю пріоритет
NOT→AND→ORі чому змішаніAND/ORбез дужок не падають, а тихо фільтрують не ту множину рядків.
IN, BETWEEN, LIKE
- Можу замінити ланцюжок
ORнаINі навпаки. - Пам'ятаю, що
BETWEENвключає обидві межі, чому для timestamp-колонок це губить останній день, і знаю надійний шаблон діапазону дат:>= початок AND < наступний день. - Знаю джокери
LIKE:%— будь-яка послідовність (зокрема порожня),_— рівно один символ. - Пам'ятаю, що чутливість
LIKEдо регістру різна в PostgreSQL (єILIKE) і MySQL, і що для літеральних%/_єESCAPE.
NULL і тризначна логіка
- Розумію, що
NULL— «невідомо», а порівняння з ним дає UNKNOWN, якийWHEREвідсіює як хибу. - Знаю, що
= NULLі<> NULLзавжди повертають нуль рядків, а перевірка — тількиIS NULL/IS NOT NULL. - Можу пояснити, чому
col <> 'X'викидає й рядки зNULL, і як дописатиOR col IS NULL.
ORDER BY і місце NULL
- Пам'ятаю, що без
ORDER BYпорядок рядків не гарантований — це деталь реалізації, а не «за id». - Розумію роль другого ключа (tie-breaker) і чому для стабільності потрібен унікальний хвіст, як-от
id. - Знаю, що
NULLсортується по-різному: у PostgreSQL у кінець (NULLS FIRST/LAST), у MySQL на початок (обхідcol IS NULL, col).
LIMIT/OFFSET, пагінація і DISTINCT
- Знаю формулу пагінації (сторінка
NрозміромS— цеLIMIT S OFFSET (N-1)*S) та інваріанти перевірки: розмір сторінки, неповна остання, сума =COUNT(*), немає дублів/пропусків на межах. - Розрізняю справжній баг пагінації і два «псевдобаги»: недетермінований порядок без tie-breaker і зсув через живі дані.
- Розумію, чому
OFFSETна глибоких сторінках повільний (рядки читаються й відкидаються, а не перестрибуються). - Пам'ятаю, що
DISTINCTпрацює по комбінації всіх колонокSELECT, знаю різницюCOUNT(*)таCOUNT(DISTINCT user_id), і що потреба вDISTINCT— привід спитати «звідки дублікати?», а не рефлекс.
Який логічний порядок виконання запиту (не порядок запису)?
Питання
З яких двох обов'язкових частин складається мінімальний SELECT?