Paso 8 · Bloque IV · Unidad 4.2
Consulta y modifica recursos sin perder intención
Construye consultas SQLModel filtradas y ordenadas, pagina con un contrato explícito y aplica PATCH desde campos presentes antes de borrar con una respuesta verificable.
1. Predice páginas y cambios antes de escribir CRUD#
La Unidad 4.1 demostró una fila persistente con POST y GET por id. Ahora el riesgo ya no es solo perder datos al reiniciar: una consulta puede devolver una ventana inestable y una actualización parcial puede borrar valores que el cliente nunca quiso tocar.
Parte de tres activos con ids 1, 2 y 3. Predice las páginas para limit=1, offset=0 y offset=1 bajo dos condiciones: con ORDER BY name, id y sin orden declarado. Después completa esta matriz para un activo cuya ubicación actual es "Madrid":
| JSON recibido | Campo presente | Mapa de cambios esperado | Ubicación final |
|---|---|---|---|
| No | | “Madrid” |
{"location": null} | Sí | {"location": None} | NULL |
{"location": "Bilbao"} | Sí | Valor nuevo | “Bilbao” |
Si tu código produce el mismo mapa para los tres casos, ha perdido intención antes de llegar a SQL. Si dos páginas cambian sin que cambien filtros o parámetros, falta un orden total.
2. Elige session.get o select según la pregunta#
session.get(AssetTable, asset_id) expresa búsqueda por clave primaria. Devuelve una entidad o None y puede aprovechar el mapa de identidad de la sesión.
select(AssetTable) construye un statement que luego ejecuta la sesión:
from sqlmodel import select
statement = select(AssetTable).where(AssetTable.category == "network")
assets = session.exec(statement).all()
La comparación usa el atributo de clase AssetTable.category, que produce una expresión SQL. asset.category == "network" sobre una instancia produce un booleano Python y no construye un WHERE.
| Pregunta | Herramienta | Resultado esperado |
|---|---|---|
| “¿Existe el id 7?” | session.get(Table, 7) | Una entidad o None. |
| “Dame todos los activos de red”. | exec(select(…).where(…)).all() | Lista, posiblemente vacía. |
| “Toma el primero según esta prioridad”. | Statement ordenado y first(). | Una entidad o None; pueden existir más. |
| “Debe existir exactamente uno”. | one() o one_or_none() | Excepción si la cardinalidad contradice la expectativa. |
first() no demuestra unicidad: solo toma una fila. Si el dominio exige una sola coincidencia, la restricción de base y su traducción pertenecen a 4.3.
3. Construye filtros con expresiones, no con SQL del cliente#
Empieza con un statement y añade condiciones solo cuando el filtro está presente:
statement = select(AssetTable)
if category is not None:
statement = statement.where(AssetTable.category == category)
if location is not None:
statement = statement.where(AssetTable.location == location)
Los valores category y location llegan al driver como parámetros ligados. No construyas text(f"... {client_value} ...") ni concatentes un ORDER BY recibido: además del riesgo de inyección, pierdes tipo, allowlist y capacidad de revisión.
Filtrar después de all() en Python carga filas que el motor podía descartar, hace imposible paginar correctamente antes de transferir datos y separa el total de la consulta real. La traza SQL debe contener el WHERE esperado.
4. Ordena antes de aplicar limit y offset#
No aceptes nombres de columnas arbitrarios. Traduce el vocabulario público a expresiones conocidas:
SORT_COLUMNS = {
"name": AssetTable.name,
"category": AssetTable.category,
}
sort_column = SORT_COLUMNS[sort]
primary_order = sort_column.desc() if direction == "desc" else sort_column.asc()
statement = (
statement
.order_by(primary_order, AssetTable.id.asc())
.offset(offset)
.limit(limit)
)
items = session.exec(statement).all()
id desempata nombres o categorías iguales. Sin ese segundo criterio, dos filas equivalentes para el primer orden pueden intercambiarse entre ejecuciones y aparecer repetidas u omitidas entre páginas.
FastAPI limita la entrada antes de construir SQL:
from typing import Annotated, Literal
from fastapi import Query
Offset = Annotated[int, Query(ge=0)]
Limit = Annotated[int, Query(ge=1, le=50)]
Sort = Literal["name", "category"]
Direction = Literal["asc", "desc"]
Un máximo no es solo validación estética: evita que limit=1000000 convierta una ruta pública en una lectura sin límite. Offset es suficiente para este dataset didáctico; cursores y sus decisiones de compatibilidad quedan pospuestos.
5. Items, total, offset y limit cuentan la misma consulta#
Una lista simple no dice si existen más resultados. En esta unidad el contrato elige un objeto de página pequeño:
class AssetPage(SQLModel):
items: list[AssetPublic]
total: int
offset: int
limit: int
total cuenta el conjunto después de filtros y antes de offset/limit. Construye ambos statements desde las mismas condiciones:
from sqlalchemy import func
items_statement = select(AssetTable)
count_statement = select(func.count()).select_from(AssetTable)
if category is not None:
condition = AssetTable.category == category
items_statement = items_statement.where(condition)
count_statement = count_statement.where(condition)
total = session.exec(count_statement).one()
items = session.exec(
items_statement
.order_by(AssetTable.name.asc(), AssetTable.id.asc())
.offset(offset)
.limit(limit)
).all()
No calcules total = len(items): en una página de dos elementos dentro de un conjunto de quince devolvería 2, no 15. Tampoco cargues las quince filas para contarlas en Python.
6. PATCH conserva presencia; optional no significa lo mismo que nullable#
Un modelo de actualización no hereda sin más del modelo base porque sus campos tienen otra obligatoriedad. Para una ubicación que el cliente puede omitir o borrar:
class AssetUpdate(SQLModel):
location: str | None = Field(default=None, max_length=120)
El tipo permite null; el default permite omitir. Pydantic conserva cuáles llegaron explícitamente:
changes = payload.model_dump(exclude_unset=True)
| Opción | Omite | Por qué importa en PATCH |
|---|---|---|
exclude_unset=True | Campos no enviados. | Conserva un null explícito y un valor igual al default. |
exclude_none=True | Todo valor None. | Perdería la intención de borrar una columna nullable. |
exclude_defaults=True | Valores iguales al default. | Perdería un reset explícito al valor por defecto. |
Presencia no decide permisos ni reglas. Un campo puede existir en la tabla y seguir siendo inmutable para el cliente. Usa un modelo Update que solo declare campos públicos modificables y valida la nulabilidad deseada; sqlmodel_update no es autorización automática.
7. Aplica el mapa al objeto gestionado y confirma una vez#
@router.patch("/{asset_id}", response_model=AssetPublic)
def update_asset(
asset_id: int,
payload: AssetUpdate,
session: SessionDep,
) -> AssetTable:
db_asset = session.get(AssetTable, asset_id)
if db_asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
changes = payload.model_dump(exclude_unset=True)
db_asset.sqlmodel_update(changes)
session.add(db_asset)
session.commit()
session.refresh(db_asset)
return db_asset
sqlmodel_update muta campos conocidos del objeto Python; no ejecuta commit, no valida autorización y no protege carreras. session.add hace explícito que el objeto participa en la unidad de trabajo; commit confirma; refresh obtiene el estado persistido que se serializa.
Un payload {} puede devolver la representación sin cambios. Si tu contrato prefiere rechazarlo, declara y prueba esa decisión. No escondas un cambio implícito dentro de un PATCH vacío.
8. DELETE también necesita identidad, commit y una respuesta definida#
from fastapi import Response, status
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset(asset_id: int, session: SessionDep) -> Response:
db_asset = session.get(AssetTable, asset_id)
if db_asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
session.delete(db_asset)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
session.delete marca el objeto; el DELETE llega a la base al hacer flush/commit. Un 204 no lleva representación. La comprobación completa observa 204, body vacío y un GET posterior con el 404 estable.
Hard delete frente a soft delete es una decisión de producto, retención, privacidad y consulta. No añadas una columna deleted_at por reflejo: cambiaría todos los filtros, restricciones y contratos. En esta unidad se borra la fila.
9. Una rebanada integrada, no un helper CRUD universal#
GET /assets/{id} ── session.get ── entidad | 404
GET /assets ── filtros ── count del conjunto
└───── order ── offset/limit ── items
PATCH /assets/{id} ── get ── campos presentes ── update ── commit/refresh
DELETE /assets/{id} ── get ── delete ── commit ── 204
Las cuatro operaciones comparten sesión y modelos, pero no por eso deben entrar en generic_crud(model, payload, action). Cada una tiene cardinalidad, status, respuesta y política de campos diferente. Extrae una función solo cuando preserve una responsabilidad concreta y su evidencia sea más clara.
10. Diagnostica desde contrato, mapa de cambios y SQL#
| Observación | Hipótesis prioritaria | Comprobación corta |
|---|---|---|
| Dos páginas repiten un id. | Falta orden o desempate total. | Inspecciona ORDER BY y prueba valores empatados. |
total vale el tamaño de la página. | Se usó len(items) después de limit. | Compara statement de count y statement de items. |
| El filtro cambia items pero no total. | Las condiciones no se aplicaron a ambos statements. | Lee los dos SQL con los mismos parámetros. |
| PATCH vacío borra ubicación. | Se serializaron defaults de campos omitidos. | Imprime model_fields_set y el mapa de cambios. |
null no borra una columna nullable. | Se usó exclude_none en vez de presencia. | Compara omitido con null explícito. |
| El cliente cambia un campo interno. | El modelo Update replica la tabla o acepta extras. | Revisa schema, extra y propiedades OpenAPI. |
| DELETE responde 204 pero GET aún devuelve 200. | Faltó commit o se consulta otra base. | Busca DELETE/COMMIT y compara URLs. |
| La ruta carga toda la tabla. | Filtro o ventana se aplicaron en Python. | La traza carece de WHERE/LIMIT/OFFSET. |
11. Decisiones y límites de esta unidad#
Esta unidad completa la primera superficie CRUD de un recurso plano y persistente. Introduce get, select, expresiones, filtros opcionales, orden total, count, limit/offset, PATCH por presencia y hard delete.
Quedan fuera:
- unicidad, conflictos 409, rollback después de constraint y concurrencia, que llegan en 4.3;
- relaciones, cascadas y varias filas atómicas, también en 4.3;
- índices como garantía de rendimiento, planes de consulta y tuning;
- migraciones, PostgreSQL y evolución del schema, en 4.4;
- cursores, búsqueda textual, SQL dinámico, bulk update/delete y soft delete;
- repositorio, service layer o helper CRUD obligatorio.
La condición de parada exige:
- elección justificada entre
getyselect; - filtros ausentes que no restringen y valores ligados que no concatenan SQL;
- orden permitido con desempate total antes de offset/limit;
- máximo de página validado y count con los mismos filtros;
- matriz de omitido/null/valor reflejada en la fila persistida;
- campos protegidos ausentes del modelo Update y de OpenAPI;
- 404 estable y DELETE 204 con body vacío y ausencia posterior.
12. Criterios de dominio#
- Construyes y lees la traza de un statement SQLModel sin confundir clase e instancia.
- Eliges
get,all,firstoonesegún la cardinalidad prometida. - Aplicar un filtro en la colección cambia de forma coherente items y total.
- Demuestras que dos páginas no repiten ni omiten ids bajo el orden declarado.
- Rechazas un limit por encima del máximo antes de ejecutar SQL.
- Predices el mapa de cambios para campo omitido, null, default y valor.
- Explicas por qué
exclude_unsetno autoriza campos ni valida el estado final. - Compruebas commit/refresh tras PATCH y commit/ausencia tras DELETE.
- Conservas response models y OpenAPI mientras amplías la persistencia.
- Detienes el alcance antes de integridad, migraciones o abstracciones universales.
Prácticas relacionadas#
- EX-B4-04 · PATCH sin borrar lo ausente
- EX-B4-05 · Construir una ventana de consulta estable
- EX-B4-S01-02 · Checkpoint: consultar y cambiar facturas