Módulo 2: The Star Schema And Conformed Dimensions

Anatomía de un star schema

Descripción

Esta lección responde una pregunta que el módulo 1 dejó, a propósito, sin resolver: ¿qué hace que una tabla sea físicamente un "hecho" y otra una "dimensión", más allá del vocabulario que ya aprendiste (medidas aditivas, contexto descriptivo)? La respuesta tiene una parte de comportamiento —ya la conoces— y una parte de forma: un hecho es una tabla alta y angosta (muchas filas, pocas columnas), una dimensión es una tabla baja y ancha (pocas filas, más columnas descriptivas), y un star schema es exactamente eso: un hecho al centro, con sus dimensiones alrededor, cada una a un solo JOIN de distancia.

Conexión con el módulo. Esta lección no construye ninguna tabla nueva —usa fact_orders, dim_store y dim_product exactamente como los dejó el módulo 1, con llave natural—. Lo que construye es el vocabulario de forma que las lecciones 3 y 4 van a modificar (agregando llaves sustitutas y dim_date), y que la lección 7 va a ensamblar por completo.

Una analogía: el clóset con todo a la mano

Retoma la analogía de la introducción de este módulo: un clóset bien organizado tiene cada prenda a un solo movimiento de la mano —abres, ves, tomas—, sin cajas anidadas dentro de otras cajas. Un star schema aplica exactamente esa idea a un warehouse: desde fact_orders, cualquier atributo de tienda o de producto está a un solo JOIN de distancia. Quieres saber en qué ciudad ocurrió una venta: un JOIN a dim_store. Quieres saber la categoría del producto vendido: un JOIN a dim_product. Nunca dos saltos, nunca una tabla intermedia que tengas que atravesar primero.

El nombre "star" (estrella) viene, literalmente, de la forma que toma el diagrama: la tabla de hechos en el centro, y cada dimensión como una punta de la estrella, conectada directamente al centro y a ninguna otra punta. Un snowflake schema —que el módulo 3 va a construir, no esta lección— rompe esa forma: normaliza una dimensión dentro de otra (por ejemplo, dim_product apuntando a dim_category en vez de tener la categoría como columna propia), y el diagrama deja de verse como una estrella limpia para parecerse más a un copo de nieve, con ramificaciones. Esta lección construye la estrella; el módulo 3 explica, con evidencia de costo de JOIN, cuándo vale la pena romperla.

Ejemplo trabajado: la forma física de fact_orders, dim_store y dim_product

Antes de mirar cualquier diagrama, verifica la forma de las tres tablas que ya tienes, con una consulta real sobre DuckDB. Parte del kiosko.py y raw_orders.py del módulo 1 —idénticos, sin ningún cambio— para reconstruir las tres tablas exactamente como quedaron al cerrar ese módulo.

# anatomy.py
from datetime import datetime

import duckdb

from kiosko import DIM_PRODUCT, DIM_STORE, Order, transform_fact_orders
from raw_orders import RAW_ORDERS

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],
)
con.execute("CREATE TABLE dim_store (store_id VARCHAR, store_name VARCHAR, city VARCHAR)")
con.executemany("INSERT INTO dim_store VALUES (?, ?, ?)",
                 [(s["store_id"], s["store_name"], s["city"]) for s in DIM_STORE])
con.execute("CREATE TABLE dim_product (product_id VARCHAR, product_name VARCHAR, category VARCHAR, unit_cost DOUBLE)")
con.executemany("INSERT INTO dim_product VALUES (?, ?, ?, ?)",
                 [(p["product_id"], p["product_name"], p["category"], p["unit_cost"]) for p in DIM_PRODUCT])

print("=== Forma de cada tabla: filas vs columnas ===")
print(con.sql("""
    SELECT 'fact_orders' AS table_name,
           (SELECT COUNT(*) FROM fact_orders) AS row_count,
           (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'fact_orders') AS column_count
    UNION ALL
    SELECT 'dim_store',
           (SELECT COUNT(*) FROM dim_store),
           (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'dim_store')
    UNION ALL
    SELECT 'dim_product',
           (SELECT COUNT(*) FROM dim_product),
           (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'dim_product')
    ORDER BY table_name
"""))

