Каскадное Софт Удалить - PullRequest
15 голосов
/ 03 февраля 2009

SQL всегда имел отличную особенность: каскадное удаление. Вы планируете это заранее, и когда придет время что-то удалить, БАМ! Не нужно беспокоиться обо всех этих зависимых записях.

Тем не менее, в настоящее время это почти табу, чтобы фактически УДАЛИТЬ что-либо Вы отмечаете это как удаленное и прекращаете показывать это. К сожалению, я не смог найти надежного решения для этого, когда есть зависимые записи. Я всегда вручную кодировал сложную сеть программных удалений.

Есть ли лучшее решение, которое я полностью пропустил?

Ответы [ 5 ]

15 голосов
/ 03 февраля 2009

Мне неприятно это говорить, но триггеры разработаны специально для такого рода вещей.

(часть ненависти в том, что хорошие триггеры очень сложно написать и, конечно, их невозможно отладить)

8 голосов
/ 29 октября 2018

Я недавно предложил решение каскадного мягкого удаления с использованием Postgres 9.6, которое использует наследование для разделения записей на удаленные и не удаленные. Вот копия документа, который я пишу для нашего проекта:


Каскадное софт-удаление

Аннотация

В этом документе я описываю наш текущий подход к удалению объектов в нашей базе данных Postgres и представляю недостатки текущей реализации. Например, пока у нас нет возможности каскадного мягкого удаления. Затем я покажу метод, который сочетает в себе сильные стороны каскадного жесткого удаления Postgres и подход архивирования, который легко реализовать, поддерживать и который повышает производительность во всех поисковых запросах.

О программном удалении в GORM

В проекте fabric8-services / fabric8-wit , написанном на Go, мы используем объектно-ориентированный картограф для нашей базы данных под названием GORM .

GORM предлагает способ мягкого удаления записей базы данных:

Если модель имеет поле DeletedAt, она автоматически получит возможность мягкого удаления! тогда он не будет удален из базы данных навсегда при вызове Delete, а только установит значение поля DeletedAt в текущее время.

Предположим, у вас есть определение модели, другими словами, структура Go, которая выглядит следующим образом:

// User is the Go model for a user entry in the database
type User struct {
    ID        int
    Name      string
DeletedAt *time.Time
}

И скажем, вы загрузили существующую запись пользователя по ее ID из БД в объект u.

id := 123
u := User{}
db.Where("id=?", id).First(&u)

Если вы затем удалите объект, используя GORM:

db.Delete(&u)

запись в БД не будет удалена с помощью DELETE в SQL, но строка будет обновлена, а для deleted_at установлено текущее время:

UPDATE users SET deleted_at="2018-10-12 11:24" WHERE id = 123;

Проблемы с программным удалением в GORM - Инверсия зависимости и отсутствие каскада

Упомянутое выше мягкое удаление удобно для архивации отдельных записей, но может привести к очень странным результатам для всех записей, которые зависят от него. Это связано с тем, что программные удаления GORM не касаются, как потенциальный DELETE в SQL, если бы внешний ключ моделировался с ON DELETE CASCADE.

Когда вы моделируете базу данных, вы обычно создаете таблицу, а затем, может быть, другую, которая имеет внешний ключ к первому:

CREATE TABLE countries (
    name text PRIMARY KEY,
    deleted_at timestamp
);

CREATE TABLE cities (
    name text,
    country text REFERENCES countries(name) ON DELETE CASCADE,
    deleted_at timestamp
);

Здесь мы смоделировали список стран и городов, которые ссылаются на конкретную страну. Когда вы DELETE сделаете запись страны, все города также будут удалены. Но так как в таблице есть столбец deleted_at, который используется в структуре Go для страны или города, средство отображения GORM будет только мягко удалять страну и оставлять соответствующие города без изменений.

Перенос ответственности из БД на пользователя / разработчика

