Entrega Incremental 5 - SQLAlchemy ORM y Alembic
REQUIERE E4 (Persistencia Archivos) completada. Se migrará de JSON a SQL manteniendo el mismo interfaz Repository. Los endpoints NO deben cambiar.
Estas clases preparan los conocimientos necesarios para esta evaluación:
Migrar la persistencia a base de datos relacional con SQLAlchemy ORM y migraciones Alembic. Mantener la misma interfaz Repository para que los endpoints sean idénticos.
Individual o en parejas (máximo 2 personas). Debe ser el mismo equipo de E1-E4.
Formato de entrega: Repositorio GitHub con: modelos SQLAlchemy, migraciones Alembic en carpeta alembic/, base de datos SQLite o script para crearla. README.md con comando alembic upgrade head.
Descripción: Definir modelos ORM que mapean a tablas de base de datos.
UsuarioModel(Base): Tabla usuariosProyectoModel(Base): Tabla proyectos con FK a usuariosTareaModel(Base): Tabla tareas con FK a proyectosinfrastructure/models.pydomain/ permanecen igualDescripción: Configurar relaciones entre tablas correctamente.
relationship("ProyectoModel", back_populates="lider")relationship("TareaModel", back_populates="proyecto", cascade="all, delete-orphan")Descripción: Nueva implementación del Repository usando SQL en lugar de JSON.
UsuarioRepositorySQL(IUsuarioRepository)ProyectoRepositorySQL(IProyectoRepository)TareaRepositorySQL(ITareaRepository)Session de SQLAlchemy vía Depends()session.add(), session.commit(), session.rollback()_to_domain(model): Convierte Modelo ORM a clase de dominio_to_model(entity): Convierte clase de dominio a Modelo ORMDescripción: Versionar la base de datos con migraciones automáticas.
alembic init alembic: Inicializar Alembicalembic revision --autogenerate -m "Initial tables": Crear migraciónalembic upgrade head: Aplicar migracionesalembic.ini: Configuración (URL de base de datos)alembic/env.py: Importar modelos, setear target_metadataalembic/versions/*.py: Migraciones generadasBase.metadata.create_all(). Obligatorio usar migraciones Alembic.
from datetime import datetime
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from src.domain.enums import PrioridadTarea, EstadoTarea
class Base(DeclarativeBase):
"""Base para todos los modelos."""
pass
class UsuarioModel(Base):
"""Modelo ORM para usuarios."""
__tablename__ = "usuarios"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
nombre_completo: Mapped[str | None] = mapped_column(String(100))
activo: Mapped[bool] = mapped_column(default=True)
fecha_registro: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relación con proyectos donde es líder
proyectos: Mapped[list["ProyectoModel"]] = relationship(
"ProyectoModel", back_populates="lider"
)
class ProyectoModel(Base):
"""Modelo ORM para proyectos."""
__tablename__ = "proyectos"
id: Mapped[int] = mapped_column(primary_key=True)
nombre: Mapped[str] = mapped_column(String(100), nullable=False)
descripcion: Mapped[str | None] = mapped_column(String(500))
fecha_creacion: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Foreign Key al líder
lider_id: Mapped[int] = mapped_column(ForeignKey("usuarios.id"))
lider: Mapped["UsuarioModel"] = relationship("UsuarioModel", back_populates="proyectos")
# Relación con tareas (cascade delete)
tareas: Mapped[list["TareaModel"]] = relationship(
"TareaModel", back_populates="proyecto", cascade="all, delete-orphan"
)
class TareaModel(Base):
"""Modelo ORM para tareas."""
__tablename__ = "tareas"
id: Mapped[int] = mapped_column(primary_key=True)
titulo: Mapped[str] = mapped_column(String(100), nullable=False)
descripcion: Mapped[str | None] = mapped_column(String(500))
prioridad: Mapped[PrioridadTarea] = mapped_column(
SQLEnum(PrioridadTarea), default=PrioridadTarea.MEDIA
)
estado: Mapped[EstadoTarea] = mapped_column(
SQLEnum(EstadoTarea), default=EstadoTarea.PENDIENTE
)
fecha_creacion: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
fecha_completado: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Foreign Key al proyecto padre
proyecto_id: Mapped[int] = mapped_column(ForeignKey("proyectos.id"))
proyecto: Mapped["ProyectoModel"] = relationship("ProyectoModel", back_populates="tareas")
from typing import Optional, List
from sqlalchemy.orm import Session
from src.domain.usuario import Usuario
from src.infrastructure.models import UsuarioModel
from src.infrastructure.repository import IUsuarioRepository
class UsuarioRepositorySQL(IUsuarioRepository):
"""Implementación SQL del repositorio de usuarios."""
def __init__(self, session: Session):
self.session = session
def _to_domain(self, model: UsuarioModel) -> Usuario:
"""Convierte modelo ORM a dominio."""
return Usuario(
username=model.username,
email=model.email,
nombre_completo=model.nombre_completo
)
def _to_model(self, usuario: Usuario, usuario_id: int = None) -> UsuarioModel:
"""Convierte dominio a modelo ORM."""
model = UsuarioModel(
username=usuario.username,
email=usuario.email,
nombre_completo=usuario.nombre_completo,
activo=usuario.activo,
fecha_registro=usuario.fecha_registro
)
if usuario_id:
model.id = usuario_id
return model
def guardar(self, usuario: Usuario, usuario_id: int = None) -> int:
"""Guarda un usuario. Retorna el ID."""
try:
model = self._to_model(usuario, usuario_id)
self.session.add(model)
self.session.commit()
return model.id
except Exception as e:
self.session.rollback()
raise
def obtener(self, usuario_id: int) -> Optional[Usuario]:
"""Obtiene un usuario por ID."""
model = self.session.query(UsuarioModel).filter_by(id=usuario_id).first()
return self._to_domain(model) if model else None
def listar(self) -> List[Usuario]:
"""Lista todos los usuarios."""
models = self.session.query(UsuarioModel).all()
return [self._to_domain(m) for m in models]
def eliminar(self, usuario_id: int) -> bool:
"""Elimina un usuario."""
model = self.session.query(UsuarioModel).filter_by(id=usuario_id).first()
if model:
self.session.delete(model)
self.session.commit()
return True
return False
engine, SessionLocal, get_db() en database.py
UsuarioModel, ProyectoModel, TareaModel con relaciones
alembic init, editar env.py, crear migración inicial
Mismos métodos que JSON pero con Session
Cambiar Depends(get_json_repo) a Depends(get_sql_repo)
alembic upgrade head, verificar que API funciona igual
| Criterio | Excelente (5.0) | Aceptable (3.5) | Insuficiente | Peso |
|---|---|---|---|---|
| ORM y Modelos | Modelos bien definidos, relaciones correctas, tipos apropiados | Modelos básicos, relaciones parciales | Sin ORM o SQL crudo en strings | 30% |
| Migraciones Alembic | alembic upgrade head funciona, migraciones limpias | Migración funciona pero con warnings | Sin Alembic o create_all() | 25% |
| Repository SQL | Implementa interfaz, conversión ORM↔Dominio correcta | Repository funcional sin separación dominio | Lógica SQL mezclada en endpoints | 25% |
| Integración | API idéntica, solo cambió Depends(), tests pasan | Cambios menores en endpoints | API rota o reescrita | 20% |
Con la base de datos SQL funcionando, estarás listo para la E6: Sustentación Final donde presentarás el proyecto completo.