# ORM Django Avanzado — Patrones de Producción

## Custom QuerySet y Manager con métodos de negocio

El patrón correcto es definir los métodos en `QuerySet` y luego exponerlos en
el `Manager` con `as_manager()`. Así se pueden encadenar filtros de negocio sin
romper la API del ORM.

```python
# apps/facturas/querysets.py
from django.db import models
from django.utils import timezone


class FacturaQuerySet(models.QuerySet):
    """QuerySet con métodos de negocio reutilizables para Factura."""

    def pendientes(self) -> "FacturaQuerySet":
        return self.filter(estatus="pendiente")

    def vencidas(self) -> "FacturaQuerySet":
        return self.filter(
            estatus="pendiente",
            fecha_vencimiento__lt=timezone.now().date(),
        )

    def del_cliente(self, cliente_id: int) -> "FacturaQuerySet":
        return self.filter(cliente_id=cliente_id)

    def con_totales(self) -> "FacturaQuerySet":
        """Anota totales calculados directamente en la base de datos."""
        from django.db.models import Sum, F, DecimalField
        from django.db.models.functions import Coalesce

        return self.annotate(
            total_neto=Coalesce(
                Sum(F("items__cantidad") * F("items__precio_unitario")),
                0,
                output_field=DecimalField(max_digits=12, decimal_places=2),
            )
        )

    def activas_en_rango(self, fecha_inicio, fecha_fin) -> "FacturaQuerySet":
        return self.filter(
            creado_en__date__gte=fecha_inicio,
            creado_en__date__lte=fecha_fin,
        )


class FacturaManager(models.Manager):
    def get_queryset(self) -> FacturaQuerySet:
        return FacturaQuerySet(self.model, using=self._db)

    # Delegación explícita para que el IDE autocomplete
    def pendientes(self) -> FacturaQuerySet:
        return self.get_queryset().pendientes()

    def vencidas(self) -> FacturaQuerySet:
        return self.get_queryset().vencidas()

    def con_totales(self) -> FacturaQuerySet:
        return self.get_queryset().con_totales()


# apps/facturas/models.py
class Factura(models.Model):
    objects = FacturaManager()  # Manager personalizado

    # Uso: encadenamiento de métodos de negocio
    # Factura.objects.del_cliente(42).vencidas().con_totales()
```

---

## Expresiones de base de datos: F, Value, Func, Cast, Coalesce

```python
from django.db.models import F, Value, Func, ExpressionWrapper, DecimalField
from django.db.models.functions import Coalesce, Cast, Upper, Concat, TruncMonth
from django.db import models


# F() — referencia a columna sin traer el valor a Python
# BIEN: actualización atómica sin race condition
Producto.objects.filter(id=producto_id).update(
    stock=F("stock") - cantidad_vendida,
    ventas_totales=F("ventas_totales") + cantidad_vendida,
)

# Value() — valor literal inyectado en la query
Factura.objects.update(nota_interna=Value("Migrada automáticamente"))

# ExpressionWrapper — operaciones aritméticas con tipos explícitos
subtotal_expr = ExpressionWrapper(
    F("items__cantidad") * F("items__precio_unitario"),
    output_field=DecimalField(max_digits=12, decimal_places=2),
)
facturas = Factura.objects.annotate(subtotal=subtotal_expr)

# Coalesce — primer valor no nulo (equivale a COALESCE en SQL)
from django.db.models import Sum

ventas = Cliente.objects.annotate(
    total_compras=Coalesce(
        Sum("facturas__total"),
        Value(0),
        output_field=DecimalField(max_digits=14, decimal_places=2),
    )
)

# Cast — cambio de tipo en la base de datos
from django.db.models.functions import Cast

clientes = Cliente.objects.annotate(
    codigo_numerico=Cast("codigo_externo", output_field=models.IntegerField())
)

# Func — funciones SQL arbitrarias no incluidas en Django
class MD5(Func):
    """Función MD5 personalizada para PostgreSQL."""
    function = "MD5"
    output_field = models.CharField()

productos = Producto.objects.annotate(hash_nombre=MD5("nombre"))

# Funciones de fecha incluidas en Django
from django.db.models.functions import TruncMonth, ExtractYear

ventas_por_mes = (
    Factura.objects
    .annotate(mes=TruncMonth("creado_en"))
    .values("mes")
    .annotate(total=Sum("total"))
    .order_by("mes")
)
```