GORM, таким образом, предоставляет разработчикам возможность (софт) удалять все зависимые города. Другими словами, то, что раньше было смоделировано как отношение между городами и странами , теперь переворачивается как отношение из стран к городам . Это связано с тем, что пользователь / разработчик теперь отвечает за (мягкое) удаление всех городов, принадлежащих стране, когда эта страна удаляется.

Предложение

Разве не было бы замечательно, если бы у нас были программные удаления и все преимущества ON DELETE CASCADE?

Оказывается, мы можем получить это без особых усилий. Давайте пока сосредоточимся на одной таблице, а именно на таблице countries.

Архивная таблица

Предположим на секунду, что у нас может быть другая таблица с именем countries_archive, которая имеет такую ​​же структуру , что и таблица countries. Также предположим, что все будущие миграции схемы , которые выполняются для countries, применяются к таблице countries_archive. Единственное исключение - уникальные ограничения и внешние ключи не будут применяться к countries_archive.

Полагаю, это уже звучит слишком хорошо, чтобы быть правдой, верно? Ну, мы можем создать такую ​​таблицу, используя то, что в Postgres называется Inheritenance :

CREATE TABLE countries_archive () INHERITS (countries);

Полученная таблица countries_archive предназначена для хранения всех записей, где deleted_at IS NOT NULL.

Обратите внимание, что в нашем коде Go мы никогда не будем напрямую использовать таблицу _archive. Вместо этого мы бы запросили исходную таблицу, из которой наследуется таблица *_archive, а Postgres затем волшебным образом заглядывает в таблицу *_archive. Чуть ниже я объясню, почему это так; это связано с разбиением.

Перемещение записей в таблицу архива на (мягкое) -DELETE

Поскольку две таблицы countries и countries_archive выглядят совершенно одинаково по схеме, мы можем очень просто INSERT в архив использовать функцию триггера, когда

  1. a DELETE происходит на countries столе
  2. или когда происходит мягкое удаление, установив deleted_at в значение, отличное от NULL.

Функция триггера выглядит следующим образом:

CREATE OR REPLACE FUNCTION archive_record()
RETURNS TRIGGER AS $$
BEGIN
    -- When a soft-delete happens...
    IF (TG_OP = 'UPDATE' AND NEW.deleted_at IS NOT NULL) THEN
        EXECUTE format('DELETE FROM %I.%I WHERE id = $1', TG_TABLE_SCHEMA, TG_TABLE_NAME) USING OLD.id;
        RETURN OLD;
    END IF;
    -- When a hard-DELETE or a cascaded delete happens
    IF (TG_OP = 'DELETE') THEN
        -- Set the time when the deletion happens
        IF (OLD.deleted_at IS NULL) THEN
            OLD.deleted_at := timenow();
        END IF;
        EXECUTE format('INSERT INTO %I.%I SELECT $1.*'
                    , TG_TABLE_SCHEMA, TG_TABLE_NAME || '_archive')
        USING OLD;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

Чтобы связать функцию с триггером, мы можем написать:

CREATE TRIGGER soft_delete_countries
    AFTER
        -- this is what is triggered by GORM
        UPDATE OF deleted_at 
        -- this is what is triggered by a cascaded DELETE or a direct hard-DELETE
        OR DELETE
    ON countries
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();

Выводы

Изначально функциональность наследования в postgres была разработана для данных раздела . Когда вы выполняете поиск в разделенных данных по определенному столбцу или условию, Postgres может выяснить, в каком разделе искать, и тем самым повысить производительность вашего запроса .

Мы можем извлечь выгоду из этого улучшения производительности только путем поиска сущностей, если не указано иное. Записи в существовании те, где deleted_at IS NULL верно. (Обратите внимание, что GORM будет автоматически добавлять AND deleted_at IS NULL к каждому запросу, если в структуре модели GORM есть DeletedAt.)

Давайте посмотрим, знает ли Postgres, как воспользоваться нашим разделением, запустив EXPLAIN:

