# Ingeniería de Datos y ETL — Implementaciones Completas

## Calidad de Datos — Validación con Great Expectations

```python
# Usando Great Expectations para validación declarativa
import great_expectations as gx

def validar_pedidos(df: pd.DataFrame) -> ValidationResult:
    """Valida el DataFrame de pedidos antes de cargar a Silver."""

    context = gx.get_context()
    suite = context.add_or_update_expectation_suite("pedidos_suite")

    validator = context.get_validator(
        batch_request=gx.core.BatchRequest(datasource_name="staging"),
        expectation_suite=suite,
    )

    # Validaciones de integridad
    validator.expect_column_to_exist("pedido_id")
    validator.expect_column_values_to_not_be_null("pedido_id")
    validator.expect_column_values_to_be_unique("pedido_id")

    # Validaciones de dominio
    validator.expect_column_values_to_be_in_set(
        "estatus", {"PENDIENTE", "PAGADO", "ENVIADO", "CANCELADO"}
    )
    validator.expect_column_values_to_be_between(
        "monto_total", min_value=0.01, max_value=999_999.99
    )
    validator.expect_column_values_to_match_regex(
        "email_cliente", r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
    )

    # Validación de freshness
    validator.expect_column_max_to_be_between(
        "fecha_pedido",
        min_value=str(date.today() - timedelta(days=1)),
        max_value=str(date.today() + timedelta(days=1)),
    )

    resultado = validator.validate()

    if not resultado.success:
        fallos = [
            r for r in resultado.results if not r.success
        ]
        logger.error(
            "validacion_datos_fallida",
            total_expectativas=len(resultado.results),
            total_fallos=len(fallos),
            detalle=[str(f.expectation_config) for f in fallos[:5]],
        )
        raise ErrorCalidadDatos(
            f"{len(fallos)} validaciones fallidas en pedidos",
            {"fallos": len(fallos), "total": len(resultado.results)},
        )

    return resultado
```

---

## CDC — Change Data Capture con Debezium

```yaml
# debezium-connector.json
{
  "name": "pedidos-cdc-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres-produccion",
    "database.port": "5432",
    "database.user": "debezium_user",
    "database.password": "${file:/opt/secrets/debezium.properties:password}",
    "database.dbname": "appdb",
    "database.server.name": "appdb",
    "table.include.list": "public.pedidos,public.usuarios",
    "plugin.name": "pgoutput",
    "publication.name": "debezium_pub",
    "slot.name": "debezium_slot",
    "topic.prefix": "cdc",
    "transforms": "route",
    "transforms.route.type": "org.apache.kafka.connect.transforms.ReplaceField$Value",
    "key.converter": "io.confluent.kafka.serializers.KafkaAvroSerializer",
    "value.converter": "io.confluent.kafka.serializers.KafkaAvroSerializer"
  }
}
```

```python
# Consumidor CDC en Python
from confluent_kafka import Consumer, KafkaError
import json


def procesar_evento_cdc(evento: dict) -> None:
    """Procesa un evento CDC de Debezium."""
    operacion = evento.get("op")  # c=create, u=update, d=delete, r=snapshot

    match operacion:
        case "c":
            nuevo = evento["after"]
            silver_pedidos.upsert(transformar_pedido(nuevo))

        case "u":
            antes = evento["before"]
            despues = evento["after"]
            silver_pedidos.upsert(transformar_pedido(despues))
            auditar_cambio(antes, despues)

        case "d":
            eliminado = evento["before"]
            silver_pedidos.marcar_eliminado(eliminado["id"])

        case "r":
            silver_pedidos.upsert(transformar_pedido(evento["after"]))


consumer = Consumer({
    "bootstrap.servers": "kafka:9092",
    "group.id": "cdc-silver-processor",
    "auto.offset.reset": "earliest",
    "enable.auto.commit": False,
})

consumer.subscribe(["cdc.appdb.public.pedidos"])

while True:
    msg = consumer.poll(timeout=1.0)
    if msg is None:
        continue
    if msg.error():
        if msg.error().code() == KafkaError._PARTITION_EOF:
            continue
        raise KafkaError(msg.error())

    try:
        evento = json.loads(msg.value())
        procesar_evento_cdc(evento)
        consumer.commit(asynchronous=False)
    except Exception as exc:
        logger.error("error_procesando_cdc", error=str(exc), exc_info=True)
        enviar_dlq(msg.value(), str(exc))
        consumer.commit(asynchronous=False)
```

---

## Evolución de Esquemas — Forward/Backward Compatibility

