Иногда бизнес-логику проще выразить на SQL – нравится вам это или нет. При этом окружающие теряют лицо: кричат “боже, хранимки!” и ведут себя недостойно. Бояться тут нечего, а истерика — от незнания.

Я не говорю, что нужно писать хранимки. Речь о том, чтобы поручить базе ту работу, что делает ваш Питон или Джава. Часто SQL выигрывает: он короче, точнее, не требует многих обращений к базе.

Нюанс в том, что меняется парадигма мышления. Когда вы пишете на SQL, то должны думать реляционно. Если писать хранимки императивно как на Питоне, это будет ужасно.

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

На какой-нибудь питонячей ORM это выглядит так:

user_id = 51315
points = 100

points_entry = UserPoints.create(user_id=user_id, points=points)
points_entry.save()

rating_entry = UserRating.get_or_create(user_id=user_id)
rating_entry.points += points
rating_entry.save()

Сперва мы добавили запись в историю баллов. Затем выбрали или создали запись в глобальном рейтинге. Прибавили баллы, записали. Все это нужно делать в транзакции, иначе есть риск потерять баллы (по аналогии с задачей про семейную пару и терминал).

Предположим, руководитель сказал: “ребят, переносим логику в БД”, но не удосужился объяснить, что это значит. Окей, программист пишет процедуру на plpgsql:

create procedure add_points(int user_id, int points)
begin
  create_points_history(user_id, points);
  rating_entry = get_user_rating_by_user_id(user_id);
  if (rating_entry is NULL) then
    insert_user_rating(user_id, points);
  else
    update_user_rating(user_id, points);
  endif;
end;

Эта процедура – построчная калька с Питона. Далее пишутся функции get_user_rating_by_user_id, get_user_rating_by_user_id и другие. Получается два экрана SQL, который трудно понять и поддерживать. Запросов по-прежнему много. Один раз увидев такое, разработчик получает душевную травму. При слове “хранимки” у него начинается истерика.

Проблема в том, что автор процедуры использует SQL не по назначению. Он пишет на нем императивно, а SQL создан для операций над отношениями. Прочитайте еще раз: SQL создан для операций над отношениями. Единица операции — таблица. Если ваш SQL полон функций get_user_by_id, get_profile_by_id, то это ошибка. Копаешь граблями – получишь заведомо плохой результат. Нельзя пересаживать программиста с императивного Питона на SQL. Пока мозги не перестроились, хорошего результата не ждите.

Достойное решение на SQL выглядит примерно так:

with update_history as (
  insert into user_history (user_id, points)
  values ($1, $2)
)
insert into user_rating(user_id, points)
  values ($1, $2)
  on conflict user_id do update
  set points += excluded.points
  returning user_id, points

Здесь все просто: часть update_history добавляет запись в историю баллов. Основная часть пытается вставить пользователя и баллы в рейтинг, а если пользователь уже есть в нем, прибавляет баллы. Запрос возвращает пользователя и новую сумму баллов.

Этот запрос выполняется транзакционно, обе части видят один снимок данных. Подчеркну: совершенно незачем выносить его в хранимку. Достаточно хранить запрос в проекте и вызывать его. Хранимки нет, но работа делегируется базе.

Итого: не пишите хранимки только затем, чтобы они были. Думайте о том, как делегировать работу базе, а не писать бороду SQL-функций. Если вы работаете с базой, мыслите реляционно. Экраны императивного SQL говорят о том, что вы делаете что-то не так.