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

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

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

    Зміст

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

    При цьому без DML тестувальнику нікуди: підготувати , відтворити баг «саме з таким станом у базі», прибрати за автотестом. А за короткими питаннями на кшталт «що буде, якщо забути 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ТакНі, тільки всеНі
    Структура таблиціЛишаєтьсяЛишаєтьсяЗникає разом з індексами й обмеженнями
    Тригери на видалення рядкаСпрацьовуютьНе спрацьовують
    Швидкість на великій таблиціПовільно, рядок за рядкомШвидкоШвидко
    КатегоріяDMLMySQL класифікує як DDL, а не DMLDDL
    Відкат у транзакціїТакPostgreSQL — так; MySQL — ні, неявний commitНі
    DELETE FROM orders WHERE created_at < '2025-01-01';  -- частина рядків
    TRUNCATE TABLE orders;                               -- усі рядки, структура лишається
    DROP TABLE orders;                                   -- таблиці більше немає

    DELETE — звичайна DML-операція: проходить рядок за рядком, для кожного спрацьовують тригери, все відбувається в транзакції і відкочується ROLLBACK-ом. TRUNCATE не перебирає рядки, тому й працює швидко, і тригери на видалення не смикає.

    Транзакційність TRUNCATE: дві різні відповіді

    Тут найдорожча діалектна розбіжність усієї глави, тож подаємо обидві поведінки явно, без «типової»:

    • PostgreSQL. TRUNCATE транзакційно безпечний щодо даних у таблицях: якщо транзакція навколо не закомітилась, усічення буде коректно відкочене. BEGINTRUNCATEROLLBACK справді повертає дані на місце. (Опція RESTART IDENTITY теж відкочується — послідовності повертаються туди, де були.)
    • MySQL/InnoDB. TRUNCATE спричиняє неявний commit і саме тому відкату не підлягає. Довідник MySQL класифікує його як DDL, а не DML: задля швидкодії команда обходить DML-механізм видалення й фізично скидає та перестворює таблицю.

    Обидві доки праві: PostgreSQL має транзакційний DDL, MySQL — ні. Стандарт цього не вирішує; PostgreSQL сам зазначає, що частину конкурентної поведінки цієї команди стандарт лишає на розсуд реалізації.

    Чому QA це коштує дорого: прибирання тестових даних, написане як BEGINTRUNCATE → перевірка → ROLLBACK, на PostgreSQL-стенді працює як задумано, а на MySQL-стенді знищує дані незворотно — і жодної помилки при цьому не буде. Той самий скрипт, той самий вигляд логів, різний результат.

    Лічильники автоінкремента теж поводяться по-різному: MySQL при TRUNCATE скидає AUTO_INCREMENT на початок — навіть для MyISAM та InnoDB, які зазвичай значення послідовності не перевикористовують; PostgreSQL за замовчуванням послідовності не чіпає (для скидання є опція RESTART IDENTITY).

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

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

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

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

    Тому в СУБД є атомарний . У 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. Деталі синтаксису тут другорядні — важливо розуміти, навіщо це: 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», а насправді інша механіка: не фільтрує рядки, не запускає тригери на видалення, по-різному поводиться з лічильниками автоінкремента, а головне — у PostgreSQL відкочується, у MySQL ні.
    • Виглядає як «прибирання в транзакції безпечне, якщо що — відкочу», а насправді це правда лише на PostgreSQL: у MySQL TRUNCATE дає неявний commit, тож ROLLBACK після нього нічого не поверне, а помилки не буде.
    • Виглядає як містика «видалив одного користувача — зникли його замовлення й документи», а насправді ON DELETE CASCADE на ланцюжку зовнішніх ключів. Перед видаленням дивіться схему.
    • Виглядає як надійний патерн «спершу SELECT перевірю, чи існує, потім INSERT», а насправді гонка: у паралельному середовищі між двома запитами запис встигає створити хтось інший. Атомарне рішення — upsert.
    • Виглядає як зайва параноя «навіщо перелік стовпців в INSERT, і так працює», а насправді страховка від зміни схеми: без переліку додавання стовпця ламає вставку або мовчки зсуває значення не в ті поля.

    Підсумок

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

    Можливі питання

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

    Джерела

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

    • PostgreSQL 18 — INSERT — три джерела рядків для вставки (VALUES, запит, DEFAULT VALUES), явний перелік стовпців і що дістають стовпці поза переліком.
    • PostgreSQL 18 — 5.5. Constraints — чому вставка падає на NOT NULL без дефолту: обмеження перевіряє сама база.
    • PostgreSQL 18 — Chapter 6. Data Manipulation — місце INSERT у розділі про зміну даних і його межі.

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

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

    • PostgreSQL 18 — 6.2. Updating DataSET задає нове значення, WHERE — які рядки; без умови оновлюються всі рядки таблиці.
    • MySQL 8.4 — 15.2.17 UPDATE Statement — та сама поведінка в MySQL і кількість зачеплених рядків як результат команди.
    • PostgreSQL 18 — psql — чому «страхувальна сітка» вмикається явно: кожна окрема команда без BEGIN іде як самостійна транзакція.

    DELETE vs TRUNCATE vs DROP

    • PostgreSQL 18 — TRUNCATE — бік PostgreSQL: усічення транзакційно безпечне щодо даних і відкочується разом із транзакцією; RESTART IDENTITY, CASCADE і пряма заувага, що частину поведінки стандарт лишає на розсуд реалізації.
    • MySQL 8.4 — TRUNCATE TABLE Statement — бік MySQL: неявний коміт і неможливість відкату, класифікація як DDL, а не DML, і скидання AUTO_INCREMENT.
    • PostgreSQL 18 — 6.3. Deleting DataDELETE як звичайна DML-операція з умовою; без WHERE таблиця лишається порожньою.
    • MySQL 8.4 — 15.2.2 DELETE Statement — те саме в MySQL, разом із поведінкою тригерів на видалення рядка.
    • PostgreSQL 18 — DROP TABLE — третій член порівняння: зникає сама таблиця разом з індексами, правилами й обмеженнями.
    • PostgreSQL 18 — 5.5. Constraints — чому таблицю під зовнішніми ключами не можна усікти без CASCADE.

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

    • PostgreSQL 18 — INSERTON CONFLICT … DO UPDATE / DO NOTHING і псевдотаблиця EXCLUDED зі значеннями, які пропонувалося вставити.
    • MySQL 8.4 — INSERT ... ON DUPLICATE KEY UPDATE Statement — той самий upsert у MySQL і застереження джерела не вживати його на таблицях із кількома унікальними індексами.
    • PostgreSQL 18 — MERGE — стандартна альтернатива: одна команда, що обирає між вставкою й оновленням.
    • PostgreSQL 18 — 5.5. Constraints — саме UNIQUE і робить наївний «SELECT, потім INSERT» гонкою, яка падає на дублікаті.

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

    • PostgreSQL 18 — 5.5. Constraints — дії ON DELETE: дефолтна заборона, CASCADE, SET NULL; і критерій вибору дії прямо з доки.
    • MySQL 8.0 — FOREIGN KEY Constraints — бік MySQL: те саме розкладання дій і окреме застереження, що каскадні дії зовнішнього ключа тригерів не активують.

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

    • PostgreSQL 18 — SELECT — чому «спершу подивись, потім міняй» узагалі працює: SELECT даних не змінює, а права на читання видаються окремо від прав на запис.
    • PostgreSQL 18 — 6.3. Deleting Data — форма точкової чистки DELETE … WHERE з умовою на власний префікс замість повного скидання таблиці.
    • PostgreSQL 18 — TRUNCATE — чому масові скидання узгоджують: TRUNCATE бере ексклюзивне блокування на таблицю й зачіпає всіх, хто з нею працює.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.