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

Проблемная область, в которой я работаю, — это управление возвратами для электронной коммерции.

Я использую Postgres (11.9) и имею следующие таблицы (я удалил ряд полей из каждой таблицы, которые не имеют отношения к вопросу):

CREATE TABLE "order" (
    id BIGSERIAL PRIMARY KEY,
    platform text NOT NULL,
    platform_order_id text NOT NULL,
    CONSTRAINT platform_order_id_unique UNIQUE (platform, platform_order_id)
);

CREATE TABLE order_item (
    id BIGSERIAL PRIMARY KEY,
    order_id int8 NOT NULL,
    platform_item_id text NOT NULL,
    quantity integer,
    CONSTRAINT FK_order_item_order_id FOREIGN KEY (order_id) REFERENCES "order",
    CONSTRAINT platform_item_id_unique UNIQUE (order_id, platform_item_id)
);

CREATE TABLE return (
    id BIGSERIAL PRIMARY KEY,
    order_id int8 NOT NULL,
    CONSTRAINT FK_return_order_id FOREIGN KEY (order_id) REFERENCES "order"
);

CREATE TABLE return_item (
    return_id int8 NOT NULL,
    order_item_id int8 NOT NULL,
    quantity integer NOT NULL,
    CONSTRAINT FK_return_item_return_id FOREIGN KEY (return_id) REFERENCES return,
    CONSTRAINT FK_return_item_item_id FOREIGN KEY (order_item_id) REFERENCES order_item
);

Чтобы кратко объяснить предметную область, я извлекаю заказы с платформ электронной коммерции и сохраняю их в своей базе данных. Заказ состоит из одного или нескольких отдельных предметов, которые имеют quantity > 1. Когда пользователь желает вернуть товар, он может вернуть количество, не превышающее количество за возврат.

Говоря более конкретно, если я куплю две черные маленькие футболки в одном заказе, вы найдете order в базе данных с одним order_item, количество которого равно 2. У меня была бы возможность создать два отдельных возврата, в каждом из которых один return_item ссылался бы на один и тот же order_item_id, но с количеством 1.

order_item и return_item вставлены в разные транзакции, и я не запрещаю нескольким транзакциям обновлять любую из них одновременно.

Как я могу гарантировать, что суммарное значение каждого quantity для всех return_item с конкретным order_item_id не превышает количество, хранящееся в соответствующем order_item для с указанным id?

Проще говоря, как предотвратить возврат третьего предмета, если количество этого предмета в исходном заказе было 2, как в примере, который я описал?

Достаточно просто написать проверку приложения, чтобы поймать это в большинстве случаев, а также несложно добавить пункт проверки бизнес-правил WHERE в мои return_item вставки, но ни одно из этих решений не дает мне таких гарантий согласованности, как ограничение уникальности. Как мне написать здесь триггер для ошибки при вставке? Или есть лучший подход, чем триггер?

ReactJs | Supabase | Добавление данных в базу данных
ReactJs | Supabase | Добавление данных в базу данных
Это и есть ваш редактор таблиц в supabase.👇
Понимание Python и переход к SQL
Понимание Python и переход к SQL
Перед нами лабораторная работа по BloodOath:
5
0
855
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Единственное решение, о котором я могу думать, это денормализация.

Добавьте столбец integertotal_returns в order_item, который изменяется с помощью триггера на return_item всякий раз, когда строки добавляются или удаляются или изменяется quantity.

Затем у вас может быть простое проверочное ограничение на order_item, которое гарантирует, что ваш инвариант выполняется.

Некоторый пример кода:

BEGIN;

/* for consistency */
ALTER TABLE order_item
   ALTER quantity SET NOT NULL
   ALTER quantity SET DEFAULT 0;

ALTER TABLE order_item
   ADD total_returns bigint DEFAULT 0 NOT NULL;

ALTER TABLE order_item
   ADD CONSTRAINT not_too_many_returns
      CHECK (total_returns <= quantity);

/* trigger function */
CREATE FUNCTION requrn_order_trig() RETURNS trigger
   LANGUAGE plpgsql AS
$$BEGIN
   IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
      UPDATE order_item
      SET total_returns = total_returns + NEW.quantity;
      WHERE id = NEW.order_item_id;
   END IF;

   IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
      UPDATE order_item
      SET total_returns = total_returns - OLD.quantity;
      WHERE id = OLD.order_item_id;
   END IF;

   RETURN NULL;
END;$$;

CREATE TRIGGER requrn_order_trig
   AFTER INSERT OR UPDATE OR DELETE ON return_item
   FOR EACH ROW EXECUTE PROCEDURE requrn_order_trig();

UPDATE order_item AS oi
SET total_returns = (SELECT sum(quantity)
                     FROM return_item AS r
                     WHERE r.order_item_id = oi.id);

