Приветствую! Сегодня поговорим про триггеры в PostgreSQL.

Начнём с базы: триггер в PostgreSQL — это такая функция, которая запускается автоматически при определённом событии в таблице. С триггерами можно автоматизировать массу рутины и освободить приложение от сложных проверок и вычислений, но это палка о двух концах.

Когда использовать триггеры:

  • Для поддержания целостности данных — скажем, обновление зависимых полей updated_at и т. д..

  • Для сложных проверок — если стандартные CHECK и CONSTRAINT недостаточны.

  • Для встроенной бизнес-логики — например, чтобы вести историю изменений.

Простой пример: обновление поля updated_at при изменении записи

Итак, начинаем с примера. У нас есть таблица orders, и мы хотим обновлять поле updated_at при каждом изменении записи.

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    product_name VARCHAR(100),
    quantity INT,
    updated_at TIMESTAMP
);

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at := NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER before_update_orders
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

Просто и удобно. Но можно сделать ещё лучше, добавив проверку WHEN, чтобы обновлять updated_at только при изменении quantity. Экономим ресурсы — не будем гонять базу впустую.

WHEN и условия в триггера

Добавим проверку с помощью условия WHEN, чтобы обновление updated_at происходило только при реальном изменении quantity.

CREATE OR REPLACE FUNCTION conditional_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.quantity IS DISTINCT FROM OLD.quantity THEN
        NEW.updated_at := NOW();
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER conditional_before_update
BEFORE UPDATE ON orders
FOR EACH ROW
WHEN (OLD.quantity IS DISTINCT FROM NEW.quantity)
EXECUTE FUNCTION conditional_update_timestamp();

Теперь триггер не выполняется зря.

BEFORE vs. AFTER триггеры: когда и зачем

BEFORE и AFTER — это не только про момент срабатывания. У них разные задачи:

  • BEFORE триггеры идеальны для проверки и изменения данных перед сохранением.

  • AFTER триггеры полезны для действий, которые зависят от конечного состояния данных (например, логирование).

Пример AFTER триггера для логирования изменений:

CREATE TABLE order_history (
    history_id SERIAL PRIMARY KEY,
    order_id INT,
    changed_quantity INT,
    change_time TIMESTAMP
);

CREATE OR REPLACE FUNCTION log_order_changes()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO order_history (order_id, changed_quantity, change_time)
    VALUES (OLD.order_id, OLD.quantity, NOW());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER after_update_log
AFTER UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION log_order_changes();

Теперь при каждом обновлении мы сохраняем историю изменений. Удобно.

INSTEAD OF триггеры: управление представлениями

INSTEAD OF триггеры полезны для views, позволяя управлять сложными операциями внутри них. Представим, что есть представление employee_salary, и нужно обновлять данные в оригинальной таблице через это представление:

CREATE VIEW employee_salary AS
SELECT id, name, salary FROM employees;

CREATE OR REPLACE FUNCTION update_employee_salary()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE employees SET salary = NEW.salary WHERE id = NEW.id;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER instead_of_update_salary
INSTEAD OF UPDATE ON employee_salary
FOR EACH ROW
EXECUTE FUNCTION update_employee_salary();

Теперь любые изменения в представлении employee_salary транслируются в таблицу employees.

Обработка ошибок в триггерах

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

CREATE OR REPLACE FUNCTION safe_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
    BEGIN
        NEW.updated_at := NOW();
    EXCEPTION
        WHEN OTHERS THEN
            INSERT INTO error_logs (error_message, error_time) 
            VALUES (SQLERRM, NOW());
            RETURN OLD;
    END;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Если что-то пойдёт не так, триггер запишет ошибку в error_logs и продолжит выполнение.

Асинхронные триггеры и фоновые задачи

Для тяжёлых операций лучше вынести задачи в асинхронный процесс. PostgreSQL поддерживает асинхронность с помощью pg_notify, LISTEN и UNLISTEN.

