Módulo 1: From Flat Tables To Dimensional Models

Paso 2: declarando el grano de fact_orders

Descripción

Esta es la lección central del módulo. Vas a reconstruir fact_orders exactamente como lo dejó foundations —mismas columnas, mismo catálogo, misma semana fija de cuarenta órdenes— y vas a cargarlo en DuckDB para ejecutar la consulta que declara el grano con evidencia, no con intuición: SELECT COUNT(*), COUNT(DISTINCT order_id || '-' || product_id) FROM fact_orders. Si esos dos números coinciden, confirmas algo preciso: cada fila de fact_orders ya es, hoy, "una línea de orden" — un producto específico, vendido dentro de una orden específica.

Conexión con el módulo. Este es el paso 2 del proceso de Kimball —declare the grain— y es, con toda propiedad, el resultado ejecutable central de todo el módulo. Las lecciones 6, 7 y 8 dan por sentado el resultado de esta consulta.

Una analogía: el grano no es "cuánto", es "de qué tamaño es cada pieza"

Retoma la analogía de la factura de la introducción de esta guía: si le preguntas a alguien "¿cuántas cosas hay en esta factura?", la pregunta es ambigua hasta que aclaras la unidad. ¿Cuántas facturas? ¿Cuántas líneas dentro de la factura (una por producto)? ¿Cuántas unidades individuales, sumando la cantidad de cada línea? Las tres son respuestas válidas a preguntas distintas — "3 facturas", "8 líneas", "23 unidades" pueden ser todas ciertas sobre el mismo montón de papeles, dependiendo de qué estés contando.

Declarar el grano es, exactamente, decidir y verificar cuál de esas unidades es la que representa una fila de tu tabla. No es una pregunta de "cuántas filas hay" —eso es solo COUNT(*)—; es la pregunta de qué representa, sin ambigüedad, cada una de esas filas. Esta lección responde esa pregunta para fact_orders, con una consulta que lo verifica, no con una suposición.

Ejemplo trabajado: reconstruyendo fact_orders y declarando su grano

Primero, el catálogo de Kiosko — idéntico, valor por valor, al que declaró foundations. Si ya tienes kiosko.py de esa guía en tu carpeta de trabajo, puedes reutilizarlo tal cual; aquí se repite completo para que esta guía sea autocontenida:

# kiosko.py
from dataclasses import dataclass
from datetime import datetime

DIM_STORE = [
    {"store_id": "S01", "store_name": "Kiosko Centro", "city": "Bogota"},
    {"store_id": "S02", "store_name": "Kiosko Norte", "city": "Lima"},
    {"store_id": "S03", "store_name": "Kiosko Sur", "city": "Santiago"},
]

