Práctica SQL: Tienda de bicicletas con JOIN y agregaciones
Practica SQL con una base de datos de tienda de bicicletas: JOINs, GROUP BY, HAVING, subconsultas correlacionadas y análisis de inventario en PostgreSQL.
Use this file to discover all available pages before exploring further.
Esta práctica utiliza una base de datos de una cadena de tiendas de bicicletas para desarrollar habilidades progresivas en SQL: desde consultas simples con JOIN hasta subconsultas correlacionadas, agregaciones con HAVING y análisis de inventario. El esquema modela un negocio real con productos, clientes, personal, pedidos y tiendas, lo que permite plantear preguntas de negocio genuinas y responderlas con SQL. El objetivo no es solo que las consultas funcionen, sino que puedas leer el plan de ejecución y entender por qué son eficientes o dónde hay oportunidad de mejora.
Usa EXPLAIN ANALYZE antes de cualquier consulta compleja para ver el plan de ejecución real. Por ejemplo: EXPLAIN ANALYZE SELECT .... Presta atención a los nodos Seq Scan en tablas grandes; suelen indicar que falta un índice. En la práctica compararemos los planes de consultas con y sin índices sobre las columnas de join.
Estas consultas practican la navegación por el esquema mediante JOIN y el filtrado con WHERE. Asegúrate de entender el tipo de join que estás usando y por qué.Productos con su marca y categoría
-- Productos con su marca y categoría, ordenados por precio descendenteSELECT p.nombre AS producto, m.nombre AS marca, c.nombre AS categoria, p.precio_listaFROM productos pJOIN marcas m ON p.marca_id = m.marca_idJOIN categorias c ON p.categoria_id = c.categoria_idORDER BY p.precio_lista DESC;
Pedidos pendientes de envío
-- Pedidos con estado 'Pendiente' que ya pasaron su fecha requeridaSELECT o.pedido_id, c.nombre || ' ' || c.apellido AS cliente, o.fecha_pedido, o.fecha_requerida, t.nombre AS tiendaFROM pedidos oJOIN clientes c ON o.cliente_id = c.cliente_idJOIN tiendas t ON o.tienda_id = t.tienda_idWHERE o.estado = 1 -- 1 = Pendiente AND o.fecha_requerida < CURRENT_DATE AND o.fecha_envio IS NULLORDER BY o.fecha_requerida ASC;
Inventario de una tienda específica
-- Stock completo de la tienda con tienda_id = 1SELECT pr.nombre AS producto, cat.nombre AS categoria, i.cantidadFROM inventario iJOIN productos pr ON i.producto_id = pr.producto_idJOIN categorias cat ON pr.categoria_id = cat.categoria_idWHERE i.tienda_id = 1ORDER BY cat.nombre, pr.nombre;
Estas consultas combinan GROUP BY, funciones de agregación (COUNT, SUM, AVG) y la cláusula HAVING para filtrar sobre grupos calculados.Total de pedidos y gasto por cliente
-- Clientes con más de un pedido: total de pedidos y monto gastadoSELECT c.nombre || ' ' || c.apellido AS cliente, COUNT(o.pedido_id) AS total_pedidos, SUM(i.cantidad * i.precio_lista * (1 - i.descuento)) AS total_gastadoFROM clientes cJOIN pedidos o ON c.cliente_id = o.cliente_idJOIN items_pedido i ON o.pedido_id = i.pedido_idGROUP BY c.cliente_id, c.nombre, c.apellidoHAVING COUNT(o.pedido_id) > 1ORDER BY total_gastado DESC;
Ventas por marca y categoría
-- Cantidad de unidades vendidas y revenue por marca/categoríaSELECT m.nombre AS marca, cat.nombre AS categoria, SUM(i.cantidad) AS unidades_vendidas, SUM(i.cantidad * i.precio_lista * (1 - i.descuento)) AS revenueFROM items_pedido iJOIN productos p ON i.producto_id = p.producto_idJOIN marcas m ON p.marca_id = m.marca_idJOIN categorias cat ON p.categoria_id = cat.categoria_idGROUP BY m.marca_id, m.nombre, cat.categoria_id, cat.nombreORDER BY revenue DESC;
Personal con más pedidos gestionados
-- Top 5 vendedores por número de pedidos procesadosSELECT s.nombre || ' ' || s.apellido AS vendedor, t.nombre AS tienda, COUNT(o.pedido_id) AS pedidos_gestionadosFROM personal pJOIN personal s ON p.personal_id = s.personal_id -- alias para claridadJOIN tiendas t ON s.tienda_id = t.tienda_idJOIN pedidos o ON o.personal_id = s.personal_idGROUP BY s.personal_id, s.nombre, s.apellido, t.nombreORDER BY pedidos_gestionados DESCLIMIT 5;
Estas consultas usan subconsultas correlacionadas, EXISTS/NOT EXISTS y análisis de inventario crítico.Productos con precio mayor al promedio de su categoría
-- Subconsulta correlacionada: precio > promedio de la misma categoríaSELECT p.nombre AS producto, p.precio_lista, c.nombre AS categoria, ROUND( (SELECT AVG(p2.precio_lista) FROM productos p2 WHERE p2.categoria_id = p.categoria_id), 2 ) AS promedio_categoriaFROM productos pJOIN categorias c ON p.categoria_id = c.categoria_idWHERE p.precio_lista > ( SELECT AVG(p2.precio_lista) FROM productos p2 WHERE p2.categoria_id = p.categoria_id)ORDER BY c.nombre, p.precio_lista DESC;
Inventario bajo por tienda
-- Productos con menos de 5 unidades en cualquier tiendaSELECT t.nombre AS tienda, pr.nombre AS producto, m.nombre AS marca, i.cantidadFROM inventario iJOIN tiendas t ON i.tienda_id = t.tienda_idJOIN productos pr ON i.producto_id = pr.producto_idJOIN marcas m ON pr.marca_id = m.marca_idWHERE i.cantidad < 5ORDER BY i.cantidad ASC, t.nombre;
Clientes que nunca han realizado un pedido
-- Clientes registrados sin ningún pedido asociadoSELECT c.cliente_id, c.nombre || ' ' || c.apellido AS cliente, c.email, c.ciudadFROM clientes cWHERE NOT EXISTS ( SELECT 1 FROM pedidos o WHERE o.cliente_id = c.cliente_id)ORDER BY c.apellido, c.nombre;
Análisis de descuentos aplicados por producto
-- Productos con descuento promedio mayor al 10% en sus items vendidosSELECT p.nombre AS producto, COUNT(i.item_id) AS veces_vendido, ROUND(AVG(i.descuento) * 100, 2) AS descuento_promedio_pct, ROUND(MIN(i.descuento) * 100, 2) AS descuento_minimo_pct, ROUND(MAX(i.descuento) * 100, 2) AS descuento_maximo_pctFROM items_pedido iJOIN productos p ON i.producto_id = p.producto_idGROUP BY p.producto_id, p.nombreHAVING AVG(i.descuento) > 0.10ORDER BY descuento_promedio_pct DESC;
Resuelve los siguientes retos usando el esquema de bicicletas. Intenta escribir la consulta antes de expandir la solución propuesta.
Reto 1: Tienda con mayor revenue en el último año
Encuentra la tienda que generó el mayor revenue total (suma de cantidad × precio_lista × (1 - descuento)) en los pedidos del último año calendario. Muestra el nombre de la tienda, el número de pedidos y el revenue.Solución:
SELECT t.nombre AS tienda, COUNT(DISTINCT o.pedido_id) AS total_pedidos, SUM(i.cantidad * i.precio_lista * (1 - i.descuento)) AS revenueFROM tiendas tJOIN pedidos o ON t.tienda_id = o.tienda_idJOIN items_pedido i ON o.pedido_id = i.pedido_idWHERE o.fecha_pedido >= DATE_TRUNC('year', CURRENT_DATE - INTERVAL '1 year') AND o.fecha_pedido < DATE_TRUNC('year', CURRENT_DATE)GROUP BY t.tienda_id, t.nombreORDER BY revenue DESCLIMIT 1;
Reto 2: Productos sin movimiento de inventario
Encuentra todos los productos que existen en la tabla productos pero que no tienen ninguna línea en items_pedido (es decir, nunca han sido vendidos). Muestra el nombre del producto, la marca y la categoría.Solución:
SELECT p.nombre AS producto, m.nombre AS marca, c.nombre AS categoriaFROM productos pJOIN marcas m ON p.marca_id = m.marca_idJOIN categorias c ON p.categoria_id = c.categoria_idWHERE NOT EXISTS ( SELECT 1 FROM items_pedido i WHERE i.producto_id = p.producto_id)ORDER BY c.nombre, p.nombre;
Reto 3: Jerarquía de personal (autounión)
El campo gerente_id en la tabla personal es una autorreferencia (cada empleado puede tener un gerente que también es personal). Escribe una consulta que muestre cada empleado junto con el nombre de su gerente directo. Los empleados sin gerente (gerentes de tienda) deben aparecer con NULL en la columna de gerente.Solución:
SELECT e.personal_id, e.nombre || ' ' || e.apellido AS empleado, t.nombre AS tienda, g.nombre || ' ' || g.apellido AS gerente_directoFROM personal eJOIN tiendas t ON e.tienda_id = t.tienda_idLEFT JOIN personal g ON e.gerente_id = g.personal_idORDER BY t.nombre, gerente_directo NULLS FIRST, empleado;