Qué esperar. Al correr python3 anatomy.py, la salida es exactamente esta:

=== Forma de cada tabla: filas vs columnas ===
┌─────────────┬───────────┬──────────────┐
│ table_name  │ row_count │ column_count │
│   varchar   │   int64   │    int64     │
├─────────────┼───────────┼──────────────┤
│ dim_product │         4 │            4 │
│ dim_store   │         3 │            3 │
│ fact_orders │        40 │            7 │
└─────────────┴───────────┴──────────────┘

Ahí está la anatomía, en números: fact_orders tiene diez veces más filas que dim_store (40 contra 3) y casi trece veces más que dim_product (40 contra 4 filas), pero menos columnas que ambas combinadas. Esta es, exactamente, la firma física de un star schema bien formado: los hechos son tablas altas y angostas —crecen sin límite con cada evento de negocio nuevo—, y las dimensiones son tablas bajas y anchas —crecen despacio, casi nunca en filas, a veces en columnas cuando se agrega un nuevo atributo descriptivo—. dim_store va a seguir teniendo tres filas incluso si Kiosko procesa un millón de órdenes; fact_orders va a seguir creciendo, una fila por cada línea de orden nueva, sin ningún límite natural.

Diagrama: la forma de estrella

flowchart TD
    F["fact_orders\n(40 filas, 7 columnas)\norder_id, store_id, product_id,\nquantity, unit_price, revenue, order_ts"]

    S["dim_store\n(3 filas, 3 columnas)\nstore_id, store_name, city"]
    P["dim_product\n(4 filas, 4 columnas)\nproduct_id, product_name, category, unit_cost"]
    D["dim_date\n(construida en la leccion 4)\ndate_key, calendar_date, day_of_week..."]

    S ---|"1 JOIN"| F
    P ---|"1 JOIN"| F
    D -.->|"1 JOIN\n(pendiente hasta L4)"| F

Fíjate en que dim_date aparece en el diagrama con una línea punteada — todavía no existe, se construye en la lección 4 de este módulo, pero el diagrama muestra el destino completo: el star schema al que este módulo entero está construyendo, pieza por pieza. Cada punta de la estrella se conecta al centro con exactamente un JOIN, y ninguna punta se conecta directamente a otra punta — esa es, con precisión, la regla que define la forma de estrella.

Profundización: la prueba práctica de "¿esto es un hecho o una dimensión?"

Más allá del comportamiento (aditivo vs descriptivo, ya visto en el módulo 1), hay una prueba física rápida que puedes aplicar a cualquier tabla nueva que te encuentres en un warehouse real: ¿esta tabla crece con cada evento de negocio, o describe algo relativamente estable que ya existía antes del evento? Una tabla de ventas crece con cada venta — es un hecho. Una tabla de tiendas no crece con cada venta —Kiosko no abre una tienda nueva cada vez que alguien compra un café— es una dimensión.

Esta prueba tiene una excepción importante que vale la pena nombrar aquí, aunque esta guía no la construya todavía: algunas dimensiones sí cambian con el tiempo —el precio de un producto, la categoría de un producto—, y cuando eso pasa, la dimensión necesita una estrategia para historizar ese cambio sin perder el pasado. Eso es exactamente lo que el módulo 4 (Slowly Changing Dimensions) resuelve. Por ahora, con dim_store y dim_product completamente estáticas —tres tiendas fijas, cuatro productos fijos, sin ningún cambio durante toda la semana de Kiosko—, la prueba simple (¿crece con cada evento?) es suficiente.