EXPLAIN SELECT * FROM countries WHERE deleted_at IS NULL;
+-------------------------------------------------------------------------+
| QUERY PLAN                                                              |
|-------------------------------------------------------------------------|
| Append  (cost=0.00..21.30 rows=7 width=44)                              |
|   ->  Seq Scan on countries  (cost=0.00..0.00 rows=1 width=44)          |
|         Filter: (deleted_at IS NULL)                                    |
|   ->  Seq Scan on countries_archive  (cost=0.00..21.30 rows=6 width=44) |
|         Filter: (deleted_at IS NULL)                                    |
+-------------------------------------------------------------------------+

Как мы видим, Postgres все еще ищет обе таблицы, countries и countries_archive. Давайте посмотрим, что произойдет, когда мы добавим проверочное ограничение к таблице countries_archive при создании таблицы:

CREATE TABLE countries_archive (
    CHECK (deleted_at IS NOT NULL)
) INHERITS (countries);

Теперь, Postgres знает, что он может пропустить countries_archive, когда ожидается, что deleted_at будет NULL:

EXPLAIN SELECT * FROM countries WHERE deleted_at IS NULL;
+----------------------------------------------------------------+
| QUERY PLAN                                                     |
|----------------------------------------------------------------|
| Append  (cost=0.00..0.00 rows=1 width=44)                      |
|   ->  Seq Scan on countries  (cost=0.00..0.00 rows=1 width=44) |
|         Filter: (deleted_at IS NULL)                           |
+----------------------------------------------------------------+

Обратите внимание на отсутствие последовательного сканирования таблицы countries_archive в вышеупомянутом EXPLAIN.

Преимущества и риски

Преимущества

  1. Мы регулярно каскадно удаляем назад и можем дать БД понять, в каком порядке удалять вещи.
  2. В то же время мы также архивируем наши данные . Каждое софт-удаление
  3. Нет изменений кода Go не требуется. Нам нужно только настроить таблицу и триггер для каждой таблицы, которая должна быть заархивирована.
  4. Всякий раз, когда мы думаем, что больше не хотим такого поведения с помощью триггеров и каскадного мягкого удаления , мы можем легко вернуться .
  5. Все будущие миграции схемы , которые выполняются в исходной таблице, будут применяться и к версии _archive этой таблицы. За исключением ограничений, что хорошо.

риски

  1. Предположим, вы добавили новую таблицу, которая ссылается на другую существующую таблицу с внешним ключом, который имеет ON DELETE CASCADE. Если в существующей таблице используется функция archive_record(), указанная выше, ваша новая таблица получит жесткие значения DELETE s, когда что-то в существующей таблице будет программно удалено. Это не проблема, если вы также используете archive_record() для своей новой зависимой таблицы. Но ты просто должен помнить это.

Заключительные мысли

Представленный здесь подход не решает проблему восстановления отдельных строк. С другой стороны, этот подход не делает его сложнее или сложнее. Это просто остается нерешенным.

В нашем приложении для определенных полей рабочего элемента не указан внешний ключ. Хорошим примером являются идентификаторы области. Это означает, что когда область имеет значение DELETE d, связанный рабочий элемент не автоматически DELETE d. Существует два сценария, когда область удаляется сама:

  1. У пользователя напрямую запрашивается удаление.
  2. Пользователь запрашивает удаление пробела, а затем область удаляется из-за ограничения внешнего ключа на пробел.

Обратите внимание, что в первом сценарии запросы пользователя проходят через код контроллера области, а затем через код репозитория области. У нас есть шанс на любом из этих слоев изменить все рабочие элементы, которые в противном случае ссылались бы на несуществующую область. Во втором сценарии все, что связано с областью, происходит и остается на уровне БД, поэтому у нас нет шансов изменить рабочие элементы. Хорошей новостью является то, что нам не нужно. Каждый рабочий элемент ссылается на пробел и поэтому будет удален в любом случае, когда пробел исчезнет.

То, что относится к областям, также относится к итерациям, меткам и столбцам доски.

Как подать заявку в нашу базу данных?

Steps

  1. Создание таблиц "* _archived" для всех таблиц, которые наследуют исходные таблицы.
  2. Установите триггер мягкого удаления, используя вышеуказанную функцию archive_record().
  3. Переместите все записи, где deleted_at IS NOT NULL, в соответствующую таблицу _archive, выполнив команду DELETE, которая вызовет функцию archive_record().

