Приложение А1 — PL/pgSQL: функции, процедуры, триггеры

Логика внутри сервера: когда она оправдана, как её писать и чего она стоит

Прогресс курса Приложение, сверх программы

Что вы освоите в этом приложении

Сверх программы. Приложение не входит в 72 часа курса и изучается самостоятельно. Опирается на разделы 6, 7, 8 и 11. Раздел 4 ссылается сюда, когда правило целостности нельзя выразить ограничением.
01

Логика в базе: когда оправдана

Функция, процедура и триггер выполняются внутри сервера, в транзакции того запроса, который их вызвал. Отсюда и сила, и цена: правило работает для любого клиента, но спрятано от того, кто читает код приложения.

Оправдано

  • Правило целостности, которое не выражается ограничением и должно соблюдаться для любого клиента: приложения, скрипта миграции, ручной правки в pgAdmin. Например, правило, сравнивающее старое и новое значение строки.
  • Журнал изменений. Запись в журнал идёт в той же транзакции, что и изменение: сохранятся либо обе, либо ни одна.
  • Операция из многих запросов, где каждый сетевой обмен с приложением заметно удлиняет транзакцию и время удержания блокировок.
Цена. Команда UPDATE users SET city = … в коде приложения не сообщает, что на таблице висят три триггера. Код в базе хуже тестируется и выкладывается только миграциями (раздел 7). PL/pgSQL не переносится в MySQL — при смене СУБД его переписывают вручную (раздел 12). Построчный триггер выполняется для каждой строки, и массовая загрузка замедляется в разы (задание 4).
Порядок выбора тот же, что в разделе 4: тип данных → NOT NULL, CHECK, UNIQUE, FOREIGN KEY, EXCLUDE → триггер → код приложения. Что выражается ограничением, триггером не делают: ограничение видно в схеме, проверяет уже существующие строки при добавлении и не содержит кода, в котором можно ошибиться.
02

Функции на SQL

Тело функции на LANGUAGE sql — один или несколько запросов; возвращается результат последнего. Параметры доступны по имени, у параметра может быть значение по умолчанию.

CREATE OR REPLACE FUNCTION fnc_users(pgender varchar DEFAULT 'female')
RETURNS SETOF users
LANGUAGE sql
STABLE
AS $$
    SELECT * FROM users WHERE gender = pgender ORDER BY id;
$$;

SELECT * FROM fnc_users();                              -- 6 строк: значение по умолчанию
SELECT * FROM fnc_users('male');                        -- 4 строки
SELECT name FROM fnc_users() WHERE city = 'Moscow';   -- Anna, Maria
idnameagegendercity
2Anna16femaleMoscow
5Elvira45femaleKazan
6Irina21femaleSaint-Petersburg
7Kate33femaleNovosibirsk
8Natalie30femaleNovosibirsk
9Maria28femaleMoscow

SETOF users — «произвольное число строк типа users». Такую функцию вызывают во FROM, как таблицу: результат можно отфильтровать, соединить, сгруппировать.

Перегрузка и ловушка значения по умолчанию

Функция в PostgreSQL определяется не именем, а именем и типами параметров. CREATE OR REPLACE с другим списком параметров не заменяет старую функцию, а создаёт вторую рядом.

CREATE FUNCTION fnc_users() RETURNS SETOF users
LANGUAGE sql AS $$ SELECT * FROM users $$;       -- создаётся без ошибок

SELECT * FROM fnc_users();
-- ERROR:  function fnc_users() is not unique
-- HINT:  Could not choose a best candidate function. You might need to add explicit type casts.

DROP FUNCTION fnc_users;
-- ERROR:  function name "fnc_users" is not unique
-- HINT:  Specify the argument list to select the function unambiguously.

DROP FUNCTION fnc_users();     -- удаляется функция без параметров
Вызов без аргументов подходит обеим функциям: одной — точно, другой — через значение по умолчанию. Сервер не выбирает между ними и сообщает об этом только при вызове. Типичная авария: миграция добавила параметр со значением по умолчанию через CREATE OR REPLACE, старая версия осталась, и все вызовы из приложения начали падать. Меняя список параметров, старую функцию удаляют в той же миграции.

Категория изменчивости

