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

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

    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, але виконується логічно інакше — і саме з цього розсинхрону ростуть половина «дивних» помилок:

    FROM
    взяти рядки

    WHERE
    відфільтрувати

    SELECT
    обрати колонки,
    порахувати вирази

    DISTINCT
    прибрати дублікати

    ORDER BY
    відсортувати

    LIMIT / OFFSET
    відрізати шматок

    FROM
    взяти рядки

    WHERE
    відфільтрувати

    SELECT
    обрати колонки,
    порахувати вирази

    DISTINCT
    прибрати дублікати

    ORDER BY
    відсортувати

    LIMIT / OFFSET
    відрізати шматок

    Практичні наслідки:

    • Аліас із 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Керування
    PostgreSQLNULL в кінці (вважається «більшим» за все)NULLS FIRST / NULLS LAST
    MySQLNULL на початку (вважається «меншим» за все)синтаксису немає; обхід — 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? Коли він шкодить?» Дивляться, чи розумієш, що він працює по всіх колонках вибірки, і чи бачиш ризик замаскувати ним справжні дублікати.

    Джерела