Зміна даних: 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, тобто кожен запит фіксується миттєво, і кнопки «скасувати» після цього не існує. Виконали — все, дані змінені для всіх, хто працює зі стендом.
Тому в роботі з живими стендами діє ритуал, який варто довести до автоматизму:
Словами: спочатку SELECT ... WHERE ... з тією самою умовою — очима перевірити, що саме зачепить зміна. Потім відкрити транзакцію (BEGIN), виконати UPDATE, подивитись на кількість зачеплених рядків (affected rows — її показує будь-який клієнт) і лише тоді COMMIT. Якщо очікували 1 рядок, а зачепило 20 000 — ROLLBACK, і нічого не сталося. Як саме працюють транзакції і чому відкат можливий — у главі про транзакції та ACID; тут досить знати цей захисний патерн.
Кількість зачеплених рядків — корисний і в інший бік: UPDATE ... 0 rows виглядає як успіх (помилки ж немає), але найчастіше означає, що WHERE нікого не знайшов — одрук у значенні, не той стенд, дані вже змінені кимось іншим.
DELETE vs TRUNCATE vs DROP
Три способи «прибрати дані» плутають постійно, а ціна плутанини — незворотно порожня таблиця. Розводяться вони за трьома осями: що саме зникає, чи можна вибрати частину і що з відкатом.
| Аспект | DELETE | TRUNCATE | DROP TABLE |
|---|---|---|---|
| Що робить | Видаляє рядки | Миттєво спорожняє таблицю | Видаляє саму таблицю |
WHERE | Так | Ні, тільки все | Ні |
| Структура таблиці | Лишається | Лишається | Зникає разом з індексами й обмеженнями |
| Тригери на видалення рядка | Спрацьовують | Не спрацьовують | — |
| Швидкість на великій таблиці | Повільно, рядок за рядком | Швидко | Швидко |
| Категорія | DML | MySQL класифікує як DDL, а не DML | DDL |
| Відкат у транзакції | Так | 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транзакційно безпечний щодо даних у таблицях: якщо транзакція навколо не закомітилась, усічення буде коректно відкочене.BEGIN→TRUNCATE→ROLLBACKсправді повертає дані на місце. (ОпціяRESTART IDENTITYтеж відкочується — послідовності повертаються туди, де були.) - MySQL/InnoDB.
TRUNCATEспричиняє неявний commit і саме тому відкату не підлягає. Довідник MySQL класифікує його як DDL, а не DML: задля швидкодії команда обходить DML-механізм видалення й фізично скидає та перестворює таблицю.
Обидві доки праві: PostgreSQL має транзакційний DDL, MySQL — ні. Стандарт цього не вирішує; PostgreSQL сам зазначає, що частину конкурентної поведінки цієї команди стандарт лишає на розсуд реалізації.
Чому QA це коштує дорого: прибирання тестових даних, написане як BEGIN → TRUNCATE → перевірка → 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) при створенні отримує правило поведінки на випадок, коли батьківський запис видаляють, — і саме воно вирішує, що станеться:
За замовчуванням 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» і слово «ідемпотентність» стосовно підготовки даних.
- «Видаляємо користувача — що станеться з його замовленнями?» — правильна відповідь починається з «залежить від правила на зовнішньому ключі» і перелічує варіанти. Хто відповідає одним сценарієм — схему бачив здалеку.
- «Чи можна змінювати дані напряму в базі тестового стенда?» — питання про зрілість: очікують «можна, але...» з дисципліною змін, а не браваду чи категоричне «ніколи».
Джерела
- PostgreSQL 18 — INSERT — три джерела рядків для вставки (
VALUES, запит,DEFAULT VALUES), явний перелік стовпців і що дістають стовпці поза переліком. - PostgreSQL 18 — 5.5. Constraints — чому вставка падає на
NOT NULLбез дефолту: обмеження перевіряє сама база. - PostgreSQL 18 — Chapter 6. Data Manipulation — місце
INSERTу розділі про зміну даних і його межі.
- PostgreSQL 18 — 8.1. Numeric Types — у PostgreSQL:
serialіGENERATED … AS IDENTITYповерх послідовності. - PostgreSQL 18 — 6.4. Returning Data from Modified Rows —
RETURNINGвіддає стовпці щойно зміненого рядка й працює однаково дляINSERT,UPDATEіDELETE. - PostgreSQL 18 — INSERT — синтаксис
RETURNINGу самій команді вставки. - MySQL 8.4 — INSERT ... ON DUPLICATE KEY UPDATE Statement — звідки беруться «дірки» в автоінкременті: видане значення не повертається назад.
- PostgreSQL 18 — 6.2. Updating Data —
SETзадає нове значення,WHERE— які рядки; без умови оновлюються всі рядки таблиці. - MySQL 8.4 — 15.2.17 UPDATE Statement — та сама поведінка в MySQL і кількість зачеплених рядків як результат команди.
- PostgreSQL 18 — psql — чому «страхувальна сітка» вмикається явно: кожна окрема команда без
BEGINіде як самостійна транзакція.
- PostgreSQL 18 — TRUNCATE — бік PostgreSQL: усічення транзакційно безпечне щодо даних і відкочується разом із транзакцією;
RESTART IDENTITY,CASCADEі пряма заувага, що частину поведінки стандарт лишає на розсуд реалізації. - MySQL 8.4 — TRUNCATE TABLE Statement — бік MySQL: неявний коміт і неможливість відкату, класифікація як DDL, а не DML, і скидання
AUTO_INCREMENT. - PostgreSQL 18 — 6.3. Deleting Data —
DELETEяк звичайна DML-операція з умовою; безWHEREтаблиця лишається порожньою. - MySQL 8.4 — 15.2.2 DELETE Statement — те саме в MySQL, разом із поведінкою тригерів на видалення рядка.
- PostgreSQL 18 — DROP TABLE — третій член порівняння: зникає сама таблиця разом з індексами, правилами й обмеженнями.
- PostgreSQL 18 — 5.5. Constraints — чому таблицю під зовнішніми ключами не можна усікти без
CASCADE.
- PostgreSQL 18 — INSERT —
ON 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бере ексклюзивне блокування на таблицю й зачіпає всіх, хто з нею працює.
Що таке DML і чим зміна даних небезпечніша за читання?
DML (data manipulation language) — це команди, що змінюють вміст таблиць: INSERT, UPDATE, DELETE. Різниця з SELECT принципова: читання нічого не ламає, найгірший його наслідок — повільний запит, а от одна команда зміни без WHERE здатна зіпсувати дані всього спільного стенда — і розгрібати наслідки доведеться всій команді. Більшість клієнтів працює в режимі autocommit — кожен запит фіксується миттєво, кнопки «скасувати» після цього немає. Саме тому в багатьох компаніях QA має на прод лише read-only доступ: хто має право на UPDATE, той має і всі шанси на аварію. Без DML тестувальнику все одно нікуди: підготувати дані, відтворити баг «саме з таким станом», прибрати за автотестом — усе це зміна даних.
Навіщо в INSERT явно перелічувати стовпці, якщо можна передати значення по порядку?
Явний перелік стовпців — дешева страховка від зміни схеми. Форма без переліку (INSERT INTO users VALUES (...)) прив'язана до фізичного порядку колонок: варто комусь додати в таблицю новий стовпець — і всі такі вставки або почнуть падати, або, що гірше, мовчки розкладуть значення не в ті поля. Другий бонус — самодокументація: запит із переліком читається без звірки зі схемою, одразу видно, що саме вставляється. Це той випадок, де кілька зайвих слів у запиті економлять годину розслідування.
Що станеться зі стовпцями, яких немає в переліку INSERT?
Вони отримають значення за замовчуванням (DEFAULT) або NULL, якщо дефолту немає. Якщо ж на стовпці стоїть обмеження NOT NULL без дефолту, вставка впаде з помилкою — у PostgreSQL це null value in column ... violates not-null constraint. Для QA це не перешкода, а перший шар перевірок цілісності: обмеження в базі — теж вимоги, і їх теж тестують. Тобто «вставка не пройшла через NOT NULL» — це не завжди баг вашого запиту, іноді це база коректно захищає інваріант, який хтось намагається порушити.
Що робить INSERT ... SELECT і чому його небезпечно запускати наосліп на живому стенді?
INSERT ... SELECT бере значення не зі списку VALUES, а з результату запиту — стандартний спосіб розмножити або скопіювати зріз у таблицю-. Небезпека в тому, що вставиться рівно стільки рядків, скільки поверне SELECT: може бути нуль, а може мільйон, і ви це побачите вже постфактум. Тому перед INSERT ... SELECT на живому стенді той самий SELECT варто запустити окремо й подивитися на обсяг. Це той самий принцип, що й із WHERE в UPDATE: спершу очима переконайся, скільки рядків зачепить операція, а вже потім виконуй.
Чому id у таблиці можуть іти дірками (1, 2, 5, 9)? Це баг «записи зникають»?
Ні, це нормальне життя автоінкремента. Послідовність (sequence) віддає чергове значення ще до завершення вставки. Якщо відкотилась або INSERT упав на обмеженні — видане значення назад не повертається, воно просто пропадає, лишаючи дірку. Тому 1, 2, 5, 9 — не «у нас зникають записи», а сліди відкатів і невдалих вставок. Ключова теза: автоінкремент гарантує унікальність і зростання, але не суцільність. Якщо хтось заводить баг «стрибають id», перше питання — а чи справді зникли самі рядки, чи це лише пропуски в нумерації.
Як отримати id щойно вставленого рядка?
Залежить від СУБД — це типове діалектне питання. У PostgreSQL найелегантніше через RETURNING: вставка одразу повертає вказані стовпці створеного рядка (INSERT ... RETURNING id, created_at). У MySQL конструкції RETURNING немає — після вставки викликають функцію LAST_INSERT_ID(), а в SQL Server схожу роль грає OUTPUT. Сам механізм є скрізь, різниться синтаксис. Практичний сенс задачі: вставив рядок — треба його id, щоб передати далі (у наступний запит, в API-виклик, у автотеста). Саме тому RETURNING у — стандартний спосіб зробити тест, який уміє прибрати за собою: створив користувача, забрав id, у teardown видалив рівно його. Бонус — RETURNING працює також з UPDATE і DELETE, показуючи, що саме ви змінили чи видалили.
Що станеться, якщо виконати UPDATE без WHERE? Як цьому запобігти?
WHERE в UPDATE синтаксично необов'язковий, тому UPDATE users SET status = 'blocked' — валідний запит, що змінить статус кожного рядка таблиці. Жодного «ви впевнені?» база не покаже, а в режимі autocommit зміна зафіксується миттєво — скасувати вже не вийде. Запобігання — це робочий ритуал, а не одне визначення: спершу SELECT з тією самою умовою WHERE, щоб очима побачити, що саме зачепить зміна; потім BEGIN (відкрити транзакцію); потім UPDATE; потім подивитися на affected rows — якщо очікували 1 рядок, а зачепило 20 000, то ROLLBACK, і нічого не сталося; і лише коли число збігається — COMMIT. На співбесіді інтерв'юер слухає саме цей ритуал, а не переказ визначення: він перевіряє реальний досвід зі стендами.
UPDATE відпрацював без помилки, але affected rows = 0. Це успіх?
Формально помилки немає, але майже завжди це означає, що WHERE нікого не знайшов — і зміна просто не відбулась. Причини типові: одрук у значенні умови, не те середовище, або дані вже змінені кимось іншим раніше. Тому кількість зачеплених рядків — корисний в обидва боки: забагато рядків сигналить про надто широкий WHERE, нуль рядків — про надто вузький або хибний. Практичний висновок: у скриптах і перевірках орієнтуйтеся на affected rows, а не лише на відсутність помилки, інакше «зелений» результат маскуватиме те, що операція нічого не зробила.
Чим DELETE відрізняється від TRUNCATE?
DELETE — звичайна DML-операція: проходить рядок за рядком, підтримує WHERE (можна видалити частину), для кожного рядка спрацьовують тригери на видалення, усе відбувається в транзакції і відкочується ROLLBACK-ом. TRUNCATE миттєво спорожняє всю таблицю: WHERE він не підтримує (тільки все), рядки не перебирає (тому й швидкий на великих таблицях), тригери на видалення не смикає. Слабка відповідь на співбесіді — «TRUNCATE швидший»; сильна розкладає по осях: вибірковість, тригери, транзакційність, лічильники автоінкремента. Є ще важлива діалектна різниця, і називати її треба з обох боків: у PostgreSQL TRUNCATE транзакційно безпечний щодо даних і відкотиться, якщо транзакція навколо не закомітилась, а в MySQL/InnoDB він спричиняє неявний commit — відкату не підлягає, і сам довідник MySQL класифікує його як DDL, а не DML.
Чим TRUNCATE відрізняється від DROP TABLE?
TRUNCATE прибирає рядки, але лишає саму таблицю з її структурою, індексами й обмеженнями — після нього таблиця порожня, але існує. DROP TABLE знищує саму таблицю: зникають і дані, і структура, і індекси, і обмеження — об'єкта більше немає. TRUNCATE за поведінкою ближчий до DDL, DROP — чистий DDL і інструмент міграцій та адміністрування. Для QA практичний висновок: повне скидання таблиць у своїй ізольованій базі перед прогоном — TRUNCATE, бо швидко й таблиця лишається; а DROP у ручній роботі тестувальника робити нічого — ним керують міграції.
Чи можна відкотити TRUNCATE транзакцією?
Залежить від СУБД, і це класична пастка, тому відповідати треба обома діалектами одразу. У PostgreSQL TRUNCATE транзакційно безпечний щодо даних: усічення буде коректно відкочене, якщо транзакція навколо не закомітилась, тож BEGIN ... ROLLBACK справді повертає дані на місце (разом із RESTART IDENTITY, якщо його вказали). У MySQL/InnoDB TRUNCATE спричиняє неявний commit — він фіксується одразу і відкату не підлягає; довідник MySQL і класифікує його як DDL, а не DML, бо задля швидкодії команда обходить DML-механізм видалення й перестворює таблицю. Обидві доки праві: PostgreSQL має транзакційний DDL, MySQL — ні, а стандарт частину поведінки цієї команди лишає на розсуд реалізації. Практичне правило: не покладайтеся на відкат TRUNCATE без певності щодо конкретної бази — на MySQL «спробую в транзакції, а якщо що, відкочу» знищує дані незворотно і без жодної помилки. Заодно лічильники автоінкремента поводяться по-різному — MySQL при TRUNCATE скидає AUTO_INCREMENT на початок, PostgreSQL за замовчуванням послідовності не чіпає (для скидання є RESTART IDENTITY).
Чому TRUNCATE таблиці іноді не спрацьовує, а база повертає помилку?
Найчастіша причина — на цю таблицю посилаються з інших таблиць. TRUNCATE таку таблицю просто так не спорожнить: інакше в залежних таблицях лишилися б , що посилаються на вже неіснуючі рядки, а це порушення цілісності. У PostgreSQL є два виходи: усікати пов'язані таблиці разом однією командою, або явно написати TRUNCATE ... CASCADE — і тоді спорожніють і залежні таблиці. Останнє — гарна ілюстрація, чому руйнівні команди треба читати двічі: CASCADE тихо винесе дані з таблиць, які ви прямо не називали.
Що таке upsert і яку проблему він вирішує?
— атомарна операція «вставити, а якщо запис уже є — оновити». Він вирішує проблему гонки в наївному підході «спершу SELECT, чи існує, потім INSERT»: між вашим SELECT і INSERT той самий запис може створити хтось інший (паралельний тест, інший інженер), і ваш INSERT упаде на унікальному обмеженні з duplicate key value violates unique constraint. У PostgreSQL upsert — це INSERT ... ON CONFLICT (...) DO UPDATE (або DO NOTHING), у MySQL — INSERT ... ON DUPLICATE KEY UPDATE, у стандарті SQL — команда MERGE. Головне не синтаксис, а навіщо: upsert робить підготовку даних ідемпотентною (idempotent) — повторний запуск не падає і не плодить дублікатів. Для автотестів це властивість номер один.
Видаляємо користувача — що станеться з його замовленнями?
Правильна відповідь починається з «залежить від правила на зовнішньому ключі», а не з одного сценарію. За замовчуванням FK поводиться як заборона (RESTRICT / NO ACTION): видалити користувача, на якого посилаються замовлення, не вийде — база поверне помилку порушення зовнішнього ключа (у PostgreSQL SQLSTATE 23503). ON DELETE CASCADE розвертає логіку: видалення батька автоматично зносить усіх нащадків, і ланцюжок може бути довгим — користувач → замовлення → позиції замовлень. ON DELETE SET NULL — компроміс: діти живуть далі, але посилання обнуляється (orders.user_id = NULL). Для тестувальника наслідок прямий: ваш «маленький» DELETE одного рядка на стенді з каскадами може тихо винести половину пов'язаних даних, тому схему FK треба знати до видалення, а не з'ясовувати після.
Що таке сирітські записи (orphaned records) і звідки вони беруться?
Сирітські записи — рядки, що логічно посилаються на батьківську сутність, якої вже немає. Найчастіша причина — коли зовнішнього ключа в схемі взагалі немає (так роблять заради швидкості або через легасі): база не боронить видалити батька, дочірні рядки лишаються висіти в порожнечу і потім ламають звіти та JOIN-и (рядок просто випадає з внутрішнього з'єднання). Це протилежність каскаду: там FK є і чистить нащадків автоматично, тут FK немає і ніхто не чистить. Пошук сирітських записів — типова перевірка цілісності бази, яку QA робить окремим запитом (LEFT JOIN з умовою, що батьківський ключ IS NULL). Це один із класів багів «дані ніби є, а звіти брешуть».
Чи можна змінювати дані напряму в базі тестового стенда?
Це питання про зрілість, і категоричне «ніколи» тут така сама слабка відповідь, як і бравада «та легко». Правильно — «можна, але з дисципліною». На проді — тільки читання: змінювати дані руками табу навіть із найкращими намірами, для цього існують міграції і support-процедури з рев'ю. На стенді зміни припустимі, але за правилами: де можливо — через застосунок (спрацює вся логіка: валідації, хешування пароля, події), прямий SQL лише коли потрібен стан, якого штатним шляхом не досягти; свої дані помічати унікальними префіксами (qa.autotest+...@example.com), щоб прибирання DELETE ... WHERE email LIKE 'qa.autotest%' не зачепило чужого; будь-яка ручна зміна — через ритуал SELECT → транзакція → перевірка → COMMIT; масові зміни (TRUNCATE, чистки) — тільки узгоджено з командою, бо спільний стенд ділять розробники, інші тестувальники й нічний прогін .
Три кейси, де зміна даних вирішує, чи зіпсуєш ти спільний стенд: захисний ритуал UPDATE у сесії psql, Playwright- на RETURNING з ідемпотентним і , і рішення «DELETE чи TRUNCATE» з перевіркою каскадів. Скрізь — що робити і чому.
Кейс 1. Ритуал безпечного UPDATE на живому стенді
Треба заблокувати одного користувача на спільному стенді. Спокуса — написати UPDATE users SET status = 'blocked' WHERE ... і натиснути Enter. Проблема: у режимі autocommit помилка в WHERE фіксується миттєво й без попередження. Тому спершу дивимось очима, а міняємо в .
-- 1) ТОЙ САМИЙ WHERE, але SELECT: очима переконатись, кого зачепить
SELECT id, email, status FROM users WHERE email = 'qa.test@example.com';
-- повернуло рівно 1 рядок — те, що треба
-- 2) відкриваємо транзакцію
BEGIN;
-- 3) сама зміна
UPDATE users SET status = 'blocked' WHERE email = 'qa.test@example.com';
-- клієнт друкує: UPDATE 1 ← affected rows = 1, збігається з очікуванням
-- 4) число збіглося → фіксуємо
COMMIT;
А тепер той самий сценарій, коли в WHERE закрався одрук (status замість email):
BEGIN;
UPDATE users SET status = 'blocked' WHERE status = 'active';
-- клієнт друкує: UPDATE 20431 ← очікували 1, а зачепило 20 тисяч!
ROLLBACK; -- нічого не сталося, стенд цілий
Що дивитися і чому:
SELECTтією ж умовою — до, а не після. Він відповідає на єдине важливе питання: скільки й яких саме рядків зачепить зміна. Побачив несподіване число — виправивWHERE, і жоден рядок ще не зіпсовано.- affected rows — в обидва боки.
UPDATE 20431замістьUPDATE 1— надто широкийWHERE;UPDATE 0— надто вузький або хибний (одрук у значенні, не той стенд, дані вже змінені кимось). Відсутність помилки ще не означає, що зміна зробила те, що ви хотіли. BEGINперетворює незворотне на зворотне. Поки немаєCOMMIT, будь-яку катастрофу скасовуєROLLBACK. Це і є перетворення синтаксично необов'язковогоWHEREна практично обов'язкову дисципліну.
Кейс 2. Playwright-фікстура: RETURNING для прибирання, upsert для ідемпотентності
Фікстурі потрібні дві властивості: забрати id створеного рядка (щоб прибрати рівно його) і не падати на повторному прогоні. Перше дає RETURNING, друге — .
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// фікстура створює користувача напряму в БД і видаляє його після тесту
const test = base.extend<{ userId: number }>({
userId: async ({}, use) => {
// унікальний email на кожен прогін замість фіксованого
const email = `qa.autotest+${Date.now()}@example.com`;
const { rows } = await pool.query(
`INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`,
[email, 'Fixture User'],
);
const id = rows[0].id; // цей id і використовуємо, і видаляємо
await use(id);
// teardown: прибираємо рівно свій рядок за відомим id
await pool.query(`DELETE FROM users WHERE id = $1`, [id]);
},
});
test('заблокований користувач не бачить кабінет', async ({ page, userId }) => {
// ... тест користується userId ...
});
Довідкові дані, які мають існувати перед тестом (тарифи, ролі, ), сідять ідемпотентним upsert-ом — тоді повторний запуск не падає на унікальному обмеженні:
-- ON CONFLICT робить сід ідемпотентним: другий запуск не впаде і не подвоїть рядок
INSERT INTO plans (code, title, price)
VALUES ('qa_trial', 'QA Trial', 0)
ON CONFLICT (code) DO UPDATE SET title = EXCLUDED.title, price = EXCLUDED.price;
Що дивитися і чому:
RETURNING idзамість другого запиту. Одна вставка одразу віддає згенерований ключ — не треба післяINSERTокремо шукати «а який же id мені видало». У MySQL той самий сенс даєLAST_INSERT_ID(), у SQL Server —OUTPUT.- Унікальний email рятує від гонки прибирання. Фіксований email на кожен прогін означає
duplicate keyпри паралельних або коли попередній прогін не прибрав за собою. Унікальний суфікс (таймстемп, а ще надійніше — UUID) прибирає колізію в корені. - Upsert — не для рядків тесту, а для довідників. Дані, які тест очікує побачити, готують ідемпотентно; дані, які тест створює, роблять унікальними й видаляють у teardown. Це різні задачі з різними інструментами.
Кейс 3. DELETE чи TRUNCATE — і чому спершу дивляться на FK
Перед прогоном треба очистити таблицю orders у своїй базі. Здається, що вибір між DELETE FROM orders і TRUNCATE TABLE orders — лише про швидкість. Насправді він про каскади й транзакції.
| Ситуація | Правильний інструмент | Чому |
|---|---|---|
| Точкова чистка на спільному стенді | DELETE ... WHERE ... | вибірковість, відкат, свої дані по префіксу |
| Повне скидання таблиці у своїй ізольованій базі | TRUNCATE | швидко, структура лишається |
| Прибрати саму таблицю (міграція) | DROP TABLE | інструмент міграцій, у ручній роботі QA не потрібен |
Пастка чекає, якщо на orders посилаються order_items. TRUNCATE TABLE orders відмовить (cannot truncate a table referenced in a foreign key constraint), бо лишилися б . У PostgreSQL є CASCADE — але він тихо винесе й order_items, які ви прямо не називали:
-- відмовить, поки є FK з order_items
TRUNCATE TABLE orders;
-- спорожнить І orders, І order_items — читати двічі перед виконанням
TRUNCATE TABLE orders CASCADE;
А після ручних видалень корисно перевірити, чи не лишилось сирітських записів — типова перевірка цілісності, якщо FK у схемі немає:
-- позиції замовлень, що посилаються на неіснуюче замовлення
SELECT oi.id, oi.order_id
FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL; -- порожньо = сирітських записів немає
Що дивитися і чому:
TRUNCATE— не «швидкий DELETE». Він не фільтрує рядки, не запускає тригери на видалення, а лічильник автоінкремента скидає в MySQL і не чіпає в PostgreSQL. Плутати їх у скрипті — джерело сюрпризів.- Захисна дужка
BEGIN/ROLLBACKнавколоTRUNCATEпрацює лише на одному з двох стендів. У PostgreSQL усічення транзакційно безпечне щодо даних і відкотиться, якщо транзакція не закомітилась. У MySQL/InnoDB воно дає неявний commit —ROLLBACKпісля нього не поверне нічого, і помилки ви теж не побачите. Той самий скрипт прибирання на MySQL-стенді знищує дані незворотно. CASCADEтреба читати двічі. І уTRUNCATE ... CASCADE, і вON DELETE CASCADEна FK одна команда може зачепити дерево таблиць. «Видалив одного користувача — зникли його замовлення й документи» — це не містика, а налаштований каскад.LEFT JOIN ... IS NULL— детектор сирітських записів. Коли FK у схемі немає (легасі, оптимізація), цілісність ніхто не боронить автоматично, і цей запит ловить рядки, що вже нікуди не посилаються, до того як вони зламають звіт.
INSERT і вставка даних
- Розумію, що DML (
INSERT/UPDATE/DELETE) змінює дані, аSELECT— ні, і чому саме тому QA на проді часто read-only. - Можу пояснити, чому явний перелік стовпців в
INSERT— страховка від зміни схеми, а форма без переліку мовчки зсуває значення. - Знаю, що неперелічені стовпці отримують
DEFAULTабоNULL, аNOT NULLбез дефолту валить вставку помилкою обмеження. - Розумію, що
INSERT ... SELECTвставляє стільки рядків, скільки повернеSELECT, тому спершу той самийSELECTтреба запустити окремо.
Автоінкремент і RETURNING
- Можу пояснити, що автоінкремент гарантує унікальність і зростання, але не суцільність — дірки в id після відкатів і невдалих вставок це норма.
- Знаю, як забрати згенерований id (
RETURNINGу PostgreSQL,LAST_INSERT_ID()у MySQL,OUTPUTу SQL Server) і навіщо це в автотестах — передати далі й прибрати за собою в teardown.
UPDATE і безпека змін
- Пам'ятаю, що
WHEREвUPDATE/DELETEсинтаксично необов'язковий, а autocommit фіксує зміну миттєво без «ви впевнені?». - Можу відтворити захисний ритуал:
SELECTтією ж умовою →BEGIN→ зміна → перевірка affected rows →COMMITабоROLLBACK. - Розумію, що
UPDATEз affected rows = 0 — це не успіх, а сигнал, щоWHEREнікого не знайшов (одрук, не той стенд, дані вже змінені).
DELETE vs TRUNCATE vs DROP
- Знаю різницю
DELETEvsTRUNCATE: вибірковість (WHERE), тригери на видалення, швидкість на великій таблиці, категорія (DML проти DDL у класифікації MySQL). - Знаю різницю
TRUNCATEvsDROP:TRUNCATEлишає порожню таблицю зі структурою,DROPзнищує саму таблицю з індексами й обмеженнями. - Знаю діалектні пастки
TRUNCATEз обох боків: у PostgreSQL усічення безпечне щодо даних і відкотиться, якщо транзакція не закомітилась; у MySQL/InnoDB — неявний commit без відкату. Лічильники теж різняться — MySQL скидаєAUTO_INCREMENT, PostgreSQL не чіпає безRESTART IDENTITY. - Не пишу як
BEGIN→TRUNCATE→ перевірка →ROLLBACKбез певності щодо СУБД: на MySQL воно знищує дані незворотно й без жодної помилки. - Розумію, чому
TRUNCATEтаблиці під відмовляє і щоCASCADEтихо спорожнить і залежні таблиці.
Upsert та ідемпотентність
- Можу пояснити гонку в «спершу
SELECT, потімINSERT» (падіння на унікальному обмеженні в паралельному середовищі) і знаю атомарні -и проти неї:INSERT ... ON CONFLICT(PostgreSQL),INSERT ... ON DUPLICATE KEY UPDATE(MySQL),MERGE(стандарт). - Розумію, що upsert робить ідемпотентним: повторний запуск не падає і не плодить дублікатів — властивість номер один для автотестів.
Каскади зовнішніх ключів
- Знаю три правила FK на видалення батька:
RESTRICT/NO ACTION(заборона, помилка23503),CASCADE(зносить нащадків),SET NULL(обнуляє посилання). - Розумію обидва боки FK при видаленні: з каскадом
DELETEодного рядка зносить дерево пов'язаних даних (тому схему FK знаю до видалення), без FK — лишає (orphaned records), що ламають звіти іJOIN-и.
Дисципліна на спільних стендах
- Розумію правило read-only на проді: якщо доступ технічно дозволяє
UPDATEна проді — вважаю, що не дозволяє. - Знаю дві практики безпечних даних на стенді: створювати через застосунок (API/UI — спрацьовує вся логіка: валідації, хешування, події), а свої записи мітити унікальним префіксом, щоб прибирання
DELETE ... LIKE 'qa.autotest%'не зачепило чужого. - Пам'ятаю, що масові зміни (
TRUNCATE, чистки, перезаливання довідників) — тільки узгоджено з командою і заздалегідь.
Квіз
Перед стартом
- Питань: 12
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Що таке DML і чим зміна даних небезпечніша за SELECT?
