Saltar a contenido

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


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> comercio
publicacion
reserva
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_minutos
ventana.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.minimo
precio.descuento_minimo_pct
RF-C-30
trg_publicacion_horizonte publicacion.horizonte_horas RF-C-24
trg_publicacion_precio precio.minimo
precio.descuento_minimo_pct
publicacion.max_unidades
RF-C-30
trg_reserva_limite_activas reserva.max_activas
reserva.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

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 comercio
publicacion
reserva
Vigente
trg_<tabla>_estado_coherente 14 tablas gobernadas Vigente2
trg_<tabla>_motivo_<papel> 10 tablas, ver arriba Vigente
trg_<tabla>_catalogo_activo comercio
plantilla_oferta
publicacion
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.


  1. cancelacion_usuario, cancelacion_comercio o cancelacion_administrativa según el valor de reserva.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 sobre motivo. ↩

  2. Instalado en 13 de las 14: perfilconsumidor no tiene ninguna marca temporal que un índice parcial consulte (a diferencia de, por ejemplo, publicada_at en publicacion), así que no hay nada que este trigger derive para esa tabla — ver D-08. La anonimización de RF-U-12/RF-A-62 que sí tocaría esta tabla al llegar a anonimizado sigue sin modelarse, según Estados. ↩