Módulo 7: Messy Domains And Medallion At Depth

Dimensiones degeneradas: order_id, a fondo

Descripción

El módulo 1 ya nombró order_id como una dimensión degenerada, sobre la marcha, sin desarrollarla — apenas una fila en una tabla de clasificación de columnas. Esta lección cumple la promesa que ese módulo dejó pendiente: la definición completa de Kimball, el criterio preciso para decidir cuándo un identificador se queda como dimensión degenerada y cuándo necesita convertirse en una tabla real, y una demostración ejecutada de por qué construir una tabla dim_order para Kiosko hoy sería, literalmente, trabajo sin ningún beneficio.

Conexión con el módulo. Esta lección cierra, con evidencia, la definición que el módulo 1 (lección 6) dejó abierta: "esta guía nombra el concepto aquí, sobre la marcha, pero lo desarrolla a fondo —con su justificación completa y sus casos de uso— en el módulo 7". Es, también, la preparación directa para la lección 4: vas a ver, en esta lección, exactamente qué le faltaría a order_id para justificar una tabla propia — y en la siguiente, Kiosko empieza a capturar dos atributos nuevos (payment_method, channel) que si vivieran sueltos en fact_orders, plantearían la misma pregunta.

Una analogía: el número de factura, escrito en el propio renglón

Piensa en una factura de compra física, de las que todavía imprime cualquier tienda: en la parte superior tiene un número de factura —Factura N.° 4821—, y ese número aparece, otra vez, en cada renglón del detalle de la compra, junto al producto, la cantidad y el precio. Nadie archiva, en una carpeta aparte, una ficha de una sola línea que diga "la Factura 4821 existe" — el número de factura no necesita su propio archivo, porque no describe nada más allá de sí mismo: no tiene una fecha propia distinta a la de la venta, no tiene un cliente propio distinto al que aparece en la misma factura, no tiene ningún atributo adicional que valga la pena guardar en otro lugar. El número de factura vive, correctamente, escrito directamente en el mismo renglón donde se necesita — nunca en un archivo separado.

order_id es exactamente ese número de factura. No tiene fecha propia (usa order_ts, ya en la misma fila), no tiene tienda propia (usa store_id, ya en la misma fila), no tiene ningún atributo descriptivo que no esté ya capturado en otra columna de fact_orders. Por eso vive directamente dentro del hecho, sin tabla propia — la definición precisa de lo que Kimball llama dimensión degenerada.

Ejemplo trabajado: usando order_id sin ninguna tabla dim_order

Con fact_orders reconstruido, cualquier pregunta de negocio que use order_id como unidad de agrupación se responde directamente, sin ningún JOIN adicional — exactamente el comportamiento que hace que una dimensión degenerada sea, precisamente, "degenerada": actúa como dimensión (agrupa, identifica), sin necesitar su propia tabla.

# degenerate_in_practice.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])

print("=== order_id usado directamente: los 5 ordenes de mayor revenue, sin ninguna tabla dim_order ===")
print(con.sql("""
    SELECT order_id, store_id, COUNT(*) AS line_items, ROUND(SUM(revenue), 2) AS order_total
    FROM fact_orders
    GROUP BY order_id, store_id
    ORDER BY order_total DESC, order_id ASC
    LIMIT 5
"""))

Fíjate en el desempate explícito, order_id ASC después de order_total DESC: sin él, SQL no garantiza ningún orden estable entre filas con el mismo order_total —y varias órdenes de Kiosko sí empatan en 4.5—, así que el resultado podría cambiar entre una corrida y otra sobre el mismo dato. Un segundo criterio de orden explícito es la única forma de que un LIMIT sobre un empate sea reproducible byte a byte, la misma disciplina que esta guía exige en cada bloque "Qué esperar".

Qué esperar.

=== order_id usado directamente: los 5 ordenes de mayor revenue, sin ninguna tabla dim_order ===
┌──────────┬──────────┬────────────┬─────────────┐
│ order_id │ store_id │ line_items │ order_total │
│ varchar  │ varchar  │   int64    │   double    │
├──────────┼──────────┼────────────┼─────────────┤
│ ORD-3001 │ S02      │          1 │         9.0 │
│ ORD-6004 │ S01      │          1 │         9.0 │
│ ORD-1004 │ S01      │          1 │         4.5 │
│ ORD-2003 │ S03      │          1 │         4.5 │
│ ORD-4004 │ S01      │          1 │         4.5 │
└──────────┴──────────┴────────────┴─────────────┘