DIM_PRODUCT = [
    {"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
    {"product_id": "P002", "product_name": "Energy Bar", "category": "snacks", "unit_cost": 0.60},
    {"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
    {"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]


@dataclass
class Order:
    order_id: str
    store_id: str
    product_id: str
    quantity: int
    unit_price: float
    order_ts: datetime


def transform_fact_orders(rows: list[Order], dim_store: list[dict], dim_product: list[dict]) -> list[dict]:
    store_index = {s["store_id"]: s for s in dim_store}
    product_index = {p["product_id"]: p for p in dim_product}
    fact_rows = []
    for order in rows:
        if order.store_id not in store_index:
            raise ValueError(f"unknown store_id: {order.store_id}")
        if order.product_id not in product_index:
            raise ValueError(f"unknown product_id: {order.product_id}")
        fact_rows.append({
            "order_id": order.order_id,
            "store_id": order.store_id,
            "product_id": order.product_id,
            "quantity": order.quantity,
            "unit_price": order.unit_price,
            "revenue": order.quantity * order.unit_price,
            "order_ts": order.order_ts,
        })
    return fact_rows

Ahora, la semana fija completa de Kiosko —los mismos siete archivos orders_2026-08-03.csv a orders_2026-08-09.csv de foundations, cuarenta órdenes en total—, declarada como datos fijos en Python para que esta lección sea ejecutable sin depender de archivos externos:

# raw_orders.py -- la semana fija de Kiosko, identica a foundations (modulos 1, 2 y 8)
RAW_ORDERS = [
    # Lunes 2026-08-03 (8 ordenes)
    ("ORD-1001", "S01", "P001", 3, 0.55, "2026-08-03T08:14:00"),
    ("ORD-1002", "S01", "P002", 1, 1.20, "2026-08-03T08:20:00"),
    ("ORD-1003", "S02", "P003", 2, 0.75, "2026-08-03T08:31:00"),
    ("ORD-1004", "S01", "P004", 1, 4.50, "2026-08-03T09:02:00"),
    ("ORD-1005", "S03", "P001", 5, 0.55, "2026-08-03T09:15:00"),
    ("ORD-1006", "S02", "P002", 2, 1.20, "2026-08-03T09:47:00"),
    ("ORD-1007", "S03", "P003", 1, 0.75, "2026-08-03T10:05:00"),
    ("ORD-1008", "S01", "P001", 2, 0.55, "2026-08-03T10:22:00"),
    # Martes 2026-08-04 (6 ordenes)
    ("ORD-2001", "S01", "P002", 1, 1.20, "2026-08-04T08:05:00"),
    ("ORD-2002", "S02", "P001", 4, 0.55, "2026-08-04T08:40:00"),
    ("ORD-2003", "S03", "P004", 1, 4.50, "2026-08-04T09:12:00"),
    ("ORD-2004", "S01", "P003", 3, 0.75, "2026-08-04T09:50:00"),
    ("ORD-2005", "S02", "P002", 2, 1.20, "2026-08-04T10:15:00"),
    ("ORD-2006", "S03", "P001", 6, 0.55, "2026-08-04T10:33:00"),
    # Miercoles 2026-08-05 (2 ordenes)
    ("ORD-3001", "S02", "P004", 2, 4.50, "2026-08-05T08:10:00"),
    ("ORD-3002", "S01", "P001", 1, 0.55, "2026-08-05T08:22:00"),
    # Jueves 2026-08-06 (5 ordenes)
    ("ORD-4001", "S01", "P001", 4, 0.55, "2026-08-06T08:10:00"),
    ("ORD-4002", "S02", "P003", 2, 0.75, "2026-08-06T08:45:00"),
    ("ORD-4003", "S03", "P002", 1, 1.20, "2026-08-06T09:20:00"),
    ("ORD-4004", "S01", "P004", 1, 4.50, "2026-08-06T09:55:00"),
    ("ORD-4005", "S02", "P001", 3, 0.55, "2026-08-06T10:30:00"),
    # Viernes 2026-08-07 (7 ordenes)
    ("ORD-5001", "S01", "P002", 2, 1.20, "2026-08-07T08:05:00"),
    ("ORD-5002", "S03", "P001", 4, 0.55, "2026-08-07T08:30:00"),
    ("ORD-5003", "S02", "P004", 1, 4.50, "2026-08-07T08:58:00"),
    ("ORD-5004", "S01", "P003", 2, 0.75, "2026-08-07T09:22:00"),
    ("ORD-5005", "S03", "P002", 3, 1.20, "2026-08-07T09:47:00"),
    ("ORD-5006", "S02", "P001", 5, 0.55, "2026-08-07T10:15:00"),
    ("ORD-5007", "S01", "P001", 2, 0.55, "2026-08-07T10:40:00"),
    # Sabado 2026-08-08 (9 ordenes)
    ("ORD-6001", "S01", "P001", 6, 0.55, "2026-08-08T08:00:00"),
    ("ORD-6002", "S02", "P002", 3, 1.20, "2026-08-08T08:18:00"),
    ("ORD-6003", "S03", "P001", 4, 0.55, "2026-08-08T08:35:00"),
    ("ORD-6004", "S01", "P004", 2, 4.50, "2026-08-08T08:52:00"),
    ("ORD-6005", "S02", "P003", 3, 0.75, "2026-08-08T09:10:00"),
    ("ORD-6006", "S03", "P002", 2, 1.20, "2026-08-08T09:28:00"),
    ("ORD-6007", "S01", "P003", 1, 0.75, "2026-08-08T09:45:00"),
    ("ORD-6008", "S02", "P001", 7, 0.55, "2026-08-08T10:02:00"),
    ("ORD-6009", "S03", "P004", 1, 4.50, "2026-08-08T10:20:00"),
    # Domingo 2026-08-09 (3 ordenes)
    ("ORD-7001", "S01", "P001", 2, 0.55, "2026-08-09T09:15:00"),
    ("ORD-7002", "S02", "P002", 1, 1.20, "2026-08-09T09:40:00"),
    ("ORD-7003", "S03", "P001", 3, 0.55, "2026-08-09T10:05:00"),
]

Y ahora, el ejemplo trabajado real: construir fact_orders, cargarlo en DuckDB, y declarar su grano con la consulta central de este módulo.

# declare_grain.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)
print(f"Total filas reconstruidas en fact_orders: {len(fact_orders)}\n")

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],
)

print("=== Declarando el grano de fact_orders ===")
print(con.sql("""
    SELECT
        COUNT(*) AS total_rows,
        COUNT(DISTINCT order_id || '-' || product_id) AS distinct_order_product_lines
    FROM fact_orders
"""))

Qué esperar. Al correr python3 declare_grain.py (con pip install duckdb ya hecho), la salida es exactamente esta:

Total filas reconstruidas en fact_orders: 40

=== Declarando el grano de fact_orders ===
┌────────────┬──────────────────────────────┐
│ total_rows │ distinct_order_product_lines │
│   int64    │            int64             │
├────────────┼──────────────────────────────┤
│         40 │                           40 │
└────────────┴──────────────────────────────┘

Cuarenta filas totales, cuarenta combinaciones distintas de order_id-product_id. Los dos números coinciden exactamente, y esa coincidencia es la evidencia — no la palabra de nadie, ni una intuición — de que hoy, en el fact_orders de Kiosko, cada fila representa exactamente una combinación única de orden y producto. Con esa evidencia en la mano, la declaración formal del grano queda así:

El grano de fact_orders es: una fila representa un producto vendido dentro de una orden específica — una línea de orden.

Fíjate en la redacción exacta: el grano se declara como "una línea de orden" (order + product), no como "una orden" a secas. Son declaraciones distintas, y aunque hoy dan el mismo número —porque cada orden de Kiosko, tal como la generó foundations, contiene exactamente un producto—, no son la misma afirmación. La lección 7 de este módulo muestra, con una consulta ejecutada, exactamente por qué esa diferencia importa incluso cuando los números de hoy coinciden.

Diagrama: qué mide cada parte de la consulta

flowchart TD
    A["fact_orders: 40 filas"] --> B["COUNT(*)\ncuenta CADA FILA, sin importar su contenido"]
    A --> C["order_id || '-' || product_id\nconstruye una llave compuesta por fila"]
    C --> D["COUNT(DISTINCT ...)\ncuenta cuantas combinaciones UNICAS existen"]
    B --> E{"Los dos numeros,\nson iguales?"}
    D --> E
    E -->|"SI (40 == 40)"| F["El grano declarado (order+product)\nCOINCIDE con la realidad del dato"]
    E -->|"NO"| G["Hay filas duplicadas O el grano\nreal es distinto al declarado"]

Profundización: por qué la llave compuesta, y no solo order_id

Fíjate en un detalle deliberado de la consulta: no se usó COUNT(DISTINCT order_id) a secas — se construyó una llave compuesta, order_id || '-' || product_id, concatenando ambas columnas con || (el operador de concatenación de texto en SQL estándar, que DuckDB soporta directamente). ¿Por qué no bastaba con order_id?

Porque order_id por sí solo asume que cada orden tiene un solo producto — es decir, asume la respuesta antes de verificarla. Si fact_orders tuviera, hoy o en el futuro, una orden con dos líneas de producto distintas, COUNT(DISTINCT order_id) daría un número menor que COUNT(*) (porque un mismo order_id se repetiría en dos filas), y esa diferencia sería exactamente la señal de que el grano real es más fino que "una orden". La llave compuesta order_id || '-' || product_id es la forma correcta de verificar el grano que realmente te interesa —una línea de orden—, sin asumir de antemano que orden y línea son lo mismo. Vas a ver esta distinción demostrada con números concretos, sobre un escenario hipotético, en la lección 7.

Una nota técnica sobre el separador: el - dentro de order_id || '-' || product_id no es decorativo — evita una colisión de llaves. Sin separador, un order_id="ORD-1" con product_id="P01" produciría el mismo texto concatenado ("ORD-1P01") que un order_id="ORD-1P" con product_id="01" — dos combinaciones distintas que, sin separador, se verían idénticas. Con el - en medio, esa colisión (poco probable con el formato de Kiosko, pero real en general) desaparece.

Errores comunes

Declarar el grano como "una orden" en vez de "una línea de orden". Qué pasa: alguien, al ver que COUNT(*) y COUNT(DISTINCT order_id) dan el mismo número hoy (40 y 40), concluye que el grano es "una orden", sin usar la llave compuesta. Por qué pasa: con los datos actuales de Kiosko, ambas declaraciones producen el mismo número, así que el error no se manifiesta con esta semana específica de datos. Cómo detectarlo: si tu declaración de grano usa la palabra "orden" sin mencionar "producto" o "línea", y tu verificación solo usó order_id, no probaste la hipótesis correcta — probaste una hipótesis más fuerte de la que realmente puedes sostener con este dato. Cómo corregirlo: usa siempre la llave compuesta más fina posible que tu esquema permita verificar — order_id || '-' || product_id, como hizo esta lección —, incluso si el resultado coincide con una declaración más simple. La lección 7 muestra, con números, por qué esta distinción no es pedantería.

Confiar en COUNT(*) solo, sin ningún COUNT(DISTINCT ...) de comparación. Qué pasa: alguien corre SELECT COUNT(*) FROM fact_orders, ve 40, y declara el grano sin ninguna verificación adicional. Por qué pasa: COUNT(*) es la consulta más simple posible, y se siente como suficiente evidencia. Cómo detectarlo: COUNT(*) por sí solo no puede detectar filas duplicadas — si fact_orders tuviera, por un bug de ingestión, la misma línea de orden repetida dos veces, COUNT(*) seguiría reportando el total de filas físicas, sin ninguna señal de que hay duplicación. Cómo corregirlo: siempre compara COUNT(*) contra COUNT(DISTINCT <llave del grano>) — si son iguales, no hay duplicados sobre esa llave; si COUNT(*) es mayor, tienes duplicados exactos que la deduplicación del módulo 5 vas a aprender a resolver.

Ejecutar la consulta sobre datos parciales y generalizar el resultado. Qué pasa: alguien corre la consulta de grano sobre solo un día de la semana (por ejemplo, únicamente el lunes, 8 filas) y declara el grano confiado en ese resultado parcial. Por qué pasa: probar con menos datos es más rápido, y ocho filas parecen suficiente evidencia a simple vista. Cómo detectarlo: si tu consulta de verificación no corrió sobre la semana completa (cuarenta filas, los siete días), tu evidencia es parcial — un bug de duplicación que solo aparece el sábado (el día con más órdenes) pasaría completamente desapercibido si solo verificaste el lunes. Cómo corregirlo: la consulta de esta lección corre sobre las cuarenta filas de la semana completa, exactamente como debe ser — verificar el grano sobre un subconjunto nunca es suficiente evidencia sobre el conjunto completo.

Ejercicios

Ejercicio 1 — Verifica el grano con una tercera consulta. Usando fact_orders ya construido en DuckDB por el ejemplo trabajado, escribe una consulta adicional que confirme que no hay ninguna fila con quantity <= 0 — una verificación distinta a la del grano, pero igual de importante antes de confiar en cualquier análisis posterior.

Ver solución
print(con.sql("SELECT COUNT(*) AS filas_con_quantity_invalida FROM fact_orders WHERE quantity <= 0"))

Salida esperada:

┌──────────────────────────────┐
│ filas_con_quantity_invalida │
│            int64             │
├──────────────────────────────┤
│                             0 │
└──────────────────────────────┘

Cero filas con quantity inválida — coherente con lo que ya sabes de foundations: validate_orders() (módulo 5 de esa guía) ya garantizó esta propiedad antes de que estos datos llegaran a fact_orders. Esta consulta no declara el grano —eso ya lo hizo la consulta principal de esta lección—, pero es el tipo de verificación complementaria que un modelador dimensional serio corre antes de dar por buena cualquier tabla de hechos nueva.

Ejercicio 2 — Calcula el grano por tienda. Usando fact_orders, escribe una consulta que confirme que el grano declarado (una línea de orden) se sostiene dentro de cada tienda por separado: para cada store_id, compara COUNT(*) contra COUNT(DISTINCT order_id || '-' || product_id).

Ver solución
print(con.sql("""
    SELECT
        store_id,
        COUNT(*) AS total_rows,
        COUNT(DISTINCT order_id || '-' || product_id) AS distinct_lines
    FROM fact_orders
    GROUP BY store_id
    ORDER BY store_id
"""))

Salida esperada:

┌──────────┬─────────────┬─────────────────┐
│ store_id │ total_rows  │ distinct_lines  │
│ varchar  │    int64    │      int64      │
├──────────┼─────────────┼─────────────────┤
│ S01      │          16 │              16 │
│ S02      │          13 │              13 │
│ S03      │          11 │              11 │
└──────────┴─────────────┴─────────────────┘

Las tres tiendas muestran la misma propiedad —total_rows igual a distinct_lines—, así que el grano declarado se sostiene no solo en el agregado de la semana completa, sino tienda por tienda. Fíjate en que estos mismos números (16, 13, 11) son, exactamente, los conteos de órdenes por tienda que ya viste en el módulo 8 de foundations — otra confirmación cruzada de que este fact_orders reconstruido es idéntico al original.

Ejercicio 3 — Explica por qué 40 == 40 no prueba que no hay filas rechazadas de más. Los dos números de la consulta principal (total_rows y distinct_order_product_lines) coinciden en 40. En 2-3 frases, explica por qué esa coincidencia confirma que no hay duplicados, pero no confirma, por sí sola, que las cuarenta filas son todas las órdenes reales que Kiosko procesó esa semana.

Ver solución

La consulta de grano compara fact_orders contra sí misma —cuenta filas y combinaciones únicas dentro de la misma tabla—, así que solo puede detectar problemas internos a esa tabla, como duplicados. No puede detectar si, por ejemplo, una orden real de Kiosko nunca llegó a fact_orders por un error de extracción — esa fila simplemente no estaría ahí para contarse, y la consulta de grano no tiene forma de saber que falta. Confirmar que las cuarenta filas son efectivamente todas las órdenes reales requiere una verificación distinta —comparar contra el conteo de los archivos fuente originales, exactamente lo que validate_orders() y el assert counts == counts_again de foundations ya hicieron en su propio módulo—. Declarar el grano y verificar completitud son dos preguntas relacionadas, pero distintas.

Resumen y siguiente paso

En esta lección declaraste el grano de fact_orders con evidencia, no con intuición: reconstruiste la tabla completa —cuarenta órdenes, la semana fija de Kiosko— en DuckDB, y ejecutaste SELECT COUNT(*), COUNT(DISTINCT order_id || '-' || product_id) FROM fact_orders, confirmando que ambos números coinciden en 40. La declaración formal que resulta: una fila de fact_orders representa una línea de orden — un producto vendido dentro de una orden específica, no "una orden" a secas, aunque hoy ambas lecturas den el mismo número.

Antes de avanzar deberías poder: escribir de memoria la consulta de verificación de grano usada en esta lección; explicar por qué se usa una llave compuesta (order_id || '-' || product_id) en vez de order_id solo; y recitar la declaración formal de grano de fact_orders, palabra por palabra.

Con el grano ya declarado y verificado, la lección 6 resuelve los pasos 3 y 4 del proceso de Kimball —dimensiones y hechos— con la definición precisa que va más allá de la "primera mirada" que ya viste en foundations.

Recursos