Что сообщают планировщику

  • VOLATILE — по умолчанию. Функция может менять данные или давать разный результат при одинаковых аргументах: вызывается заново для каждой строки.
  • STABLE — данные не меняет; в пределах одного запроса при одинаковых аргументах результат одинаков. Функции, читающие таблицы, и now().
  • IMMUTABLE — результат зависит только от аргументов, таблиц функция не читает: lower(), арифметика. Только такие функции допустимы в функциональном индексе (раздел 11).
Пометка IMMUTABLE на функции, которая читает таблицу, при создании ошибки не вызовет. Индекс по такой функции хранит значения на момент вычисления, и после изменения таблицы поиск по индексу возвращает неверные строки. Не уверены — оставляйте VOLATILE: это медленнее, но всегда правильно.
03

VARIADIC — переменное число аргументов

Параметр VARIADIC принимает любое число значений одного типа; внутри функции они доступны как массив.

CREATE OR REPLACE FUNCTION func_minimum(VARIADIC arr numeric[])
RETURNS numeric
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT min(x) FROM unnest(arr) AS x;
$$;

SELECT func_minimum(10, 5, 8);                  -- 5
SELECT func_minimum(900, 600, 1200, 750);       -- 600
SELECT func_minimum(VARIADIC ARRAY[3, 1, 2]);    -- 1: готовый массив передают с VARIADIC

Правила

  • Параметр VARIADIC — последний в списке.
  • Все значения приводятся к одному типу элемента массива.
  • Хотя бы одно значение передать обязательно: пустой вызов func_minimum() не находит функцию.
04

PL/pgSQL: переменные, условия, исключения

PL/pgSQL нужен, когда в функции есть решения: условия, проверки, циклы. Тело — блок DECLARE … BEGIN … END. Функция ниже оценивает вовлечённость сообщества по среднему score постов.

CREATE OR REPLACE FUNCTION fnc_community_level(p_community_id integer)
RETURNS text
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    v_name  varchar(100);
    v_avg   numeric;
BEGIN
    SELECT c.name, avg(p.score)
    INTO   v_name, v_avg
    FROM   communities c
    LEFT JOIN posts p ON p.community_id = c.id
    WHERE  c.id = p_community_id
    GROUP BY c.name;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'сообщество % не найдено', p_community_id
            USING ERRCODE = 'no_data_found';
    END IF;

    IF v_avg IS NULL THEN
        RETURN v_name || ': постов нет';
    ELSIF v_avg >= 1000 THEN
        RETURN v_name || ': высокая';
    ELSIF v_avg >= 850 THEN
        RETURN v_name || ': средняя';
    ELSE
        RETURN v_name || ': низкая';
    END IF;
END;
$$;

SELECT id, fnc_community_level(id) FROM communities ORDER BY id;

SELECT fnc_community_level(99);
-- ERROR:  сообщество 99 не найдено
idfnc_community_levelсредний score
1TechTalks: низкая750
2GamersHub: средняя950
3PhotoWorld: средняя900
4TravelDiaries: низкая825
5FoodLovers: высокая1050
6SportLife: высокая1066,67

Что здесь происходит

  • DECLARE объявляет переменные; := — присваивание.
  • SELECT … INTO записывает результат запроса в переменные.
  • FOUND — истина, если последний запрос вернул или затронул хотя бы одну строку.
  • RAISE EXCEPTION прерывает функцию и всю транзакцию, как любая ошибка сервера. ERRCODE задаёт код, по которому приложение отличит «не найдено» от сбоя. RAISE NOTICE выводит сообщение без прерывания — для отладки.
Две ловушки. Первая: SELECT … INTO молча берёт первую строку, если запрос вернул несколько, и оставляет переменные NULL, если ни одной. SELECT … INTO STRICT в обоих случаях выдаёт ошибку — пишите его, когда строка должна быть ровно одна.

Вторая: переменная с именем столбца. Объявите переменную name, и запрос SELECT name FROM communities в теле функции упадёт с column reference "name" is ambiguous: неясно, столбец это или переменная. Отсюда приставки p_ у параметров и v_ у переменных.
05

RETURN QUERY — набор строк из PL/pgSQL

