Proyecto: base de datos de una tienda
💡 🎯 Al terminar esta lección podrás…
- Diseñar un modelo de datos con varias tablas relacionadas.
- Crear la base con un script reproducible y operarla con transacciones.
- Escribir informes con
JOIN,GROUP BYyHAVING. - Aplicar medidas básicas de seguridad y copias.
1.El encargo
Una pequeña tienda de tecnología de tu ciudad lleva sus ventas en un cuaderno y quiere pasarlas a una base de datos. Te pide poder:
- Guardar el catálogo de productos con su categoría, precio y stock.
- Registrar a sus clientes, sin repetir sus datos.
- Anotar cada pedido con los productos y cantidades que incluye, y descontar el stock.
- Obtener informes: cuánto vende, quién compra más, qué se vende más y qué no se mueve.
- Estar protegida: datos coherentes, permisos limitados y copias de seguridad.
Es justo lo que has aprendido en las ocho lecciones. Aquí lo reúnes en un único proyecto, que funciona en SQLite (y con pocos cambios en PostgreSQL).
2.Paso 1: diseñar el modelo
Antes de escribir SQL, decide qué entidades hay y cómo se relacionan. Del encargo salen cuatro:
| Tabla | Qué guarda | Clave y restricciones |
|---|---|---|
productos | Catálogo: nombre, categoría, precio, stock | id PK; NOT NULL en casi todo; stock DEFAULT 0 |
clientes | Nombre y correo | id PK; email UNIQUE |
pedidos | Una compra: quién y cuándo | cliente_id FK a clientes |
lineas_pedido | Qué productos y cuántos hay en cada pedido | PK compuesta (pedido_id, producto_id); dos FK; CHECK (cantidad > 0) |
Las relaciones son: un cliente tiene muchos pedidos (uno a muchos) y un pedido contiene muchos productos y un producto está en muchos pedidos (muchos a muchos, resuelto con lineas_pedido).
💡 Consejo · Una decisión de diseño
El precio del producto está en productos, no en la línea del pedido. Pero entonces, si el precio cambia mañana, los informes de ventas pasadas cambiarían. En una tienda real guardarías también el precio vendido en lineas_pedido. Es un buen reto para el ejercicio.
3.Paso 2: crear la base de datos
Guarda todo en un único archivo tienda.sql. El script se puede ejecutar las veces que quieras: las líneas DROP TABLE IF EXISTS (en orden inverso a las dependencias) empiezan siempre desde cero. Este código se ha ejecutado de verdad en SQLite: crea 4 tablas con 8 productos, 4 clientes, 4 pedidos y 7 líneas.
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS lineas_pedido;
DROP TABLE IF EXISTS pedidos;
DROP TABLE IF EXISTS clientes;
DROP TABLE IF EXISTS productos;
CREATE TABLE productos (
id INTEGER PRIMARY KEY,
nombre TEXT NOT NULL,
categoria TEXT NOT NULL,
precio NUMERIC NOT NULL,
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE clientes (
id INTEGER PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY,
cliente_id INTEGER NOT NULL REFERENCES clientes(id),
fecha TEXT NOT NULL
);
CREATE TABLE lineas_pedido (
pedido_id INTEGER NOT NULL REFERENCES pedidos(id),
producto_id INTEGER NOT NULL REFERENCES productos(id),
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
PRIMARY KEY (pedido_id, producto_id)
);
INSERT INTO productos (id, nombre, categoria, precio, stock) VALUES
(1, 'Teclado', 'Periféricos', 120.00, 15),
(2, 'Ratón', 'Periféricos', 45.50, 40),
(3, 'Monitor FHD', 'Pantallas', 650.00, 8),
(4, 'Auriculares', 'Audio', 89.90, 0),
(5, 'Webcam', 'Periféricos', 150.00, 12),
(6, 'Altavoz', 'Audio', 99.00, 25),
(7, 'Monitor 4K', 'Pantallas', 899.00, 3),
(8, 'Cable HDMI', 'Accesorios', 15.00, 100);
INSERT INTO clientes (id, nombre, email) VALUES
(1, 'Ana Torres', 'ana@correo.com'),
(2, 'Luis Pérez', 'luis@correo.com'),
(3, 'Marta Ruiz', 'marta@correo.com'),
(4, 'Diego Soto', 'diego@correo.com');
INSERT INTO pedidos (id, cliente_id, fecha) VALUES
(1, 1, '2026-09-01'),
(2, 1, '2026-09-15'),
(3, 2, '2026-09-20'),
(4, 3, '2026-10-02');
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad) VALUES
(1, 1, 1), (1, 2, 1),
(2, 5, 1),
(3, 7, 1), (3, 8, 2),
(4, 6, 2), (4, 2, 3);
sqlite3 tienda.db ".read tienda.sql"
Comprueba que todo entró bien:
Resultado esperado: conteo de filas
| productos | clientes | pedidos | lineas |
|---|---|---|---|
| 8 | 4 | 4 | 7 |
4.Paso 3: registrar un pedido con una transacción
Registrar un pedido toca varias tablas: crear el pedido, añadir sus líneas y descontar el stock. Si algo falla a medias, la tienda quedaría con datos incoherentes. Por eso se hace dentro de una transacción (lección 3): todo se guarda, o no se guarda nada.
BEGIN;
INSERT INTO pedidos (id, cliente_id, fecha)
VALUES (5, 4, '2026-10-06');
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad)
VALUES (5, 3, 1), (5, 2, 2);
UPDATE productos SET stock = stock - 1 WHERE id = 3;
UPDATE productos SET stock = stock - 2 WHERE id = 2;
COMMIT;
Si cualquiera de las instrucciones falla, ejecutas ROLLBACK; en lugar de COMMIT; y la base queda como estaba. Después de la transacción:
Stock actualizado (antes: Ratón 40, Monitor FHD 8)
| id | nombre | stock |
|---|---|---|
| 2 | Ratón | 38 |
| 3 | Monitor FHD | 7 |
El pedido nuevo
| pedido | nombre | total |
|---|---|---|
| 5 | Diego Soto | 741.00 |
📌 Nota · PostgreSQL
Allí lo normal es no escribir el id: lo genera la base (columna identity) y lo recuperas con INSERT … RETURNING id para usarlo en las líneas. En SQLite también puedes usar last_insert_rowid().
5.Paso 4: los informes
Los informes son consultas que combinan todo lo aprendido: JOIN, GROUP BY, HAVING y LEFT JOIN … IS NULL. Los resultados que ves son los de la base original (sin el pedido nuevo del paso anterior).
Pedidos con cliente e importe
SELECT p.id AS pedido, c.nombre, p.fecha,
SUM(l.cantidad * pr.precio) AS total
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.id
JOIN productos pr ON pr.id = l.producto_id
GROUP BY p.id
ORDER BY p.id;
Resultado esperado
| pedido | nombre | fecha | total |
|---|---|---|---|
| 1 | Ana Torres | 2026-09-01 | 165.50 |
| 2 | Ana Torres | 2026-09-15 | 150.00 |
| 3 | Luis Pérez | 2026-09-20 | 929.00 |
| 4 | Marta Ruiz | 2026-10-02 | 334.50 |
Gasto por cliente (incluye a quien no compró)
SELECT c.nombre,
COUNT(DISTINCT p.id) AS pedidos,
COALESCE(SUM(l.cantidad * pr.precio), 0) AS gasto
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
LEFT JOIN lineas_pedido l ON l.pedido_id = p.id
LEFT JOIN productos pr ON pr.id = l.producto_id
GROUP BY c.id
ORDER BY gasto DESC;
Resultado esperado
| nombre | pedidos | gasto |
|---|---|---|
| Luis Pérez | 1 | 929.00 |
| Marta Ruiz | 1 | 334.50 |
| Ana Torres | 2 | 315.50 |
| Diego Soto | 0 | 0.00 |
Productos más vendidos
SELECT pr.nombre AS producto,
SUM(l.cantidad) AS unidades,
SUM(l.cantidad * pr.precio) AS ingresos
FROM lineas_pedido l
JOIN productos pr ON pr.id = l.producto_id
GROUP BY pr.id
ORDER BY unidades DESC, ingresos DESC;
Resultado esperado
| producto | unidades | ingresos |
|---|---|---|
| Ratón | 4 | 182.00 |
| Altavoz | 2 | 198.00 |
| Cable HDMI | 2 | 30.00 |
| Monitor 4K | 1 | 899.00 |
| Webcam | 1 | 150.00 |
| Teclado | 1 | 120.00 |
Ingresos por categoría
SELECT pr.categoria,
SUM(l.cantidad * pr.precio) AS ingresos
FROM lineas_pedido l
JOIN productos pr ON pr.id = l.producto_id
GROUP BY pr.categoria
ORDER BY ingresos DESC;
Resultado esperado
| categoria | ingresos |
|---|---|
| Pantallas | 899.00 |
| Periféricos | 452.00 |
| Audio | 198.00 |
| Accesorios | 30.00 |
Productos que nunca se han vendido
SELECT pr.nombre, pr.stock
FROM productos pr
LEFT JOIN lineas_pedido l ON l.producto_id = pr.id
WHERE l.pedido_id IS NULL;
Resultado esperado
| nombre | stock |
|---|---|
| Monitor FHD | 8 |
| Auriculares | 0 |
Ingresos por mes
SELECT substr(p.fecha, 1, 7) AS mes,
COUNT(DISTINCT p.id) AS pedidos,
SUM(l.cantidad * pr.precio) AS ingresos
FROM pedidos p
JOIN lineas_pedido l ON l.pedido_id = p.id
JOIN productos pr ON pr.id = l.producto_id
GROUP BY mes
ORDER BY mes;
Resultado esperado
| mes | pedidos | ingresos |
|---|---|---|
| 2026-09 | 3 | 1244.50 |
| 2026-10 | 1 | 334.50 |
⚠️ Importante · Fechas en PostgreSQL
El informe por mes usa substr(fecha, 1, 7) porque en el proyecto la fecha es texto AAAA-MM-DD. En PostgreSQL con una columna DATE usarías to_char(fecha, 'YYYY-MM') o date_trunc('month', fecha).
6.Paso 5: proteger la base
Un proyecto terminado no es solo el que consulta bien. Antes de darlo por cerrado revisa:
- Integridad:
PRAGMA foreign_keys = ONen cada conexión (SQLite) yNOT NULL,UNIQUEyCHECKdonde correspondan. - Inyección: la aplicación solo usa consultas parametrizadas; ningún valor del usuario se pega en el SQL.
- Permisos: la aplicación no usa la cuenta de administrador (rol con
GRANTsolo de lo necesario). - Datos personales: si añades usuarios con contraseña, guarda solo el hash (bcrypt o Argon2).
- Copias: un comando de respaldo programado y una restauración probada.
7.Pruébalo: panel de informes
Este panel reúne los informes del proyecto. Elige uno para ver su consulta y su resultado (ejecutado en SQLite real sobre los datos de tienda.sql).
8.Errores frecuentes
- Empezar a escribir tablas sin diseñar antes las entidades y relaciones: luego hay que rehacer todo.
- Olvidar
PRAGMA foreign_keys = ONal cargar el script y al conectar desde la aplicación. - Cargar datos antes de crear las tablas relacionadas, o crear las tablas en orden incorrecto: primero las que no dependen de otras.
- Registrar un pedido sin transacción: un fallo deja el pedido sin líneas o el stock sin descontar.
- Informes con
INNER JOINque ocultan clientes o productos sin ventas: usaLEFT JOINcuando haya que incluirlos. - Sumar importes tras un
JOINque repite filas (por ejemplo, unir pedidos con líneas y sumar un campo del pedido). - No guardar el script
.sqly modificar la base a mano: no se puede reproducir. - No probar la restauración de la copia.
📒 Apuntes para llevar
- Un proyecto de base de datos sigue cinco pasos: diseñar, crear, operar, informar y proteger.
- Diseña primero: entidades, relaciones (uno a muchos, muchos a muchos), claves y restricciones.
- Guarda el esquema y los datos en un script
.sqlque se pueda ejecutar de nuevo; es la documentación de tu diseño. - Las operaciones que tocan varias tablas van en una transacción:
BEGIN; … COMMIT;oROLLBACK;. - Los informes combinan
JOIN,GROUP BY,HAVINGyLEFT JOIN … IS NULL. - Protege: claves foráneas activas, consultas parametrizadas, rol con pocos permisos, hash de contraseñas y copias probadas.
- Con pocos cambios (identificadores, fechas,
ILIKE) el mismo proyecto funciona en PostgreSQL.
9.Glosario
| Término | Significado |
|---|---|
| Modelo de datos | Descripción de las tablas, sus columnas y las relaciones entre ellas. |
| Esquema | Conjunto de definiciones de las tablas y restricciones de una base de datos. |
| Script SQL | Archivo .sql con instrucciones que se pueden ejecutar para reconstruir la base. |
| Transacción | Grupo de operaciones que se aplican todas o ninguna. |
| Informe | Consulta que resume datos para tomar decisiones (ventas, stock, clientes…). |
| Ticket promedio | Importe medio de cada pedido. |
| Datos huérfanos | Filas que apuntan a otra fila que ya no existe; las claves foráneas lo evitan. |
| Respaldo | Copia de seguridad de la base que permite restaurarla tras un fallo. |
🧠 Comprueba lo aprendido
Elige una respuesta en cada pregunta. Verás al instante si acertaste y por qué.
¿Cuál es el primer paso de un proyecto de base de datos?
Un buen diseño evita rehacer tablas y datos más adelante.
En la tienda, ¿cómo se relacionan pedidos y productos?
Un pedido incluye varios productos y un producto aparece en varios pedidos.
¿Por qué se registra un pedido dentro de una transacción?
Si falla una instrucción, ROLLBACK deja la base como estaba.
¿Qué informe usa LEFT JOIN … WHERE l.pedido_id IS NULL en el proyecto?
Conserva todos los productos y se queda con los que no tienen ninguna línea de pedido.
¿Qué debe hacer la aplicación para evitar la inyección SQL?
Los parámetros separan los datos de las instrucciones.
Tu progreso se guarda en este navegador y se refleja en el índice del curso.
💡 🚀 ¡Ahora te toca!
La práctica fija lo aprendido. Pasa al ejercicio de esta lección.