Пример

Вот полностью рабочий пример , в котором мы продемонстрировали каскадное мягкое удаление по двум таблицам countries и capitals. Мы покажем, как записи архивируются независимо от метода, который был выбран для удаления.

CREATE TABLE countries (
    id int primary key,
    name text unique,
    deleted_at timestamp
);
CREATE TABLE countries_archive (
    CHECK ( deleted_at IS NOT NULL )
) INHERITS(countries);

CREATE TABLE capitals (
    id int primary key,
    name text,
    country_id int references countries(id) on delete cascade,
    deleted_at timestamp
);
CREATE TABLE capitals_archive (
    CHECK ( deleted_at IS NOT NULL )
) INHERITS(capitals);

CREATE OR REPLACE FUNCTION archive_record()
RETURNS TRIGGER AS $$
BEGIN
    IF (TG_OP = 'UPDATE' AND NEW.deleted_at IS NOT NULL) THEN
        EXECUTE format('DELETE FROM %I.%I WHERE id = $1', TG_TABLE_SCHEMA, TG_TABLE_NAME) USING OLD.id;
        RETURN OLD;
    END IF;
    IF (TG_OP = 'DELETE') THEN
        IF (OLD.deleted_at IS NULL) THEN
            OLD.deleted_at := timenow();
        END IF;
        EXECUTE format('INSERT INTO %I.%I SELECT $1.*'
                    , TG_TABLE_SCHEMA, TG_TABLE_NAME || '_archive')
        USING OLD;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER soft_delete_countries
    AFTER
        UPDATE OF deleted_at 
        OR DELETE
    ON countries
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();

CREATE TRIGGER soft_delete_capitals
    AFTER
        UPDATE OF deleted_at 
        OR DELETE
    ON capitals
    FOR EACH ROW
    EXECUTE PROCEDURE archive_record();

INSERT INTO countries (id, name) VALUES (1, 'France');
INSERT INTO countries (id, name) VALUES (2, 'India');
INSERT INTO capitals VALUES (1, 'Paris', 1);
INSERT INTO capitals VALUES (2, 'Bengaluru', 2);

SELECT 'BEFORE countries' as "info", * FROM ONLY countries;
SELECT 'BEFORE countries_archive' as "info", * FROM countries_archive;
SELECT 'BEFORE capitals' as "info", * FROM ONLY capitals;
SELECT 'BEFORE capitals_archive' as "info", * FROM capitals_archive;

-- Delete one country via hard-DELETE and one via soft-delete
DELETE FROM countries WHERE id = 1;
UPDATE countries SET deleted_at = '2018-12-01' WHERE id = 2;

SELECT 'AFTER countries' as "info", * FROM ONLY countries;
SELECT 'AFTER countries_archive' as "info", * FROM countries_archive;
SELECT 'AFTER capitals' as "info", * FROM ONLY capitals;
SELECT 'AFTER capitals_archive' as "info", * FROM capitals_archive;
6 голосов
/ 03 февраля 2009

Ограничения внешнего ключа могут выполнять каскадные обновления. Если вы связали свои таблицы как с ключом, так и с флагом удаления, то, когда флаг удаления в основной таблице изменился, это изменение распространится вниз до таблицы подробностей. Я этого не пробовал, но это должно сработать.

2 голосов
/ 03 февраля 2009

Я думаю, что преимущество мягкого удаления, как правило, состоит в том, что не каждая таблица имеет флаг мягкого удаления, поэтому количество вещей, которые необходимо каскадировать, мало. Строки просто не используются в базе данных, но не осиротевшие - на них просто ссылаются только удаленные строки.

Как и все, все зависит от вашей модели.

0 голосов
/ 03 февраля 2009

Не знаю, о каком бэкэнде вы говорите, но вы могли бы подхватить изменение «флага удаления» и каскадно изменить его, используя триггер.

...