GROUP BY order_id funciona exactamente como agrupar por cualquier llave foránea hacia una dimensión real —store_id, product_id— sin que order_id necesite ninguna tabla propia detrás. Fíjate en line_items: las cinco órdenes de mayor revenue tienen, todas, exactamente una línea — confírmalo sobre el dominio completo:

print("\n=== Confirmando que cada orden de Kiosko tiene exactamente 1 linea (grano ya declarado en M1) ===")
print(con.sql("""
    SELECT COUNT(*) AS total_orders, MAX(line_items) AS max_lines_per_order, MIN(line_items) AS min_lines_per_order
    FROM (SELECT order_id, COUNT(*) AS line_items FROM fact_orders GROUP BY order_id)
"""))

Qué esperar.

=== Confirmando que cada orden de Kiosko tiene exactamente 1 linea (grano ya declarado en M1) ===
┌──────────────┬─────────────────────┬─────────────────────┐
│ total_orders │ max_lines_per_order │ min_lines_per_order │
│    int64     │        int64        │        int64        │
├──────────────┼─────────────────────┼─────────────────────┤
│           40 │                   1 │                   1 │
└──────────────┴─────────────────────┴─────────────────────┘

Cuarenta órdenes, todas con exactamente una línea — el mismo hecho que el módulo 1 ya verificó con COUNT(*) == COUNT(DISTINCT order_id || '-' || product_id), confirmado ahora desde un ángulo distinto: MAX(line_items) == MIN(line_items) == 1.

La alternativa mala, construida a propósito para medir su costo

Para que el argumento de esta lección no dependa solo de teoría, constrúyela: una tabla dim_order_bad, con la única columna que tendría sentido —order_id— y nada más, porque Kiosko no tiene ningún atributo adicional que agregarle.

# dim_order_bad.py -- continua sobre con y fact_orders del ejemplo trabajado
con.execute("CREATE TABLE dim_order_bad AS SELECT DISTINCT order_id FROM fact_orders")

print("=== dim_order_bad: cuantas filas y columnas tiene ===")
print(con.sql("SELECT COUNT(*) AS total_rows FROM dim_order_bad"))
print(con.sql("DESCRIBE dim_order_bad"))

print("=== Union contra dim_order_bad: mismo resultado, un JOIN de mas ===")
direct = con.sql("SELECT COUNT(*) FROM fact_orders").fetchone()[0]
joined = con.sql("SELECT COUNT(*) FROM fact_orders f JOIN dim_order_bad d ON f.order_id = d.order_id").fetchone()[0]
print(f"directo (sin JOIN): {direct} filas")
print(f"con JOIN contra dim_order_bad: {joined} filas")
print(f"identicas: {direct == joined}")

Qué esperar.

=== dim_order_bad: cuantas filas y columnas tiene ===
┌────────────┐
│ total_rows │
│   int64    │
├────────────┤
│         40 │
└────────────┘

┌─────────────┬─────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │ column_type │  null   │   key   │ default │  extra  │
│   varchar   │   varchar   │ varchar │ varchar │ varchar │ varchar │
├─────────────┼─────────────┼─────────┼─────────┼─────────┼─────────┤
│ order_id    │ VARCHAR     │ YES     │ NULL    │ NULL    │ NULL    │
└─────────────┴─────────────┴─────────┴─────────┴─────────┴─────────┘

=== Union contra dim_order_bad: mismo resultado, un JOIN de mas ===
directo (sin JOIN): 40 filas
con JOIN contra dim_order_bad: 40 filas
identicas: True

dim_order_bad: cuarenta filas, una sola columna, y esa columna es exactamente la misma llave que ya vive en fact_orders. Unir contra ella no agrega ni un solo dato nuevo —el conteo de filas antes y después del JOIN es idéntico, 40 == 40—, así que su único efecto real es agregar un salto de JOIN innecesario a cualquier consulta que la use. Esta es, en números concretos, la razón exacta por la que Kimball recomienda dejar un identificador como dimensión degenerada cuando no tiene ningún atributo propio: crear la tabla no es un error catastrófico, pero es trabajo —y complejidad de consulta— sin ningún beneficio a cambio.

Diagrama: cuándo un identificador se queda degenerado, y cuándo se convierte en tabla

