- « Et si l’on pouvait stocker des données de manière persistante en toute sécurité dans Rust, écrire facilement des requêtes complexes, sans rédiger une seule ligne de SQL ? »
- Rust-query est une bibliothèque développée pour concrétiser cette idée
Rust et les bases de données
- Les bibliothèques de base de données existantes pour Rust manquent soit de garanties à la compilation, soit sont fastidieuses à utiliser et moins intuitives que SQL
- Les bases de données jouent un rôle important pour construire des logiciels résistants aux conflits et pour prendre en charge les transactions atomiques
- SQL est le protocole standard pour interagir avec une base de données, mais il convient mieux à une génération par ordinateur qu’à une écriture manuelle, inefficace pour les humains
Présentation de rust-query
- rust-query est une bibliothèque de requêtes de base de données profondément intégrée au système de types de Rust
- Elle est conçue pour permettre d’effectuer des opérations sur une base de données de manière native dans Rust
Principales fonctionnalités et choix de conception
- Alias de table explicites : fournit un objet factice représentant la table après une jointure (
let user = User::join(rows);)
- Sécurité vis-à-vis de
null : les valeurs optionnelles d’une requête sont gérées avec le type Option de Rust
- Fonctions d’agrégation intuitives : prise en charge d’agrégations intuitives au niveau ligne sans
GROUP BY
- Navigation type-safe des clés étrangères : exécution facile de jointures implicites basées sur les clés étrangères (
track.album().artist().name())
- Recherche unique type-safe : récupération d’une ligne avec une contrainte d’unicité donnée (retourne
Option<Rating>)
- Schéma multi-version : permet de vérifier de façon déclarative toutes les différences entre versions du schéma
- Migrations type-safe : permet de traiter les lignes avec du code Rust arbitraire
- Gestion type-safe des conflits d’unicité : retourne un type d’erreur spécifique en cas de conflit sur une contrainte d’unicité
- Références de ligne liées à la durée de vie de la transaction : les références de ligne ne sont valides que tant que la ligne existe
- ID de ligne encapsulés par type : les numéros de ligne ne sont pas exposés hors de l’API
Requêtes et insertion de données
Définition du schéma
#[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::*;
- Définition du schéma à l’aide de la syntaxe
enum de Rust
- Les contraintes de clé étrangère sont créées en indiquant le nom d’une autre table comme type de colonne
- Ajout de contraintes d’unicité avec l’attribut
#[unique]
- La macro
#[schema] analyse la définition et génère le module v0
Insertion de données
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");
}
- Les opérations d’insertion renvoient une référence vers la ligne nouvellement insérée
- Pour insérer dans une table avec contrainte d’unicité, il faut utiliser
try_insert
try_insert retourne un type d’erreur spécifique en cas de conflit
Requête de données
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 représente l’ensemble courant de lignes dans la requête
aggregate permet d’effectuer des opérations d’agrégation
- Les résultats peuvent être collectés dans un vecteur de tuples ou de structures
Évolution du schéma et migrations
- Lors de la création d’une nouvelle version du schéma, on utilise l’attribut
#[version]
Ajouter une nouvelle version du schéma
#[schema]
#[version(0..=1)]
enum Schema {
User {
name: String,
#[version(1..)]
email: String,
},
// ... reste du schéma ...
}
use v1::*;
Migration des données
- Les migrations sont vérifiées par le système de types pour l’ancien et le nouveau schéma
- Les données de ligne peuvent être traitées avec du code Rust arbitraire (
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")),
})
}),
});
Conclusion
- rust-query propose une nouvelle approche pour interagir avec des bases de données relationnelles dans Rust :
- vérification à la compilation
- requêtes composables avec Rust
- prise en charge de l’évolution du schéma via la vérification des types
- Il utilise actuellement SQLite comme unique backend et convient au développement d’applications expérimentales
- Les retours sont les bienvenus via les issues GitHub
2 commentaires
| Il est plus adapté à une génération par ordinateur et inefficace à écrire directement à la main.
Du point de vue de quelqu’un qui se retrouve à faire cette « nouvelle génération » propre à la Corée, avec plus de 100 développeurs mobilisés.
C’est très intéressant.
En réalité, la plupart des développeurs mobilisés sont des experts SQL, non ?
Commentaires sur Hacker News
La préoccupation concernant les schémas définis par l’application vient du fait qu’ils sont validés par le mauvais système. La base de données fait autorité sur le schéma, et toutes les autres couches de l’application en déduisent leurs hypothèses. SQLx pour Rust génère des structures à partir des types de la base de données et les valide à la compilation, mais ne garantit pas qu’ils correspondent aux types de la base de production. Si vous concevez une requête sur un Postgres v15 local et exécutez Postgres v12 en production, vous pouvez avoir une erreur à l’exécution. Les schémas définis par l’application donnent un faux sentiment de sécurité et imposent du travail supplémentaire aux ingénieurs.
SQL n’est pas parfait, mais il a quelques avantages. La plupart des gens connaissent les bases de SQL, et la documentation de bases comme PostgreSQL est rédigée en SQL. Les outils externes utilisent aussi SQL, et modifier une requête ne nécessite pas une étape de compilation coûteuse. SQLx évite les problèmes liés aux systèmes de types qui allongent le temps de compilation en vérifiant les types des paramètres et en laissant la base de données elle-même valider la requête. Sur de nouvelles bases de données, un meilleur langage de requête peut l’emporter, mais sur les bases SQL existantes, SQLx est un meilleur choix.
Certains s’opposent à l’idée que SQL devrait être écrit par des ordinateurs. SQL est un langage de haut niveau, plus haut niveau encore que Python ou Rust. SQL a été conçu pour être lisible et facile à utiliser, puis il est transformé en plusieurs procédures à la compilation. SQL se situe au goulot d’étranglement du développement web, là où surviennent les mutations d’état. Comme SQL est un langage de haut niveau, il est difficile à optimiser. SQL est une dette technique, mais l’utiliser est 10 fois plus efficace que de développer une API plus appropriée.
Certains se disent heureux de voir des explorations autour du typesafe-db-access en Rust. Les bibliothèques existantes n’offrent pas de garanties à la compilation et sont verbeuses ou maladroites, comme SQL. diesel fournit des garanties à la compilation. Dans le débat ORM vs non-ORM, certains préfèrent les query builders typés, et diesel entre dans cette catégorie. Rust-query semble s’orienter davantage vers un ORM complet.
Certains trouvent intéressante l’approche qui relie schéma et types de données. Dans l’exemple, l’absence d’une énumération Schema n’est pas intuitive. Ce serait plus clair si elle était définie dans la macro.
Le fait que l’API de la bibliothèque n’expose pas les vrais numéros de ligne est jugé déroutant. Dans un serveur web, il faut pouvoir transmettre des identifiants de ligne avec les données afin que le frontend puisse y faire référence et les modifier dans d’autres requêtes.
Certains sont partiellement d’accord avec l’idée que SQL devrait être écrit par des ordinateurs, mais SQL n’est pas le langage le plus pratique à générer pour un générateur de code. Une simple optimisation du plan peut complètement changer la structure d’une requête. La proposition SQL pipe de Google améliore un peu les choses, mais conserve malgré tout les problèmes d’un nouveau langage de requête.
Certains utilisent SeaQuery, mais estiment que la documentation n’est pas suffisante pour générer des requêtes avancées. Les requêtes fortement typées peuvent ralentir le processus de développement, au point d’envisager un retour aux prepared statements classiques avec value binding.
Les migrations via des manipulations au niveau de chaque ligne peuvent être très lentes à exécuter. Par exemple, sur une table contenant 1 milliard de lignes, une instruction
UPDATEclassique peut prendre jusqu’à une heure. Des mises à jour ligne par ligne prendraient encore plus de temps.