Capítulo 13 de 15 6 secciones 15 min

Vistas, disparadores y lo que SQLite no tiene

Consultas con nombre, código que corre solo, y los procedimientos almacenados que en uno de los cuatro motores no existen.

Una vista es una consulta con nombre: no guarda datos, siempre está al día y sirve para que una definición signifique lo mismo en todos los reportes. Un disparador es código que la base ejecuta sola cuando pasa algo, con OLD y NEW a mano. Y la diferencia más grande del libro: SQLite no tiene procedimientos almacenados, esa lógica va en tu programa.

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?

PostgreSQLsí si la vista es simple; si no, con un trigger INSTEAD OF
MySQLsí si la vista es simple, o sea sin GROUP BY, DISTINCT ni funciones raras
SQL Serversí si es simple, o con un trigger INSTEAD OF
SQLitenunca 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

PostgreSQLCREATE MATERIALIZED VIEW y REFRESH MATERIALIZED VIEW cuando quieras
MySQLno las tiene: se hace con una tabla normal y un proceso que la rellena
SQL Servervistas indexadas: CREATE VIEW ... WITH SCHEMABINDING y un índice único encima
SQLiteno 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:

  • ⏱️ AFTER o BEFORE: si corre después o antes del cambio.
  • 🎬 El evento: INSERT, UPDATE o DELETE. Y se puede afinar a una columna, como aquí con UPDATE OF precio.
  • 🔁 FOR EACH ROW: se ejecuta una vez por fila cambiada, no una vez por sentencia.
  • 👯 OLD y NEW: la fila como estaba y como queda. En un INSERT solo hay NEW; en un DELETE, solo OLD.

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

PostgreSQLel cuerpo va en una FUNCTION aparte y el TRIGGER la llama: son dos objetos, siempre
MySQLel cuerpo va dentro, y hace falta cambiar el DELIMITER para poder escribirlo
SQL Servertrabaja con las tablas INSERTED y DELETED, no con OLD y NEW fila a fila
SQLiteel 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

PostgreSQLRAISE EXCEPTION 'el precio no puede ser negativo'
MySQLSIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'el precio no puede ser negativo'
SQL ServerTHROW 50000, 'el precio no puede ser negativo', 1
SQLiteSELECT 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

PostgreSQLCREATE PROCEDURE y CREATE FUNCTION, en PL/pgSQL y hasta en Python
MySQLCREATE PROCEDURE, cambiando antes el DELIMITER
SQL ServerCREATE PROCEDURE en T-SQL, y es donde más se usan de los cuatro
SQLiteNO 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 OLD y NEW a mano. Sirve para auditar y para impedir.
  • 🛑 Antes de escribir uno, mira si un CHECK, un DEFAULT o 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! 🌸

¿Tienes alguna duda o consulta?