RETURNS TABLE объявляет столбцы результата, RETURN QUERY добавляет к нему строки запроса. Функция ниже возвращает посты сообществ, которые пользователь просматривал в указанный день, со score ниже порога. Перед запросом — проверка, ради которой здесь нужен PL/pgSQL: функция из одного запроса короче на LANGUAGE sql.

CREATE OR REPLACE FUNCTION fnc_user_views_on_date(
    p_user_id   integer,
    p_max_score integer,
    p_date      date
)
RETURNS TABLE (community_name text, post_title text, post_score integer)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM users WHERE id = p_user_id) THEN
        RAISE EXCEPTION 'пользователь % не найден', p_user_id
            USING ERRCODE = 'no_data_found';
    END IF;

    RETURN QUERY
        SELECT c.name::text, p.title::text, p.score
        FROM   views v
        JOIN   communities c ON c.id = v.community_id
        JOIN   posts p       ON p.community_id = c.id
        WHERE  v.user_id   = p_user_id
          AND  v.viewed_at = p_date
          AND  p.score     < p_max_score
        ORDER BY p.score;
END;
$$;

SELECT * FROM fnc_user_views_on_date(4, 800, '2026-01-08');
community_namepost_titlepost_score
TechTalkstutorial Python basics600

Dmitriy (id 4) открывал ленту TechTalks 8 января; из двух постов сообщества ниже порога только один.

Типы столбцов запроса должны совпадать с объявленными. c.name имеет тип varchar, а объявлен text — без приведения ::text вызов падает с structure of query does not match function result type. Имена столбцов в RETURNS TABLE — тоже переменные функции: назовите столбец score, и любое неуточнённое упоминание score в теле даст ошибку неоднозначности из пункта 04.
06

Процедуры: фиксация по частям

Функция выполняется внутри транзакции вызвавшего её запроса и завершить эту транзакцию не может. Процедура вызывается командой CALL и может выполнять COMMIT и ROLLBACK прямо в теле. Это нужно пакетным операциям, которые нельзя держать одной транзакцией: долгая транзакция держит блокировки строк и не даёт очистке убирать старые версии (раздел 6).

CREATE OR REPLACE PROCEDURE prc_recalc_ratings()
LANGUAGE plpgsql
AS $$
DECLARE
    r record;
BEGIN
    FOR r IN SELECT id FROM communities ORDER BY id LOOP
        UPDATE communities
        SET    rating = (SELECT round(avg(score) / 250, 1)
                         FROM   posts WHERE community_id = r.id)
        WHERE  id = r.id;
        COMMIT;
    END LOOP;
END;
$$;

CALL prc_recalc_ratings();
SELECT id, name, rating FROM communities ORDER BY id;
idnamerating
1TechTalks3.0
2GamersHub3.8
3PhotoWorld3.6
4TravelDiaries3.3
5FoodLovers4.2
6SportLife4.3
BEGIN;
CALL prc_recalc_ratings();
-- ERROR:  invalid transaction termination
ROLLBACK;
COMMIT в процедуре работает, только если CALL выполнен вне явной транзакции. В pgAdmin по умолчанию режим автофиксации, и вызов пройдёт; приложение, которое обернуло вызов в транзакцию, получит ошибку выше. COMMIT недоступен и внутри блока с обработчиком EXCEPTION.
Цикл по строкам здесь оправдан только фиксацией после каждой итерации. Без неё это один UPDATE … FROM из раздела 8, и цикл медленнее его в десятки раз. И обратная сторона фиксации по частям: если процедура упадёт на середине, уже зафиксированные итерации останутся. Процедуру проектируют так, чтобы её можно было безопасно запустить повторно (задание 3).
07

Триггеры: как устроены

Триггер связывает функцию с событием на таблице: вставкой, изменением, удалением. Функция возвращает тип trigger и создаётся отдельно от самого триггера — одну функцию можно навесить на несколько таблиц.

ВидЧто можетВозвращаемое значение
BEFORE … FOR EACH ROWизменить строку до записи, запретить операциюNEW (для удаления — OLD) — строка записывается; NULL — операция над этой строкой молча пропускается
AFTER … FOR EACH ROWвидеть записанную строку, писать в другие таблицыигнорируется, принято NULL
… FOR EACH STATEMENTвыполниться один раз на команду, даже если строк нольигнорируется