---

## Window Functions para ranking y análisis

Requieren PostgreSQL o SQLite 3.25+. No compatibles con MySQL < 8.0.

```python
from django.db.models import F, Sum, Window
from django.db.models.functions import Rank, DenseRank, RowNumber, Lag, Lead


# Ranking de clientes por ventas dentro de cada región
from django.db.models import PartitionBy  # Django 4.2+

clientes_rankeados = Cliente.objects.annotate(
    ranking_ventas=Window(
        expression=Rank(),
        partition_by=[F("region")],
        order_by=F("total_ventas").desc(),
    )
).filter(ranking_ventas__lte=10)  # Top 10 por región

# Acumulado (running total)
facturas_con_acumulado = Factura.objects.annotate(
    acumulado=Window(
        expression=Sum("total"),
        partition_by=[F("cliente_id")],
        order_by=F("creado_en").asc(),
    )
)

# Lag y Lead — comparar con fila anterior/siguiente
ventas_con_delta = VentaMensual.objects.annotate(
    venta_anterior=Window(
        expression=Lag("total", offset=1),
        partition_by=[F("region_id")],
        order_by=F("periodo").asc(),
    ),
    venta_siguiente=Window(
        expression=Lead("total", offset=1),
        partition_by=[F("region_id")],
        order_by=F("periodo").asc(),
    ),
).annotate(
    variacion=F("total") - F("venta_anterior")
)
```

---

## Subqueries y OuterRef para consultas correlacionadas

```python
from django.db.models import OuterRef, Subquery, Exists


# Subquery: traer el último pago de cada factura en una sola query
ultimo_pago = (
    Pago.objects
    .filter(factura_id=OuterRef("pk"))
    .order_by("-creado_en")
    .values("monto")[:1]
)

facturas = Factura.objects.annotate(
    monto_ultimo_pago=Subquery(
        ultimo_pago,
        output_field=models.DecimalField(max_digits=12, decimal_places=2),
    )
)

# Exists: filtrar objetos que tienen al menos un relacionado con condición
# BIEN: Exists es más eficiente que __isnull con annotate para existencia
facturas_con_pagos_recientes = Factura.objects.filter(
    Exists(
        Pago.objects.filter(
            factura_id=OuterRef("pk"),
            creado_en__gte=hace_30_dias,
        )
    )
)

# MAL: N+1 disfrazado — no usar .values() de un queryset externo en bucle
# BIEN: Subquery correlacionada ejecutada una sola vez
ultimo_status = (
    HistorialEstatus.objects
    .filter(factura_id=OuterRef("pk"))
    .order_by("-creado_en")
    .values("estatus")[:1]
)
facturas = Factura.objects.annotate(estatus_actual=Subquery(ultimo_status))
```

---

## Índices avanzados

```python
from django.db import models
from django.contrib.postgres.indexes import GinIndex, GistIndex, BrinIndex
from django.contrib.postgres.search import SearchVectorField


class Articulo(models.Model):
    titulo = models.CharField(max_length=300)
    contenido = models.TextField()
    etiquetas = models.JSONField(default=list)
    # Campo precalculado para búsqueda de texto completo
    vector_busqueda = SearchVectorField(null=True, blank=True)

    creado_en = models.DateTimeField(auto_now_add=True)
    activo = models.BooleanField(default=True)
    categoria_id = models.IntegerField()

    class Meta:
        indexes = [
            # Índice compuesto — activos recientes por categoría
            models.Index(
                fields=["categoria_id", "-creado_en"],
                name="idx_articulo_cat_fecha",
            ),
            # Índice parcial — solo registros activos
            # Requiere PostgreSQL; reducción drástica de tamaño del índice
            models.Index(
                fields=["creado_en"],
                condition=models.Q(activo=True),
                name="idx_articulo_activo_fecha",
            ),
            # GIN para búsqueda full-text y JSON
            GinIndex(
                fields=["vector_busqueda"],
                name="idx_articulo_fts",
            ),
            GinIndex(
                fields=["etiquetas"],
                name="idx_articulo_etiquetas_gin",
            ),
            # GiST para rangos y tipos geoespaciales con PostGIS
            GistIndex(
                fields=["rango_fechas"],  # campo RangeField
                name="idx_articulo_rango",
            ),
        ]
```

