SQL и Qt

Триггеры: автоматическая реакция на изменения данных

Введение

Триггер — функция, автоматически выполняемая СУБД при определённом событии модификации данных (INSERT/UPDATE/DELETE) для конкретной таблицы — статья 403 уже использовала триггер для поддержания денормализованного значения; в этой статье разберём механизм триггеров более подробно и систематично.

Концепция

Триггер привязывается к конкретной таблице и конкретному событию (BEFORE/AFTER в комбинации с INSERT/UPDATE/DELETE), и выполняется автоматически каждый раз, когда это событие происходит — BEFORE-триггер выполняется до фактического применения изменения (и может изменить значения, которые будут сохранены, или вовсе отменить операцию), AFTER-триггер выполняется после применения изменения (типично используется для побочных эффектов — логирования, обновления денормализованных значений, как в статье 403). Триггер может выполняться FOR EACH ROW (отдельный вызов для каждой затронутой строки) или FOR EACH STATEMENT (один вызов на всю команду независимо от количества затронутых строк, поддерживается не всеми СУБД одинаково полно).

Пример кода: Триггеры

-- BEFORE-триггер — валидация и автоматическое изменение значения перед сохранением
CREATE OR REPLACE FUNCTION validate_order_amount()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.total_amount < 0 THEN
        RAISE EXCEPTION 'Сумма заказа не может быть отрицательной: %', NEW.total_amount;
    END IF;

    -- Автоматическое заполнение временной метки при создании, если не указана явно
    IF NEW.created_at IS NULL THEN
        NEW.created_at := NOW();
    END IF;

    RETURN NEW; -- для BEFORE-триггера возврат NEW определяет, что реально будет сохранено
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_validate_order
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION validate_order_amount();
-- AFTER-триггер — логирование изменений в отдельную таблицу аудита
CREATE TABLE orders_audit (
    id SERIAL PRIMARY KEY,
    order_id INTEGER,
    action VARCHAR(10),
    old_amount DECIMAL(12, 2),
    new_amount DECIMAL(12, 2),
    changed_at TIMESTAMP DEFAULT NOW()
);

CREATE OR REPLACE FUNCTION audit_order_changes()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'UPDATE' THEN
        INSERT INTO orders_audit (order_id, action, old_amount, new_amount)
        VALUES (NEW.id, 'UPDATE', OLD.total_amount, NEW.total_amount);
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO orders_audit (order_id, action, old_amount, new_amount)
        VALUES (OLD.id, 'DELETE', OLD.total_amount, NULL);
    END IF;
    RETURN NULL; -- для AFTER-триггера возвращаемое значение игнорируется
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_audit_orders
AFTER UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_order_changes();

Пояснения к коду

validate_order_amount() показывает типичное применение BEFORE-триггера — проверка корректности данных (RAISE EXCEPTION прерывает операцию целиком, если сумма заказа отрицательна, что СУБД нельзя обойти случайно из кода приложения, в отличие от проверки, реализованной только на уровне приложения) и автоматическое заполнение значения по умолчанию через изменение NEW перед его фактическим сохранением. TG_OP (специальная переменная, доступная внутри функции триггера, содержащая название операции, вызвавшей срабатывание триггера) позволяет одной функции audit_order_changes() обрабатывать сразу несколько разных событий (UPDATE и DELETE в примере) с разной логикой для каждого, и OLD/NEW (доступные внутри триггера специальные переменные, представляющие значения строки до и после изменения соответственно) дают доступ и к прежнему, и к новому состоянию изменяемой строки.

Подводные камни

  • Чрезмерное усложнение бизнес-логики приложения через каскад из множества взаимосвязанных триггеров, срабатывание одного из которых вызывает изменение, активирующее другой триггер, и так далее — такая цепочка триггеров может стать крайне трудной для понимания и отладки, особенно когда разработчик, читающий код приложения, не видит явно (без отдельного изучения схемы БД), что простая операция UPDATE неявно вызывает целую цепочку дополнительных действий через скрытые от прямого взгляда триггеры; для сложной бизнес-логики стоит взвесить, не лучше ли реализовать её явно в коде приложения, где она более заметна и легче отлаживается.
  • Использование триггеров для логики, критичной по производительности при массовых, объёмных операциях (например, массовая загрузка миллионов строк через INSERT) — если для каждой вставленной строки срабатывает триггер FOR EACH ROW, выполняющий относительно дорогую операцию (как, например, дополнительный запрос к другой таблице), это может значительно замедлить массовую загрузку по сравнению с временным отключением триггера на время такой операции (с последующим явным выполнением эквивалентной логики одним пакетным запросом после загрузки).
  • Отсутствие явного учёта побочных эффектов триггеров при тестировании (статья 439) — если тесты, работающие непосредственно с тестовой БД, не учитывают, что некоторые операции неявно вызывают побочные эффекты через триггеры (изменение других таблиц, как в примере с аудитом), тестовые проверки могут не обнаружить регрессию в логике триггера, либо наоборот — неожиданно «сломаться» из-за побочного эффекта триггера, не очевидного из самого тестируемого кода.
  • Использование RAISE EXCEPTION внутри триггера для валидации, которая дублирует (или, что хуже, противоречит) валидации, уже выполняемой на уровне приложения — наличие проверки одних и тех же бизнес-правил в двух разных местах (код приложения и триггер БД) создаёт риск их рассогласования при изменении правила только в одном из двух мест, и стоит явно решить, где находится единственный, авторитетный источник истины для конкретного правила валидации (или явно задокументировать намеренное дублирование как дополнительный, защитный уровень).