- "¿Qué tal si en Rust pudieras persistir datos de forma segura, escribir consultas complejas con facilidad y no tener que escribir ni una sola línea de SQL?"
- Rust-query es una librería desarrollada para hacer esto posible
Rust y la base de datos
- Las librerías de base de datos existentes en Rust carecen de garantías suficientes en tiempo de compilación, o son incómodas de usar y no tan intuitivas como SQL
- La base de datos cumple un papel importante al construir software resistente a conflictos y al dar soporte a transacciones atómicas
- SQL es el protocolo estándar para interactuar con bases de datos, pero es más adecuado para que lo genere una computadora; escribirlo manualmente es ineficiente
Introducción a Rust-query
- rust-query es una librería de consultas a bases de datos profundamente integrada con el sistema de tipos de Rust
- Está diseñada para permitir realizar operaciones de base de datos en Rust de forma nativa
Funciones principales y decisiones de diseño
- Alias de tabla explícitos: proporciona objetos dummy que representan esa tabla después de hacer joins (
let user = User::join(rows);)
- Seguridad ante null: los valores opcionales de la consulta se manejan con el tipo
Option de Rust
- Funciones de agregación intuitivas: soporta agregaciones intuitivas por fila sin
GROUP BY
- Recorrido de claves foráneas con seguridad de tipos: permite hacer joins implícitos fácilmente basados en claves foráneas (
track.album().artist().name())
- Búsqueda única con seguridad de tipos: consulta filas con una restricción de unicidad específica (devuelve
Option<Rating>)
- Esquema multiversión: permite verificar de forma declarativa todas las diferencias entre versiones del esquema
- Migraciones con seguridad de tipos: permite procesar filas usando código Rust arbitrario
- Manejo de conflictos únicos con seguridad de tipos: devuelve un tipo de error específico cuando hay conflicto con una restricción de unicidad
- Referencias a filas ligadas a la vida de la transacción: las referencias a filas solo son válidas mientras la fila exista
- ID de fila encapsulados por tipo: los números de fila no se exponen fuera de la API
Consultas e inserción de datos
Definición del esquema
#[schema]
enum Schema {
User {
name: String,
},
Story {
author: User,
title: String,
content: String,
},
#[unique(user, story)]
Rating {
user: User,
story: Story,
stars: i64,
},
}
use v0::*;
- El esquema se define usando la sintaxis
enum de Rust
- Las restricciones de clave foránea se crean indicando el nombre de otra tabla como tipo de columna
- Se agregan restricciones de unicidad usando el atributo
#[unique]
- El macro
#[schema] analiza la definición y genera el módulo v0
Inserción de datos
fn insert_data(txn: &mut TransactionMut<Schema>) {
let alice = txn.insert(User { name: "alice" });
let bob = txn.insert(User { name: "bob" });
let dream = txn.insert(Story {
author: alice,
title: "My crazy dream",
content: "A dinosaur and a bird...",
});
let rating = txn.try_insert(Rating {
user: bob,
story: dream,
stars: 5,
}).expect("no rating for this user and story exists yet");
}
- Las operaciones de inserción devuelven referencias a las filas recién insertadas
- Al insertar en tablas con restricciones de unicidad, es necesario usar
try_insert
try_insert devuelve un tipo de error específico en caso de conflicto
Consulta de datos
fn query_data(txn: &Transaction<Schema>) {
let results = txn.query(|rows| {
let story = Story::join(rows);
let avg_rating = aggregate(|rows| {
let rating = Rating::join(rows);
rows.filter_on(rating.story(), &story);
rows.avg(rating.stars().as_float())
});
rows.into_vec((story.title(), avg_rating))
});
for (title, avg_rating) in results {
println!("story '{title}' has avg rating {avg_rating:?}");
}
}
rows representa el conjunto actual de filas dentro de la consulta
- Se usan
aggregate para realizar operaciones de agregación
- Los resultados pueden recopilarse en un vector de tuplas o structs
Evolución del esquema y migraciones
- Al crear una nueva versión del esquema, se usa el atributo
#[version]
Agregar una nueva versión del esquema
#[schema]
#[version(0..=1)]
enum Schema {
User {
name: String,
#[version(1..)]
email: String,
},
// ... resto del esquema ...
}
use v1::*;
Migración de datos
- Las migraciones se verifican por tipos tanto contra el esquema anterior como contra el nuevo
- Es posible procesar los datos de las filas con código Rust arbitrario (usando
map_dummy)
let m = m.migrate(v1::update::Schema {
user: Box::new(|old_user| {
Alter::new(v1::update::UserMigration {
email: old_user
.name()
.map_dummy(|name| format!("{name}@example.com")),
})
}),
});
Cierre
- rust-query propone un nuevo enfoque para interactuar con bases de datos relacionales en Rust:
- verificación en tiempo de compilación
- consultas combinables con Rust
- soporte para la evolución del esquema mediante verificación de tipos
- Actualmente usa SQLite como único backend, y resulta adecuado para desarrollar aplicaciones experimentales
- Se agradece la retroalimentación a través de issues en GitHub
2 comentarios
| Esto es adecuado para que lo genere una computadora, y para una persona escribirlo directamente sería ineficiente.
Desde la perspectiva de alguien que está haciendo esas "próximas generaciones" que solo existen en Corea, donde se asignan más de 100 desarrolladores.
Muy interesante.
De hecho, la mayoría de los desarrolladores asignados son expertos en SQL, ¿no?
Comentarios de Hacker News
La preocupación con los esquemas definidos por la aplicación es que terminan siendo validados por el sistema equivocado. La base de datos es la autoridad sobre el esquema, y todas las demás capas de la aplicación hacen suposiciones basadas en ella. SQLx de Rust genera structs a partir de los tipos de la base de datos y los valida en tiempo de compilación, pero no garantiza que sean los mismos tipos que en la base de datos de producción. Si diseñas una consulta en un Postgres v15 local y en producción ejecutas Postgres v12, puedes encontrarte con errores en tiempo de ejecución. Los esquemas definidos por la aplicación dan una falsa sensación de seguridad y además imponen trabajo extra a los ingenieros.
SQL no es perfecto, pero sí tiene varias ventajas. La mayoría de la gente conoce lo básico de SQL, y la documentación de bases de datos como PostgreSQL está escrita en SQL. Las herramientas externas también usan SQL, y cambiar una consulta no requiere una etapa de compilación costosa. SQLx evita los problemas de los sistemas de tipos que aumentan el tiempo de compilación al verificar los tipos de los parámetros y dejar que la propia base de datos valide la consulta. En bases de datos nuevas podría imponerse un mejor lenguaje de consultas, pero en las bases de datos SQL existentes, SQLx es una mejor opción.
Hay quienes no están de acuerdo con la idea de que SQL debería ser escrito por computadoras. SQL es un lenguaje de alto nivel, incluso más alto nivel que Python o Rust. SQL fue diseñado para ser legible y fácil de usar, y durante la compilación se transforma en varios procedimientos. SQL está en el cuello de botella del desarrollo web, donde ocurren las mutaciones de estado. Precisamente por ser un lenguaje de alto nivel, SQL es difícil de optimizar. SQL es deuda técnica, pero usar SQL es 10 veces más eficiente que desarrollar una API más adecuada.
Hay opiniones positivas sobre explorar el acceso seguro a bases de datos con tipos en Rust. Las bibliotecas existentes no ofrecen garantías en tiempo de compilación y resultan tan verbosas o incómodas como SQL.
dieselsí ofrece garantías en tiempo de compilación. En el debate entre ORM y no ORM, se prefiere un query builder con seguridad de tipos, ydieselentra en esa categoría.rust-queryparece inclinarse más hacia el lado de un ORM completo.Hay quien opina que el enfoque de vincular el esquema con los tipos de datos es interesante. En el ejemplo, no es intuitivo que no exista un enum
Schema. Si estuviera definido dentro de una macro, sería más claro.Resulta confuso que la API de la biblioteca no exponga los números reales de fila. En un servidor web, debería ser posible pasar el ID de fila de los datos para que el frontend pueda referenciar y modificar esos datos con otra solicitud.
Hay una postura parcialmente de acuerdo con la idea de que SQL debería ser escrito por computadoras, pero SQL no es el lenguaje más conveniente para que lo escriban los generadores de código. Una optimización simple del plan puede cambiar por completo la estructura de la consulta. La propuesta de SQL pipe de Google mejora un poco esto, pero sigue teniendo los mismos problemas de un lenguaje de consultas nuevo.
Hay quien ha usado SeaQuery, pero considera que la documentación no es suficiente para generar consultas avanzadas. Las consultas fuertemente tipadas pueden ralentizar el proceso de desarrollo, así que está considerando volver a las prepared statements tradicionales y al binding de valores.
Las migraciones mediante manipulación a nivel de filas individuales pueden ser extremadamente lentas. Por ejemplo, en una tabla con mil millones de filas, una sentencia
UPDATEcomún puede tardar hasta una hora. Las actualizaciones fila por fila tardarían aún más.