Tema
Triggers de auditoría
Un trigger es código SQL que la base de datos ejecuta sola cuando ocurre un INSERT, UPDATE o DELETE. Con ellos puedes llevar un historial de cambios (quién cambió qué, cuándo y cuál era el valor anterior) sin tocar el código de tu aplicación, y sin que nadie pueda saltárselo.
Casos reales: "¿quién cambió el precio de este producto?", "¿cuándo se canceló este pedido?", "recupera la descripción que había ayer".
Tabla de auditoría genérica
sql
CREATE TABLE auditoria (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tabla VARCHAR(64) NOT NULL,
registro_id INT UNSIGNED NOT NULL,
accion ENUM('INSERT','UPDATE','DELETE') NOT NULL,
antes JSON NULL, -- valores previos
despues JSON NULL, -- valores nuevos
usuario_id INT UNSIGNED NULL, -- usuario de la APLICACIÓN (ver más abajo)
usuario_db VARCHAR(100) NULL, -- usuario de MySQL
creado_en TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_aud_registro (tabla, registro_id),
KEY idx_aud_fecha (creado_en)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;¿Quién hizo el cambio?
La base de datos no sabe qué usuario de tu app inició sesión. La solución: antes de cada operación, tu aplicación define una variable de sesión de MySQL que los triggers leen.
php
// Justo después de conectar (o al inicio de cada petición autenticada)
db()->prepare('SET @usuario_app = ?')->execute([$_SESSION['usuario_id'] ?? null]);js
// Con pool, usa la misma conexión para el SET y la consulta
const conn = await pool.getConnection();
try {
await conn.query('SET @usuario_app = ?', [req.usuario.id]);
await conn.execute('UPDATE productos SET precio = ? WHERE id = ?', [199, 5]);
} finally {
conn.release();
}Triggers para la tabla productos (MySQL)
sql
DELIMITER $$
CREATE TRIGGER productos_ai AFTER INSERT ON productos
FOR EACH ROW
BEGIN
INSERT INTO auditoria (tabla, registro_id, accion, despues, usuario_id, usuario_db)
VALUES ('productos', NEW.id, 'INSERT',
JSON_OBJECT('nombre', NEW.nombre, 'precio', NEW.precio, 'stock', NEW.stock, 'activo', NEW.activo),
@usuario_app, CURRENT_USER());
END$$
CREATE TRIGGER productos_au AFTER UPDATE ON productos
FOR EACH ROW
BEGIN
-- Sólo registra si cambió algo relevante (<=> compara también NULLs)
IF NOT (OLD.nombre <=> NEW.nombre AND OLD.precio <=> NEW.precio
AND OLD.stock <=> NEW.stock AND OLD.activo <=> NEW.activo) THEN
INSERT INTO auditoria (tabla, registro_id, accion, antes, despues, usuario_id, usuario_db)
VALUES ('productos', NEW.id, 'UPDATE',
JSON_OBJECT('nombre', OLD.nombre, 'precio', OLD.precio, 'stock', OLD.stock, 'activo', OLD.activo),
JSON_OBJECT('nombre', NEW.nombre, 'precio', NEW.precio, 'stock', NEW.stock, 'activo', NEW.activo),
@usuario_app, CURRENT_USER());
END IF;
END$$
CREATE TRIGGER productos_ad AFTER DELETE ON productos
FOR EACH ROW
BEGIN
INSERT INTO auditoria (tabla, registro_id, accion, antes, usuario_id, usuario_db)
VALUES ('productos', OLD.id, 'DELETE',
JSON_OBJECT('nombre', OLD.nombre, 'precio', OLD.precio, 'stock', OLD.stock, 'activo', OLD.activo),
@usuario_app, CURRENT_USER());
END$$
DELIMITER ;¿Qué es DELIMITER?
Dentro del trigger hay varios ;. DELIMITER $$ le dice al cliente de MySQL que la instrucción termina en $$, no en ;. En phpMyAdmin, escribe $$ en el campo "Delimitador" bajo el editor SQL.
Triggers de reglas de negocio
Además de auditar, los triggers sirven para mantener datos coherentes:
sql
DELIMITER $$
-- Historial de estados de un pedido
CREATE TABLE IF NOT EXISTS pedido_historial (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
estado_anterior VARCHAR(20) NULL,
estado_nuevo VARCHAR(20) NOT NULL,
usuario_id INT UNSIGNED NULL,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY (pedido_id)
)$$
CREATE TRIGGER pedidos_estado_au AFTER UPDATE ON pedidos
FOR EACH ROW
BEGIN
IF NOT (OLD.estado <=> NEW.estado) THEN
INSERT INTO pedido_historial (pedido_id, estado_anterior, estado_nuevo, usuario_id)
VALUES (NEW.id, OLD.estado, NEW.estado, @usuario_app);
END IF;
END$$
-- Impide stock negativo con un error claro
CREATE TRIGGER productos_stock_bu BEFORE UPDATE ON productos
FOR EACH ROW
BEGIN
IF NEW.stock < 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'El stock no puede ser negativo';
END IF;
END$$
-- Devuelve el stock al cancelar un pedido
CREATE TRIGGER pedidos_cancelar_au AFTER UPDATE ON pedidos
FOR EACH ROW
BEGIN
IF NEW.estado = 'cancelado' AND OLD.estado <> 'cancelado' THEN
UPDATE productos pr
JOIN pedido_items pi ON pi.producto_id = pr.id
SET pr.stock = pr.stock + pi.cantidad
WHERE pi.pedido_id = NEW.id;
END IF;
END$$
DELIMITER ;Consultar el historial
sql
-- Historial completo de un producto
SELECT a.creado_en, a.accion, u.nombre AS usuario,
a.antes->>'$.precio' AS precio_antes,
a.despues->>'$.precio' AS precio_despues
FROM auditoria a
LEFT JOIN usuarios u ON u.id = a.usuario_id
WHERE a.tabla = 'productos' AND a.registro_id = 5
ORDER BY a.creado_en DESC;
-- Cambios de precio de la última semana
SELECT registro_id, antes->>'$.nombre' AS producto,
antes->>'$.precio' AS antes, despues->>'$.precio' AS despues, creado_en
FROM auditoria
WHERE tabla = 'productos' AND accion = 'UPDATE'
AND JSON_EXTRACT(antes, '$.precio') <> JSON_EXTRACT(despues, '$.precio')
AND creado_en >= NOW() - INTERVAL 7 DAY;
-- Ver los triggers existentes / eliminar uno
SHOW TRIGGERS LIKE 'productos';
DROP TRIGGER IF EXISTS productos_au;Versión PostgreSQL (una función para todas las tablas)
En PostgreSQL puedes escribir una sola función genérica y conectarla a cualquier tabla:
sql
CREATE TABLE auditoria (
id BIGSERIAL PRIMARY KEY,
tabla TEXT NOT NULL,
registro_id BIGINT,
accion TEXT NOT NULL,
antes JSONB,
despues JSONB,
usuario_id INT,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION fn_auditar() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO auditoria (tabla, registro_id, accion, antes, despues, usuario_id)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END,
NULLIF(current_setting('app.usuario_id', true), '')::INT -- SET app.usuario_id = '7' desde tu app
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Conectarla a las tablas que quieras auditar
CREATE TRIGGER trg_auditar_productos AFTER INSERT OR UPDATE OR DELETE ON productos
FOR EACH ROW EXECUTE FUNCTION fn_auditar();
CREATE TRIGGER trg_auditar_pedidos AFTER INSERT OR UPDATE OR DELETE ON pedidos
FOR EACH ROW EXECUTE FUNCTION fn_auditar();Desde Node con pg, dentro de una transacción: await cliente.query("SELECT set_config('app.usuario_id', $1, true)", [String(req.usuario.id)]).
Cuidado con el rendimiento
Cada trigger añade trabajo a cada escritura. Audita sólo las tablas importantes (productos, pedidos, usuarios, pagos), no tablas de mucho tráfico como logs o sesiones. Y programa una limpieza: DELETE FROM auditoria WHERE creado_en < NOW() - INTERVAL 1 YEAR.
Hosting compartido
Algunos hostings no permiten crear triggers (falta el privilegio TRIGGER o SUPER con binlog activo). Si es tu caso, registra la auditoría desde el código de tu aplicación con una función auditar($tabla, $id, $accion, $antes, $despues).