Реляційна модель: таблиці, ключі, зв'язки
Зміст
Реляційна модель — це спосіб, у який майже всі бізнес-бази даних (PostgreSQL, MySQL, SQL Server, Oracle) організують дані: не одна суцільна купа, а окремі таблиці, поєднані ключами (keys). Для тестувальника це фундамент. Коли ти перевіряєш, що замовлення справді збереглося за потрібним користувачем, що видалення акаунта не лишило «висячих» рядків, що email не можна зареєструвати двічі — ти працюєш саме з реляційною моделлю, навіть якщо не пишеш жодного SELECT (див. навіщо QA взагалі БД).
На співбесіді це один із гарантованих блоків для manual і automation QA. «Чим PRIMARY KEY відрізняється від UNIQUE?», «як реалізувати зв'язок many-to-many?», «що станеться, якщо видалити рядок, на який посилаються інші?» — питають рівно тому, що не розуміючи ключів, неможливо ні прочитати схему застосунку, ні відрізнити реальний баг цілісності даних від штатної поведінки БД. Розберемося з механізмом, а не із зубрінням.
Таблиця, рядок, стовпець, схема
Базова одиниця реляційної БД — таблиця (table). Це двовимірна структура: стовпці задають, які дані ми зберігаємо, рядки — конкретні записи.
- Стовпець (column, поле) — це атрибут із фіксованим типом даних:
idтипуinteger,emailтипуvarchar,created_atтипуtimestamp. Тип — це контракт: у стовпецьintegerне покласти рядок, і це БД перевіряє сама. - Рядок (row, запис) — один екземпляр сутності: один користувач, одне замовлення. Усі рядки таблиці мають однаковий набір стовпців.
Ключова властивість реляційної моделі: порядок рядків не гарантований. Таблиця — це множина записів, а не список. Тому запит без ORDER BY може повертати рядки в різному порядку на різних прогонах — типове джерело флакі-перевірок, коли автотест очікує «перший рядок» без явного сортування.
Слово схема (schema) має два значення, і їх плутають:
- Схема даних — опис структури: які є таблиці, стовпці, типи, ключі й обмеження. Саме її змінюють міграції.
- Схема як простір імен (namespace) — у PostgreSQL це логічна група об'єктів усередині бази (наприклад,
public.users). На trainee-рівні достатньо першого значення; друге просто май на увазі, щоб не сплутати.
Первинний ключ: природний проти сурогатного
Первинний ключ (primary key, PK) — це стовпець (або кілька), що однозначно ідентифікує кожен рядок таблиці. Він має три властивості, що й роблять його ідентифікатором:
- унікальний — двох рядків з однаковим PK бути не може;
- не порожній (
NOT NULL) — «невідомий» ідентифікатор нічого не ідентифікує; - один на таблицю — головний спосіб адресувати запис.
Далі — головна розвилка проєктування. Ключ буває природний або сурогатний.
Природний ключ (natural key) — значення, що вже існує в предметній області й саме собою унікальне: email користувача, ІПН, ISBN книги, код валюти USD. Спокуса очевидна: не треба вигадувати зайвий стовпець.
Сурогатний ключ (surrogate key) — штучний ідентифікатор без бізнес-сенсу, який генерує БД: автоінкрементне число (SERIAL/IDENTITY у PostgreSQL, AUTO_INCREMENT у MySQL) або UUID.
Чому на практиці майже завжди беруть сурогатний — і це те, що варто вміти пояснити:
- Бізнес-значення змінюються. Людина змінює прізвище, компанія — назву, користувач — email. Якщо на цьому полі тримається ідентичність рядка й на нього посилаються інші таблиці, зміна перетворюється на біль.
- Природна унікальність часто ілюзорна. «Ім'я + прізвище» не унікальні, номер телефону переходить до іншої людини.
- Природний ключ буває складеним і громіздким, а на нього ще й посилаються — тягати пару-трійку стовпців усюди незручно.
Звідси практичний висновок для QA: сурогатний id гарантує лише технічну унікальність рядка, але не бізнес-унікальність. Щоб email не зареєстрували двічі, потрібне окреме обмеження UNIQUE на цьому стовпці — про це нижче.
Ключ може бути й складеним (composite) — з кількох стовпців разом. Класичний приклад — проміжна таблиця зв'язку M:N, де ключем є пара (order_id, product_id).
Зовнішній ключ і посилальна цілісність
Таблиці стають реляційними, коли починають посилатися одна на одну. Механізм посилання — зовнішній ключ (foreign key, FK): стовпець, значення якого мусить збігатися з первинним ключем іншої таблиці (у стандарті SQL — з PK або з унікальним стовпцем).
Приклад: таблиця orders має стовпець user_id, що є FK на users.id. Це буквально означає «це замовлення належить он тому користувачеві».
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Найважливіша річ, яку дає FK, — посилальна цілісність (referential integrity): гарантія, що посилання завжди веде на реальний рядок. БД сама, без жодного коду застосунку, не дасть:
- вставити замовлення з
user_id, якого немає вusers(немає на кого послатися); - видалити користувача, поки на нього посилаються замовлення, — якщо не задано, що робити з «дітьми».
Ось цей другий пункт — постійне джерело питань «це баг чи ні». Поведінку при видаленні/оновленні батьківського рядка задають діями ON DELETE / ON UPDATE:
RESTRICT/NO ACTION(типове) — заборонити операцію, поки є посилання. Спроба видалити користувача з замовленнями завершиться помилкою на кшталтviolates foreign key constraint.CASCADE— видалити (чи оновити) разом із батьком усі дочірні рядки. Видалив користувача — його замовлення зникли автоматично.SET NULL— обнулити FK у дітей (замовлення лишиться, але «без власника»). Потребує, щоб стовпець допускавNULL.
Зверни увагу: FK, який допускає NULL, означає опційний зв'язок. orders.manager_id з NULL — це замовлення, якому ще не призначили менеджера; це нормальний стан, а не «зіпсовані дані».
Для тестувальника FK — це і страховка, і об'єкт перевірки. Порушення цілісності (замовлення посилається на неіснуючого користувача, «сирітські» рядки після кривої міграції) — класичний клас багів на рівні даних, який не завжди видно в UI (див. тестування цілісності даних і міграцій). А самі FK — це те, за чим потім «зшивають» таблиці у JOIN.
PRIMARY KEY проти UNIQUE
Обидва обмеження гарантують унікальність значень і обидва створюють під собою індекс (тому пошук за ними швидкий — див. індекси). Через це їх постійно плутають. Різниця — принципова:
| Аспект | PRIMARY KEY | UNIQUE |
|---|---|---|
| Скільки на таблицю | Рівно один | Скільки завгодно |
NULL | Заборонено (неявний NOT NULL) | Дозволено |
| Роль | Головний ідентифікатор рядка | Додаткове бізнес-обмеження |
| Ціль зовнішнього ключа | За замовчуванням посилаються сюди | Теж можна, але рідше |
Практичний сенс різниці видно на прикладі таблиці users: первинний ключ — сурогатний id (на нього посилаються замовлення), а UNIQUE на email не дає зареєструвати двох людей з однаковою поштою. Це два різні завдання, і одне обмеження їх не закриває.
Нюанс про NULL, який люблять на співбесідах: у PostgreSQL і MySQL стовпець UNIQUE за замовчуванням допускає кілька рядків із NULL, бо за логікою SQL NULL не дорівнює NULL, тож формально дублікатами вони не вважаються. У деяких СУБД (історично — SQL Server) поведінка інша — дозволений лише один NULL. Тому «UNIQUE + NULL» — місце, де варто звірятися з конкретною СУБД, а не покладатися на пам'ять.
Зв'язки: 1:1, 1:N, M:N
Реляційна модель тримається на трьох типах зв'язків між таблицями. Уміти впізнати кожен — половина роботи з незнайомою схемою.
Один-до-багатьох (1:N) — найпоширеніший. Один рядок батьківської таблиці пов'язаний із багатьма дочірніми, але кожен дочірній — рівно з одним батьком. Один користувач має багато замовлень; кожне замовлення належить одному користувачеві. Реалізація проста: FK на боці „багато“. У orders є user_id — і все.
Один-до-одного (1:1) — рідкісний. Один рядок відповідає рівно одному рядку в іншій таблиці. Навіщо взагалі різати сутність надвоє: винести рідко потрібні або великі поля (профіль користувача з аватаркою й біографією окремо від users), або відокремити чутливі дані. Реалізація — FK, який водночас є UNIQUE (щоб на один users.id не почепилося двох профілів), або спільний первинний ключ.
Багато-до-багатьох (M:N) — і головна пастка новачків. Один рядок з обох боків пов'язаний з багатьма з іншого: одне замовлення містить багато товарів, і один товар входить у багато замовлень. Прямим зовнішнім ключем це не реалізується — не можна покласти «список» id у клітинку, реляційна модель цього не передбачає.
Розв'язання — проміжна (звʼязувальна, associative/junction) таблиця. Зв'язок M:N завжди розкладається на два зв'язки 1:N через окрему таблицю, кожен рядок якої — один факт зв'язку:
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
PRIMARY KEY (order_id, product_id)
);
Тут order_items тримає два зовнішні ключі, а її первинний ключ — складений, з пари (order_id, product_id): один товар не має двічі фігурувати в одному замовленні окремими рядками. Бонус проміжної таблиці — на ній зручно вішати атрибути самого зв'язку: quantity, ціна на момент покупки. Класичні приклади того самого патерну: студенти й курси через таблицю записів, статті й теги через таблицю тегування.
Як читати ER-діаграму
ER-діаграма (entity-relationship diagram) — це схема бази «з висоти пташиного польоту»: прямокутники-сутності (таблиці), їхні атрибути й лінії зв'язків між ними. Її показують на онбордингу, у документації фічі, іноді — прямо на співбесіді з проханням «поясни, що тут відбувається».
Найпоширеніша нотація ліній — «воронячі лапки» (crow's foot). Символ на кінці лінії читається як кардинальність: риска | — «один», кружечок o — «нуль» (тобто необов'язково), а розгалуження, схоже на пташину лапку, — «багато». Їх комбінують: «один і тільки один», «нуль або один», «один або багато», «нуль або багато». Позначки PK і FK біля атрибутів кажуть, де первинний, а де зовнішній ключ.
Ось наша схема інтернет-магазину цілком — з усіма трьома типами зв'язків:
Читаємо діаграму вголос, і схема сама розповідає правила застосунку: користувач розміщує нуль або багато замовлень (||--o{) — 1:N; користувач має рівно один профіль (||--||) — 1:1; замовлення містить один або багато рядків (||--|{), і товар входить у нуль або багато рядків — а разом ці два зв'язки 1:N через ORDER_ITEMS і дають M:N між замовленнями й товарами. Побачив таблицю, що складається переважно з двох зовнішніх ключів, — майже напевно перед тобою проміжна таблиця, тобто десь є зв'язок many-to-many.
Для QA ER-діаграма — це карта того, що можна зламати: кожна лінія зв'язку — це FK-обмеження, яке варто перевірити (а що буде при видаленні? а nullable він чи ні?), а кожен UK/UNIQUE — бізнес-правило унікальності, яке має ловити дублікати.
Прив'язати теорію до автотесту нескладно: підготувавши дані, часто корисно перевірити цілісність прямо в БД, а не лише через UI чи API (як дані виглядають на межі застосунку — див. REST API та формати даних):
// Перевірка, що після видалення юзера не лишилось сиріт-замовлень
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS orphans
FROM orders o
LEFT JOIN users u ON u.id = o.user_id
WHERE u.id IS NULL`,
);
expect(rows[0].orphans).toBe(0);
Типові помилки
- Виглядає як «PK і UNIQUE — одне й те саме», а насправді PK один на таблицю й неявно
NOT NULL, аUNIQUEможе бути кілька й він допускаєNULL. Це різні інструменти для різних завдань. - Виглядає як «M:N зробимо двома FK, що дивляться одне на одного», а насправді це не масштабується й веде до дублювання; коректне рішення — окрема проміжна таблиця з двох зовнішніх ключів.
- Виглядає як баг «дані замовлень зникли після видалення юзера», а насправді штатно відпрацював
ON DELETE CASCADE. Перш ніж заводити дефект — глянь на визначення зовнішнього ключа. - Виглядає як баг «не можу видалити рядок, БД лається», а насправді спрацював
RESTRICT: на рядок посилаються дочірні записи, і це захист цілісності, а не збій. - Виглядає як «сурогатний
idзахищає від дублікатів», а насправдіidунікальний завжди, а бізнес-дублікат (два акаунти з тим самим email) пролізе, якщо наemailнемає окремогоUNIQUE. - Виглядає як «
NULLу зовнішньому ключі — це зіпсовані дані», а насправді nullable FK означає опційний зв'язок (замовлення без призначеного менеджера) — легальний стан, а не сирітський рядок.
Підсумок
- Первинний ключ однозначно ідентифікує рядок: він унікальний,
NOT NULLі один на таблицю; на практиці беруть сурогатний, бо бізнес-значення змінюються. - Зовнішній ключ забезпечує посилальну цілісність — БД сама не дасть послатися на неіснуючий рядок; що станеться з «дітьми» при видаленні батька, вирішує
ON DELETE(RESTRICT/CASCADE/SET NULL). PRIMARY KEY— головний ідентифікатор (один, безNULL);UNIQUE— додаткове бізнес-правило унікальності (багато, зNULL). Сурогатнийidне замінюєUNIQUEна email.- Зв'язок 1:N реалізується зовнішнім ключем на боці «багато»; 1:1 — зовнішнім ключем із
UNIQUE; M:N — тільки через проміжну таблицю з двох зовнішніх ключів. - На ER-діаграмі «воронячі лапки» кодують кардинальність; таблиця з двох FK — ознака зв'язку many-to-many.
Що питають на співбесіді
- «Чим PRIMARY KEY відрізняється від UNIQUE?» — найчастіше питання розділу. Інтерв'юер перевіряє, чи назвеш обидві відмінності: кількість на таблицю й ставлення до
NULL, а не лише «обидва унікальні». - «Природний чи сурогатний ключ — що обереш і чому?» — тут дивляться на аргументацію: чи розумієш, що бізнес-значення змінюються й тому не годяться в ідентифікатор, на який посилаються інші таблиці.
- «Як реалізувати зв'язок many-to-many?» — очікувана відповідь одна: проміжна таблиця з двома зовнішніми ключами. Плюс бали, якщо згадаєш складений первинний ключ і атрибути на самому зв'язку.
- «Що таке посилальна цілісність?» та «що станеться, якщо видалити користувача, на якого посилаються замовлення?» — перевіряють, чи розумієш, що цілісність стежить сама БД, і чи знаєш варіанти
ON DELETE. Сильний кандидат одразу уточнює: «залежить від дії зовнішнього ключа». - Наскрізний сигнал, який шукає інтерв'юер у QA: чи вмієш ти відрізнити реальний баг цілісності даних (сирітські рядки, дублікати) від штатної, хай і несподіваної, поведінки обмежень.
Джерела
- PostgreSQL Documentation — Constraints — первинні, зовнішні та
UNIQUE-обмеження, діїON DELETE/ON UPDATEз прикладами. - MySQL Reference Manual — FOREIGN KEY Constraints — реалізація зовнішніх ключів і посилальних дій у MySQL.
- Mermaid — Entity Relationship Diagrams — нотація «воронячих лапок» і кардинальностей, як на діаграмі вище.
- ISO/IEC 9075 (SQL) — міжнародний стандарт мови SQL, що фіксує поняття первинного/зовнішнього ключа й обмежень цілісності.
Примітка: реляційна модель і SQL не входять до силабусу ISTQB CTFL 4.0 — це базове знання баз даних, тож канонічні джерела тут — документація СУБД і стандарт SQL, а не силабус.
Що таке таблиця, рядок і стовпець у реляційній БД?
Таблиця (table) — базова одиниця реляційної моделі: сітка, у якій стовпці описують структуру даних, а рядки наповнюють її конкретними записами. Стовпець (column, поле) — атрибут із жорстко заданим типом: id — integer, email — varchar, created_at — timestamp; тип працює як контракт, який СУБД перевіряє сама — текст у цілочисловий стовпець не запишеться. Рядок (row, запис) — один екземпляр сутності (один користувач, одне замовлення); набір стовпців у всіх рядків таблиці однаковий. Для QA це фундамент: перевіряючи, що замовлення збереглося за потрібним користувачем, ти маєш справу саме з таблицями та зв'язками між ними, навіть без жодного написаного SELECT.
Чому порядок рядків у таблиці не гарантований і чим це загрожує автотестам?
Таблиця — це множина записів, а не список, тому реляційна модель принципово не обіцяє жодного порядку рядків. Запит без ORDER BY може повертати рядки в різній послідовності на різних прогонах, на різних версіях СУБД чи після зміни плану виконання. Це класичне джерело флакі-перевірок: автотест бере «перший рядок» видачі й асертить його, а насправді «перший» щоразу інший. Практичне правило — якщо тест покладається на конкретний порядок, він мусить явно задати ORDER BY за детермінованим ключем; інакше зелений сьогодні тест почервоніє завтра без жодної зміни в продукті.
Що таке первинний ключ і які властивості роблять його ідентифікатором?
Первинний ключ (primary key, PK) — це стовпець або набір стовпців, що однозначно ідентифікує кожен рядок таблиці. Він має три властивості: унікальний (два рядки не можуть мати однаковий PK), не порожній (неявний NOT NULL — ідентифікатор без значення не вказує на жоден рядок) і один на таблицю (саме ним запис адресують). Саме на первинний ключ за замовчуванням посилаються зовнішні ключі інших таблиць. Ключ може бути й складеним (composite) — з кількох стовпців разом, як пара (order_id, product_id) у проміжній таблиці зв'язку.
Природний чи сурогатний ключ — що обереш і чому?
На практиці майже завжди беруть сурогатний, і вміння це аргументувати — те, що перевіряють на співбесіді. Природний ключ (natural key) — унікальне значення, яке дані вже мають самі по собі: email, ІПН, ISBN, код валюти USD. Сурогатний (surrogate key) — технічний ідентифікатор, позбавлений бізнес-змісту, який видає сама БД: автоінкремент (SERIAL у PostgreSQL, AUTO_INCREMENT у MySQL) або UUID. Сурогатний виграє з трьох причин: бізнес-значення нестабільні (людина змінює прізвище, користувач — email), а на ідентифікатор уже посилаються інші таблиці; унікальність «природних» значень часто оманлива (номер телефону з часом дістається іншій людині); природний ключ буває складеним і громіздким, і тягати його в кожне посилання незручно. Важливий наслідок для QA: сурогатний id дає лише технічну унікальність рядка, а не бізнес-унікальність.
Що таке зовнішній ключ і посилальна цілісність?
Зовнішній ключ (foreign key, FK) — стовпець, чиї значення зобов'язані мати відповідник серед первинних (чи унікальних) ключів іншої таблиці; наприклад, orders.user_id як FK на users.id читається як «це замовлення належить конкретному користувачеві». Найважливіше, що дає FK, — посилальна цілісність (referential integrity): БД власними обмеженнями, без коду застосунку, гарантує, що кожне посилання вказує на реальний рядок — не дозволить ані вставити замовлення з неіснуючим user_id, ані видалити користувача, поки замовлення на нього посилаються (якщо не задано інше). Для тестувальника FK — це і страховка, і об'єкт перевірки: биті посилання та «сирітські» рядки після кривої міграції — типовий клас дефектів у даних, які з UI часто непомітні.
Що станеться, якщо видалити користувача, на якого посилаються замовлення?
Правильна відповідь починається з «залежить від дії зовнішнього ключа» — і це саме те, чого чекає інтерв'юер. Поведінку при видаленні батьківського рядка задають діями ON DELETE: RESTRICT / NO ACTION (поведінка за замовчуванням) блокує операцію, поки існують посилання, — спроба впаде з помилкою violates foreign key constraint. CASCADE тягне видалення за собою: разом із користувачем автоматично зникнуть і всі його замовлення. SET NULL розриває зв'язок, виставляючи FK дочірніх рядків у NULL (замовлення живе далі, просто без власника), — для цього стовпець мусить бути nullable. Практичний висновок для QA: перш ніж заводити дефект «дані зникли після видалення» чи «БД не дає видалити рядок», глянь на визначення FK — часто це штатний CASCADE або RESTRICT, а не баг.
Чим PRIMARY KEY відрізняється від UNIQUE?
Це найчастіше питання розділу, і сильна відповідь називає обидві відмінності, а не лише «обидва унікальні». Спільне: обидва гарантують унікальність значень і обидва підкріплені індексом, тож пошук за ними швидкий. Відмінності принципові: PRIMARY KEY — рівно один на таблицю й неявно NOT NULL; UNIQUE — скільки завгодно на таблицю й допускає NULL. За роллю PK — головний ідентифікатор рядка, на який за замовчуванням посилаються зовнішні ключі, а UNIQUE — додаткове бізнес-обмеження. Практичний приклад: у таблиці users первинним ключем роблять сурогатний id, на який посилаються замовлення, а окремий UNIQUE на email блокує реєстрацію двох акаунтів з однаковою поштою — це різні завдання, і жодне одне обмеження обох не закриє.
Чи гарантує сурогатний id, що email не зареєструють двічі?
Ні — і це поширена пастка. Сурогатний id унікальний завжди, але він забезпечує лише технічну унікальність рядка, а не бізнес-унікальність за іншим полем. Двоє людей із тим самим email спокійно отримають різні id й обидва запишуться в таблицю, бо для БД це два різні рядки. Щоб цього не сталося, потрібне окреме обмеження UNIQUE на стовпці email. Для QA це прямий тест-кейс: спробувати зареєструвати той самий email двічі й переконатися, що система відхиляє дублікат на рівні бази, а не лише перевіркою у формі, яку легко обійти прямим запитом до API.
Скільки значень NULL допускає стовпець UNIQUE?
Залежить від СУБД, і саме тому це люблять на співбесідах. У PostgreSQL і MySQL стовпець UNIQUE за замовчуванням допускає кілька рядків із NULL: порівняння NULL з NULL у SQL не дає «істину», тож такі рядки не рахуються дублікатами. У деяких СУБД (історично — SQL Server) поведінка інша: дозволений лише один NULL. Тому поведінку «UNIQUE + NULL» варто перевіряти в документації конкретної СУБД, а не відповідати з пам'яті. Практичний наслідок: якщо ти тестуєш обмеження унікальності на nullable-стовпці, поведінка з порожніми значеннями може відрізнятися між середовищами на різних СУБД.
Що означає зовнішній ключ, який допускає NULL?
Nullable FK означає опційний зв'язок, а не зіпсовані дані. Наприклад, orders.manager_id зі значенням NULL — замовлення, у якого менеджера поки просто немає: стан цілком штатний. Тому не варто заводити баг «сирітський рядок» щоразу, коли бачиш NULL у зовнішньому ключі: спершу з'ясуй, чи цей зв'язок за схемою обов'язковий. Різниця важлива для QA-перевірок: обов'язковий FK оголошують NOT NULL (замовлення без користувача не має існувати), а опційний свідомо лишають nullable — і тест має перевіряти саме той інваріант, який закладено в схему, а не абстрактне «FK завжди має бути заповнений».
Які бувають типи зв'язків між таблицями і як реалізується кожен?
Реляційна модель тримається на трьох типах зв'язків. Один-до-багатьох (1:N) — найпоширеніший: один користувач має багато замовлень, кожне замовлення належить одному користувачеві; реалізація проста — FK на боці «багато» (user_id у orders). Один-до-одного (1:1) — рідкісний: рядку однієї таблиці відповідає щонайбільше один рядок іншої; реалізують через FK з накладеним UNIQUE або через спільний первинний ключ. Багато-до-багатьох (M:N) — одне замовлення містить багато товарів, і один товар входить у багато замовлень; самими лише FK у двох таблицях його не зібрати — потрібна проміжна таблиця. Хто впізнає ці три патерни з першого погляду, той уже наполовину розібрався в незнайомій схемі.
Як реалізувати зв'язок many-to-many?
Очікувана відповідь одна: через проміжну (зв'язувальну, junction) таблицю з двома зовнішніми ключами. Прямим зовнішнім ключем M:N не зробити — «список» id у клітинку не запишеш, реляційна модель такого не знає. M:N завжди розпадається на пару зв'язків 1:N через окрему таблицю, де один рядок фіксує один факт зв'язку: order_items з order_id і product_id. Плюс бали на співбесіді, якщо згадаєш, що первинний ключ такої таблиці зазвичай складений — пара (order_id, product_id), щоб той самий товар не потрапив у замовлення двома рядками, — і що на самому зв'язку зручно тримати атрибути: quantity, ціну на момент покупки. Той самий патерн: студенти й курси (таблиця записів на курс), статті й теги (таблиця тегування).
Навіщо взагалі зв'язок 1:1, якщо можна тримати все в одній таблиці?
Технічно 1:1 справді можна злити в одну таблицю, тож питання перевіряє, чи розумієш мотивацію розділення. Причин дві: перша — прибрати з «гарячої» users важкі чи рідко потрібні поля (біографія, аватарка), щоб основні запити лишалися легкими; друга — ізолювати чутливі дані з окремими правами доступу. Реалізують 1:1 через FK з обмеженням UNIQUE (інакше до одного users.id причепиться кілька профілів) або через спільний первинний ключ. Для QA важливо, що обидва рядки мають лишатися синхронними: створення користувача без профілю чи навпаки — потенційний баг, залежно від того, як застосунок гарантує пару.
Як читати ER-діаграму й нотацію «воронячих лапок»?
ER-діаграма (entity-relationship diagram) — погляд на базу згори: прямокутники-сутності (таблиці), їхні атрибути й лінії зв'язків. Найуживаніша нотація ліній — «воронячі лапки» (crow's foot), де кардинальність закодована символом на кінці лінії: «один» малюється рискою |, «нуль» (участь необов'язкова) — кружечком o, «багато» — трипалим розгалуженням, за яке нотація й дістала назву. Символи комбінуються в «один і тільки один», «нуль або один», «один або багато», «нуль або багато»; атрибути підписують PK/FK, щоб було видно, де який ключ. Читаючи діаграму вголос, ти фактично проговорюєш правила застосунку: «користувач розміщує нуль або багато замовлень» — це зв'язок 1:N. Для QA кожна лінія — FK-обмеження, що заслуговує на окрему перевірку, а кожна позначка UK/UNIQUE — правило унікальності, яке мусить відсікати дублікати.
Як за схемою впізнати, що десь є зв'язок many-to-many?
Ознака — проміжна таблиця, яка складається переважно з двох зовнішніх ключів. Побачив таблицю на кшталт order_items з парою FK (order_id, product_id) і, можливо, кількома атрибутами самого зв'язку (quantity) — з високою ймовірністю це розкладений зв'язок M:N між двома «головними» сутностями. Її первинний ключ зазвичай складений — з тієї самої пари зовнішніх ключів. На ER-діаграмі це видно як дві лінії 1:N, що сходяться до однієї таблиці посередині. Для тестувальника така таблиця — окремий об'єкт перевірок: чи не дублюються факти зв'язку, чи коректно поводяться обидва FK при видаленні батьків, чи не лишаються «висячі» рядки зв'язку після видалення товару або замовлення.
Тест очікує конкретний рядок, а результат плаває між прогонами. З чого почати розбір?
Найімовірніша причина — відсутність детермінованого сортування, а не баг продукту. Оскільки порядок рядків без ORDER BY не гарантований, будь-яка перевірка виду «візьми перший рядок і асертни його поле» стає лотереєю: сьогодні БД повернула один порядок, завтра інший. Перше, що варто зробити, — переконатися, що запит (чи ORM за ним) явно сортує за стабільним ключем, наприклад за id або created_at. Другий підозрюваний — недостатня ізоляція тестових даних: паралельні прогони чи залишки від попереднього тесту домішують «чужі» рядки у видачу. Тобто симптом «зелено поодинці, червоно в наборі» тут читається як проблема сортування або ізоляції стану, а не як дефект застосунку.
Замовлення посилається на user_id, якого немає в users. Це взагалі можливо і про що каже?
За наявності коректного зовнішнього ключа з посилальною цілісністю це неможливо: БД не дасть ані вставити таке замовлення, ані видалити користувача, лишивши «сироту». Тож якщо такий рядок реально існує, це серйозний сигнал — найчастіше про те, що зовнішній ключ узагалі не оголошений (посилання є лише «на папері», а обмеження в схемі немає), або його вимкнули під час масової міграції даних і не ввімкнули назад. Це справжній баг цілісності даних, який часто не видно в UI, поки застосунок не спробує підтягнути неіснуючого користувача й не впаде. Для QA це привід перевірити не лише дані, а й саму схему: чи є REFERENCES там, де за логікою мусить бути, і чи не лишили міграції «дірку» в цілісності.
Три кейси, де реляційна модель вирішує, знайшов ти реальний баг цілісності чи погнався за примарою: читання ER-діаграми як карти перевірок, розбір «це баг чи штатний ON DELETE?» на живій ситуації й перевірка цілісності прямо в БД замість сліпої віри в UI. Скрізь — що дивитися і чому.
Кейс 1. ER-діаграма як карта того, що можна зламати
Тобі дали схему інтернет-магазину на онбордингу й попросили «прикинути, що тут тестувати». Спершу читаємо діаграму вголос — і вона сама розповідає правила застосунку.
Кожна лінія й позначка перетворюється на конкретну перевірку:
| Зв'язок на схемі | Як читається | Що перевіряє QA |
|---|---|---|
| USERS до ORDERS, «нуль або багато» | 1:N, user_id — FK | замовлення не створюється на неіснуючого користувача; що з замовленнями при видаленні юзера |
| USERS до USER_PROFILES, «один-до-одного» | 1:1 через FK з UNIQUE | на один users.id не чіпляється двох профілів; пара користувач-профіль синхронна |
| ORDER_ITEMS з двох FK | проміжна таблиця, отже M:N | той самий товар не дублюється в одному замовленні окремими рядками |
email з позначкою UK | бізнес-правило унікальності | дублікат email відхиляється на рівні БД, не лише у формі |
Що дивитися і чому:
- Таблиця з двох зовнішніх ключів — майже завжди зв'язок many-to-many.
ORDER_ITEMSтримаєorder_idіproduct_id, її первинний ключ складений з цієї пари. Побачив такий патерн — знай, що десь ховається M:N, і його треба покрити окремо від «головних» таблиць. oпроти|на кінці лінії — це питання «а nullable він чи ні?».||--o{(нуль або багато) допускає користувача без жодного замовлення — легальний стан. Якби там стояло||--|{(один або багато), користувач без замовлень був би порушенням; це різні тест-кейси.UK(unique key) — не те саме, щоPK. Первинний ключUSERS— сурогатнийid(на нього посилаються замовлення), аemailлишеUNIQUE. Дублікат email пролізе, якщо це обмеження забули: перевіряти його треба прямим повторним запитом, а не довірою до валідації у формі.
Кейс 2. «Це баг чи штатна поведінка?» — розбір по ON DELETE
Прилітає тікет: «Видалив тестового користувача через адмінку — разом із ним зникли всі його замовлення. Втрата даних, критичний баг!». Перш ніж заводити дефект, дивимося на визначення зовнішнього ключа.
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
Ось ON DELETE CASCADE — і «зникнення» замовлень тут штатне: так спроєктовано, видалення батька каскадить на дітей. Це не баг застосунку, а поведінка БД, закладена в схему. Таблиця рішень для трьох варіантів:
| Симптом | Визначення FK | Це баг? |
|---|---|---|
| замовлення зникли разом із юзером | ON DELETE CASCADE | ні — штатний каскад |
БД не дає видалити юзера, лається violates foreign key constraint | ON DELETE RESTRICT/NO ACTION | ні — захист цілісності |
замовлення лишились, але user_id став NULL | ON DELETE SET NULL | ні — «без власника» за задумом |
замовлення посилається на user_id, якого немає в users | FK не оголошений або вимкнений | так — реальний баг цілісності |
Що дивитися і чому:
- Перш ніж писати «втрата даних», відкрий схему. Різниця між багом і фічею тут — один рядок у визначенні FK. Сильний QA у тікеті одразу уточнює: «поведінка задана
ON DELETE CASCADE, це очікувано; питання лише в тому, чи каскад — бажаний продуктовий сценарій». RESTRICTвиглядає як збій, а насправді захист. Скарга «не можу видалити рядок, БД лається» — це не поламка, а спрацьоване обмеження: на рядок посилаються дочірні записи. Баг був би, якби видалення пройшло й лишило сиріт.- Справжній дефект — четвертий рядок таблиці. Якщо в базі реально є замовлення на неіснуючого користувача, значить, FK узагалі немає або його вимкнули під час міграції. Оце варто ескалювати: цілісність «на папері» без обмеження в схемі рано чи пізно впаде в проді.
Кейс 3. Перевірка цілісності прямо в БД, а не через UI
UI показує, що користувача видалено, а форма реєстрації «наче» не пускає дублікат email. «Наче» — недостатньо: інваріанти цілісності надійніше перевіряти прямо в базі, бо UI й API можуть брехати, а обмеження БД — ні.
import { test, expect } from '@playwright/test';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
test('після видалення юзера не лишилось сиріт-замовлень', async () => {
// orphan = замовлення, чий user_id не має пари в users
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS orphans
FROM orders o
LEFT JOIN users u ON u.id = o.user_id
WHERE u.id IS NULL`,
);
expect(rows[0].orphans, 'знайдено сирітські замовлення — порушено цілісність').toBe(0);
});
Бізнес-унікальність email — окрема перевірка: сурогатний id її не гарантує, тож тестуємо саме обмеження UNIQUE, а не форму.
test('email не можна зареєструвати двічі — на рівні БД', async () => {
const email = `dup-${Date.now()}@example.com`;
await pool.query(`INSERT INTO users(email) VALUES ($1)`, [email]);
// друга спроба того самого email має впертися в UNIQUE, а не створити рядок
await expect(
pool.query(`INSERT INTO users(email) VALUES ($1)`, [email]),
).rejects.toThrow(/duplicate key value|unique constraint/i);
});
Що дивитися і чому:
LEFT JOIN ... WHERE u.id IS NULL— канонічний пошук сиріт. Ліве з'єднання лишає всі замовлення, аu.id IS NULLвиловлює ті, для яких пари вusersне знайшлося. Нуль сиріт — цілісність ціла; будь-яке число більше нуля — привід копати міграцію чи вимкнений FK.- Дублікат email ловиться помилкою БД, а не «тихим» другим рядком. Якщо
UNIQUEна місці, друга вставка падає зduplicate key value. Якщо ж обидві вставки пройшли — обмеження немає, і валідація у формі лише маскує діру: прямий запит до API створить два акаунти. - Не плутай технічну й бізнес-унікальність у тесті. Перевіряти
idна унікальність безглуздо — його гарантує сама БД. Цінність має перевірка саме бізнес-ключа (email), бо це той інваріант, який реально ламається на практиці.
Таблиця, рядок, схема
- Розумію, чому порядок рядків без
ORDER BYне гарантований (таблиця — множина, не список) і чому «перший рядок без сортування» — джерело флакі-тесту, що лікується явнимORDER BYза стабільним ключем. - Знаю, що стовпець має фіксований тип-контракт, який БД перевіряє сама (рядок у
integer-стовпець не покласти). - Розрізняю два значення слова «схема»: опис структури (таблиці, типи, ключі — його змінюють міграції) і простір імен (namespace) на кшталт
public.users.
Первинний ключ
- Знаю три властивості первинного ключа: унікальний,
NOT NULL(неявно) і один на таблицю. - Можу пояснити різницю природний (natural) vs сурогатний (surrogate) ключ і чому на практиці майже завжди беруть сурогатний.
- Знаю, що первинний ключ буває складеним (composite) — з кількох стовпців разом, як пара
(order_id, product_id).
Зовнішній ключ і посилальна цілісність
- Можу пояснити, що зовнішній ключ (FK) — це стовпець, значення якого мусить збігатися з PK (чи
UNIQUE) іншої таблиці. - Розумію, що посилальна цілісність — гарантія БД, що посилання завжди веде на реальний рядок, без коду застосунку.
- Знаю три дії
ON DELETE:RESTRICT/NO ACTION(заборонити, типове),CASCADE(видалити дітей),SET NULL(обнулити FK, потребує nullable-стовпця). - Можу відрізнити штатний
CASCADE/RESTRICTвід бага: перш ніж заводити дефект «дані зникли»/«не дає видалити», дивлюся на визначення FK. - Розумію, що nullable FK — це опційний зв'язок (замовлення без менеджера), а не сирітський рядок.
PRIMARY KEY проти UNIQUE
- Можу назвати обидві відмінності: PK один на таблицю й неявно
NOT NULL, аUNIQUE— багато й допускаєNULL. - Знаю спільне: обидва гарантують унікальність і обидва створюють під собою індекс.
- Розумію, що сурогатний
idне замінюєUNIQUEнаemail— бізнес-дублікат пролізе без окремого обмеження. - Пам'ятаю нюанс про
NULL: у PostgreSQL/MySQLUNIQUE-стовпець за замовчуванням допускає кількаNULL, у SQL Server історично — лише один.
Зв'язки й ER-діаграма
- Розрізняю три типи зв'язків і реалізацію кожного: 1:N — FK на боці «багато»; 1:1 — FK з
UNIQUE; M:N — junction-таблиця з двох FK, бо «список» id у клітинку не покласти. - Знаю, навіщо різати сутність на 1:1: винести великі/рідкісні поля або відокремити чутливі дані.
- Умію читати «воронячі лапки» (crow's foot):
|— один,o— нуль/необов'язково, розгалуження — багато. - Впізнаю проміжну таблицю за ознакою «складається переважно з двох FK» — сигнал, що десь є зв'язок many-to-many.
- Розумію, що для QA ER-діаграма — карта того, що можна зламати: кожна лінія це FK-перевірка, кожен
UK— правило унікальності.
Чому запит без ORDER BY може повертати рядки в різному порядку на різних прогонах?
Питання
Чому порядок рядків у таблиці без ORDER BY не гарантований?