DTOs tipados¶
Una aplicación dice la forma de una misma respuesta más de una vez: el dict que arma la vista, el serializador del framework que tenga delante, la interfaz de TypeScript del otro lado. Nada las compara, así que se separan — y la separación es silenciosa, porque cada una es válida por su cuenta.
El generador de DTOs reduce eso a una sola declaración. Dices qué modelo y qué campos; el CLI
escribe el TypedDict en tu propio fichero, a partir de la metadata compilada.
uv run snakeorm dto --file blog/dto.py --sync # write them
uv run snakeorm dto --file blog/dto.py # check only: exits 1 if anything drifted
Lo que escribes tú¶
La declaración vive dentro del bloque if TYPE_CHECKING: del propio fichero, junto al import de los
modelos:
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from snakeorm.dto import snake_dto
from blog.models import Author, Post
snake_dto(Author, fields=[Author.id, Author.username], name="AuthorDto")
snake_dto(Post, fields=[Post.id, Post.title, Post.author], name="PostCard")
Tres cosas: qué modelo, qué campos y cómo se llama la clase. El nombre es obligatorio — es lo que se va a escribir.
Lo que escribe el CLI¶
Dentro de una región marcada de ese mismo fichero, y en ningún otro sitio:
# snakeorm-dto: begin generated block
class AuthorDto(TypedDict):
id: int
username: str
class PostCard(TypedDict):
id: int
title: str
author: AuthorDto
# snakeorm-dto: end generated block
Todo lo que queda fuera de las dos líneas marcadoras es tuyo y no se toca jamás. La región se regenera entera en cada pasada, que es lo que hace que una segunda no tenga nada que escribir.
Por qué la declaración va dentro de if TYPE_CHECKING:¶
Tres propiedades a la vez, y hacen falta las tres:
| el checker valida cada path | Post.tilte no compila |
| en runtime no se ejecuta nada | el bloque no llega a ejecutarse |
| el módulo no cuesta nada de importar | no arrastra los modelos, ni el ORM |
Así que el fichero con tus DTOs es barato de importar desde una vista, y el ciclo de imports entre
models.py y dto.py no puede formarse — el import que lo cerraría no ocurre nunca.
Y el CLI lee ese bloque del fuente con ast; no importa jamás el fichero que va a reescribir. Lo
que sí importa es el módulo de modelos que ese fichero nombra, porque la metadata compilada es donde
viven los tipos y la nulabilidad — así que un fichero de DTOs que no importa no es problema de este
comando, pero un módulo de modelos que revienta al importar sí lo sigue siendo.
Las tres formas de elegir campos¶
| escribes | significa |
|---|---|
snake_dto(Post, name="X") |
todas las columnas |
snake_dto(Post, fields=[...], name="X") |
exactamente ésas |
snake_dto(Post, exclude=[...], name="X") |
todas menos ésas |
Las dos juntas es un error: son dos respuestas a la misma pregunta y pueden discrepar.
Para cualquier cosa que cruce la red, mejor fields=. Una exclusión publica cada columna que se
añada después — que es la dirección que falla en abierto.
Las relaciones se ANIDAN, no se expanden¶
Post.author se convierte en author: AuthorDto, no en las columnas del autor injertadas. Lees
author y sabes que va anidado, y su forma es otra declaración con nombre propio.
Una columna al otro lado de un to-one se escribe como el path y se nombra por él:
da author_username: str. El path entero está en el nombre a propósito: con solo el último paso,
author.username y editor.username serían los dos username y uno taparía al otro en silencio.
Si un modelo tiene más de una declaración, di cuál anidar:
No se elige nada por ti. Dos declaraciones sobre un modelo y ninguna regla para escoger producirían una clase con buen aspecto describiendo la forma equivocada.
La nulabilidad se acumula sobre el path ENTERO¶
Post.editor.username es str | None aunque username sea NOT NULL, porque un LEFT JOIN sobre un
editor_id nullable no devuelve nada de verdad. Post.editor es AuthorDto | None por exactamente
el mismo motivo.
Una colección nunca es opcional: Post.comments es list[CommentDto], porque un padre sin hijos
recibe una lista vacía.
Esto escribe la FORMA, nunca la consulta¶
Aquí nadie emite el include(...) que rellena una relación anidada, y es deliberado: un include()
escrito en tu fichero sería un segundo sitio diciendo lo que la declaración ya dice. Lo que pasa si
se te olvida es la promesa más vieja del ORM, no un agujero:
SnakeRelationshipNotLoaded: Relation 'author' was not loaded.
Use .include(Post.author) in the query.
O sea que una declaración que anida Post.author describe una fila leída con include(Post.author).
Mantén la pareja, y el ORM te avisa cuando no lo hagas.
Hay una forma de tragarse ese aviso. SnakeRelationshipNotLoaded hereda de AttributeError, así
que hasattr(row, name) contesta False y getattr(row, name, None) devuelve el valor por defecto
— las dos en silencio. Un serializador que recorre una forma se escribe con esa llamada exacta,
porque los nombres vienen de datos. Usa el getattr de dos argumentos, que es lo que hace el ORM por
dentro.
El modo check es el porqué de todo esto¶
$ snakeorm dto --file blog/dto.py
blog/dto.py: Would write 1 change(s).
AuthorDto: added `country_id: int | None`
Run `snakeorm dto --sync` to write them, and read the diff: these classes live in your files.
Código de salida 1. Añade una columna al modelo y el build se pone rojo, en vez de publicarse la
columna sin que nadie lo haya decidido. Ésa es la mitad que hace seguro usar exclude= y «todas las
columnas».
Lo que NO hace¶
- Escribir tus imports. Ni siquiera
TypedDict. Editar el bloque de imports de alguien a ojo es una reclamación mayor sobre su fichero que rellenar una región que él marcó, y equivocarse significa que el fichero deja de importar. Te dice qué línea añadir. - Renombrar un campo.
author_usernamees mecánico. - Expandir una relación en las columnas del modelo lejano.
- Agregar nada. Un
COUNTno es una forma, es una consulta.
Ese último grupo es la razón de que esto encaje en el payload de una API —que es la forma del
modelo— y no en la fila de una plantilla, que renombra claves, aplana None a "" y formatea
fechas. Eso es presentación, no serialización.
No se exporta desde el paquete raíz¶
Se importa de snakeorm.dto. Es una herramienta joven, y el paquete raíz es superficie publicada con
una red de documentación alrededor.
Siguiente: cómo funciona el tipado.