#+title: Inside SQLx — construire du SQL vérifié à la compilation à partir de briques Lego
#+author: ChiefKemist
#+date: <2026-07-25 sam.>
- Introduction
Nous construisons un SQLx miniature pour comprendre certaines de ses fonctionnalités principales et comment elles pourraient être conçues. Nous explorerons en même temps certaines fonctionnalités de Rust et de son écosystème, comme les macros et les espaces de travail Cargo. L'objectif ultime est de comprendre, lorsqu'il s'agit de Rust et de SQL, d'où proviennent réellement les garanties de requête à la compilation.
Nous commençons avec du SQL que Rust traite comme une chaîne de caractères non vérifiée, et nous terminons avec ceci :
#+begin_src rust :tangle no
let users = toy_sqlx::query!(
&connection,
"SELECT id, email FROM users WHERE active = ?1",
true,
)?;
#+end_src
où un seul appel de macro fera contribuer trois systèmes à la correction :
- SQLite prouve que le SQL est valide pour le schéma de build.
- La macro transforme les preuves de SQLite en code Rust.
- rustc vérifie les champs générés et les structures de l'application.
Nous ne clonerons pas SQLx, nous exposons plutôt le plus petit pipeline utile de vérification à la compilation pour imposer un SQL correct dans le code Rust.
- À propos des macros
#+begin_quote
« Le langage entier est là tout le temps. Il n'y a pas de distinction réelle entre le temps de lecture, le temps de compilation et le temps d'exécution. Vous pouvez compiler ou exécuter du code pendant la lecture, lire ou exécuter du code pendant la compilation, et lire ou compiler du code au moment de l'exécution.
Exécuter du code au moment de la lecture permet aux utilisateurs de reprogrammer la syntaxe de Lisp ; exécuter du code au moment de la compilation est la base des macros ; compiler au moment de l'exécution est la base de l'utilisation de Lisp comme langage d'extension dans des programmes comme Emacs ; et lire au moment de l'exécution permet aux programmes de communiquer en utilisant des s-expressions, une idée récemment réinventée sous le nom de XML. »
#+end_quote
— Paul Graham, /Revenge of the Nerds/, dans /Hackers & Painters/,
section « What Made Lisp Different », point 9.
Référence : [[https://paulgraham.com/icad.html][Paul Graham — Revenge of the Nerds]]
- Échafaudage : trois crates
Crates de l'espace de travail Cargo : toy-sqlx, toy-macro, toy-demo
#+name: toy-sqlx/Cargo.toml
#+begin_src toml :tangle toy-sqlx/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-sqlx"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
toy-sqlx-macros = { path = "../toy-sqlx-macros" }
rusqlite = { version = "0.39", features = ["bundled"] }
#+end_src
#+name: toy-macro/Cargo.toml
#+begin_src toml :tangle toy-sqlx-macros/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-sqlx-macros"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
rusqlite = { version = "0.39", features = ["bundled", "column_metadata"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
syn = { version = "2", features = ["full"] }
#+end_src
#+name: toy-demo/Cargo.toml
#+begin_src toml :tangle toy-demo/Cargo.toml :mkdirp yes :comments no
[package]
name = "toy-demo"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[features]
fail-literal = []
fail-invalid-sql = []
fail-arity = []
fail-query-as-name = []
fail-query-as-type = []
fail-expression-metadata = []
fail-unsupported-shape = []
fail-view-source = []
fail-subquery-source = []
[dependencies]
toy-sqlx = { path = "../toy-sqlx" }
#+end_src
- Étape 0 : exposer l'échec à l'exécution
:PROPERTIES:
:MINUTES: 5
:END:
#+begin_quote
Problème : Rust voit le SQL comme du texte ordinaire, il ne peut donc pas rejeter une colonne erronée.
Pourquoi s'en soucier : le premier retour utile n'arrive qu'après que le programme a été construit, déployé et que le chemin de la requête est exécuté.
Cette étape : créer une minuscule base de données et préparer délibérément une requête invalide à l'exécution. C'est la base de référence que chaque étape ultérieure doit améliorer.
#+end_quote
La fixture ne contient que les preuves de schéma dont nous aurons besoin plus tard : types de colonnes déclarés et une colonne nullable. Le programme de configuration n'utilise aucune macro vérifiée.
#+name: fixture-schema
#+begin_src sql :tangle toy-demo/schema.sql :mkdirp yes :comments no
PRAGMA foreign_keys = ON;
DROP VIEW IF EXISTS hidden_join;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id INTEGER PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE CHECK (active IN (FALSE, TRUE))
);
INSERT INTO users (email, display_name, active) VALUES
('ada@example.test', 'Ada', TRUE),
('grace@example.test', NULL, TRUE),
('alan@example.test', 'Alan', FALSE);
#+end_src
#+name: fixture-setup
#+begin_src rust :tangle toy-demo/src/bin/setup.rs :mkdirp yes :comments no
fn main() -> Result<(), Box> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
connection.execute_batch(include_str!("../../schema.sql"))?;
println!("initialized {}", toy_sqlx::database_path(&url).display());
Ok(())
}
#+end_src
#+name: runtime-failure
#+begin_src rust :tangle toy-demo/src/bin/00_runtime_sql.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let result = connection.prepare("SELECT id, definitely_missing FROM users");
match result {
Ok(_) => println!("unexpected success"),
Err(error) => println!("runtime SQLite error: {error}"),
}
Ok(())
}
#+end_src
- Exécuter l'étape :
#+begin_src bash :results output
./scripts/toy-demo.sh 0
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.27s
: Running target/debug/00_runtime_sql
: runtime SQLite error: no such column: definitely_missing in SELECT id, definitely_missing FROM users at offset 11
- Observer : Rust compile ; SQLite signale =definitely_missing= à l'exécution.
- Nous avons gagné : un échec concret à déplacer plus tôt.
- Toujours cassé : le compilateur ne reçoit jamais le SQL comme une entrée inspectable. L'étape 1 donne le SQL à une macro procédurale.
- Étape 1 : rendre le SQL visible à la compilation
:PROPERTIES:
:MINUTES: 6
:END:
#+begin_quote
Problème : une macro procédurale reçoit des jetons Rust, pas la valeur cachée à l'intérieur d'une variable d'exécution.
Pourquoi s'en soucier : la vérification à la compilation est impossible à moins que le SQL lui-même ne soit disponible pendant l'expansion de la macro.
Cette étape : exiger un littéral de chaîne. La première macro ne fait qu'analyser et renvoyer le littéral, afin que nous puissions voir cette limite sans mélanger la logique de base de données.
#+end_quote
La crate d'exécution ci-dessous est une plomberie de support : connexion à SQLite et mappage des lignes. Elle n'effectue aucune analyse à la compilation.
#+name: toy-runtime
#+begin_src rust :tangle toy-sqlx/src/lib.rs :mkdirp yes :comments no
use std::path::Path;
pub use rusqlite;
pub use toy_sqlx_macros::{checked_query, checked_sql, query, query_as, sql_literal};
pub fn database_path(url: &str) -> &Path {
let path = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(url);
Path::new(path)
}
pub fn connect(url: &str) -> rusqlite::Resultrusqlite::Connection {
let connection = rusqlite::Connection::open(database_path(url))?;
connection.pragma_update(None, "foreign_keys", "ON")?;
Ok(connection)
}
#[doc(hidden)]
pub fn map_rows<T, F>(
connection: &rusqlite::Connection,
sql: &str,
parameters: &[&dyn rusqlite::ToSql],
mut map: F,
) -> rusqlite::Result<Vec>
where
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result,
{
let mut statement = connection.prepare(sql)?;
let rows = statement.query_map(parameters, |row| map(row))?;
rows.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_urls_and_paths() {
assert_eq!(database_path("sqlite://db/toy.db"), Path::new("db/toy.db"));
assert_eq!(database_path("sqlite::memory:"), Path::new(":memory:"));
assert_eq!(database_path("db/toy.db"), Path::new("db/toy.db"));
}
#[test]
fn enables_foreign_keys() {
let connection = connect("sqlite::memory:").unwrap();
let enabled: bool = connection
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.unwrap();
assert!(enabled);
}
}
#+end_src
#+name: macro-foundation
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
//! Un modèle délibérément petit, limité à SQLite, des macros de requête à la compilation de SQLx.
//!
//! # L'idée centrale
//!
//! Une macro procédurale s'exécute pendant que la crate qui l'a appelée est en cours de compilation. Elle reçoit des jetons Rust, demande à SQLite ce qu'une chaîne SQL signifie, et émet de nouveaux jetons Rust. Le Rust émis passe ensuite par le compilateur Rust normal.
//!
//! Le pipeline est :
//!
//! 1. [syn] analyse l'entrée de la macro en structures de données Rust.
//! 2. [rusqlite] prépare le SQL et renvoie les métadonnées des paramètres/colonnes.
//! 3. Cette crate valide le sous-ensemble pédagogique délibérément petit.
//! 4. [quote!] construit du code Rust ordinaire à partir de ces métadonnées.
//! 5. rustc vérifie les champs, types et littéraux de structure générés.
//!
//! Ceci est du code pédagogique, pas un analyseur SQL général. Les requêtes typées n'acceptent intentionnellement que des colonnes directes provenant d'une seule table SQLite réelle.
// Outils de la bibliothèque standard utilisés pour les noms, variables d'environnement, fichiers de cache et chemins.
use std::{collections::HashSet, env, fs, path::PathBuf};
// proc_macro::TokenStream est le type d'entrée et de sortie orienté compilateur.
use proc_macro::TokenStream;
// proc_macro2 fournit des types de jetons plus faciles à construire et à tester.
use proc_macro2::{Ident, Span, TokenStream as Tokens};
// quote! transforme une syntaxe de type Rust en jetons ; format_ident! crée des identifiants.
use quote::{format_ident, quote};
// SQLite est à la fois la base de données d'exécution et notre autorité SQL à la compilation.
use rusqlite::{Connection, OpenFlags};
// Les descriptions sont sérialisées afin que la même preuve puisse être utilisée hors ligne.
use serde::{Deserialize, Serialize};
// syn analyse les littéraux, expressions, types, virgules et grammaires de macro personnalisées.
#[rustfmt::skip]
use syn::{parse::{Parse, ParseStream}, parse_macro_input, punctuated::Punctuated, Expr, LitStr, Token, Type};
#+end_src
#+name: macro-entry-points
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Exiger que l'entrée de la macro soit un littéral de chaîne, puis émettre ce littéral inchangé.
///
/// Cette première macro pédagogique ne comprend pas le SQL. Son seul travail est de montrer
/// que la vérification à la compilation nécessite une entrée à la compilation : sql_literal!(variable)
/// échoue car variable n'est pas un [LitStr].
#[proc_macro]
pub fn sql_literal(input: TokenStream) -> TokenStream {
// parse_macro_input! arrête l'expansion et émet un diagnostic de compilateur en cas d'échec.
let sql = parse_macro_input!(input as LitStr);
// #sql interpole le littéral analysé dans le flux de jetons construit par quote!.
quote!(#sql).into()
}
/// Convertir notre syn::Result interne en flux de jetons requis par rustc.
///
/// Les macros procédurales ne peuvent pas renvoyer de Result. Une syn::Error devient donc une
/// invocation de compile_error! placée à l'emplacement source le plus utile.
fn result(result: syn::Result) -> TokenStream {
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#+end_src
#+name: literal-demo
#+begin_src rust :tangle toy-demo/src/bin/01_literal_macro.rs :mkdirp yes :comments no
fn main() {
#[cfg(feature = "fail-literal")]
{
let sql = "SELECT 40 + 2";
let _ = toy_sqlx::sql_literal!(sql);
}
#[cfg(not(feature = "fail-literal"))]
println!("{}", toy_sqlx::sql_literal!("SELECT 40 + 2 AS answer"));
}
#+end_src
Utilisez =cargo expand= pour prouver que la macro émet le littéral inchangé. La première
commande confirme que =cargo-expand= est installé. S'il est manquant, exécutez
=cargo install cargo-expand --locked= une fois.
#+begin_src bash :results output
set -euo pipefail
cargo expand --version
cargo expand --color never -p toy-demo --bin 01_literal_macro 2>/dev/null |
grep -C 2 'SELECT 40 + 2 AS answer'
#+end_src
#+RESULTS:
: cargo-expand-expand 1.0.59 + prettyplease 0.2.10
: fn main() {
: {
: ::std::io::_print(format_args!("{0}\n", "SELECT 40 + 2 AS answer"));
: };
: }
L'expansion utile est intentionnellement ennuyeuse :
#+begin_src rust :tangle no
"SELECT 40 + 2 AS answer"
#+end_src
- Exécuter l'étape et son échec attendu :
#+begin_src bash :results output
./scripts/toy-demo.sh 1
./scripts/toy-demo.sh fail-literal
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.20s
: Running target/debug/01_literal_macro
: SELECT 40 + 2 AS answer
: fail-literal: failed as intended
- Observer : un littéral compile ; un =String= d'exécution est rejeté par l'analyseur de macro.
- Nous avons gagné : la macro possède le texte SQL pendant la compilation.
- Toujours cassé : posséder du texte ne prouve pas que le SQL est valide. L'étape 2 demande au système
qui comprend réellement SQLite.
- Étape 2 : laisser SQLite rejeter le mauvais SQL pendant la compilation
:PROPERTIES:
:MINUTES: 12
:END:
#+begin_quote
Problème : la macro peut maintenant voir le SQL, mais elle ne peut toujours pas dire si une table
ou une colonne existe.
Pourquoi s'en soucier : écrire un deuxième analyseur SQLite à l'intérieur de la macro serait volumineux,
incomplet et moins fiable que SQLite lui-même.
Cette étape : pendant l'expansion de la macro, ouvrir la base de données de build en lecture seule et demander
à SQLite de préparer la requête. Convertir la réponse de SQLite en une petite =Description=
que les étapes ultérieures peuvent réutiliser.
#+end_quote
Il n'y a que trois pièces mobiles : la description, le point d'entrée de la macro et
le chargeur en ligne. Dans cette étape, observez l'appel à =connection.prepare=. Les
autres champs de description deviennent utiles aux étapes 3, 4 et 6.
#+name: description-ir
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// Augmentez ceci chaque fois que la signification ou la forme des métadonnées mises en cache change. Les anciennes entrées de cache
// échouent alors clairement au lieu d'être interprétées selon de nouvelles règles.
const CACHE_VERSION: u8 = 3;
/// La petite représentation intermédiaire partagée par la vérification en ligne et hors ligne.
///
/// Considérez Description comme une fiche d'information sur une chaîne SQL :
///
/// - parameter_count indique combien de valeurs SQLite attend ;
/// - columns décrit le jeu de résultats ;
/// - source_is_table enregistre si notre règle étroite de requête typée a été prouvée ;
/// - version, database et sql protègent le chargement du cache hors ligne.
///
/// Garder une seule représentation est important : la génération de code n'a pas besoin de savoir
/// si ces faits proviennent de SQLite en direct ou d'un fichier de cache.
#[rustfmt::skip]
#[derive(Debug, Serialize, Deserialize)]
struct Description { version: u8, database: String, sql: String, parameter_count: usize, source_is_table: bool, columns: Vec }
/// La preuve SQLite nécessaire pour générer un champ de sortie Rust.
///
/// declared_type est le type écrit dans CREATE TABLE ; ce n'est pas le type de
/// chaque valeur que SQLite pourrait stocker dynamiquement. nullable décide entre T
/// et Option<T> dans le Rust généré.
#[rustfmt::skip]
#[derive(Debug, Serialize, Deserialize)]
struct Column { name: String, declared_type: Option, nullable: bool }
#+end_src
#+name: checked-sql-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Demander à SQLite de valider un littéral SQL pendant l'expansion de la macro.
///
/// La valeur émise est toujours juste le littéral de chaîne original. L'amélioration
/// est le timing : un SQL invalide devient une erreur de compilation au lieu d'une erreur d'exécution.
#[proc_macro]
pub fn checked_sql(input: TokenStream) -> TokenStream {
let sql = parse_macro_input!(input as LitStr);
// Le chargement de la description effectue la validation ; cette macro rejette les faits.
result(load_description(&sql).map(|_| quote!(#sql)))
}
#+end_src
#+name: online-description
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Obtenir la preuve de requête à partir d'exactement une source.
///
/// Le mode hors ligne lit une description précédemment enregistrée. Le mode en ligne demande à SQLite
/// directement et enregistre éventuellement la réponse pour une construction hors ligne ultérieure.
fn load_description(sql: &LitStr) -> syn::Result {
if flag("TOY_SQLX_OFFLINE") {
return load_cache(&sql.value(), sql.span());
}
let description = describe_online(&sql.value(), sql.span())?;
if flag("TOY_SQLX_PREPARE") {
save_cache(&description, sql.span())?;
}
Ok(description)
}
/// Reconnaître la forme SQL délibérément minuscule prise en charge par les requêtes typées.
///
/// Ceci est une protection de portée, pas un analyseur SQL. Il renvoie un nom de table uniquement pour une
/// projection de colonnes directes à partir d'une table simple. Toute incertitude renvoie
/// None, ce qui fait que la macro typée rejette la requête plutôt que d'inventer des faits.
#[rustfmt::skip]
fn direct_source(sql: &str) -> Option<&str> {
// Les commentaires pourraient cacher des clauses FROM supplémentaires, donc ce jouet les rejette purement et simplement.
if ["--", "/", "/"].iter().any(|marker| sql.contains(marker)) { return None; }
// La tokenisation par espace blanc suffit car chaque forme acceptée est simple.
let words = sql.split_whitespace().collect::<Vec<>>();
// Exactement un FROM exclut les sous-requêtes SELECT ordinaires et les sources ambiguës.
let from = words.iter().enumerate().filter(|(, word)| word.eq_ignore_ascii_case("FROM")).map(|(index, )| index).collect::<Vec<>>();
let [from] = from.as_slice() else { return None };
// Le premier mot après FROM doit être un identifiant de table non cité et non qualifié.
let table = *words.get(from + 1)?;
// Arrêter la clause source lorsque le filtrage/tri ordinaire commence.
let tail = words[from + 2..].iter().take_while(|word| !matches!(word.to_ascii_uppercase().as_str(), "WHERE" | "GROUP" | "ORDER" | "LIMIT")).copied().collect::<Vec<>>();
let ident = |word: &str| word.chars().all(|c| c.is_ascii_alphanumeric() || c == '');
// Chaque champ sélectionné doit être column, table.column, ou l'une ou l'autre forme avec AS name.
let fields = words[1..*from].join(" ");
let direct_field = |field: &str| { let parts = field.split_whitespace().collect::<Vec<>>(); let path = parts.first()?.split('.').collect::<Vec<>>(); Some((path.len() == 1 || path.len() == 2) && path.iter().all(|part| ident(part)) && (parts.len() == 1 || (parts.len() == 3 && parts[1].eq_ignore_ascii_case("AS") && ident(parts[2])))) };
// Après la table, n'autoriser aucun alias, alias, ou AS alias—jamais une deuxième source.
let alias = tail.is_empty() || (tail.len() == 1 && ident(tail[0])) || (tail.len() == 2 && tail[0].eq_ignore_ascii_case("AS") && ident(tail[1]));
(ident(table) && alias && fields.split(',').all(|field| direct_field(field) == Some(true))).then_some(table)
}
/// Demander à une base de données SQLite en direct de décrire une requête pendant la compilation.
///
/// La préparation valide la syntaxe, les noms de table et les noms de colonne sans exécuter la
/// requête. span pointe les diagnostics vers le littéral SQL dans le code de l'appelant.
#[rustfmt::skip]
fn describe_online(sql: &str, span: Span) -> syn::Result {
// Cargo exécute cette fonction dans le processus proc-macro, pas dans le programme final.
let url = env::var("TOY_DATABASE_URL")
.map_err(|| syn::Error::new(span, "TOY_DATABASE_URL is required for online checking"))?;
// Accepter le même petit ensemble de formes d'URL SQLite que la crate d'exécution.
let path = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(&url);
// Le mode lecture seule empêche une compilation de modifier la base de données pédagogique.
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|error| syn::Error::new(span, format!("cannot open SQLite: {error}")))?;
// Ceci est la vérification clé à la compilation : laisser SQLite valider le SQL SQLite.
let statement = connection.prepare(sql).map_err(|error| {
syn::Error::new(
span,
format!("SQLite rejected this query during compilation: {error}"),
)
})?;
// Copier uniquement les faits de sortie nécessaires à la génération de code Rust ultérieure.
let columns = (0..statement.column_count())
.map(|index| {
let name = statement.column_name(index)?.to_owned();
let metadata = statement.column_metadata(index)?;
// rusqlite expose plusieurs champs d'origine ; ce jouet a besoin de la déclaration et de NOT NULL.
let (declared_type, nullable) = match metadata {
Some((, _, _, declared, _, not_null, _, )) => (
declared.map(|value| value.to_string_lossy().into_owned()),
!not_null,
),
// La preuve d'origine manquante ne doit jamais être traitée comme non nulle.
None => (None, true),
};
Ok(Column { name, declared_type, nullable })
})
.collect::<rusqlite::Result<Vec<>>>()
.map_err(|error| syn::Error::new(span, format!("cannot describe output: {error}")))?;
// La reconnaissance lexicale ne suffit pas : sqlite_schema doit confirmer une table réelle, pas une vue.
#[rustfmt::skip]
let source_is_table = direct_source(sql).is_some_and(|table| connection.query_row(
"SELECT type = 'table' FROM sqlite_schema WHERE name = ?1", [table], |row| row.get(0),
).unwrap_or(false));
// La déclaration préparée elle-même fournit le nombre de paramètres autorisés de SQLite.
Ok(Description {
version: CACHE_VERSION,
database: "SQLite".into(),
sql: sql.into(),
parameter_count: statement.parameter_count(),
source_is_table,
columns,
})
}
#+end_src
#+name: prepare-demo
#+begin_src rust :tangle toy-demo/src/bin/02_compile_time_prepare.rs :mkdirp yes :comments no
fn main() {
#[cfg(feature = "fail-invalid-sql")]
let sql = toy_sqlx::checked_sql!("SELECT id, definitely_missing FROM users");
#[cfg(not(feature = "fail-invalid-sql"))]
let sql = toy_sqlx::checked_sql!("SELECT id, email FROM users");
println!("SQLite accepted during compilation: {sql}");
}
#+end_src
- Exécuter l'étape et son échec attendu :
#+begin_src bash :results output
./scripts/toy-demo.sh 2
./scripts/toy-demo.sh fail-sql
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.57s
: Running target/debug/02_compile_time_prepare
: SQLite accepted during compilation: SELECT id, email FROM users
: fail-sql: failed as intended
- Observer : =definitely_missing= est maintenant une erreur de compilation.
- Nous avons gagné : SQLite a accepté la requête contre la base de données de build.
- Limite : la base de données d'exécution peut toujours avoir un schéma différent.
- Toujours cassé : un SQL valide peut recevoir le mauvais nombre de valeurs Rust. L'étape 3
vérifie les paramètres par rapport aux arguments.
- Étape 3 : rejeter le mauvais nombre d'arguments
:PROPERTIES:
:MINUTES: 8
:END:
#+begin_quote
Problème : une requête peut être valide alors que ses paramètres =?1=, =?2= ne correspondent pas au
nombre de valeurs fournies par Rust.
Pourquoi s'en soucier : ce décalage devient sinon une autre erreur de préparation/liaison à l'exécution.
Cette étape : comparer le nombre de paramètres de SQLite avec le nombre d'arguments de la macro.
Gardez les expressions Rust visibles pour rustc sans les évaluer.
#+end_quote
Ceci est délibérément uniquement sur l'arité. SQLite ne fournit pas de types de liaison statiques.
#+name: checked-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrée analysée pour checked_query!("SQL", arg1, arg2, ...).
///
/// Punctuated est la représentation par syn de zéro ou plusieurs expressions séparées par
/// des virgules. Garder les expressions sous forme de syntaxe nous permet de les compter sans les exécuter.
#[rustfmt::skip]
struct CheckedInput { sql: LitStr, args: Punctuated<Expr, Token![,]> }
/// Enseigner à syn la petite grammaire acceptée par checked_query!.
impl Parse for CheckedInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
// Le premier jeton doit être le littéral de chaîne SQL.
let sql = input.parse()?;
let args = if input.is_empty() {
// Une requête sans paramètres n'a besoin d'aucune virgule et d'aucun argument.
Punctuated::new()
} else {
// Sinon, consommer la virgule après le SQL, puis toutes les expressions séparées par des virgules.
input.parse::<Token![,]>()?;
Punctuated::parse_terminated(input)?
};
Ok(Self { sql, args })
}
}
#+end_src
#+name: checked-query-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Valider le SQL plus l'arité des paramètres, mais ne pas exécuter la requête.
///
/// Cette macro de transition isole une leçon : SQLite peut nous dire combien de
/// paramètres il attend, mais pas les types de paramètres SQLite statiques.
#[proc_macro]
pub fn checked_query(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as CheckedInput);
result(expand_checked(input))
}
#+end_src
#+name: checked-query-expansion
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Implémenter checked_query! après que ses jetons ont été analysés.
fn expand_checked(input: CheckedInput) -> syn::Result {
let description = load_description(&input.sql)?;
validate_arity(input.args.len(), &description, input.sql.span())?;
let CheckedInput { sql, args } = input;
let args = args.into_iter().collect::<Vec<_>>();
Ok(quote! {{
// Cette branche ne s'exécute jamais, mais rustc résout et vérifie toujours le type de chaque expression.
// #(...)* est la syntaxe de répétition de quote : émettre le corps une fois par argument.
if false {
#(let _ = &(#args);)*
}
// La valeur d'exécution de la macro reste la chaîne SQL originale.
#sql
}})
}
#+end_src
Étendez la démo pour voir l'argument non évalué conservé à l'intérieur de =if false= :
#+begin_src bash :results output
set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 03_parameter_arity 2>/dev/null |
grep -A 4 'if false'
#+end_src
#+RESULTS:
: if false {
: let _ = &(deliberately_wrong_type);
: }
: "SELECT id FROM users WHERE active = ?1"
: };
#+name: arity-demo
#+begin_src rust :tangle toy-demo/src/bin/03_parameter_arity.rs :mkdirp yes :comments no
fn main() {
let deliberately_wrong_type = "SQLite accepts dynamic values";
#[cfg(feature = "fail-arity")]
let sql = toy_sqlx::checked_query!(
"SELECT id FROM users WHERE active = ?1 AND id >= ?2",
deliberately_wrong_type,
);
#[cfg(not(feature = "fail-arity"))]
let sql = toy_sqlx::checked_query!(
"SELECT id FROM users WHERE active = ?1",
deliberately_wrong_type,
);
println!("arity checked, parameter type deliberately unchecked: {sql}");
}
#+end_src
- Exécuter l'étape et son échec attendu :
#+begin_src bash :results output
./scripts/toy-demo.sh 3
./scripts/toy-demo.sh fail-arity
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.19s
: Running target/debug/03_parameter_arity
: arity checked, parameter type deliberately unchecked: SELECT id FROM users WHERE active = ?1
: fail-arity: failed as intended
- Observer : une valeur pour deux paramètres échoue ; une valeur pour un paramètre
passe—même lorsque cette valeur est délibérément une chaîne inappropriée. - Nous avons gagné : le nombre de liaisons correspond.
- Limite : aucune revendication de type de liaison SQLite statique n'est faite.
- Toujours cassé : les requêtes réussies renvoient toujours des lignes non typées. L'étape 4 génère
la forme de sortie Rust et le décodeur.
- Étape 4 : générer un enregistrement typé et un décodeur
:PROPERTIES:
:MINUTES: 17
:END:
#+begin_quote
Problème : même un SQL vérifié nécessite toujours des appels répétitifs =row.get(0)=,
=row.get(1)= et des types Rust écrits à la main.
Pourquoi s'en soucier : le décodage positionnel est fragile, et la forme du résultat SQL est dupliquée
dans Rust à la main.
Cette étape : transformer les noms de colonnes et les types déclarés dans =Description= en un =Record= local,
puis générer le décodeur positionnel qui le construit.
#+end_quote
Le mappage des types déclarés est intentionnellement visible et minuscule :
| La déclaration SQLite contient | Type de base Rust généré |
|---+---|
| =BOOL= ou =BOOLEAN= | =bool= |
| =INT= | =i64= |
| =REAL=, =FLOA=, ou =DOUB= | =f64= |
| =CHAR=, =CLOB=, ou =TEXT= | =String= |
| =BLOB= | =Vec= |
| autre chose | erreur de compilation |
L'étape 6 explique quand un type de base reste =T= et quand la nullabilité l'enveloppe dans
=Option=.
#+name: query-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrée analysée pour query!(&connection, "SQL", arg1, arg2, ...).
///
/// La connexion et chaque argument sont des expressions Rust complètes. Ils sont conservés sous forme de
/// syntaxe jusqu'à ce que l'expansion émette du code qui évalue chacun exactement une fois.
#[rustfmt::skip]
struct QueryInput { connection: Expr, sql: LitStr, args: Punctuated<Expr, Token![,]> }
/// Analyser la connexion d'abord, puis réutiliser CheckedInput pour le SQL et les arguments.
#[rustfmt::skip]
impl Parse for QueryInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
let connection = input.parse()?; input.parse::<Token![,]>()?;
let checked = input.parse::()?;
Ok(Self { connection, sql: checked.sql, args: checked.args })
}
}
#+end_src
#+name: query-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Valider et exécuter une requête dont l'enregistrement de sortie est généré par la macro.
///
/// Le type local généré a un champ Rust par colonne SQL sélectionnée.
#[proc_macro]
pub fn query(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as QueryInput);
result(expand_query(input, Output::Generated))
}
#+end_src
#+name: typed-query-expansion
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Choisir quelle valeur Rust le décodeur de ligne construit.
enum Output {
/// query! nous demande de définir un type Record local.
Generated,
/// query_as! nous donne un type appartenant à l'application à construire.
Given(Box),
}
/// Une colonne de sortie SQL vérifiée traduite en un nom de champ Rust et des jetons de type.
#[rustfmt::skip]
struct RustColumn { ident: Ident, ty: Tokens }
/// Construire le programme Rust émis par query! et query_as!.
///
/// Tout ce qui précède le quote! final s'exécute pendant la compilation. Le code à l'intérieur
/// de ce quote! est ce que le programme de l'appelant exécutera à l'exécution.
#[rustfmt::skip]
fn expand_query(input: QueryInput, output: Output) -> syn::Result {
// Phase de compilation : rassembler les preuves et rejeter les entrées non prises en charge.
let description = load_description(&input.sql)?;
validate_arity(input.args.len(), &description, input.sql.span())?;
let columns = typed_columns(&description, input.sql.span())?;
// Transformer chaque colonne vérifiée en jetons `field_name: RustType`.
let fields = columns.iter().map(|column| {
let ident = &column.ident;
let ty = &column.ty;
quote!(#ident: #ty)
});
// Transformer chaque colonne vérifiée en `field_name: row.get::<_, RustType>(index)?`.
let values: Vec<_> = columns
.iter()
.enumerate()
.map(|(index, column)| {
let ident = &column.ident;
let ty = &column.ty;
quote!(#ident: __toy_row.get::<usize, #ty>(#index)?)
})
.collect();
// Les deux macros publiques partagent un seul décodeur ; seul leur constructeur final diffère.
let (definition, expression) = match output {
Output::Generated => (
quote!(#[derive(Debug)] struct Record { #(#fields),* }),
quote!(Record { #(#values),* }),
),
Output::Given(output) => (Tokens::new(), quote!(#output { #(#values),* })),
};
let QueryInput { connection, sql, args } = input;
// Les noms locaux générés nous permettent d'évaluer chaque expression de l'appelant exactement une fois.
let names: Vec<_> = (0..args.len())
.map(|index| format_ident!("__toy_arg_{index}"))
.collect();
let args = args.into_iter().collect::<Vec<_>>();
// Phase d'exécution : tout ce bloc est inséré au site d'appel de la macro.
Ok(quote! {{
#definition
// Les côtés droits des tuples sont évalués avant que les noms générés ne soient liés.
let (__toy_connection, #(#names,)*) = (#connection, #(&(#args),)*);
// rusqlite accepte une tranche de références vers des valeurs implémentant `ToSql`.
let __toy_parameters: &[&dyn ::toy_sqlx::rusqlite::ToSql] = &[#(#names),*];
::toy_sqlx::map_rows(
__toy_connection,
#sql,
__toy_parameters,
// Décoder une ligne SQLite dans la structure générée ou fournie par l'appelant.
|__toy_row| ::core::result::Result::Ok(#expression),
)
}})
}
/// Comparer le nombre d'arguments Rust avec le nombre de paramètres de SQLite.
///
/// Ceci vérifie intentionnellement uniquement combien de valeurs existent. SQLite ne donne pas
/// à ce jouet des types de paramètres statiques stables, donc en revendiquer plus serait trompeur.
fn validate_arity(got: usize, description: &Description, span: Span) -> syn::Result<()> {
if got == description.parameter_count {
Ok(())
} else {
let expected = description.parameter_count;
Err(syn::Error::new(
span,
format!("SQLite expects {expected} parameter(s), but the macro received {got}"),
))
}
}
/// Transformer les colonnes SQLite en noms de champs et types Rust sûrs.
///
/// La fonction applique d'abord la limite de preuve, puis vérifie les noms, rejette
/// les champs en double, mappe les déclarations SQLite et applique la nullabilité.
fn typed_columns(description: &Description, span: Span) -> syn::Result<Vec> {
ensure_typed_shape(&description.sql, span)?;
if !description.source_is_table {
return Err(syn::Error::new(
span,
"typed query must select direct columns from one table; expressions, views, and subqueries are out of scope",
));
}
if description.columns.is_empty() {
return Err(syn::Error::new(span, "typed query must return columns"));
}
let mut names = HashSet::new();
description
.columns
.iter()
.map(|column| {
// Un nom de sortie SQL doit être utilisable dans un littéral de structure Rust généré.
let ident = rust_ident(&column.name, span)?;
if !names.insert(ident.to_string()) {
return Err(syn::Error::new(span, "duplicate output field name"));
}
// Les expressions n'ont souvent pas de déclaration de table ; la sortie typée les rejette.
let declared = column.declared_type.as_deref().ok_or_else(|| {
let name = &column.name;
syn::Error::new(span, format!("SQLite has no declared type for {name:?}; typed queries only support direct table columns"))
})?;
let base = rust_type(declared, span)?;
// Rust représente une valeur SQL potentiellement NULL comme Option<T>.
let ty = if column.nullable {
quote!(::core::option::Option<#base>)
} else {
base
};
Ok(RustColumn { ident, ty })
})
.collect()
}
/// Rejeter les formes SQL larges avant de générer du Rust typé.
///
/// Les jointures et les requêtes composées nécessitent une analyse de nullabilité que cet atelier ne
/// met pas en œuvre. Le rejet conservateur maintient la petite garantie honnête.
fn ensure_typed_shape(sql: &str, span: Span) -> syn::Result<()> {
let sql = sql.trim_start().to_ascii_uppercase();
let words =
|| sql.split(|character: char| !character.is_ascii_alphanumeric() && character != '_');
let unsupported = !sql.starts_with("SELECT ")
|| words().filter(|word| *word == "SELECT").count() != 1
|| words().any(|word| matches!(word, "JOIN" | "UNION" | "INTERSECT" | "EXCEPT"));
if unsupported {
Err(syn::Error::new(
span,
"typed queries support one direct-table SELECT; joins and compound queries are out of scope",
))
} else {
Ok(())
}
}
/// Convertir un nom de sortie SQLite en un identifiant de champ Rust.
///
/// La forme brute (r#type, par exemple) autorise les mots-clés Rust lorsque c'est légal. Le
/// repli gère les identifiants ordinaires sur les versions de compilateur avec un comportement différent
/// d'analyse des identifiants bruts.
fn rust_ident(name: &str, span: Span) -> syn::Result {
syn::parse_str(&format!("r#{name}"))
.or_else(|| syn::parse_str(name))
.map_err(|| syn::Error::new(span, format!("{name:?} is not a Rust field name")))
}
/// Mapper un petit ensemble de types déclarés SQLite vers des jetons de type Rust.
///
/// SQLite utilise l'affinité de type et autorise les valeurs stockées dynamiques. Ce tableau est un
/// sous-ensemble pédagogique, pas une affirmation que chaque valeur stockée doit avoir ce type Rust.
///
/// | La déclaration contient | Type de base généré |
/// | --- | --- |
/// | BOOL ou BOOLEAN | bool |
/// | INT | i64 |
/// | REAL, FLOA, ou DOUB | f64 |
/// | CHAR, CLOB, ou TEXT | String |
/// | BLOB | Vec<u8> |
/// | autre chose | erreur de compilation |
fn rust_type(declared: &str, span: Span) -> syn::Result {
let name = declared.trim().to_ascii_uppercase();
if matches!(name.as_str(), "BOOL" | "BOOLEAN") {
Ok(quote!(bool))
} else if name.contains("INT") {
Ok(quote!(i64))
} else if name.contains("REAL") || name.contains("FLOA") || name.contains("DOUB") {
Ok(quote!(f64))
} else if name.contains("CHAR") || name.contains("CLOB") || name.contains("TEXT") {
Ok(quote!(::std::string::String))
} else if name.contains("BLOB") {
Ok(quote!(::std::vec::Vec))
} else {
Err(syn::Error::new(
span,
format!("no toy Rust mapping for SQLite declaration {declared:?}"),
))
}
}
#+end_src
#+name: typed-record-demo
#+begin_src rust :tangle toy-demo/src/bin/04_typed_record.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let users = toy_sqlx::query!(
&connection,
"SELECT id, email, display_name FROM users WHERE active = ?1 ORDER BY id",
true,
)?;
for user in users {
println!("{} {} {:?}", user.id, user.email, user.display_name);
}
Ok(())
}
#+end_src
Étendez la démo et sélectionnez le =Record= généré plus l'appel de mappage de ligne d'exécution :
#+begin_src bash :results output
set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 04_typed_record 2>/dev/null |
grep -E -A 12 'struct Record|::toy_sqlx::map_rows'
#+end_src
#+RESULTS:
#+begin_example
struct Record {
id: i64,
email: ::std::string::String,
display_name: ::core::option::Option<::std::string::String>,
}
#[automatically_derived]
impl ::core::fmt::Debug for Record {
#[inline]
fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
::core::fmt::Formatter::debug_struct_field3_finish(
f,
"Record",
"id",
::toy_sqlx::map_rows(
__toy_connection,
"SELECT id, email, display_name FROM users WHERE active = ?1 ORDER BY id",
__toy_parameters,
|__toy_row| ::core::result::Result::Ok(Record {
id: __toy_row.get::<usize, i64>(0usize)?,
email: __toy_row.get::<usize, ::std::string::String>(1usize)?,
display_name: __toy_row
.get::<usize, ::core::option::Option<::std::string::String>>(2usize)?,
}),
)
}?;
for user in users {
#+end_example
La forme générée importante est :
#+begin_src rust :tangle no
struct Record {
id: i64,
email: String,
display_name: Option,
}
map_rows(connection, sql, parameters, |row| Ok(Record {
id: row.get::<usize, i64>(0)?,
email: row.get::<usize, String>(1)?,
display_name: row.get::<usize, Option>(2)?,
}))
#+end_src
- Exécuter l'étape :
#+begin_src bash :results output
./scripts/toy-demo.sh 4
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.34s
: Running target/debug/04_typed_record
: 1 ada@example.test Some("Ada")
: 2 grace@example.test None
- Observer : la boucle utilise =user.id= et =user.email= ; aucun appel =row.get=
écrit à la main ne reste dans le code de l'application. - Nous avons gagné : les noms de sortie et les déclarations prises en charge deviennent des champs Rust.
- Question garée pour l'étape 6 : quelle preuve justifie =String= pour =email= mais
=Option= pour =display_name=? - Toujours cassé ensuite : =Record= est local à l'expansion de la macro. L'étape 5 mappe la
même preuve dans une structure appartenant à l'application.
- Étape 5 : peupler une structure appartenant à l'application
:PROPERTIES:
:MINUTES: 10
:END:
#+begin_quote
Problème : le =Record= local généré est pratique à l'intérieur d'une expression, mais
les applications ont déjà des types de domaine nommés utilisés à travers les fonctions et les modules.
Pourquoi s'en soucier : les résultats de requête doivent correspondre à ces types sans ajouter un deuxième décodeur manuel.
Cette étape : générer un littéral ordinaire de la structure de l'appelant. Laisser rustc—pas
la macro—vérifier les noms de champs et les types Rust.
#+end_quote
=query_as!= réutilise la même expansion et ne change que le constructeur final. Il
ne inspecte pas ou ne réfléchit pas sur la définition de la structure.
#+name: query-as-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrée analysée pour query_as!(OutputType, &connection, "SQL", args...).
#[rustfmt::skip]
struct QueryAsInput { output: Type, query: QueryInput }
/// Analyser le type Rust de l'appelant, puis réutiliser la grammaire complète de query!.
#[rustfmt::skip]
impl Parse for QueryAsInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
let output = input.parse()?; input.parse::<Token![,]>()?;
Ok(Self { output, query: input.parse()? })
}
}
#+end_src
#+name: query-as-entry
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Valider et exécuter une requête dans une structure Rust fournie par l'appelant.
///
/// La macro émet un littéral de structure ordinaire. rustc signale donc les champs manquants,
/// supplémentaires ou incorrectement typés sans mécanisme de réflexion personnalisé.
#[proc_macro]
#[rustfmt::skip]
pub fn query_as(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as QueryAsInput);
result(expand_query(input.query, Output::Given(Box::new(input.output))))
}
#+end_src
#+name: query-as-demo
#+begin_src rust :tangle toy-demo/src/bin/05_query_as.rs :mkdirp yes :comments no
struct UserSummary {
id: i64,
email: String,
}
#[cfg(feature = "fail-query-as-name")]
struct WrongName {
id: i64,
address: String,
}
#[cfg(feature = "fail-query-as-type")]
struct WrongType {
id: i32,
email: String,
}
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
#[cfg(feature = "fail-query-as-name")]
let _ = toy_sqlx::query_as!(
WrongName,
&connection,
"SELECT id, email FROM users ORDER BY id"
)?;
#[cfg(feature = "fail-query-as-type")]
let _ = toy_sqlx::query_as!(
WrongType,
&connection,
"SELECT id, email FROM users ORDER BY id"
)?;
#[cfg(not(any(feature = "fail-query-as-name", feature = "fail-query-as-type")))]
for user in toy_sqlx::query_as!(
UserSummary,
&connection,
"SELECT id, email FROM users ORDER BY id"
)? {
println!("{} {}", user.id, user.email);
}
Ok(())
}
#+end_src
Étendez =query_as!= pour voir qu'il réutilise =map_rows= mais construit
=UserSummary= au lieu de définir =Record= :
#+begin_src bash :results output
set -euo pipefail
./scripts/toy-demo.sh setup >/dev/null
export TOY_DATABASE_URL="sqlite://$(pwd)/toy-demo/toy.db"
cargo expand --color never -p toy-demo --bin 05_query_as 2>/dev/null |
grep -A 12 '::toy_sqlx::map_rows'
#+end_src
#+RESULTS:
#+begin_example
::toy_sqlx::map_rows(
__toy_connection,
"SELECT id, email FROM users ORDER BY id",
__toy_parameters,
|__toy_row| ::core::result::Result::Ok(UserSummary {
id: __toy_row.get::<usize, i64>(0usize)?,
email: __toy_row.get::<usize, ::std::string::String>(1usize)?,
}),
)
}? {
{
::std::io::_print(format_args!("{0} {1}\n", user.id, user.email));
};
#+end_example
L'expression générée est un littéral de structure ordinaire :
#+begin_src rust :tangle no
UserSummary {
id: row.get::<usize, i64>(0)?,
email: row.get::<usize, String>(1)?,
}
#+end_src
- Exécuter l'étape et les deux échecs attendus :
#+begin_src bash :results output
./scripts/toy-demo.sh 5
./scripts/toy-demo.sh fail-query-as-name
./scripts/toy-demo.sh fail-query-as-type
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.31s
: Running target/debug/05_query_as
: 1 ada@example.test
: 2 grace@example.test
: 3 alan@example.test
: fail-query-as-name: failed as intended
: fail-query-as-type: failed as intended
- Observer : le =UserSummary= correct compile ; un champ ou un type de champ erroné produit
une erreur rustc ordinaire. - Nous avons gagné : les lignes vérifiées peuvent entrer dans des types appartenant à l'application sans deuxième
système de décodage. - Toujours cassé : le Rust généré n'est digne de confiance que si les métadonnées SQL le justifient.
L'étape 6 définit la limite de preuve honnête.
- Étape 6 : rendre la revendication de type honnête
:PROPERTIES:
:MINUTES: 8
:END:
#+begin_quote
Problème : générer =String= pour une valeur qui peut réellement être =NULL= rend une
fonctionnalité à la compilation fausse avec confiance.
Pourquoi s'en soucier : les jointures, vues, sous-requêtes et expressions peuvent changer la nullabilité même
lorsqu'une colonne de table sous-jacente dit =NOT NULL=.
Cette étape : accepter la sortie typée uniquement pour les colonnes directes d'une table réelle. Lire
le schéma de cette table : =NOT NULL= devient =T= ; tout ce qui est nullable devient
=Option=. Rejeter les formes dont nous ne pouvons pas justifier la preuve.
#+end_quote
Ce n'est pas un petit analyseur SQL prétendant comprendre chaque requête. Le rejet est
le mécanisme de correction.
#+name: nullability-demo
#+begin_src rust :tangle toy-demo/src/bin/06_nullability.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
#[cfg(feature = "fail-expression-metadata")]
let _ = toy_sqlx::query!(&connection, "SELECT COUNT(*) AS user_count FROM users")?;
#[cfg(feature = "fail-unsupported-shape")]
let _ = toy_sqlx::query!(
&connection,
"SELECT a.id FROM users AS a JOIN users AS b ON a.id = b.id",
)?;
#[cfg(feature = "fail-view-source")]
let _ = toy_sqlx::query!(&connection, "SELECT bid FROM hidden_join")?;
#[cfg(feature = "fail-subquery-source")]
let _ = toy_sqlx::query!(
&connection,
"SELECT (VALUES(NULL),(email)) AS email FROM users"
)?;
#[cfg(not(any(
feature = "fail-expression-metadata",
feature = "fail-unsupported-shape",
feature = "fail-view-source",
feature = "fail-subquery-source"
)))]
for user in toy_sqlx::query!(
&connection,
"SELECT email, display_name FROM users ORDER BY id"
)? {
println!("{} {:?}", user.email, user.display_name);
}
Ok(())
}
#+end_src
- Exécuter l'étape et ses échecs de limite de preuve :
#+begin_src bash :results output
./scripts/toy-demo.sh 6
./scripts/toy-demo.sh fail-expression
./scripts/toy-demo.sh fail-shape
./scripts/toy-demo.sh fail-view
./scripts/toy-demo.sh fail-subquery
#+end_src
#+RESULTS:
#+begin_example
Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
Finished dev profile [unoptimized + debuginfo] target(s) in 0.76s
Running target/debug/06_nullability
ada@example.test Some("Ada")
grace@example.test None
alan@example.test Some("Alan")
fail-expression: failed as intended
fail-shape: failed as intended
fail-view: failed as intended
fail-subquery: failed as intended
#+end_example
- Observer : =email= direct est =String= ; =display_name= nullable est
=Option= ; les formes non prises en charge échouent pendant la compilation. - Nous avons gagné : une revendication de requête typée étroite soutenue par une preuve de schéma directe.
- Toujours cassé : l'expansion en ligne a besoin de la base de données à chaque fois. L'étape 7 rend
la preuve portable.
- Étape 7 : compiler sans la base de données de build
:PROPERTIES:
:MINUTES: 7
:END:
#+begin_quote
Problème : la vérification à la compilation dépend maintenant de l'ouverture de la base de données de build.
Pourquoi s'en soucier : les développeurs et l'intégration continue peuvent avoir besoin de builds reproductibles où cette base de données est
indisponible.
Cette étape : enregistrer la =Description= déjà validée au format JSON, puis charger cette même
forme en mode hors ligne. Garder chaque étape de validation et de génération de code après le
chargeur inchangée.
#+end_quote
Le cache est délibérément visible : un petit hash choisit le nom de fichier, tandis que
la version, le backend et le SQL exact sont vérifiés avant que la preuve mise en cache ne soit approuvée.
#+name: offline-cache
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Lire les drapeaux d'environnement booléens de l'atelier.
fn flag(name: &str) -> bool {
env::var(name)
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
}
/// Produire un nom de fichier de cache court et déterministe à partir du texte SQL exact.
///
/// FNV-1a est utilisé car sa boucle est facile à enseigner. Un cache de production
/// utiliserait normalement un hash résistant aux collisions et des garanties de concurrence plus fortes.
fn hash(sql: &str) -> u64 {
sql.as_bytes()
.iter()
.fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}
/// Localiser le fichier de cache de cette requête à l'intérieur de la crate qui a appelé la macro.
///
/// CARGO_MANIFEST_DIR appartient à l'appelant, donc chaque crate obtient son propre
/// répertoire .toy-sqlx au lieu de partager le répertoire de la crate proc-macro.
fn cache_path(sql: &str, span: Span) -> syn::Result {
let manifest = env::var_os("CARGO_MANIFEST_DIR")
.ok_or_else(|| syn::Error::new(span, "Cargo did not provide CARGO_MANIFEST_DIR"))?;
Ok(PathBuf::from(manifest)
.join(".toy-sqlx")
.join(format!("query-{:016x}.json", hash(sql))))
}
/// Sérialiser une description SQLite en direct sous forme de JSON lisible pour la compilation hors ligne.
fn save_cache(description: &Description, span: Span) -> syn::Result<()> {
let path = cache_path(&description.sql, span)?;
fs::create_dir_all(path.parent().unwrap())
.and_then(|_| fs::write(&path, serde_json::to_vec_pretty(description).unwrap()))
.map_err(|error| syn::Error::new(span, format!("cannot write {}: {error}", path.display())))
}
/// Charger la preuve mise en cache et rejeter tout ce qui ne correspond pas à cette requête.
///
/// Vérifier la version, le backend et le SQL exact empêche une entrée de cache créée sous
/// des hypothèses différentes de piloter silencieusement la génération de code Rust.
fn load_cache(sql: &str, span: Span) -> syn::Result {
let path = cache_path(sql, span)?;
let bytes = fs::read(&path).map_err(|error| {
syn::Error::new(
span,
format!("offline metadata missing at {}: {error}", path.display()),
)
})?;
let description: Description = serde_json::from_slice(&bytes)
.map_err(|error| syn::Error::new(span, format!("invalid offline metadata: {error}")))?;
// Les trois vérifications font partie de la limite de confiance du cache.
if description.version != CACHE_VERSION
|| description.database != "SQLite"
|| description.sql != sql
{
Err(syn::Error::new(
span,
"offline metadata does not match this SQLite query",
))
} else {
Ok(description)
}
}
#+end_src
- Exécuter la démo en ligne, puis prouver la construction hors ligne :
#+begin_src bash :results output
./scripts/toy-demo.sh 7
./scripts/toy-demo.sh offline
#+end_src
#+RESULTS:
: Compiling toy-demo v0.1.0 (/Users/chiefkemist/Documents/native_workspace/mini-sqlx-workshop/toy-demo)
: Finished dev profile [unoptimized + debuginfo] target(s) in 0.85s
: Running target/debug/07_offline_metadata
: decoded 3 users using a compile-time description
: offline: success plus missing, malformed, version, backend, and SQL mismatch checks passed
- Observer : préparer les métadonnées en ligne, supprimer la base de données, forcer l'expansion, et
compiler avec succès à partir du cache. - Nous avons gagné : la génération de code consomme une =Description= indépendamment du fait que
sa preuve provienne de SQLite en direct ou d'un cache vérifié. - Limite : la fraîcheur du cache, la résistance aux collisions, les écritures atomiques et les écrivains
concurrents sont des préoccupations de production. Le jouet vérifie les métadonnées manquantes, malformées,
mauvaise version, mauvais backend et mauvais SQL.
** Vérification emmêlée et présentateur :noexport:
L'histoire principale se termine ci-dessus. Les blocs restants automatisent les tests, les démonstrations,
les échecs attendus et les vérifications de taille ; ce ne sont pas des étapes pédagogiques supplémentaires.
#+name: focused-macro-tests
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// Ces tests ciblent les petites politiques qui sont les plus faciles à casser lors de l'édition
// de l'atelier : mappage de type, rejet de forme de requête, noms de champs et hashes de cache.
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[rustfmt::skip]
fn maps_the_teaching_types() {
// Comparer les jetons émis sous forme de texte car ces assistants génèrent du code, pas des valeurs.
assert_eq!(rust_type("INTEGER", Span::call_site()).unwrap().to_string(), "i64");
assert_eq!(
rust_type("TEXT", Span::call_site()).unwrap().to_string(),
":: std :: string :: String"
);
assert!(rust_type("NUMERIC", Span::call_site()).is_err());
}
#[test]
#[rustfmt::skip]
fn rejects_unsupported_sources_and_shapes() {
// Chaque forme incertaine doit échouer fermée plutôt que de générer un type non sain.
assert!(ensure_typed_shape("SELECT * FROM a LEFT JOIN b", Span::call_site()).is_err());
assert!(ensure_typed_shape("DELETE FROM a RETURNING id", Span::call_site()).is_err());
assert!(ensure_typed_shape("SELECT (SELECT id FROM a) FROM b", Span::call_site()).is_err());
assert_eq!(direct_source("SELECT id FROM users"), Some("users"));
assert_eq!(direct_source("SELECT id FROM users AS u WHERE u.id > 0"), Some("users"));
assert_eq!(direct_source("SELECT bid /* FROM users */ FROM hidden_join"), None);
assert_eq!(direct_source("SELECT u.id FROM users u , hidden_join v"), None);
assert_eq!(direct_source("SELECT (VALUES(NULL),(email)) AS email FROM users"), None);
}
#[test]
#[rustfmt::skip]
fn rejects_bad_and_duplicate_fields() {
// Les noms SQL invalides ou répétés ne peuvent pas former une structure Rust valide.
assert!(rust_ident("bad name", Span::call_site()).is_err());
let column = || Column { name: "id".into(), declared_type: Some("INTEGER".into()), nullable: false };
let description = Description { version: CACHE_VERSION, database: "SQLite".into(), sql: "SELECT id, id FROM users".into(), parameter_count: 0, source_is_table: true, columns: vec![column(), column()] };
assert!(typed_columns(&description, Span::call_site()).is_err());
}
#[test]
fn hash_is_stable() {
// Changer cette valeur déplacerait chaque fichier de cache et nécessite une décision explicite.
assert_eq!(hash("SELECT 1"), 0x199e7bca63ea84f2);
}
}
#+end_src
#+name: offline-demo
#+begin_src rust :tangle toy-demo/src/bin/07_offline_metadata.rs :mkdirp yes :comments no
fn main() -> Result<(), toy_sqlx::rusqlite::Error> {
let url = std::env::var("TOY_DATABASE_URL").expect("TOY_DATABASE_URL is required");
let connection = toy_sqlx::connect(&url)?;
let users = toy_sqlx::query!(&connection, "SELECT id, email FROM users ORDER BY id")?;
println!(
"decoded {} users using a compile-time description",
users.len()
);
Ok(())
}
#+end_src
#+name: presentation-runner
#+begin_src bash :tangle scripts/toy-demo.sh :mkdirp yes :comments no :tangle-mode (identity #o755)
#!/usr/bin/env bash
set -euo pipefail
root="(cd -- "(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
export TOY_DATABASE_URL="{TOY_DATABASE_URL:-sqlite://root/toy-demo/toy.db}"
database_path() {
case "$1" in
sqlite://) printf '%s\n' "${1#sqlite://}" ;;
sqlite:) printf '%s\n' "${1#sqlite:}" ;;
*) printf '%s\n' "$1" ;;
esac
}
setup() {
cargo run --quiet -p toy-demo --bin setup
}
run_stage() {
local bin="0{1}_2" pattern="$3" output
setup >/dev/null
output="(cargo run -p toy-demo --bin "bin" 2>&1)"
printf '%s\n' "$output"
grep -Eq "pattern" <<<output
}
expect_failure() {
local label="1" bin="2" feature="3" pattern="4" setup_first="${5:-yes}" log
[[ "$setup_first" == yes ]] && setup >/dev/null
log="$(mktemp)"
if cargo check -p toy-demo --bin "bin" --features "feature" >"$log" 2>&1; then
cat "$log"
rm -f "$log"
echo "$label unexpectedly compiled" >&2
return 1
fi
if ! grep -Eq "pattern" "log"; then
cat "$log"
rm -f "$log"
echo "$label failed without the expected diagnostic" >&2
return 1
fi
rm -f "$log"
echo "$label: failed as intended"
}
create_hidden_view() {
local database
database="(database_path "TOY_DATABASE_URL")"
python3 - "$database" <<'PY'
import sqlite3, sys
connection = sqlite3.connect(sys.argv[1])
connection.execute("CREATE VIEW hidden_join AS SELECT b.id AS bid FROM users a LEFT JOIN users b ON b.id = -1")
connection.commit()
PY
}
offline_failure() {
local pattern="$1" log
log="$(mktemp)"
cargo clean -p toy-demo >/dev/null
if TOY_SQLX_OFFLINE=1 cargo check -p toy-demo --bin 07_offline_metadata >"$log" 2>&1; then
cat "$log"
rm -f "$log"
echo "invalid offline metadata unexpectedly compiled" >&2
return 1
fi
grep -Eq "pattern" "log" || { cat "log"; rm -f "log"; return 1; }
rm -f "$log"
}
offline() {
local database backup cache saved
setup >/dev/null
rm -rf toy-demo/.toy-sqlx
cargo clean -p toy-demo >/dev/null
TOY_SQLX_PREPARE=1 cargo check -p toy-demo --bin 07_offline_metadata
database="(database_path "TOY_DATABASE_URL")"
backup="${database}.offline-backup"
cache="$(find toy-demo/.toy-sqlx -name 'query-*.json' -print -quit)"
saved="${cache}.saved"
cp "cache" "saved"
cleanup() {
[[ -e "backup" ]] && mv "backup" "$database"
[[ -e "saved" ]] && mv -f "saved" "$cache"
}
trap cleanup EXIT
rm -f "$backup"
mv "database" "backup"
cargo clean -p toy-demo >/dev/null
TOY_SQLX_OFFLINE=1 cargo check -p toy-demo --bin 07_offline_metadata
rm "$cache"
offline_failure 'offline metadata missing'
cp "saved" "cache"
printf '{' >"$cache"
offline_failure 'invalid offline metadata'
for field in version database sql; do
cp "saved" "cache"
python3 - "cache" "field" <<'PY'
import json, sys
path, field = sys.argv[1:]
data = json.load(open(path))
if field == "version": data[field] += 1
else: data[field] += "-wrong"
open(path, "w").write(json.dumps(data))
PY
offline_failure 'offline metadata does not match'
done
cleanup
trap - EXIT
echo "offline: success plus missing, malformed, version, backend, and SQL mismatch checks passed"
}
size() {
local macro_prod macro_tests runtime demos total
read -r macro_prod macro_tests <<(awk '
BEGIN { test = 0; production = 0; tests = 0 }
/^#[cfg(test)]/ { test = 1 }
/^[[:space:]]$/ || /^[[:space:]]/// { next }
{ if (test) tests++; else production++ }
END { print production, tests }
' toy-sqlx-macros/src/lib.rs)
runtime="(awk '!/^[[:space:]]*/ && !/^[[:space:]]///' toy-sqlx/src/lib.rs | wc -l | tr -d ' ')"
demos="(awk '!/^[[:space:]]*/ && !/^[[:space:]]///' toy-demo/src/bin/*.rs | wc -l | tr -d ' ')"
total=$((macro_prod + macro_tests + runtime + demos))
printf 'macro production=%s tests=%s runtime=%s demos=%s total=%s\n'
"macro_prod" "macro_tests" "runtime" "demos" "$total"
((macro_prod <= 340 && macro_tests <= 50 && runtime <= 80 && demos <= 180 && total <= 650))
}
workspace() {
setup >/dev/null
scripts/setup-db.sh >/dev/null
cargo test --workspace
cargo clippy -p toy-sqlx -p toy-sqlx-macros -p toy-demo --all-targets -- -D warnings
cargo +1.85.0 check -p toy-sqlx -p toy-sqlx-macros -p toy-demo
cargo fmt --all -- --check
}
all() {
"$0" 0
"$0" 1
"$0" 2
"$0" 3
"$0" 4
"$0" 5
"$0" 6
"$0" 7
"$0" fail-literal
"$0" fail-sql
"$0" fail-arity
"$0" fail-query-as-name
"$0" fail-query-as-type
"$0" fail-expression
"$0" fail-shape
"$0" fail-view
"$0" fail-subquery
"$0" offline
"$0" size
}
case "${1:-}" in
setup) setup ;;
0) run_stage 0 runtime_sql 'runtime SQLite error' ;;
- run_stage 1 literal_macro 'SELECT 40 + 2 AS answer' ;;
- run_stage 2 compile_time_prepare 'SQLite accepted during compilation' ;;
- run_stage 3 parameter_arity 'parameter type deliberately unchecked' ;;
- run_stage 4 typed_record 'grace@example.test None' ;;
- run_stage 5 query_as 'alan@example.test' ;;
- run_stage 6 nullability 'grace@example.test None' ;;
- run_stage 7 offline_metadata 'decoded 3 users using a compile-time description' ;;
fail-literal) expect_failure "$1" 01_literal_macro fail-literal 'expected string literal' ;;
fail-sql) expect_failure "$1" 02_compile_time_prepare fail-invalid-sql 'SQLite rejected this query' ;;
fail-arity) expect_failure "$1" 03_parameter_arity fail-arity 'SQLite expects 2 parameter.*received 1' ;;
fail-query-as-name) expect_failure "$1" 05_query_as fail-query-as-name 'has no field named.*email' ;;
fail-query-as-type) expect_failure "$1" 05_query_as fail-query-as-type 'incompatible types|mismatched types' ;;
fail-expression) expect_failure "$1" 06_nullability fail-expression-metadata 'direct columns from one table' ;;
fail-shape) expect_failure "$1" 06_nullability fail-unsupported-shape 'joins and compound queries are out of scope' ;;
fail-view)
setup >/dev/null
create_hidden_view
expect_failure "$1" 06_nullability fail-view-source 'direct columns from one table' no
;;
fail-subquery) expect_failure "$1" 06_nullability fail-subquery-source 'direct columns from one table' ;;
offline) offline ;;
size) size ;;
workspace) workspace ;;
all) all ;;
)
echo "usage: scripts/toy-demo.sh {setup|0|1|2|3|4|5|6|7|offline|fail-|all|workspace|size}" >&2
exit 2
;;
esac
#+end_src
- Récapitulatif : assembler le pipeline
La progression était une chaîne de retours plus précoces :
#+begin_example
runtime SQL error
-> compile-time literal
-> SQLite validation
-> arity validation
-> generated record
-> application struct
-> evidence-backed nullability
-> offline compilation
#+end_example
Rien ici n'a obligé la macro à devenir une base de données. La macro ne fait que transporter
la preuve de SQLite dans du code Rust que rustc sait déjà comment vérifier.
| Revendication finale | Preuve utilisée | Limite honnête |
|---+---+---|
| SQL est valide | SQLite l'a préparé pendant l'expansion | seulement le schéma de build |
| Le nombre de liaisons correspond | nombre de paramètres SQLite | pas de types de liaison SQLite |
| Les champs de sortie existent | métadonnées de colonne de déclaration | colonnes directes uniquement |
| La forme de sortie Rust correspond | enregistrement généré/littéral de structure | les valeurs dynamiques peuvent mal se décoder |
| Colonne directe nullable | schéma =NOT NULL= métadonnées | jointures/expressions rejetées |
| Build sans DB | =Description= mise en cache | la fraîcheur du cache est une discipline externe |
Le modèle d'ingénierie réutilisable est : demander à la base de données, préserver sa preuve,
générer du Rust ordinaire, et laisser rustc terminer la vérification.
- Q&R
Le contenu principal s'arrête ici à la minute 80. Les sections suivantes ne sont pas requises pour l'histoire principale.
- Annexe
** Typage des paramètres dépendant du backend
Les backends de type PostgreSQL peuvent renvoyer des métadonnées de type de paramètre et générer des assertions de type Rust
pour le code mort. SQLite ne renvoie que l'arité. Ajouter un analyseur SQLite inventé
au cœur obscurcirait cette limite.
** Remplacements d'expression
Un jouet plus grand peut analyser des alias tels que ="count!: INTEGER"=. Le cœur rejette
les expressions afin que chaque type inféré ait des métadonnées directes.
** Nullabilité consciente des jointures
Les systèmes réels peuvent inspecter les plans ou le bytecode SQLite. Le =mini-sqlx-macros= avancé
démontre pourquoi cela étend rapidement l'implémentation.
** Caches plus forts
Discutez des hashes cryptographiques, de l'identité du schéma, des écritures atomiques, des verrous et de la fraîcheur du cache CI
sans les emmêler dans l'implémentation pédagogique.
** Runtimes asynchrones et pools
Ils changent l'exécution, pas le pipeline de preuve à la compilation/génération de code.
** Migrations et CI
La macro vérifie le schéma présent pendant le build. L'intégration continue devrait créer une
DB isolée, appliquer les migrations, compiler/préparer les requêtes et vérifier la preuve mise en cache.
** Mapper le jouet vers SQLx
La =Description= du jouet correspond à un résultat de description de backend ; =load_description=
correspond à la sélection de données de requête en ligne/hors ligne ; =expand_query= correspond à
la génération de code d'argument/sortie. Les noms sont conceptuels, pas des revendications
de fidélité à la source.
** Github
[[https://github.com/chiefkemist/toy-sqlx][toy-sqlx -- Pas un clone de SQLx]]

