¿Qué es SQL y por qué lo necesitas?
Imagina que tienes una agenda de contactos en una libreta de papel. Mientras tienes 20 contactos, todo va bien. Pero cuando tienes 20.000… ¿cómo buscas a alguien por ciudad? ¿Cómo eliminas todos los duplicados? Necesitas un sistema que organice, busque y manipule datos de forma eficiente. Eso es una base de datos, y SQL (Structured Query Language) es el idioma universal para hablar con ella.
SQL no es un lenguaje de programación general como Python o Java. Es un lenguaje declarativo: tú le dices qué quieres, no cómo hacerlo.
PostgreSQL
Aplicaciones empresariales, datos complejos. Open Source. (Usado en esta guía).
MySQL / MariaDB
Web tradicional (WordPress, Laravel, etc.). Open Source.
SQLite
Apps móviles, prototipos, testing. Open Source.
SQL Server / Oracle
Entornos corporativos, banca. Comerciales y de pago.
Instalación y primer contacto
# Actualizar paquetes e instalar
sudo apt update
sudo apt install postgresql postgresql-client
# Verificar y arrancar el servicio
sudo systemctl start postgresql
sudo systemctl enable postgresql
sudo systemctl status postgresql
# Usando Homebrew
brew install postgresql@16
# Iniciar el servicio en segundo plano
brew services start postgresql@16
# Instalación en openSUSE
sudo zypper install postgresql postgresql-server
# Iniciar servicio
sudo systemctl start postgresql
sudo systemctl enable postgresql
# Opción 1: Descargar el instalador oficial desde:
# https://www.postgresql.org/download/windows/
# Opción 2: Usar Windows Subsystem for Linux (WSL) - Recomendado para devs
wsl --install
# Luego abre WSL y sigue las instrucciones de Ubuntu/Debian
PostgreSQL crea un usuario del sistema llamado postgres. Vamos a usarlo para entrar y crear nuestro propio usuario.
# Cambiar al usuario postgres del sistema y abrir consola psql
sudo -u postgres psql
-- 1. Crear un usuario (rol) con contraseña
CREATE USER dev_user WITH PASSWORD 'mi_password_segura';
-- 2. Crear una base de datos asignada a ese usuario
CREATE DATABASE mi_primera_db OWNER dev_user;
-- 3. Salir
\q
# 4. Ahora puedes conectarte directamente desde la terminal:
psql -U dev_user -d mi_primera_db -h localhost
-h localhost podrías recibir este error. Esto ocurre porque PostgreSQL intenta autenticar usando usuarios de Linux en vez de contraseñas. Para solucionarlo, edita pg_hba.conf: busca la línea local all all peer y cámbiala por local all all md5. Luego reinicia: sudo systemctl restart postgresql.Los fundamentos: tablas, tipos de datos y CRUD
Piensa en una hoja de cálculo de Excel: tiene columnas y filas. En SQL, una Tabla es un conjunto de columnas (que definen el tipo de dato) y filas (los registros reales).
-- Tipos comunes: SERIAL (autoincremental), VARCHAR(n), TEXT, BOOLEAN, TIMESTAMP
-- NUMERIC(10,2) es ideal para dinero (nunca uses FLOAT para dinero)
CREATE TABLE productos (
id SERIAL PRIMARY KEY, -- ID único, se genera solo
nombre VARCHAR(200) NOT NULL, -- Obligatorio
descripcion TEXT, -- Opcional, sin límite
precio NUMERIC(10,2) NOT NULL, -- Decimal exacto
stock INTEGER DEFAULT 0, -- Si no se indica, vale 0
activo BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
-- INSERT (Crear)
INSERT INTO productos (nombre, precio, stock) VALUES
('Teclado mecánico', 89.99, 50),
('Ratón gaming', 45.50, 120);
-- SELECT (Leer)
SELECT nombre, precio FROM productos WHERE precio > 50 ORDER BY precio DESC LIMIT 3;
-- UPDATE (Modificar) - NUNCA olvides el WHERE
UPDATE productos SET precio = precio * 1.10 WHERE nombre = 'Teclado mecánico';
-- DELETE (Eliminar) - NUNCA olvides el WHERE
DELETE FROM productos WHERE activo = false;
alumnos con id (SERIAL), nombre, email (UNIQUE), nota (NUMERIC). Inserta 3 alumnos, haz un UPDATE para subir la nota al alumno con id 1, y luego elimínalo.
Filtrado avanzado y funciones
-- Filtros avanzados
SELECT * FROM productos WHERE precio BETWEEN 40 AND 100;
SELECT * FROM productos WHERE nombre IN ('Ratón gaming', 'Webcam HD');
SELECT * FROM productos WHERE nombre ILIKE '%teclado%'; -- ILIKE ignora mayúsculas
-- Lógica condicional (CASE)
SELECT nombre, stock,
CASE
WHEN stock = 0 THEN 'Agotado'
WHEN stock < 10 THEN 'Bajo stock'
ELSE 'Suficiente'
END AS estado
FROM productos;
-- Agrupación (GROUP BY)
SELECT categoria, COUNT(*) AS total, AVG(precio) AS precio_medio
FROM productos
GROUP BY categoria
HAVING AVG(precio) > 50; -- HAVING filtra grupos, WHERE filtra filas individuales
Normalización: Diseñar sin dolor
Imagina un Excel gigante donde, cada vez que un cliente compra algo, escribes su nombre, su teléfono y su dirección. Si ese cliente cambia de teléfono, tendrás que buscar y actualizar sus 500 compras pasadas. Si olvidas una, tendrás datos inconsistentes. Normalizar es el proceso de romper ese Excel en tablas pequeñas y especializadas para evitar esto.
- 1NF (Primera Forma Normal): Cada celda tiene 1 solo valor atómico. Prohibido guardar
"tel1, tel2"separados por comas en una celda. Crea otra tabla conectada. - 2NF y 3NF: Cada columna debe depender ÚNICAMENTE de su Clave Primaria (ID). Si el Código Postal (CP) determina la Ciudad, la columna "Ciudad" no debe estar en la tabla de clientes, sino en una tabla separada de Códigos Postales.
Constraints: Tu última línea de defensa
Normalmente validarás que un precio sea mayor a cero en tu código Python/Node.js. Pero el código backend siempre tiene bugs, o un día alguien conectará un script directamente a tu BD. Las Constraints (Restricciones) aseguran que la base de datos sea un escudo impenetrable que rechace datos basura vengan de donde vengan.
-- Tabla de pedidos con FK hacia clientes
-- CHECK: El guardián de la lógica. Evita precios o stocks imposibles.
ALTER TABLE productos ADD CONSTRAINT chk_positivo CHECK (precio > 0 AND stock >= 0);
-- Combinación única (un mismo pedido no puede tener el mismo producto en 2 líneas)
ALTER TABLE pedido_items ADD CONSTRAINT uq_item UNIQUE (pedido_id, producto_id);
-- Cascada: Si se borra un usuario, borrar sus pedidos
-- ON DELETE CASCADE: Limpieza automática. Si se borra un usuario, borra sus pedidos.
-- (¡Peligro! Úsalo con cuidado, a veces es mejor prohibir el borrado con RESTRICT)
ALTER TABLE pedidos ADD CONSTRAINT fk_usuario FOREIGN KEY (user_id)
REFERENCES usuarios(id) ON DELETE CASCADE;
Relaciones (JOINs): Reconstruyendo el puzzle
En la sección 5 rompimos nuestro Excel gigante en tablas pequeñitas perfectas. Pero ahora necesitamos leer la información junta (Ej: "Dime el nombre del cliente y el total de su pedido"). Para eso usamos las Foreign Keys (el puente que conecta las tablas) y los JOINs (la instrucción de SQL para cruzarlas).
Visualizando los JOINs (Diagramas de Venn)
- INNER JOIN: Es el más estricto. Solo devuelve los registros que tienen pareja en ambas tablas. (Ej: Clientes que han hecho al menos un pedido).
- LEFT JOIN: Devuelve todos los registros de la tabla izquierda (A), y los datos de la derecha (B) si existen. Si no existen, rellena con
NULL. (Ej: Todos los clientes, y si tienen pedidos los muestra, si no, muestra NULL). - RIGHT JOIN: Igual que el LEFT, pero da prioridad a la tabla de la derecha (B). (Menos común, usualmente se invierte el orden de las tablas y se usa LEFT).
- FULL (OUTER) JOIN: Devuelve todos los registros de ambas tablas, emparejando los que coinciden y rellenando con
NULLlos que quedan desparejados.
-- Tabla de pedidos con FK hacia clientes
-- Tabla de pedidos con FK (Foreign Key) hacia clientes
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
cliente_id INTEGER NOT NULL REFERENCES clientes(id),
total NUMERIC(10,2) NOT NULL
);
-- INNER JOIN: Solo clientes con pedidos (intersección)
-- INNER JOIN: Intersección estricta. Muestra SOLO los clientes que SÍ tienen pedidos.
SELECT c.nombre, p.total
FROM pedidos p
INNER JOIN clientes c ON p.cliente_id = c.id;
-- LEFT JOIN: Todos los clientes, tengan o no pedidos
-- LEFT JOIN: Incluyente. Muestra TODOS los clientes (la tabla de la izquierda),
-- e incluye sus pedidos. Si no tienen pedidos, muestra NULL en esas columnas.
SELECT c.nombre, COUNT(p.id) AS total_pedidos
FROM clientes c
LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.nombre;
- Crea las tablas
autores(id, nombre, pais),libros(id, titulo, anio, autor_id) yprestamos(id, libro_id, lector, fecha_prestamo, fecha_devolucion). - Inserta algunos autores, libros y préstamos inventados.
- Haz un INNER JOIN para listar "Título del libro — Nombre del autor".
- Haz un LEFT JOIN para encontrar autores que no tienen libros registrados.
- Cuenta cuántos libros tiene cada autor con GROUP BY.
-- 1. Título + Autor (INNER JOIN)
SELECT l.titulo, a.nombre AS autor
FROM libros l
INNER JOIN autores a ON l.autor_id = a.id;
-- 2. Autores sin libros (LEFT JOIN)
SELECT a.nombre
FROM autores a
LEFT JOIN libros l ON a.id = l.autor_id
WHERE l.id IS NULL;
-- 3. Total de libros por autor
SELECT a.nombre, COUNT(l.id) AS total_libros
FROM autores a
LEFT JOIN libros l ON a.id = l.autor_id
GROUP BY a.nombre
ORDER BY total_libros DESC;
Subconsultas, CTEs y Vistas
Subconsultas (Subqueries)
Una subconsulta es una consulta anidada dentro de otra. Piensa en las muñecas rusas (matrioshkas): abres una y dentro hay otra más pequeña. Son muy útiles para calcular un valor sobre la marcha y usarlo inmediatamente como filtro en el WHERE.
-- Productos con precio superior a la media
SELECT nombre, precio FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos);
CTEs (Common Table Expressions) y Vistas
Anidar muchas subconsultas acaba creando un código "espagueti" muy difícil de leer. Las CTEs (que siempre empiezan con WITH) resuelven esto actuando como variables temporales: le das un nombre a tu subconsulta arriba del todo, y luego la usas abajo como si fuera una tabla normal de tu base de datos.
-- CTEs (Common Table Expressions) con WITH
-- Hacen el código infinitamente más legible que subconsultas anidadas
WITH resumen_clientes AS (
SELECT c.id, c.nombre, SUM(p.total) AS gasto_total
FROM clientes c LEFT JOIN pedidos p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre
)
SELECT nombre, gasto_total FROM resumen_clientes WHERE gasto_total > 100;
-- Vistas (Views) - Consultas preguardadas en la DB
CREATE VIEW v_clientes_vip AS
SELECT * FROM resumen_clientes WHERE gasto_total > 1000;
Transacciones (ACID)
Imagina que transfieres 100€ de tu cuenta a la de un amigo. Esto implica DOS operaciones SQL: restar de tu cuenta y sumar a la suya. Si el servidor se apaga por un corte de luz justo en el medio, el dinero desaparece. Las transacciones solucionan esto actuando como un contrato de "Todo o Nada" que garantizan las reglas ACID (Atomicidad, Consistencia, Aislamiento, Durabilidad).
BEGIN; -- Inicia la transacción
-- Restamos dinero a Ana
UPDATE cuentas SET saldo = saldo - 200 WHERE nombre = 'Ana';
-- Si algo explota aquí, el COMMIT final no se ejecutará
-- Sumamos dinero a Luis
UPDATE cuentas SET saldo = saldo + 200 WHERE nombre = 'Luis';
COMMIT; -- O usar ROLLBACK si capturamos un error
- Crea una tabla
cuentascontitularysaldo(que no permita saldo negativo). - Inserta a Ana con 1000€ y Luis con 500€.
- Haz una transferencia de 200€ de Ana a Luis usando BEGIN/COMMIT.
- Intenta transferir 2000€ (más de lo que tiene Ana) y usa ROLLBACK para cancelar.
CREATE TABLE cuentas (
id SERIAL PRIMARY KEY,
titular VARCHAR(100) NOT NULL,
saldo NUMERIC(10,2) NOT NULL CHECK (saldo >= 0) -- Red de seguridad
);
INSERT INTO cuentas (titular, saldo) VALUES ('Ana', 1000.00), ('Luis', 500.00);
-- Transferencia exitosa (Ana -> Luis : 200€)
BEGIN;
UPDATE cuentas SET saldo = saldo - 200 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 200 WHERE id = 2;
COMMIT;
-- Transferencia fallida (Ana -> Luis : 2000€)
BEGIN;
UPDATE cuentas SET saldo = saldo - 2000 WHERE id = 1;
-- Falla por el CHECK (saldo >= 0). PostgreSQL aborta automáticamente.
ROLLBACK; -- Los saldos quedan intactos
Índices y Rendimiento
Tu aplicación va rapidísima en tu portátil con 50 registros. Pero en producción, con 10 millones de filas, las búsquedas tardan 30 segundos y el servidor colapsa. ¿Qué ha pasado? Sin índices, la base de datos lee fila por fila desde la primera hasta la última (lo que se conoce como Full Table Scan).
Piensa en el índice alfabético de un libro. Si buscas información sobre "PostgreSQL", no lees las 500 páginas del libro enteras: vas al índice al final, buscas la 'P', y saltas directamente a la página 347. Un índice en SQL hace exactamente eso creando una estructura matemática invisible (un árbol B-Tree) superrápida.
-- B-Tree index (por defecto) para búsquedas rápidas
CREATE INDEX idx_clientes_email ON clientes(email);
-- Índice compuesto (útil cuando filtras por 2 columnas a la vez)
CREATE INDEX idx_pedidos_cliente_estado ON pedidos(cliente_id, estado);
-- Ver el plan de ejecución de la DB para saber si usa el índice
EXPLAIN ANALYZE SELECT * FROM clientes WHERE email = 'ana@email.com';
WHERE o JOIN.Permisos y Seguridad
Hasta ahora hemos trabajado como superusuario postgres. Eso es como darle las llaves maestras de todo el edificio corporativo al repartidor de pizzas. Si un atacante compromete tu aplicación web, heredará esos permisos y podrá borrar la base de datos entera. Debes aplicar el Principio de Mínimo Privilegio: cada aplicación o usuario debe tener solo los permisos estrictamente necesarios para hacer su trabajo, ni uno más.
-- Crear usuario para la app web con permisos limitados
CREATE USER app_web WITH PASSWORD 'seguro_123';
GRANT SELECT, INSERT, UPDATE ON productos, pedidos TO app_web;
-- Revocar permisos peligrosos
REVOKE DELETE ON pedidos FROM app_web;
De Desarrollo a Producción
En producción, tu código (Python, Java) se encarga de lanzar las queries de forma segura usando librerías y transacciones. Un par de reglas vitales:
Migraciones (El Git de la BD)
Igual que usas Git para tu código, nunca hagas ALTER TABLE a mano en producción. Usa herramientas de migración (Flyway, Alembic) para tener un historial de versiones de tu esquema de datos que se aplique automáticamente.
Backups
Automatiza dumps (pg_dump). Y recuerda: un backup que no has probado a restaurar, no es un backup.
Window Functions (Avanzado)
Con GROUP BY calculas totales, pero pierdes el detalle (las filas colapsan en una sola). ¿Y si quieres ver la compra de un cliente Y al mismo tiempo su gasto acumulado histórico en la misma tabla?
Las Window Functions son la magia analítica de SQL. Mantienen las filas originales intactas, pero les "pegan" al lado una columna nueva con el cálculo del grupo. Piensa en ellas como un director de maratón que ve la estadística global de la carrera, pero sin perder de vista a cada corredor individual.
-- Suma acumulada de gastos del cliente en cada pedido, manteniendo las filas de pedidos intactas
SELECT
cliente_id,
total,
SUM(total) OVER (PARTITION BY cliente_id ORDER BY fecha) AS acumulado
FROM pedidos;
-- Ranking del más caro al más barato por categoría
SELECT nombre, categoria, precio,
RANK() OVER (PARTITION BY categoria ORDER BY precio DESC) AS rank
FROM productos;
Triggers y Funciones
Un Trigger (disparador) es una acción que la base de datos ejecuta automáticamente cuando ocurre un evento (INSERT, UPDATE o DELETE). Es como la alarma antihurto de una tienda: salta sola sin que el cajero tenga que pulsar un botón. Son el mecanismo perfecto para crear tablas de auditoría automáticas e invisibles para los programadores del backend.
FOR y condicionales IF). Puede parecer arameo al principio, pero permite delegar lógica crítica directamente al motor de la base de datos, haciéndola imposible de evadir por tu propia aplicación.-- 1. Función que inserta en el log
CREATE OR REPLACE FUNCTION fn_auditar_productos() RETURNS TRIGGER AS $$
BEGIN
IF NEW.precio <> OLD.precio THEN
INSERT INTO log_precios (prod_id, precio_viejo, precio_nuevo)
VALUES (OLD.id, OLD.precio, NEW.precio);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. El Trigger que llama a la función en cada UPDATE
CREATE TRIGGER trg_auditar_precios
AFTER UPDATE ON productos FOR EACH ROW EXECUTE FUNCTION fn_auditar_productos();
Esquema Producción E-commerce
Este esquema resume las mejores prácticas de la guía, usando UUIDs, Constraints y buenas relaciones.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE usuarios (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, -- UUID oculta a atacantes cuántos usuarios tienes
email VARCHAR(150) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE productos (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
sku VARCHAR(50) UNIQUE NOT NULL,
precio NUMERIC(10,2) NOT NULL CHECK (precio > 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0)
);
CREATE TABLE pedidos (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
usuario_id UUID REFERENCES usuarios(id),
estado VARCHAR(20) DEFAULT 'pendiente'
);
CREATE TABLE pedido_items (
pedido_id UUID REFERENCES pedidos(id) ON DELETE CASCADE,
producto_id UUID REFERENCES productos(id),
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_fijado NUMERIC(10,2) NOT NULL, -- Precio al momento de comprar
PRIMARY KEY (pedido_id, producto_id)
);
Los 10 Pitfalls (Cuidado en Producción)
| # | Pitfall / Error Común | Consecuencia | Solución Correcta |
|---|---|---|---|
| 01 | UPDATE o DELETE sin WHERE | Modificas o borras TODA la tabla de la base de datos irreversiblemente. | Escribe primero el WHERE, o haz un SELECT de prueba antes. |
| 02 | SQL Injection | Si interpolas variables (ej. "SELECT * FROM x WHERE a = " + obj.a), un usuario puede inyectar código malicioso y vaciarte las tablas. |
Usa siempre consultas preparadas / parámetros (? o $1). |
| 03 | Usar FLOAT para dinero | Errores invisibles de redondeo en céntimos (IEEE 754) que te cuadrarán mal las cuentas y enfadarán a Hacienda. | Usa siempre tipos decimales exactos como NUMERIC(10,2) o guarda los céntimos en INTEGER. |
| 04 | No hacer transacciones en pagos | Si falla a la mitad, cobras a un usuario pero no le creas el pedido, o le restas stock de un producto fantasma. | Envuelve procesos multi-tabla en un BEGIN; ... COMMIT; |
| 05 | Falta de Índices | Una base de datos rápida en local pasará a tardar 30 segundos por query cuando tengas 1 millón de filas (Full Table Scans). | Usa EXPLAIN y añade índices a las columnas usadas en WHERE, JOIN o de alta cardinalidad. |
| 06 | Contraseñas en claro | Si tu BD se filtra por un ataque, las contraseñas de tus usuarios serán públicas. Demanda legal asegurada (RGPD). | Guarda siempre Hashes usando algoritmos como bcrypt o argon2. |
| 07 | Exceso de Índices | Las escrituras (INSERT/UPDATE) se volverán lentísimas, porque cada cambio obliga a reconstruir los índices. | Solo indexa lo imprescindible. Si lees mucho y escribes poco, bien. |
| 08 | Ignorar las Constraints | El código backend siempre tiene bugs. Si la BD no rechaza datos erróneos (precios negativos, nulls), tu sistema colapsará. | Pon NOT NULL, UNIQUE y CHECK en las tablas para que actúen de escudo final. |
| 09 | Falta de Backups o de testearlos | Ataque Ransomware o borrado accidental destruyen el proyecto. "Si el backup no ha sido restaurado, no es un backup real". | Haz pg_dump automáticos (cron) y testea la restauración cada mes en un entorno seguro. |
| 10 | Transacciones abiertas colgando | Abres un BEGIN, el backend falla, y nunca envías el ROLLBACK. La BD queda con "Locks" bloqueando todas las tablas, cayendo la aplicación entera. |
Usa gestores de conexión y control de transacciones de tu framework que cierren la conexión o hagan rollback automáticamente on-error. |
Gran Reto Final
Construye desde cero el esquema completo de una tienda online funcional (E-commerce) y demuestra tu nivel. Deberás aplicar todo:
- Normalización a 3NF: usuarios, productos, categorias, pedidos, pedido_items.
- Implementar Constraints completas (CHECKs de positivos, UNIQUEs, FKs).
- Auditoría de cambios de precio usando Triggers.
- Procedimiento Almacenado para crear una venta con su transacción y validación de stock.
- Window Function para un ranking de productos.
Resumen: Tu Hoja de Ruta SQL
| Nivel | Temas | Secciones |
|---|---|---|
| 🟢 Principiante | Instalación, CRUD, tipos de datos | 1 — 3 + Mini-Lab 1 |
| 🟡 Intermedio | Filtros, JOINs, agregaciones, GROUP BY | 4 — 5 + Mini-Lab 2 |
| 🔵 Avanzado | Índices, CTEs, Vistas, Transacciones | 6 — 8 + Mini-Lab 3 |
| 🟣 Producción | Seguridad, backups, migraciones, deploy | 9 — 10 |
| ⚫ Profesional | Window Functions, Triggers, Normalización, Diseño | 11 — 15 |
| 🔴 Consolidación | Pitfalls, Gran Reto Final | 16 — 17 |
Solución Maestra (El código que lo unifica todo)
Vuelve a la sección 15 para ver la estructura base de las tablas del E-commerce, pero aquí te presento el componente más avanzado de la solución: el procedimiento almacenado. Este bloque de código PL/pgSQL encapsula todo lo aprendido: declaración de variables, condicionales, validación de stock, actualización de inventario y registro de compra, todo ejecutándose de forma atómica en una única transacción dentro de la base de datos.
-- 5. Lógica de Negocio (Procedimiento Almacenado)
-- Procesa un pedido validando stock y calculando totales de manera atómica
CREATE OR REPLACE PROCEDURE sp_registrar_compra(
p_usuario_id UUID,
p_producto_id UUID,
p_cantidad INTEGER
)
LANGUAGE plpgsql AS $$
DECLARE
v_precio NUMERIC(10,2);
v_pedido_id UUID;
BEGIN
-- 1. FASE DE VALIDACIÓN: Obtener precio y validar stock
SELECT precio INTO v_precio FROM productos_pro
WHERE id = p_producto_id AND stock >= p_cantidad;
IF NOT FOUND THEN
RAISE EXCEPTION 'Producto no encontrado o stock insuficiente';
END IF;
-- 2. FASE DE ACCIÓN: Descontar stock del inventario
UPDATE productos_pro SET stock = stock - p_cantidad WHERE id = p_producto_id;
-- 3. FASE DE REGISTRO: Crear cabecera del pedido
INSERT INTO pedidos_pro (usuario_id, total, estado)
VALUES (p_usuario_id, v_precio * p_cantidad, 'pagado')
RETURNING id INTO v_pedido_id;
-- 4. FASE DE DETALLE: Insertar los items del pedido
INSERT INTO pedido_items (pedido_id, producto_id, cantidad, precio_unit)
VALUES (v_pedido_id, p_producto_id, p_cantidad, v_precio);
-- 5. FINALIZACIÓN: El COMMIT es implícito si no hubo errores
RAISE NOTICE 'Venta registrada con éxito. Pedido ID: %', v_pedido_id;
END;
$$;