En esta página
Proyecto final — Base de datos e-commerce
Proyecto final: Base de datos e-commerce completa
¡Es hora de poner en práctica todo lo que has aprendido! En este proyecto final diseñarás e implementarás desde cero una base de datos de e-commerce profesional que incluye todas las características que encontrarás en sistemas reales: esquema normalizado, restricciones de integridad, índices optimizados, vistas, funciones almacenadas y Row Level Security.
Objetivo del proyecto
Construirás el esquema de base de datos para una tienda en línea con las siguientes entidades:
- Usuarios: clientes con perfiles, autenticación y preferencias
- Categorías: árbol jerárquico (categorías y subcategorías)
- Productos: catálogo con atributos variables en JSONB
- Inventario: stock por variante de producto
- Pedidos: cabeceras de pedido con estado y dirección de envío
- Líneas de pedido: productos individuales de cada pedido
- Reseñas: valoraciones de clientes sobre productos
- Direcciones: múltiples direcciones por usuario
Fase 1: Configuración inicial
-- Crear la base de datos del proyecto
CREATE DATABASE ecommerce_proyecto
WITH ENCODING = 'UTF8'
LC_COLLATE = 'es_ES.UTF-8'
LC_CTYPE = 'es_ES.UTF-8'
TEMPLATE = template0;
\c ecommerce_proyecto
-- Habilitar extensiones necesarias
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- Para gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- Para búsqueda de texto con índices
CREATE EXTENSION IF NOT EXISTS unaccent; -- Para búsquedas sin acentos
-- Crear esquemas para organizar el modelo
CREATE SCHEMA tienda; -- Catálogo y transacciones
CREATE SCHEMA auth; -- Usuarios y autenticación
CREATE SCHEMA reportes; -- Vistas y funciones analíticas
SET search_path TO tienda, auth, public;Fase 2: Esquema de autenticación y usuarios
-- Tabla de usuarios (compatible con Supabase/JWT)
CREATE TABLE auth.usuarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
nombre VARCHAR(150) NOT NULL,
apellido VARCHAR(150),
telefono VARCHAR(20),
avatar_url TEXT,
activo BOOLEAN NOT NULL DEFAULT true,
email_verificado BOOLEAN NOT NULL DEFAULT false,
ultimo_login TIMESTAMPTZ,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_email_formato
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
-- Direcciones de envío (relación 1:N con usuarios)
CREATE TABLE auth.direcciones (
id SERIAL PRIMARY KEY,
usuario_id UUID NOT NULL REFERENCES auth.usuarios(id) ON DELETE CASCADE,
alias VARCHAR(50) NOT NULL DEFAULT 'Mi casa', -- 'Casa', 'Trabajo', etc.
nombre_receptor VARCHAR(150) NOT NULL,
calle VARCHAR(300) NOT NULL,
ciudad VARCHAR(100) NOT NULL,
codigo_postal VARCHAR(10) NOT NULL,
pais VARCHAR(2) NOT NULL DEFAULT 'ES', -- ISO 3166-1 alpha-2
es_predeterminada BOOLEAN NOT NULL DEFAULT false,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Garantizar que solo hay UNA dirección predeterminada por usuario
CREATE UNIQUE INDEX idx_una_dir_predeterminada
ON auth.direcciones(usuario_id)
WHERE es_predeterminada = true;Fase 3: Catálogo de productos
-- Categorías con soporte para jerarquía (árbol)
CREATE TABLE tienda.categorias (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
descripcion TEXT,
padre_id INTEGER REFERENCES tienda.categorias(id) ON DELETE SET NULL,
orden SMALLINT NOT NULL DEFAULT 0,
activa BOOLEAN NOT NULL DEFAULT true,
imagen_url TEXT,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Función para obtener la ruta completa de una categoría
CREATE OR REPLACE FUNCTION tienda.ruta_categoria(p_id INTEGER)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_ruta TEXT;
BEGIN
WITH RECURSIVE ruta AS (
SELECT id, nombre, padre_id, nombre AS ruta_texto
FROM tienda.categorias
WHERE id = p_id
UNION ALL
SELECT c.id, c.nombre, c.padre_id, c.nombre || ' > ' || r.ruta_texto
FROM tienda.categorias c
JOIN ruta r ON c.id = r.padre_id
)
SELECT ruta_texto INTO v_ruta FROM ruta WHERE padre_id IS NULL;
RETURN v_ruta;
END;
$$;
-- Tabla de productos
CREATE TABLE tienda.productos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(50) NOT NULL UNIQUE,
nombre VARCHAR(300) NOT NULL,
slug VARCHAR(300) NOT NULL UNIQUE,
descripcion TEXT,
descripcion_corta VARCHAR(500),
precio_base NUMERIC(10,2) NOT NULL CHECK (precio_base >= 0),
precio_oferta NUMERIC(10,2) CHECK (precio_oferta >= 0 AND precio_oferta <= precio_base),
costo NUMERIC(10,2) CHECK (costo >= 0), -- Precio de costo (privado)
categoria_id INTEGER NOT NULL REFERENCES tienda.categorias(id),
marca VARCHAR(100),
atributos JSONB NOT NULL DEFAULT '{}', -- Color, talla, material, etc.
imagenes JSONB NOT NULL DEFAULT '[]', -- Array de URLs
metadatos_seo JSONB NOT NULL DEFAULT '{}', -- title, description, og_image
activo BOOLEAN NOT NULL DEFAULT true,
destacado BOOLEAN NOT NULL DEFAULT false,
peso_gramos INTEGER CHECK (peso_gramos > 0),
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Índice de búsqueda de texto completo
busqueda TSVECTOR GENERATED ALWAYS AS (
to_tsvector('spanish',
COALESCE(nombre, '') || ' ' ||
COALESCE(descripcion_corta, '') || ' ' ||
COALESCE(marca, '')
)
) STORED
);
-- Inventario separado (permite variantes futuras)
CREATE TABLE tienda.inventario (
id SERIAL PRIMARY KEY,
producto_id UUID NOT NULL REFERENCES tienda.productos(id) ON DELETE CASCADE,
variante VARCHAR(100) NOT NULL DEFAULT 'default', -- 'Rojo L', 'Azul M', etc.
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
stock_minimo INTEGER NOT NULL DEFAULT 5, -- Alerta cuando stock <= stock_minimo
ubicacion VARCHAR(50), -- 'Almacén A, Estante 3'
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (producto_id, variante)
);Fase 4: Sistema de pedidos
-- Pedidos (cabecera)
CREATE TABLE tienda.pedidos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero TEXT NOT NULL UNIQUE
DEFAULT 'ORD-' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' || LPAD(nextval('tienda.seq_pedidos')::TEXT, 4, '0'),
usuario_id UUID NOT NULL REFERENCES auth.usuarios(id),
direccion_id INTEGER REFERENCES auth.direcciones(id) ON DELETE SET NULL,
-- Estado del pedido
estado VARCHAR(20) NOT NULL DEFAULT 'borrador'
CHECK (estado IN ('borrador', 'pendiente', 'confirmado', 'preparando', 'enviado', 'entregado', 'cancelado', 'devuelto')),
-- Totales (calculados y guardados para historial)
subtotal NUMERIC(10,2) NOT NULL DEFAULT 0,
descuento NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (descuento >= 0),
impuestos NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (impuestos >= 0),
gastos_envio NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (gastos_envio >= 0),
total NUMERIC(10,2) NOT NULL DEFAULT 0 CHECK (total >= 0),
notas TEXT,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmado_en TIMESTAMPTZ,
enviado_en TIMESTAMPTZ,
entregado_en TIMESTAMPTZ
);
-- Secuencia para números de pedido
CREATE SEQUENCE tienda.seq_pedidos START 1000;
-- Líneas de pedido
CREATE TABLE tienda.pedidos_items (
id SERIAL PRIMARY KEY,
pedido_id UUID NOT NULL REFERENCES tienda.pedidos(id) ON DELETE CASCADE,
producto_id UUID NOT NULL REFERENCES tienda.productos(id),
variante VARCHAR(100) NOT NULL DEFAULT 'default',
nombre_producto VARCHAR(300) NOT NULL, -- Copia del nombre (histórico)
sku VARCHAR(50) NOT NULL, -- Copia del SKU (histórico)
cantidad INTEGER NOT NULL CHECK (cantidad > 0),
precio_unit NUMERIC(10,2) NOT NULL CHECK (precio_unit >= 0), -- Precio al momento de la compra
descuento_unit NUMERIC(10,2) NOT NULL DEFAULT 0,
subtotal NUMERIC(10,2) GENERATED ALWAYS AS (cantidad * (precio_unit - descuento_unit)) STORED,
UNIQUE (pedido_id, producto_id, variante)
);Fase 5: Sistema de reseñas
-- Reseñas de productos (solo clientes que compraron pueden reseñar)
CREATE TABLE tienda.resenas (
id SERIAL PRIMARY KEY,
producto_id UUID NOT NULL REFERENCES tienda.productos(id) ON DELETE CASCADE,
usuario_id UUID NOT NULL REFERENCES auth.usuarios(id),
pedido_id UUID REFERENCES tienda.pedidos(id) ON DELETE SET NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
titulo VARCHAR(200),
contenido TEXT,
verificada BOOLEAN NOT NULL DEFAULT false, -- Compra verificada
util_votos INTEGER NOT NULL DEFAULT 0,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (producto_id, usuario_id) -- Un usuario, una reseña por producto
);Fase 6: Índices para rendimiento
-- Índices en claves foráneas (PostgreSQL no los crea automáticamente)
CREATE INDEX idx_direcciones_usuario ON auth.direcciones(usuario_id);
CREATE INDEX idx_productos_categoria ON tienda.productos(categoria_id);
CREATE INDEX idx_inventario_producto ON tienda.inventario(producto_id);
CREATE INDEX idx_pedidos_usuario ON tienda.pedidos(usuario_id);
CREATE INDEX idx_pedidos_estado ON tienda.pedidos(estado);
CREATE INDEX idx_pedidos_creado_en ON tienda.pedidos(creado_en DESC);
CREATE INDEX idx_pedidos_items_pedido ON tienda.pedidos_items(pedido_id);
CREATE INDEX idx_pedidos_items_producto ON tienda.pedidos_items(producto_id);
CREATE INDEX idx_resenas_producto ON tienda.resenas(producto_id);
CREATE INDEX idx_resenas_usuario ON tienda.resenas(usuario_id);
-- Índice parcial: solo productos activos y destacados (para homepage)
CREATE INDEX idx_productos_destacados ON tienda.productos(creado_en DESC)
WHERE activo = true AND destacado = true;
-- Índice GIN para búsqueda de texto completo
CREATE INDEX idx_productos_busqueda ON tienda.productos USING GIN(busqueda);
-- Índice GIN para búsqueda en JSONB
CREATE INDEX idx_productos_atributos ON tienda.productos USING GIN(atributos);
-- Índice para búsqueda aproximada de texto (trigram)
CREATE INDEX idx_productos_nombre_trgm ON tienda.productos USING GIN(nombre gin_trgm_ops);
-- Índice para categorías jerárquicas
CREATE INDEX idx_categorias_padre ON tienda.categorias(padre_id)
WHERE padre_id IS NOT NULL;Fase 7: Triggers automáticos
-- Función genérica para actualizar 'actualizado_en'
CREATE OR REPLACE FUNCTION actualizar_timestamp()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.actualizado_en := NOW();
RETURN NEW;
END;
$$;
-- Aplicar a todas las tablas que tienen 'actualizado_en'
CREATE TRIGGER trg_usuarios_ts
BEFORE UPDATE ON auth.usuarios
FOR EACH ROW EXECUTE FUNCTION actualizar_timestamp();
CREATE TRIGGER trg_productos_ts
BEFORE UPDATE ON tienda.productos
FOR EACH ROW EXECUTE FUNCTION actualizar_timestamp();
CREATE TRIGGER trg_pedidos_ts
BEFORE UPDATE ON tienda.pedidos
FOR EACH ROW EXECUTE FUNCTION actualizar_timestamp();
-- Trigger: actualizar totales del pedido automáticamente
CREATE OR REPLACE FUNCTION recalcular_total_pedido()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_subtotal NUMERIC(10,2);
v_iva NUMERIC(10,2);
v_total NUMERIC(10,2);
BEGIN
SELECT COALESCE(SUM(subtotal), 0) INTO v_subtotal
FROM tienda.pedidos_items
WHERE pedido_id = COALESCE(NEW.pedido_id, OLD.pedido_id);
v_iva := ROUND(v_subtotal * 0.21, 2);
v_total := v_subtotal + v_iva;
UPDATE tienda.pedidos
SET subtotal = v_subtotal,
impuestos = v_iva,
total = v_total
WHERE id = COALESCE(NEW.pedido_id, OLD.pedido_id);
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_recalcular_pedido
AFTER INSERT OR UPDATE OR DELETE ON tienda.pedidos_items
FOR EACH ROW EXECUTE FUNCTION recalcular_total_pedido();
-- Trigger: reducir stock al confirmar pedido
CREATE OR REPLACE FUNCTION reducir_stock_pedido()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.estado = 'confirmado' AND OLD.estado != 'confirmado' THEN
UPDATE tienda.inventario inv
SET stock = inv.stock - pi.cantidad
FROM tienda.pedidos_items pi
WHERE pi.pedido_id = NEW.id
AND inv.producto_id = pi.producto_id
AND inv.variante = pi.variante;
NEW.confirmado_en := NOW();
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_stock_pedido
BEFORE UPDATE ON tienda.pedidos
FOR EACH ROW EXECUTE FUNCTION reducir_stock_pedido();Fase 8: Vistas analíticas
-- Vista: catálogo público (sin datos sensibles como costo)
CREATE VIEW reportes.catalogo_publico AS
SELECT
p.id,
p.sku,
p.nombre,
p.slug,
p.descripcion_corta,
p.precio_base,
p.precio_oferta,
COALESCE(p.precio_oferta, p.precio_base) AS precio_final,
CASE WHEN p.precio_oferta IS NOT NULL
THEN ROUND(((p.precio_base - p.precio_oferta) / p.precio_base * 100)::NUMERIC, 0)
ELSE 0
END AS pct_descuento,
c.nombre AS categoria,
c.slug AS categoria_slug,
p.marca,
p.atributos,
p.imagenes,
p.destacado,
COALESCE(SUM(inv.stock), 0) AS stock_total,
COALESCE(ROUND(AVG(r.rating), 1), 0) AS rating_promedio,
COUNT(DISTINCT r.id) AS num_resenas
FROM tienda.productos p
LEFT JOIN tienda.categorias c ON p.categoria_id = c.id
LEFT JOIN tienda.inventario inv ON inv.producto_id = p.id
LEFT JOIN tienda.resenas r ON r.producto_id = p.id
WHERE p.activo = true
GROUP BY p.id, p.sku, p.nombre, p.slug, p.descripcion_corta,
p.precio_base, p.precio_oferta, c.nombre, c.slug,
p.marca, p.atributos, p.imagenes, p.destacado;
-- Vista materializada: estadísticas de ventas por producto
CREATE MATERIALIZED VIEW reportes.mv_ventas_producto AS
SELECT
p.id AS producto_id,
p.nombre,
p.sku,
COUNT(DISTINCT ped.id) AS num_pedidos,
SUM(pi.cantidad) AS unidades_vendidas,
SUM(pi.subtotal) AS ingresos_totales,
ROUND(AVG(pi.precio_unit), 2) AS precio_promedio_venta,
MAX(ped.creado_en) AS ultima_venta
FROM tienda.productos p
LEFT JOIN tienda.pedidos_items pi ON pi.producto_id = p.id
LEFT JOIN tienda.pedidos ped ON ped.id = pi.pedido_id
AND ped.estado NOT IN ('cancelado', 'devuelto')
GROUP BY p.id, p.nombre, p.sku;
CREATE UNIQUE INDEX idx_mv_ventas_producto ON reportes.mv_ventas_producto(producto_id);Fase 9: Row Level Security
-- Habilitar RLS en tablas sensibles
ALTER TABLE auth.usuarios ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth.direcciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE tienda.pedidos ENABLE ROW LEVEL SECURITY;
ALTER TABLE tienda.resenas ENABLE ROW LEVEL SECURITY;
-- Políticas: usuarios solo ven y editan sus propios datos
CREATE POLICY "usuarios ven su perfil"
ON auth.usuarios FOR SELECT
USING (id = current_setting('app.user_id', true)::UUID);
CREATE POLICY "usuarios editan su perfil"
ON auth.usuarios FOR UPDATE
USING (id = current_setting('app.user_id', true)::UUID);
CREATE POLICY "usuarios ven sus direcciones"
ON auth.direcciones FOR ALL
USING (usuario_id = current_setting('app.user_id', true)::UUID);
CREATE POLICY "usuarios ven sus pedidos"
ON tienda.pedidos FOR SELECT
USING (usuario_id = current_setting('app.user_id', true)::UUID);
CREATE POLICY "usuarios crean pedidos propios"
ON tienda.pedidos FOR INSERT
WITH CHECK (usuario_id = current_setting('app.user_id', true)::UUID);
-- Administradores ven todo (rol especial)
CREATE POLICY "admins acceso total usuarios"
ON auth.usuarios FOR ALL
USING (current_setting('app.user_role', true) = 'admin');
CREATE POLICY "admins acceso total pedidos"
ON tienda.pedidos FOR ALL
USING (current_setting('app.user_role', true) = 'admin');Fase 10: Datos de prueba y verificación
-- Insertar datos de prueba
INSERT INTO tienda.categorias (nombre, slug, padre_id) VALUES
('Electrónica', 'electronica', NULL),
('Ordenadores', 'ordenadores', 1),
('Móviles', 'moviles', 1),
('Ropa', 'ropa', NULL),
('Hombre', 'ropa-hombre', 4),
('Mujer', 'ropa-mujer', 4);
INSERT INTO tienda.productos (sku, nombre, slug, descripcion_corta, precio_base, categoria_id, marca, atributos, activo, destacado) VALUES
('LAP-M3-001', 'MacBook Pro 14"', 'macbook-pro-14', 'Laptop profesional con chip M3 Pro', 1999.99, 2, 'Apple',
'{"color": "gris espacial", "ram": "18GB", "ssd": "512GB", "pantalla": "14.2 pulgadas"}', true, true),
('PHN-S25-001', 'Samsung Galaxy S25', 'samsung-galaxy-s25', 'Smartphone flagship Android', 999.99, 3, 'Samsung',
'{"color": "negro", "ram": "12GB", "almacenamiento": "256GB", "camara": "200MP"}', true, true),
('CAM-001', 'Camiseta Premium', 'camiseta-premium-hombre', 'Algodón pima 100%', 29.99, 5, 'BrandX',
'{"material": "algodón pima", "tallas": ["S", "M", "L", "XL"], "colores": ["blanco", "negro", "azul"]}', true, false);
INSERT INTO tienda.inventario (producto_id, variante, stock) VALUES
((SELECT id FROM tienda.productos WHERE sku = 'LAP-M3-001'), 'Gris Espacial', 15),
((SELECT id FROM tienda.productos WHERE sku = 'PHN-S25-001'), 'Negro 256GB', 40),
((SELECT id FROM tienda.productos WHERE sku = 'CAM-001'), 'Blanco M', 100),
((SELECT id FROM tienda.productos WHERE sku = 'CAM-001'), 'Negro L', 80);
-- Verificar el catálogo
SELECT nombre, precio_base, precio_final, stock_total, rating_promedio
FROM reportes.catalogo_publico
ORDER BY destacado DESC, nombre;
-- Verificar el árbol de categorías
WITH RECURSIVE arbol AS (
SELECT id, nombre, padre_id, 0 AS nivel
FROM tienda.categorias
WHERE padre_id IS NULL
UNION ALL
SELECT c.id, c.nombre, c.padre_id, a.nivel + 1
FROM tienda.categorias c
JOIN arbol a ON c.padre_id = a.id
)
SELECT REPEAT(' ', nivel) || nombre AS categoria, nivel
FROM arbol
ORDER BY nivel, nombre;
-- Verificar la ruta de una categoría
SELECT tienda.ruta_categoria(2); -- 'Electrónica > Ordenadores'
SELECT tienda.ruta_categoria(5); -- 'Ropa > Hombre'
-- Estadísticas del esquema
SELECT
schemaname AS esquema,
tablename AS tabla,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS tamaño_total,
pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS tamaño_datos
FROM pg_tables
WHERE schemaname IN ('tienda', 'auth', 'reportes')
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;[!tip] Próximos pasos para este proyecto: puedes extender este esquema con cupones de descuento (
tienda.cupones), programa de puntos (auth.puntos_usuarios), historial de precios (tienda.historial_precios), notificaciones (auth.notificaciones), y un sistema de búsqueda avanzada usando los índices GIN de texto completo y trigram que ya creaste. También puedes conectar este esquema directamente a Supabase como backend de una aplicación Angular.
[!info] ¡Felicidades! Has completado el curso de Bases de Datos SQL. Ahora dominas el modelo relacional, las consultas complejas con JOINs y CTEs, la normalización, los índices, las transacciones y la seguridad. Estos son los fundamentos que usan los ingenieros de datos en empresas de todos los tamaños. El siguiente paso recomendado es conectar este conocimiento con una aplicación real usando un ORM como Prisma (Node.js) o SQLAlchemy (Python), o directamente con el cliente oficial de PostgreSQL de tu lenguaje favorito.
nextSteps: []
Inicia sesión para guardar tu progreso