Módulo 2: The Star Schema And Conformed Dimensions
Mini-proyecto: el star schema de Kiosko en DuckDB
Descripción
Este proyecto cierra el módulo integrando las seis piezas anteriores: la anatomía de un star schema (lección 2), llaves sustitutas para dim_store y dim_product (lección 3), dim_date construida de cero (lección 4), el vocabulario de dimensiones conformadas (lección 5), el bus matrix como mapa de planeación (lección 6), y el ensamblaje de los tres JOIN, verificado (lección 7). Lo que falta es reunir todo en una entrega formal: las cuatro tablas del star, construidas de punta a punta, documentadas como una estructura de datos, y verificadas —número por número— contra el revenue que ya conoces desde foundations.
El proyecto tiene cuatro partes. Primero, construyes las cuatro tablas del star schema completo. Segundo, ensamblas y verificas el JOIN de las tres dimensiones contra fact_orders, confirmando que el conteo no cambia. Tercero, documentas el star como una estructura formal, reutilizable por el resto de la guía —el equivalente, para este módulo, de lo que GRAIN_DECLARATION fue para el módulo 1—. Cuarto, verificas contra foundations: confirmas que el revenue total y por tienda y por producto, calculado ahora a través del star completo, coincide exactamente con los números que ya conoces.
Conexión con el módulo. Este proyecto no introduce ningún concepto nuevo — es la integración final de las siete lecciones anteriores, empaquetada como STAR_SCHEMA_DECLARATION, la estructura que los módulos 3 a 8 de esta guía dan por sentado sin volver a discutirla.
Una analogía: el plano arquitectónico firmado
Cada lección de este módulo construyó una pieza distinta del edificio: los cimientos (anatomía), las llaves de cada puerta (llaves sustitutas), una habitación completa que faltaba (dim_date), el criterio para compartir espacios entre inquilinos (dimensiones conformadas), el mapa del edificio completo (bus matrix). Este proyecto es el plano arquitectónico firmado: el documento final que certifica que el edificio, tal como quedó construido, cumple su propósito — cada habitación conectada correctamente, sin ningún pasillo roto, verificado con una inspección real antes de la entrega.
El material: todo lo que este módulo construyó, en un solo flujo
Necesitas, en la misma carpeta: kiosko.py y raw_orders.py (idénticos al módulo 1). No necesitas ningún archivo adicional — generate_date_dim() se define directamente en el script de este proyecto, igual que en la lección 7.
La solución de referencia, verificada
Parte 1 — Construir las cuatro tablas del star
# star_schema_project.py -- Kiosko's star schema in DuckDB (mini-proyecto de cierre del modulo 2)
from datetime import date, timedelta, datetime
import duckdb
from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def generate_date_dim(start_date: str, end_date: str) -> list[dict]:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
if end < start:
raise ValueError(f"end_date ({end_date}) es anterior a start_date ({start_date})")
rows = []
current = start
while current <= end:
weekday_index = current.weekday()
rows.append({
"date_key": int(current.strftime("%Y%m%d")),
"calendar_date": current,
"day_of_week": DAY_NAMES[weekday_index],
"month": current.month,
"quarter": (current.month - 1) // 3 + 1,
"year": current.year,
"is_weekend": weekday_index >= 5,
})
current += timedelta(days=1)
return rows
print("=== Kiosko: star schema completo, entrega final del modulo 2 ===\n")
# --- fact_orders, heredado sin cambios de M1 ---
orders = [
Order(order_id=r[0], store_id=r[1], product_id=r[2], quantity=r[3],
unit_price=r[4], order_ts=datetime.fromisoformat(r[5]))
for r in RAW_ORDERS
]
fact_orders = transform_fact_orders(orders, DIM_STORE, DIM_PRODUCT)
con = duckdb.connect()
con.execute("""
CREATE TABLE fact_orders (
order_id VARCHAR, store_id VARCHAR, product_id VARCHAR,
quantity INTEGER, unit_price DOUBLE, revenue DOUBLE, order_ts TIMESTAMP
)
""")
con.executemany(
"INSERT INTO fact_orders VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["order_id"], r["store_id"], r["product_id"], r["quantity"],
r["unit_price"], r["revenue"], r["order_ts"]) for r in fact_orders],
)
# --- las tres dimensiones, con llave sustituta donde aplica ---
con.execute("CREATE TABLE dim_store_natural (store_id VARCHAR, store_name VARCHAR, city VARCHAR)")
con.executemany("INSERT INTO dim_store_natural VALUES (?, ?, ?)",
[(s["store_id"], s["store_name"], s["city"]) for s in DIM_STORE])
con.execute("""
CREATE TABLE dim_store AS
SELECT ROW_NUMBER() OVER (ORDER BY store_id) AS store_key, store_id, store_name, city
FROM dim_store_natural
""")
con.execute("CREATE TABLE dim_product_natural (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)")
con.executemany("INSERT INTO dim_product_natural VALUES (?, ?, ?, ?)",
[(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT])
con.execute("""
CREATE TABLE dim_product AS
SELECT ROW_NUMBER() OVER (ORDER BY product_id) AS product_key, product_id, product_name, category, unit_cost
FROM dim_product_natural
""")
dim_date_rows = generate_date_dim("2026-08-01", "2026-08-31")
con.execute("""
CREATE TABLE dim_date (
date_key INTEGER, calendar_date DATE, day_of_week VARCHAR,
month INTEGER, quarter INTEGER, year INTEGER, is_weekend BOOLEAN
)
""")
con.executemany(
"INSERT INTO dim_date VALUES (?, ?, ?, ?, ?, ?, ?)",
[(r["date_key"], r["calendar_date"], r["day_of_week"], r["month"],
r["quarter"], r["year"], r["is_weekend"]) for r in dim_date_rows],
)
print("Parte 1 -- las cuatro tablas del star, construidas")
for table in ["fact_orders", "dim_store", "dim_product", "dim_date"]:
count = con.sql(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f" {table:14} {count:3} filas")
Esta primera parte no es un ejercicio aislado — reconstruye, en un solo lugar, todo lo que las lecciones 3 y 4 construyeron por separado. Es el material de entrada para las tres partes que siguen.
Parte 2 — Ensamblar el star y verificar el grano
star_query = """
SELECT
f.order_id, f.store_id, f.product_id,
s.store_key, s.store_name,
p.product_key, p.product_name, p.category,
d.date_key, d.calendar_date, d.day_of_week, d.is_weekend,
f.quantity, f.unit_price, f.revenue
FROM fact_orders f
JOIN dim_store s ON f.store_id = s.store_id
JOIN dim_product p ON f.product_id = p.product_id
JOIN dim_date d ON CAST(strftime(f.order_ts, '%Y%m%d') AS INTEGER) = d.date_key
"""
con.execute(f"CREATE TABLE fact_orders_star AS {star_query}")
print("\nParte 2 -- ensamblando el star con los 3 JOIN y verificando el grano")
before = con.sql("SELECT COUNT(*) FROM fact_orders").fetchone()[0]
after = con.sql("SELECT COUNT(*) FROM fact_orders_star").fetchone()[0]
print(f" fact_orders (sin unir): {before} filas")
print(f" fact_orders_star (con 3 JOIN): {after} filas")
assert before == after, "el join perdio o duplico filas"
print(f" Verificacion: {before} == {after} -> OK, ningun JOIN perdio ni duplico una fila")
Fíjate en que esta parte materializa el resultado del JOIN como una tabla nueva, fact_orders_star —a diferencia de la lección 7, que dejó el JOIN como una subconsulta reutilizada—. Ambos enfoques son válidos; materializar la tabla aquí facilita las verificaciones de la Parte 4 sin repetir la consulta completa cada vez.
Parte 3 — Documentar el star como una estructura formal
STAR_SCHEMA_DECLARATION = {
"fact_table": "fact_orders",
"grain": "una linea de orden (heredado del modulo 1, sin cambios)",
"dimensions": {
"dim_store": {"surrogate_key": "store_key", "natural_key": "store_id", "rows": 3},
"dim_product": {"surrogate_key": "product_key", "natural_key": "product_id", "rows": 4},
"dim_date": {"surrogate_key": "date_key", "natural_key": "calendar_date", "rows": 31},
},
"conformed_dimensions": ["dim_store", "dim_date"],
"verified_star_row_count": after,
}
print("\nParte 3 -- la declaracion formal del star schema de Kiosko")
for key, value in STAR_SCHEMA_DECLARATION.items():
print(f" {key}: {value}")
Cada campo de STAR_SCHEMA_DECLARATION corresponde a una lección concreta de este módulo: dimensions viene de las lecciones 3 y 4, conformed_dimensions viene de la lección 5, y verified_star_row_count es la evidencia numérica misma de la Parte 2. Esta estructura, igual que GRAIN_DECLARATION en el módulo 1, es el contrato que los módulos 3 a 8 de esta guía van a dar por sentado sin volver a discutirlo.
Parte 4 — Verificar contra foundations, número por número
print("\nParte 4 -- verificacion cruzada: los mismos numeros, ahora a traves del star completo")
print(con.sql("SELECT ROUND(SUM(revenue), 2) AS total_revenue FROM fact_orders_star"))
print(con.sql("""
SELECT store_id, store_name, COUNT(*) AS order_count, SUM(quantity) AS total_units, ROUND(SUM(revenue), 2) AS revenue
FROM fact_orders_star
GROUP BY store_id, store_name
ORDER BY store_id
"""))
print(con.sql("""
SELECT product_id, product_name, COUNT(*) AS order_count, SUM(quantity) AS total_units, ROUND(SUM(revenue), 2) AS revenue
FROM fact_orders_star
GROUP BY product_id, product_name
ORDER BY product_id
"""))
Qué esperar. Al correr python3 star_schema_project.py completo (las cuatro partes juntas), la salida es exactamente esta:
=== Kiosko: star schema completo, entrega final del modulo 2 ===
Parte 1 -- las cuatro tablas del star, construidas
fact_orders 40 filas
dim_store 3 filas
dim_product 4 filas
dim_date 31 filas
Parte 2 -- ensamblando el star con los 3 JOIN y verificando el grano
fact_orders (sin unir): 40 filas
fact_orders_star (con 3 JOIN): 40 filas
Verificacion: 40 == 40 -> OK, ningun JOIN perdio ni duplico una fila
Parte 3 -- la declaracion formal del star schema de Kiosko
fact_table: fact_orders
grain: una linea de orden (heredado del modulo 1, sin cambios)
dimensions: {'dim_store': {'surrogate_key': 'store_key', 'natural_key': 'store_id', 'rows': 3}, 'dim_product': {'surrogate_key': 'product_key', 'natural_key': 'product_id', 'rows': 4}, 'dim_date': {'surrogate_key': 'date_key', 'natural_key': 'calendar_date', 'rows': 31}}
conformed_dimensions: ['dim_store', 'dim_date']
verified_star_row_count: 40
Parte 4 -- verificacion cruzada: los mismos numeros, ahora a traves del star completo
┌───────────────┐
│ total_revenue │
│ double │
├───────────────┤
│ 106.15 │
└───────────────┘
┌──────────┬───────────────┬─────────────┬─────────────┬─────────┐
│ store_id │ store_name │ order_count │ total_units │ revenue │
│ varchar │ varchar │ int64 │ int128 │ double │
├──────────┼───────────────┼─────────────┼─────────────┼─────────┤
│ S01 │ Kiosko Centro │ 16 │ 34 │ 38.3 │
│ S02 │ Kiosko Norte │ 13 │ 37 │ 38.8 │
│ S03 │ Kiosko Sur │ 11 │ 31 │ 29.05 │
└──────────┴───────────────┴─────────────┴─────────────┴─────────┘
┌────────────┬───────────────────────┬─────────────┬─────────────┬─────────┐
│ product_id │ product_name │ order_count │ total_units │ revenue │
│ varchar │ varchar │ int64 │ int128 │ double │
├────────────┼───────────────────────┼─────────────┼─────────────┼─────────┤
│ P001 │ Bottled Water 600ml │ 16 │ 61 │ 33.55 │
│ P002 │ Energy Bar │ 10 │ 18 │ 21.6 │
│ P003 │ Instant Coffee Sachet │ 7 │ 14 │ 10.5 │
│ P004 │ Phone Charger Cable │ 7 │ 9 │ 40.5 │
└────────────┴───────────────────────┴─────────────┴─────────────┴─────────┘
Detente en la Parte 4, porque es la que le da confianza a todo el proyecto: 106.15 de revenue total, 38.3/38.8/29.05 por tienda, 33.55/21.6/10.5/40.5 por producto — exactamente los mismos números que ya viste en el proyecto del módulo 1, y antes que eso, en el capstone de foundations. Esto confirma algo que ninguna lección anterior de este módulo probó de forma tan completa: ensamblar el star schema completo —con llaves sustitutas, con dim_date, con los tres JOIN— no cambió ni un centavo de revenue. El star agrega estructura y contexto; no altera el hecho que ya conocías.
Diagrama: las seis piezas del módulo, cerradas con evidencia
flowchart TD
A["L2: Anatomia del star\n(fact alto/angosto, dims bajas/anchas)"] --> B
B["L3: store_key, product_key\nVERIFICADO -- llaves deterministas"] --> C
C["L4: dim_date\nVERIFICADO -- 31 filas, agosto 2026"] --> D
D["L5-L6: Dimensiones conformadas\ny bus matrix -- el mapa completo"] --> E
E["L7: Los 3 JOIN ensamblados\nVERIFICADO -- 40 == 40"] --> F
F["STAR_SCHEMA_DECLARATION\nel contrato formal que este proyecto entrega"]
F --> G["Modulos 3-8: dan por sentado\neste contrato sin volver a discutirlo"]
Cerrando el checklist de la lección 2 del módulo 1, pieza por pieza
| Pieza del checklist (lección 2, módulo 1) | Estado al cerrar este módulo |
|---|---|
Grano de fact_orders declarado y verificado | Resuelto — módulo 1 |
Llaves sustitutas, dim_date, dimensiones conformadas | Resuelto — ESTE MÓDULO, STAR_SCHEMA_DECLARATION verificado con 40 == 40 |
| Snowflake vs tabla ancha | Pendiente — módulo 3 |
| Historización (SCD) | Pendiente — módulo 4 |
| Join punto-en-el-tiempo, deduplicación | Pendiente — módulo 5 |
| Accumulating snapshot, cumulative design | Pendiente — módulo 6 |
| Dimensión junk, más de un hecho | Pendiente — módulo 7 |
Dos filas de las doce del checklist original ya quedaron resueltas —y son, en orden, exactamente las dos primeras—: sin un grano declarado (módulo 1) y sin un star schema completo con llaves sustitutas y dimensiones conformadas (este módulo), ninguna de las cinco piezas restantes tendría un cimiento confiable sobre el cual construirse. El módulo 3, el siguiente en la lista, necesita específicamente el star schema que este proyecto acaba de cerrar: solo se puede comparar el costo de un JOIN normalizado (snowflake) contra el costo de este mismo JOIN de estrella si el star ya existe, construido y verificado.
Errores comunes
Entregar STAR_SCHEMA_DECLARATION sin la verificación de la Parte 2. Qué pasa: alguien, apurado por mostrar la estructura formal como resultado final, construye STAR_SCHEMA_DECLARATION directamente, sin correr primero el assert before == after de la Parte 2. Por qué pasa: la estructura de datos se ve más presentable como "el entregable", y la verificación del JOIN se siente como un paso preliminar descartable. Cómo detectarlo: si tu entrega final no incluye ninguna evidencia ejecutada de que el JOIN no perdió ni duplicó filas, estás documentando una afirmación, no una declaración verificada — exactamente la misma trampa que el módulo 1 ya advirtió sobre el grano. Cómo corregirlo: la Parte 2 de este proyecto no es opcional — es la garantía que hace confiable todo lo que sigue en la Parte 3 y la Parte 4.
Confundir "el star está construido" con "el modelo ya está terminado". Qué pasa: alguien termina este proyecto, ve las cuatro tablas construidas y verificadas, y concluye que el warehouse dimensional de Kiosko ya está completo. Por qué pasa: un star schema completo, con llaves sustitutas y dim_date, se siente como un logro sustancial —y lo es—, y es fácil olvidar que sigue siendo el segundo de ocho módulos. Cómo detectarlo: si no puedes nombrar, de memoria, al menos tres de las cinco piezas todavía pendientes en la tabla del checklist de esta lección, te falta releer esa tabla. Cómo corregirlo: dim_product sigue siendo completamente estática —sin ninguna versión histórica—, fact_orders sigue siendo el único proceso de negocio, y no existe ninguna deduplicación ni accumulating snapshot todavía. Este proyecto cierra el segundo paso de ocho, no la guía completa.
Reutilizar fact_orders_star como si fuera la nueva fact_orders de la guía. Qué pasa: alguien, satisfecho con el resultado del JOIN, empieza a referirse a fact_orders_star como "el nuevo fact_orders", reemplazando en su cabeza la tabla original con la versión ya unida a sus dimensiones. Por qué pasa: fact_orders_star tiene más columnas útiles (store_name, product_name, day_of_week) y se siente, en la práctica, más completa para consultar. Cómo detectarlo: si en algún ejercicio futuro asumes que fact_orders ya tiene columnas como store_name sin unir nada, mezclaste las dos tablas. Cómo corregirlo: fact_orders —la tabla original, con sus siete columnas y llaves naturales— sigue siendo el hecho canónico de esta guía, sin cambios, tal como lo dejó el módulo 1. fact_orders_star es un producto derivado, útil para consultas puntuales, pero no reemplaza al hecho original — los módulos siguientes de esta guía siguen construyendo sobre fact_orders, no sobre su versión ya unida.
Ejercicios
Ejercicio 1 — Verifica revenue por día de la semana, a través del star. Usando fact_orders_star, escribe una consulta que agrupe por day_of_week y calcule revenue total, ordenado por la fecha real de aparición (no alfabéticamente).
Ver solución
print(con.sql("""
SELECT day_of_week, is_weekend, ROUND(SUM(revenue), 2) AS revenue
FROM fact_orders_star
GROUP BY day_of_week, is_weekend
ORDER BY MIN(calendar_date)
"""))
Salida esperada:
┌─────────────┬────────────┬─────────┐
│ day_of_week │ is_weekend │ revenue │
│ varchar │ boolean │ double │
├─────────────┼────────────┼─────────┤
│ Monday │ false │ 15.85 │
│ Tuesday │ false │ 15.85 │
│ Wednesday │ false │ 9.55 │
│ Thursday │ false │ 11.05 │
│ Friday │ false │ 18.05 │
│ Saturday │ true │ 31.85 │
│ Sunday │ true │ 3.95 │
└─────────────┴────────────┴─────────┘
Suma los siete valores: 15.85 + 15.85 + 9.55 + 11.05 + 18.05 + 31.85 + 3.95 = 106.15 — el mismo revenue total de siempre, ahora desglosado por día de la semana, algo que fact_orders por sí solo, sin dim_date, no podía calcular sin repetir manualmente el mismo cálculo de fecha en cada consulta. El sábado (Saturday) tiene el revenue más alto —consistente con que también fue el día con más órdenes, nueve, según viste en el módulo 1—.
Ejercicio 2 — Extiende STAR_SCHEMA_DECLARATION con la fecha de verificación. Sin usar datetime.now(), agrega un campo verified_on con la fecha del último día de la semana de datos que este proyecto usó, "2026-08-09" — el mismo patrón que ya usaste en el proyecto del módulo 1.
Ver solución
STAR_SCHEMA_DECLARATION["verified_on"] = "2026-08-09"
print(f"verified_on: {STAR_SCHEMA_DECLARATION['verified_on']}")
Salida esperada:
verified_on: 2026-08-09
Igual que en el módulo 1, la fecha es un valor fijo y deliberado, no el resultado de datetime.now() — la misma disciplina de reproducibilidad que esta guía exige en cada bloque ejecutable, ahora aplicada también a los metadatos de la declaración, no solo a los datos de negocio.
Ejercicio 3 — Explica, de memoria, qué necesita el módulo 3 de este proyecto para poder empezar. Sin mirar el diseño de la guía, describe en un párrafo de 4-6 frases qué piezas de STAR_SCHEMA_DECLARATION —y de las cuatro tablas construidas en este proyecto— va a necesitar el módulo 3 para comparar star, snowflake y tabla ancha (OBT).
Ver solución
El módulo 3 necesita, como punto de partida, exactamente el star schema que este proyecto acaba de cerrar: fact_orders unido a dim_store, dim_product y dim_date mediante los tres JOIN ya verificados. Sobre esa base, va a normalizar dim_product —sacando category a una tabla nueva, dim_category, con su propia llave sustituta, siguiendo exactamente el mismo patrón de ROW_NUMBER() OVER (ORDER BY ...) que ya usaste en la lección 3 de este módulo— para construir la versión snowflake. Después va a comparar, con EXPLAIN, el costo de un JOIN de un solo salto (el star que este proyecto entrega) contra un JOIN de dos saltos (fact_orders → dim_product → dim_category, la versión snowflake). Finalmente, va a construir mart_daily_sales_obt, una tabla completamente denormalizada que junta todo en una sola fila ancha, para comparar el espacio y la velocidad de las tres formas. Ninguna de esas comparaciones tendría sentido sin el star ya construido y verificado que este proyecto entrega — es, literalmente, el punto de partida sobre el que se mide todo lo demás.
Resumen y siguiente paso: el final del módulo 2
Con este mini-proyecto cierras el módulo 2 completo. Construiste las cuatro tablas del star schema de Kiosko: fact_orders (heredado sin cambios), dim_store y dim_product con llave sustituta, y dim_date construida de cero con generate_date_dim(). Ensamblaste los tres JOIN y verificaste, con evidencia, que no perdieron ni duplicaron ni una sola de las cuarenta filas originales. Documentaste todo en STAR_SCHEMA_DECLARATION —el contrato formal que el resto de esta guía da por sentado— y confirmaste, número por número, que el revenue total (106.15) y sus desgloses por tienda y por producto siguen siendo exactamente los mismos que conoces desde foundations.
Diste el segundo paso de un camino de ocho módulos: fact_orders sigue siendo, columna por columna, el mismo hecho — lo que cambió es que ahora vive rodeado de un star schema completo, con llaves propias, una dimensión de calendario reutilizable, y el vocabulario para reconocer qué dimensiones va a poder compartir con los procesos de negocio que todavía no existen.
Hacia dónde sigues. El módulo 3 —star-vs-snowflake-vs-one-big-table— toma el star que acabas de construir y lo pone a prueba: normaliza dim_product en una versión snowflake, compara el costo real de cada JOIN con EXPLAIN, y construye una tabla ancha (One Big Table) completamente denormalizada — para que puedas decidir, con evidencia y no por moda, cuándo cada forma gana.
Recursos
- Kimball Group — "Star Schema / OLAP Cube" — la fuente completa del vocabulario dimensional que este proyecto integra: llaves sustitutas, dimensiones conformadas, y la forma de estrella verificada de punta a punta. kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/star-schema-olap-cube. En inglés.
- Microsoft Learn — "Understand star schema and the importance for Power BI" — confirmación práctica y vendor-neutral de cada pieza del star schema construida en este módulo. learn.microsoft.com/en-us/power-bi/guidance/star-schema. En inglés.
- "The Data Warehouse Toolkit", 3ra edición (Kimball & Ross, Wiley) — la referencia canónica que sostiene el vocabulario completo usado en este módulo, desde la anatomía del star hasta el bus matrix. wiley.com/en-jp/The+Data+Warehouse+Toolkit. En inglés.
- DuckDB — documentación oficial del cliente Python, la herramienta que ejecutó cada verificación de este módulo. duckdb.org/docs/current/clients/python/overview. En inglés.