Antes de empezar
Vienes de la Práctica 4: el mini-repo ya tiene pyproject.toml, pytest.ini con asyncio_mode = auto, requirements/ pineados, el Makefile con PYTHONPATH := . y shared/legacy_db/ leyendo la base heredada con legacy_db_read(sql, params). Esa parte no se toca. Lo que agregas hoy es la otra mitad, la que en m-b-core convive con ella: un esquema propio en Postgres, con ORM y migraciones.
Esa convivencia es deliberada. El CLAUDE.md de m-b-core, guideline 10, dice: Don't add pandas, polars, or ORMs for compute. Queries are raw SQL via SQLAlchemy text()
. No es "los ORM están prohibidos": para leer la legacy se usa SQL crudo, porque esas tablas no las controlamos. El esquema nuevo sí, y ahí hay modelos y migraciones. Confundir las dos mitades es el error más común al llegar al repo.
Necesitas Docker corriendo. Agrega a requirements/base.txt las cuatro dependencias de hoy:
requirements/base.txt (agregar)sqlmodel==0.0.24
sqlalchemy[asyncio]==2.0.37
alembic==1.14.1
asyncpg==0.30.0
cd mesa-core-practicas
source .venv/bin/activate
pip install -r requirements/local.txt
docker info > /dev/null && echo "docker ok"
Construcción paso a paso
Paso 1 Un Postgres 16 en tu laptop
Nada de instalar Postgres en el sistema: una base por proyecto, declarada en el repo, que se levanta y se tira con un comando. Es lo que hace el docker-compose.yml de m-b-core (su CLAUDE.md arranca con docker compose up -d postgres redis).
docker-compose.ymlname: mesa-core-practicas
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: practicas
POSTGRES_PASSWORD: localdev # solo local; en prod la contraseña sale de Secret Manager
POSTGRES_DB: practicas
ports:
- "5445:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U practicas -d practicas"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
La URL de conexión no vive en el código: plantilla versionada, archivo real fuera de git (env/*.env al .gitignore, con !env/*.env.template).
env/local.env.template# Copia a env/local.env (ignorado por git) y rellena.
ENV=dev
DB_URL=postgresql+asyncpg://practicas:CAMBIAME@localhost:5445/practicas
Y los atajos en el Makefile, que ya exporta PYTHONPATH := .:
Makefile (agregar)ALEMBIC := $(VENV)/bin/alembic
db-up:
docker compose up -d postgres
db-down:
docker compose down
db-rev: # ej: make db-rev m="agrega columna x"
$(ALEMBIC) revision --autogenerate -m "$(m)"
db-up-head:
$(ALEMBIC) upgrade head
db-down-1:
$(ALEMBIC) downgrade -1
cp env/local.env.template env/local.env
# edita env/local.env y pon la contraseña del compose (localdev)
make db-up
docker compose ps
Deberías verdocker compose up -d postgres
Network mesa-core-practicas_default Created
Volume mesa-core-practicas_pgdata Created
Container mesa-core-practicas-postgres-1 Started
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
mesa-core-practicas-postgres-1 postgres:16 "docker-entrypoint.s…" postgres 9 seconds ago Up 8 seconds (healthy) 0.0.0.0:5445->5432/tcp
Las cuatro decisiones del archivo. name: fija el nombre del proyecto; sin esa línea Compose lo saca del nombre de la carpeta, y con ella todas las copias del repo comparten stack (trampa 1, peor de lo que suena). El healthcheck con pg_isready es lo que hace que (healthy) signifique algo: Postgres tarda un par de segundos en aceptar conexiones tras arrancar, y sin healthcheck tu primer alembic upgrade falla por carrera. El volumen con nombre hace que los datos sobrevivan a docker compose down y solo mueran con down -v. Y el puerto 5445:5432: el 5432 del host casi siempre está tomado; elige uno tuyo y anótalo en la URL.
Paso 2 shared/db.py: un engine, muchas sesiones
Primero, settings.py gana una variable. Sigue siendo el único módulo que lee el entorno:
shared/settings.py (agregar a Settings) # postgresql+asyncpg://user:pass@host:5445/db — nunca literal en el código
DB_URL: str = os.getenv("DB_URL", "")
shared/db.py"""Conexión y sesiones async a Postgres (nuestro esquema propio).
Calca shared/db.py de m-b-core: engine perezoso (se crea en el primer uso y
solo si hay DB_URL), sessionmaker async y un context manager `db_session()`.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from .settings import settings
_engine: AsyncEngine | None = None
_SessionLocal: async_sessionmaker[AsyncSession] | None = None
def get_engine() -> AsyncEngine:
"""Engine único del proceso. Falla claro si falta DB_URL."""
global _engine, _SessionLocal
if _engine is None:
if not settings.DB_URL:
raise ValueError("DB_URL no está configurada (env/local.env).")
_engine = create_async_engine(
settings.DB_URL,
pool_pre_ping=True,
echo=settings.ENV == "staging", # ojo: echo=True vuelca SQL con datos
)
_SessionLocal = async_sessionmaker(_engine, expire_on_commit=False, class_=AsyncSession)
return _engine
@asynccontextmanager
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Una sesión por unidad de trabajo. Quien la usa decide la transacción."""
get_engine()
assert _SessionLocal is not None
async with _SessionLocal() as session:
yield session
Comprueba que conecta. El Makefile exporta PYTHONPATH, pero no carga env/local.env: eso lo haces tú en la terminal.
set -a && . ./env/local.env && set +a
export PYTHONPATH=.
python -c "
import asyncio
from sqlalchemy import text
from shared.db import db_session
async def main():
async with db_session() as s:
print((await s.execute(text('select version()'))).scalar_one())
asyncio.run(main())"
Deberías verPostgreSQL 16.15 (Debian 16.15-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc …
Perezoso, no ansioso. El engine se crea en la primera llamada a get_engine(), no al importar el módulo. Parece un detalle y no lo es: el _ensure_engine() de m-b-core tiene el mismo diseño con un comentario explícito — so the app can start without a database (e.g. local API-only docker compose)
. Un módulo que abre conexiones al importarse rompe los tests y rompe el arranque del contenedor cuando la base todavía no está. pool_pre_ping=True descarta conexiones muertas antes de usarlas (imprescindible detrás de un pooler que corta ocioso). Y mira echo=settings.ENV == "staging": es el default de m-b-core, y es el patrón que llenó de SQL el stdout de staging — echo=True vuelca cada sentencia con sus valores, así que en un entorno con datos reales es a la vez una fuga y una factura de logging.
Paso 3 Dos tablas nuestras, declaradas con SQLModel
SQLModel es Pydantic y SQLAlchemy a la vez: la misma clase valida datos y define la tabla. LocalConfig guarda lo que la legacy no tiene (si el local acepta reservas online, cuántos días de anticipación); Aviso es el registro de avisos enviados, que en la práctica 7 será la tabla de idempotencia del worker.
shared/models.py"""Modelos SQLModel del esquema propio: la fuente de verdad de alembic."""
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime
from sqlmodel import Field, SQLModel
def _utcnow() -> datetime:
# aware, siempre; naive está prohibido (práctica 6)
return datetime.now(UTC)
class LocalConfig(SQLModel, table=True):
"""Configuración propia por restaurante (lo que el legacy no tiene)."""
__tablename__ = "local_config"
# Local.Id del legacy: la clave viene de fuera, no la genera Postgres
local_id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": False})
permite_reserva_online: bool = Field(default=True)
max_dias_anticipacion: int = Field(default=30, ge=0)
updated_at: datetime = Field(
default_factory=_utcnow, sa_column=Column(DateTime(timezone=True), nullable=False)
)
class Aviso(SQLModel, table=True):
"""Registro de avisos enviados (idempotencia del worker, práctica 7)."""
__tablename__ = "aviso"
id: int | None = Field(default=None, primary_key=True)
local_id: int = Field(index=True)
tipo: str = Field(max_length=40)
enviado_en: datetime = Field(
default_factory=_utcnow, sa_column=Column(DateTime(timezone=True), nullable=False)
)
python -c "
from sqlmodel import SQLModel
from shared import models # el import es lo que registra las tablas
print(sorted(SQLModel.metadata.tables))"
Deberías ver['aviso', 'local_config']
Tres cosas y un import. (1) local_id lleva autoincrement=False: es el Local.Id que ya existe en la legacy, no un serial que invente Postgres — por defecto SQLAlchemy crea una secuencia y tu 11 se vuelve un 1. (2) DateTime(timezone=True) con datetime.now(UTC): timestamptz. La práctica 6 se dedica entera a por qué no es opcional. (3) Field(ge=0) valida en Python, no en la base; la restricción de verdad la agregas a mano en la migración del paso 5. Y el import: SQLModel.metadata solo conoce las tablas de los módulos que alguien importó. Ese detalle es la trampa 3, y cuando muerde borra tu esquema entero.
Paso 4 alembic en modo async, y la máscara que m-b-core tiene mal
alembic trae plantillas; la que necesitas es la async, porque tu engine es asyncpg:
alembic init -t async alembic
Deberías verCreating directory '…/mesa-core-practicas/alembic' ... done
Creating directory '…/mesa-core-practicas/alembic/versions' ... done
Generating …/alembic/script.py.mako ... done
Generating …/alembic/env.py ... done
Generating …/alembic.ini ... done
Please edit configuration/connection/logging settings in '…/alembic.ini' before proceeding.
Tres cambios en alembic.ini. El más importante es dejar sqlalchemy.url vacío: la URL tiene la contraseña y este archivo va a git.
alembic.ini (los tres cambios)script_location = alembic
# el nombre del archivo es el id de revisión, y el id lo elegimos nosotros
file_template = %%(rev)s
# para que `from shared…` funcione dentro de env.py
prepend_sys_path = .
# vacío a propósito: la URL la pone env.py desde el entorno
sqlalchemy.url =
Y ahora env.py, que es el archivo que de verdad importa:
alembic/env.py"""Entorno de alembic (async). Calca alembic/env.py de m-b-core, con dos diferencias
deliberadas: la URL viene de shared.settings (no de os.getenv suelto) y la máscara
de la URL es la de SQLAlchemy (`render_as_string(hide_password=True)`), no un
`split('@')` a mano — ver la trampa 1 de la práctica.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection, make_url
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel
from alembic import context
from shared import models # noqa: F401 — registra las tablas en SQLModel.metadata
from shared.settings import settings
config = context.config
fileConfig(config.config_file_name)
# La fuente de verdad del esquema son los modelos SQLModel.
target_metadata = SQLModel.metadata
if not settings.DB_URL:
raise RuntimeError("DB_URL requerido para alembic (env/local.env)")
# Máscara correcta: SQLAlchemy sabe cuál es la contraseña.
print("[alembic] DB_URL:", make_url(settings.DB_URL).render_as_string(hide_password=True))
config.set_main_option("sqlalchemy.url", settings.DB_URL)
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
asyncio.run(run_async_migrations()) # (la plantilla trae también el modo offline)
Agrega también el import de SQLModel a la plantilla de migraciones, o cada archivo generado fallará al importarse:
alembic/script.py.mako (agregar bajo los imports)import sqlmodel # tipos AutoString de SQLModel
Deberías ver (base vacía: no hay revisión aplicada)INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
[alembic] DB_URL: postgresql+asyncpg://practicas:***@localhost:5445/practicas
La línea que acabas de escribir arregla un bug de producción. El alembic/env.py de m-b-core, línea 47, hace la máscara a mano:
m-b-core · alembic/env.py:47 (el original) print(
f"[alembic] DB_URL (masked): {DB_URL.split('@')[0]}@***:***@***"
if "@" in DB_URL
else "[alembic] DB_URL: <not set>"
)
Córrelo con una URL con la forma de la de producción y compara las dos salidas:
python -c "
from sqlalchemy.engine import make_url
DB_URL = 'postgresql+asyncpg://mesa_app:S3cr3t0-DeProd@10.20.0.7:6432/mesa'
print('m-b-core :', f\"[alembic] DB_URL (masked): {DB_URL.split('@')[0]}@***:***@***\")
print('correcto :', '[alembic] DB_URL:', make_url(DB_URL).render_as_string(hide_password=True))"
Deberías ver (y ahí está el problema)m-b-core : [alembic] DB_URL (masked): postgresql+asyncpg://mesa_app:S3cr3t0-DeProd@***:***@***
correcto : [alembic] DB_URL: postgresql+asyncpg://mesa_app:***@10.20.0.7:6432/mesa
Lee las dos líneas otra vez. split('@')[0] corta por la arroba y se queda con la primera mitad, que es justo esquema://usuario:contraseña. Los asteriscos tapan el host y el puerto: lo único que no era secreto. La palabra (masked) es lo que hace que nadie lo mire dos veces. Y esa línea corre en cada alembic upgrade head del deploy, así que la contraseña queda escrita en los logs de Cloud Build de cada release; el mismo patrón está repetido en la línea 99, para la URL del engine. make_url() parsea la URL de verdad y render_as_string(hide_password=True) reemplaza solo el componente contraseña. La lección general: cuando enmascares un secreto no cortes strings, usa el parser del formato — y verifica la máscara con un caso real antes de confiar en ella.
Paso 5 La primera migración: generar y después leer
Con la base vacía y los modelos importados, alembic compara lo que hay con lo que debería haber y escribe la diferencia:
alembic revision --autogenerate -m "local_config y aviso" --rev-id 0001_local_config_aviso
Deberías verINFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'aviso'
INFO [alembic.autogenerate.compare] Detected added index ''ix_aviso_local_id'' on '('local_id',)'
INFO [alembic.autogenerate.compare] Detected added table 'local_config'
[alembic] DB_URL: postgresql+asyncpg://practicas:***@localhost:5445/practicas
Generating …/alembic/versions/0001_local_config_aviso.py ... done
El --rev-id no es cosmético: con file_template = %%(rev)s el id es el nombre del archivo, así que las migraciones se ordenan solas y se citan por número en los PRs. Es la convención de m-b-core (0001_create_legacy_schema.py, 0002_legacy_replica_state_and_grants.py…). Sin él te queda a2f7902804d6.py y nadie sabe qué hay dentro.
Ahora abre el archivo. Esto es lo que alembic escribió, tal cual, y el comentario # please adjust! va en serio:
alembic/versions/0001_local_config_aviso.py (crudo, recién generado)def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('aviso', …)
op.create_index(op.f('ix_aviso_local_id'), 'aviso', ['local_id'], unique=False)
op.create_table('local_config',
sa.Column('local_id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('permite_reserva_online', sa.Boolean(), nullable=False),
sa.Column('max_dias_anticipacion', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('local_id')
)
# ### end Alembic commands ###
Está casi bien: recogió el autoincrement=False y el timezone=True porque los declaraste en el modelo. Falta lo que no puede saber: Field(ge=0) es una validación de Pydantic y vive en Python, así que la base aceptaría -5 sin chistar. Agrégalo a mano, con el comentario que explica por qué:
alembic/versions/0001_local_config_aviso.py (revisado a mano) op.create_table('local_config',
# Revisado a mano: local_id es el Id del legacy, no un serial de Postgres
sa.Column('local_id', sa.Integer(), autoincrement=False, nullable=False),
…
sa.PrimaryKeyConstraint('local_id'),
# Revisado a mano: `Field(ge=0)` valida en Python, no en la BD. La restricción
# que importa vive donde viven los datos.
sa.CheckConstraint('max_dias_anticipacion >= 0', name='ck_local_config_max_dias'),
)
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas -c '\d local_config'
Deberías verINFO [alembic.runtime.migration] Running upgrade -> 0001_local_config_aviso, local_config y aviso
Table "public.local_config"
Column | Type | Nullable
------------------------+--------------------------+----------
local_id | integer | not null
permite_reserva_online | boolean | not null
max_dias_anticipacion | integer | not null
updated_at | timestamp with time zone | not null
Indexes:
"local_config_pkey" PRIMARY KEY, btree (local_id)
Check constraints:
"ck_local_config_max_dias" CHECK (max_dias_anticipacion >= 0)
--autogenerate es un borrador, no un resultado. Compara estructura: tablas, columnas, tipos, índices. No ve reglas de negocio, ni CHECK que quieras poner tú, ni datos que haya que migrar, ni el orden en que conviene hacer las cosas para no bloquear una tabla grande. Frente a Laravel la diferencia es esta: make:migration te da un archivo vacío para que escribas el DDL; --autogenerate te da el DDL escrito para que lo leas. Ninguno de los dos te libera de leerlo. Y en m-b-core la migración es lo único que corre antes del tráfico en cada deploy: un op.drop_column que se coló en un autogenerate no es un bug, es un incidente.
Paso 6 Segunda migración: agregar una columna sin perder nada (y qué sí se pierde al bajar)
Antes de tocar nada, mete una fila para tener algo que perder. Esto es la parte del ejercicio que no se puede saltar:
docker compose exec -T postgres psql -U practicas -d practicas \
-c "INSERT INTO local_config (local_id, permite_reserva_online, max_dias_anticipacion, updated_at)
VALUES (11, true, 30, now());"
Ahora agrega el campo al modelo. Es una preparación de la práctica 6: un local puede tener una zona horaria propia que no coincida con la de su Ubigeo.
shared/models.py (dentro de LocalConfig) # Nullable: los locales existentes no la tienen y no queremos inventar un valor.
zona_horaria_override: str | None = Field(default=None, max_length=64)
alembic revision --autogenerate -m "local_config.zona_horaria_override" --rev-id 0002_zona_horaria_override
Deberías verINFO [alembic.ddl.postgresql] Detected sequence named 'aviso_id_seq' as owned by integer column 'aviso(id)', assuming SERIAL and omitting
INFO [alembic.autogenerate.compare] Detected added column 'local_config.zona_horaria_override'
Generating …/alembic/versions/0002_zona_horaria_override.py ... done
alembic/versions/0002_zona_horaria_override.pyrevision: str = '0002_zona_horaria_override'
down_revision: Union[str, None] = '0001_local_config_aviso'
def upgrade() -> None:
op.add_column('local_config', sa.Column('zona_horaria_override',
sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True))
def downgrade() -> None:
op.drop_column('local_config', 'zona_horaria_override')
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas \
-c "SELECT local_id, max_dias_anticipacion, zona_horaria_override FROM local_config;"
Deberías verINFO [alembic.runtime.migration] Running upgrade 0001_local_config_aviso -> 0002_zona_horaria_override, local_config.zona_horaria_override
local_id | max_dias_anticipacion | zona_horaria_override
----------+-----------------------+-----------------------
11 | 30 |
(1 row)
La fila sigue ahí y la columna nueva llegó en NULL. Eso es lo que hace segura esta migración: nullable=True y sin server_default, así que Postgres solo toca el catálogo y no reescribe la tabla. Ahora la otra mitad del experimento. Llena la columna y baja una revisión:
docker compose exec -T postgres psql -U practicas -d practicas \
-c "UPDATE local_config SET zona_horaria_override = 'America/Lima' WHERE local_id = 11;" \
-c "SELECT local_id, zona_horaria_override FROM local_config;"
make db-down-1
make db-up-head
docker compose exec -T postgres psql -U practicas -d practicas \
-c "SELECT local_id, zona_horaria_override FROM local_config;"
Deberías ver (la lección)UPDATE 1
local_id | zona_horaria_override
----------+-----------------------
11 | America/Lima
(1 row)
INFO [alembic.runtime.migration] Running downgrade 0002_zona_horaria_override -> 0001_local_config_aviso, …
INFO [alembic.runtime.migration] Running upgrade 0001_local_config_aviso -> 0002_zona_horaria_override, …
local_id | zona_horaria_override
----------+-----------------------
11 |
(1 row)
El downgrade restaura el esquema, nunca los datos. Bajaste y volviste a subir: la columna está de vuelta con el mismo tipo y la misma longitud, y el America/Lima ya no existe. op.drop_column es un DROP COLUMN: los valores se fueron con la columna y ningún upgrade los va a adivinar. Por eso el downgrade sirve para dos cosas y solo dos: deshacer una migración que acabas de aplicar en tu laptop, y probar que es simétrica. En producción el plan de vuelta atrás no es downgrade, es una migración nueva hacia adelante. De ahí la regla operativa: una migración que borra datos nunca va en el mismo PR que el código que deja de usarlos — primero se despliega el código que ya no los necesita, y en un PR posterior se borra la columna.
Paso 7 Una unidad de negocio, una transacción
Cambiar la configuración de un local implica dos escrituras: actualizar local_config y dejar un Aviso de auditoría. O entran las dos, o no entra ninguna. Eso es una transacción, y en SQLAlchemy 2 se escribe así (en errors.py, una ConfigError(Exception) de base y DiasAnticipacionInvalidos(ConfigError); la práctica 7 las convierte en respuestas HTTP):
services/api/app/locales/config_service.py"""Servicio de configuración por local: la unidad de negocio es UNA transacción.
`actualizar_config` cambia la config del local Y registra un Aviso de auditoría.
O entran los dos cambios, o ninguno: `async with session.begin()` abre la
transacción y hace commit al salir; si algo lanza dentro, hace rollback.
"""
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import Aviso, LocalConfig
from .errors import DiasAnticipacionInvalidos
MAX_DIAS_ANTICIPACION = 365
async def obtener_config(session: AsyncSession, local_id: int) -> LocalConfig | None:
return await session.get(LocalConfig, local_id)
async def actualizar_config(
session: AsyncSession,
local_id: int,
*,
permite_reserva_online: bool,
max_dias_anticipacion: int,
) -> LocalConfig:
"""Crea o actualiza la config y deja un Aviso, atómicamente."""
async with session.begin(): # una transacción por unidad de trabajo
config = await session.get(LocalConfig, local_id)
if config is None:
config = LocalConfig(local_id=local_id)
session.add(config)
config.permite_reserva_online = permite_reserva_online
config.max_dias_anticipacion = max_dias_anticipacion
config.updated_at = datetime.now(UTC)
await session.flush() # el INSERT/UPDATE ya viajó a la BD (aún sin commit)
# Validación de negocio DESPUÉS del flush a propósito: demuestra que si
# falla aquí, el rollback también deshace lo de arriba.
if not 0 <= max_dias_anticipacion <= MAX_DIAS_ANTICIPACION:
raise DiasAnticipacionInvalidos(
f"max_dias_anticipacion={max_dias_anticipacion} fuera de 0..{MAX_DIAS_ANTICIPACION}"
)
session.add(Aviso(local_id=local_id, tipo="config_actualizada"))
return config
El test que le da sentido no es el del camino feliz, es el del camino roto: cuando la validación falla, la base tiene que quedar exactamente como estaba.
services/api/app/locales/tests/test_config_service.py"""Tests del servicio: la transacción no deja la BD a medias."""
from services.api.app.locales import config_service as svc
from services.api.app.locales.errors import DiasAnticipacionInvalidos
from shared.models import Aviso, LocalConfig
# … + pytest, func/select
pytestmark = pytest.mark.unit
async def _cuenta(session, modelo) -> int:
return (await session.execute(select(func.count()).select_from(modelo))).scalar_one()
async def test_actualizar_crea_config_y_aviso_juntos(sqlite_session):
config = await svc.actualizar_config(
sqlite_session, 11, permite_reserva_online=True, max_dias_anticipacion=30
)
assert config.local_id == 11
assert await _cuenta(sqlite_session, LocalConfig) == 1
assert await _cuenta(sqlite_session, Aviso) == 1
# … y test_actualizar_existente_no_duplica: dos llamadas seguidas dejan
# 1 fila en local_config y 2 en aviso (un aviso por cambio).
@pytest.mark.parametrize("dias", [-1, 366, 10_000])
async def test_dias_invalidos_no_deja_la_bd_a_medias(sqlite_session, dias):
with pytest.raises(DiasAnticipacionInvalidos):
await svc.actualizar_config(
sqlite_session, 873, permite_reserva_online=True, max_dias_anticipacion=dias
)
# el flush ya había viajado a la BD; el rollback lo deshizo: ni config ni aviso
assert await _cuenta(sqlite_session, LocalConfig) == 0
assert await _cuenta(sqlite_session, Aviso) == 0
Para creerte que ese test vale, rómpelo. Reemplaza el async with session.begin(): por lo que sale natural cuando uno viene de guardar registro a registro:
config_service.py (versión mala, temporal) config = await session.get(LocalConfig, local_id)
…
await session.commit() # <- commit #1: la config ya quedó grabada
if not 0 <= max_dias_anticipacion <= MAX_DIAS_ANTICIPACION:
raise DiasAnticipacionInvalidos(...)
session.add(Aviso(local_id=local_id, tipo="config_actualizada"))
await session.commit() # <- commit #2: nunca se llega si lo de arriba lanza
return config
pytest -m "not integration" -q
Deberías ver________________ test_dias_invalidos_no_deja_la_bd_a_medias[-1] ________________
services/api/app/locales/tests/test_config_service.py:48: in test_dias_invalidos_no_deja_la_bd_a_medias
assert await _cuenta(sqlite_session, LocalConfig) == 0
E assert 1 == 0
=========================== short test summary info ============================
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[-1]
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[366]
FAILED …::test_dias_invalidos_no_deja_la_bd_a_medias[10000]
================== 3 failed, 2 passed, 2 deselected in 0.16s ===================
Restaura el async with session.begin(): y vuelve al verde.
assert 1 == 0: la config quedó grabada y su aviso no existe. Ese es el estado a medias, y en producción no se ve nunca — se ve tres semanas después, cuando alguien pregunta por qué hay locales con configuración cambiada y sin rastro de quién la cambió. Dos detalles del código bueno: flush() manda el INSERT a la base pero no hace commit (por eso la validación va después, para probar que el rollback lo alcanza), y session.begin() como context manager hace commit al salir del bloque y rollback si sale por una excepción. Es el try/commit/except/rollback que en PHP escribías a mano, con la ventaja de que no se te puede olvidar el rollback.
Paso 8 Tests unit con SQLite, tests integration con Postgres
Los tests del paso 7 no necesitan Docker: corren sobre una SQLite en memoria creada desde los mismos modelos. Pero hay cosas que solo Postgres puede contestar. Dos fixtures, una por nivel:
services/api/app/locales/tests/conftest.py"""Fixtures de sesión: SQLite en memoria (unit) y Postgres del compose (integration)."""
from shared import models # noqa: F401 — registra las tablas
# … + os, pytest, AsyncSession/async_sessionmaker/create_async_engine, SQLModel
@pytest.fixture()
async def sqlite_session() -> AsyncGenerator[AsyncSession, None]:
"""Una BD nueva por test: rápida, sin Docker. Para tests unit del servicio."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with maker() as session:
yield session
await engine.dispose()
@pytest.fixture()
async def pg_session() -> AsyncGenerator[AsyncSession, None]:
"""Postgres real del compose (marca integration). Limpia las tablas al terminar."""
url = os.getenv("DB_URL")
if not url or not url.startswith("postgresql+asyncpg://"):
pytest.skip("DB_URL de Postgres no configurada")
engine = create_async_engine(url)
async def _limpiar() -> None:
async with engine.begin() as conn:
for table in ("aviso", "local_config"):
await conn.exec_driver_sql(f"DELETE FROM {table}") # tablas del código, no input
await _limpiar() # un test no hereda filas de nadie (ni de tus inserts a mano)
maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async with maker() as session:
yield session
await _limpiar()
await engine.dispose()
Declara la marca nueva en pytest.ini, junto a las de la práctica 4:
pytest.ini (en markers) integration: Tests against real local services (Postgres del compose).
Y ahora el test que justifica todo el nivel integration: el CHECK que agregaste a mano en la migración 0001 no existe en SQLite, porque SQLite se crea desde SQLModel.metadata y la restricción vive en la migración.
services/api/app/locales/tests/test_config_service_pg.py"""Lo mismo contra Postgres real: aquí además existe el CHECK de la migración."""
from sqlalchemy.exc import IntegrityError
# … + pytest, func/select, config_service as svc, Aviso/LocalConfig
pytestmark = pytest.mark.integration
async def test_actualizar_en_postgres(pg_session):
await svc.actualizar_config(
pg_session, 2246, permite_reserva_online=True, max_dias_anticipacion=45
)
total = (await pg_session.execute(select(func.count()).select_from(LocalConfig))).scalar_one()
assert total == 1
async def test_check_constraint_lo_garantiza_la_bd(pg_session):
"""Aunque el código no validara, Postgres rechaza -5 por el CHECK de la migración 0001."""
pg_session.add(LocalConfig(local_id=999, max_dias_anticipacion=-5))
with pytest.raises(IntegrityError):
await pg_session.commit()
await pg_session.rollback()
total = (await pg_session.execute(select(func.count()).select_from(Aviso))).scalar_one()
assert total == 0
make tests # unit: SQLite en memoria, sin Docker
make tests-integration # integration: contra el Postgres del compose
make lint
Deberías ver…/test_config_service.py::test_actualizar_crea_config_y_aviso_juntos PASSED [ 20%]
…/test_config_service.py::test_actualizar_existente_no_duplica PASSED [ 40%]
…/test_config_service.py::test_dias_invalidos_no_deja_la_bd_a_medias[-1] PASSED [ 60%]
… [100%]
======================= 5 passed, 2 deselected in 0.14s ========================
…/test_config_service_pg.py::test_actualizar_en_postgres PASSED [ 50%]
…/test_config_service_pg.py::test_check_constraint_lo_garantiza_la_bd PASSED [100%]
======================= 2 passed, 5 deselected in 0.20s ========================
All checks passed!
15 files already formatted
Compruébalo tú: pide el DDL que SQLModel generó para SQLite y mete el -5 que Postgres rechaza.
Deberías verCREATE TABLE local_config (
local_id INTEGER NOT NULL,
permite_reserva_online BOOLEAN NOT NULL,
max_dias_anticipacion INTEGER NOT NULL,
zona_horaria_override VARCHAR(64),
updated_at DATETIME NOT NULL,
PRIMARY KEY (local_id)
)
SQLite aceptó -5: el CHECK vive solo en la migración
Qué atrapa cada nivel. SQLite en memoria es una base nueva por test, arranca en milisegundos y no depende de Docker: perfecta para la lógica del servicio (¿la transacción revierte? ¿se duplica la fila?). Pero es una base distinta, creada desde los modelos y no desde tus migraciones: no tiene el CHECK, guarda DATETIME en vez de timestamp with time zone y no conoce ni jsonb ni ON CONFLICT. Todo lo que dependa del motor real o del esquema que aplica alembic va marcado integration.
git add -A
git commit -m "feat(db): postgres propio con SQLModel y alembic; config_service transaccional"
Con Claude
Con migraciones el riesgo cambia de forma: aquí un error no rompe un test, borra una columna en producción. Y Claude es especialmente convincente escribiendo migraciones, porque el archivo parece correcto. Dale el contexto que le falta —qué base, qué revisión es la actual, qué no puede tocar— y pídele siempre el downgrade junto con el upgrade.
Así noAgrega un campo de zona horaria a la configuración del local.
Va a editar el modelo y, con suerte, correr autogenerate. Con mala suerte te escribe la migración a mano con un nullable=False sin default, que en una tabla con filas falla — o peor, pasa en tu laptop vacía y revienta en staging.
Así síAgrega zona_horaria_override: str | None (max 64) a LocalConfig en shared/models.py. Después genera la migración con alembic revision --autogenerate -m "local_config.zona_horaria_override" --rev-id 0002_zona_horaria_override y pégame el archivo generado sin aplicarlo. La tabla ya tiene filas en staging: la columna debe ser nullable=True y sin server_default. Dime qué hace el downgrade y qué datos se pierden si se ejecuta.
Le diste el tipo, el id de revisión, la restricción operativa (hay filas) y le pediste que te explique el vuelta atrás antes de aplicar nada.
Así noLa migración falla, arréglala. / Escribe un test para actualizar_config.
Lo primero suele terminar en stamp head o en borrar alembic_version: la base queda desincronizada y el siguiente que corra upgrade no se entera. Lo segundo te da el camino feliz, que pasa igual con dos commit() sueltos y sin transacción.
Así síalembic upgrade head falla con este traceback: [pegar]. Antes de proponer nada dime en qué revisión está la base (alembic current), cuál intenta aplicar y qué operación exacta rompe; no propongas stamp ni borrar alembic_version. Y agrega un test unit con la fixture sqlite_session que llame a actualizar_config con max_dias_anticipacion inválido (parametriza -1, 366, 10000), espere DiasAnticipacionInvalidos y afirme 0 filas en local_config y en aviso: tiene que fallar si alguien reemplaza el session.begin() por dos commit().
Primero el diagnóstico, prohibido el atajo que esconde el problema, y el test descrito por el bug que debe atrapar — el criterio del TESTING.md de m-b-core.