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

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

    Реляційна модель: таблиці, ключі, зв'язки

    Зміст

    Реляційна модель — це спосіб, у який майже всі бізнес-бази даних (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) має два значення, і їх плутають:

    1. Схема даних — опис структури: які є таблиці, стовпці, типи, ключі й обмеження. Саме її змінюють міграції.
    2. Схема як простір імен (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 KEYUNIQUE
    Скільки на таблицюРівно одинСкільки завгодно
    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 біля атрибутів кажуть, де первинний, а де зовнішній ключ.

    Ось наша схема інтернет-магазину цілком — з усіма трьома типами зв'язків:

    розміщує

    має

    містить

    входить у

    USERS

    int

    id

    PK

    string

    email

    UK

    ORDERS

    int

    id

    PK

    int

    user_id

    FK

    timestamp

    created_at

    USER_PROFILES

    int

    user_id

    PK

    FK

    string

    bio

    ORDER_ITEMS

    int

    order_id

    FK

    int

    product_id

    FK

    int

    quantity

    PRODUCTS

    int

    id

    PK

    string

    name

    розміщує

    має

    містить

    входить у

    USERS

    int

    id

    PK

    string

    email

    UK

    ORDERS

    int

    id

    PK

    int

    user_id

    FK

    timestamp

    created_at

    USER_PROFILES

    int

    user_id

    PK

    FK

    string

    bio

    ORDER_ITEMS

    int

    order_id

    FK

    int

    product_id

    FK

    int

    quantity

    PRODUCTS

    int

    id

    PK

    string

    name

    Читаємо діаграму вголос, і схема сама розповідає правила застосунку: користувач розміщує нуль або багато замовлень (||--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, а не силабус.