Зміна даних: 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, тобто кожен запит фіксується миттєво, і кнопки «скасувати» після цього не існує. Виконали — все, дані змінені для всіх, хто працює зі стендом.
Тому в роботі з живими стендами діє ритуал, який варто довести до автоматизму:
Словами: спочатку 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 | Ближче до DDL | DDL |
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) при створенні отримує правило поведінки на випадок, коли батьківський запис видаляють, — і саме воно вирішує, що станеться:
За замовчуванням 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» і слово «ідемпотентність» стосовно підготовки даних.
- «Видаляємо користувача — що станеться з його замовленнями?» — правильна відповідь починається з «залежить від правила на зовнішньому ключі» і перелічує варіанти. Хто відповідає одним сценарієм — схему бачив здалеку.
- «Чи можна змінювати дані напряму в базі тестового стенда?» — питання про зрілість: очікують «можна, але...» з дисципліною змін, а не браваду чи категоричне «ніколи».
Джерела
- PostgreSQL: Data Manipulation — офіційна глава про INSERT/UPDATE/DELETE, включно з Returning Data from Modified Rows.
- PostgreSQL: INSERT — повний синтаксис вставки,
ON CONFLICT(upsert) іRETURNING. - PostgreSQL: TRUNCATE — поведінка з транзакціями, FK і
RESTART IDENTITY. - MySQL: TRUNCATE TABLE — неявний commit і скидання AUTO_INCREMENT.
- MySQL: INSERT ... ON DUPLICATE KEY UPDATE — upsert у діалекті MySQL.
Що таке 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 він спричиняє неявний commit — відкату не підлягає.
Чим TRUNCATE відрізняється від DROP TABLE?
TRUNCATE прибирає рядки, але лишає саму таблицю з її структурою, індексами й обмеженнями — після нього таблиця порожня, але існує. DROP TABLE знищує саму таблицю: зникають і дані, і структура, і індекси, і обмеження — об'єкта більше немає. TRUNCATE за поведінкою ближчий до DDL, DROP — чистий DDL і інструмент міграцій та адміністрування. Для QA практичний висновок: повне скидання таблиць у своїй ізольованій базі перед прогоном — TRUNCATE, бо швидко й таблиця лишається; а DROP у ручній роботі тестувальника робити нічого — ним керують міграції.
Чи можна відкотити TRUNCATE транзакцією?
Залежить від СУБД, і це класична пастка. У PostgreSQL TRUNCATE є транзакційним: його можна виконати всередині BEGIN ... ROLLBACK і скасувати, наче нічого не було. У MySQL (InnoDB) TRUNCATE спричиняє неявний commit — він фіксується одразу і відкату не підлягає, тобто «спробую в транзакції, а якщо що — відкочу» там не спрацює. Тому загальне правило безпеки: не покладайтеся на відкат TRUNCATE без певності щодо конкретної бази. Заодно лічильники автоінкремента поводяться по-різному — MySQL при TRUNCATE скидає AUTO_INCREMENT на початок, PostgreSQL за замовчуванням послідовності не чіпає (для скидання є RESTART IDENTITY).
Чому TRUNCATE таблиці іноді не спрацьовує, а база повертає помилку?
Найчастіша причина — на цю таблицю посилаються зовнішні ключі з інших таблиць. TRUNCATE таку таблицю просто так не спорожнить: інакше в залежних таблицях лишилися б записи-сироти, що посилаються на вже неіснуючі рядки, а це порушення цілісності. У PostgreSQL є два виходи: транкейтити пов'язані таблиці разом однією командою, або явно написати TRUNCATE ... CASCADE — і тоді спорожніють і залежні таблиці. Останнє — гарна ілюстрація, чому руйнівні команди треба читати двічі: CASCADE тихо винесе дані з таблиць, які ви прямо не називали.
Що таке upsert і яку проблему він вирішує?
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, друге — upsert.
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 не відкочується (неявний commit), а лічильник автоінкремента скидає в MySQL і не чіпає в PostgreSQL. Плутати їх у скрипті сідінгу — джерело сюрпризів.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). - Знаю різницю
TRUNCATEvsDROP:TRUNCATEлишає порожню таблицю зі структурою,DROPзнищує саму таблицю з індексами й обмеженнями. - Знаю діалектні пастки
TRUNCATE: у PostgreSQL відкочується в транзакції, у MySQL — неявний commit без відкату; лічильники теж різняться — MySQL скидаєAUTO_INCREMENT, PostgreSQL не чіпає безRESTART IDENTITY. - Розумію, чому
TRUNCATEтаблиці під зовнішніми ключами відмовляє і щоCASCADEтихо спорожнить і залежні таблиці.
Upsert та ідемпотентність
- Можу пояснити гонку в «спершу
SELECT, потімINSERT» (падіння на унікальному обмеженні в паралельному середовищі) і знаю атомарні upsert-и проти неї: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, чистки, перезаливання довідників) — тільки узгоджено з командою і заздалегідь.
Чому явний перелік стовпців в INSERT вважають страховкою, а не зайвим педантизмом?
Питання
Що таке DML і чим зміна даних небезпечніша за SELECT?