En esta página

Proyecto final — Base de datos e-commerce

25 min lectura TextoCap. 5 — SQL en producción

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: []