Переменные внутри триггерной функции

  • NEW — новая версия строки; при удалении NULL.
  • OLD — старая версия строки; при вставке NULL.
  • TG_OP'INSERT', 'UPDATE', 'DELETE' или 'TRUNCATE'.
  • TG_TABLE_NAME, TG_WHEN — таблица и момент срабатывания.

Правило «возраст пользователя не уменьшается» ограничением CHECK не выразить: CHECK видит только новую версию строки, а правило сравнивает старую и новую. Это работа триггера BEFORE. Заодно он убирает пробелы по краям названия города.

CREATE OR REPLACE FUNCTION trg_users_before_update()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF NEW.age < OLD.age THEN
        RAISE EXCEPTION 'возраст пользователя % нельзя уменьшить: % → %',
            OLD.id, OLD.age, NEW.age;
    END IF;
    NEW.city := trim(NEW.city);
    RETURN NEW;
END;
$$;

CREATE TRIGGER users_before_update
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION trg_users_before_update();

UPDATE users SET age = 20 WHERE id = 1;
-- ERROR:  возраст пользователя 1 нельзя уменьшить: 21 → 20

UPDATE users SET city = '  Moscow ' WHERE id = 1;
SELECT city, length(city) FROM users WHERE id = 1;   -- Moscow, 6
Самая дорогая ошибка в триггере BEFORERETURN NULL, скопированный из триггера AFTER. Команда завершается без ошибки с UPDATE 0, изменения не происходит, и приложение об этом не узнаёт. Если же функция дошла до конца вообще без RETURN, ошибка будет: control reached end of trigger procedure without RETURN.
08

Журнал изменений на триггере AFTER

Журнал хранит старую и новую версию строки целиком в jsonb: добавление столбца в users не потребует менять ни таблицу журнала, ни функцию.

CREATE TABLE users_audit (
    id         bigint      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    changed_at timestamptz NOT NULL DEFAULT now(),
    changed_by text        NOT NULL DEFAULT current_user,
    operation  char(1)     NOT NULL CHECK (operation IN ('I', 'U', 'D')),
    user_id    integer     NOT NULL,
    old_row    jsonb,
    new_row    jsonb
);

CREATE OR REPLACE FUNCTION trg_users_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO users_audit (operation, user_id, old_row, new_row)
    VALUES (left(TG_OP, 1),
            CASE WHEN TG_OP = 'INSERT' THEN NEW.id ELSE OLD.id END,
            CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END,
            CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END);
    RETURN NULL;
END;
$$;

CREATE TRIGGER users_audit_ins_del
AFTER INSERT OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION trg_users_audit();

-- изменение без фактической разницы в журнал не пишется
CREATE TRIGGER users_audit_upd
AFTER UPDATE ON users
FOR EACH ROW WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION trg_users_audit();

Проверка: вставка, изменение, изменение «на то же значение», удаление.

INSERT INTO users (name, age, gender, city)
VALUES ('Alex', 28, 'male', 'Yekaterinburg')
RETURNING id;                                        -- 11

UPDATE users SET city = 'Novosibirsk' WHERE id = 11;
UPDATE users SET city = city;                        -- UPDATE 11, в журнал ничего
DELETE FROM users WHERE id = 11;

SELECT id, operation, user_id,
       old_row ->> 'city' AS old_city,
       new_row ->> 'city' AS new_city
FROM   users_audit
ORDER BY id;
idoperationuser_idold_citynew_city
1I11NULLYekaterinburg
2U11YekaterinburgNovosibirsk
3D11NovosibirskNULL
Запись в журнал идёт в той же транзакции, что и изменение. Откатили UPDATE — исчезла и запись о нём; упала вставка в журнал — откатится и само изменение. Журнал, который пишет приложение отдельным запросом, такой гарантии не даёт. Упорядочивают журнал по id, а не по времени: now() возвращает время начала транзакции, и все записи одной транзакции получают одинаковую отметку.
09

Права, сопровождение и риски

SECURITY DEFINER

Функция по умолчанию выполняется с правами того, кто её вызвал. С SECURITY DEFINER — с правами владельца. Так роли дают одно ограниченное действие без прав на таблицу: роль app_user из раздела 6 может удалить лайки пользователя, но не может выполнить произвольный DELETE.

