Пошук уроків, статей та іншого контенту
Реалізуйте soft delete через ознаку видалення, часові мітки та правила роботи з активними даними.
М’яке видалення, або soft delete, не прибирає рядок із таблиці фізично. Замість цього запис позначається як видалений і перестає належати до активних даних.
Переваги такого підходу:
запис можна відновити;
зберігається історія даних;
не ламаються посилання на старі записи;
можна аналізувати, коли саме запис було видалено.
Недолік — видалений запис фізично залишається в таблиці. Тому всі запити до активних даних повинні враховувати правило м’якого видалення.
Найчастіше використовують два поля:
is_deleted — ознака видалення;
deleted_at — час видалення.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
is_deleted boolean NOT NULL DEFAULT false,
deleted_at timestamptz NULL,
CONSTRAINT users_delete_state_check
CHECK (
(is_deleted = false AND deleted_at IS NULL)
OR
(is_deleted = true AND deleted_at IS NOT NULL)
)
);Обмеження CHECK не дозволяє створити суперечливий стан:
is_deleted = false, але deleted_at заповнене;
is_deleted = true, але deleted_at порожнє.
Для часу варто використовувати тип timestamptz. Він зберігає момент часу з урахуванням часового поясу, що важливо для застосунків, якими користуються в різних часових зонах.
Новий запис за замовчуванням є активним:
INSERT INTO users (email, name)
VALUES ('olena@example.com', 'Олена');М’яке видалення виконується через UPDATE:
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE id = 1
AND is_deleted = false;Умова is_deleted = false захищає від повторного «видалення» вже видаленого запису.
CURRENT_TIMESTAMP повертає поточний час транзакції. У межах однієї транзакції це значення залишається стабільним, що зазвичай є бажаною поведінкою для операцій із базою даних.
Активним вважається запис, для якого:
is_deleted = false
AND deleted_at IS NULLТому звичайний запит повинен містити відповідний фільтр:
SELECT id, email, name
FROM users
WHERE is_deleted = false
AND deleted_at IS NULL;Видалені записи можна отримати окремо:
SELECT id, email, name, deleted_at
FROM users
WHERE is_deleted = true
AND deleted_at IS NOT NULL;Не варто покладатися лише на deleted_at IS NULL, якщо в схемі є обидва поля. Явна перевірка двох умов робить правило зрозумілим і захищає від помилок у разі пошкодження даних.
Щоб не повторювати фільтр у багатьох запитах, можна створити представлення:
CREATE VIEW active_users AS
SELECT id, email, name
FROM users
WHERE is_deleted = false
AND deleted_at IS NULL;Тепер приклад запиту до активних користувачів виглядає простіше:
SELECT id, email, name
FROM active_users
ORDER BY id;Представлення не копіює дані. Воно щоразу виконує запит до таблиці users, тому зміна стану запису одразу відображається в active_users.
Водночас представлення не забороняє випадково звернутися до базової таблиці. На рівні застосунку потрібно домовитися, що для звичайних операцій використовуються активні дані, а таблиця users напряму — лише для спеціальних сценаріїв.
Наступний приклад можна виконати в PostgreSQL. Він створює тимчасову таблицю, додає записи, м’яко видаляє одного користувача і показує різницю між активними та всіма записами.
CREATE TEMP TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
is_deleted boolean NOT NULL DEFAULT false,
deleted_at timestamptz NULL,
CONSTRAINT users_delete_state_check
CHECK (
(is_deleted = false AND deleted_at IS NULL)
OR
(is_deleted = true AND deleted_at IS NOT NULL)
)
);
INSERT INTO users (email, name)
VALUES
('olena@example.com', 'Олена'),
('andrii@example.com', 'Андрій'),
('marta@example.com', 'Марта');
-- М’яко видаляємо користувача з id = 2.
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE id = 2
AND is_deleted = false;
-- Усі записи, включно з видаленими.
SELECT id, email, name, is_deleted, deleted_at
FROM users
ORDER BY id;
-- Лише активні записи.
SELECT id, email, name
FROM users
WHERE is_deleted = false
AND deleted_at IS NULL
ORDER BY id;
-- Лише видалені записи.
SELECT id, email, name, deleted_at
FROM users
WHERE is_deleted = true
AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC;М’яке видалення дає змогу відновити запис. Для цього потрібно одночасно скинути обидва поля:
UPDATE users
SET
is_deleted = false,
deleted_at = NULL
WHERE id = 2
AND is_deleted = true;Операція також повинна повертати запис у стан, узгоджений з обмеженням CHECK. Не можна змінювати лише одне з полів.
Іноді відновлення може бути неможливим через бізнес-правила. Наприклад, після видалення користувача його електронну адресу могли призначити іншому активному користувачу.
Припустімо, що активні користувачі повинні мати унікальні адреси електронної пошти. Звичайне обмеження:
UNIQUE (email)заборонить створити нового користувача з адресою видаленого користувача. Для soft delete часто потрібна інша поведінка: адресу видаленого запису можна використати повторно.
У PostgreSQL для цього використовують частковий унікальний індекс:
CREATE UNIQUE INDEX users_active_email_unique
ON users (email)
WHERE is_deleted = false
AND deleted_at IS NULL;Тепер:
два активні записи з однаковим email неможливі;
активний і видалений запис з однаковим email можливі;
два видалені записи з однаковим email також можливі.
Приклад:
INSERT INTO users (email, name)
VALUES ('old@example.com', 'Перший користувач');
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE email = 'old@example.com'
AND is_deleted = false;
-- Адресу видаленого запису можна використати повторно.
INSERT INTO users (email, name)
VALUES ('old@example.com', 'Новий користувач');Під час відновлення першого запису операція може завершитися помилкою унікальності, якщо активний запис із такою адресою вже існує. Це очікувана поведінка: перед відновленням потрібно перевірити конфлікти з активними даними.
Зміна стану запису та пов’язані перевірки повинні виконуватися атомарно. Наприклад:
BEGIN;
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE id = 1
AND is_deleted = false;
COMMIT;Якщо операція складається з кількох змін, які мають виконатися разом, усі їх потрібно розмістити в одній транзакції. У разі помилки можна скасувати зміни:
BEGIN;
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE id = 1
AND is_deleted = false;
-- Якщо наступна перевірка або операція завершилася помилкою:
ROLLBACK;Після ROLLBACK запис повернеться до попереднього стану.
Для передбачуваної роботи системи варто зафіксувати такі правила:
Нові записи створюються з is_deleted = false і deleted_at = NULL.
Видалення виконується через UPDATE, а не через DELETE.
Під час видалення одночасно встановлюються is_deleted = true і deleted_at.
Звичайні запити фільтрують лише активні записи.
Відновлення одночасно встановлює is_deleted = false і deleted_at = NULL.
Для унікальності тільки активних записів використовуються часткові унікальні індекси.
Фізичне видалення виконується окремою адміністративною або архівною процедурою, а не звичайною операцією користувача.
DELETEDELETE FROM users
WHERE id = 1;Такий запит фізично видаляє рядок і суперечить підходу soft delete. Для м’якого видалення потрібно використовувати UPDATE.
is_deletedSELECT *
FROM users
WHERE is_deleted = false;Якщо дані пошкоджені або запис був створений некоректно, такий запит може повернути рядок із заповненим deleted_at. Надійніше використовувати обидві умови:
SELECT *
FROM users
WHERE is_deleted = false
AND deleted_at IS NULL;UPDATE users
SET is_deleted = true
WHERE id = 1;Такий запит порушить обмеження CHECK, адже deleted_at залишиться порожнім.
Правильний варіант:
UPDATE users
SET
is_deleted = true,
deleted_at = CURRENT_TIMESTAMP
WHERE id = 1;SELECT COUNT(*)
FROM users;Цей запит порахує також видалені записи. Для кількості активних користувачів потрібен фільтр:
SELECT COUNT(*)
FROM users
WHERE is_deleted = false
AND deleted_at IS NULL;CREATE UNIQUE INDEX users_email_unique
ON users (email);Такий індекс враховує і видалені записи. Якщо потрібно дозволити повторне використання значення після soft delete, індекс має містити умову для активних рядків.
М’яке видалення зберігає запис у таблиці, але виключає його з активних даних. Практична схема може містити:
is_deleted як ознаку видалення;
deleted_at як час видалення;
CHECK-обмеження для узгодженості цих полів;
часткові індекси для правил унікальності активних записів;
представлення або стандартизовані запити для роботи лише з активними даними.
Головне правило — видалення, відновлення та читання даних повинні послідовно дотримуватися одного й того самого визначення активного запису.