---

## select_for_update — locks pesimistas y optimistas

```python
from django.db import transaction


# Lock pesimista: bloquea las filas seleccionadas hasta el fin de la transacción
# Útil para operaciones que no toleran lecturas sucias
@transaction.atomic
def transferir_saldo(origen_id: int, destino_id: int, monto: float) -> None:
    # NOWAIT: lanza DatabaseError en lugar de esperar si hay lock
    cuentas = (
        Cuenta.objects
        .select_for_update(nowait=True)
        .filter(id__in=[origen_id, destino_id])
        .order_by("id")  # Orden consistente evita deadlocks
    )
    origen = next(c for c in cuentas if c.id == origen_id)
    destino = next(c for c in cuentas if c.id == destino_id)

    if origen.saldo < monto:
        raise ValueError("Saldo insuficiente")

    origen.saldo -= monto
    destino.saldo += monto
    Cuenta.objects.bulk_update([origen, destino], fields=["saldo"])


# Lock optimista con campo version (sin bloqueo de fila)
class Inventario(models.Model):
    producto_id = models.IntegerField()
    cantidad = models.PositiveIntegerField()
    version = models.PositiveIntegerField(default=0)

def decrementar_inventario_optimista(producto_id: int, cantidad: int) -> bool:
    """Retorna True si actualizó correctamente, False si hubo conflicto."""
    inventario = Inventario.objects.get(producto_id=producto_id)
    filas_afectadas = Inventario.objects.filter(
        id=inventario.id,
        version=inventario.version,  # condición de versión
        cantidad__gte=cantidad,
    ).update(
        cantidad=F("cantidad") - cantidad,
        version=F("version") + 1,
    )
    return filas_afectadas == 1
```

---

## bulk_create y bulk_update — operaciones masivas

```python
from django.db import models


# bulk_create — insertar miles de registros en una sola query
def importar_productos(datos: list[dict]) -> list["Producto"]:
    objetos = [
        Producto(
            sku=d["sku"],
            nombre=d["nombre"],
            precio=d["precio"],
            stock=d["stock"],
        )
        for d in datos
    ]
    # ignore_conflicts: omite duplicados sin lanzar excepción
    # update_conflicts (Django 4.1+): upsert real
    return Producto.objects.bulk_create(
        objetos,
        batch_size=500,  # evita queries demasiado grandes
        update_conflicts=True,
        update_fields=["nombre", "precio", "stock"],
        unique_fields=["sku"],  # columna que determina el conflicto
    )


# bulk_update — actualizar campo específico en miles de registros
def aplicar_descuento_masivo(ids: list[int], porcentaje: float) -> None:
    productos = Producto.objects.filter(id__in=ids)
    for producto in productos:
        producto.precio = producto.precio * (1 - porcentaje / 100)
    # Solo actualiza los campos declarados — más eficiente que save()
    Producto.objects.bulk_update(productos, fields=["precio"], batch_size=500)


# NUNCA: save() en bucle sobre miles de objetos
# MAL:
for producto in Producto.objects.filter(categoria="electrónica"):
    producto.precio = producto.precio * 0.9
    producto.save()  # 1 UPDATE por fila — desastroso en producción

# BIEN: update() cuando el valor no depende de lógica Python compleja
Producto.objects.filter(categoria="electrónica").update(
    precio=F("precio") * 0.9
)
```