┌────────────────────────────────────────────────────────────────────┐
│  ¿El identificador tiene ATRIBUTOS PROPIOS mas alla de si mismo?    │
│  (una fecha distinta, un estado, una direccion, una nota...)        │
└────────────────────────────────────────────────────────────────────┘
                    │                              │
                   NO                              SI
                    │                              │
                    v                              v
    ┌───────────────────────────────┐  ┌───────────────────────────────┐
    │  DIMENSION DEGENERADA          │  │  DIMENSION REAL, tabla propia │
    │  Vive como columna dentro del  │  │  order_id + sus atributos     │
    │  hecho. Sin tabla, sin JOIN.   │  │  propios, en su propia tabla. │
    │  Kiosko hoy: order_id          │  │  Ejemplo: si Kiosko agregara  │
    │                                │  │  "direccion de entrega" y     │
    │                                │  │  "nota del cliente" por orden │
    └───────────────────────────────┘  └───────────────────────────────┘

Profundización: qué SÍ convertiría a order_id en una dimensión real

Vale la pena ser concretos sobre el límite, porque no es una regla absoluta — es una pregunta que se vuelve a hacer cada vez que el negocio cambia. Si Kiosko, en algún punto futuro, empezara a capturar una dirección de entrega por orden (para el canal de delivery), o una nota del cliente ("sin bolsa, por favor"), esos dos atributos sí describirían algo sobre la orden que ninguna otra columna de fact_orders ya captura — y en ese momento, order_id dejaría de ser degenerada: se convertiría en la llave primaria de una tabla dim_order real, con delivery_address y customer_note como sus columnas propias.

Esta pregunta —¿tiene atributos propios, más allá de sí mismo?— es exactamente la misma que vas a usar en la lección 4 para decidir qué hacer con payment_method y channel, los dos atributos nuevos que Kiosko empieza a capturar por orden. La diferencia clave, que la lección 4 desarrolla: payment_method y channel sí describen algo nuevo sobre cada orden —cómo se pagó, por qué canal se hizo—, así que no pueden quedarse "degenerados" como texto libre sin ningún tratamiento. Pero tampoco necesitan, cada uno, su propia tabla de dimensión completa —eso sería sobre-normalizar dos atributos de baja cardinalidad—. La solución intermedia, que ninguna lección de esta guía usó todavía, es la dimensión junk: agrupar varios atributos de baja cardinalidad en una sola tabla pequeña, con un solo flag_key.

Errores comunes

Crear una tabla dim_order "por si acaso" se necesita en el futuro. Qué pasa: alguien, anticipando que Kiosko podría necesitar atributos de orden más adelante, crea dim_order hoy, con solo order_id como columna, para "estar preparado". Por qué pasa: parece una decisión prudente, del tipo "mejor tenerlo por si acaso". Cómo detectarlo: si tu dim_order tiene una sola columna y ningún consumidor real la necesita hoy, estás pagando el costo de un JOIN extra en cada consulta a cambio de una flexibilidad que no existe todavía — exactamente lo que esta lección midió con dim_order_bad. Cómo corregirlo: agrega la tabla cuando el atributo real aparezca, no antes — dim_order con una sola columna no es más flexible que order_id viviendo dentro de fact_orders; es exactamente lo mismo, con un JOIN de más.

Confundir "dimensión degenerada" con "columna que no importa". Qué pasa: alguien, al escuchar "degenerada" —una palabra con connotación negativa en el lenguaje cotidiano—, asume que order_id es una columna de segunda categoría, menos importante que store_id o product_id. Por qué pasa: el nombre técnico suena a defecto, cuando en realidad describe una forma válida y deliberada de modelar. Cómo detectarlo: si tratas order_id como opcional o descartable en algún análisis, perdiste de vista que es, junto con product_id, la llave que define el grano mismo de fact_orders —sin ella, no podrías distinguir una línea de orden de otra—. Cómo corregirlo: "degenerada" es un término técnico de Kimball sin ninguna connotación de calidad — describe únicamente que el identificador no tiene tabla propia, no que sea menos importante que cualquier otra columna del hecho.

Intentar historizar order_id con SCD, como si fuera una dimensión con atributos que cambian. Qué pasa: alguien, después del módulo 4 (SCD), se pregunta si order_id debería tener valid_from/valid_to como dim_product_scd, asumiendo que toda dimensión eventualmente necesita historizarse. Por qué pasa: SCD se aplicó, en esta guía, a la única dimensión con atributos que cambian con el tiempo — es fácil generalizar esa técnica a cualquier cosa etiquetada como "dimensión". Cómo detectarlo: si te preguntas qué pasaría si order_id "cambiara de valor", la pregunta misma no tiene sentido de negocio — una orden no cambia de identidad, se crea una vez y no se modifica. Cómo corregirlo: SCD historiza atributos que describen algo que puede cambiar (el precio de un producto, su categoría) — order_id no describe nada, es el identificador mismo de la fila del hecho. No hay nada que historizar en una dimensión degenerada, porque no tiene ningún atributo más allá de su propia identidad.

