Логика внутри сервера: когда она оправдана, как её писать и чего она стоит
RAISE EXCEPTION и возвращать наборы строк через RETURN QUERYBEFORE и AFTER и вести журнал измененийSECURITY DEFINERФункция, процедура и триггер выполняются внутри сервера, в транзакции того запроса, который их вызвал. Отсюда и сила, и цена: правило работает для любого клиента, но спрятано от того, кто читает код приложения.
UPDATE users SET city = … в коде приложения не сообщает, что на таблице висят три триггера. Код в базе хуже тестируется и выкладывается только миграциями (раздел 7). PL/pgSQL не переносится в MySQL — при смене СУБД его переписывают вручную (раздел 12). Построчный триггер выполняется для каждой строки, и массовая загрузка замедляется в разы (задание 4).NOT NULL, CHECK, UNIQUE, FOREIGN KEY, EXCLUDE → триггер → код приложения. Что выражается ограничением, триггером не делают: ограничение видно в схеме, проверяет уже существующие строки при добавлении и не содержит кода, в котором можно ошибиться.Тело функции на 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
| id | name | age | gender | city |
|---|---|---|---|---|
| 2 | Anna | 16 | female | Moscow |
| 5 | Elvira | 45 | female | Kazan |
| 6 | Irina | 21 | female | Saint-Petersburg |
| 7 | Kate | 33 | female | Novosibirsk |
| 8 | Natalie | 30 | female | Novosibirsk |
| 9 | Maria | 28 | female | Moscow |
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: это медленнее, но всегда правильно.Параметр 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() не находит функцию.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 не найдено
| id | fnc_community_level | средний score |
|---|---|---|
| 1 | TechTalks: низкая | 750 |
| 2 | GamersHub: средняя | 950 |
| 3 | PhotoWorld: средняя | 900 |
| 4 | TravelDiaries: низкая | 825 |
| 5 | FoodLovers: высокая | 1050 |
| 6 | SportLife: высокая | 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_ у переменных.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_name | post_title | post_score |
|---|---|---|
| TechTalks | tutorial Python basics | 600 |
Dmitriy (id 4) открывал ленту TechTalks 8 января; из двух постов сообщества ниже порога только один.
c.name имеет тип varchar, а объявлен text — без приведения ::text вызов падает с structure of query does not match function result type. Имена столбцов в RETURNS TABLE — тоже переменные функции: назовите столбец score, и любое неуточнённое упоминание score в теле даст ошибку неоднозначности из пункта 04.Функция выполняется внутри транзакции вызвавшего её запроса и завершить эту транзакцию не может. Процедура вызывается командой 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;
| id | name | rating |
|---|---|---|
| 1 | TechTalks | 3.0 |
| 2 | GamersHub | 3.8 |
| 3 | PhotoWorld | 3.6 |
| 4 | TravelDiaries | 3.3 |
| 5 | FoodLovers | 4.2 |
| 6 | SportLife | 4.3 |
BEGIN;
CALL prc_recalc_ratings();
-- ERROR: invalid transaction termination
ROLLBACK;
COMMIT в процедуре работает, только если CALL выполнен вне явной транзакции. В pgAdmin по умолчанию режим автофиксации, и вызов пройдёт; приложение, которое обернуло вызов в транзакцию, получит ошибку выше. COMMIT недоступен и внутри блока с обработчиком EXCEPTION.UPDATE … FROM из раздела 8, и цикл медленнее его в десятки раз. И обратная сторона фиксации по частям: если процедура упадёт на середине, уже зафиксированные итерации останутся. Процедуру проектируют так, чтобы её можно было безопасно запустить повторно (задание 3).Триггер связывает функцию с событием на таблице: вставкой, изменением, удалением. Функция возвращает тип 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
BEFORE — RETURN NULL, скопированный из триггера AFTER. Команда завершается без ошибки с UPDATE 0, изменения не происходит, и приложение об этом не узнаёт. Если же функция дошла до конца вообще без RETURN, ошибка будет: control reached end of trigger procedure without RETURN.Журнал хранит старую и новую версию строки целиком в 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;
| id | operation | user_id | old_city | new_city |
|---|---|---|---|---|
| 1 | I | 11 | NULL | Yekaterinburg |
| 2 | U | 11 | Yekaterinburg | Novosibirsk |
| 3 | D | 11 | Novosibirsk | NULL |
UPDATE — исчезла и запись о нём; упала вставка в журнал — откатится и само изменение. Журнал, который пишет приложение отдельным запросом, такой гарантии не даёт. Упорядочивают журнал по id, а не по времени: now() возвращает время начала транзакции, и все записи одной транзакции получают одинаковую отметку.Функция по умолчанию выполняется с правами того, кто её вызвал. С 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 — одна вставка в журнал на всю команду.CREATE OR REPLACE с другими параметрами создаёт вторую функциюIMMUTABLE на функции, читающей таблицу, портит индексы молчаSELECT … INTO STRICT — когда строка должна быть ровно однаp_ и v_ защищают от конфликта имён переменных и столбцовBEFORE возвращает NEW; RETURN NULL молча отменяет операциюCHECKSECURITY DEFINER — только с SET search_path и REVOKE … FROM PUBLIC