COMMIT;

Это отличный ответ и, безусловно, работает. Мне вот интересно, а можно ли это сделать без столбца денормализации/автообновления? Просто запустить запрос через триггер во время вставки для нового return_item и отклонить вставку, если общее количество превысит допустимое количество? Если это возможно, это было бы самым чистым ИМО. Я никогда раньше не писал триггеры, поэтому я не уверен, что это возможно.

mistahenry 23.12.2020 18:05

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

Laurenz Albe 07.01.2021 08:07

Я бы не рекомендовал хранить эту производную информацию, потому что ее утомительно поддерживать.

Вместо этого вы можете реализовать логику в запросе DML. Рассмотрим следующий запрос для регистрации нового возвращенного элемента:

insert into return_item (order_item_id, quantity)
select v.*
from (values ($1, $2)) as v(order_item_id, quantity)
inner join order_items oi on oi.id = v.order_item_id
where oi.quantity >= v.quantity + (
    select coalesce(sum(ri.quantity), 0) from return_item ri where ri.order_item_id = v.order_item_id
)

Входные значения задаются как $1 и $2. Запрос выводит соответствующую строку в order_items и проверяет, превышает ли общее возвращенное количество этого товара заказанное количество.

Вся логика реализована в одном запросе, поэтому при наличии нескольких параллельных процессов нет риска состояния гонки.

Из вашего приложения вы можете проверить, затронул ли запрос какую-либо строку. Если это не так, то вы знаете, что возврат был отклонен.

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

Ответ принят как подходящий

Вы просите конкретно триггерное решение. Кстати, вы можете добиться того же и с помощью простого SQL, если вы можете убедиться, что все клиенты используют необходимые операторы. Связанный пример:

Триггерное решение

Вы упомянули, что возможен одновременный доступ для записи. Это делает его более сложным. Например, две транзакции могут попытаться вернуть элемент из одного и того же order_item одновременно. Оба проверяют и обнаруживают, что можно вернуть еще один элемент, и делают это, тем самым превышая количество order_item.quantity на 1. Предостережение классического параллелизма.

Чтобы защититься от него, вы можете использовать SERIALIZABLE изоляцию транзакций. Но это значительно дороже, и все транзакции, которые могут выполнять запись в задействованные таблицы, должны придерживаться его.

В качестве альтернативы снимите стратегические блокировки строк на уровне изоляции по умолчанию READ COMMITTED. Вот базовая реализация:

Триггерная функция:

CREATE FUNCTION trg_return_item_insup_bef()
  RETURNS trigger
  LANGUAGE plpgsql AS
$func$
DECLARE
   _ordered_items int;
   _remaining_items int;
BEGIN
   SELECT quantity
   FROM   order_item
   WHERE  id = NEW.order_item_id
   FOR    NO KEY UPDATE                -- lock the parent row first ... (!!!)
   INTO   _ordered_items;              --  ... while fetching quantity

   SELECT _ordered_items - COALESCE(sum(quantity), 0)
   FROM   return_item
   WHERE  order_item_id = NEW.order_item_id
   INTO   _remaining_items;

   IF NEW.quantity > _remaining_items THEN
      RAISE EXCEPTION 'Tried to return % items, but only % of % are left.'
                     , NEW.quantity, _remaining_items, _ordered_items;
   END IF;
   
   RETURN NEW;
END
$func$;

Курок:

CREATE TRIGGER insup_bef
BEFORE INSERT OR UPDATE ON return_item
FOR EACH ROW
EXECUTE PROCEDURE trg_return_item_insup_bef();

db<>рабочий пример здесь

Любая попытка вернуть элементы сначала блокирует родительскую строку в order_item. Конкурирующие транзакции должны дождаться, пока эта будет зафиксирована, и тогда они увидят новые зафиксированные строки. Это устраняет состояние гонки. FOR NO KEY UPDATE правильная сила блокировки. Ни в слабую, ни в слишком сильную.

Запись в order_item также может повлиять на общее количество предметов. Но они также снимают блокировку записи (неявно) и вынуждены стоять в очереди таким же образом. Но если возможны более поздние обновления order_item.quantity, вам придется добавить туда аналогичные проверки в триггере (на случай, если он будет понижен).

Я добавил основную информацию в сообщение об ошибке, возникающее при превышении количества. Вы можете разместить больше или меньше информации там.

Пример установки может быть оптимизирован. "заказ" - зарезервированное слово. Таблица return в примере бесполезна, как и return_item.return_id. ПК отсутствует в return_item. order_item.quantity должно быть NOT NULL CHECK (quantity > 0). COALESCE в функции триггера является избыточным при правильной реализации. Но это второстепенные заметки.

Другие вопросы по теме