Límites conocidos¶
Parte del contrato, no una lista de disculpas.
Del mecanismo de tipos¶
type[Brand]es llamable. El checker aceptaCar.brand(). No hace nada útil y no hay forma de prohibirlo con descriptores recursivos.==sobre una expresión de clase devuelveSnakeCondition, nobool. Consecuencia:assert Car.price == 100siempre pasa (es truthy).- La tupla
field_specifiersestá duplicada cinco veces. Lo impone PEP 681. Un test la mantiene sincronizada; eliminarla no se puede.
De las consultas¶
- El streaming no convive con un
include()de a-muchos.session.iterate()SÍ existe (síncrono y asíncrono) y recorre el resultado sin materializarlo — cursor del servidor en Postgres y MySQL,fetchmanyen SQLite. Con lo que lanza es con uninclude()de a-muchos o un prefetch: el select-in necesita TODAS las raíces para su segunda consulta, y en streaming no existen. Elinclude()de a-uno sí vale (viaja en el mismo JOIN). Todo lo demás —all(),first()— sí materializa el resultado entero en memoria. only()/defer()no se combinan coninclude(). El emisor con includes construye su lista de columnas por segmentos; meterle un subconjunto es otra pieza. Se RECHAZA, no se ensancha en silencio, y el mensaje lo dice.session.select()proyecta CUATRO columnas como mucho. Las sobrecargas se paran enc4, así que una quinta no es una tupla más laxa: esNo overload variant of "select" matches, en tiempo de compilación. Parte la proyección en dos selects, que además es la forma que se sigue leyendo. Ensancharlo es una línea por aridad en un fichero que ya lleva cuatro.annotatevalida en runtime que la query sea del mismo modelo que el@snake_result, no en el checker.- Los CHECK no admiten subconsultas (
EXISTS,IN (SELECT ...)). Se rechaza al declararlos — PostgreSQL tampoco los admite ahí. in_()no trocea por el tope de marcadores.add_all()y el select-in deinclude()sí;in_()emite un marcador por valor, contra 65.535 en Postgres y MySQL y 32.766 en SQLite. Revienta en el driver al ejecutar, no al construir. Trocea a mano unin_()grande.- Un
INcompuesto tiene DOS techos, y el ORM solo guarda el que puede saber exacto. Los parámetros sonanchura × nº de claves, y pasarse del límite declarado del motor se rechaza antes de emitir, nombrando las dos cifras. PostgreSQL se para ANTES y por otro motivo: medido sobre 17, rechaza alrededor de ocho mil CLAVES constack depth limit exceededa cualquier anchura, que es la recursión del parser y no los 65.535 del protocolo. Esa cifra se mueve con elmax_stack_depthdel servidor, así que el ORM no se adelanta: rechazar en un número copiado de la configuración de un servidor prohibiría en uno afinado lo que allí la base de datos permite. Trocea la lista de claves a mano y combina los resultados. - Las escrituras masivas no disparan señales.
update_where/delete_whereson una sentencia SQL; no hay instancias que notificar. El ORM avisa si el modelo tiene señales registradas. DISTINCT ONestá fuera de alcance.distinct()emite elDISTINCTestándar sobre el SELECT entero, nunca elDISTINCT ON (...)de Postgres. Es una extensión de un solo motor, así que si algún día entra, entra por el catálogoCapcon unNopeen los otros dos —no como un método que funciona en un motor de tres y se calla en los demás—. Para una consulta solo de Postgres hoy,session.raw.
De los números y el JSON¶
- Un
dictenJSONBse NORMALIZA. Reordena claves, quita duplicadas y normaliza números (100.0==100). Es la naturaleza dejsonb. Para texto exacto:json_storage=SnakeJsonStorage.JSON. json_get(as_type=...)solo admitestr,int,floatobool. UnDecimalo undatetimelanzanSnakeUnsupportedFeature.- Una clave JSON tiene que ser un identificador simple. Se emite DENTRO de la sentencia, no como
parámetro, así que una clave con espacio o punto se rechaza con
SnakeValueError. - Un
intmayor que ±9,2·10¹⁸ no cabe. El defecto esBIGINT(64 bits). Más allá, usaDecimal(mapea aNUMERIC, precisión arbitraria). Lascalese valida al escribir (SnakeValueError). - Una columna
datetimeno tiene forma por defecto: la eliges tú.snake_datetime()sobreSnakeColumn[datetime]es una HORA DE PARED (TIMESTAMP, sin zona);snake_datetimetz()sobreSnakeColumn[SnakeUtc]es un INSTANTE (TIMESTAMPTZ). Undatetimedeclarado con unsnake_column()pelado se rechaza al importar, y mezclar las dos —un valor con zona en una columna de hora de pared, uno naive en una de instante— lanzaSnakeValueErroral escribir. El ORM nunca tira untzinfoen silencio. - Una columna
TIMESTAMPTZsolo admite UTC. Guarda el instante, no el offset:14:30+02:00volvería de Postgres como12:30+00:00y de SQLite como14:30+02:00, así que.hourdependería del motor. Conviértelo tú conto_utc(value).
De SQLite¶
- No hay esquemas con nombre. Los "esquemas" son bases adjuntas (
ATTACH); elschema=se ignora al emitir. - No hay
ALTER TABLE ADD CONSTRAINT, y hay dos desenlaces, no uno. Los CHECK y las FK van dentro delCREATE TABLE, así que cambiar uno en una tabla que ya existe pasa por rehacerla. Una migración autodetectada LO HACE: el diff colapsa el cambio en un únicoRebuildTable, y SQLite lo deletrea entero (PRAGMA defer_foreign_keys = ON, crear la forma nueva al lado, copiar las filas, tirar la tabla vieja, renombrar). Lo que sí para y lo dice es un plan escrito a mano: unAddChecko unAddForeignKeyle pregunta aCap.ADD_CONSTRAINT, que aquí esNope, y el plan lo rechaza nombrando la salida.UNIQUEsí se traduce (a índice único). - Reconstruir es la única forma de tirar una columna que sujeta una clave ajena. SQLite
contesta
unknown column ... in foreign key definition, así queCap.DROP_COLUMN_CASCADES_FKesNopey el plan para elDropColumnnombrando la clave. A diferencia de MySQL, poner unDropForeignKeydelante NO lo desbloquea: este motor tampoco tieneDROP CONSTRAINT, así que esa operación para enCap.ADD_CONSTRAINT— medido, una migración autodetectada que quita una relación y su columna se niega en las dos operaciones. La tabla hay que reconstruirla a mano, con unRunSQL. - No hay
ALTER COLUMN. Cambiar tipo o nulabilidad de una columna existente no existe aquí. - No hay
CREATE OR REPLACE VIEWni funciones almacenadas. Lo primero se reescribe comoDROP+CREATE; lo segundo se para en el plan. - Los
COMMENT ONse omiten al crear, y se rechazan al alterar. UnCREATE TABLEque llevedb_commentemite la tabla y deja fuera los comentarios; unAlterTableComment—una operación cuyo único cometido es cambiar uno— para en el plan conCap.COMMENTS. No hay nada que cambiar en un motor que no guarda ninguno. - No hay
SELECT ... FOR UPDATE. - No guarda tamaños ni precisión. Su sistema es de afinidades:
SMALLINT/INTEGER/BIGINTson el mismoINTEGER;VARCHAR(50)/TEXT/CHAR(10)el mismoTEXT.int_size,max_lengthyprecision/scalelos honra Postgres; aquí se aceptan por portabilidad pero no se imponen. - Un
Decimalse ordena como TEXTO. Se guarda comoTEXTpara no perder exactitud, así queORDER BYes lexicográfico ('100.00'antes que'99.00'). Para orden numérico:ORDER BY CAST(price AS REAL)a mano. - Tampoco hay arrays. Igual que en MySQL: una
list[T]se guarda como JSON en una columnaTEXTy vuelve siendo la misma lista, pero no se puede consultar dentro de ella desde SQL. - Un
floatNaN vuelve comoNULL. SQLite no sabe guardarlo (Inf/-Infsí). Postgres sí. - No hay timeout de sentencia en el servidor.
TimeoutDriverse niega a envolver un driver de SQLite (SnakeDialectError):busy_timeoutespera un cerrojo, no hace nada con una consulta lenta.
De MySQL / MariaDB¶
-
Tampoco hay funciones almacenadas.
Cap.STORED_FUNCTIONSesNopeaquí igual que en SQLite, y por un motivo distinto: el cuerpo de una rutina es SQL crudo y reemplazarla depende deCREATE OR REPLACE FUNCTION, que MariaDB acepta y MySQL rechaza de plano. Un solo dialecto sirve a los dos, así que no puede prometer lo que solo uno cumple.@snake_functiones solo PostgreSQL. -
No hay
RETURNING.add()recupera el PK autoincremental (lastrowid);add_all()de un lote NO rellena los PKs. No es en silencio: el ORM lanza unSnakeWarninguna vez por motor, y las filas SÍ se insertan — lo que queda sin valor es eliden memoria. Si ese id iba a ser la clave foránea de la fila siguiente, la guarda de valor obligatorio lanza unSnakeValueErrorque nombra la columna. Si necesitas los ids, inserta conadd()uno a uno, o ramifica consession.dialect.supports_returning. - No hay instantes nativos:
snake_datetimetz()cae a TEXT. El único tipo con zona de MySQL (TIMESTAMP) topa en el año 2038 yDATETIMEno es tz-aware, así que unSnakeUtcse guarda como texto ISO-8601. El instante vuelve entero, huso incluido; lo que se pierde es que el motor lo trate como una fecha al ordenar, comparar y operar. Va declaradoDegraded, así que la sesión avisa una vez. Unsnake_datetime()(hora de pared) SÍ es unDATETIMEnativo, y su precisión declarada se honra (snake_datetime(precision=3)→DATETIME(3)), con un tope de 6 dígitos. - Un
Decimaltiene que declarar su precisión. Aquí no hay decimal sin límite: unDECIMALpelado esDECIMAL(10,0), así que9.99se guarda como10—medido—. Se rechaza al emitir en vez de degradarse porque elNUMERICde Postgres es de precisión arbitraria y el mismo modelo no pierde nada allí. Declarasnake_decimal(precision=..., scale=...)y es portable en los tres. - Un
DECIMALpara en 65 dígitos y 30 decimales. Postgres llega a 1000, así que unsnake_decimal(precision=500, scale=2)es válido allí e imposible aquí: se rechaza al emitir el DDL, nombrando el motor. Son dos topes distintos —DECIMAL(40,35)tiene la precisión dentro del límite y la escala fuera. - No hay tipo para
timedeltani arrays. No se rechaza ninguno de los dos: untimedeltase guarda comoTEXTy unalist[T]como JSON en una columnaTEXT, y los dos vuelven siendo lo que eran.Cap.INTERVALyCap.ARRAYSsonDegraded, noNope— lo que se pierde es que el motor sume una duración a una fecha o consulte DENTRO del array.boolesTINYINT(1)yUUIDesCHAR(36)(round-trippean, no son nativos). - No hay índices parciales, y el mismo
Nopetiene DOS destinos. ElWHEREno forma parte delCREATE INDEXde MySQL, así queCap.PARTIAL_INDEXESesNope— y lo que pasa después depende del índice. Un índice de BÚSQUEDA declarado conwhere=se degrada: se le quita elWHEREy se crea sobre la tabla entera. Encuentra las mismas filas y cuesta más espacio, y la sesión lo dice una vez. Un índice UNIQUE parcial para el plan: ensancharUNIQUE(email) WHERE deleted_at IS NULLaUNIQUE(email)prohíbe filas que el dominio permite, que es otro esquema y no uno más lento. O quitas elunique=True, o expresas la regla con una columna generada más unUNIQUEnormal encima en unRunSQL. - Tirar la clave antes es lo que libera una columna que sujeta una clave ajena. InnoDB necesita el índice sobre el
que se apoya la clave y contesta el error
1553, así queCap.DROP_COLUMN_CASCADES_FKesNopey el plan para elDropColumnnombrando la clave. La salida está una operación antes: unDropForeignKeydelante delDropColumn, que es justo lo que ya emite la migración autodetectada — una escrita a mano tiene que decirlo, y decirlo es además lo que permite al rollback devolver la clave. - El DDL no es transaccional. Cada
ALTER/CREATEhace commit implícito: si el paso 3 falla, 1 y 2 quedan aplicados. El runner lo avisa. Migra en pasos pequeños y reversibles. - Un "schema" ES una base de datos. No hay esquemas con nombre dentro de una, así que
@snake_model(schema=...)no aplica aquí. - Un comentario es una cláusula, y cambiar el de una COLUMNA reescribe la columna. MySQL no
tiene
COMMENT ON—es un error de sintaxis—, pero sí guarda comentarios: el de la tabla va dentro delCREATE TABLE(... COMMENT = '...') y se cambia conALTER TABLE ... COMMENT = '...', y el de una columna vive en la definición de esa columna. Esa primera mitad es una GRAFÍA, y el dialecto la traduce, así que undb_commentya no se descarta aquí. La segunda mitad es la razón de queCap.COMMENTSseaDegradedy noFull: no existe sentencia que cambie el comentario de UNA columna, así que el ORM emite unMODIFY COLUMNcon la definición entera reescrita a partir de tu modelo. Todo lo que el modelo declara sobrevive; lo que la base guarda y el modelo no describe —una collation, unON UPDATE CURRENT_TIMESTAMP, una expresión generada— no. Ojo además a que en este motor un comentario vacío y ningún comentario son el mismo valor. TimeoutDriveremiteSET SESSION max_statement_time, que es la variable de MariaDB. El MySQL de Oracle la rechaza con1193 Unknown system variableal envolver el driver.
De la introspección¶
- El round-trip no es biyectivo.
TEXT,VARCHAR(50)yCHAR(10)vuelven todos comostr. Es correcto, no un fallo. - Lo que el ORM no sabe expresar se avisa, no se representa. Triggers, tipos exóticos e índices por expresión salen como comentario y aviso por consola.
De las migraciones¶
- Los renombrados no se detectan solos. El diff ve un
DROPy unADD; sugiere unRenameColumnpor consola, pero no decide. Adivinar pierde datos. - Un squash para cuando cruza una migración de datos.
RunPython/RunSQLmutan filas, así que colapsarlas exigiría EJECUTARLAS, y un squash no toca la base de datos. Colapsa el tramo que llega hasta ella y deja el resto del histórico como está. - Un squash no borra las migraciones que reemplaza, y es a propósito. Puede haber una base con solo algunas de ellas aplicadas, y los ficheros originales son los que le permiten ponerse al día. Borrarlos es una decisión de una persona, más adelante.
- Alternar
int↔ autoincremental SÍ se emite, y en Postgres es la secuencia escrita entera.BIGSERIALno es un tipo: es un atajo deCREATE TABLE, y unALTER ... TYPE BIGSERIALrecibe del servidortype "bigserial" does not exist. Así que la migración emite lo que el atajo SIGNIFICA —CREATE SEQUENCE,SET DEFAULT nextval(...),ALTER SEQUENCE ... OWNED BYy unsetvalalMAXactual para que no se repita ninguna clave— y el inverso quita el default y la secuencia. MySQL lo lleva dentro delMODIFY COLUMN, y exige que la columna sea clave (1075 there can be only one auto column and it must be defined as a key). SQLite para en el plan, conCap.ALTER_COLUMN. RebuildTablesolo colapsa un cambio de constraints PURO, y en SQLite no siempre basta. Cuando lo único que ha cambiado de una tabla son sus CHECK y sus claves ajenas, el diff emite un únicoRebuildTableen vez deAddCheck/AddForeignKeysueltos, y cada motor lo deletrea a su manera — Postgres y MySQL con elALTERmínimo, SQLite con la reconstrucción entera. De ahí salen dos límites. Primero, el colapso exige que los constraints sean la ÚNICA diferencia: añade una columna en el mismo paso y la tabla se va por el camino normal, porque un par de snapshots que discrepara en una columna se aplicaría en SQLite (que recrea desdeafter) y no en Postgres (cuyoALTERmínimo no emite nada para eso) —RebuildTablese niega a construirse así y nombra lo que discrepa. Segundo, la reconstrucción llevaPRAGMA defer_foreign_keys = ON, que mueve el veredicto alCOMMIT; eso basta para una tabla a la que no apunta nadie, y NO basta cuando la clave de otra tabla nombra a la que se está rehaciendo — elDROP TABLEsube el contador diferido, nada lo baja, y elCOMMITse niega. Un fallo ruidoso y atómico, no un esquema corrupto.RunPythonsinbackwardno se puede deshacer. El rollback lanza un error que dice qué añadir.- El runner asíncrono no ejecuta migraciones de datos.
RunPythonrecibe una sesión síncrona.
De la herencia polimórfica¶
- Las columnas propias de una hija tienen que admitir
NULL. La tabla es una sola y existen también en las filas hermanas. Se comprueba al declarar. - Un discriminador desconocido se hidrata como la clase base. Se pierden los campos de la subclase; no la fila.
- No hay herencia joined-table, y está DESCARTADA, no aplazada. Una tabla por subclase unidas por
la clave primaria —Django la llama herencia multi-tabla; SQLAlchemy,
joined table inheritance— no existe ni está prevista. La tabla única con discriminador ya cubre el polimorfismo que el dominio pide, y su precio es la regla delNULLde arriba: las columnas propias de una hija existen también en las filas de sus hermanas. La joined-table recuperaría esas columnas y cobraría a cambio un JOIN por lectura, y sería una SEGUNDA estrategia de herencia atravesando el compilador, el linker, el emisor y el hidratador. Si algún día un dominio se le queda grande a la tabla única, el argumento llegará con él.
De la declaración y las sesiones¶
- Un destino de relación solo importable bajo
TYPE_CHECKINGrompesnake_link(). El linker usaget_type_hints(evalúa en runtime): sale unNameErrorcrudo. Declara los modelos a nivel de módulo, importables en runtime. - El
__exit__de la sesión no cierra el driver (por diseño). Hace commit/rollback al salir; el driver es inyectado. Para devolverlo al pool,session.close()(sync y async). - En Postgres,
TimeoutDriverfijastatement_timeoutconSET, noSET LOCAL. Unrollbacklo revierte. Para un timeout robusto, ponlo en el DSN (options='-c statement_timeout=...'). - El nombre de rutina de
call()/execute_procedure()se VALIDA, no se cita. Los argumentos viajan parametrizados; el nombre no puede — ningún motor acepta un marcador donde va un identificador —, así que llega al SQL tal cual y cada parte separada por puntos tiene que ser un identificador simple (una letra o_, y luego letras, dígitos,_o$). Cualquier otra cosa es unSnakeValueErrorantes de que exista SQL. No se cita a propósito: unCREATE FUNCTION CalculatePayrollsin comillas queda en el catálogo de PostgreSQL comocalculatepayroll, así que citar la llamada dejaría de encontrarlo. Para un nombre que de verdad necesite comillas, escribe la sentencia conraw(...).
Una clave primaria de texto necesita longitud en MySQL¶
snake_str(primary_key=True) sin max_length se convierte en TEXT, y MySQL y MariaDB no admiten
una columna TEXT en una clave — una clave necesita longitud y TEXT no la tiene. El ORM se niega
a emitir ese CREATE TABLE y dice qué columna y qué argumento:
No elige una longitud por ti, y eso es lo importante, no un olvido. Un VARCHAR(255) por
defecto haría que la tabla se creara y metería en el esquema un límite que nadie decidió; el día que
un valor lo superase, el dato se truncaría en vez de rechazarse.
Solo se rechaza la CLAVE PRIMARIA. Un UNIQUE o un índice sobre una cadena sin longitud lo acepta
MariaDB y se deja en paz, porque prohibir lo que el motor permite es otra forma de estar mal.
Lo que directamente no hay¶
- Identity map. Dos consultas a la misma fila dan dos objetos.
a == besTrue(por PK), peroa is besFalse. - Lazy loading. A propósito: acceder a una relación no cargada lanza. Es lo que hace el N+1 imposible por defecto.
- Búsqueda full-text. Con
session.raw, pero sin API tipada. - Operadores JSON de contención y ruta.
json_get()lee una clave con cast declarado;@>,?y compañía no tienen API tipada. Los tres motores usan tres mecanismos distintos para ellos. - Operadores de array. Una columna
list[T]va y vuelve en los tres —nativa en PostgreSQL, texto JSON en los otros—, pero consultar DENTRO no tiene API. Eso es lo queCap.ARRAYSllama degradado. noticesystatusmessagedel servidor. El Protocol del driver no expone el cursor, que es lo que permite que un dialecto sirva a todos; el precio es que el aviso de un trigger es invisible.- Una página de error propia del ORM. Cuando revienta dentro de un framework, la página que sale es la del framework, y no sabe nada del ORM.
- Motores más allá de PostgreSQL, MySQL/MariaDB y SQLite. Esos tres son de primera clase, síncrono y asíncrono. Para un cuarto —SQL Server, Oracle— la costura está lista y los ficheros no están escritos.
Volver a cómo funciona el tipado o a la arquitectura.