Un procedimiento hay que llamarlo. Y basta con que una aplicación, un becario con psql o un proceso de importación escriban directamente en la tabla para saltárselo entero. Un trigger es lo contrario: código que se ejecuta solo, sin que nadie lo invoque, en el momento exacto en que una fila se inserta, se modifica o se borra. Es la única pieza del curso capaz de garantizar que algo pasa siempre, escriba quien escriba.
Aquí se cierra la promesa de 09-02: las reglas de negocio que un CHECK no puede expresar porque necesitan consultar otra tabla. Verás la anatomía completa, los cuatro ejes que definen el comportamiento de un trigger, las variables NEW y OLD con el mecanismo que las hace útiles —devolver NULL cancela la operación—, los cuatro casos canónicos aplicados a TiendaVerde con código completo, y los INSTEAD OF que hacen escribibles las vistas de 10-01. Y sus peligros, con el mismo peso que las ventajas, porque un trigger es lógica invisible: hace que un UPDATE haga cosas que tú no escribiste.
Contenido
- Anatomía: función de trigger +
CREATE TRIGGER - Los cuatro ejes
NEW,OLD,TG_OPy el valor de retorno- Caso 1: auditoría de cambios de precio
- Caso 2: mantener un total desnormalizado
- Caso 3: la regla que un
CHECKno puede expresar - Caso 4: descuento automático de stock, y si conviene
INSTEAD OFsobre vistas- Orden de disparo y gestión
- Los peligros
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Anatomía: función de trigger +
CREATE TRIGGER
CREATE TRIGGEREn PostgreSQL un trigger son dos objetos, y esa separación desconcierta al principio:
- Una función que devuelve el tipo especial
TRIGGERy no recibe parámetros declarados. - Un
CREATE TRIGGERque dice sobre qué tabla, cuándo y con qué granularidad se ejecuta esa función.
CREATE OR REPLACE FUNCTION fn_trg_ejemplo() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE 'Operación % sobre la tabla %', TG_OP, TG_TABLE_NAME;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_ejemplo
BEFORE INSERT OR UPDATE ON productos
FOR EACH ROW
EXECUTE FUNCTION fn_trg_ejemplo();La ventaja de separarlos es que una misma función puede servir a varios triggers de varias tablas —de ahí TG_TABLE_NAME—, y es exactamente lo que hace útil una función de auditoría genérica.
(Todos los triggers, funciones y tablas de esta lección son ejemplos: no forman parte del esquema canónico de TiendaVerde de 01-06. La columna pedidos.total del caso 2, en particular, no es canónica.)
- Los cuatro ejes
| Eje | Valores | Qué significa |
|---|---|---|
| Cuándo | BEFORE / AFTER / INSTEAD OF |
Antes de escribir (puede alterar o cancelar), después de escribir (la fila ya existe y tiene su id), o en lugar de la operación (solo sobre vistas) |
| Qué | INSERT, UPDATE, DELETE, TRUNCATE |
Combinables con OR. UPDATE OF precio restringe a esa columna |
| Granularidad | FOR EACH ROW / FOR EACH STATEMENT |
Una vez por fila afectada, o una vez por sentencia (aunque toque un millón de filas o ninguna) |
| Condición | WHEN (condición) |
Filtro previo: el trigger ni se dispara si no se cumple |
La combinación decide casi todo:
| Quieres… | Usa |
|---|---|
| Validar o rechazar una fila | BEFORE ... FOR EACH ROW |
| Modificar el valor que se va a guardar | BEFORE ... FOR EACH ROW (devolviendo NEW alterado) |
| Registrar, auditar o propagar a otra tabla | AFTER ... FOR EACH ROW |
| Un resumen o una comprobación global tras la sentencia | AFTER ... FOR EACH STATEMENT |
| Escribir a través de una vista | INSTEAD OF ... FOR EACH ROW |
BEFORE frente a AFTER en una frase: en BEFORE la fila todavía no existe, así que puedes cambiarla o cancelarla, pero su id autogenerado aún no está disponible en un INSERT; en AFTER ya existe y su id es real, pero devolver algo distinto de NULL no cambia nada. Regla: valida y modifica en BEFORE; reacciona en AFTER.
Y el WHEN no es solo elegancia: WHEN (OLD.precio IS DISTINCT FROM NEW.precio) evita ejecutar la función en los millones de UPDATE que no tocan el precio. Es la optimización más barata que existe en un trigger.
NEW, OLD, TG_OP y el valor de retorno
NEW, OLD, TG_OP y el valor de retornoDentro de una función de trigger FOR EACH ROW hay dos variables de tipo fila, y no siempre están las dos:
| Operación | OLD |
NEW |
|---|---|---|
INSERT |
(no existe) | La fila que va a insertarse |
UPDATE |
La fila antes del cambio | La fila después del cambio |
DELETE |
La fila que va a borrarse | (no existe) |
Usar NEW en un DELETE o OLD en un INSERT da record "new" is not assigned yet. De ahí que una función que atiende a varias operaciones tenga que preguntar con TG_OP ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'). Hay más variables disponibles —TG_TABLE_NAME, TG_WHEN, TG_LEVEL, TG_ARGV[] con los argumentos del CREATE TRIGGER—, pero con esas dos se resuelve casi todo.
Y ahora el mecanismo que hace útiles a los triggers: qué significa lo que devuelve la función.
| Contexto | RETURN NEW |
RETURN NEW modificado |
RETURN NULL |
|---|---|---|---|
BEFORE ... FOR EACH ROW |
Sigue con normalidad | Se guarda la fila alterada | La operación se cancela en silencio |
AFTER ... FOR EACH ROW |
Indiferente | Indiferente | Indiferente |
FOR EACH STATEMENT |
Indiferente | Indiferente | Indiferente |
Léelo despacio, porque son las dos capacidades que ninguna otra herramienta del curso tiene: en un BEFORE ... FOR EACH ROW, devolver NULL cancela la operación —la fila no se inserta, el UPDATE no se aplica, y el INSERT 0 0 es la única pista— y devolver NEW con campos cambiados guarda esos cambios. En un DELETE, lo que se devuelve es OLD. En un AFTER, el valor de retorno se ignora y por convención se escribe RETURN NULL.
Cancelar en silencio es peligroso. Si la fila se rechaza por una regla de negocio, casi siempre es mejor
RAISE EXCEPTIONcon un mensaje claro queRETURN NULL: quien escribió elINSERTmerece saber por qué no pasó nada. Reserva elNULLpara descartes deliberados y documentados, como filtrar filas basura en una carga masiva.
- Caso 1: auditoría de cambios de precio
El uso más incontestable de un trigger: registrar qué cambió, quién lo cambió y cuándo. Ninguna aplicación puede olvidarlo, porque no depende de ninguna aplicación.
CREATE TABLE auditoria_precios ( -- ⚠️ tabla de ejemplo, NO canónica
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
producto_id INTEGER NOT NULL,
precio_ant NUMERIC(10,2),
precio_nuevo NUMERIC(10,2),
usuario TEXT NOT NULL,
momento TIMESTAMPTZ NOT NULL
);
CREATE OR REPLACE FUNCTION fn_audita_precio() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO auditoria_precios (producto_id, precio_ant, precio_nuevo, usuario, momento)
VALUES (NEW.id, OLD.precio, NEW.precio, current_user, now());
RETURN NULL; -- AFTER: el retorno se ignora
END;
$$;
CREATE TRIGGER trg_productos_audita_precio
AFTER UPDATE ON productos
FOR EACH ROW
WHEN (OLD.precio IS DISTINCT FROM NEW.precio) -- solo si el precio cambia de verdad
EXECUTE FUNCTION fn_audita_precio();UPDATE productos SET precio = 13.20 WHERE id = 1; -- sube el aceite
UPDATE productos SET stock = 118 WHERE id = 1; -- no toca el precio
SELECT producto_id, precio_ant, precio_nuevo, usuario FROM auditoria_precios;| producto_id | precio_ant | precio_nuevo | usuario |
|---|---|---|---|
| 1 | 12.50 | 13.20 | curso_sql |
Una sola fila: el segundo UPDATE no disparó nada gracias al WHEN. Tres decisiones que conviene copiar de este ejemplo: AFTER, porque solo se audita lo que efectivamente ocurrió; IS DISTINCT FROM en lugar de <>, porque con NULL el <> no es verdadero y un cambio de NULL a un valor se perdería (04-03); y TIMESTAMPTZ con now(), no la hora que envíe el cliente.
- Caso 2: mantener un total desnormalizado
01-06 explicó por qué pedidos no tiene columna total: es un agregado que habría que mantener sincronizado. Cuando el cálculo al vuelo y la vista de 10-01 ya no bastan —un listado de pedidos que ordena y filtra por total sobre millones de filas—, la salida es guardarlo y mantenerlo con un trigger.
ALTER TABLE pedidos ADD COLUMN total NUMERIC(10,2) NOT NULL DEFAULT 0; -- ⚠️ NO canónica
CREATE OR REPLACE FUNCTION fn_recalcula_total_pedido() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
v_pedido_id INTEGER := COALESCE(NEW.pedido_id, OLD.pedido_id);
BEGIN
UPDATE pedidos AS pe
SET total = COALESCE((SELECT ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
FROM lineas_pedido AS lp WHERE lp.pedido_id = v_pedido_id), 0)
WHERE pe.id = v_pedido_id;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_lineas_total
AFTER INSERT OR UPDATE OR DELETE ON lineas_pedido
FOR EACH ROW EXECUTE FUNCTION fn_recalcula_total_pedido();
-- Relleno inicial: el trigger solo cubre el futuro
UPDATE pedidos AS pe
SET total = COALESCE((SELECT ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
FROM lineas_pedido AS lp WHERE lp.pedido_id = pe.id), 0);| id | total |
|---|---|
| 1 | 42.10 |
| 2 | 26.70 |
| 3 | 29.53 |
| 4 | 31.75 |
| 5 | 32.10 |
| 6 | 26.75 |
(6 primeros de 20 pedidos; SELECT SUM(total) FROM pedidos da 727.95.) Y si ahora borras una línea del pedido 1, su total se recalcula solo.
Tres detalles que hacen que funcione y que se olvidan siempre: COALESCE(NEW.pedido_id, OLD.pedido_id), porque en un DELETE no hay NEW; el COALESCE(..., 0) exterior, porque al borrar la última línea el SUM devuelve NULL y la columna es NOT NULL; y el relleno inicial, porque un trigger recién creado no sabe nada del pasado — es el error número uno al desnormalizar.
Y la advertencia honesta: esto es lo que se hace cuando no queda más remedio. Has cambiado una lectura barata por una escritura extra en cada INSERT, UPDATE y DELETE de líneas, has creado una vía para que los datos se desincronicen y has movido una regla a un sitio invisible. Antes de llegar aquí, prueba con una vista (10-01), con un índice (módulo 8) y con una vista materializada.
- Caso 3: la regla que un
CHECK no puede expresar
CHECK no puede expresarEsta es la promesa de 09-02. La regla es "una reseña solo puede escribirla un cliente que haya comprado ese producto", y un CHECK no puede expresarla: un CHECK solo ve las columnas de su propia fila, y esto exige consultar pedidos y lineas_pedido.
CREATE OR REPLACE FUNCTION fn_valida_resena() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
WHERE pe.cliente_id = NEW.cliente_id
AND lp.producto_id = NEW.producto_id
AND pe.estado <> 'cancelado'
) THEN
RAISE EXCEPTION 'El cliente % no ha comprado el producto %', NEW.cliente_id, NEW.producto_id
USING ERRCODE = 'P0001',
HINT = 'Solo pueden reseñar quienes hayan comprado el producto';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_resenas_valida
BEFORE INSERT OR UPDATE ON resenas
FOR EACH ROW EXECUTE FUNCTION fn_valida_resena();-- Hugo (cliente 14) no ha hecho ningún pedido
INSERT INTO resenas (producto_id, cliente_id, puntuacion, comentario, fecha)
VALUES (1, 14, 5, 'Genial', '2026-03-01');ERROR: El cliente 14 no ha comprado el producto 1 HINT: Solo pueden reseñar quienes hayan comprado el producto CONTEXT: PL/pgSQL function fn_valida_resena() line 12 at RAISE
Y las 12 reseñas existentes siguen siendo válidas: todas corresponden a clientes que compraron ese producto, así que el trigger no rompe nada. Compáralo con el ejercicio 3b de 01-06, donde exactamente este INSERT funcionaba porque ninguna restricción lo impedía. Ahora ya no.
Dos advertencias imprescindibles. La primera: un trigger valida lo que pasa a partir de ahora, no lo que ya está. Al añadir una regla a una tabla con datos, comprueba antes cuántas filas la incumplen. La segunda es más sutil: esta comprobación no es inmune a la concurrencia. Entre el SELECT del EXISTS y el INSERT de la reseña, otra transacción podría cancelar el pedido; bajo READ COMMITTED el trigger no lo vería (09-04). Para la mayoría de las reglas de negocio eso es aceptable; para un invariante que deba cumplirse siempre, hacen falta bloqueos explícitos o SERIALIZABLE.
- Caso 4: descuento automático de stock, y si conviene
Técnicamente es el más sencillo de los cuatro:
CREATE OR REPLACE FUNCTION fn_descuenta_stock() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE productos SET stock = stock - NEW.cantidad WHERE id = NEW.producto_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE productos SET stock = stock + OLD.cantidad WHERE id = OLD.producto_id;
ELSE -- UPDATE: devolver la cantidad antigua y descontar la nueva
UPDATE productos SET stock = stock + OLD.cantidad WHERE id = OLD.producto_id;
UPDATE productos SET stock = stock - NEW.cantidad WHERE id = NEW.producto_id;
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_lineas_stock
AFTER INSERT OR UPDATE OR DELETE ON lineas_pedido
FOR EACH ROW EXECUTE FUNCTION fn_descuenta_stock();Funciona, y el CHECK (stock >= 0) de productos impide que quede negativo: si alguien intenta vender más de lo que hay, el UPDATE del trigger falla y toda la transacción se deshace, incluida la línea que la provocó.
Pero ¿conviene? Aquí no hay una respuesta única, y merece la pena verla enfrentada al sp_confirmar_pedido de 10-04:
| Trigger (aquí) | Procedimiento (10-04) | |
|---|---|---|
| ¿Se puede eludir? | No, escriba quien escriba en lineas_pedido |
Sí: basta con hacer el INSERT a mano |
| Mensaje de error | Genérico: violates check constraint "productos_stock_check" |
Claro: "Stock insuficiente de X: quedan 0 y se piden 1" |
| Comprobación previa | No la hay: se descubre al fallar el CHECK |
Sí, con FOR UPDATE antes de decidir |
| Visibilidad | Invisible para quien lee el código de la aplicación | Explícita |
| Carga masiva de 100 000 líneas | Un UPDATE extra por línea |
Se puede optimizar en bloque |
| Coherencia si alguien corrige un histórico | Recalcula stock, quizá sin querer | No se toca |
El criterio: si el stock es un invariante sagrado y hay varias vías de escritura, trigger. Si hay una sola vía de entrada controlada y el mensaje al usuario importa, procedimiento. Y si eliges el trigger, añade un BEFORE que valide con un mensaje decente en lugar de dejar que el error lo dé el CHECK.
INSTEAD OF sobre vistas
INSTEAD OF sobre vistas10-01 dejó pendiente qué hacer con las vistas que no son actualizables automáticamente —las que tienen JOIN, agregados o columnas calculadas—. La respuesta es un trigger INSTEAD OF, que sustituye la operación por lo que tú decidas.
CREATE OR REPLACE FUNCTION fn_v_detalle_insert() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (NEW.pedido_id, NEW.producto_id, NEW.cantidad,
COALESCE(NEW.precio_unitario, (SELECT precio FROM productos WHERE id = NEW.producto_id)),
COALESCE(NEW.descuento, 0));
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_v_detalle_insert
INSTEAD OF INSERT ON v_detalle_ventas
FOR EACH ROW EXECUTE FUNCTION fn_v_detalle_insert();Ahora INSERT INTO v_detalle_ventas (pedido_id, producto_id, cantidad) VALUES (1, 3, 2); funciona: la vista une cuatro tablas, pero el trigger sabe que solo hay que escribir en lineas_pedido y de dónde sacar el precio. Tres reglas: los INSTEAD OF solo existen sobre vistas, siempre son FOR EACH ROW, y hacen falta tres triggers distintos —INSERT, UPDATE, DELETE— si quieres que la vista sea escribible del todo. Existe también el sistema de reglas (CREATE RULE), anterior y hoy desaconsejado: reescribe la consulta antes de ejecutarla, con efectos sorprendentes cuando hay funciones volátiles de por medio.
- Orden de disparo y gestión
Cuando varios triggers compiten por el mismo momento sobre la misma tabla, PostgreSQL los ejecuta en orden alfabético por nombre.
flowchart LR
A["UPDATE productos<br/>SET precio = 13.20"] --> B["<b>BEFORE</b> ROW<br/>alfabético"] --> C["se escribe<br/>la fila"]
C --> D["<b>AFTER</b> ROW<br/>alfabético"] --> E["<b>AFTER</b> STATEMENT"]
B -.->|"RETURN NULL"| F["cancelado"]
Que el orden dependa del nombre es tan frágil como suena: renombrar trg_valida a trg_zvalida puede cambiar el comportamiento del sistema sin que nadie toque una línea de lógica. Si dos triggers dependen del orden entre sí, júntalos en uno solo; si no dependen, mejor.
| Tarea | Cómo |
|---|---|
| Listar los de una tabla | \d productos en psql, o SELECT tgname, tgenabled FROM pg_trigger WHERE tgrelid = 'productos'::regclass AND NOT tgisinternal; |
| Ver su definición | SELECT pg_get_triggerdef(oid) FROM pg_trigger WHERE tgname = 'trg_lineas_stock'; |
| Desactivar / reactivar | ALTER TABLE lineas_pedido DISABLE TRIGGER trg_lineas_stock; … ENABLE TRIGGER |
| Desactivar todos | ALTER TABLE lineas_pedido DISABLE TRIGGER ALL; (requiere ser superusuario para los internos) |
| Borrar | DROP TRIGGER trg_lineas_stock ON lineas_pedido; |
DISABLE TRIGGER es imprescindible para una carga masiva: importar un millón de líneas con el trigger de stock activo son un millón de UPDATE extra. Se desactiva, se carga, se recalculan los agregados de una vez y se reactiva. Y viene con una trampa: mientras está desactivado, cualquier escritura normal también lo esquiva, así que hazlo en una ventana controlada y no lo olvides encendido.
Nombra los triggers con prefijo y patrón: trg_<tabla>_<qué hace>. En una base con doscientos objetos, encontrar por qué un UPDATE hace algo raro empieza por poder listar los triggers de esa tabla y entender sus nombres.
- Los peligros
Merecen el mismo espacio que las ventajas, porque un trigger mal puesto es de las cosas más difíciles de depurar que existen.
- Lógica invisible. "El
UPDATEhizo algo que yo no escribí" es la frase que define el problema. El SQL de la aplicación no menciona el trigger por ninguna parte; quien depura tiene que sospechar que existe. Es la razón por la que muchos equipos los limitan a auditoría e integridad, que son usos previsibles. - Coste por fila.
FOR EACH ROWsignifica una ejecución por cada fila. UnUPDATEque toca 500 000 filas ejecuta 500 000 veces la función, con sus consultas dentro. Lo que en una fila tarda 0,2 ms, en medio millón son 100 segundos. - Cascadas y recursión. Un trigger sobre
lineas_pedidoque actualizapedidospuede disparar un trigger sobrepedidosque actualizalineas_pedido... y así hastastack depth limit exceeded. Un trigger que modifica su propia tabla es directamente recursivo; se corta con unWHENque detecte que no hay nada que hacer, o conpg_trigger_depth() = 0. - Difíciles de probar. No se pueden invocar aisladamente: hay que provocar la operación que los dispara, y las pruebas acaban siendo de integración.
- Transaccionales, para bien y para mal. Un trigger corre dentro de la transacción que lo dispara: si falla, revierte todo, incluida la operación original. Es lo que quieres para integridad; es un desastre si dentro llamas a un servicio externo o envías un correo (10-04, ejercicio 3).
- Interacción con
COPYy con las FK.COPYdispara los triggers de fila, así que una importación puede ser mucho más lenta de lo esperado. Y las claves foráneas de PostgreSQL están implementadas como triggers internos, que no aparecen en\dpero sí enpg_triggercontgisinternal = true.
| Un trigger es la respuesta correcta cuando… | Es un parche cuando… |
|---|---|
| La regla debe cumplirse escriba quien escriba | Solo hay una vía de escritura: ponlo ahí y se ve |
| Hay que auditar cambios de forma incontestable | Sustituye a un CHECK o a una FK que sí podrían expresarlo |
Un CHECK no puede porque consulta otra tabla |
Encadena efectos en cascada difíciles de seguir |
| Mantiene un dato derivado que ya has decidido desnormalizar | Se usa para parchear datos que la aplicación envía mal |
Se necesita INSTEAD OF sobre una vista |
Contiene lógica de negocio compleja: eso es un procedimiento |
Nota de dialecto: el concepto es universal, la sintaxis no. PostgreSQL separa función y trigger, y es el único de la lista que lo hace. MySQL 8 pone el cuerpo dentro del
CREATE TRIGGER, solo admiteFOR EACH ROWy no tieneINSTEAD OFni triggers de sentencia. SQL Server trabaja por sentencia con las pseudotablasINSERTEDyDELETEDen lugar deNEW/OLD, y sí tieneINSTEAD OF. Oracle usa:NEWy:OLDcon dos puntos y añade los compound triggers. SQLite los tiene, conFOR EACH ROWobligatorio y sinTRUNCATE.
Errores Comunes y Consejos
- Olvidar el
RETURNen unBEFORE ... FOR EACH ROW. Caer al final de la función devuelveNULL, y eso cancela la operación en silencio: filas que no se insertan sin ningún error. - Usar
NEWen unDELETEuOLDen unINSERT.record "new" is not assigned yet. Pregunta porTG_OPo usaCOALESCE(NEW.x, OLD.x). - Crear un trigger de validación y no revisar los datos existentes. Solo se aplica al futuro; las filas que ya incumplen la regla se quedan.
- Desnormalizar sin el relleno inicial. El trigger mantiene la columna a partir de ahora, pero las 20 filas anteriores se quedan a 0.
- Comparar con
<>en vez deIS DISTINCT FROMen elWHEN. ConNULLimplicado,<>no es verdadero y el cambio no se audita. - Poner un
FOR EACH ROWdonde bastaba unFOR EACH STATEMENT, o meter una consulta cara dentro de un trigger de fila. Multiplica por el número de filas. - Hacer llamadas externas desde un trigger. Corre dentro de la transacción: si esta se deshace, el correo ya se envió y no se puede desenviar.
- Depender del orden alfabético entre dos triggers. Es frágil. Si el orden importa, unifícalos.
- Dejar
DISABLE TRIGGER ALLpuesto. La carga masiva termina y nadie reactiva; a partir de ahí las reglas no se aplican y nadie se entera hasta meses después. - Consejo: prefijo
trg_y patróntrg_<tabla>_<acción>. Y documenta en elCREATE TABLE, conCOMMENT ON TRIGGER, qué hace y por qué. - Consejo:
RAISE EXCEPTIONcon mensaje claro, noRETURN NULL. El silencio es el peor mensaje de error posible. - Consejo: ante un comportamiento inexplicable,
\d tablaes el primer comando. Los triggers salen listados ahí, y muchas veces la explicación está en esa línea.
Ejercicios
Ejercicio 1
Diseña la auditoría completa de productos: una tabla auditoria_productos que registre cualquier INSERT, UPDATE o DELETE con la operación, la fila antigua y la nueva en formato texto, el usuario y el momento. (1) ¿BEFORE o AFTER? ¿ROW o STATEMENT? (2) Escribe la función y el trigger usando TG_OP. (3) ¿Cómo la harías genérica para servir también a clientes y pedidos sin duplicar código?
Ejercicio 2
Sobre el trigger de reseñas del apartado 6, responde razonando y comprueba: (1) ¿Qué pasa si intentas insertar la reseña de Hugo (cliente 14) sobre el producto 1? ¿Y si el cliente 1 reseña el producto 1? (2) ¿Por qué el trigger es BEFORE y no AFTER? ¿Funcionaría igual siendo AFTER? (3) El pedido 6 está cancelado: si su cliente intentara reseñar un producto que solo compró en ese pedido, ¿pasaría la validación? Localiza en la consulta la línea responsable.
Ejercicio 3
Un compañero ha puesto este trigger sobre productos y ahora ningún UPDATE termina:
CREATE OR REPLACE FUNCTION fn_marca_revision() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
UPDATE productos SET fecha_alta = CURRENT_DATE WHERE id = NEW.id;
RETURN NEW;
END; $$;
CREATE TRIGGER trg_productos_revision AFTER UPDATE ON productos
FOR EACH ROW EXECUTE FUNCTION fn_marca_revision();(1) ¿Qué error da y por qué? (2) Reescríbelo bien, sin UPDATE interno. (3) ¿En qué otro caso, además de este, un trigger puede entrar en bucle?
Soluciones
Solución 1
1. AFTER ... FOR EACH ROW. AFTER porque solo se audita lo que ocurrió de verdad —en BEFORE, la operación aún podría fallar por un CHECK o una FK y quedaría un registro de algo que nunca pasó— y FOR EACH ROW porque se audita cada fila, no cada sentencia. 2:
CREATE TABLE auditoria_productos ( -- ⚠️ tabla de ejemplo, NO canónica
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tabla TEXT, operacion TEXT, fila_id INTEGER,
valor_ant TEXT, valor_nuevo TEXT, usuario TEXT, momento TIMESTAMPTZ
);
CREATE OR REPLACE FUNCTION fn_audita_generica() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO auditoria_productos (tabla, operacion, fila_id, valor_ant, valor_nuevo, usuario, momento)
VALUES (TG_TABLE_NAME, TG_OP,
COALESCE(NEW.id, OLD.id),
CASE WHEN TG_OP <> 'INSERT' THEN OLD::TEXT END,
CASE WHEN TG_OP <> 'DELETE' THEN NEW::TEXT END,
current_user, now());
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_productos_audita AFTER INSERT OR UPDATE OR DELETE ON productos
FOR EACH ROW EXECUTE FUNCTION fn_audita_generica();3. Ya lo es. La función no menciona productos por ninguna parte: usa TG_TABLE_NAME para saber de dónde viene y NEW::TEXT / OLD::TEXT para volcar la fila entera sea cual sea su estructura. Basta con crear otro CREATE TRIGGER idéntico sobre clientes y pedidos. Los dos CASE son necesarios porque OLD no existe en un INSERT ni NEW en un DELETE. En un sistema real se guardaría to_jsonb(NEW) en una columna JSONB en lugar de texto, y así la auditoría sería consultable clave a clave — que es 10-06.
Solución 2
1. La de Hugo falla con ERROR: El cliente 14 no ha comprado el producto 1: no tiene ningún pedido. La del cliente 1 sobre el producto 1 pasa, porque el aceite está en la línea 1 de su pedido 1. De hecho esa reseña ya existe: es la número 1 de las doce.
2. Es BEFORE porque valida, y validar es rechazar antes de escribir. Siendo AFTER también funcionaría —el RAISE EXCEPTION abortaría la transacción y la fila insertada se desharía—, pero se habría hecho trabajo inútil: escribir la fila, actualizar sus índices y deshacerlo todo. BEFORE para validar, AFTER para reaccionar, y aquí además BEFORE deja abierta la puerta a corregir NEW en lugar de rechazarlo.
3. No pasaría, por la línea AND pe.estado <> 'cancelado'. Es una decisión de negocio deliberada: un pedido cancelado no es una compra, así que no da derecho a reseñar. Y es exactamente el tipo de matiz que solo cabe en un trigger: un CHECK no puede consultar pedidos.estado, y una clave foránea tampoco.
Solución 3
1. Da ERROR: stack depth limit exceeded. El trigger se dispara AFTER UPDATE sobre productos y hace un UPDATE sobre productos, que vuelve a dispararlo, que vuelve a actualizar… hasta agotar la pila. Es la recursión directa del apartado 10, y no hay ninguna protección automática contra ella.
2. La forma correcta es no actualizar nada: modificar la fila que se está escribiendo se hace en un BEFORE, cambiando NEW y devolviéndolo.
CREATE OR REPLACE FUNCTION fn_marca_revision() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.fecha_alta := CURRENT_DATE; -- se guarda con la propia fila: sin UPDATE, sin recursión
RETURN NEW;
END; $$;
CREATE TRIGGER trg_productos_revision BEFORE UPDATE ON productos
FOR EACH ROW EXECUTE FUNCTION fn_marca_revision();Este es el patrón canónico de la columna actualizado_en que llevan casi todas las tablas de producción, y la razón por la que se implementa con BEFORE y no con AFTER.
3. Con cascadas entre tablas: A escribe en B, un trigger de B escribe en A, y el ciclo se cierra sin que ninguno se modifique a sí mismo. También con ON DELETE CASCADE combinado con triggers de borrado, y con dos triggers que se reactivan mutuamente en la misma tabla. Las defensas: WHEN que corte cuando ya no hay nada que cambiar (WHEN (OLD.total IS DISTINCT FROM NEW.total)), la comprobación IF pg_trigger_depth() > 1 THEN RETURN NULL; END IF;, o rediseñar para que el ciclo no exista.
Conclusión
Código que se ejecuta solo, con todo lo que eso implica:
- Un trigger son dos objetos: una función que devuelve
TRIGGERy unCREATE TRIGGERque dice sobre qué tabla, cuándo y con qué granularidad se ejecuta. La separación permite que una función genérica sirva a varias tablas, conTG_TABLE_NAMEyTG_OP. - Los cuatro ejes: cuándo (
BEFOREvalida y modifica,AFTERreacciona,INSTEAD OFsustituye), qué (INSERT/UPDATE/DELETE/TRUNCATE), granularidad (FOR EACH ROWfrente aFOR EACH STATEMENT) yWHEN (condición), que evita disparar en balde. NEWyOLDno siempre están las dos, y el valor de retorno es el mecanismo clave: en unBEFORE ... FOR EACH ROW,RETURN NULLcancela la operación yRETURN NEWmodificado la altera. EnAFTERse ignora.- Los cuatro casos de TiendaVerde: auditoría de cambios de precio con
WHEN (OLD.precio IS DISTINCT FROM NEW.precio)—el uso más incontestable—;pedidos.totaldesnormalizado que suma 727,95 € y exige relleno inicial; la regla de la reseña que unCHECKno puede expresar porque consulta otra tabla, cerrando 09-02; y el descuento de stock, con la comparación honesta frente alsp_confirmar_pedidode 10-04. - Los
INSTEAD OFhacen escribibles las vistas que 10-01 no podía actualizar automáticamente: solo sobre vistas, siempreFOR EACH ROW, y uno por operación. - El orden de disparo es alfabético por nombre, lo cual es frágil: si dos triggers dependen del orden, unifícalos. Se listan con
\dy conpg_trigger, se apagan conALTER TABLE ... DISABLE TRIGGERpara las cargas masivas y se nombrantrg_<tabla>_<acción>. - Y los peligros, con el mismo peso: lógica invisible que sorprende a quien depura, coste por fila en cargas masivas, cascadas y recursión hasta
stack depth limit exceeded, dificultad para probar, y el hecho de que corren dentro de la transacción que los dispara. Un trigger es la respuesta correcta cuando la regla debe cumplirse escriba quien escriba; es un parche cuando sustituye a unCHECKo esconde lógica de negocio que debería verse.
Con vistas, CTE, funciones de ventana, procedimientos y triggers, la caja de herramientas de SQL relacional está casi completa. Falta un caso que el modelo relacional lleva mal por diseño: los datos cuya forma no es fija. Un aceite tiene acidez y variedad; una crema, ingredientes y tipo de piel; el año que viene alguien querrá guardar la huella de carbono de cada producto. Añadir una columna por atributo es inviable, y montar una tabla de pares clave-valor es el remedio clásico y doloroso. En la última lección del módulo, JSON y datos semiestructurados, verás el tipo JSONB de PostgreSQL: cómo construir documentos, cómo acceder a ellos con ->, ->> y JSONPath, cómo indexarlos con GIN —la promesa pendiente de 08-03—, cómo convertirlos de nuevo en filas para seguir usando todo el SQL del curso, y —lo más importante— qué no debe ir nunca en un JSON.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
