Saltar a contenido

Modelos

Los modelos son clases SQLAlchemy 2.0 que heredan de Base. Viven en app/Models/ (compartidos por todos los módulos), un modelo por archivo, estilo Eloquent.

Definir un modelo

# app/Models/Invoice.py
from decimal import Decimal
from sqlalchemy import String, Numeric
from sqlalchemy.orm import Mapped, mapped_column

from milpa.Core.Database import Base, TimestampMixin, SoftDeleteMixin

class Invoice(TimestampMixin, SoftDeleteMixin, Base):
    __tablename__ = "invoices"

    id: Mapped[int] = mapped_column(primary_key=True)
    numero: Mapped[str] = mapped_column(String(20), unique=True)
    monto: Mapped[Decimal] = mapped_column(Numeric(12, 2))

Base (de milpa/Core/Database) es la DeclarativeBase del proyecto. Trae una naming_convention estable para índices/constraints (migraciones Alembic reproducibles).

Auto-discovery

app/Models/__init__.py importa todos los modelos de la carpeta al cargarse (pkgutil). Esto es necesario porque SQLAlchemy debe tener registrados todos los modelos para resolver las relaciones declaradas por string (CompanyCompanyAddress) sin depender del orden de imports.

Consecuencia práctica: agregar un modelo = crear su archivo. No editas el __init__. Y from app.Models.Invoice import Invoice basta para que todo el registro quede cargado.

(Contrasta con app/Dictionaries, que son constantes y no necesitan registro: se importan por submódulo. Ver Estructura.)

Mixins: timestamps y soft delete

Ambos son opt-in por modelo: solo los hereda un modelo cuya tabla tiene las columnas.

TimestampMixin

Agrega dos columnas que la BD llena (server-side, en la zona de la app):

Columna Comportamiento
created_at se setea al INSERT (func.now()).
updated_at se setea al INSERT y se refresca en cada UPDATE (= $table->timestamps()).
class Invoice(TimestampMixin, Base):
    ...

En SQLite (tests) no hay zona por sesión → func.now() cae a UTC. En prod (Postgres/MySQL) sale en hora local. Ver Base de datos.

SoftDeleteMixin

Borrado lógico (vía sqlalchemy-easy-softdelete). Agrega deleted_at y:

  • Filtra automáticamente deleted_at IS NULL en todo SELECT (incluidas relaciones).
  • Marca como borrado en vez de eliminar físicamente.
class Invoice(TimestampMixin, SoftDeleteMixin, Base):
    ...

Para incluir borrados lógicos en una query puntual (= withTrashed de Laravel):

session.execute(
    select(Invoice).execution_options(include_deleted=True)
).scalars().all()

Los catálogos sin estas columnas simplemente no heredan los mixins:

class Moneda(Base):                       # sin timestamps ni soft delete
    __tablename__ = "monedas"
    codigo: Mapped[str] = mapped_column(String(3), primary_key=True)

Relaciones

Relaciones SQLAlchemy normales. Como todos los modelos se auto-importan, puedes declararlas por string sin preocuparte del orden:

from sqlalchemy.orm import relationship, Mapped

class Invoice(Base):
    __tablename__ = "invoices"
    id: Mapped[int] = mapped_column(primary_key=True)
    items: Mapped[list["InvoiceItem"]] = relationship(back_populates="invoice")

Para leer grafos de objetos sin caer en N+1 ni en DetachedInstanceError, usa eager loading (selectinload) dentro del scope de sesión y devuelve un DTO. Ver Repositorios y transacciones.

Siguiente paso

Repositorios y transacciones.