Ejercicios

Ejercicio 1 — Calcula cuántas órdenes distintas tuvo cada tienda, usando solo order_id. Sin ninguna tabla dim_order, escribe una consulta que cuente COUNT(DISTINCT order_id) por store_id, y confirma que da el mismo resultado que ya conoces desde el módulo 1 (S01: 16, S02: 13, S03: 11).

Ver solución
print(con.sql("""
    SELECT store_id, COUNT(DISTINCT order_id) AS distinct_orders
    FROM fact_orders
    GROUP BY store_id
    ORDER BY store_id
"""))

Salida esperada:

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

Los mismos números del módulo 1 —16, 13, 11—, esta vez calculados con COUNT(DISTINCT order_id) en vez de COUNT(*), porque en el dominio actual de Kiosko ambas cuentas coinciden (cada orden tiene una sola línea). Ninguna tabla dim_order fue necesaria para responder esta pregunta.

Ejercicio 2 — Elimina dim_order_bad y confirma que ninguna consulta anterior deja de funcionar. Ejecuta DROP TABLE dim_order_bad, y vuelve a correr la consulta de la Parte 1 de esta lección (los 5 pedidos de mayor revenue). Confirma que el resultado es idéntico.

Ver solución
con.execute("DROP TABLE dim_order_bad")
print(con.sql("""
    SELECT order_id, store_id, COUNT(*) AS line_items, ROUND(SUM(revenue), 2) AS order_total
    FROM fact_orders
    GROUP BY order_id, store_id
    ORDER BY order_total DESC, order_id ASC
    LIMIT 5
"""))

Salida esperada: exactamente la misma tabla de 5 filas del ejemplo trabajado (ORD-3001, ORD-6004, ORD-1004, ORD-2003, ORD-4004) — porque esa consulta nunca dependió de dim_order_bad en primer lugar. Esta es, quizás, la confirmación más directa de todo el argumento de esta lección: una tabla que se puede borrar sin que ninguna consulta real deje de funcionar nunca debió haberse creado.

Ejercicio 3 — Explica, de memoria, qué convertiría a store_id en una dimensión degenerada (hipotéticamente). store_id hoy es una llave foránea real hacia dim_store, con atributos propios (store_name, city). En 2-3 frases, describe qué tendría que ser cierto sobre dim_store para que, en cambio, tuviera sentido tratar store_id como una dimensión degenerada dentro de fact_orders.

Ver solución

store_id dejaría de justificar una tabla propia si dim_store no tuviera ningún atributo descriptivo más allá del identificador mismo — si, por ejemplo, Kiosko nunca necesitara saber el nombre ni la ciudad de una tienda, y store_id solo sirviera para agrupar ventas por sucursal sin ningún contexto adicional. En ese escenario hipotético, mantener dim_store como tabla aparte tendría el mismo problema que dim_order_bad en esta lección: una tabla de una sola columna, que no agrega ninguna información que fact_orders no tuviera ya. En el Kiosko real de esta guía, dim_store sí tiene atributos propios (store_name, city) desde el módulo 1, así que la pregunta es puramente hipotética — pero es exactamente el mismo criterio que decide, en cada caso real, si algo debe ser dimensión degenerada o dimensión con tabla propia.

Resumen y siguiente paso

Esta lección desarrolló a fondo la dimensión degenerada que el módulo 1 nombró de pasada: order_id vive dentro de fact_orders, sin tabla propia, porque no tiene ningún atributo descriptivo más allá de sí mismo — ni fecha propia, ni tienda propia, ni ningún dato que otra columna del hecho no capture ya. Construiste, con evidencia, la alternativa mala —dim_order_bad, cuarenta filas, una sola columna, cero información nueva— y confirmaste que unirse contra ella produce exactamente el mismo resultado que consultar fact_orders directamente, con un JOIN de más y ningún beneficio a cambio.

Antes de avanzar deberías poder: recitar la definición de Kimball de dimensión degenerada; explicar el criterio exacto —¿tiene atributos propios más allá de sí mismo?— que decide si un identificador necesita tabla propia; y anticipar por qué payment_method/channel (la lección 4) no pueden tratarse igual que order_id, aunque ambos empiecen como atributos de baja cardinalidad de una orden.

La lección 4 introduce el segundo tipo de dimensión que este módulo formaliza: cuando Kiosko empieza a capturar payment_method y channel por orden, ¿los guarda como dos columnas sueltas dentro de fact_orders, o los agrupa en una dimensión junk pequeña, con un solo flag_key? Vas a construir dim_order_flags de verdad, y a medir la diferencia.

Recursos