Módulo 4: SQLAlchemy ORM
Proyecto del Módulo: blog en SQLAlchemy + Repository pattern
Descripción
Es hora de consolidar todo: vas a estructurar el blog como una aplicación Python real con modelos, repositorios (capa de acceso a datos), y tests. El Repository pattern encapsula las queries de cada entidad detrás de funciones con nombres semánticos — más legible, testeable, y reutilizable.
Al final del módulo tendrás app/ con todo el código Python necesario para operar el blog desde Python, listo para integrarse con FastAPI en el módulo 6.
Estructura final del proyecto
postgresql-sqlalchemy-blog/
├── README.md
├── docker-compose.yml
├── pyproject.toml
├── .env
├── app/
│ ├── __init__.py
│ ├── database.py ← Engine, Session, Base
│ ├── models.py ← User, Post, Comment, etc.
│ └── repositories/
│ ├── __init__.py
│ ├── users.py
│ ├── posts.py
│ ├── comments.py
│ └── tags.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_users.py
│ └── test_posts.py
└── db/
└── ... (scripts SQL, sin cambios)
Repository Pattern: ¿qué es?
Un repository es una capa que encapsula el acceso a datos de una entidad. Cada operación común (crear, buscar por ID, listar publicados, etc.) es una función con nombre semántico.
Antes (queries inline):
# En tu API endpoint
@app.get("/users/{username}")
async def get_user(username: str, db: Session):
user = db.execute(
select(User)
.options(selectinload(User.posts))
.where(User.username == username, User.deleted_at.is_(None))
).scalar_one_or_none()
return user
Después (con repository):
# En tu API endpoint
@app.get("/users/{username}")
async def get_user(username: str, db: Session):
return users_repo.get_by_username_with_posts(db, username)
Ventajas:
- Reutilizable — la misma función desde cualquier endpoint
- Testeable — mockeas el repository entero
- Cambios en una sola capa — agregar un filtro
deleted_atse hace una vez - Separación de concerns — endpoints son delgados, lógica de DB en repos
Crear app/repositories/users.py
# app/repositories/users.py
"""Repository de usuarios."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import Comment, Post, User
def create(
session: Session,
*,
email: str,
username: str,
password_hash: str,
full_name: str | None = None,
bio: str | None = None,
) -> User:
"""Crea un nuevo usuario."""
user = User(
email=email,
username=username,
password_hash=password_hash,
full_name=full_name,
bio=bio,
)
session.add(user)
session.flush() # para que tenga id antes de retornar
return user
def get_by_id(session: Session, user_id: UUID) -> User | None:
"""Obtiene un usuario por ID. None si no existe o está soft-deleted."""
return session.execute(
select(User).where(
User.id == user_id,
User.deleted_at.is_(None),
)
).scalar_one_or_none()
def get_by_username(session: Session, username: str) -> User | None:
"""Obtiene un usuario por username."""
return session.execute(
select(User).where(
User.username == username,
User.deleted_at.is_(None),
)
).scalar_one_or_none()
def get_by_email(session: Session, email: str) -> User | None:
"""Login: busca por email (case-insensitive). Usa idx_users_email_lower."""
from sqlalchemy import func
return session.execute(
select(User).where(
func.lower(User.email) == email.lower(),
User.deleted_at.is_(None),
)
).scalar_one_or_none()
def get_by_username_with_posts(session: Session, username: str) -> User | None:
"""Usuario + sus posts publicados (eager)."""
return session.execute(
select(User)
.options(
selectinload(User.posts.and_(Post.published == True))
)
.where(
User.username == username,
User.deleted_at.is_(None),
)
).scalar_one_or_none()
def list_active(
session: Session,
*,
limit: int = 50,
offset: int = 0,
) -> list[User]:
"""Lista de usuarios activos paginada."""
return list(session.execute(
select(User)
.where(User.deleted_at.is_(None), User.is_active == True)
.order_by(User.created_at.desc())
.limit(limit)
.offset(offset)
).scalars().all())
def soft_delete(session: Session, user_id: UUID) -> bool:
"""
Soft delete del usuario + sus posts (despublica) + sus comments (marcar deleted).
Retorna True si se aplicó, False si el usuario no existe.
"""
from sqlalchemy import update
from sqlalchemy.sql import func as sql_func
user = session.get(User, user_id)
if not user or user.deleted_at is not None:
return False
now = sql_func.now()
user.deleted_at = now
user.updated_at = now
# Despublicar sus posts
session.execute(
update(Post)
.where(Post.author_id == user_id)
.values(published=False, updated_at=now)
)
# Marcar sus comments como deleted
session.execute(
update(Comment)
.where(Comment.author_id == user_id)
.values(is_deleted=True, updated_at=now)
)
return True
Crear app/repositories/posts.py
# app/repositories/posts.py
"""Repository de posts."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from app.models import Category, Post, Tag, User, post_tags_table
def create(
session: Session,
*,
author_id: UUID,
title: str,
slug: str,
content: str,
excerpt: str | None = None,
category_id: UUID | None = None,
tag_slugs: list[str] | None = None,
publish: bool = False,
) -> Post:
"""
Crea un post. Si publish=True, marca published_at = NOW().
Si tag_slugs se provee, asocia los tags existentes.
"""
post = Post(
author_id=author_id,
category_id=category_id,
title=title,
slug=slug,
content=content,
excerpt=excerpt,
published=publish,
published_at=datetime.now(timezone.utc) if publish else None,
)
if tag_slugs:
tags = session.execute(
select(Tag).where(Tag.slug.in_(tag_slugs))
).scalars().all()
post.tags = list(tags)
session.add(post)
session.flush()
return post
def get_by_slug(session: Session, slug: str) -> Post | None:
"""Obtiene un post por slug, con autor, category, tags y comments cargados."""
return session.execute(
select(Post)
.options(
joinedload(Post.author),
joinedload(Post.category),
selectinload(Post.tags),
selectinload(Post.comments).joinedload(Comment.author),
)
.where(Post.slug == slug)
).scalar_one_or_none()
def list_published(
session: Session,
*,
limit: int = 10,
offset: int = 0,
category_slug: str | None = None,
tag_slug: str | None = None,
) -> list[Post]:
"""
Feed paginado de posts publicados, opcionalmente filtrado por category o tag.
Optimizado: máximo 4 queries para el feed.
"""
stmt = (
select(Post)
.options(
joinedload(Post.author),
joinedload(Post.category),
selectinload(Post.tags),
)
.where(Post.published == True)
.order_by(Post.published_at.desc())
.limit(limit)
.offset(offset)
)
if category_slug:
stmt = stmt.join(Post.category).where(Category.slug == category_slug)
if tag_slug:
stmt = stmt.join(Post.tags).where(Tag.slug == tag_slug)
return list(session.execute(stmt).scalars().unique().all())
def search(session: Session, query: str, *, limit: int = 20) -> list[Post]:
"""Búsqueda case-insensitive en título y content."""
pattern = f"%{query}%"
return list(session.execute(
select(Post)
.options(joinedload(Post.author))
.where(
Post.published == True,
Post.title.ilike(pattern) | Post.content.ilike(pattern),
)
.order_by(Post.published_at.desc())
.limit(limit)
).scalars().all())
def list_related(session: Session, post_id: UUID, *, limit: int = 5) -> list[Post]:
"""Posts con tags compartidos al post dado."""
target_tag_ids = (
select(post_tags_table.c.tag_id)
.where(post_tags_table.c.post_id == post_id)
)
stmt = (
select(Post, func.count(post_tags_table.c.tag_id).label("shared"))
.join(post_tags_table, Post.id == post_tags_table.c.post_id)
.where(
post_tags_table.c.tag_id.in_(target_tag_ids),
Post.id != post_id,
Post.published == True,
)
.group_by(Post.id)
.order_by(func.count(post_tags_table.c.tag_id).desc())
.limit(limit)
)
return [post for post, _ in session.execute(stmt).all()]
def update(
session: Session,
post_id: UUID,
*,
title: str | None = None,
content: str | None = None,
excerpt: str | None = None,
category_id: UUID | None = None,
tag_slugs: list[str] | None = None,
publish: bool | None = None,
) -> Post | None:
"""Actualiza un post. Solo modifica los campos pasados."""
post = session.get(Post, post_id)
if not post:
return None
if title is not None:
post.title = title
if content is not None:
post.content = content
if excerpt is not None:
post.excerpt = excerpt
if category_id is not None:
post.category_id = category_id
if tag_slugs is not None:
# Reemplazar todos los tags
new_tags = session.execute(select(Tag).where(Tag.slug.in_(tag_slugs))).scalars().all()
post.tags = list(new_tags)
if publish is not None:
post.published = publish
if publish and post.published_at is None:
post.published_at = datetime.now(timezone.utc)
return post
def delete(session: Session, post_id: UUID) -> bool:
"""Borra un post (físico, con cascade a comments y post_tags)."""
post = session.get(Post, post_id)
if not post:
return False
session.delete(post)
return True
def stats_by_author(session: Session) -> list[tuple[str, int, int]]:
"""Retorna lista de (username, num_posts_publicados, num_comments_recibidos)."""
from app.models import Comment
stmt = (
select(
User.username,
func.count(Post.id.distinct()).label("posts"),
func.count(Comment.id.distinct()).label("comments_received"),
)
.join(User.posts)
.outerjoin(Comment, Comment.post_id == Post.id)
.where(Post.published == True)
.group_by(User.id, User.username)
.order_by(func.count(Post.id.distinct()).desc())
)
return [(row.username, row.posts, row.comments_received) for row in session.execute(stmt)]
Nota: importa
Commentadentro de la función para evitar circular imports en algunos casos.
app/repositories/__init__.py: re-exports
# app/repositories/__init__.py
"""Capa de acceso a datos del blog."""
from app.repositories import users, posts, comments, tags
__all__ = ["users", "posts", "comments", "tags"]
Uso desde otras partes:
from app import repositories
with db_session() as session:
user = repositories.users.get_by_username(session, "maria")
feed = repositories.posts.list_published(session, limit=10)
app/repositories/comments.py y tags.py
(Implementaciones similares siguiendo el mismo patrón. Incluyo solo los más relevantes.)
# app/repositories/comments.py
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.models import Comment, Post
def create(
session: Session,
*,
post_id: UUID,
author_id: UUID,
content: str,
parent_comment_id: UUID | None = None,
) -> Comment:
"""Crea un comment, opcionalmente como reply de otro."""
comment = Comment(
post_id=post_id,
author_id=author_id,
content=content,
parent_comment_id=parent_comment_id,
)
session.add(comment)
session.flush()
return comment
def list_by_post(session: Session, post_id: UUID) -> list[Comment]:
"""Comments de un post (con autor cargado), excluyendo soft-deleted."""
return list(session.execute(
select(Comment)
.options(joinedload(Comment.author))
.where(
Comment.post_id == post_id,
Comment.is_deleted == False,
)
.order_by(Comment.created_at)
).scalars().all())
def soft_delete(session: Session, comment_id: UUID) -> bool:
"""Marca un comment como is_deleted=True."""
comment = session.get(Comment, comment_id)
if not comment:
return False
comment.is_deleted = True
return True
# app/repositories/tags.py
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import Tag, post_tags_table
def list_all_with_usage(session: Session) -> list[tuple[Tag, int]]:
"""Lista de (tag, num_uses) ordenado por uso descendente."""
stmt = (
select(Tag, func.count(post_tags_table.c.post_id).label("usage"))
.outerjoin(post_tags_table, Tag.id == post_tags_table.c.tag_id)
.group_by(Tag.id)
.order_by(func.count(post_tags_table.c.post_id).desc())
)
return [(row.Tag, row.usage) for row in session.execute(stmt)]
def get_by_slug(session: Session, slug: str) -> Tag | None:
return session.execute(select(Tag).where(Tag.slug == slug)).scalar_one_or_none()
Tests con pytest
Crea tests/conftest.py con fixtures reutilizables:
# tests/conftest.py
"""Fixtures de pytest para tests del blog."""
from collections.abc import Generator
import pytest
from sqlalchemy.orm import Session
from app.database import SessionLocal
@pytest.fixture
def db() -> Generator[Session, None, None]:
"""Session aislada en transacción que rollbackea al final."""
session = SessionLocal()
try:
yield session
finally:
session.rollback()
session.close()
Test de un repository:
# tests/test_users.py
"""Tests de users repository."""
from app import repositories
from app.repositories import users as users_repo
def test_get_by_username_existing(db):
user = users_repo.get_by_username(db, "maria")
assert user is not None
assert user.username == "maria"
def test_get_by_username_nonexistent(db):
user = users_repo.get_by_username(db, "no_existe")
assert user is None
def test_create_user(db):
user = users_repo.create(
db,
email="test_create@blog.local",
username="test_create",
password_hash="$2b$12$x",
)
assert user.id is not None
assert user.username == "test_create"
# rollback en fixture: la fila no persiste
def test_soft_delete_user(db):
user = users_repo.get_by_username(db, "luis")
assert user is not None
success = users_repo.soft_delete(db, user.id)
assert success
db.refresh(user)
assert user.deleted_at is not None
Ejecuta:
pytest tests/ -v
Verificación final del módulo
✅ Criterio 1: Modelos completos
python -c "from app.models import User, Post, Comment, Category, Tag; print('OK')"
✅ Criterio 2: Smoke test funciona
python -m app
Imprime versión PostgreSQL y modelos registrados.
✅ Criterio 3: Repositories funcionan
from app.database import db_session
from app import repositories
with db_session() as session:
feed = repositories.posts.list_published(session, limit=5)
for p in feed:
print(f"{p.title} — {p.author.username}")
Debe imprimir 5 posts con autores sin queries N+1.
✅ Criterio 4: Tests pasan
pytest tests/ -v
✅ Criterio 5: mypy limpio
mypy app/
Sin errores.
✅ Criterio 6: No N+1 en feed
Activa echo=True, ejecuta posts.list_published(session), verifica que ves máximo 4 queries (post + user + category + tags).
✅ Criterio 7: Composable
# Combinar repos en una operación de mayor nivel
with db_session() as session:
post = repositories.posts.create(
session,
author_id=repositories.users.get_by_username(session, "maria").id,
title="Test final M4",
slug="test-final-m4",
content="C",
tag_slugs=["postgresql", "tutorial"],
publish=True,
)
print(f"Creado: {post.title} con tags {[t.slug for t in post.tags]}")
Si todo pasa: estás listo para el Módulo 5
Has completado el Módulo 4. Tienes:
- ✅ Engine + Session + Base configurados (
app/database.py) - ✅ 5 modelos con relaciones (
app/models.py) - ✅ 4 repositorios con funciones semánticas (
app/repositories/) - ✅ Tests con pytest
- ✅ Type hints completos (mypy passes)
- ✅ Eager loading aplicado para evitar N+1
- ✅ Patrón Repository implementado
En el Módulo 5 (Alembic Migrations) vas a:
- Configurar Alembic apuntando a
Base.metadata - Generar migrations automáticas con
--autogenerate - Aplicar con
upgrade head, revertir condowngrade - Manejar migrations en equipo (branching, merging)
- Data migrations vs schema migrations
Resumen del módulo
- Cápsula 01: contexto y mapa de SQLAlchemy 2.0
- Cápsula 02: Engine, Session, DeclarativeBase
- Cápsula 03: Modelos básicos con
Mapped[]+mapped_column() - Cápsula 04: Relaciones 1:N, N:M, self-reference
- Cápsula 05: CRUD con Session (Unit of Work, identity map)
- Cápsula 06: Queries con
select()(filter, join, aggregations, CTEs) - Cápsula 07: Eager vs lazy loading, N+1 problem
- Cápsula 08: Repository pattern + tests
Recursos Adicionales
- "Repository Pattern" — Martin Fowler — Patrón original
- FastAPI + SQLAlchemy 2.0 example — Estructura típica
- "Cosmic Python — Architecture Patterns with Python" — Libro gratuito sobre Repository, Unit of Work, etc.
- pytest-postgresql — Tests con DB real
- "SQLAlchemy 2.0 in Production" — Best practices
🎉 Has completado el Módulo 4: SQLAlchemy ORM.
Siguiente módulo: Módulo 5 — Alembic Migrations.