Otra observación útil, ya presente en los números de esta lección: fíjate en que dim_product, con cuatro columnas, ya tiene más columnas que dim_store, con tres. Esto no es casualidad ni un límite fijo — las dimensiones tienden a acumular columnas con el tiempo, a medida que el negocio necesita describir sus entidades con más detalle (un producto podría eventualmente tener brand, supplier, weight_grams...), mientras que los hechos tienden a mantener un número de columnas más estable, definido por su grano — agregar una columna nueva a un hecho normalmente significa que el grano cambió, algo que la lección 7 del módulo 1 ya te enseñó a tomar en serio.

Errores comunes

Confundir "pocas columnas" con "menos importante". Qué pasa: alguien, al ver que fact_orders tiene solo siete columnas frente a las cuatro de dim_product, asume que la dimensión es "más rica" o "más completa", y que el hecho es una tabla secundaria. Por qué pasa: en el lenguaje cotidiano, "tiene más columnas" suena a "tiene más información", y es fácil transferir esa intuición al modelo dimensional sin cuestionarla. Cómo detectarlo: si tu razonamiento sobre qué tabla es "el centro" del modelo se basa en el número de columnas en vez del número de filas y del rol de negocio (¿esto mide un evento, o describe una entidad?), estás usando el criterio equivocado. Cómo corregirlo: el hecho es el centro del star precisamente porque es la tabla que crece —la que acumula el historial completo de eventos de negocio—; las dimensiones son satélites que dan contexto a ese historial, sin importar cuántas columnas tengan.

Pensar que un star schema completo necesita muchas dimensiones para ser "real". Qué pasa: alguien ve el star de Kiosko con solo tres dimensiones (pronto cuatro, con dim_date) y siente que es "demasiado simple" comparado con ejemplos de warehouses de producción que tienen quince o veinte dimensiones. Por qué pasa: los ejemplos de la industria, en libros y conferencias, suelen mostrar modelos grandes y complejos, y es fácil asumir que la complejidad es requisito, no consecuencia. Cómo detectarlo: si sientes la necesidad de "inventar" dimensiones adicionales para Kiosko sin que ningún proceso de negocio real las necesite, estás optimizando por apariencia, no por necesidad. Cómo corregirlo: el número de dimensiones lo determina el proceso de negocio, no una meta arbitraria — Kiosko tiene tres (pronto cuatro) porque son, exactamente, las que su proceso de venta necesita para responder sus preguntas reales. Un warehouse de producción con veinte dimensiones probablemente tiene veinte procesos de negocio distintos detrás, no un capricho de diseño.

Creer que toda tabla con una llave primaria es automáticamente una dimensión. Qué pasa: alguien ve cualquier tabla con una columna que identifica cada fila de forma única y la clasifica como "dimensión", sin evaluar si crece con cada evento de negocio. Por qué pasa: la mayoría de las dimensiones sí tienen una llave primaria clara, y es tentador usar esa característica como la prueba definitiva. Cómo detectarlo: una tabla de logs de auditoría, por ejemplo, también puede tener una llave primaria única por fila (un log_id), pero crece con cada evento del sistema — es, por comportamiento, mucho más parecida a un hecho que a una dimensión, aunque tenga la forma superficial de una. Cómo corregirlo: usa siempre las dos pruebas juntas — ¿crece con cada evento de negocio? y ¿describe algo relativamente estable? — nunca solo la forma de la llave primaria.

Ejercicios

Ejercicio 1 — Clasifica una tabla hipotética de Kiosko. Imagina que Kiosko agrega una tabla dim_employee (employee_id, employee_name, hire_date, store_id) para registrar a los empleados de cada tienda. Sin escribir código, explica en 2-3 frases si esta tabla es un hecho o una dimensión, usando la prueba de esta lección (¿crece con cada evento de negocio, o describe algo relativamente estable?).

Ver solución

