Функції, CASE і пастки NULL
Зміст
Половина «багів», які QA знаходить через SQL, — насправді не баги, а нерозуміння NULL. Запит повертає менше рядків, ніж очікувалось; звіт «за сьогодні» порожній, хоча замовлення точно є; сума в SQL не збігається з сумою в UI. У всіх трьох випадках винен один і той самий механізм: NULL і тризначна логіка, за якою SQL обчислює умови.
На співбесідах ця тема — стабільний фільтр між «писав SELECT за туторіалом» і «розуміє, що робить». Питання «що поверне NULL = NULL» ставлять чи не на кожній співбесіді з SQL, і воно миттєво показує глибину. Тому в цій главі спершу розберемо, чим NULL відрізняється від нуля й порожнього рядка, а потім — інструменти, які з ним працюють: COALESCE, NULLIF, CASE, і поруч — суміжні пастки: рядкові функції, дати з таймзонами та перетворення типів.
NULL — це «невідомо», а не нуль і не порожній рядок
NULL — не значення, а позначка «значення відсутнє». У колонці phone NULL означає «телефон невідомий», а не «телефон — порожній рядок» і не «телефон дорівнює нулю». Це різні стани, і база їх розрізняє: '' — відоме значення (порожнє), 0 — відоме значення (нуль), NULL — ми не знаємо, що там.
З цього випливає головне: порівнювати з «невідомо» безглуздо, і SQL чесно це визнає. Будь-яке порівняння, де бере участь NULL, дає не TRUE і не FALSE, а третій результат — UNKNOWN. Так працює тризначна логіка (three-valued logic): у SQL логічний вираз має три можливі результати, а не два.
| Вираз | Результат |
|---|---|
NULL = NULL | UNKNOWN |
NULL != 'active' | UNKNOWN |
NULL + 10 | NULL |
TRUE AND UNKNOWN | UNKNOWN |
FALSE AND UNKNOWN | FALSE |
TRUE OR UNKNOWN | TRUE |
NOT UNKNOWN | UNKNOWN |
NULL = NULL — UNKNOWN, бо ми порівнюємо два невідомих: чи рівні вони, сказати неможливо. Арифметика з NULL так само «заражає» результат: ціна * NULL — NULL.
А тепер ключова деталь, через яку губляться рядки: WHERE пропускає лише рядки, для яких умова дорівнює TRUE. FALSE і UNKNOWN відкидаються однаково.
Класичний наслідок. У таблиці users 10 рядків зі status = 'active', 5 зі status = 'inactive' і 3 зі status NULL:
SELECT COUNT(*) FROM users WHERE status != 'active';
-- Поверне 5, а не 8: для NULL-рядків умова дає UNKNOWN
Інтуїція каже «NULL — це ж не active, отже має потрапити», але для бази NULL != 'active' — UNKNOWN, і рядок відкинуто. Щоб охопити й невідомі статуси, умову доповнюють явно:
WHERE status != 'active' OR status IS NULL
IS NULL та IS NOT NULL — єдині базові предикати, які чесно відповідають TRUE/FALSE на питання про NULL (ти вже бачив їх у главі про SELECT). Для порівняння двох колонок, кожна з яких може бути NULL, у PostgreSQL є IS DISTINCT FROM / IS NOT DISTINCT FROM — NULL-безпечна рівність, яка трактує два NULL як «однакові»; у MySQL ту саму роль виконує оператор <=>.
Ще три місця, де NULL поводиться не так, як очікуєш:
- ORDER BY. Стандарт не фіксує, куди сортувати NULL, тож СУБД розходяться: PostgreSQL за замовчуванням вважає NULL «найбільшим» (при ASC вони внизу), MySQL — «найменшим» (при ASC вони вгорі). У PostgreSQL позицію можна задати явно:
ORDER BY discount DESC NULLS LAST. Для QA це означає: звіряючи відсортований список UI зі своїм запитом, спершу з'ясуй, куди твоя СУБД кладе NULL. - Агрегати.
COUNT(phone)рахує лише не-NULL значення, тому може бути менший заCOUNT(*);AVGтеж ігнорує NULL, а не рахує його як нуль. Детально — у главі про агрегацію. - NOT IN. Один NULL у підзапиті — і
NOT INне поверне жодного рядка. Це найпідступніша пастка NULL, розбираємо її в главі про підзапити.
Абзац глибини: попри те що NULL = NULL — UNKNOWN, DISTINCT і GROUP BY групують усі NULL разом — вони використовують не рівність, а семантику «not distinct». А CHECK-обмеження, на відміну від WHERE, UNKNOWN пропускає: рядок з NULL пройде перевірку CHECK (price > 0). Тому «в колонці з CHECK не може бути дивних значень» — хибне припущення, яке варто перевіряти окремо.
COALESCE і NULLIF
COALESCE повертає перший не-NULL аргумент зі списку:
SELECT COALESCE(discount, 0) FROM orders; -- NULL-знижка стає нулем
SELECT COALESCE(nickname, email, '—') FROM users; -- перший заповнений варіант
Це стандартна функція, працює однаково в усіх основних СУБД. Для QA вона важлива подвійно. По-перше, нею користуєшся сам: у звірках, де NULL треба привести до значення за замовчуванням. По-друге, її використовує (або забуває використати) бекенд: якщо UI показує знижку «0», а в базі лежить NULL — десь між базою і екраном стоїть COALESCE або його аналог у коді. Тоді питання до розробника не «чому дані різні», а «де саме NULL перетворюється на нуль і чи всюди однаково».
NULLIF(a, b) — дзеркальна операція: повертає NULL, якщо аргументи рівні, і перший аргумент, якщо ні. Головне практичне застосування — захист від ділення на нуль:
SELECT amount / NULLIF(quantity, 0) FROM order_items;
-- quantity = 0 → ділимо на NULL → результат NULL, а не помилка
А в парі вони дають типовий шаблон «безпечне ділення з дефолтом»:
SELECT COALESCE(amount / NULLIF(quantity, 0), 0) FROM order_items;
CASE WHEN
CASE — це вираз (не окремий оператор!), який повертає значення за умовами. Його можна вжити всюди, де очікується значення: у SELECT, WHERE, ORDER BY. Основна, «пошукова» (searched) форма:
SELECT id,
CASE
WHEN total >= 1000 THEN 'vip'
WHEN total >= 100 THEN 'regular'
ELSE 'new'
END AS segment
FROM customers;
Правила прості, але кожне — джерело помилок:
- умови перевіряються зверху вниз, перемагає перша TRUE; рядок з
total = 5000отримає 'vip', до другої гілки справа не дійде — тому порядок гілок значущий; ELSEнеобов'язковий, але без нього всі «непокриті» рядки отримають NULL — і цей NULL далі поводитиметься за всіма правилами вище;- гілка з UNKNOWN не спрацьовує — як і у WHERE.
Є ще «проста» форма — CASE колонка WHEN значення THEN .... Вона порівнює через рівність, і саме тут закопана пастка:
CASE status
WHEN 'active' THEN 'Активний'
WHEN NULL THEN 'Невідомо' -- НІКОЛИ не спрацює: status = NULL → UNKNOWN
ELSE 'Інше'
END
Рядки з NULL-статусом підуть у гілку ELSE, а не в «Невідомо». Ловити NULL можна лише пошуковою формою з IS NULL:
CASE
WHEN status = 'active' THEN 'Активний'
WHEN status IS NULL THEN 'Невідомо'
ELSE 'Інше'
END
У щоденній роботі QA CASE найчастіше зустрічається у звірках: привести технічні коди до людських назв, як в UI, або порахувати кілька категорій одним запитом через SUM(CASE WHEN ... THEN 1 ELSE 0 END) — цей прийом розберемо в главі про агрегацію.
Рядкові функції
Базовий набір скрізь схожий, відрізняються деталі. Найуживаніше для QA:
| Функція | Що робить | Пастка |
|---|---|---|
UPPER / LOWER | змінює регістр | стандартний спосіб звірки без урахування регістру |
TRIM | зрізає пробіли з обох країв | «однакові» рядки не рівні через хвостовий пробіл |
LENGTH / CHAR_LENGTH | довжина рядка | у MySQL LENGTH рахує байти, CHAR_LENGTH — символи |
SUBSTRING | фрагмент рядка | нумерація позицій з 1, не з 0 |
CONCAT / оператор конкатенації | склеювання рядків | NULL «з'їдає» весь результат (оператор — у PostgreSQL, CONCAT — у MySQL) |
REPLACE | заміна підрядка | — |
POSITION | позиція підрядка | повертає 0, якщо не знайдено |
Три деталі, які реально стріляють у тестуванні.
NULL у конкатенації. У PostgreSQL оператор || з NULL дає NULL: first_name || ' ' || last_name для користувача без прізвища поверне NULL цілком — і в звіті зникне все ім'я. У MySQL так само поводиться CONCAT (будь-який NULL-аргумент — результат NULL), а от CONCAT_WS NULL-аргументи пропускає — тому в одному звіті імена є, а в іншому зникли, хоча дані ті самі. До речі, || у MySQL за замовчуванням — логічне АБО, а не конкатенація: ще один прояв діалектів, про які йшлося в першій главі розділу.
Байти проти символів. У MySQL LENGTH('Київ') в utf8mb4 поверне 8 (байти), а CHAR_LENGTH('Київ') — 4 (символи). Коли перевіряєш обмеження довжини поля на кирилиці, різниця між цими двома функціями — це різниця між «влазить» і «не влазить».
Регістр у порівняннях і LIKE. У PostgreSQL LIKE чутливий до регістру (для нечутливого є ILIKE), а в MySQL чутливість визначає collation колонки, і дефолтні — нечутливі. Тому один і той самий WHERE city LIKE 'kyiv%' в одній СУБД знайде 'Kyiv', а в іншій — ні. Якщо тест-звірка «не бачить» рядок, який точно є, — спершу перевір регістр і хвостові пробіли, а вже потім бий тривогу.
Дати, час і пастки таймзон
Типів дат кілька: DATE (лише дата), TIME, TIMESTAMP (дата+час без таймзони) і TIMESTAMP WITH TIME ZONE (момент часу; у PostgreSQL — timestamptz). Деталі типів — у главі про DDL і типи даних, тут — те, що ламає перевірки.
timestamptz у PostgreSQL зберігає момент часу в UTC, а при виведенні конвертує в таймзону сесії. timestamp без таймзони — це «настінний годинник»: числа без прив'язки до зони, конвертацій немає. У MySQL схоже розділення: TIMESTAMP конвертується в UTC при записі й назад при читанні, DATETIME зберігається як є. Типова архітектура: база зберігає UTC, UI показує локальний час користувача. Тому «в базі 21:30, а на екрані 00:30» — не баг, а конвертація UTC у київський час (влітку UTC+3). Багом це стає, коли зсув не відповідає таймзоні: подвійна конвертація, зсув на добу, час «стрибає» після переходу на літній/зимовий час.
Друга пастка — межі доби при фільтрації timestamp-колонки:
-- created_at — timestamp. Так «сьогоднішні» замовлення загубляться:
WHERE created_at = '2026-07-17'
-- рядок '2026-07-17' стане '2026-07-17 00:00:00' — збіг лише рівно опівніч
-- BETWEEN захопить зайве:
WHERE created_at BETWEEN '2026-07-17' AND '2026-07-18'
-- включає '2026-07-18 00:00:00' — замовлення рівно опівночі порахується двічі в сусідніх звітах
-- Надійний шаблон — піврозкритий інтервал:
WHERE created_at >= '2026-07-17' AND created_at < '2026-07-18'
А тепер складемо обидві пастки разом — і отримаємо класичний «баг», який QA приносить розробнику, а той закриває як invalid:
Замовлення, створене вночі за Києвом, у UTC належить попередній добі. Якщо звіт ріже добу по UTC, а користувач думає локальним часом — записи біля півночі «мігрують» між днями. Це не баг бази і не баг звіту окремо — це питання «в якій таймзоні визначаємо добу», і його треба з'ясувати до того, як заводити дефект.
Суміжна дрібниця: NOW(), CURRENT_DATE, CURRENT_TIMESTAMP обчислюються за таймзоною сесії/сервера БД, а не твоєю. «Сьогодні» для CI-сервера в іншій зоні може бути ще «вчора» для тебе — улюблена причина тестів, що падають лише вночі. І штрих для глибини: у PostgreSQL NOW() повертає час початку транзакції, тож усередині однієї транзакції він не змінюється між викликами.
В автотестах найнадійніша дисципліна — порівнювати моменти часу в UTC та ISO 8601, а не локальні рядки з UI:
// Playwright: нормалізуємо час з API до UTC перед порівнянням
const created = new Date(order.createdAt);
expect(created.toISOString()).toBe('2026-07-16T21:30:00.000Z');
CAST і неявні перетворення
Явне перетворення типів — CAST(вираз AS тип); у PostgreSQL є короткий синтаксис вираз::тип:
SELECT CAST('123' AS INTEGER); -- стандартно
SELECT '123'::INTEGER; -- PostgreSQL
SELECT CAST(2.7 AS INTEGER); -- 3: PostgreSQL округлює, а не відрізає дріб
Небезпека — у неявних перетвореннях, коли ти порівнюєш значення різних типів і СУБД конвертує сама. PostgreSQL суворий: 'abc'::INTEGER впаде з помилкою invalid input syntax for type integer. MySQL — ні: при порівнянні рядка з числом він мовчки конвертує рядок у число, і 'abc' стає 0, а '123abc' — 123:
-- MySQL: order_number — VARCHAR
SELECT * FROM orders WHERE order_number = 123;
-- знайде і '123', і '123abc', і '0123' — рядки конвертуються в число
Запит «працює», повертає рядки — і саме тому баг живе довго: помилки немає, є тихо неправильна вибірка. Правило для QA: порівнюй рядкову колонку з рядком (= '123'), числову — з числом, і насторожуйся щоразу, коли типи в порівнянні різні.
Ще дві пастки з цієї ж родини:
- Цілочисельне ділення. У PostgreSQL
5 / 2дає2— обидва операнди цілі, дріб відкидається. У MySQL5 / 2дає2.5000. Той самий вираз — різні результати; у PostgreSQL рятує явний CAST одного з операндів у numeric. - Каст на колонці вимикає індекс. Коли для порівняння СУБД мусить конвертувати саму колонку (як у прикладі з
order_number = 123), індекс по цій колонці може не використовуватись, і запит на великій таблиці «раптово» повільний. Механіку розберемо в главі про індекси і швидкодію запитів.
І нагадування з розряду «чому суми не сходяться на копійки»: гроші зберігають у DECIMAL, а не у FLOAT — двійковий float не представляє десяткові дроби точно. Деталі — у главі про типи даних.
Типові помилки
- Виглядає як «
WHERE status != 'active'поверне все, крім active», а насправді рядки з NULL теж відкинуто: порівняння з NULL дає UNKNOWN, а WHERE пропускає лише TRUE. - Виглядає як «
= NULL— те саме, щоIS NULL», а насправді= NULLзавжди UNKNOWN, і вибірка порожня — навіть якщо NULL-рядків повна таблиця. - Виглядає як «
CASE status WHEN NULL THEN ...спіймає невідомі статуси», а насправді проста форма CASE порівнює через рівність, і гілка не спрацює ніколи; NULL ловиться лише черезWHEN status IS NULL. - Виглядає як «BETWEEN по датах бере рівно одну добу», а насправді для timestamp він включає опівніч наступного дня; безпечний шаблон — піврозкритий інтервал
>= початок AND менше кінець. - Виглядає як «звіт за сьогодні порожній — баг бекенда», а насправді
created_at = '2026-07-17'порівнює з рівно опівніччю, а нічні записи ще й «мігрували» в сусідню добу через UTC. - Виглядає як «UI показує не той час, що в базі, — дефект», а насправді база зберігає UTC, а UI конвертує в локальний час; дефект лише тоді, коли зсув не збігається з таймзоною користувача.
- Виглядає як «CONCAT пропустить порожні значення», а насправді один NULL-аргумент робить NULL увесь результат (і
||у PostgreSQL, іCONCATу MySQL); пропускає NULL тількиCONCAT_WS. - Виглядає як «запит з
order_number = 123по varchar-колонці — просто нестрогий стиль», а насправді MySQL мовчки конвертує рядки в числа: у вибірку потрапить і'123abc', а індекс може не спрацювати.
Підсумок
- NULL — це «невідомо», а не нуль і не порожній рядок: будь-яке порівняння з ним дає UNKNOWN, а WHERE лишає тільки рядки з TRUE.
- З NULL працюють лише спеціальні конструкції:
IS NULL/IS NOT NULL,COALESCE(перший не-NULL),NULLIF(NULL при рівності); звичайні=і!=— ніколи. - CASE — вираз: гілки перевіряються зверху вниз, перемагає перша TRUE, без ELSE результат — NULL; NULL ловиться тільки пошуковою формою з
IS NULL. - Доба в timestamp-колонці фільтрується піврозкритим інтервалом; база зазвичай зберігає UTC, UI показує локальний час — записи біля півночі «мігрують» між добами.
- Неявні перетворення типів — джерело тихих багів: MySQL конвертує рядки в числа без помилки, цілочисельне ділення відкидає дріб, а каст на колонці вимикає індекс.
Що питають на співбесіді
- «Що поверне
NULL = NULLі чому?» — перевіряють, чи знаєш тризначну логіку, а не завчене «використовуй IS NULL». Сильна відповідь пояснює: NULL — невідомо, порівняння двох невідомих — UNKNOWN, WHERE його відкидає. - «У таблиці є рядки з NULL у статусі. Що поверне
WHERE status != 'active'?» — класична перевірка на практичне розуміння: чи бачиш ти, що NULL-рядки випадуть, і чи знаєш, як дописати умову. - «Чим COALESCE відрізняється від NULLIF? Наведи приклад для кожної» — очікують не означення, а живий кейс: дефолт для NULL-знижки і захист від ділення на нуль.
- «Як вибрати всі замовлення за конкретну дату, якщо колонка — timestamp?» — інтерв'юер дивиться, чи запропонуєш піврозкритий інтервал і чи згадаєш про таймзону; «
WHERE created_at = дата» — миттєвий мінус. - «Чому час у базі відрізняється від часу в UI на 2–3 години? Це баг?» — перевірка зрілості: чи розрізняєш зберігання в UTC і відображення в локальній зоні, і як з'ясуєш, у якій зоні ріжеться доба звіту.
- «Що поверне
SELECT 5 / 2?» — питання з підступом на знання діалектів і цілочисельного ділення; чесна відповідь — «залежить від СУБД» з поясненням.
Інтерв'юер на цій темі відрізняє кандидата, який пише запити «поки не запрацює», від того, хто передбачає поведінку до запуску. Найсильніше враження справляє не перелік функцій, а вміння пояснити «чому»: чому WHERE відкидає UNKNOWN, чому CASE не ловить NULL рівністю, чому нічні замовлення випали зі звіту.
Джерела
- PostgreSQL: Comparison Functions and Operators — офіційна семантика порівнянь, IS NULL, IS DISTINCT FROM.
- PostgreSQL: Conditional Expressions — CASE, COALESCE, NULLIF з прикладами.
- PostgreSQL: Date/Time Types — timestamp проти timestamptz і поведінка таймзон.
- MySQL: Problems with NULL Values — типові помилки роботи з NULL у MySQL.
- MySQL: Type Conversion in Expression Evaluation — правила неявних перетворень при порівняннях.
Чим NULL відрізняється від нуля і порожнього рядка?
NULL — це не значення, а позначка «значення відсутнє / невідоме», тоді як 0 і '' — цілком відомі значення. У колонці phone порожній рядок означає «телефон відомий і він порожній», нуль — «телефон дорівнює нулю», а NULL — «ми не знаємо, який телефон». База розрізняє ці три стани й поводиться з ними по-різному: з нулем і рядком можна порівнювати звичайним =, а з NULL — ні. Практичний наслідок для QA: коли UI показує порожнє поле, це може бути і '', і NULL, і це різні кейси в базі — тому у звірці «поле пусте» треба уточнювати, який саме стан ти перевіряєш.
Що таке тризначна логіка і що поверне NULL = NULL?
NULL = NULL поверне UNKNOWN — третій логічний результат поряд із TRUE і FALSE. Причина в тому, що NULL означає «невідомо», а порівняти два невідомих неможливо: чи рівні вони, сказати не можна. Саме тому в SQL діє тризначна логіка (three-valued logic): будь-який логічний вираз може дати TRUE, FALSE або UNKNOWN, а не лише перші два. Це поширюється й на арифметику: NULL + 10 дає NULL, ціна * NULL теж NULL — NULL «заражає» результат. Це питання звучить майже на кожній SQL-співбесіді, бо миттєво показує, чи кандидат розуміє механізм, а не завчив «використовуй IS NULL».
Як правильно перевірити, що колонка містить NULL, і чому = NULL не працює?
Перевіряти треба предикатами IS NULL / IS NOT NULL — це єдині базові конструкції, які чесно відповідають TRUE або FALSE на питання про NULL. Запис status = NULL завжди дає UNKNOWN (порівняння з невідомим), тому вибірка WHERE status = NULL буде порожня, навіть якщо вся таблиця складається з NULL-рядків. Це класична пастка початківців: = NULL синтаксично валідний, помилки немає, просто результат завжди «нічого». Якщо ж треба порівняти дві колонки, де NULL можливий з обох боків, у PostgreSQL для цього є NULL-безпечна рівність IS DISTINCT FROM / IS NOT DISTINCT FROM (трактує два NULL як однакові), а в MySQL ту саму роль виконує оператор <=>.
У таблиці є рядки з NULL у статусі. Що поверне WHERE status != 'active'?
Поверне лише рядки з відомим статусом, що не дорівнює 'active', а всі NULL-рядки випадуть. Механізм такий: для NULL-рядка вираз status != 'active' дає UNKNOWN, а крізь WHERE проходять лише рядки з результатом TRUE: і FALSE, і UNKNOWN він відсіює без розбору. Інтуїтивно здається, що NULL «не active» і мусить потрапити у вибірку, але база так не вважає. Якщо є 10 рядків active, 5 inactive і 3 з NULL, то запит поверне 5, а не 8. Щоб охопити й невідомі статуси, умову дописують явно: WHERE status != 'active' OR status IS NULL. Це друга за популярністю перевірка на співбесіді після NULL = NULL.
Чим COALESCE відрізняється від NULLIF? Наведи приклад для кожної.
Це дзеркальні функції. COALESCE повертає перший не-NULL аргумент зі списку — нею NULL приводять до значення за замовчуванням: COALESCE(discount, 0) перетворює відсутню знижку на нуль, COALESCE(nickname, email, '—') бере перший заповнений варіант. NULLIF(a, b) навпаки — дає NULL при збігу аргументів, а інакше віддає перший із них; головне застосування — захист від ділення на нуль: NULLIF(quantity, 0) перетворює нульовий дільник на NULL, і замість помилки виходить NULL. Обидві — стандартні функції, працюють однаково в усіх основних СУБД. Для QA це ще й діагностика: якщо в базі знижка NULL, а UI показує «0», значить, десь на шляху до екрана NULL перетворюється на нуль — COALESCE або його аналог у коді (чи їх якраз забули поставити), — і питання до розробника вже конкретне.
Як безпечно поділити на величину, яка може бути нулем?
Обгорнути дільник у NULLIF(дільник, 0): якщо він дорівнює нулю, NULLIF поверне NULL, а ділення на NULL дає NULL, а не помилку division by zero. Тобто amount / NULLIF(quantity, 0) при quantity = 0 тихо дасть NULL замість падіння запиту. Часто це поєднують із COALESCE, щоб отримати ще й дефолт: COALESCE(amount / NULLIF(quantity, 0), 0) — «безпечне ділення з нулем за замовчуванням». Це типовий робочий шаблон у звірках, де в даних трапляються нульові кількості чи дільники.
CASE — це оператор чи вираз? Де його можна вживати?
CASE — це вираз, а не окремий оператор: він обчислюється у значення, тому доречний у будь-якому місці запиту, яке приймає значення: SELECT, WHERE, ORDER BY, аргументи функцій. Це важлива відмінність від процедурних мов, де case/switch — керуюча конструкція. З того, що CASE повертає значення, випливає практика: ним приводять технічні коди до людських назв просто в SELECT, сортують за кастомним порядком в ORDER BY або закривають кілька лічильників за один прохід через SUM(CASE WHEN ... THEN 1 ELSE 0 END). Для QA CASE найчастіше зустрічається саме у звірках даних з UI, де база зберігає коди, а екран показує назви.
У чому різниця між пошуковою і простою формою CASE?
Пошукова (searched) форма — це CASE WHEN умова THEN ..., де кожна гілка містить повноцінну булеву умову. Проста (simple) форма — CASE колонка WHEN значення THEN ... — коротший запис, який порівнює колонку зі значеннями через рівність. І саме тут закопана пастка: у простій формі WHEN NULL THEN ... не спрацює ніколи, бо колонка = NULL дає UNKNOWN, а не TRUE — NULL-рядки підуть у ELSE, а не в очікувану гілку. Спіймати NULL можна лише пошуковою формою з явним WHEN status IS NULL THEN .... Тому для будь-якої логіки, де важливий NULL, беруть searched-форму, а не просту.
Що станеться, якщо в CASE жодна умова не спрацювала, а ELSE немає?
Тоді CASE поверне NULL — гілка ELSE необовʼязкова, і без неї всі «непокриті» рядки отримають NULL за замовчуванням. Цей NULL далі поводитиметься за всіма правилами тризначної логіки: у наступному порівнянні дасть UNKNOWN, у сумі «заразить» результат. Друга важлива деталь — гілки перевіряються зверху вниз, і перемагає перша, що дала TRUE: рядок з total = 5000 при умовах >= 1000 і >= 100 отримає результат першої гілки, до другої справа не дійде. Тому порядок гілок значущий, а брак ELSE — часте джерело несподіваних NULL у похідних колонках, які потім тихо ламають агрегати чи наступні порівняння.
Куди різні СУБД сортують NULL у ORDER BY і як задати позицію явно?
Стандарт SQL не фіксує позицію NULL при сортуванні, тому дефолти різні: для PostgreSQL NULL — «найбільше» значення, і при ASC такі рядки спливають наприкінці списку; MySQL навпаки трактує NULL як «найменше» і при ASC ставить їх першими. Тобто той самий ORDER BY discount дасть різний порядок рядків з NULL у різних базах. PostgreSQL дозволяє зафіксувати позицію явно — NULLS FIRST / NULLS LAST в ORDER BY. Для QA це прямий практичний наслідок: коли звіряєш відсортований список з UI зі своїм запитом, спершу перевір, де саме твоя база розміщує NULL, бо інакше «розбіжність у порядку» виявиться не багом, а різницею дефолтів сортування.
Як COUNT і AVG поводяться з NULL-значеннями?
Агрегати ігнорують NULL, а не рахують його як нуль. COUNT(phone) рахує лише не-NULL значення колонки, тому може бути меншим за COUNT(*), який рахує всі рядки незалежно від вмісту. AVG(discount) ділить суму не-NULL значень на кількість не-NULL значень — NULL не потрапляє ні в чисельник, ні в знаменник, тобто це не те саме, що вважати його нулем. Практичний наслідок: середнє в SQL може не збігатися із «середнім», яке хтось порахував, підставивши нулі замість пропусків. Тому у звірці сум і середніх завжди варто уточнювати, як обробляються NULL — ігноруються чи приводяться до нуля через COALESCE.
Чому NOT IN з підзапитом іноді не повертає жодного рядка?
Бо один-єдиний NULL у результаті підзапиту ламає весь NOT IN. Механізм знову тризначна логіка: x NOT IN (1, 2, NULL) розкривається у x != 1 AND x != 2 AND x != NULL, а останній доданок — x != NULL — завжди UNKNOWN, і весь AND ніколи не стає TRUE. Тому вибірка виходить порожня, хоча очікувалися рядки. Це одна з найпідступніших пасток NULL, бо запит виглядає коректним і на даних без NULL працює, а варто одному NULL просочитися в довідник — і NOT IN тихо перестає повертати будь-що. Надійніша альтернатива — NOT EXISTS або явне відсіювання NULL у підзапиті.
Чому конкатенація імені й прізвища іноді дає NULL, і як цього уникнути?
Тому що NULL «зʼїдає» весь результат конкатенації. У PostgreSQL оператор || з будь-яким NULL-аргументом дає NULL: у користувача без прізвища first_name || ' ' || last_name поверне не «Іван», а NULL — і в звіті зникне все імʼя разом із наявною частиною. У MySQL так само поводиться функція CONCAT — будь-який NULL робить NULL увесь результат. Уникнути цього допомагає CONCAT_WS (with separator), яка NULL-аргументи просто пропускає, а не обнуляє результат. Звідси типова розбіжність, яку приносять QA: один звіт показує повні імена, а сусідній — порожні клітинки при тих самих даних, просто перший зібраний через CONCAT_WS, а другий через ||.
LENGTH проти CHAR_LENGTH — у чому різниця і коли вона критична?
У MySQL LENGTH рахує довжину в байтах, а CHAR_LENGTH — у символах, і на кирилиці це різні числа. Наприклад, у кодуванні utf8mb4 LENGTH('Київ') поверне 8 (кожна кирилична літера — 2 байти), а CHAR_LENGTH('Київ') — 4. Для латиниці обидві функції дадуть однакове число, тому баг легко проґавити на англомовних тестах і зловити на українських чи іншомовних даних. Критично це стає при перевірці обмежень довжини поля: різниця між LENGTH і CHAR_LENGTH — це буквально різниця між «текст влазить у поле» і «не влазить». Тому у тестах на межі довжини з не-латиницею треба свідомо обирати, у чому міряється ліміт — у байтах чи символах.
Чому один і той самий LIKE 'kyiv%' в одній СУБД знаходить рядок, а в іншій — ні?
Через різну чутливість до регістру. У PostgreSQL LIKE чутливий до регістру, тож city LIKE 'kyiv%' не знайде значення 'Kyiv' — для нечутливого пошуку там є окремий оператор ILIKE. У MySQL чутливість визначає collation колонки, а дефолтні collation зазвичай нечутливі, тому той самий запит знайде і 'Kyiv', і 'KYIV'. Звідси розбіжність: однаковий WHERE city LIKE 'kyiv%' в одній базі повертає рядок, в іншій — ні, хоча дані ідентичні. Практичне правило для QA: якщо тест-звірка «не бачить» рядок, який точно існує, найперше перевіряй регістр і хвостові пробіли (їх зрізає TRIM), а вже потім заводь баг.
Як правильно відібрати всі рядки за конкретну дату, якщо колонка — timestamp?
Піврозкритим інтервалом: WHERE created_at >= '2026-07-17' AND created_at < '2026-07-18'. Пряме WHERE created_at = '2026-07-17' не працює, бо рядок-дата доповнюється до '2026-07-17 00:00:00', і збіг буде лише для записів рівно опівночі — решта дня випаде. BETWEEN '2026-07-17' AND '2026-07-18' теж хибний: він включає верхню межу '2026-07-18 00:00:00', тому запис рівно опівночі порахується двічі в сусідніх звітах. Піврозкритий інтервал (більше-або-дорівнює початок, строго менше наступний день) захоплює рівно одну добу без дірок і перекриттів. Це стандартне питання на співбесіді, і відповідь created_at = дата — миттєвий мінус.
Час у базі на 3 години менший, ніж в UI. Це баг?
Найімовірніше — ні, це очікувана конвертація таймзон. Типова архітектура: база зберігає момент часу в UTC, а UI показує його в локальній зоні користувача. Тому пара «21:30 у базі — 00:30 на екрані» для київського користувача влітку (UTC+3) — правильна поведінка, а не дефект. У PostgreSQL так працює timestamptz (зберігає в UTC, при виведенні конвертує в таймзону сесії), у MySQL — TIMESTAMP. Про баг варто говорити тоді, коли зсув не пояснюється зоною користувача: конвертація спрацювала двічі, дата поїхала на цілу добу або час стрибнув після переходу на літній/зимовий час. Тому зріла відповідь — спершу зʼясувати, у якій зоні зберігається час і в якій показується, а вже потім вирішувати, є розбіжність багом чи ні.
Чому замовлення, створене вночі, «зникає» зі звіту за сьогодні?
Бо вночі за локальним часом воно в UTC належить попередній добі. Користувач у Києві створює замовлення 17.07 о 00:30, база зберігає його як 2026-07-16 21:30 UTC, а звіт із фільтром WHERE created_at >= '2026-07-17' (по UTC) цей рядок не бачить — для UTC це ще 16 липня. В UI замовлення «сьогоднішнє», а у звіті за 17-те його немає. Винні тут не база і не звіт поодинці — команди просто не домовилися, у якій таймзоні визначається доба: користувач думає локальним часом, а звіт ріже день по UTC. Записи біля півночі через це «мігрують» між днями. Перш ніж заводити дефект, треба зʼясувати, у якій зоні має рахуватися доба звіту, — інакше розробник закриє тікет як invalid.
Що поверне SELECT 5 / 2, і чому на це не можна відповідати одним числом?
Правильна відповідь — «залежить від СУБД». PostgreSQL поверне 2: коли обидва операнди цілі, спрацьовує цілочисельне ділення, і дробова частина відкидається (не округлюється). У MySQL той самий вираз дає 2.5000. Це прояв різниці діалектів, тому чесна відповідь на співбесіді — назвати обидві поведінки й пояснити механізм, а не бовкнути «2.5». У PostgreSQL, якщо потрібен дріб, один з операндів явно кастять у numeric. Суміжна пастка з тієї ж родини — гроші треба зберігати в DECIMAL, а не FLOAT: у двійкового float немає точного подання десяткових дробів, і саме тому суми «не сходяться на копійки».
Чим небезпечні неявні перетворення типів при порівнянні рядка з числом?
Тим, що деякі СУБД конвертують типи мовчки й повертають тихо неправильну вибірку без жодної помилки. PostgreSQL тут суворий: 'abc'::INTEGER впаде з помилкою. А MySQL при порівнянні рядкової колонки з числом сам конвертує рядок у число: у запиті WHERE order_number = 123 по VARCHAR-колонці він знайде і '123', і '123abc' (стане 123), і '0123' — бо рядки перетворюються на числа. Помилки немає, рядки повертаються, і саме тому баг живе довго. Друга частина проблеми — коли конвертації зазнає сама колонка, а не літерал: індекс по ній може не використатися, і запит на великій таблиці «раптово» стає повільним. Робоче правило: тип літерала має збігатися з типом колонки — рядок із рядком, число з числом; різнотипне порівняння — завжди привід придивитися.
Три кейси, де NULL і таймзони вирішують, чи знайшов ти баг, чи вигадав його: розбір «звіт за сьогодні порожній» (де переплелися UNKNOWN і межа доби в UTC), звірка статусів UI проти бази через CASE з пасткою простої форми, і API-тест, який порівнює час правильно — нормалізуючи його до UTC й ISO 8601 замість локальних рядків з екрана. Скрізь — що саме дивитися і чому.
Кейс 1. «Звіт за сьогодні порожній» — а замовлення точно є
Класика: тестувальник створює замовлення о 00:30 за Києвом, бачить його в UI як «сьогоднішнє», відкриває звіт за сьогодні — порожньо. Перш ніж заводити баг на бекенд, треба розібрати сам запит звіту. Нехай created_at — це timestamp, а база зберігає UTC.
-- Так писати НЕ можна: збіг лише для записів рівно опівночі
SELECT * FROM orders WHERE created_at = '2026-07-17';
-- BETWEEN захопить зайве — опівніч наступної доби порахується двічі:
SELECT * FROM orders WHERE created_at BETWEEN '2026-07-17' AND '2026-07-18';
-- Надійний шаблон — піврозкритий інтервал:
SELECT * FROM orders WHERE created_at >= '2026-07-17' AND created_at < '2026-07-18';
Але навіть правильний піврозкритий інтервал не побачить нічне замовлення, якщо доба ріжеться по UTC:
-- Користувач створив замовлення 17.07 о 00:30 за Києвом (UTC+3 влітку).
-- У базі воно лежить як:
-- created_at = '2026-07-16 21:30:00' (UTC)
-- Звіт за 17-те по UTC цей рядок не бачить — для UTC це ще 16 липня.
SELECT * FROM orders
WHERE created_at >= '2026-07-17' AND created_at < '2026-07-18';
-- Нічне замовлення сюди не потрапить
Щоб звіт рахував добу за київським часом, межі треба задати в зоні користувача й перевести в UTC. Просто дописати зону в рядок не вийде: при порівнянні з timestamp-колонкою PostgreSQL мовчки проігнорує суфікс Europe/Kyiv у літералі. Тому межі будують через timestamptz і AT TIME ZONE:
-- PostgreSQL: київська доба, переведена в UTC-межі для timestamp-колонки
SELECT * FROM orders
WHERE created_at >= timestamptz '2026-07-17 00:00:00 Europe/Kyiv' AT TIME ZONE 'UTC'
AND created_at < timestamptz '2026-07-18 00:00:00 Europe/Kyiv' AT TIME ZONE 'UTC';
Що дивитися і чому:
= датапо timestamp — завжди підозра. Рядок-дата доповнюється до'2026-07-17 00:00:00', тож збіг буде лише опівніч. Побачив таке в запиті звіту — це вже половина пояснення «порожнього» результату.- Дві пастки складаються. Спершу
=замість інтервалу губить майже весь день, потім UTC-межа «мігрує» нічні записи в сусідню добу. Кожна окремо дає розбіжність, разом — стабільно «зниклі» замовлення біля півночі. - Поодинці ні база, ні звіт тут не винні. Питання «в якій таймзоні визначаємо добу» — продуктове рішення. Спершу зʼясуй його з командою, а вже потім вирішуй, є тут дефект чи ні: інакше тікет закриють як invalid.
- Порожньо ще не означає «даних немає». Той самий запит без фільтра по даті (або з
IS NULLнаcreated_at) швидко покаже, чи справа в умові WHERE, а не у відсутності записів.
Кейс 2. Звірка статусів UI проти бази: CASE, який не ловить NULL
Звіт в UI показує людські назви статусів («Активний», «Неактивний», «Невідомо»), а база зберігає технічні коди. Треба звірити, що для кожного замовлення UI показує правильну назву. Наївний запит через просту форму CASE тихо бреше на NULL-рядках:
-- Пастка: проста форма CASE порівнює через рівність, тому WHEN NULL не спрацює
SELECT id,
CASE status
WHEN 'active' THEN 'Активний'
WHEN 'inactive' THEN 'Неактивний'
WHEN NULL THEN 'Невідомо' -- НІКОЛИ: status = NULL → UNKNOWN
ELSE 'Інше'
END AS label
FROM orders;
-- Рядки з NULL-статусом отримають 'Інше', а не 'Невідомо'
NULL ловиться лише пошуковою формою з явним IS NULL:
SELECT id,
CASE
WHEN status = 'active' THEN 'Активний'
WHEN status = 'inactive' THEN 'Неактивний'
WHEN status IS NULL THEN 'Невідомо'
ELSE 'Інше'
END AS label
FROM orders;
А порахувати всі категорії одним запитом — типовий прийом SUM(CASE ... THEN 1 ELSE 0 END), зручний для звірки лічильників з дашбордом:
SELECT
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_cnt,
SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_cnt,
SUM(CASE WHEN status IS NULL THEN 1 ELSE 0 END) AS unknown_cnt
FROM orders;
Що дивитися і чому:
- Проста форма CASE + NULL = тихий баг. Помилки немає, запит працює, просто NULL-рядки їдуть у ELSE. Якщо на дашборді категорія «Невідомо» завжди 0, хоча NULL-статуси в базі є, — перший підозрюваний саме
CASE status WHEN NULL. - Порядок гілок значущий. Перемагає перша TRUE згори вниз; якщо загальніша умова стоїть раніше за конкретну, конкретна ніколи не спрацює.
- Без ELSE зʼявляється прихований NULL. Непокриті рядки отримають NULL, і він далі «заразить» будь-яке порівняння чи суму. У звірці це виглядає як несподівано порожня клітинка.
SUM(CASE ...)рахує кілька категорій за один прохід. Зручно звіряти з UI-лічильниками, але кожну гілку треба свідомо покрити, зокремаIS NULL, інакше частина рядків просто не порахується ніде.
Кейс 3. API-тест: порівнюємо час в UTC та ISO 8601, а не рядки з UI
Час — головне джерело флаку в перевірках. Локальний рядок з екрана («00:30») залежить від зони раннера, переходу на літній час і локалі; надійна дисципліна — нормалізувати момент часу з API до UTC та ISO 8601 і порівнювати вже його.
import { test, expect } from '@playwright/test';
test('час створення нормалізуємо до UTC перед порівнянням', async ({ request }) => {
const res = await request.get('https://app.example.com/api/orders/42');
expect(res.status()).toBe(200);
const order = await res.json();
// API віддає ISO-момент; new Date + toISOString прибирають вплив зони раннера
const created = new Date(order.createdAt);
expect(created.toISOString()).toBe('2026-07-16T21:30:00.000Z');
});
Окрема перевірка — що фільтр звіту по даті рахує добу в очікуваній зоні, а не «губить» нічні записи:
test('нічне замовлення потрапляє у звіт за потрібну добу', async ({ request }) => {
// замовлення створене 17.07 о 00:30 за Києвом = 16.07 21:30 UTC
const report = await request.get(
'https://app.example.com/api/reports/orders?date=2026-07-17&tz=Europe/Kyiv',
);
const ids = (await report.json()).map((o: { id: number }) => o.id);
// якщо звіт ріже добу по UTC — цей ассерт впаде, і це знахідка, а не флак
expect(ids).toContain(42);
});
Що дивитися і чому:
toISOString()— спільна система координат. Він завжди дає UTC із суфіксомZ, тож тест не залежить від зони CI-раннера. Порівняння локальних рядків («00:30») — навпаки, зелене локально й червоне вночі в іншій зоні.- Розділяй «неправильний зсув» і «неправильна доба». Перший ассерт ловить помилку конвертації часу, другий — помилку межі доби у звіті. Це різні дефекти, і плутати їх в одному ассерті означає отримати незрозуміле падіння.
- Падіння тут — сигнал, а не флак. Якщо нічне замовлення не потрапило у звіт, це відтворюваний баг межі доби, а не «моргання». Не глуши його ретраями — спершу зʼясуй, у якій зоні звіт визначає день.
- Не порівнюй час рядком з UI. Формат на екрані залежить від локалі й зони користувача; оракул свіжості й правильності — момент часу з API, приведений до UTC, а не текст у комірці таблиці.
NULL і тризначна логіка
- Розумію, що NULL — це «невідомо», а не нуль і не порожній рядок:
NULL,0і''— три різні стани, і база їх розрізняє. - Знаю, що будь-яке порівняння з NULL дає UNKNOWN, а не TRUE/FALSE:
NULL = NULL— теж UNKNOWN, бо порівнюємо два невідомих. - Можу пояснити, чому WHERE пропускає лише умови = TRUE, а
WHERE status != 'active'губить NULL-рядки, і вмію дописатиOR status IS NULL. - Знаю різницю між
= NULL(завжди UNKNOWN, вибірка порожня) іIS NULL/IS NOT NULL(єдині чесні предикати для NULL). - Можу пояснити пастку
NOT IN: один NULL у підзапиті — і вибірка порожня, тому надійнішеNOT EXISTS. - Знаю, що агрегати ігнорують NULL (
COUNT(col)менший заCOUNT(*),AVGне рахує NULL як нуль), а в ORDER BY PostgreSQL кладе NULL «найбільшим», MySQL — «найменшим».
COALESCE, NULLIF і CASE
- Знаю різницю між
COALESCE(перший не-NULL аргумент) іNULLIF(NULL, якщо аргументи рівні) і можу навести приклад для кожної. - Пам'ятаю шаблон безпечного ділення
COALESCE(a / NULLIF(b, 0), 0)— захист і від ділення на нуль, і від NULL у результаті. - Розумію, що CASE — це вираз (не оператор): його можна вживати в SELECT, WHERE, ORDER BY, і що проста форма не ловить NULL (
WHEN NULLніколи не спрацює). - Пам'ятаю, що гілки CASE перевіряються зверху вниз, перемагає перша TRUE, а без ELSE непокриті рядки отримують NULL.
Рядкові функції
- Розумію, чому конкатенація з NULL дає NULL (
||у PostgreSQL,CONCATу MySQL) і щоCONCAT_WSNULL-аргументи пропускає. - Знаю різницю між
LENGTH(байти) іCHAR_LENGTH(символи) у MySQL і що на кирилиці це різні числа. - Розумію різницю в чутливості
LIKEдо регістру: PostgreSQL чутливий (єILIKE), MySQL залежить від collation колонки. - Знаю першу гіпотезу, коли звірка «не бачить» наявний рядок: регістр і хвостові пробіли (
TRIM), а не баг застосунку.
Дати, час і таймзони
- Знаю різницю між
timestamp(без зони, «настінний годинник») іtimestamptz(момент часу в UTC), і що зазвичай база зберігає UTC, а UI показує локальний час. - Вмію відібрати добу з timestamp-колонки піврозкритим інтервалом
>= початок AND < кінець, а не через= датачиBETWEEN, і розумію, чому нічні записи «мігрують» між добами. - Пам'ятаю, що
NOW()/CURRENT_DATEрахуються за таймзоною сесії/сервера БД, а не моєю, — звідси тести, що падають лише вночі.
Перетворення типів
- Знаю явне перетворення
CAST(вираз AS тип)і скороченнявираз::типу PostgreSQL, і що PostgreSQL при касті в INTEGER округлює. - Розумію небезпеку неявних перетворень: MySQL мовчки конвертує рядок у число (
'123abc'→123), аSELECT 5 / 2дає2у PostgreSQL і2.5у MySQL. - Пам'ятаю, що каст на колонці може вимкнути індекс і сповільнити запит, а гроші зберігають у
DECIMAL, неFLOAT.
Що поверне вираз NULL = NULL у SQL?
Питання
NULL, 0 і '' — чи це те саме?