CREATE OR REPLACE FUNCTION fnc_delete_user_likes(p_user_id integer)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
    v_deleted integer;
BEGIN
    DELETE FROM public.likes WHERE user_id = p_user_id;
    GET DIAGNOSTICS v_deleted = ROW_COUNT;
    RETURN v_deleted;
END;
$$;

-- право на выполнение по умолчанию есть у всех: сначала отнять
REVOKE EXECUTE ON FUNCTION fnc_delete_user_likes(integer) FROM PUBLIC;
GRANT  EXECUTE ON FUNCTION fnc_delete_user_likes(integer) TO app_user;
Без SET search_path функция SECURITY DEFINER — дыра. Вызывающий создаёт в доступной ему схеме таблицу или функцию с тем же именем, что используется в теле, ставит свою схему первой в пути поиска — и код владельца выполняет подменённый объект с правами владельца. Схема временных таблиц pg_temp доступна на запись всем и по умолчанию просматривается первой, поэтому её указывают в пути явно и последней. Второе, что забывают: EXECUTE на новую функцию выдан PUBLIC, и без REVOKE вызвать её может любая роль.

Сопровождение

-- функции и процедуры своей схемы; в psql то же — \df
SELECT proname, prokind            -- f — функция, p — процедура
FROM   pg_proc
WHERE  pronamespace = 'public'::regnamespace
ORDER BY proname;

-- триггеры таблицы; в psql они видны в выводе \d users
SELECT tgname FROM pg_trigger
WHERE  tgrelid = 'users'::regclass AND NOT tgisinternal;

-- тип результата через CREATE OR REPLACE не меняется
CREATE OR REPLACE FUNCTION fnc_community_level(p_community_id integer)
RETURNS varchar LANGUAGE sql AS $$ SELECT 'x'::varchar $$;
-- ERROR:  cannot change return type of existing function

-- функцию, на которой висит триггер, не удалить
DROP FUNCTION trg_users_audit();
-- ERROR:  cannot drop function trg_users_audit() because other objects depend on it

DROP TRIGGER users_audit_upd ON users;
DROP TRIGGER users_audit_ins_del ON users;
DROP FUNCTION trg_users_audit();

Риски, которые проявляются не сразу

  • Массовые операции. Построчный триггер вызывается для каждой строки, COPY тоже его вызывает, TRUNCATE — нет (раздел 8). Для пакетной загрузки журнал пишут триггером уровня команды с таблицей переходов: AFTER INSERT ON likes REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT — одна вставка в журнал на всю команду.
  • Цепочки. Триггер, изменяющий другую таблицу, запускает её триггеры. По коду приложения такую цепочку не восстановить; перед изменением таблицы смотрите список её триггеров.
  • Версии кода. Функции живут в базе, а не в репозитории. Любое изменение — миграцией (раздел 7), иначе на тестовом и боевом сервере окажутся разные версии, и никто не узнает какие.
  • Параллельная работа. Триггер выполняется в транзакции команды и подчиняется тем же правилам изоляции, что и она (раздел 11). Проверка «такой строки ещё нет» в триггере без блокировки пропускает дубликаты при одновременных вставках — ровно так же, как проверка в приложении.

Ключевые выводы приложения

Запомните главное

  • Что выражается ограничением, триггером не делают
  • Функция определяется именем и типами параметров; CREATE OR REPLACE с другими параметрами создаёт вторую функцию
  • Две функции, подходящие под вызов, дают ошибку только при вызове
  • IMMUTABLE на функции, читающей таблицу, портит индексы молча
  • SELECT … INTO STRICT — когда строка должна быть ровно одна
  • Приставки p_ и v_ защищают от конфликта имён переменных и столбцов
  • Процедура может фиксировать по частям; после сбоя зафиксированное остаётся
  • BEFORE возвращает NEW; RETURN NULL молча отменяет операцию
  • Правило, сравнивающее старое и новое значение, — работа триггера, не CHECK
  • Журнал на триггере пишется в той же транзакции, что и изменение
  • SECURITY DEFINER — только с SET search_path и REVOKE … FROM PUBLIC
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 13: Итоговый проект Все разделы курса