```python
# Estrategia: columnas nuevas siempre nullable o con default
# NUNCA eliminar columnas en producción — deprecar primero

# Migración segura — backward compatible
"""
-- BIEN: columna nueva con default
ALTER TABLE silver.pedidos
  ADD COLUMN IF NOT EXISTS canal_venta TEXT DEFAULT 'WEB';

-- MAL: cambiar tipo de columna existente directamente
-- ALTER TABLE silver.pedidos ALTER COLUMN monto TYPE BIGINT;

-- BIEN: renombrar con período de transición
ALTER TABLE silver.pedidos ADD COLUMN monto_centavos BIGINT;
UPDATE silver.pedidos SET monto_centavos = (monto * 100)::BIGINT;
-- (semana después, cuando todos los consumidores usen la nueva columna)
ALTER TABLE silver.pedidos DROP COLUMN monto;
ALTER TABLE silver.pedidos RENAME COLUMN monto_centavos TO monto;
"""

# Schema registry con Avro para streaming
SCHEMA_PEDIDO_V1 = {
    "type": "record",
    "name": "Pedido",
    "fields": [
        {"name": "id",     "type": "string"},
        {"name": "monto",  "type": "double"},
        {"name": "estatus","type": "string"},
    ]
}

# V2: campo nuevo con default — backward compatible
SCHEMA_PEDIDO_V2 = {
    "type": "record",
    "name": "Pedido",
    "fields": [
        {"name": "id",          "type": "string"},
        {"name": "monto",       "type": "double"},
        {"name": "estatus",     "type": "string"},
        {"name": "canal_venta", "type": "string", "default": "WEB"},
    ]
}
```

---

## Patrones dbt — Transformaciones Declarativas

```sql
-- models/silver/pedidos_limpio.sql
{{
  config(
    materialized='incremental',
    unique_key='pedido_id',
    on_schema_change='append_new_columns',
    partition_by={
      "field": "fecha_pedido",
      "data_type": "date",
      "granularity": "day"
    }
  )
}}

WITH fuente AS (
  SELECT * FROM {{ source('bronze', 'pedidos_raw') }}
  {% if is_incremental() %}
    WHERE _ingestado_en > (SELECT MAX(_ingestado_en) FROM {{ this }})
  {% endif %}
),

limpieza AS (
  SELECT
    TRIM(pedido_id)                            AS pedido_id,
    LOWER(TRIM(email_cliente))                 AS email_cliente,
    UPPER(TRIM(estatus))                       AS estatus,
    ROUND(CAST(monto_total AS NUMERIC), 2)    AS monto_total,
    DATE(fecha_pedido)                         AS fecha_pedido,
    _ingestado_en,
    EXTRACT(YEAR FROM DATE(fecha_pedido))      AS anio,
    EXTRACT(MONTH FROM DATE(fecha_pedido))     AS mes,
    CASE WHEN monto_total < 0 THEN TRUE ELSE FALSE END AS tiene_monto_negativo,
    CASE WHEN email_cliente NOT LIKE '%@%'    THEN TRUE ELSE FALSE END AS email_invalido

  FROM fuente
  WHERE pedido_id IS NOT NULL
)

SELECT * FROM limpieza
```

```yaml
# models/silver/schema.yml
models:
  - name: pedidos_limpio
    description: "Pedidos normalizados de Bronze, deduplicados y validados"
    columns:
      - name: pedido_id
        tests:
          - not_null
          - unique
      - name: email_cliente
        tests:
          - not_null
      - name: estatus
        tests:
          - accepted_values:
              values: ["PENDIENTE", "PAGADO", "ENVIADO", "CANCELADO"]
      - name: monto_total
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= 0"
```

---

## Linaje de Datos

```python
# linaje.py — registrar transformaciones para auditoría
from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass
class NodoLinaje:
    nombre: str
    tipo: str  # "fuente", "transformacion", "destino"
    ubicacion: str


@dataclass
class EventoLinaje:
    run_id: str
    pipeline: str
    origen: NodoLinaje
    destino: NodoLinaje
    registros_entrada: int
    registros_salida: int
    registros_rechazados: int
    timestamp: datetime
    query_o_script: str | None = None


class RegistroLinaje:
    def registrar(self, evento: EventoLinaje) -> None:
        db.execute("""
            INSERT INTO meta.linaje_datos (
                run_id, pipeline, origen_nombre, origen_tipo, origen_ubicacion,
                destino_nombre, destino_tipo, destino_ubicacion,
                registros_entrada, registros_salida, registros_rechazados,
                timestamp, query_script
            ) VALUES (
                :run_id, :pipeline, :onom, :otipo, :oubic,
                :dnom, :dtipo, :dubic,
                :rent, :rsal, :rrec,
                :ts, :qs
            )
        """, {
            "run_id": evento.run_id, "pipeline": evento.pipeline,
            "onom": evento.origen.nombre, "otipo": evento.origen.tipo,
            "oubic": evento.origen.ubicacion,
            "dnom": evento.destino.nombre, "dtipo": evento.destino.tipo,
            "dubic": evento.destino.ubicacion,
            "rent": evento.registros_entrada, "rsal": evento.registros_salida,
            "rrec": evento.registros_rechazados,
            "ts": evento.timestamp.isoformat(), "qs": evento.query_o_script,
        })
```
