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 (Company → CompanyAddress) 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()). |
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 NULLen todo SELECT (incluidas relaciones). - Marca como borrado en vez de eliminar físicamente.
Para incluir borrados lógicos en una query puntual (= withTrashed de Laravel):
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.