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

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

    DDL, типи даних і обмеження цілісності

    Зміст

    Досі ми працювали з даними всередині вже готових таблиць: читали і змінювали рядки. Ця глава — про рівень нижче: як таблиця влаштована. Який тип у кожного стовпця, які правила база не дасть порушити навіть найкривішому запиту застосунку. Для QA це не адміністративна деталь, а джерело цілого класу багів: гроші, що не сходяться на копійку, emoji, які перетворюються на ?, час події, що «стрибає» на три години між середовищами, і 500-та помилка там, де мала бути 400.

    Це глава-поглиблення: при першому проході її можна пропустити — жоден базовий SELECT-JOIN-GROUP BY на неї не спирається. Але вона канонічна для теми типів і обмежень: коли інші глави згадують NOT NULL, зовнішній ключ чи CHECK, вони посилаються сюди. Читати варто перед співбесідою рівня middle і щоразу, коли баг пахне «дані в базі не такі, як очікує застосунок».

    Чотири підмови SQL: DDL, DML, DCL, TCL

    SQL зазвичай ділять на чотири групи команд — не заради академічності, а тому що вони поводяться по-різному щодо транзакцій і прав доступу. На співбесіді питання «на які категорії поділяється SQL» — розминкове, але провалити його прикро.

    SQL

    DDL — структура
    CREATE, ALTER, DROP, TRUNCATE

    DML — дані
    SELECT, INSERT, UPDATE, DELETE

    DCL — права
    GRANT, REVOKE

    TCL — транзакції
    BEGIN, COMMIT, ROLLBACK, SAVEPOINT

    SQL

    DDL — структура
    CREATE, ALTER, DROP, TRUNCATE

    DML — дані
    SELECT, INSERT, UPDATE, DELETE

    DCL — права
    GRANT, REVOKE

    TCL — транзакції
    BEGIN, COMMIT, ROLLBACK, SAVEPOINT

    • 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 (оглядово)

    • BOOLEANtrue/falseNULL, якщо дозволено). У PostgreSQL тип рідний; у MySQL BOOLEAN — це псевдонім для 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 — значення не повторюються. Пастка: за стандартом SQL NULL вважається «невідомим», тому 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

    Головна ідея: обмеження в базі — це теж вимоги, і їх теж треба перевіряти. Причому з двох боків. Розглядайте цілісність як кілька шарів захисту, де база — останній рубіж:

    порушення

    UI-валідація
    (зручність)

    Валідація в API
    (бізнес-логіка)

    Обмеження в БД
    (остання лінія оборони)

    Помилка обмеження

    порушення

    UI-валідація
    (зручність)

    Валідація в API
    (бізнес-логіка)

    Обмеження в БД
    (остання лінія оборони)

    Помилка обмеження

    Що з цього народжує реальні перевірки:

    • Обмеження справді працює. Спробувати вставити 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.

    Джерела