Consultar¶
SnakeQuery(Modelo) construye; SnakeSession ejecuta. Construir no ejecuta: un SnakeQuery es
inmutable y no ha tocado la base.
from snakeorm import SnakeQuery, SnakeSession, PostgresDialect, PsycopgDriver
dsn = "postgresql://user:pass@localhost/mydb"
session = SnakeSession(PsycopgDriver.connect(dsn), PostgresDialect())
active_users = session.all(
SnakeQuery(User).filter(User.active == True).order_by(User.email)
)
Cada método devuelve una consulta nueva; la anterior no se toca:
base = SnakeQuery(User).filter(User.active == True)
first_page = session.all(base.limit(10))
how_many = session.count(base) # `base` stays intact
Leer¶
| Llamada | Devuelve |
|---|---|
session.all(query) |
list[T] |
session.first(query) |
T \| None |
session.count(query) |
int |
session.exists(query) |
bool |
No hay session.get(...). Para una fila por PK, usa first() con un filtro.
Filtrar¶
Operadores de Python sobre los atributos de clase:
SnakeQuery(User).filter(
User.email.like("%@company.com"),
User.created_at > cutoff, # implicit AND between arguments
)
Repertorio: ==, !=, <, <=, >, >=, .in_(), .not_in(), .like(), .ilike(),
.startswith(), .endswith(), .between(), .is_null(), .is_not_null(). Combínalas con &
(AND), | (OR), ~ (NOT), siempre con paréntesis:
El lado derecho puede ser otra columna, y no cuesta ningún parámetro: una referencia a columna es parte de la sentencia, no un valor que viaje al lado:
SnakeQuery(Stock).filter(Stock.quantity > Stock.reserved)
SnakeQuery(Stock).filter(Stock.quantity - Stock.reserved > minimum)
El par sigue tipado: comparar una columna int contra una str lo rechaza el checker, que es el
error por el que esto merece la pena. La aritmética compone igual, y siempre lo hizo.
El SQL SIEMPRE va parametrizado
Los valores nunca se interpolan: la emisión devuelve (sql, params) y el driver los manda por
separado. Mata la inyección de SQL y habilita el multi-motor: los placeholders (%s, ?, $1)
son justo lo que cambia entre motores.
Leer dentro de una columna JSON¶
SnakeQuery(Doc).filter(Doc.meta.json_get("size", as_type=int) > 100)
SnakeQuery(Doc).filter(Doc.meta.json_get("owner", "name", as_type=str) == "ada")
as_type es obligatorio, y no es ceremonia. Lo que un motor devuelve de un documento es TEXTO, así
que sin tipo declarado esa primera línea sería una comparación de texto — y como texto, '9' > '100'
es cierto. El tipo declarado es lo que hace que el ORM emita el cast.
Varias claves recorren una ruta anidada en UN solo acceso. Cada motor lo escribe a su manera (->> y
#>>, JSON_EXTRACT con un unquote, json_extract); lo escribe el dialecto, no tú.
El documento no lo comprueba el ORM
Si no contiene lo que declaraste, lo dice el MOTOR. SnakeORM no puede saber qué hay dentro de un documento que no escribió, y adivinarlo es lo único que no hace nunca.
Traer media fila¶
Sobre una tabla ancha, only() nombra las columnas que traer y defer() las que dejar. La clave
primaria viaja siempre: una fila sin identidad no se puede reescribir ni casar con sus relaciones.
session.all(SnakeQuery(Visit).only(Visit.path)) # + the primary key, always
session.all(SnakeQuery(Visit).defer(Visit.agent)) # everything except that one
Leer una columna que se quedó fuera lanza SnakeColumnNotLoaded — no vuelve como None. Esa
negativa es la funcionalidad: un valor que nadie cargó no puede parecerse nunca a un valor.
only() / defer() NO se combina con include(): la pareja lanza SnakeUnsupportedFeature en vez
de tirar en silencio las columnas que nombraste. Usa select(...) si lo que quieres son valores.
Casi siempre lo que quieres es select()
Si lo que necesitas son los VALORES, session.select(query, Visit.path, Visit.ms) da tuplas
tipadas, no cuesta ninguna instancia a medias y no puede levantar después. only() es para
cuando lo que quieres ES el modelo — para dárselo a código que espera uno.
Una fila parcial no se puede reescribir
session.update(row) sobre una fila construida con only()/defer() lanza
SnakeColumnNotLoaded: el UPDATE cubre todas las columnas que no son PK y las que dejaste fuera
no están para mandarlas. Que viaje la clave primaria no salva nada — con PK y todo, la única
escritura que sobrevive es session.delete(row). Y update_where tampoco es escapatoria,
porque only() es uno de los botones que el guardia de escritura masiva rechaza.
Si necesitas escribir, lee la fila entera, o usa select() y luego update_where por clave
primaria.
Navegar relaciones¶
Lo que distingue a este ORM:
Sin cadenas mágicas y sin join() a mano: los JOIN salen del camino navegado, cada salto
comprobado por el type-checker.
Traer los relacionados¶
Navegar filtra; para traer los objetos relacionados, pídelo con .include():
cars = session.all(SnakeQuery(Car).include(Car.brand))
cars[0].brand.name # already loaded, no second query
Sin include, tocar car.brand lanza SnakeRelationshipNotLoaded en vez de disparar una consulta a
tus espaldas. Así se evita el N+1 por defecto: el ORM no va a la base sin que se lo pidas.
session.all(SnakeQuery(Car).include(Car.brand.country)) # to-one: LEFT JOIN
session.all(SnakeQuery(Brand).include(Brand.cars)) # to-many: select-in, not a JOIN
El select-in de a-muchos se trocea para caber en el tope de marcadores del motor, así que un lote grande de padres cuesta varias segundas consultas en vez de una que el driver rechazaría.
Proyectar y agregar¶
Cuando no quieres el objeto entero, select() devuelve tuplas:
from decimal import Decimal
from snakeorm import SnakeQuery, count, sum_
gq = SnakeQuery(Car).group_by(Car.brand_id).having(count() > 1)
rows = session.select(gq, Car.brand_id, count(), sum_(Car.price))
# list[tuple[int, int, Decimal | None]] -> rows[0][1] is int, not Any
Para objetos tipados en vez de tuplas, session.annotate(query, ResultClass, **aggregates) agrupa
por la PK del modelo base.
sum_, avg, min_ y max_ devuelven un tipo NULABLE
Porque SQL lo dice: sobre cero filas devuelven NULL, no 0. Por eso te obligan a escribir
Decimal | None. count() sí es int: sobre cero filas devuelve 0.
Ordenar, paginar, deduplicar¶
SnakeQuery(User).order_by(User.email.desc())
SnakeQuery(User).order_by(User.nickname.asc().nulls_last())
SnakeQuery(User).limit(20).offset(40)
SnakeQuery(User).distinct()
NULLS FIRST/LAST no se escribe salvo que lo pidas
Sin .nulls_first() / .nulls_last() manda el defecto del motor, que no es el mismo en
todos. Inventar un comportamiento no pedido sería peor.
MySQL y MariaDB reciben otra escritura, y las mismas filas
Ninguno tiene la palabra NULLS LAST —los dos contestan ERROR 1064—, así que el ORM escribe
la forma portable, ORDER BY (nickname IS NULL) ASC, nickname ASC. Preguntas igual en los tres
motores y las filas vuelven en el mismo orden; lo que cambia es una clave de orden de más en el
SQL emitido.
count() rechaza distinct() y group_by()
No es una limitación: es un rechazo, y merece entenderse. Un COUNT(*) sobre una consulta que
deduplica o agrupa es un número DISTINTO del que devolvería esa consulta: filas, no filas
distintas; filas, no grupos. Contestar lo primero en silencio cuando pediste lo segundo es el
error que acaba dentro de un paginador enseñando mal el número de páginas durante meses.
limit() y offset() son la excepción, y ésos SÍ se tiran en silencio. Un COUNT está para
contestar «cuántos hay», así que la página que pediste se ignora a propósito — que es lo que hace
correcto poner session.count(base) al lado de session.all(base.limit(10)). Se dice en alto
porque el borde es afilado: session.exists(query.limit(0)) contesta True, sobre una
consulta que no puede devolver ni una fila.
users = SnakeQuery(User).distinct()
session.all(users) # the distinct rows
session.count(users) # refused: this number would not be theirs
# Ask for the count of what you actually want counted
distinct_emails = session.select(SnakeQuery(User).group_by(User.email), User.email)
Misma regla para all() con group_by(): una agrupación solo tiene respuesta como
proyección, así que va por select(), que es donde columnas y agregados pueden convivir.
Recorrer mucho sin cargarlo todo¶
all() construye una lista con todas las filas antes de devolver la primera. Sobre diez
millones de filas eso son diez millones de tuplas y diez millones de objetos en memoria, y a nadie
le hace falta ver más de una a la vez para exportarlas.
El resultado se queda en el motor (un cursor del servidor en Postgres y MySQL) y solo viajan chunk
filas por vez. Es perezoso: no se ejecuta nada hasta pedir la primera fila, así que cortar con un
break no paga el resto. En async es igual, con async for.
No convive con un include() de a-muchos
iterate() lanza si le pasas un include() de a-muchos o un prefetch. El select-in necesita
TODAS las raíces para lanzar su segunda consulta, y aquí las filas van saliendo de una en una.
Servírtelo exigiría materializarlas (que es lo que querías evitar) o una consulta por fila (un
N+1). Las dos traicionan lo que pediste, así que se dice en voz alta en vez de decidir por ti.
El include() de a-uno sí funciona: viaja en el mismo JOIN, fila a fila.
Escribir¶
user = User(email="ana@x.com", nickname="ana")
session.add(user) # INSERT; fills the generated id
user.nickname = "anita"
session.update(user) # UPDATE ... WHERE pk = ...
session.delete(user)
session.commit()
commit() cierra la transacción; rollback(), savepoint() y set_isolation() están en
transacciones.
add() rellena el id generado, pero no por el mismo camino en todos los motores. Donde hay
RETURNING (PostgreSQL y SQLite), el INSERT devuelve las columnas del servidor en la misma ida y
vuelta. MySQL/MariaDB no tiene RETURNING: ahí el id autoincremental sale de last_insert_id, que
se le pregunta a la conexión justo después de escribir. Para ti el resultado es idéntico —user.id
tiene valor— y la diferencia solo asoma en masa, que es la sección siguiente.
En masa. update_where recibe una secuencia de pares (columna, valor), no un dict:
session.add_all([u1, u2, u3])
session.update_where(
SnakeQuery(User).filter(User.active == False),
[(User.active, True)],
)
session.delete_where(SnakeQuery(User).filter(User.created_at < cutoff))
Una escritura masiva usa el FILTRO, y nada más
update_where/delete_where emiten el WHERE y ningún otro botón. Cualquier otro que hayas
puesto en la misma consulta se rechaza con SnakeUnsupportedFeature — limit(), offset(),
order_by(), only(), distinct(), for_update(). No es que se ignoren: tirar lo que pediste
contestaría otra pregunta sin decirlo.
a bulk DELETE only uses the filter (WHERE), and it does not emit limit(). Dropping what
you asked for would answer a different question without saying so: select the rows first
if you need those, then write by primary key.
limit() es el que duele, porque borrar por lotes es justo lo que uno teclea. Y sin filtro
ninguno también se rechaza: un DELETE sin WHERE se llevaría la tabla entera.
Sin RETURNING, add_all() NO rellena las PK autoincrementales
En MySQL/MariaDB las filas SÍ se insertan; lo que queda vacío es el id en memoria. Y no hay
con qué rellenarlo: last_insert_id habla de UNA fila, y que los ids de un INSERT múltiple
salgan consecutivos depende del servidor, no del ORM. Adivinar ahí sería escribir claves ajenas
en silencio.
Por eso el ORM avisa en vez de prohibir, una vez por motor. Si necesitas los ids después,
usa add() por instancia. Ése es todo el remedio.
La escritura masiva NO dispara señales
Un update_where es UNA sentencia SQL: no hay objetos que notificar. Si tienes señales
registradas, el ORM te lo avisa. Ver señales y triggers.
Siguiente: migraciones, o salta a consultas avanzadas si ya vas con soltura.