DDL, типи даних і обмеження цілісності
Зміст
Досі ми працювали з даними всередині вже готових таблиць: читали і змінювали рядки. Ця глава — про рівень нижче: як таблиця влаштована. Який тип у кожного стовпця, які правила база не дасть порушити навіть найкривішому запиту застосунку. Для QA це не адміністративна деталь, а джерело цілого класу багів: гроші, що не сходяться на копійку, emoji, які перетворюються на ?, час події, що «стрибає» на три години між середовищами, і 500-та помилка там, де мала бути 400.
Це глава-поглиблення: при першому проході її можна пропустити — жоден базовий SELECT-JOIN-GROUP BY на неї не спирається. Але вона канонічна для теми типів і обмежень: коли інші глави згадують NOT NULL, зовнішній ключ чи CHECK, вони посилаються сюди. Читати варто перед співбесідою рівня middle і щоразу, коли баг пахне «дані в базі не такі, як очікує застосунок».
Чотири підмови SQL: DDL, DML, DCL, TCL
SQL зазвичай ділять на чотири групи команд — не заради академічності, а тому що вони поводяться по-різному щодо транзакцій і прав доступу. На співбесіді питання «на які категорії поділяється SQL» — розминкове, але провалити його прикро.
- DDL (Data Definition Language) — визначає структуру:
CREATE,ALTER,DROP,TRUNCATE. Створює й міняє таблиці, індекси, типи, обмеження. - DML (Data Manipulation Language) — маніпулює даними:
INSERT,UPDATE,DELETEіSELECT(частина авторів виносить читання в окрему DQL, але на junior-рівні це деталь). - DCL (Data Control Language) — керує доступом:
GRANT(видати право) іREVOKE(відібрати). Саме DCL стоїть за read-only доступом QA на прод, про який ішлося у першій главі розділу. - TCL (Transaction Control Language) — керує транзакціями:
COMMIT,ROLLBACK,SAVEPOINT. Механіку розбирає окрема глава про транзакції та ACID.
Одна практична різниця, яку варто знати вже зараз: у PostgreSQL DDL транзакційний — CREATE TABLE чи ALTER можна відкотити всередині BEGIN ... ROLLBACK. А в MySQL DDL спричиняє неявний commit: щойно ви виконали ALTER TABLE, відкрита транзакція мовчки зафіксувалась, і відкату вже немає. Це прямо впливає на міграції та на клінап у тестах.
CREATE, ALTER, DROP: життя таблиці
CREATE TABLE народжує таблицю: перелік стовпців, їхні типи й обмеження в одному місці.
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
status VARCHAR(20) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Уже в цьому визначенні зашито більше правил, ніж у половині тест-кейсів: id генерує база, user_id мусить указувати на реального користувача, amount не буває від'ємним, валюта за замовчуванням USD, час створення проставляється сам. Кожен рядок тут — потенційна перевірка.
ALTER TABLE змінює вже наявну таблицю: додати стовпець (ADD COLUMN), прибрати (DROP COLUMN), змінити тип (ALTER COLUMN ... TYPE), навісити чи зняти обмеження (ADD CONSTRAINT / DROP CONSTRAINT). Це найнебезпечніша DDL-операція на живій базі, і QA стикається з нею через міграції.
ALTER TABLE orders ADD COLUMN discount NUMERIC(12, 2);
Класична пастка міграцій: додати стовпець одразу NOT NULL без DEFAULT до таблиці, у якій уже є рядки. База не знає, що поставити в наявні рядки, і в PostgreSQL команда впаде (MySQL без strict mode натомість може мовчки заповнити наявні рядки неявним дефолтом — нулем чи порожнім рядком, що по-своєму гірше). Тому такі зміни роблять у два-три кроки (додати nullable → заповнити → навісити NOT NULL) — це частина патерну expand-contract, який детально розбирає глава про тестування БД і міграції.
DROP TABLE знищує таблицю разом зі структурою, індексами й обмеженнями — незворотно (різницю з DELETE і TRUNCATE розкладено в главі про зміну даних). У ручній роботі тестувальника DROP робити нема чого; це інструмент міграцій і адміністрування.
Типи даних: вибір типу — це вже вимога
Тип стовпця — не формальність, а контракт про те, які значення взагалі можуть існувати. Помилка у виборі типу породжує баги, які не ловляться на рівні коду, бо код формально коректний — просто база зберігає не те.
Числа: DECIMAL проти FLOAT і чому гроші не у float
Це найважливіший пункт глави і стабільне питання на співбесіді. Є дві родини числових типів:
| Родина | Типи | Як зберігає | Точність |
|---|---|---|---|
| Точні | NUMERIC / DECIMAL, цілі INTEGER/BIGINT | десятково, як записано | абсолютна |
| Наближені | REAL / FLOAT / DOUBLE PRECISION | двійковим кодом (IEEE 754) | приблизна |
FLOAT і DOUBLE зберігають число у двійковій формі з плаваючою комою. Проблема в тому, що багато звичних десяткових дробів у двійковій системі не мають скінченного запису — так само як 1/3 не записати скінченно в десятковій. Найвідоміша ілюстрація: 0.1 + 0.2 у float дає не 0.3, а 0.30000000000000004. Той самий ефект (детальніше про нього — в розділі про JavaScript/TypeScript) живе і в базі даних, якщо гроші зберігати у FLOAT.
На одній операції похибка мікроскопічна. Але фінансові дані сумуються тисячами рядків, множаться на курси, округлюються до копійок — і похибки накопичуються, аж поки звіт не розійдеться з реальністю на цент, а бухгалтерія не заведе баг. Тому правило категоричне: гроші, ціни, курси — тільки в NUMERIC/DECIMAL. NUMERIC(12, 2) означає «до 12 значущих цифр, з них 2 після коми» — база зберігає й рахує це десятково, без двійкових сюрпризів.
-- amount зберігається й додається точно, копійка в копійку
amount NUMERIC(12, 2) NOT NULL
FLOAT доречний там, де наближеність не шкодить: наукові виміри, координати, відсотки навантаження, дані сенсорів. Ознака «тут потрібен DECIMAL» — коли значення рахують люди й кожна копійка має зійтися.
Текст: VARCHAR, TEXT і кодування
Для рядків є CHAR(n) (фіксована довжина, доповнюється пробілами — майже не використовується), VARCHAR(n) (рядок до n символів) і TEXT (рядок без явного ліміту). Практична різниця між VARCHAR і TEXT залежить від СУБД: у PostgreSQL вони за швидкістю однакові, і VARCHAR(n) — це фактично TEXT з перевіркою довжини; у MySQL відмінності відчутніші (зберігання, поведінка індексів, дефолти). Для QA важливіші два питання: що станеться при перевищенні довжини і чи правильне кодування.
Перевищення довжини поводиться підступно і залежить від конфігурації. У PostgreSQL вставка задовгого рядка у VARCHAR(n) дає помилку. У MySQL без суворого режиму (strict mode) історично рядок міг мовчки обрізатися до n символів — і це вже не помилка, а тихо зіпсовані дані. Сучасні версії за замовчуванням суворі й теж кидають помилку, але поведінка залежить від налаштувань сервера, тож для QA це пряма перевірка: вставити рядок рівно на межі та на символ довше й подивитись, обрізає база чи відхиляє.
Кодування — окрема міна. Стандарт де-факто — UTF-8, який уміє будь-який символ Unicode. Класична пастка живе в MySQL: там кодування utf8 насправді неповне (до 3 байтів на символ) і не вміщає emoji та частину рідкісних символів — для повного Unicode потрібне utf8mb4 (4 байти). Симптом на практиці: користувач вводить 😀 у нікнеймі, а в базі опиняється ???? або взагалі помилка вставки. Це готовий тест-кейс на будь-яке текстове поле: emoji, ієрогліфи, символи з різних алфавітів, комбіновані діакритики.
Дата й час: з таймзоною і без
Тут ховається один із найпідступніших багів «тест падає лише вночі». Основні типи:
DATE— лише дата, без часу.TIME— лише час доби.TIMESTAMP— дата й час разом, і саме тут критична розвилка: з таймзоною чи без.
У PostgreSQL TIMESTAMPTZ (with time zone) зберігає момент часу в UTC: на вході переводить значення із зони сесії в UTC, на виході — назад у зону сесії — тобто фіксує абсолютний момент. А TIMESTAMP (without time zone) зберігає «настінний час» буквально, без жодної конвертації, — і два клієнти в різних зонах прочитають однакове число, хоча мали на увазі різні моменти. Загальна рекомендація: для подій (коли щось сталося) — завжди TIMESTAMPTZ; чистий TIMESTAMP без зони лишають для абстрактних «настінних» значень на кшталт «магазин відчиняється о 09:00».
У MySQL назви збивають з пантелику: там TIMESTAMP зберігається в UTC і конвертується (але з вузьким діапазоном приблизно 1970–2038), а DATETIME зберігає час буквально без зони. Тобто однакове слово TIMESTAMP означає в PostgreSQL і MySQL різні речі — типове діалектне питання.
Практичний висновок для QA: більшість «часових» багів — це або збереження без таймзони, або різні зони на середовищах (CI в UTC, локальна машина в Europe/Kyiv, прод у ще якійсь). Перевіряйте, що подія, створена о 23:30 за Києвом, показується тим самим моментом усім користувачам, а не «завтрашнім числом» комусь за океаном.
BOOLEAN, ENUM, JSONB (оглядово)
- BOOLEAN —
true/false(іNULL, якщо дозволено). У PostgreSQL тип рідний; у MySQLBOOLEAN— це псевдонім дляTINYINT(1), тобто під капотом0/1, і запит може повернути число замістьtrue/false. - ENUM — фіксований набір допустимих рядкових значень, наприклад статус замовлення
('new', 'paid', 'shipped'). Зручно, але з пасткою: додати нове значення — цеALTER, тобто міграція. Альтернатива — окрема таблиця-довідник абоCHECK-обмеження; вибір між ними — тема проєктування схеми. Для QA головне: якщо в статуси додалиrefunded, а типENUMне оновили міграцією — вставка такого статусу впаде, і це очікувано, а не баг. - JSONB (PostgreSQL) — структурований JSON у бінарному вигляді, який можна індексувати й запитувати всередині (у MySQL близький аналог — тип
JSON). Дає гнучкість схеми, але саме гнучкість — це клас багів: усерединуJSONBможна покласти будь-яку структуру, і база не перевіряє її форму так, як перевіряє звичайні стовпці. Зникле поле чи змінений тип усередині JSON база пропустить мовчки. Про схемну гнучкість як джерело багів детальніше — у главі про NoSQL.
Обмеження цілісності: правила, які база стереже сама
Обмеження (constraints) — це правила, вбудовані в таблицю, які СУБД не дасть порушити жодному запиту, хоч би що робив застосунок. Це остання лінія оборони даних, і для тестувальника — готовий список вимог, які хтось уже сформулював у схемі.
NOT NULL— стовпець не може бути порожнім (про те, чомуNULL— це «невідомо», а не «нуль» чи «порожній рядок», — окрема глава про пастки NULL).UNIQUE— значення не повторюються. Пастка: за стандартом SQLNULLвважається «невідомим», томуUNIQUE-стовпець зазвичай дозволяє кількаNULL— вони не рахуються дублікатами. ТобтоUNIQUEнаemailне завадить трьом рядкам матиemail = NULL.CHECK— булева умова, яку кожен рядок мусить задовольняти:CHECK (amount >= 0),CHECK (age BETWEEN 0 AND 150),CHECK (status IN ('new','paid','shipped')).DEFAULT— значення, яке база підставляє, коли стовпець не передали (DEFAULT 'USD',DEFAULT now()). Формально це не обмеження, а правило заповнення, але живе воно поруч.FOREIGN KEY(зовнішній ключ) — посилальна цілісність: значення мусить існувати в іншій таблиці. Не даватиме створити замовлення на неіснуючого користувача й (залежно від правилON DELETE) керує каскадами. Механіку ключів розбирає глава про реляційну модель, каскади при видаленні — глава про зміну даних.
Окрема примітка про PRIMARY KEY: це не самостійне обмеження, а комбінація NOT NULL + UNIQUE з деякими додатковими гарантіями. Тому первинний ключ ніколи не буває NULL і завжди унікальний.
Що з цього тестує QA
Головна ідея: обмеження в базі — це теж вимоги, і їх теж треба перевіряти. Причому з двох боків. Розглядайте цілісність як кілька шарів захисту, де база — останній рубіж:
Що з цього народжує реальні перевірки:
- Обмеження справді працює. Спробувати вставити
NULLуNOT NULL, дублікат уUNIQUE, від'ємну суму протиCHECK, посилання на неіснуючий запис протиFK. Якщо база пропустила те, що не мала, — баг цілісності, і дані вже брудні. - Застосунок реагує на порушення чемно. Коли обмеження спрацьовує, користувач має отримати зрозумілу помилку (наприклад,
400/409через API), а не500з голим текстом SQL-помилки й не «білий екран». Розбіжність «база каже ні, а застосунок падає замість акуратної відповіді» — класична знахідка. - Межі типів. Максимальна довжина рядка (обрізає чи відхиляє), точність грошей (копійки сходяться після множень і сум), Unicode й emoji в текстових полях, таймзони між середовищами.
- Шари не суперечать одне одному. UI дозволяє довший рядок, ніж уміщає
VARCHAR; API приймає статус, якого немає вENUM; дефолт у базі не збігається з тим, що показує форма. Це найсмачніші баги — вони на стику.
Перевіряти це найзручніше на рівні API, де видно код відповіді. Мінімальний приклад на Playwright — переконатися, що дублікат email дає коректну помилку клієнта, а не 500:
test('дублікат email → 409, а не 500', async ({ request }) => {
const email = `qa.${Date.now()}@example.com`;
await request.post('/api/users', { data: { email, name: 'First' } });
const dup = await request.post('/api/users', { data: { email, name: 'Second' } });
expect(dup.status()).toBe(409); // унікальне обмеження відпрацювало як бізнес-правило
});
Коли ж перевірку доводиться робити напряму в базі (порахувати «сиріт», звірити типи після міграції) — діє read-only дисципліна з першої глави, а сам пошук порушень цілісності розгортає глава про тестування БД і міграції.
Типові помилки
- Виглядає як «гроші ж число, збережу у FLOAT», а насправді двійкова похибка, що накопичується:
0.1 + 0.2не дорівнює0.3, і звіт розходиться на копійку. Гроші — тількиNUMERIC/DECIMAL. - Виглядає як «поле текстове, emoji впишеться», а насправді в MySQL з кодуванням
utf8(неutf8mb4) emoji перетворюється на????або валить вставку. - Виглядає як «час зберігається, все гаразд», а насправді
TIMESTAMPбез таймзони — і подія «стрибає» на кілька годин між CI, локаллю і продом. Для моментів часу потрібен варіант із таймзоною. - Виглядає як «UNIQUE на email не дасть дублів», а насправді кілька рядків з
email = NULLцілком уживаються:NULLне дорівнюєNULL, тож дляUNIQUEвони не дублікати. - Виглядає як «додам NOT NULL стовпець міграцією — справа однієї команди», а насправді в PostgreSQL
ADD COLUMN ... NOT NULLбезDEFAULTна непорожній таблиці впаде; потрібні кроки expand-contract. - Виглядає як «база відхилила погані дані — тест пройдено», а насправді застосунок віддав
500замість400/409: обмеження спрацювало, але помилку не обробили — це баг застосунку, а не успіх.
Підсумок
- SQL ділиться на DDL (структура), DML (дані), DCL (права), TCL (транзакції); DDL у PostgreSQL транзакційний, у MySQL — з неявним commit.
- Гроші, ціни й курси — тільки в точних типах
NUMERIC/DECIMAL;FLOAT/DOUBLEнаближені й накопичують похибку. - Тип і кодування — це вимоги: межі довжини
VARCHAR,utf8mb4для повного Unicode, таймзона вTIMESTAMPдля абсолютних моментів. - Обмеження (
NOT NULL,UNIQUE,CHECK,DEFAULT,FK) — остання лінія оборони даних;UNIQUEпри цьому зазвичай пропускає кількаNULL. - QA перевіряє і те, що обмеження справді відхиляє погані дані, і те, що застосунок реагує на це коректною помилкою, а не
500.
Що питають на співбесіді
- «На які категорії поділяються команди SQL?» — розминка: DDL/DML/DCL/TCL з парою прикладів кожної. Бонус — згадати, що DDL по-різному поводиться з транзакціями в PostgreSQL і MySQL.
- «Чому не можна зберігати гроші у float? Що використати?» — питання-маркер. Слабко: «float неточний». Сильно: двійкове представлення не вміщає десяткові дроби, похибка накопичується на сумах і множеннях, тому
DECIMAL/NUMERICз фіксованою кількістю знаків після коми. - «Різниця між VARCHAR і TEXT / CHAR?» — очікують межу довжини, залежність від СУБД і, головне, поведінку при перевищенні (обрізає чи відхиляє) плюс тему кодування й
utf8mb4. - «TIMESTAMP with time zone проти without — у чому різниця?» — дивляться, чи розумієте, що один фіксує абсолютний момент (UTC), а інший — «настінний час» без конвертації, і чому це джерело багів між середовищами.
- «Які знаєте обмеження цілісності?» — перелік
NOT NULL/UNIQUE/CHECK/DEFAULT/FOREIGN KEY/PRIMARY KEY; сильний кандидат додасть пасткуUNIQUEзNULLі щоPRIMARY KEY— цеNOT NULL+UNIQUE. - «Обмеження — це відповідальність бекенду. Що тут тестувати QA?» — перевірка на зрілість: очікують шари захисту (UI/API/БД), сценарії порушення кожного обмеження й вимогу, щоб застосунок віддавав чемну помилку замість
500.
Джерела
- PostgreSQL: Data Types — числові типи,
numericпротиfloat,timestampз таймзоною і без,JSONB. - PostgreSQL: Constraints —
NOT NULL,UNIQUE(і поведінка зNULL),CHECK, зовнішні ключі. - PostgreSQL: CREATE TABLE — синтаксис визначення стовпців і обмежень.
- MySQL: The CHAR and VARCHAR Types та Unicode Support — довжина, суворий режим,
utf8mb4.
На які категорії поділяються команди SQL?
Класично SQL ділять на чотири підмови за тим, чим команда керує. DDL (Data Definition Language) визначає структуру — CREATE, ALTER, DROP, TRUNCATE. DML (Data Manipulation Language) працює з даними — INSERT, UPDATE, DELETE і SELECT (частина авторів виносить читання в окрему DQL). DCL (Data Control Language) керує правами — GRANT і REVOKE, саме за ним стоїть read-only доступ QA на прод. TCL (Transaction Control Language) керує транзакціями — COMMIT, ROLLBACK, SAVEPOINT. Поділ не академічний: групи по-різному поводяться щодо транзакцій і прав доступу. На співбесіді це розминка, тож відповідь має звучати без пауз.
Чим відрізняється поведінка DDL у транзакціях між PostgreSQL і MySQL?
У PostgreSQL DDL живе всередині транзакції: CREATE TABLE чи ALTER, виконані після BEGIN, зникнуть разом із ROLLBACK, ніби їх не було. У MySQL натомість кожна DDL-команда виконує неявний commit (implicit commit): відкрита транзакція фіксується в момент ALTER TABLE, і повернути її вже неможливо. Практичний наслідок стосується міграцій і клінапу в тестах: у MySQL не можна розраховувати, що обгортка BEGIN ... ROLLBACK прибере структурні зміни. Це бонусна деталь до питання про категорії SQL, яка одразу показує, що кандидат працював із різними СУБД.
Що роблять CREATE, ALTER і DROP і яка з них найнебезпечніша на живій базі?
Це трійця DDL для життєвого циклу таблиці. CREATE TABLE створює таблицю з нуля — одним виразом описує стовпці, їхні типи й обмеження. ALTER TABLE змінює вже наявну таблицю — додати чи прибрати стовпець, змінити тип, навісити або зняти обмеження. DROP TABLE знищує таблицю разом зі структурою, індексами й обмеженнями, незворотно. Найнебезпечніша на живій базі — саме ALTER: вона зачіпає таблицю з реальними даними, і QA стикається з нею через міграції. Руками тестувальник DROP не запускає — ця команда належить міграціям і адмініструванню.
Чому не можна зберігати гроші у FLOAT і що використати замість нього?
Це питання-маркер: слабка відповідь — «float неточний», сильна пояснює механізм. FLOAT і DOUBLE зберігають число у двійковій формі з плаваючою комою (IEEE 754), а багато звичних десяткових дробів у двійковій системі не мають скінченного запису — так само як 1/3 у десятковій. Класична ілюстрація: 0.1 + 0.2 дає не 0.3, а 0.30000000000000004. На одній операції похибка мікроскопічна, але фінансові операції складаються тисячами, перемножуються на курси й округлюються — накопичена похибка зрештою дає звіт, який не б'ється з реальністю. Тому гроші, ціни й курси тримають тільки в точних типах NUMERIC/DECIMAL, які зберігають і рахують значення десятково.
Що означає запис NUMERIC(12, 2)?
NUMERIC(12, 2) (синонім DECIMAL) задає точне десяткове число: до 12 значущих цифр усього, з них 2 після коми. Перше число називають точністю (precision), друге — масштабом (scale). На практиці це означає до 10 цифр до коми й 2 після — тобто суми приблизно до 10 мільярдів з копійками. База зберігає й рахує таке число десятково, без двійкових сюрпризів, тому воно ідеальне для грошей. Якщо спробувати вставити значення, що не влазить у задану точність, база відхилить його або округлить згідно з правилами масштабу — це теж перевірка для QA.
Коли FLOAT доречний, а коли ні?
FLOAT/DOUBLE доречні там, де наближеність не шкодить: наукові виміри, географічні координати, відсотки навантаження, дані сенсорів — значення, які й так є приблизними за природою. Не доречні вони скрізь, де числа рахують люди й кожна копійка має зійтися: гроші, ціни, курси, бухгалтерія. Проста ознака «тут потрібен DECIMAL» — коли результат порівнюють на точну рівність або підбивають підсумки, які мусять збігтися до останнього знака. Тобто вибір типу — це вже вимога до точності, а не технічна дрібниця.
У чому різниця між VARCHAR, TEXT і CHAR?
CHAR(n) — рядок фіксованої довжини, доповнюється пробілами до n; майже не використовується. VARCHAR(n) — рядок змінної довжини до n символів. TEXT — рядок без явного ліміту. Практична різниця між VARCHAR і TEXT залежить від СУБД: у PostgreSQL різниці у швидкості немає, а VARCHAR(n) поводиться як TEXT, до якого причепили перевірку довжини; у MySQL типи відрізняються помітніше — способом зберігання, роботою індексів, дефолтами. Для QA головне не академічна різниця, а два питання: що станеться при перевищенні довжини й чи правильне кодування стовпця.
Що станеться при вставці рядка, довшого за VARCHAR(n)?
Поведінка підступна й залежить від СУБД та конфігурації. У PostgreSQL вставка задовгого рядка у VARCHAR(n) дає помилку. MySQL у нестрогому режимі (без strict mode) зі старих часів обрізав рядок до n символів без жодної помилки — дані псувалися тихо. У сучасних версіях strict mode увімкнений за замовчуванням, тож задовгий рядок теж відхиляється, але фінальне слово за конфігурацією сервера. Для QA звідси готовий парний тест: значення точно в ліміт і на один символ більше — і дивимось, відмова це чи мовчазне різання. Тихе обрізання — гірший сценарій, бо дані псуються без жодного сигналу.
Чому в MySQL кодування utf8 може зіпсувати emoji і що з цим робити?
Пастка в тому, що кодування з назвою utf8 у MySQL — не справжній повний UTF-8: воно вміщає максимум 3 байти на символ, тож emoji й частина рідкісних символів у нього не влазять. Повний Unicode дає лише utf8mb4 (4 байти на символ). На практиці це виглядає так: у нікнейм вводять 😀, а після збереження читають ???? — або вставка взагалі завершується помилкою. Стандарт де-факто — саме UTF-8, який уміє будь-який символ Unicode, тому правильна конфігурація стовпця — utf8mb4. Для тестувальника це готовий тест-кейс на будь-яке текстове поле: emoji, ієрогліфи, символи з різних алфавітів, комбіновані діакритики.
Яка різниця між TIMESTAMP with time zone і without time zone?
Це один із найпідступніших багів «тест падає лише вночі». У PostgreSQL TIMESTAMPTZ (with time zone) фіксує абсолютний момент часу: на вході переводить значення із зони сесії в UTC, на виході — назад у зону сесії. А TIMESTAMP (without time zone) нічого не конвертує — тримає «настінний час» так, як записали, тож клієнти з різних зон бачать однакові цифри, які насправді означають різні моменти. Правило вибору: усе, що фіксує момент події (коли щось сталося), зберігаємо з таймзоною; без таймзони лишаємо хіба абстрактний «настінний» час на кшталт розкладу «відчиняємось о 09:00». Більшість «часових» багів — це або збереження без таймзони, або різні зони на середовищах.
Чому TIMESTAMP означає різні речі в PostgreSQL і MySQL?
Назви збивають з пантелику. У PostgreSQL TIMESTAMP без уточнення — це час без таймзони («настінний»), а абсолютний момент дає окремий TIMESTAMPTZ. У MySQL навпаки: тип TIMESTAMP зберігається в UTC і конвертується під зону сесії (але з вузьким діапазоном приблизно 1970–2038), а буквальний час без зони дає DATETIME. Тобто однакове слово TIMESTAMP в цих СУБД означає протилежні речі. Це типове діалектне питання, і плутанина тут — джерело багів при міграції коду чи схеми між СУБД, а також при читанні документації не тієї бази.
Які знаєте обмеження цілісності?
Обмеження (constraints) — правила, вшиті в саму таблицю: їх не обійде жоден запит, хоч би що робив застосунок. NOT NULL вимагає значення в кожному рядку. UNIQUE забороняє повтори. CHECK навішує на рядок довільну булеву умову — скажімо, CHECK (amount >= 0). DEFAULT підставляє значення, коли його не передали (строго кажучи, це правило заповнення, а не обмеження). FOREIGN KEY тримає посилальну цілісність: значення мусить існувати в сусідній таблиці. Сильний кандидат додасть, що PRIMARY KEY — це не окреме обмеження, а комбінація NOT NULL + UNIQUE, і згадає пастку UNIQUE з NULL. Для тестувальника цей перелік — готовий список вимог, які хтось уже сформулював у схемі.
Чому UNIQUE-обмеження на email може не завадити кільком порожнім значенням?
Це улюблена пастка на співбесіді. За стандартом SQL NULL означає «невідомо», а не конкретне значення, тому два NULL не вважаються рівними одне одному. Наслідок: UNIQUE-стовпець зазвичай дозволяє скільки завгодно рядків із NULL — вони не рахуються дублікатами. На практиці UNIQUE на email спокійно пропустить хоч три, хоч тридцять рядків із порожнім email. Якщо за вимогою email мусить бути в кожного, самого UNIQUE замало — потрібне ще NOT NULL. Для QA це прямий негативний тест: перевірити, чи справді система забороняє порожній email там, де він обов'язковий, а не лише дублікати заповнених.
Чому PRIMARY KEY ніколи не буває NULL і завжди унікальний?
Тому що PRIMARY KEY — не самостійне обмеження, а комбінація NOT NULL + UNIQUE з деякими додатковими гарантіями (таблиця може мати лише один первинний ключ, і на нього зазвичай спираються зовнішні ключі). Складова NOT NULL не дає ключу бути порожнім, а UNIQUE — повторюватися. Тому первинний ключ однозначно ідентифікує рядок: за його значенням завжди можна знайти рівно один запис або жодного. Це відрізняє його від звичайного UNIQUE-стовпця, який, як щойно зазначено, кілька NULL пропускає.
Що робить FOREIGN KEY і від чого він захищає?
Зовнішній ключ (foreign key) забезпечує посилальну цілісність (referential integrity): значення в стовпці мусить існувати в іншій таблиці. Наприклад, user_id у таблиці замовлень із REFERENCES users(id) не дасть створити замовлення на неіснуючого користувача — база відхилить вставку. Крім того, залежно від правил ON DELETE, зовнішній ключ керує каскадами: що робити з замовленнями, коли видаляють користувача (заборонити, обнулити чи видалити разом). Для QA це і захист від «сиріт» (рядків, що посилаються в нікуди), і джерело перевірок: спробувати створити запис із посиланням на неіснуючий ключ і переконатися, що база відмовляє.
Чому додавання NOT NULL стовпця міграцією може впасти і як це роблять правильно?
Класична пастка міграцій: додати стовпець одразу NOT NULL без DEFAULT до таблиці, у якій уже є рядки. Наявним рядкам нізвідки взяти значення для нового обов'язкового стовпця, тож у PostgreSQL така команда одразу падає (а MySQL без strict mode може мовчки заповнити рядки неявним дефолтом — що по-своєму гірше). Правильно це роблять у два-три кроки: додати стовпець nullable, заповнити його значеннями для наявних рядків, а вже потім навісити NOT NULL. Це частина патерну expand-contract, коли схему розширюють сумісно, мігрують дані й лише потім прибирають старе. Для QA це означає, що міграцію треба перевіряти не на порожній таблиці, а на реалістичному наборі даних, інакше падіння спливе вже на проді.
Обмеження — це відповідальність бекенду. Що тут узагалі тестувати QA?
Це перевірка на зрілість. Ключова ідея: обмеження в базі — це теж вимоги, і перевіряти їх треба з двох боків. Перший бік — що обмеження справді працює: спроба вставити NULL у NOT NULL, дублікат у UNIQUE, від'ємну суму проти CHECK, посилання на неіснуючий запис проти FK має бути відхилена; якщо база пропустила те, що не мала, — дані вже брудні. Другий бік — що застосунок реагує на порушення чемно: коли обмеження спрацьовує, користувачу має прилетіти осмислена відповідь — 400 чи 409 на рівні API, — а не 500 із сирим текстом SQL-помилки чи білий екран. Сильна відповідь згадає й шари захисту (UI → API → БД), і баги на стику шарів: UI дозволяє довший рядок, ніж уміщає VARCHAR, API приймає статус, якого немає в ENUM.
Тест шле дані, що порушують обмеження, а сервер відповідає 500. Це очікувано?
Ні — це щонайменше «півбага» бекенду. Обмеження справді спрацювало й відхилило погані дані, це добре. Але добре спроєктований застосунок мусить перехопити помилку бази й повернути свідому помилку клієнта — 400 чи 409 (наприклад, 409 Conflict на дублікат унікального значення), а не падати з необробленим винятком і віддавати 500 з голим текстом SQL-помилки. 500 тут означає, що якийсь шар коду не обробив передбачувану ситуацію. Фіксувати варто обидві складові: тригер прийшов від даних, але реакція сервера — окремий дефект. Саме тому негативні тести на порушення обмежень цінні: вони перевіряють не лише що база відмовляє, а й що застосунок робить це контрольовано.
Що варто знати про ENUM і JSONB з погляду ризиків для даних?
ENUM — фіксований набір допустимих рядкових значень, наприклад статус замовлення ('new', 'paid', 'shipped'). Пастка: додати нове значення — це ALTER, тобто окрема міграція; якщо в статуси додали refunded, а тип не оновили, вставка такого статусу впаде — і це очікувано, а не баг. JSONB (PostgreSQL; у MySQL близький JSON) зберігає структурований JSON, який можна індексувати й запитувати всередині, але саме його гнучкість — це клас багів: усередину можна покласти будь-яку структуру, і база не перевіряє її форму так, як перевіряє звичайні стовпці. Зникле поле чи змінений тип усередині JSON база пропустить мовчки, тож валідація вмісту лягає на застосунок і на тести.
Три кейси, у яких вибір типу й обмеження вирішує, зелений тест чи брудні дані: рев'ю CREATE TABLE з таблицею рішень «який тип обрати й чому», API-тести на межі типів (довжина, emoji, точність грошей) і негативні тести на самі обмеження з перевіркою, що застосунок відповідає чемно, а не 500.
Кейс 1. Рев'ю CREATE TABLE: де тут майбутні баги
Прийшла міграція з новою таблицею платежів. Її варто читати як список вимог — кожен стовпець це рішення, яке або захистить дані, або породить дефект.
CREATE TABLE payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
amount FLOAT NOT NULL, -- підозра №1
currency VARCHAR(10), -- підозра №2
note VARCHAR(50), -- підозра №3
status VARCHAR(20) NOT NULL DEFAULT 'new',
paid_at TIMESTAMP -- підозра №4
);
Таблиця рішень — що не так і чим це загрожує:
| Стовпець | Проблема | Наслідок для даних | Як має бути |
|---|---|---|---|
amount | гроші у FLOAT | двійкова похибка накопичується на сумах, звіт розходиться на копійку | NUMERIC(12, 2) NOT NULL CHECK (amount >= 0) |
currency | без NOT NULL, без DEFAULT, задовгий | валюта може бути порожня; VARCHAR(10) дозволяє сміття замість коду | VARCHAR(3) NOT NULL DEFAULT 'USD' (+ за потреби CHECK) |
note | VARCHAR(50) | текст користувача обріжеться або відхилиться на межі | зважено підняти ліміт або взяти TEXT |
status | немає CHECK | у status влізе будь-який рядок, а не лише дозволені | CHECK (status IN ('new','paid','refunded')) |
paid_at | TIMESTAMP без зони | момент оплати «стрибне» між CI, локаллю і продом | TIMESTAMPTZ (це подія — коли сталося) |
Що дивитися і чому:
FLOATдля грошей — найдорожча помилка. На одній операції похибка мікроскопічна, але платежі сумуються й множаться на курси; шукати потім розбіжність на цент у звіті — окреме полювання. Правило категоричне: гроші, ціни, курси — тількиNUMERIC/DECIMAL.TIMESTAMPбез таймзони наpaid_at. Момент оплати — це подія, їй потрібен абсолютний час (TIMESTAMPTZ). Інакше «оплачено о 23:30 за Києвом» комусь за океаном покажеться завтрашнім числом.- Стовпець без обмеження — це дозвіл на сміття.
statusбезCHECKчиENUMприйме будь-який рядок;currencyбезNOT NULLпропустить порожнє. Кожне пропущене обмеження — вимога, яку ніхто не стереже. user_idтут якраз зроблено правильно:NOT NULL REFERENCES users(id)не дасть створити платіж на неіснуючого користувача. Це зразок, з яким варто звіряти решту стовпців.
Кейс 2. API-тести на межі типів
Межі типів — це вимоги, які найзручніше перевіряти через API, де видно код відповіді й тіло. Три класичні межі: довжина VARCHAR, Unicode/emoji й точність грошей.
import { test, expect } from '@playwright/test';
test('рядок на межі проходить, задовгий — відхиляється, а не мовчки ріжеться', async ({ request }) => {
const atLimit = 'x'.repeat(50); // рівно VARCHAR(50)
const overLimit = 'x'.repeat(51); // на символ довше
const ok = await request.post('/api/payments', { data: { note: atLimit, amount: '10.00' } });
expect(ok.status()).toBe(201);
const tooLong = await request.post('/api/payments', { data: { note: overLimit, amount: '10.00' } });
// очікуємо свідому помилку валідації, НЕ 201 з тихо обрізаним note
expect(tooLong.status()).toBe(400);
});
test('emoji в текстовому полі зберігається без спотворення', async ({ request }) => {
const nickname = 'qa😀🚀'; // потрібне utf8mb4, якщо база на MySQL
const created = await request.post('/api/users', { data: { name: nickname, email: `u.${Date.now()}@ex.com` } });
expect(created.status()).toBe(201);
const id = (await created.json()).id;
const back = await (await request.get(`/api/users/${id}`)).json();
// класична пастка MySQL utf8: emoji перетворюється на ???? або губиться
expect(back.name).toBe(nickname);
});
Точність грошей перевіряють на операції, де похибка FLOAT виплила б: сума копійок або множення на курс.
test('сума платежів сходиться до копійки', async ({ request }) => {
// 0.1 + 0.2 у FLOAT дало б 0.30000000000000004
await request.post('/api/payments', { data: { amount: '0.10' } });
await request.post('/api/payments', { data: { amount: '0.20' } });
const total = (await (await request.get('/api/payments/total')).json()).total;
expect(total).toBe('0.30'); // точний тип → рівно 0.30, без хвоста
});
Що дивитися і чому:
- Перевищення довжини перевіряють парою «на межі + на символ довше». Головна небезпека не помилка, а тихе обрізання: якщо задовгий
noteповертає201, а в базі лежить урізаний рядок — дані зіпсовано без сигналу. Асерт має ловити саме відмову, а не мовчазний успіх. - Emoji — дешевий детектор кодування. Один символ
😀через повний цикл (записали → прочитали) показує, чи стоїтьutf8mb4. Розширте до ієрогліфів і комбінованих діакритиків для важливих полів. - Гроші звіряйте як рядок, а не як число з плаваючою комою. Порівняння
0.30рядком не дає похибці IEEE 754 просочитися в сам тест; якщо API віддає гроші числом типуnumber, це вже привід до розмови про контракт.
Кейс 3. Обмеження як вимоги: порушити й перевірити реакцію
Кожне обмеження в схемі — готовий негативний тест. Перевіряємо і що база відхиляє погані дані, і що застосунок віддає чемну помилку, а не 500 з голим SQL.
import { test, expect } from '@playwright/test';
test('дублікат email → 409, а не 500', async ({ request }) => {
const email = `qa.${Date.now()}@example.com`;
await request.post('/api/users', { data: { email, name: 'First' } });
const dup = await request.post('/api/users', { data: { email, name: 'Second' } });
expect(dup.status()).toBe(409); // UNIQUE спрацював як бізнес-правило, оброблено чемно
});
test('порушення CHECK (від\'ємна сума) → 400, не 500', async ({ request }) => {
const res = await request.post('/api/payments', { data: { amount: '-5.00' } });
expect(res.status()).toBe(400); // CHECK (amount >= 0) відхилив, застосунок пояснив чому
});
test('FK: платіж на неіснуючого користувача відхиляється', async ({ request }) => {
const res = await request.post('/api/payments', { data: { user_id: 999999999, amount: '10.00' } });
// зовнішній ключ не дасть «сироту»; очікуємо 400/409, а не тихе створення й не 500
expect([400, 409]).toContain(res.status());
});
Що дивитися і чому:
- Дублікат
UNIQUE— це409 Conflict, а не500. Обмеження мусить спрацювати, але помилку бази застосунок зобов'язаний перехопити й перекласти на зрозумілий код клієнта.500з текстом SQL-помилки — окремий дефект бекенду, який фіксують поряд із самим фактом порушення. CHECKперевіряють саме забороненим значенням. Від'ємна сума протиCHECK (amount >= 0)має дати400; якщо база пропустила її — це баг цілісності, і дані вже брудні.FKловить «сиріт» на вході. Спроба послатися на неіснуючийuser_idмає бути відхилена; тихе створення означало б, що зовнішнього ключа насправді немає або він вимкнений.- Пастка
UNIQUEзNULL. Якщо email обов'язковий, окремо перевірте, що система забороняє порожній email: саме лишеUNIQUEкількаNULLпропустить, боNULLне дорівнюєNULL. Обов'язковість гарантуєNOT NULL, а не унікальність.
Підмови SQL і DDL
- Можу перелічити чотири категорії SQL: DDL (структура), DML (дані), DCL (права), TCL (транзакції) — з парою прикладів кожної.
- Знаю, що DDL у PostgreSQL транзакційний (можна відкотити в
BEGIN ... ROLLBACK), а в MySQL кожна DDL-команда робить неявний commit. - Розумію призначення
CREATE/ALTER/DROPі щоALTER— найнебезпечніша операція на живій базі, бо зачіпає реальні дані.
Типи даних: числа
- Можу пояснити різницю точних (
NUMERIC/DECIMAL,INTEGER) і наближених (REAL/FLOAT/DOUBLE) типів: десяткове зберігання проти двійкового IEEE 754. - Розумію, чому гроші не тримають у
FLOAT: двійкова похибка (0.1 + 0.2не дорівнює0.3) накопичується на сумах і множеннях. - Знаю, що гроші, ціни й курси — тільки в
NUMERIC/DECIMAL, і що означаєNUMERIC(12, 2)(точність і масштаб).
Типи даних: текст і кодування
- Знаю різницю
CHAR/VARCHAR(n)/TEXTі що вона залежить від СУБД (у PostgreSQLVARCHAR(n)≈TEXTз перевіркою довжини). - Можу пояснити поведінку при перевищенні довжини: PostgreSQL відхиляє, MySQL без strict mode історично міг мовчки обрізати.
- Розумію пастку MySQL:
utf8неповне (3 байти) і не вміщає emoji; для повного Unicode потрібнеutf8mb4.
Типи даних: дата й час
- Розумію різницю
TIMESTAMPwith time zone (абсолютний момент у UTC) і without time zone («настінний час» без конвертації). - Знаю правило: для подій (коли щось сталося) — тип із таймзоною; чистий
TIMESTAMP— для абстрактних «настінних» значень. - Пам'ятаю, що
TIMESTAMPу PostgreSQL і MySQL означає різні речі (у MySQLTIMESTAMP— UTC із діапазоном ~1970–2038, аDATETIME— без зони).
Обмеження цілісності
- Можу перелічити
NOT NULL,UNIQUE,CHECK,DEFAULT,FOREIGN KEYі пояснити призначення кожного. - Знаю пастку
UNIQUEзNULL: кілька рядків ізNULLне вважаються дублікатами, тожUNIQUEна email не гарантує обов'язковості. - Розумію, що
PRIMARY KEY— цеNOT NULL+UNIQUE, тому первинний ключ ніколи неNULLі завжди унікальний. - Можу пояснити посилальну цілісність через
FOREIGN KEY: значення мусить існувати в іншій таблиці,ON DELETEкерує каскадами. - Знаю, чому
ADD COLUMN ... NOT NULLбезDEFAULTна непорожній таблиці в PostgreSQL впаде, і що це роблять кроками (expand-contract).
Що тестує QA
- Розумію, що обмеження в базі — це вимоги, і перевіряю їх з двох боків: що база відхиляє погані дані і що застосунок реагує чемною помилкою (
400/409, а не500з голим SQL). - Знаю шари захисту UI → API → БД і полюю на баги на стику (UI дозволяє довше за
VARCHAR, API приймає статус позаENUM). - Перевіряю межі типів: довжина рядка (обрізає чи відхиляє), точність грошей після множень і сум, Unicode/emoji, таймзони між середовищами.
До якої категорії SQL належать CREATE, ALTER і DROP?
Питання
Чотири підмови SQL — які й за що відповідають?