Hasta aquí tus consultas viven en tu archivo, en tu laptop. Este capítulo va de lo contrario: cosas que se guardan dentro de la base y que están ahí para todo el mundo, corra la consulta quien la corra 🏠
Son tres, y en SQLite solo hay dos, que es la sorpresa del capítulo.
Una vista es una consulta con nombre
En el capítulo 7 escribimos el resumen por ciudad. Es de esas consultas que pides una vez y después la quiere todo el mundo, y cada persona la reescribe un poquito distinta y salen tres números distintos para la misma pregunta 🫠
CREATE VIEW ventas_por_ciudad AS
SELECT c.ciudad,
COUNT(DISTINCT c.id) AS clientes,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.ciudad;
SELECT * FROM ventas_por_ciudad ORDER BY soles DESC;
ciudad clientes pedidos soles -------- -------- ------- --------- Chiclayo 25 212 115979.43 Piura 24 167 107165.17 Arequipa 22 168 101432.51 Cusco 17 112 64966.23 Trujillo 17 113 64150.66 Lima 15 104 63748.53
A partir de ahora ventas_por_ciudad se usa como si fuera una
tabla, y nadie más tiene que acordarse del LEFT JOIN ni del
COUNT(DISTINCT).
SELECT ciudad, soles FROM ventas_por_ciudad WHERE soles > 100000 ORDER BY soles DESC;
ciudad soles -------- --------- Chiclayo 115979.43 Piura 107165.17 Arequipa 101432.51
Una vista no guarda datos. Es un apodo: cada vez que la consultas, la base pega tu consulta encima de la de dentro y ejecuta todo junto. Por eso siempre está al día, y por eso no es más rápida que escribir la consulta a mano.
Para qué la uso yo, que es la parte que casi nunca te cuentan:
- 📐 Para que "cliente activo" signifique lo mismo en los cinco reportes. La definición vive en un sitio.
- 🧼 Para esconder la suciedad. Una vista que ya venga con el
UPPER(TRIM(...))del capítulo 5 y nadie tenga que acordarse. - 🔐 Para dar acceso a una parte. Le das permiso a la vista y no a la tabla, y esa persona ve las columnas que le tocan y ninguna más.
CREATE VIEW clientes_limpios AS SELECT id, UPPER(TRIM(nombre)) AS nombre, ciudad, segmento, fecha_alta FROM clientes; SELECT id, nombre FROM clientes_limpios WHERE id IN (1, 12, 26) ORDER BY id;
id nombre -- --------------------- 1 MINIMARKET EL SOL 001 12 MARKET CENTRAL 012 26 MINIMARKET EL SOL 026
Los dieciocho nombres sucios del capítulo 5, arreglados de una vez y para todos, sin tocar la tabla original. Esa es mi vista favorita de este libro 🧽
Lo que no se puede hacer con una vista es escribir a través de ella.
INSERT INTO clientes_limpios (id, nombre, ciudad, segmento, fecha_alta) VALUES (200, 'NUEVA', 'Lima', 'Bodega', '2026-06-24');
OperationalError: cannot modify clientes_limpios because it is a view
¿Se puede escribir a través de una vista?
| PostgreSQL | sí si la vista es simple; si no, con un trigger INSTEAD OF |
| MySQL | sí si la vista es simple, o sea sin GROUP BY, DISTINCT ni funciones raras |
| SQL Server | sí si es simple, o con un trigger INSTEAD OF |
| SQLite | nunca directamente: siempre hace falta un trigger INSTEAD OF |
Que una vista "simple" sea escribible y una complicada no, y que dónde está la raya lo decida cada motor, es de las cosas que hacen que yo trate las vistas como solo lectura y listo.
SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name;
name ----------------- clientes_limpios ventas_por_ciudad
DROP VIEW clientes_limpios; SELECT COUNT(*) AS vistas FROM sqlite_master WHERE type = 'view';
vistas ------ 1
La vista que sí guarda datos
Como una vista se recalcula entera cada vez, si por dentro tiene un
GROUP BY sobre diez millones de filas, cada persona que abra el
dashboard paga ese GROUP BY. Para eso existen las vistas
materializadas, que sí guardan el resultado.
CREATE MATERIALIZED VIEW ventas_cache AS SELECT 1;
OperationalError: near "MATERIALIZED": syntax error
Vistas materializadas
| PostgreSQL | CREATE MATERIALIZED VIEW y REFRESH MATERIALIZED VIEW cuando quieras |
| MySQL | no las tiene: se hace con una tabla normal y un proceso que la rellena |
| SQL Server | vistas indexadas: CREATE VIEW ... WITH SCHEMABINDING y un índice único encima |
| SQLite | no las tiene |
La de PostgreSQL se refresca cuando tú digas, así que el dato puede estar viejo y hay que decidir cada cuánto. La de SQL Server se mantiene sola en cada escritura, que es más cómodo y más caro. En MySQL y SQLite lo haces a mano con INSERT INTO ... SELECT del capítulo 10.
Los disparadores
Un disparador es código que la base ejecuta sola cuando pasa algo. Nadie lo llama: se dispara.
El caso que de verdad vale la pena es la auditoría: quiero saber quién cambió un precio y cuándo, sin depender de que el programa que lo cambió se acuerde de anotarlo.
CREATE TABLE precios (
id_producto INTEGER PRIMARY KEY,
precio REAL NOT NULL
);
CREATE TABLE precios_historial (
id INTEGER PRIMARY KEY,
id_producto INTEGER NOT NULL,
precio_viejo REAL,
precio_nuevo REAL,
cuando TEXT NOT NULL
);
CREATE TRIGGER tr_precios_cambio
AFTER UPDATE OF precio ON precios
FOR EACH ROW
BEGIN
INSERT INTO precios_historial (id_producto, precio_viejo, precio_nuevo, cuando)
VALUES (OLD.id_producto, OLD.precio, NEW.precio, '2026-06-24');
END;
INSERT INTO precios (id_producto, precio) SELECT id, precio FROM productos WHERE id <= 3;
SELECT * FROM precios ORDER BY id_producto;
id_producto precio ----------- ------ 1 7.08 2 15.92 3 59.12
Tres productos cargados y ni una fila en el historial, porque el disparador
solo mira los UPDATE. Ahora sube los precios.
UPDATE precios SET precio = precio * 1.10 WHERE id_producto <= 2; SELECT id_producto, ROUND(precio_viejo, 2) AS antes, ROUND(precio_nuevo, 2) AS despues, cuando FROM precios_historial ORDER BY id;
id_producto antes despues cuando ----------- ----- ------- ---------- 1 7.08 7.79 2026-06-24 2 15.92 17.51 2026-06-24
Yo no escribí ningún INSERT en el historial y ahí están las dos
filas 🪄
Las piezas del disparador, que son las mismas en los cuatro motores aunque se escriban distinto:
- ⏱️
AFTERoBEFORE: si corre después o antes del cambio. - 🎬 El evento:
INSERT,UPDATEoDELETE. Y se puede afinar a una columna, como aquí conUPDATE OF precio. - 🔁
FOR EACH ROW: se ejecuta una vez por fila cambiada, no una vez por sentencia. - 👯
OLDyNEW: la fila como estaba y como queda. En unINSERTsolo hayNEW; en unDELETE, soloOLD.
Y la otra cosa que hacen bien los disparadores: impedir algo.
CREATE TRIGGER tr_precio_no_negativo
BEFORE INSERT ON precios
FOR EACH ROW
WHEN NEW.precio < 0
BEGIN
SELECT RAISE(ABORT, 'el precio no puede ser negativo');
END;
INSERT INTO precios (id_producto, precio) VALUES (99, -5);
IntegrityError: el precio no puede ser negativo
El mensaje es el mío, escrito por mí. Ese WHEN es la condición
para que el disparador se moleste siquiera, y con precio positivo ni se
entera.
INSERT INTO precios (id_producto, precio) VALUES (99, 5); SELECT COUNT(*) AS filas FROM precios;
filas ----- 4
Aunque, siendo honesta, para este caso concreto un
CHECK (precio >= 0) del capítulo 10 es más simple, más rápido y
se lee mejor. Antes de escribir un disparador, pregúntate si un
CHECK, un DEFAULT o una clave foránea hacen el
trabajo, porque casi siempre sí 💛
Escribir un disparador
| PostgreSQL | el cuerpo va en una FUNCTION aparte y el TRIGGER la llama: son dos objetos, siempre |
| MySQL | el cuerpo va dentro, y hace falta cambiar el DELIMITER para poder escribirlo |
| SQL Server | trabaja con las tablas INSERTED y DELETED, no con OLD y NEW fila a fila |
| SQLite | el cuerpo va dentro, entre BEGIN y END, con OLD y NEW |
Lo de SQL Server cambia la cabeza y no solo la sintaxis: sus disparadores se ejecutan una vez por SENTENCIA y te dan dos tablas con todas las filas afectadas, así que el código que escribas ahí es un UPDATE de conjunto y no un "para cada fila".
Abortar desde un disparador con tu propio mensaje
| PostgreSQL | RAISE EXCEPTION 'el precio no puede ser negativo' |
| MySQL | SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'el precio no puede ser negativo' |
| SQL Server | THROW 50000, 'el precio no puede ser negativo', 1 |
| SQLite | SELECT RAISE(ABORT, 'el precio no puede ser negativo') |
Cuatro formas distintas de decir la misma frase. La de MySQL es la que nadie se aprende: ese 45000 es el código de "error definido por el usuario" y hay que ponerlo tal cual.
SELECT name, tbl_name FROM sqlite_master WHERE type = 'trigger' ORDER BY name;
name tbl_name --------------------- -------- tr_precio_no_negativo precios tr_precios_cambio precios
DROP TRIGGER tr_precio_no_negativo; SELECT COUNT(*) AS disparadores FROM sqlite_master WHERE type = 'trigger';
disparadores ------------ 1
Y el aviso que va con los disparadores, que es serio: un disparador es código que corre sin que nadie lo llame y que no se ve en ninguna consulta. Cuando alguien pase tres días buscando por qué una tabla cambia sola, va a ser esto. Ponles nombres que se entiendan, apúntalos en algún sitio y úsalos poco.
Y los procedimientos, que en SQLite no existen
CREATE PROCEDURE resumen_canal()
BEGIN
SELECT canal, COUNT(*) FROM pedidos GROUP BY canal;
END;
OperationalError: near "PROCEDURE": syntax error
CREATE FUNCTION doble(x INT) RETURNS INT RETURN x * 2;
OperationalError: near "FUNCTION": syntax error
Ninguna de las dos. SQLite no tiene procedimientos almacenados ni funciones definidas por el usuario en SQL, y no es un olvido: es la decisión de diseño de caber en un archivo y no traer un lenguaje de programación dentro.
En SQLite, esa lógica va en tu programa: en Python, en tu app, donde sea. Y
la verdad es que hoy mucha gente lo hace así incluso teniendo motores que sí los
soportan, porque el código en un archivo .py se versiona en git, se
revisa en un pull request y se prueba; el código dentro de la base, no 🤷♀️
Procedimientos almacenados
| PostgreSQL | CREATE PROCEDURE y CREATE FUNCTION, en PL/pgSQL y hasta en Python |
| MySQL | CREATE PROCEDURE, cambiando antes el DELIMITER |
| SQL Server | CREATE PROCEDURE en T-SQL, y es donde más se usan de los cuatro |
| SQLite | NO EXISTEN |
Esta es la diferencia más grande de todo el libro: no es que se escriba distinto, es que en uno de los cuatro no está. Si vienes de SQL Server, donde media la lógica de negocio suele vivir en procedimientos, el cambio de cabeza es enorme.
Ejercicios
Siete sobre tu copia. Intenta antes de abrir 💛
1. Tu vista de clientes activos
Una vista con los clientes que han comprado alguna vez, su número de pedidos y sus soles.
CREATE VIEW clientes_activos AS
SELECT c.id, TRIM(c.nombre) AS nombre, c.ciudad, c.segmento,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles
FROM clientes c
JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad, c.segmento;
SELECT COUNT(*) AS activos FROM clientes_activos;
activos ------- 119
119, que son los 120 menos Comercial Rojas 120 del capítulo 7. Y ahora "cliente activo" quiere decir lo mismo para todo el que consulte esta base, que es exactamente para lo que sirve una vista 🌟
2. Consultar la vista como si fuera tabla
Los cinco activos que más gastaron, en Chiclayo.
SELECT nombre, pedidos, soles FROM clientes_activos WHERE ciudad = 'Chiclayo' ORDER BY soles DESC LIMIT 5;
nombre pedidos soles ---------------------- ------- ------- Market Central 087 12 8148.56 MARKET CENTRAL 078 14 7977.9 Autoservicio Norte 008 14 7494.38 Bodega San Martin 048 14 7328.96 Market Central 064 11 6941.42
Mira lo corta que quedó la consulta. Todo el JOIN y el
GROUP BY están escondidos en la vista, y quien pregunta solo tiene
que saber filtrar y ordenar.
3. Una vista sobre otra vista
Los activos que están por encima de S/8.000, apoyándote en la vista anterior.
CREATE VIEW clientes_top AS SELECT * FROM clientes_activos WHERE soles > 8000; SELECT nombre, ciudad, soles FROM clientes_top ORDER BY soles DESC;
nombre ciudad soles -------------------------- -------- -------- Distribuidora Paz 058 Lima 10131.72 Almacenes Vega 067 Piura 9516.06 Market Central 087 Chiclayo 8148.56 Restaurante Miraflores 093 Trujillo 8119.95
Sí, se pueden encadenar. Y también es fácil pasarse: cinco vistas una encima
de otra son imposibles de depurar cuando algo sale mal, porque el
EXPLAIN te muestra la consulta expandida entera y no se parece a
nada de lo que escribiste 😵💫
4. Un disparador que lleva la cuenta
Una tabla de pedidos nuevos que mantenga sola un contador por canal.
CREATE TABLE pedidos_nuevos (id INTEGER PRIMARY KEY, canal TEXT NOT NULL, monto REAL NOT NULL);
CREATE TABLE contador_canal (canal TEXT PRIMARY KEY, pedidos INTEGER NOT NULL);
CREATE TRIGGER tr_contar_pedido
AFTER INSERT ON pedidos_nuevos
FOR EACH ROW
BEGIN
INSERT INTO contador_canal (canal, pedidos) VALUES (NEW.canal, 1)
ON CONFLICT (canal) DO UPDATE SET pedidos = contador_canal.pedidos + 1;
END;
INSERT INTO pedidos_nuevos (canal, monto) VALUES ('Web', 100), ('Web', 200), ('Tienda', 50);
SELECT * FROM contador_canal ORDER BY canal;
canal pedidos ------ ------- Tienda 1 Web 2
Un disparador con el upsert del capítulo 11 adentro. Web en 2 y Tienda en 1,
y yo solo escribí tres INSERT en la otra tabla.
Así se llevan los contadores de verdad en producción, y también así nacen los
misterios: dentro de un año, quien vea contador_canal no va a saber
de dónde salen esos números 🕵️♀️
5. Un disparador que borra en cascada
Al borrar un pedido nuevo, que su contador baje solo.
CREATE TRIGGER tr_descontar_pedido
AFTER DELETE ON pedidos_nuevos
FOR EACH ROW
BEGIN
UPDATE contador_canal SET pedidos = pedidos - 1 WHERE canal = OLD.canal;
END;
DELETE FROM pedidos_nuevos WHERE canal = 'Web' AND monto = 100;
SELECT * FROM contador_canal ORDER BY canal;
canal pedidos ------ ------- Tienda 1 Web 1
Aquí NEW no existe porque no hay fila nueva: en un
DELETE solo tienes OLD. Y en un INSERT es
al revés.
6. El disparador que protege
Impide que nadie deje un monto en cero en pedidos nuevos.
CREATE TRIGGER tr_monto_positivo
BEFORE INSERT ON pedidos_nuevos
FOR EACH ROW
WHEN NEW.monto <= 0
BEGIN
SELECT RAISE(ABORT, 'un pedido no puede valer cero');
END;
INSERT INTO pedidos_nuevos (canal, monto) VALUES ('Web', 0);
IntegrityError: un pedido no puede valer cero
Y fíjate en algo importante: como es BEFORE, la fila no llegó a
entrar, así que el contador del ejercicio 4 tampoco se movió. El orden de los
disparadores importa.
7. Escríbelo para los cuatro
Sin ejecutar: la misma vista y el mismo disparador de auditoría, en los cuatro motores.
-- La VISTA se escribe igual en los cuatro. Esta es de las buenas.
CREATE VIEW ventas_por_ciudad AS
SELECT c.ciudad, COUNT(p.id) AS pedidos, SUM(p.monto) AS soles
FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.ciudad;
-- El DISPARADOR, no. PostgreSQL pide una función aparte:
CREATE FUNCTION auditar_precio() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO precios_historial (id_producto, precio_viejo, precio_nuevo, cuando)
VALUES (OLD.id_producto, OLD.precio, NEW.precio, CURRENT_DATE);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_precios_cambio AFTER UPDATE OF precio ON precios
FOR EACH ROW EXECUTE FUNCTION auditar_precio();
-- MySQL: el cuerpo va dentro, pero hay que cambiar el DELIMITER
DELIMITER //
CREATE TRIGGER tr_precios_cambio AFTER UPDATE ON precios
FOR EACH ROW
BEGIN
INSERT INTO precios_historial (id_producto, precio_viejo, precio_nuevo, cuando)
VALUES (OLD.id_producto, OLD.precio, NEW.precio, CURRENT_DATE);
END//
DELIMITER ;
-- SQL Server: una vez por sentencia, con las tablas INSERTED y DELETED
CREATE TRIGGER tr_precios_cambio ON precios AFTER UPDATE AS
BEGIN
INSERT INTO precios_historial (id_producto, precio_viejo, precio_nuevo, cuando)
SELECT d.id_producto, d.precio, i.precio, CAST(GETDATE() AS DATE)
FROM deleted d JOIN inserted i ON i.id_producto = d.id_producto;
END;
Lo de MySQL merece una nota: ese DELIMITER // existe porque el
cliente de MySQL corta las sentencias en el punto y coma, y el cuerpo del
disparador tiene punto y comas dentro. Así que hay que decirle "por un rato, el
final de sentencia es //". Es puro cliente, no es SQL 🫠
Y mira el de SQL Server: no hay OLD ni NEW, hay un
JOIN entre deleted e inserted. Es otra
forma de pensarlo, y es la que hay que aprender si trabajas ahí.
Lo que te llevas
- 🏷️ Una vista es una consulta con nombre. No guarda datos, siempre está al día y no acelera nada.
- 📐 Sirve para que una definición de negocio signifique lo mismo para todos, para esconder la limpieza y para dar acceso a una parte.
- 💾 Las materializadas sí guardan el resultado: están en PostgreSQL y (a su manera) en SQL Server. En MySQL y SQLite se hacen a mano con una tabla.
- 🪄 Un disparador corre solo cuando pasa algo, con
OLDyNEWa mano. Sirve para auditar y para impedir. - 🛑 Antes de escribir uno, mira si un
CHECK, unDEFAULTo una clave foránea hacen el trabajo. Casi siempre sí. - 👻 Un disparador es código invisible desde la consulta. Nómbralos bien y úsalos poco.
- 🚫 SQLite no tiene procedimientos almacenados. Esa lógica va en tu programa, que además se versiona y se prueba.
En el capítulo 14 juntamos todo: un análisis completo de la tienda de punta a punta, con las preguntas que de verdad te van a hacer y las consultas que las contestan.
Que tengas lindo día! 🌸