CREATE OR REPLACE FUNCTION notify_log()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify('log_channel', json_build_object(
        'id', NEW.id,
        'action', TG_OP,
        'time', NOW()
    )::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER after_update_notify
AFTER UPDATE ON employees
FOR EACH ROW
EXECUTE FUNCTION notify_log();

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

Триггеры для JSON данных

Сложные структуры данных вроде JSON открывают некоторые возможности для триггеров. Например, триггер может проверять или парсить содержимое JSON, выделяя данные в другие таблицы.

CREATE OR REPLACE FUNCTION validate_json_data()
RETURNS TRIGGER AS $$
BEGIN
    IF NOT (NEW.data ->> 'required_field' IS NOT NULL) THEN
        RAISE EXCEPTION 'Required field is missing in JSON data';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER before_insert_json
BEFORE INSERT ON my_table
FOR EACH ROW
EXECUTE FUNCTION validate_json_data();

Каскадные триггеры и как избежать циклов

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

IF TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW' THEN
    -- Основная логика
END IF;

Примеры использования всего этого дела

Записываем историю изменений

Допустим, есть у нас CRM-система, а клиент в один прекрасный момент начинает задавать вопросики — кто, мол, сменил его e-mail с рабочей почты на некий "lovefluffy72@gmail.com"? Ручками это не отследишь. Тут-то помогут наши триггеры, со своим блокнотом и карандашиком: они будут записывать каждое изменение, и мы навсегда избавимся от ночных звонков с вопросом «кто это сделал?».

CREATE TABLE customer_changes (
    change_id SERIAL PRIMARY KEY,
    customer_id INT,
    old_name VARCHAR(255),
    old_email VARCHAR(255),
    changed_by VARCHAR(255),
    change_time TIMESTAMP
);

CREATE OR REPLACE FUNCTION log_customer_changes()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO customer_changes (customer_id, old_name, old_email, changed_by, change_time)
    VALUES (OLD.customer_id, OLD.name, OLD.email, current_user, NOW());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_customer_changes
AFTER UPDATE ON customers
FOR EACH ROW
EXECUTE FUNCTION log_customer_changes();

Теперь вся история изменений лежит в таблице customer_changes, и всегда есть козырь в рукаве, чтобы ответить, кто, когда и зачем поменял данные.

Заказы

Допустим, есть интернет-магазин, где пользователи обновляют свои заказы по 10 раз на дню. Что произойдет, если кто-то забудет пересчитать общую стоимость заказа после изменения количества товара? Да, это будет ошибка на миллион (ну или хотя бы на хороший ужин). Чтобы этого избежать, триггер перехватывает момент и обновляет поле total_amount на лету. Все счастливы.

CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE orders
    SET total_amount = (
        SELECT SUM(quantity * price)
        FROM order_items
        WHERE order_id = NEW.order_id
    )
    WHERE order_id = NEW.order_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_order_total_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_order_total();

Теперь, сколько бы товаров ни добавляли и удаляли из заказа, total_amount всегда будет показывать правильную сумму.

Проверка наличия товара на складе

А теперь представим страшный сценарий: пользователь оформляет заказ на 100500 единиц товара, а в наличии — две штуки. Триггер проверит, есть ли нужное количество товара, и если нет — сообщит.

CREATE OR REPLACE FUNCTION check_stock_availability()
RETURNS TRIGGER AS $$
BEGIN
    IF (SELECT quantity FROM inventory WHERE product_id = NEW.product_id) < NEW.quantity THEN
        RAISE EXCEPTION 'Insufficient stock for product %', NEW.product_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER before_order_item_insert
BEFORE INSERT ON order_items
FOR EACH ROW
EXECUTE FUNCTION check_stock_availability();

Теперь триггер стал главным контролером на складе. Он не допустит дефицита и убережет нас от неприятных разговоров с клиентом.

Всегда ли нужен триггер?

В некоторых случаях использование триггеров можно заменить бизнес-логикой в приложении или функциями базы данных. Например:

  • Бизнес-логика в коде: если логика не зависит от целостности базы, лучше обрабатывать обновления и логирование на уровне приложения.

  • Встроенные функции PostgreSQL: например, для проверки условий иногда достаточно встроенных механизмов базы данных, например как CHECK CONSTRAINT, вместо создания отдельной триггерной функции.

Спасибо за внимание. Делитесь своими кейсами использования триггеров в комментариях!


Пользуясь случаем, напоминаю про ближайшие открытые уроки по PostgreSQL:

Комментарии (12)


  1. vagon333
    13.11.2024 13:29

    Благодарю! Спер копию в локальный Joplin.

    Давно примеряюсь перенести продукт с MS SQL Server на PostgreSQL.

    В SQL Server использую SQL CLR в триггерах, где сложная логика написана на C-sharp.
    В частности, есть простая, но очень удобная функция отсылки HTTP запроса, что позволяет SQL серверу из триггера Push Notification на дальнейшую сложную обработку вместо Periodic Pull через интервалы времени.

    А как проще всего реализовать HTTP GET/POST в триггере на PostgreSQL?


    1. RekGRpth
      13.11.2024 13:29

      А как проще всего реализовать HTTP GET/POST в триггере на PostgreSQL?

      Вызвать pg_curl (в pg_task, если надо асинхронно)


    1. sirmax123
      13.11.2024 13:29

      а как там с обработкой ошибок если запрос занял много времени? А таких запросов много?

      Я лет несколько не смотрел но как бы себе блокировку не создать? Или если оно умеет порождать отдельный процесс, то форк бомбу?

      Если что я не настоящий сварщик, простой мимокрокодил


      1. vagon333
        13.11.2024 13:29

        Параметр на timeout


    1. cross_join
      13.11.2024 13:29

      Для асинхронной обработки в SQL Server есть несколько стандартных механизмов "из коробки": диспетчер задач (SQL agent), события (create event notification и extended events), брокер (Service Broker).
      Создание триггеров на C# CLR - нерекомендуемая практика, язык Transact SQL достаточно развит, а триггеры поддерживают обработку всего набора данных за один проход. Если вдруг вы уперлись в сложность вычислений, то это верный признак для выноса обработки из СУБД на клиентов (сервисы)
      Прежде чем планировать миграцию, попытайтесь достичь той же производительности и стоимости поддержки, если это является критерием в вашей организации.


  1. Akina
    13.11.2024 13:29

    • AFTER триггеры полезны для действий, которые зависят от конечного состояния данных (например, логирование).

    Логирование вообще-то требует обоих триггеров.

    BEFORE - логирует намерение. Действие (пользователя). Причём логирование должно выполняться в нетранзакционное хранилище, чтобы информация не откатилась при ошибке транзакции. Кстати, а Postgress так умеет?

    AFTER - логирует результат реализации этого намерения, если запрос не привёл к ошибке.


    1. RekGRpth
      13.11.2024 13:29

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

      с расширением https://github.com/omniti-labs/pg_jobmon


      1. Akina
        13.11.2024 13:29

        То есть "из коробки" такой возможности не имеется?

        К слову, а как себя ведёт COPY TO, если файл назначения существует? дописывает? перезаписывает? генерирует ошибку? нет у меня сейчас постгресса, на котором можно было бы попробовать, а официальная документация этот момент как-то стыдливо обходит... Если файл дописывается, то вот оно, нетранзакционное хранилище...


        1. NickNal
          13.11.2024 13:29

          Если писать COPY TO напрямую в файл, то он будет перезаписываться

          Чтобы сделать append, можно:

          • направить COPY TO в STDOUT и перенаправлять STDOUT в целевой файл

          • направить COPY TO в PROGRAM

          copy (select * from table) to PROGRAM 'cat >>/tmp/log.txt'
          


    1. NickNal
      13.11.2024 13:29

      Логирование можно делать через автономные транзакции с помощью встроенного расширения dblink (есть из коробки) - можно писать логи в ту же БД, можно писать в другие БД на том же или другом кластере PostgreSQL

      Ну или любым из FDW для сброса логов в другие типы хранилищ


  1. asmm
    13.11.2024 13:29

    Мощь Postgress в красивой реализации триггеров уровня STATEMENT


  1. Akina
    13.11.2024 13:29

    PostgreSQL поддерживает как per-row triggers, так и per-statement triggers:

    On tables and foreign tables, triggers can be defined to execute either before or after any INSERT, UPDATE, or DELETE operation, either once per modified row, or once per SQL statement.

    Однако о втором типе триггеров в статье - ни полслова. Что, с моей точки зрения, очень плохо. Особенно с учётом того, что триггерные функции у этих типов триггеров просто обязаны различаться. Да и эффективность у этих типов триггеров различна, причём далеко не в пользу per-statement типа.