Elegir espacio

Dos espacios

¿Qué quieres consultar?

Elige el espacio al que quieres entrar.

Biblioteca FastAPIConsulta y modifica recursos sin perder intención

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 recibidoCampo presenteMapa de cambios esperadoUbicación final
No“Madrid”
{"location": null}{"location": None}NULL
{"location": "Bilbao"}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.

PreguntaHerramientaResultado 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ónOmitePor qué importa en PATCH
exclude_unset=TrueCampos no enviados.Conserva un null explícito y un valor igual al default.
exclude_none=TrueTodo valor None.Perdería la intención de borrar una columna nullable.
exclude_defaults=TrueValores 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ónHipótesis prioritariaComprobació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 get y select;
  • 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, first o one segú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_unset no 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#

Fuentes#