dim_employee es una dimensión: describe una entidad relativamente estable (un empleado de Kiosko), no un evento de negocio que ocurre repetidamente. Aunque la tabla eventualmente crecería cuando Kiosko contratara nuevos empleados, ese crecimiento sería mucho más lento y menos frecuente que el de fact_orders, que gana una fila nueva con cada línea de orden vendida — no con cada contratación. El nombre mismo, con el prefijo dim_, ya sigue la convención de esta guía para nombrar dimensiones, consistente con la clasificación por comportamiento.

Ejercicio 2 — Verifica la forma de una tabla nueva con SQL. Usando el mismo patrón de consulta del ejemplo trabajado (contar filas y columnas con information_schema.columns), escribe la consulta que confirmaría la forma de una hipotética dim_employee con cinco empleados y cuatro columnas — sin ejecutarla contra ninguna tabla real, solo escribe el SQL que usarías.

Ver solución
SELECT 'dim_employee' AS table_name,
       (SELECT COUNT(*) FROM dim_employee) AS row_count,
       (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'dim_employee') AS column_count

Con los datos hipotéticos del ejercicio 1 (cinco empleados, cuatro columnas), esta consulta debería devolver row_count = 5 y column_count = 4 — pocas filas, forma típica de dimensión, consistente con la clasificación que ya hiciste en el ejercicio anterior.

Ejercicio 3 — Calcula cuántas columnas suman las dos dimensiones actuales de Kiosko, combinadas. Usando fact_orders, dim_store y dim_product ya cargados en DuckDB por el ejemplo trabajado, escribe y ejecuta una consulta que sume el número de columnas de dim_store y dim_product juntas, y compárala contra el número de columnas de fact_orders.

Ver solución
print(con.sql("""
    SELECT
        (SELECT COUNT(*) FROM information_schema.columns WHERE table_name='dim_store') +
        (SELECT COUNT(*) FROM information_schema.columns WHERE table_name='dim_product') AS total_dim_columns,
        (SELECT COUNT(*) FROM information_schema.columns WHERE table_name='fact_orders') AS fact_columns
"""))

Salida esperada:

┌───────────────────┬──────────────┐
│ total_dim_columns │ fact_columns │
│       int64       │    int64     │
├───────────────────┼──────────────┤
│                 7 │            7 │
└───────────────────┴──────────────┘

dim_store (3 columnas) más dim_product (4 columnas) suman exactamente 7 — el mismo número de columnas que tiene fact_orders por sí sola. Es una coincidencia numérica de esta semana específica de Kiosko, no una regla general del modelado dimensional — no esperes que siempre coincida así—, pero sirve para notar algo real: hoy, con las dimensiones todavía en su forma natural del módulo 1, ninguna de las tres tablas es dramáticamente "más ancha" que las otras. La lección 3 cambia ese número: al agregar store_key y product_key, cada dimensión gana una columna más (4 + 5 = 9 en total), y las dimensiones empiezan a superar en columnas a fact_orders — el patrón que la profundización de esta lección ya anticipó.

Resumen y siguiente paso

En esta lección verificaste, con una consulta real, la anatomía física de un star schema: fact_orders es una tabla alta y angosta (40 filas, 7 columnas) que crece con cada línea de orden; dim_store y dim_product son tablas bajas y anchas (3 y 4 filas respectivamente) que describen entidades relativamente estables. El nombre "star" viene de la forma del diagrama: el hecho al centro, cada dimensión a un solo JOIN de distancia, sin ninguna dimensión conectada directamente a otra.

Antes de avanzar deberías poder: explicar la diferencia física entre un hecho y una dimensión usando filas y columnas, no solo comportamiento; dibujar de memoria el diagrama de estrella de Kiosko con sus tres (pronto cuatro) puntas; y describir, en una frase, qué distingue a un star schema de un snowflake schema.

La lección 3 ataca la primera pieza estructural que todavía falta: dim_store y dim_product siguen usando llave natural. La siguiente lección agrega llaves sustitutas —store_key, product_key— y explica, con una analogía concreta, por qué esa decisión importa incluso antes de que el módulo 4 la vuelva indispensable.

Recursos