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

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

    Зміна даних: INSERT, UPDATE, DELETE

    Зміст

    Досі ми тільки читали дані: SELECT нічого не ламає, найгірше, що може статися, — повільний запит. Зміна даних (data manipulation language, DML) — інша ліга: одна команда без WHERE перетворює базу спільного стенда на кашу, і відновлювати її буде вся команда. Саме тому в багатьох компаніях QA має на прод лише read-only доступ, а право на UPDATE — це водночас і право на катастрофу.

    При цьому без DML тестувальнику нікуди: підготувати тестові дані, відтворити баг «саме з таким станом у базі», прибрати за автотестом. А на співбесідах трійця INSERT / UPDATE / DELETE — стабільна класика: «що буде, якщо забути WHERE?», «чим DELETE відрізняється від TRUNCATE?». Відповіді короткі, але за ними видно, чи людина реально працювала з базою, чи бачила SQL лише в конспекті.

    INSERT: додаємо рядки

    INSERT додає нові рядки в таблицю. Канонічна форма — з явним переліком стовпців:

    INSERT INTO users (email, name, status)
    VALUES ('qa.test@example.com', 'Test User', 'active');

    Перелік стовпців можна опустити й передавати значення «по порядку», але це міна: щойно хтось додасть у таблицю новий стовпець, усі такі вставки або впадуть, або — гірше — запишуть значення не туди. Явний перелік — дешева страховка, яка ще й документує, що саме ви вставляєте.

    Стовпці, яких немає в переліку, отримують значення за замовчуванням (DEFAULT) або NULL. Якщо на стовпці стоїть NOT NULL без дефолту — вставка впаде з помилкою обмеження (у PostgreSQL це повідомлення null value in column ... violates not-null constraint). Для QA це не перешкода, а перший шар перевірок цілісності: обмеження в базі — теж вимоги, і їх теж тестують (детально — у главі про DDL і обмеження: /sql/ddl-typy-danykh-i-obmezhennia-tsilisnosti).

    Кілька рядків вставляють одним запитом — це швидше й атомарніше, ніж десять окремих INSERT:

    INSERT INTO users (email, name, status) VALUES
      ('qa.active@example.com',  'Active User',  'active'),
      ('qa.blocked@example.com', 'Blocked User', 'blocked'),
      ('qa.deleted@example.com', 'Deleted User', 'deleted');

    Третя форма — INSERT ... SELECT: джерелом значень слугує не список VALUES, а результат запиту. Це стандартний спосіб розмножити тестові дані або скопіювати зріз у тимчасову таблицю:

    -- Копія активних користувачів у таблицю-пісочницю
    INSERT INTO users_sandbox (email, name, status)
    SELECT email, name, status
    FROM users
    WHERE status = 'active';

    Тут вставиться стільки рядків, скільки поверне SELECT — може бути нуль, може бути мільйон. Тому перед INSERT ... SELECT на живому стенді той самий SELECT варто запустити окремо й подивитись на обсяг.

    Автоінкремент і RETURNING

    Сурогатні первинні ключі (про них — у главі про реляційну модель) зазвичай генерує сама база: у PostgreSQL — послідовності (sequence) за колонками IDENTITY або старішим SERIAL, у MySQL — AUTO_INCREMENT. Ви не передаєте id в INSERT — база видає наступне значення сама.

    Дві речі, які тут треба розуміти QA:

    Дірки в нумерації — норма. Послідовність видає значення до того, як вставка завершиться. Якщо транзакція відкотилась або INSERT упав на обмеженні — видане значення не повертається назад. Тому id 1, 2, 5, 9 — не «баг, у нас зникають записи», а звичайне життя автоінкремента. Автоінкремент гарантує унікальність і зростання, але не суцільність.

    Як дізнатись згенерований id. Найчастіша практична задача: вставив рядок — треба його id, щоб передати далі (у наступний запит, в API-виклик, у клінап автотеста). У PostgreSQL це елегантно робить RETURNING — вставка одразу повертає вказані стовпці створеного рядка:

    INSERT INTO users (email, name)
    VALUES ('qa.new@example.com', 'New User')
    RETURNING id, created_at;

    RETURNING працює також з UPDATE і DELETE — можна побачити, що саме ви змінили чи видалили, тим самим запитом. У MySQL такої конструкції немає — там після вставки викликають функцію LAST_INSERT_ID(), а в SQL Server схожу роль грає OUTPUT. Це типове діалектне питання на співбесіді: сам механізм є скрізь, синтаксис різний.

    В автотестах RETURNING — стандартний спосіб зробити фікстуру, яка вміє прибрати за собою:

    // Playwright-фікстура: створюємо користувача напряму в БД, після тесту видаляємо
    const { rows } = await db.query(
      `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`,
      [`qa.${Date.now()}@example.com`, 'Fixture User'],
    );
    const userId = rows[0].id; // цей id і використовуємо в тесті, і видаляємо в teardown

    Стратегії тестових даних і клінапу — окрема велика тема, її розбирає глава про тестові дані і БД в автотестах.

    UPDATE: чому WHERE — не опція

    UPDATE змінює значення в наявних рядках: SET каже, що міняти, WHERE — у яких рядках:

    UPDATE users
    SET status = 'blocked'
    WHERE email = 'qa.test@example.com';

    Механіка проста, але в ній зашита найвідоміша SQL-катастрофа: WHERE — синтаксично необов'язковий. UPDATE users SET status = 'blocked' — цілком валідний запит, який заблокує всіх користувачів у таблиці. База не спитає «ви впевнені?»: більшість клієнтів працюють у режимі autocommit, тобто кожен запит фіксується миттєво, і кнопки «скасувати» після цього не існує. Виконали — все, дані змінені для всіх, хто працює зі стендом.

    Тому в роботі з живими стендами діє ритуал, який варто довести до автоматизму:

    Ні

    Так

    Так

    Ні

    Потрібен UPDATE або DELETE

    Спершу SELECT з тим самим WHERE

    Кількість і склад рядків очікувані?

    Виправити WHERE

    BEGIN — відкрити транзакцію

    Виконати UPDATE або DELETE

    affected rows збігається з очікуванням?

    COMMIT

    ROLLBACK

    Ні

    Так

    Так

    Ні

    Потрібен UPDATE або DELETE

    Спершу SELECT з тим самим WHERE

    Кількість і склад рядків очікувані?

    Виправити WHERE

    BEGIN — відкрити транзакцію

    Виконати UPDATE або DELETE

    affected rows збігається з очікуванням?

    COMMIT

    ROLLBACK

    Словами: спочатку SELECT ... WHERE ... з тією самою умовою — очима перевірити, що саме зачепить зміна. Потім відкрити транзакцію (BEGIN), виконати UPDATE, подивитись на кількість зачеплених рядків (affected rows — її показує будь-який клієнт) і лише тоді COMMIT. Якщо очікували 1 рядок, а зачепило 20 000 — ROLLBACK, і нічого не сталося. Як саме працюють транзакції і чому відкат можливий — у главі про транзакції та ACID; тут досить знати цей захисний патерн.

    Кількість зачеплених рядків — корисний оракул і в інший бік: UPDATE ... 0 rows виглядає як успіх (помилки ж немає), але найчастіше означає, що WHERE нікого не знайшов — одрук у значенні, не той стенд, дані вже змінені кимось іншим.

    DELETE vs TRUNCATE vs DROP

    Три способи «прибрати дані» плутають постійно, а на співбесідах це питання-фаворит. Розводяться вони за трьома осями: що саме зникає, чи можна вибрати частину і що з відкатом.

    АспектDELETETRUNCATEDROP TABLE
    Що робитьВидаляє рядкиМиттєво спорожняє таблицюВидаляє саму таблицю
    WHEREТакНі, тільки всеНі
    Структура таблиціЛишаєтьсяЛишаєтьсяЗникає разом з індексами й обмеженнями
    Тригери на видалення рядкаСпрацьовуютьНе спрацьовують
    Швидкість на великій таблиціПовільно, рядок за рядкомШвидкоШвидко
    КатегоріяDMLБлижче до DDLDDL
    DELETE FROM orders WHERE created_at < '2025-01-01';  -- частина рядків
    TRUNCATE TABLE orders;                               -- усі рядки, структура лишається
    DROP TABLE orders;                                   -- таблиці більше немає

    DELETE — звичайна DML-операція: проходить рядок за рядком, для кожного спрацьовують тригери, все відбувається в транзакції і відкочується ROLLBACK-ом. TRUNCATE не перебирає рядки, тому й працює швидко, і тригери на видалення не смикає. З транзакційністю — важлива діалектна різниця: у PostgreSQL TRUNCATE можна виконати всередині транзакції і відкотити, а в MySQL він спричиняє неявний commit і відкату не підлягає. Лічильники автоінкремента теж поводяться по-різному: MySQL при TRUNCATE скидає AUTO_INCREMENT на початок, PostgreSQL за замовчуванням послідовності не чіпає (для скидання є опція RESTART IDENTITY).

    Ще один практичний нюанс: таблицю, на яку посилаються зовнішні ключі з інших таблиць, просто так не «транкейтнеш» — база відмовить, бо інакше лишилися б записи-сироти. У PostgreSQL можна або транкейтити пов'язані таблиці разом, або явно написати TRUNCATE ... CASCADE — і тоді спорожніють і залежні таблиці, що само по собі непогана ілюстрація, чому команду треба читати двічі.

    Для QA типовий вибір виглядає так: точкова чистка на спільному стенді — тільки DELETE з акуратним WHERE; повне скидання таблиць у своїй ізольованій базі перед прогоном тестів — TRUNCATE, бо швидко; DROP — інструмент міграцій і адміністрування, у ручній роботі тестувальника йому робити нічого.

    Upsert: вставити або оновити

    Часта задача сідінгу даних: «якщо такого запису немає — створити, якщо є — оновити». Наївне рішення «спершу SELECT, потім вирішити» містить гонку: між вашим SELECT та INSERT той самий запис може створити хтось інший (паралельний тест, інший інженер), і INSERT упаде на унікальному обмеженні з duplicate key value violates unique constraint.

    Тому в СУБД є атомарний upsert. У PostgreSQL — INSERT ... ON CONFLICT:

    INSERT INTO users (email, name)
    VALUES ('qa.seed@example.com', 'Seed User')
    ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;
    -- або ON CONFLICT (email) DO NOTHING, якщо оновлювати не треба

    У MySQL те саме робить INSERT ... ON DUPLICATE KEY UPDATE, а в стандарті SQL є універсальна команда MERGE. Деталі синтаксису на співбесіді junior-рівня рідко ганяють — досить розуміти, навіщо це: upsert робить підготовку даних ідемпотентною (idempotent), тобто повторний запуск сіда не падає і не плодить дублікатів. Для автотестів це властивість номер один.

    Каскадні ефекти зовнішніх ключів

    Видалення чи оновлення рядка може зачепити не лише його. Зовнішній ключ (foreign key, FK) при створенні отримує правило поведінки на випадок, коли батьківський запис видаляють, — і саме воно вирішує, що станеться:

    RESTRICT / NO ACTION

    ON DELETE CASCADE

    ON DELETE SET NULL

    DELETE FROM users WHERE id = 42

    Що стоїть на FK orders.user_id?

    Помилка FK, нічого не видалено

    Видалено користувача і всі його orders,
    а каскадом — і order_items цих замовлень

    Користувача видалено,
    orders лишились з user_id = NULL

    RESTRICT / NO ACTION

    ON DELETE CASCADE

    ON DELETE SET NULL

    DELETE FROM users WHERE id = 42

    Що стоїть на FK orders.user_id?

    Помилка FK, нічого не видалено

    Видалено користувача і всі його orders,
    а каскадом — і order_items цих замовлень

    Користувача видалено,
    orders лишились з user_id = NULL

    За замовчуванням FK поводиться як заборона: видалити користувача, на якого посилаються замовлення, не вийде — база поверне помилку порушення зовнішнього ключа (у PostgreSQL — SQLSTATE 23503). ON DELETE CASCADE розвертає логіку: видалення батька автоматично зносить усіх нащадків, і ланцюжок може бути довгим — користувач → замовлення → позиції замовлень. ON DELETE SET NULL — компроміс: діти живуть далі, але посилання обнуляється.

    Чим це важливо тестувальнику. По-перше, ваш «маленький» DELETE одного рядка на стенді з каскадами може тихо винести половину пов'язаних даних — перед видаленням варто знати схему (або хоча б подивитись визначення FK). По-друге, каскади — джерело класу багів «дані зникли самі»: користувач скаржиться, що пропали записи, а насправді хтось видалив батьківську сутність, і каскад відпрацював рівно так, як налаштований. Питання лише в тому, чи задумано так — і це вже питання до вимог, тобто до вас. По-третє, протилежний баг: коли FK у схемі немає взагалі (так теж роблять — заради швидкості чи через легасі), видалення батька лишає записи-сироти (orphaned records), які потім ламають звіти і JOIN-и. Пошук сиріт — типова перевірка цілісності, до неї повернемось у главі про тестування БД і міграції.

    Дисципліна змін на спільних стендах

    Спільний стенд (staging, test env) — це база, з якою одночасно працюють розробники, інші тестувальники й автотести. Ваш необережний UPDATE тут — не особиста драма, а зіпсований день команди: чужі тестові сесії, демо для замовника, нічний прогін регресії. Тому дисципліна змін — не бюрократія, а гігієна:

    • Прод — тільки читання. Змінювати дані на проді руками — табу навіть із найкращими намірами; для цього існують міграції і support-процедури з рев'ю. Якщо ваш доступ технічно дозволяє UPDATE на проді — вважайте, що не дозволяє.
    • Зміни — через застосунок, поки можливо. Створити користувача через API чи UI надійніше, ніж INSERT напряму: спрацює вся логіка застосунку (валідації, хешування пароля, події). Прямий SQL — коли потрібен стан, якого штатним шляхом не досягти, або коли штатний шлях нестерпно повільний.
    • Свої дані — помічені й відокремлені. Унікальні префікси чи домени (qa.autotest+...@example.com) відрізняють ваші записи від чужих і роблять безпечним подальший клінап: DELETE ... WHERE email LIKE 'qa.autotest%' не зачепить нічого стороннього.
    • Ритуал SELECT → транзакція → перевірка → COMMIT — для будь-якої ручної зміни, без винятків «та тут же один рядочок».
    • Масові зміни — з попередженням. TRUNCATE, чистки, перезаливання довідників — тільки узгоджено з командою: у чат, заздалегідь, з вікном часу.

    Це прямий родич правила read-only з першої глави розділу: права на запис — це відповідальність, а не зручність.

    Типові помилки

    • Виглядає як успішний запит без помилок, а насправді UPDATE з affected rows = 0: умова WHERE нікого не знайшла (одрук у статусі, не те середовище), і зміна просто не відбулась. Перевіряйте кількість зачеплених рядків, а не лише відсутність помилки.
    • Виглядає як баг «id стрибають: 1, 2, 5, 9 — записи зникають!», а насправді нормальні дірки автоінкремента після відкатів і невдалих вставок. Послідовність гарантує унікальність, не суцільність.
    • Виглядає як «TRUNCATE — це просто швидкий DELETE», а насправді інша механіка: не фільтрує рядки, не запускає тригери на видалення, у MySQL не відкочується, по-різному поводиться з лічильниками автоінкремента.
    • Виглядає як містика «видалив одного користувача — зникли його замовлення й документи», а насправді ON DELETE CASCADE на ланцюжку зовнішніх ключів. Перед видаленням дивіться схему.
    • Виглядає як надійний патерн «спершу SELECT перевірю, чи існує, потім INSERT», а насправді гонка: у паралельному середовищі між двома запитами запис встигає створити хтось інший. Атомарне рішення — upsert.
    • Виглядає як зайва параноя «навіщо перелік стовпців в INSERT, і так працює», а насправді страховка від зміни схеми: без переліку додавання стовпця ламає вставку або мовчки зсуває значення не в ті поля.

    Підсумок

    • WHERE в UPDATE і DELETE синтаксично необов'язковий — тому ти зобов'язаний зробити його обов'язковим: спершу SELECT з тією самою умовою, потім зміна в транзакції, потім перевірка affected rows.
    • Автоінкремент гарантує унікальність, а не суцільність: дірки в id — норма. Згенероване значення забирають через RETURNING (PostgreSQL) або LAST_INSERT_ID() (MySQL).
    • DELETE видаляє рядки і відкочується; TRUNCATE миттєво спорожняє таблицю в обхід тригерів (у MySQL — безповоротно); DROP знищує саму таблицю зі структурою.
    • Видалення рядка — це потенційно видалення дерева: поведінку визначають правила FK (RESTRICT / CASCADE / SET NULL), і їх треба знати до DELETE, а не після.
    • На спільних стендах діє дисципліна: прод — read-only, дані створюються через застосунок де можливо, свої записи помічені, масові зміни — узгоджені.

    Що питають на співбесіді

    • «Чим DELETE відрізняється від TRUNCATE і DROP?» — питання-маркер. Слабка відповідь — «TRUNCATE швидший». Сильна розкладає по осях: WHERE і вибірковість, тригери, транзакційність (з нюансом MySQL), доля структури таблиці та лічильників автоінкремента.
    • «Що станеться, якщо виконати UPDATE без WHERE? Як запобігти?» — інтерв'юер слухає не визначення, а робочий ритуал: SELECT тією ж умовою, транзакція, перевірка affected rows, ROLLBACK як план Б. Це перевірка на реальний досвід зі стендами.
    • «Як отримати id щойно вставленого рядка?» — очікують RETURNING або LAST_INSERT_ID() залежно від СУБД; бонус — розуміння, навіщо це в автотестах (передати далі, прибрати за собою).
    • «Що таке upsert і коли він потрібен?» — дивляться, чи розумієте проблему гонки в «SELECT, потім INSERT» і слово «ідемпотентність» стосовно підготовки даних.
    • «Видаляємо користувача — що станеться з його замовленнями?» — правильна відповідь починається з «залежить від правила на зовнішньому ключі» і перелічує варіанти. Хто відповідає одним сценарієм — схему бачив здалеку.
    • «Чи можна змінювати дані напряму в базі тестового стенда?» — питання про зрілість: очікують «можна, але...» з дисципліною змін, а не браваду чи категоричне «ніколи».

    Джерела