Triggers PL/pgSQL¶
Las reglas que un CHECK no alcanza. Esta página define el contrato común
que todo trigger del modelo respeta, las siete familias de las que se
derivan casi todos, y el catálogo completo con el estado de implementación de
cada uno.
Requerimientos cubiertos¶
RF-U-31RF-U-33RF-U-35RF-U-37RF-C-17RF-C-19RF-C-22RF-C-31RF-C-43RF-A-25RF-A-37RF-A-46RF-A-63
Por qué tantas reglas viven aquí¶
El criterio de invariantes ordena los mecanismos de
más barato a más evadible y manda usar el primero que sirva. Las primeras
cuatro filas de esa tabla (CHECK, índice único, EXCLUDE, llave foránea) ya
cubren todo lo que depende de una sola fila. Lo que queda depende de otra
tabla, de otras filas de la misma tabla, de un parámetro
configurable o de now(), y ninguno de esos cuatro casos es expresable
de forma declarativa en PostgreSQL.
La alternativa sería dejarlas en la capa de aplicación. El modelo no lo hace
por D-07: una regla que vive en un servicio se evade desde psql,
desde una migración de datos, desde un comando de gestión y desde el segundo
cliente que alguien escriba. Una que vive en el trigger, no.
Tres consecuencias de esa elección
Una columna denormalizada que el servicio "se compromete" a copiar
bien no es una garantía. El trigger la sobrescribe en el
BEFORE INSERT con el valor de su origen, ignorando lo que haya
mandado el llamador, y el trigger de solo lectura la congela después.
Es lo que hace cierto a D-10.
Un tope configurable no puede ser un CHECK: cambiar el valor
exigiría una migración, justo lo que RF-A-37 prohíbe. El
trigger lee parametro_sistema en cada escritura. El costo es una
lectura indexada por fila escrita, sobre una tabla de 23 filas que
vive entera en caché.
Todo trigger levanta RAISE check_violation USING COLUMN = '<col>',
que es la misma condición real que dispara un CHECK. Django la
sigue clasificando como IntegrityError y la capa de errores puede
construir una respuesta que apunte al campo culpable sin conocer el
código 23514.
Contrato común¶
Todo trigger del modelo cumple estas cinco reglas. Las tres primeras ya las
cumplen los triggers existentes (trg_parametrosistema_especificacion,
trg_media_variantes_especificacion, trg_alerta_especificacion,
trg_campana_*, trg_notificacion_datos_especificacion,
trg_solicitudafiliacion_horario_especificacion); las dos últimas son
contrato nuevo que esta página introduce.
| Regla | Detalle |
|---|---|
Nombre trg_<tabla>_<regla> |
Con las dos excepciones que fija Nomenclatura: los cinco de la clase base y los de solo lectura |
| Cuerpo normalizado | Se declara con normalize_trigger, que colapsa el texto a una sola línea antes de emitirlo |
| Error con columna | RAISE check_violation USING COLUMN = ..., nunca RAISE EXCEPTION a secas |
| Existencia antes de leer | Un BEFORE corre antes de que se verifique la llave foránea diferida; ver abajo |
| Idempotente bajo reintento | Un AFTER que escribe en otra tabla usa ON CONFLICT DO NOTHING o compara antes de escribir |
Dereferenciar una llave foránea¶
Las llaves foráneas del modelo son DEFERRABLE INITIALLY DEFERRED, así que un
trigger BEFORE que lee la fila referenciada no puede asumir que existe.
La forma canónica es siempre la misma, y trg_alerta_especificacion ya la
sigue:
SELECT ... INTO origen FROM <tabla_referenciada> WHERE id = NEW.<columna>_id;
IF NOT FOUND THEN
RAISE foreign_key_violation
USING COLUMN = '<columna>_id',
MESSAGE = '<columna>_id no corresponde a ninguna fila.';
END IF;
Sin esa comprobación, un identificador inexistente produce el error de la consulta interna del trigger, que no nombra la columna y llega al cliente como un conflicto genérico.
Funciones auxiliares¶
Cuatro funciones que el resto de los triggers da por hechas. Viven en una migración propia, no en un modelo, porque son infraestructura de esquema y no dominio.
| Función | Devuelve | Uso |
|---|---|---|
hz_parametro(clave) |
El valor de parametro_sistema como numeric; lanza si falta |
Toda regla parametrizada |
hz_actor() |
current_setting('hz.actor', true), o NULL si nadie la fijó |
Validación de transición |
hz_hoy() |
(now() AT TIME ZONE 'America/Managua')::date |
Fecha operativa, por D-02 |
hz_minutos(hora) |
Minutos desde medianoche, IMMUTABLE |
Las columnas generadas franja y franja_atencion |
hz_actor() es el único punto donde el trigger depende de que la capa de API
coopere: la petición hace SET LOCAL hz.actor = '<admin|comercio|sistema|user>'
al abrir la transacción. Si nadie la fija, la validación de transición sigue
exigiendo que el par exista, pero no exige que exista para un actor
concreto. Esa degradación es deliberada: una migración de datos o una
corrección desde psql no tienen actor, y no deben quedar bloqueadas por no
tenerlo; lo que no pueden es inventar una transición que el catálogo no
declara.
Las siete familias¶
Ciclo de vida del estado¶
Cuatro triggers genéricos por cada tabla con estado_id. Son la mitad del
catálogo y la razón por la que D-12 puede prometer que agregar un
estado es un INSERT: la semántica se lee de las columnas del catálogo, nunca
se compara contra codigo.
| Trigger | Impone | Alcance |
|---|---|---|
trg_<tabla>_estado_activo |
El estado asignado debe estar is_active |
Las 14 tablas gobernadas |
trg_<tabla>_estado_terminal |
Una fila en estado terminal no vuelve a cambiar de estado |
Las 14 tablas gobernadas |
trg_<tabla>_transicion_valida |
El par (from, to) debe existir activo en transicion_<entidad> |
comerciopublicacionreserva |
trg_<tabla>_estado_coherente |
Los atributos semánticos del estado concuerdan con las marcas de la fila | Por tabla, con su propia regla |
Los tres primeros son la misma plantilla instanciada con el nombre del catálogo. El cuarto es específico: es donde cada tabla declara qué significa para ella estar en un estado.
-- trg_<tabla>_transicion_valida, BEFORE UPDATE OF estado_id, ROW
-- WHEN (OLD.estado_id IS DISTINCT FROM NEW.estado_id)
SELECT require_motivo, require_descripcion
INTO transicion
FROM transicion_<entidad>
WHERE from_estado_id = OLD.estado_id
AND to_estado_id = NEW.estado_id
AND is_active
AND (hz_actor() IS NULL OR actor = hz_actor())
ORDER BY (actor = hz_actor()) DESC
LIMIT 1;
IF NOT FOUND THEN
RAISE check_violation
USING COLUMN = 'estado_id',
MESSAGE = 'La transición no está declarada para este actor.';
END IF;
La exigencia de motivo se resuelve dos veces y por caminos distintos, y no
es redundancia: transicion_<entidad>.require_motivo la exige por tránsito
(cancelar exige motivo), mientras que estado_<entidad>.require_motivo la
exige por destino (estar cancelada exige tener motivo, se haya llegado por
donde se haya llegado). El primero lo comprueba este trigger; el segundo,
trg_<tabla>_estado_coherente, que es el que también cubre los trece dominios
sin tabla de transiciones.
Motivo de lista cerrada¶
motivo es una sola tabla discriminada por contexto, y hoy nada impide
que una cancelación de reserva apunte a un motivo de rechazo de afiliación.
La ficha de Catálogos ya dice que require_nota "lo
consumen los triggers de las tablas que referencian el motivo"; esos triggers
son esta familia.
-- trg_<tabla>_motivo_<papel>, BEFORE INSERT OR UPDATE, ROW
IF NEW.<columna>_id IS NOT NULL THEN
SELECT contexto, require_nota, is_active
INTO motivo
FROM motivo
WHERE id = NEW.<columna>_id;
IF NOT FOUND THEN
RAISE foreign_key_violation USING COLUMN = '<columna>_id';
END IF;
IF NOT motivo.is_active THEN
RAISE check_violation
USING COLUMN = '<columna>_id',
MESSAGE = 'El motivo está desactivado.';
END IF;
IF motivo.contexto <> <contexto esperado> THEN
RAISE check_violation
USING COLUMN = '<columna>_id',
MESSAGE = 'El motivo no pertenece al contexto de esta acción.';
END IF;
IF motivo.require_nota AND length(NEW.<nota>) = 0 THEN
RAISE check_violation
USING COLUMN = '<nota>',
MESSAGE = 'Este motivo exige una nota.';
END IF;
END IF;
Las diez instancias, con el contexto que cada una espera:
| Tabla | Columna | contexto |
Columna de nota |
|---|---|---|---|
reserva |
motivo_id |
Derivado de cancelada_by1 |
motivo_nota |
validacion_reserva |
motivo_parcial_id |
entrega_parcial |
nota |
pausa_comercio |
motivo_id |
pausa_comercio |
nota |
sancion_usuario |
motivo_id |
suspension_usuario |
nota |
solicitud_afiliacion |
motivo_rechazo_id |
rechazo_afiliacion |
nota_resolucion |
solicitud_cambio_comercio |
motivo_rechazo_id |
rechazo_cambio_comercio |
n/a |
despublicacion |
motivo_id |
despublicacion |
nota |
valoracion |
motivo_moderacion_id |
moderacion_valoracion |
n/a |
respuesta_valoracion |
motivo_rechazo_id |
moderacion_valoracion |
n/a |
incidencia |
categoria_motivo_id |
incidencia_categoria |
descripcion |
Las cuatro columnas de nota marcadas n/a son tablas donde el motivo nunca
declara require_nota; el trigger igual se instala, sin la última rama.
Catálogo activo¶
D-13 dice que los catálogos se validan con activo y se referencian
con RESTRICT. La llave foránea impide borrar la fila referenciada, pero
nada impide que una fila nueva estrene una referencia a un catálogo ya
desactivado, que es exactamente lo que RF-A-45 quiere evitar
cuando administración retira una categoría.
trg_<tabla>_catalogo_activo corre BEFORE INSERT (no en UPDATE: una fila
histórica conserva su clasificación) y exige is_active en cada referencia a
catálogo. Se instala en comercio (tipo_negocio_id, categoria_comida_id,
zona_id), plantilla_oferta (categoria_comida_id) y publicacion
(categoria_comida_id).
Derivación de snapshot¶
La familia que hace cierto a D-10 y a D-15.
trg_readonly_snapshot congela una columna denormalizada después de la
inserción, pero no valida qué se escribió en ella. Hoy un INSERT en
reserva puede declarar un comercio_id que no es el de su publicación, un
precio_unitario que no es el publicado o un ventana_fin_at inventado, y el
trigger de solo lectura lo congela igual de bien. La copia queda inmutable,
pero falsa.
-- trg_<tabla>_snapshot, BEFORE INSERT, ROW
SELECT ... INTO origen FROM <tabla_origen> WHERE id = NEW.<origen>_id;
IF NOT FOUND THEN
RAISE foreign_key_violation USING COLUMN = '<origen>_id';
END IF;
NEW.<columna_1> := origen.<columna_1>;
NEW.<columna_2> := origen.<columna_2>;
...
RETURN NEW;
El trigger sobrescribe en vez de comparar. Rechazar un valor incorrecto obligaría al llamador a conocer el valor correcto antes de escribirlo, que es precisamente lo que no se le quiere pedir a una capa de API genérica; con la sobrescritura, la columna denormalizada deja de ser un dato de entrada y pasa a ser una consecuencia de la llave foránea.
Se instala en reserva (desde publicacion), validacion_reserva (desde
reserva), valoracion (desde reserva), incidencia (desde reserva) y
publicacion (desde plantilla_oferta, solo cuando no es manual).
Contadores denormalizados¶
D-17 declara que los contadores se mantienen con triggers y que el
CHECK es la red de seguridad. La red existe
(chk_publicacion_stock_coherente, chk_comercio_calificacion_coherente);
los triggers que la harían innecesaria, no.
| Trigger | Sobre | Mantiene |
|---|---|---|
trg_reserva_ajustar_stock |
reserva |
publicacion.cantidad_reservada |
trg_validacionreserva_entrega |
validacion_reserva |
publicacion.cantidad_entregada |
trg_valoracion_calificacion |
valoracion |
comercio.calificacion_promedio y calificacion_conteo |
Los tres son AFTER, de nivel ROW, y el delta se calcula desde el atributo
semántico del estado, no desde su código: trg_reserva_ajustar_stock suma
cuando el estado nuevo tiene consume_stock y el viejo no, resta en el caso
contrario, y no hace nada si ninguno de los dos cambió. Es la traducción
directa de la aritmética que describe Reservas: la
expiración no libera porque expirada conserva consume_stock; las
cancelaciones sí porque no lo conservan.
Regla parametrizada¶
Un tope que RF-A-37 exige poder cambiar sin desplegar. El trigger
lee hz_parametro(...) y compara. Nueve instancias:
| Trigger | clave |
Origen |
|---|---|---|
trg_ventanarecogida_duracion |
ventana.duracion_min_minutosventana.duracion_max_minutos |
RF-C-18 |
trg_ventanarecogida_maximo_dia |
ventana.max_por_dia |
RF-C-20 |
trg_plantillaoferta_maximo_activas |
plantilla.max_activas |
RF-C-27 |
trg_plantillaoferta_precio |
precio.minimoprecio.descuento_minimo_pct |
RF-C-30 |
trg_publicacion_horizonte |
publicacion.horizonte_horas |
RF-C-24 |
trg_publicacion_precio |
precio.minimoprecio.descuento_minimo_pctpublicacion.max_unidades |
RF-C-30 |
trg_reserva_limite_activas |
reserva.max_activasreserva.max_unidades |
RF-U-33 |
trg_usuariocomercio_maximo |
comercio.max_operadores |
RF-C-13 |
trg_campana_limite_semanal |
campana.max_por_semana |
RF-A-48 |
Los cinco instantes de publicacion también son regla parametrizada
(publicacion.corte_reservas_min, validacion.tolerancia_min), pero el
trigger que los resuelve pertenece a la familia de derivación, no a esta: no
compara contra el parámetro, lo usa para calcular la columna y después
D-16 la congela.
El parámetro congelado no se vuelve a leer
Una regla parametrizada se evalúa en la escritura y nunca después. Si
mañana la tolerancia de validación sube de 10 a 20 minutos, las
publicaciones de hoy conservan su validacion_hasta_at, y el trigger que
valida una entrega compara contra esa columna congelada, no contra el
parámetro vigente. Un trigger que releyera el parámetro reescribiría la
historia, que es justo lo que D-16 prohíbe.
Alerta automática¶
RF-A-25 y RF-A-44 describen señales que hoy la
documentación atribuye a procesos programados. Las que se disparan por una
escritura concreta no necesitan proceso: son AFTER INSERT sobre la tabla que
las origina, y unq_alerta_abierta hace el resto.
-- trg_<tabla>_alerta_<codigo>, AFTER INSERT OR UPDATE, ROW
INSERT INTO alerta (..., codigo, tipo, severidad, entidad_tipo, entidad_id, datos, estado_id)
VALUES (..., '<codigo>', 'moderacion', '<severidad>', '<entidad>', NEW.id, <datos>, <inicial>)
ON CONFLICT ON CONSTRAINT unq_alerta_abierta DO NOTHING;
El ON CONFLICT es lo que convierte el índice único parcial de alerta en
control de flujo en vez de en un error: mientras la alerta anterior siga sin
revisar, la siguiente escritura no la duplica ni revienta la transacción que
la originó.
Solo dos alertas califican como trigger. Las de cumplimiento
(RF-A-44) y las de antigüedad (RF-A-32) siguen
siendo procesos programados, porque el paso del tiempo no es un evento de
escritura:
| Trigger | Sobre | Dispara |
|---|---|---|
trg_publicacion_alerta_moderacion |
publicacion |
Descuento sobre umbral, precio bajo el mínimo, oferta específica sin foto, cantidad mayor a 30 |
trg_valoracion_alerta_baja |
valoracion |
puntuacion <= 2 |
Catálogo¶
Estado de cada trigger del modelo. Vigente es lo que ya está en
src/hz_*/; Pendiente es diseño de esta revisión, sin implementar. Los
cinco de la clase base no se repiten aquí: están en
Columnas estándar.
Transversales¶
| Trigger | Tablas | Estado |
|---|---|---|
trg_<tabla>_estado_activo |
14 tablas gobernadas | Vigente |
trg_<tabla>_estado_terminal |
14 tablas gobernadas | Vigente |
trg_<tabla>_transicion_valida |
comerciopublicacionreserva |
Vigente |
trg_<tabla>_estado_coherente |
14 tablas gobernadas | Vigente2 |
trg_<tabla>_motivo_<papel> |
10 tablas, ver arriba | Vigente |
trg_<tabla>_catalogo_activo |
comercioplantilla_ofertapublicacion |
Vigente |
Lo que no es trigger¶
Tres clases de regla que este diseño deja fuera a propósito.
No todo invariante de negocio cabe en la base:
Correo, SMS, push, almacenamiento de objetos y geocodificación no
pueden participar de un COMMIT. Un trigger que los invocara
convertiría un fallo del proveedor en un fallo de la escritura.
Expirar una retención, cerrar una ventana o vencer una solicitud ocurren porque pasó el tiempo, no porque alguien escribió. Son los procesos programados, y su contrato es que cada uno hace la escritura que sí dispara los triggers de esta página.
Qué rol puede provocar qué tránsito es una decisión de permisos, no de integridad. La base valida que el par exista en el catálogo y, si la petición declaró su actor, que exista para ese actor; quién es esa petición lo resuelve la capa de API.
-
cancelacion_usuario,cancelacion_comercioocancelacion_administrativasegún el valor dereserva.cancelada_by. Es la única instancia de la familia cuyo contexto esperado no es una constante, y la razón por la que la familia se instancia por tabla en vez de resolverse con un único trigger genérico sobremotivo. ↩ -
Instalado en 13 de las 14:
perfilconsumidorno tiene ninguna marca temporal que un índice parcial consulte (a diferencia de, por ejemplo,publicada_atenpublicacion), así que no hay nada que este trigger derive para esa tabla — ver D-08. La anonimización deRF-U-12/RF-A-62que sí tocaría esta tabla al llegar aanonimizadosigue sin modelarse, segúnEstados. ↩