Ubuntu TechHive
inside-sqlx-building-compile-time-checked-sql-from-lego-brick.md
Inside SQLx — building compile-time checked SQL from Lego brick
article.detalle

Inside SQLx — building compile-time checked SQL from Lego brick

reading.progreso 43 min de lectura

El objetivo es comprender, cuando se trata de `Rust` y `SQL`, de dónde provienen realmente las garantías de consulta en tiempo de compilación.

#+title: Inside SQLx — construyendo SQL verificado en tiempo de compilación a partir de bloques de Lego
#+author: ChiefKemist
#+date: <2026-07-25 Sat>

  • Introducción

Estamos construyendo un SQLx de juguete para entender algunas de sus características principales y cómo podrían construirse. Al mismo tiempo, exploraremos algunas características de Rust + su ecosistema, como las macros y los espacios de trabajo de Cargo. El objetivo final es entender, cuando se trata de Rust y SQL, de dónde provienen realmente las garantías de consulta en tiempo de compilación.

Comenzamos con SQL que Rust trata como una cadena no verificada, y terminamos con esto:

#+begin_src rust :tangle no
let users = toy_sqlx::query!(
&connection,
"SELECT id, email FROM users WHERE active = ?1",
true,
)?;
#+end_src

donde una llamada a macro hará que tres sistemas contribuyan a la corrección:

  • SQLite prueba que el SQL es válido para el esquema de compilación.
  • La macro convierte la evidencia de SQLite en código Rust.
  • rustc verifica los campos generados y las estructuras de la aplicación.

No clonaremos SQLx, en su lugar, expondremos la tubería de tiempo de compilación más pequeña y útil para imponer un SQL correcto en el código Rust.

  • Acerca de las Macros

#+begin_quote
“Todo el lenguaje está ahí todo el tiempo. No hay una distinción real entre tiempo de lectura, tiempo de compilación y tiempo de ejecución. Puedes compilar o ejecutar código mientras lees, leer o ejecutar código mientras compilas, y leer o compilar código en tiempo de ejecución.

Ejecutar código en tiempo de lectura permite a los usuarios reprogramar la sintaxis de Lisp; ejecutar código en tiempo de compilación es la base de las macros; compilar en tiempo de ejecución es la base del uso de Lisp como lenguaje de extensión en programas como Emacs; y leer en tiempo de ejecución permite a los programas comunicarse usando s-expresiones, una idea recientemente reinventada como XML.”
#+end_quote

— Paul Graham, /Revenge of the Nerds/, en /Hackers & Painters/,
sección “What Made Lisp Different,” ítem 9.

Referencia: [[https://paulgraham.com/icad.html][Paul Graham — Revenge of the Nerds]]

  • Andamiaje: tres crates

Crates del espacio de trabajo de 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

  • Paso 0: exponer el fallo en tiempo de ejecución
    :PROPERTIES:
    :MINUTES: 5
    :END:

#+begin_quote
Problema: Rust ve el SQL como texto ordinario, por lo que no puede rechazar una columna incorrecta.

Por qué importa: la primera retroalimentación útil llega solo después de que el programa se compila, se despliega y se ejecuta la ruta de la consulta.

Este paso: crear una pequeña base de datos y preparar deliberadamente una consulta inválida en tiempo de ejecución. Esta es la línea base que cada paso posterior debe mejorar.
#+end_quote

El fixture solo tiene la evidencia del esquema que necesitaremos más adelante: tipos de columna declarados y una columna anulable. El programa de configuración no utiliza ninguna macro verificada.

#+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

  • Ejecutar el paso:

#+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

  • Observar: Rust compila; SQLite reporta =definitely_missing= en tiempo de ejecución.
  • Ganamos: un fallo concreto para moverlo más temprano.
  • Aún roto: el compilador nunca recibe SQL como entrada inspeccionable. El paso 1
    le da el SQL a una macro procedimental.
  • Paso 1: hacer que el SQL sea visible en tiempo de compilación
    :PROPERTIES:
    :MINUTES: 6
    :END:

#+begin_quote
Problema: una macro procedimental recibe tokens de Rust, no el valor oculto dentro de una variable en tiempo de ejecución.

Por qué importa: la verificación en tiempo de compilación es imposible a menos que el SQL mismo esté disponible durante la expansión de la macro.

Este paso: requerir un literal de cadena. La primera macro solo analiza y devuelve el literal, para que podamos ver este límite sin mezclar la lógica de la base de datos.
#+end_quote

El crate de tiempo de ejecución a continuación es la fontanería de soporte: conectar a SQLite y mapear filas.
No realiza ningún análisis en tiempo de compilación.

#+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 modelo deliberadamente pequeño, solo para SQLite, de las macros de consulta en tiempo de compilación de SQLx.
//!
//! # La idea central
//!
//! Una macro procedimental se ejecuta mientras se compila el crate que la llamó. Recibe
//! tokens de Rust, le pregunta a SQLite qué significa una cadena SQL y emite nuevos
//! tokens de Rust. El Rust emitido luego pasa por el compilador de Rust normal.
//!
//! La tubería es:
//!
//! 1. [syn] analiza la entrada de la macro en estructuras de datos de Rust.
//! 2. [rusqlite] prepara el SQL y devuelve metadatos de parámetros/columnas.
//! 3. Este crate valida el subconjunto de enseñanza deliberadamente pequeño.
//! 4. [quote!] construye código Rust ordinario a partir de esos metadatos.
//! 5. rustc verifica los campos, tipos y literales de estructura generados.
//!
//! Este es código de enseñanza, no un analizador SQL general. Las consultas tipadas
//! aceptan intencionalmente solo columnas directas de una tabla real de SQLite.

// Herramientas de la biblioteca estándar utilizadas para nombres, variables de entorno, archivos de caché y rutas.
use std::{collections::HashSet, env, fs, path::PathBuf};

// proc_macro::TokenStream es el tipo de entrada y salida orientado al compilador.
use proc_macro::TokenStream;
// proc_macro2 proporciona tipos de token que son más fáciles de construir y probar.
use proc_macro2::{Ident, Span, TokenStream as Tokens};
// quote! convierte sintaxis tipo Rust en tokens; format_ident! crea identificadores.
use quote::{format_ident, quote};
// SQLite es tanto la base de datos en tiempo de ejecución como nuestra autoridad SQL en tiempo de compilación.
use rusqlite::{Connection, OpenFlags};
// Las descripciones se serializan para que la misma evidencia pueda usarse sin conexión.
use serde::{Deserialize, Serialize};
// syn analiza literales, expresiones, tipos, comas y gramáticas de macros personalizadas.
#[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
/// Requerir que la entrada de la macro sea un literal de cadena, luego emitir ese literal sin cambios.
///
/// Esta primera macro de enseñanza no entiende SQL. Su único trabajo es mostrar
/// que la verificación en tiempo de compilación necesita entrada en tiempo de compilación: sql_literal!(variable)
/// falla porque variable no es un [LitStr].
#[proc_macro]
pub fn sql_literal(input: TokenStream) -> TokenStream {
// parse_macro_input! detiene la expansión y emite un diagnóstico del compilador en caso de fallo.
let sql = parse_macro_input!(input as LitStr);
// #sql interpola el literal analizado en el flujo de tokens construido por quote!.
quote!(#sql).into()
}

/// Convertir nuestro syn::Result interno en el flujo de tokens requerido por rustc.
///
/// Las macros procedimentales no pueden devolver Result. Por lo tanto, un syn::Error se convierte en una
/// invocación de compile_error! colocada en el intervalo de origen más útil.
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

Usa =cargo expand= para probar que la macro emite el literal sin cambios. El primer
comando confirma que =cargo-expand= está instalado. Si falta, ejecuta
=cargo install cargo-expand --locked= una vez.

#+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"));
: };
: }

La expansión útil es intencionalmente aburrida:

#+begin_src rust :tangle no
"SELECT 40 + 2 AS answer"
#+end_src

  • Ejecutar el paso y su fallo esperado:

#+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

  • Observar: un literal compila; un =String= en tiempo de ejecución es rechazado por el analizador de macros.
  • Ganamos: la macro posee el texto SQL durante la compilación.
  • Aún roto: poseer texto no prueba un SQL válido. El paso 2 le pregunta al sistema
    que realmente entiende SQLite.
  • Paso 2: dejar que SQLite rechace SQL incorrecto durante la compilación
    :PROPERTIES:
    :MINUTES: 12
    :END:

#+begin_quote
Problema: la macro ahora puede ver el SQL, pero todavía no puede decir si una tabla
o columna existe.

Por qué importa: escribir un segundo analizador de SQLite dentro de la macro sería grande,
incompleto y menos confiable que SQLite mismo.

Este paso: durante la expansión de la macro, abrir la base de datos de compilación en modo de solo lectura y pedirle a
SQLite que prepare la consulta. Convertir la respuesta de SQLite en una pequeña =Description=
que los pasos posteriores puedan reutilizar.
#+end_quote

Solo hay tres piezas móviles: la descripción, el punto de entrada de la macro y
el cargador en línea. En este paso, observa la llamada a =connection.prepare=. Los
otros campos de descripción se vuelven útiles en los pasos 3, 4 y 6.

#+name: description-ir
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// Aumenta esto cada vez que cambie el significado o la forma de los metadatos en caché. Las entradas de caché antiguas
// fallan claramente en lugar de ser interpretadas bajo nuevas reglas.
const CACHE_VERSION: u8 = 3;

/// La pequeña representación intermedia compartida por la verificación en línea y fuera de línea.
///
/// Piensa en Description como una hoja de datos sobre una cadena SQL:
///
/// - parameter_count dice cuántos valores espera SQLite;
/// - columns describe el conjunto de resultados;
/// - source_is_table registra si se probó nuestra regla estricta de consulta tipada;
/// - version, database y sql protegen la carga de caché fuera de línea.
///
/// Mantener una representación es importante: la generación de código no necesita saber
/// si estos hechos provienen de SQLite en vivo o de un archivo de caché.
#[rustfmt::skip]
#[derive(Debug, Serialize, Deserialize)]
struct Description { version: u8, database: String, sql: String, parameter_count: usize, source_is_table: bool, columns: Vec }

/// La evidencia de SQLite necesaria para generar un campo de salida de Rust.
///
/// declared_type es el tipo escrito en CREATE TABLE; no es el tipo de
/// cada valor que SQLite podría almacenar dinámicamente. nullable decide entre T
/// y Option<T> en el Rust generado.
#[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
/// Pedir a SQLite que valide un literal SQL durante la expansión de la macro.
///
/// El valor emitido sigue siendo solo el literal de cadena original. La mejora
/// es el tiempo: el SQL inválido se convierte en un error del compilador en lugar de un error en tiempo de ejecución.
#[proc_macro]
pub fn checked_sql(input: TokenStream) -> TokenStream {
let sql = parse_macro_input!(input as LitStr);
// Cargar la descripción realiza la validación; esta macro descarta los hechos.
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
/// Obtener evidencia de consulta de exactamente una fuente.
///
/// El modo fuera de línea lee una descripción guardada previamente. El modo en línea le pregunta a SQLite
/// directamente y opcionalmente guarda la respuesta para una compilación fuera de línea posterior.
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)
}

/// Reconocer la forma SQL deliberadamente pequeña admitida por las consultas tipadas.
///
/// Este es un protector de alcance, no un analizador SQL. Devuelve un nombre de tabla solo para una
/// proyección de columnas directas de una tabla simple. Cualquier incertidumbre devuelve
/// None, lo que hace que la macro tipada rechace la consulta en lugar de inventar hechos.
#[rustfmt::skip]
fn direct_source(sql: &str) -> Option<&str> {
// Los comentarios podrían ocultar cláusulas FROM adicionales, por lo que este juguete los rechaza directamente.
if ["--", "/", "/"].iter().any(|marker| sql.contains(marker)) { return None; }
// La tokenización por espacios en blanco es suficiente porque cada forma aceptada es simple.
let words = sql.split_whitespace().collect::<Vec<>>();
// Exactamente un FROM excluye subconsultas SELECT ordinarias y fuentes ambiguas.
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 };
// La primera palabra después de FROM debe ser un identificador de tabla sin comillas y sin calificar.
let table = *words.get(from + 1)?;
// Detener la cláusula de origen cuando comienza el filtrado/ordenamiento ordinario.
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 == '
');
// Cada campo seleccionado debe ser column, table.column, o cualquiera de las formas con 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])))) };
// Después de la tabla, no permitir alias, alias, o AS alias—nunca una segunda fuente.
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)
}

/// Pedir a una base de datos SQLite en vivo que describa una consulta durante la compilación.
///
/// La preparación valida la sintaxis, los nombres de tablas y los nombres de columnas sin ejecutar la
/// consulta. span apunta los diagnósticos de vuelta al literal SQL en el código del llamador.
#[rustfmt::skip]
fn describe_online(sql: &str, span: Span) -> syn::Result {
// Cargo ejecuta esta función dentro del proceso proc-macro, no en el programa final.
let url = env::var("TOY_DATABASE_URL")
.map_err(|| syn::Error::new(span, "TOY_DATABASE_URL is required for online checking"))?;
// Aceptar el mismo pequeño conjunto de formas de URL de SQLite que el crate de tiempo de ejecución.
let path = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(&url);
// El modo de solo lectura evita que una compilación modifique la base de datos de enseñanza.
let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|error| syn::Error::new(span, format!("cannot open SQLite: {error}")))?;
// Esta es la verificación clave en tiempo de compilación: dejar que SQLite valide el SQL de SQLite.
let statement = connection.prepare(sql).map_err(|error| {
syn::Error::new(
span,
format!("SQLite rejected this query during compilation: {error}"),
)
})?;
// Copiar solo los hechos de salida necesarios para la generación de código Rust posterior.
let columns = (0..statement.column_count())
.map(|index| {
let name = statement.column_name(index)?.to_owned();
let metadata = statement.column_metadata(index)?;
// rusqlite expone varios campos de origen; este juguete necesita declaración y NOT NULL.
let (declared_type, nullable) = match metadata {
Some((
, _, _, declared, _, not_null, _, )) => (
declared.map(|value| value.to_string_lossy().into_owned()),
!not_null,
),
// La evidencia de origen faltante nunca debe tratarse como no nula.
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}")))?;
// El reconocimiento léxico no es suficiente: sqlite_schema debe confirmar una tabla real, no una vista.
#[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 declaración preparada misma suministra el conteo de marcadores de posición autorizado 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

  • Ejecutar el paso y su fallo esperado:

#+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

  • Observar: =definitely_missing= es ahora un error del compilador.
  • Ganamos: SQLite aceptó la consulta contra la base de datos de compilación.
  • Límite: la base de datos en tiempo de ejecución todavía puede tener un esquema diferente.
  • Aún roto: las consultas válidas todavía devuelven filas sin tipo. El paso 3
    verifica los marcadores de posición contra los argumentos.
  • Paso 3: rechazar el número incorrecto de argumentos
    :PROPERTIES:
    :MINUTES: 8
    :END:

#+begin_quote
Problema: una consulta puede ser válida mientras sus marcadores de posición =?1=, =?2= no coinciden con el
número de valores suministrados por Rust.

Por qué importa: esa falta de coincidencia de lo contrario se convierte en otro fallo de preparación/vinculación en tiempo de ejecución.

Este paso: comparar el conteo de parámetros de SQLite con el número de argumentos de la macro.
Mantener las expresiones de Rust visibles para rustc sin evaluarlas.
#+end_quote

Esto es deliberadamente solo de aridad. SQLite no proporciona tipos de enlace estáticos.

#+name: checked-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrada analizada para checked_query!("SQL", arg1, arg2, ...).
///
/// Punctuated es la representación de syn de cero o más expresiones separadas por
/// comas. Mantener las expresiones como sintaxis nos permite contarlas sin ejecutarlas.
#[rustfmt::skip]
struct CheckedInput { sql: LitStr, args: Punctuated<Expr, Token![,]> }

/// Enseñar a syn la pequeña gramática aceptada por checked_query!.
impl Parse for CheckedInput {
fn parse(input: ParseStream<'_>) -> syn::Result {
// El primer token debe ser el literal de cadena SQL.
let sql = input.parse()?;
let args = if input.is_empty() {
// Una consulta sin marcadores de posición no necesita coma ni argumentos.
Punctuated::new()
} else {
// De lo contrario, consumir la coma después de SQL, luego todas las expresiones separadas por comas.
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
/// Validar SQL más aridad de parámetros, pero no ejecutar la consulta.
///
/// Esta macro de transición aísla una lección: SQLite puede decirnos cuántos
/// parámetros espera, pero no los tipos de parámetros estáticos de SQLite.
#[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
/// Implementar checked_query! después de que sus tokens hayan sido analizados.
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! {{
// Esta rama nunca se ejecuta, pero rustc todavía resuelve y verifica el tipo de cada expresión.
// #(...)* es la sintaxis de repetición de quote: emitir el cuerpo una vez por argumento.
if false {
#(let _ = &(#args);)*
}
// El valor en tiempo de ejecución de la macro sigue siendo la cadena SQL original.
#sql
}})
}
#+end_src

Expande la demostración para ver el argumento no evaluado retenido dentro de =if false=:

#+begin_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_bash

#+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

  • Ejecutar el paso y su fallo esperado:

#+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

  • Observar: un valor para dos marcadores de posición falla; un valor para un marcador de posición
    pasa—incluso cuando ese valor es deliberadamente una cadena inapropiada.
  • Ganamos: el conteo de enlaces coincide.
  • Límite: no se hace ninguna afirmación de tipo de enlace estático de SQLite.
  • Aún roto: las consultas exitosas todavía devuelven filas sin tipo. El paso 4 genera
    la forma de salida de Rust y el decodificador.
  • Paso 4: generar un registro tipado y un decodificador
    :PROPERTIES:
    :MINUTES: 17
    :END:

#+begin_quote
Problema: incluso el SQL verificado todavía requiere llamadas repetitivas =row.get(0)=,
=row.get(1)= y tipos de Rust escritos a mano.

Por qué importa: la decodificación posicional es frágil, y la forma del resultado SQL se duplica
en Rust a mano.

Este paso: convertir los nombres de columna y tipos declarados en =Description= en un =Record= local,
luego generar el decodificador posicional que lo construye.
#+end_quote

El mapeo de tipo declarado es intencionalmente visible y pequeño:

| La declaración de SQLite contiene | Tipo base de Rust generado |
|---+---|
| =BOOL= o =BOOLEAN= | =bool= |
| =INT= | =i64= |
| =REAL=, =FLOA=, o =DOUB= | =f64= |
| =CHAR=, =CLOB=, o =TEXT= | =String= |
| =BLOB= | =Vec= |
| cualquier otra cosa | error en tiempo de compilación |

El paso 6 explica cuándo un tipo base permanece =T= y cuándo la nulabilidad lo envuelve en
=Option=.

#+name: query-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrada analizada para query!(&connection, "SQL", arg1, arg2, ...).
///
/// La conexión y cada argumento son expresiones completas de Rust. Se mantienen como
/// sintaxis hasta que la expansión emite código que evalúa cada uno exactamente una vez.
#[rustfmt::skip]
struct QueryInput { connection: Expr, sql: LitStr, args: Punctuated<Expr, Token![,]> }

/// Analizar la conexión primero, luego reutilizar CheckedInput para SQL y argumentos.
#[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
/// Validar y ejecutar una consulta cuyo registro de salida es generado por la macro.
///
/// El tipo generado local tiene un campo de Rust por cada columna SQL seleccionada.
#[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
/// Elegir qué valor de Rust construye el decodificador de filas.
enum Output {
/// query! nos pide definir un tipo Record local.
Generated,
/// query_as! nos da un tipo propiedad de la aplicación para construir.
Given(Box),
}

/// Una columna de salida SQL verificada traducida a un nombre de campo de Rust y tokens de tipo.
#[rustfmt::skip]
struct RustColumn { ident: Ident, ty: Tokens }

/// Construir el programa Rust emitido por query! y query_as!.
///
/// Todo antes del quote! final se ejecuta durante la compilación. El código dentro
/// de ese quote! es lo que el programa del llamador ejecutará en tiempo de ejecución.
#[rustfmt::skip]
fn expand_query(input: QueryInput, output: Output) -> syn::Result {
// Fase de tiempo de compilación: recopilar evidencia y rechazar entradas no admitidas.
let description = load_description(&input.sql)?;
validate_arity(input.args.len(), &description, input.sql.span())?;
let columns = typed_columns(&description, input.sql.span())?;

// Convertir cada columna verificada en tokens `field_name: RustType`.
let fields = columns.iter().map(|column| {
    let ident = &column.ident;
    let ty = &column.ty;
    quote!(#ident: #ty)
});
// Convertir cada columna verificada 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();

// Ambas macros públicas comparten un decodificador; solo difiere su constructor final.
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;
// Los nombres locales generados nos permiten evaluar cada expresión del llamador exactamente una vez.
let names: Vec<_> = (0..args.len())
    .map(|index| format_ident!("__toy_arg_{index}"))
    .collect();
let args = args.into_iter().collect::<Vec<_>>();

// Fase de tiempo de ejecución: todo este bloque se inserta en el sitio de llamada de la macro.
Ok(quote! {{
    #definition
    // Los lados derechos de las tuplas se evalúan antes de que se vinculen los nombres generados.
    let (__toy_connection, #(#names,)*) = (#connection, #(&(#args),)*);
    // rusqlite acepta un corte de referencias a valores que implementan `ToSql`.
    let __toy_parameters: &[&dyn ::toy_sqlx::rusqlite::ToSql] = &[#(#names),*];
    ::toy_sqlx::map_rows(
        __toy_connection,
        #sql,
        __toy_parameters,
        // Decodificar una fila de SQLite en la estructura generada o proporcionada por el llamador.
        |__toy_row| ::core::result::Result::Ok(#expression),
    )
}})

}

/// Comparar el número de argumentos de Rust con el conteo de marcadores de posición de SQLite.
///
/// Esto verifica intencionalmente solo cuántos valores existen. SQLite no le da
/// a este juguete tipos de parámetros estáticos estables, por lo que afirmar más sería engañoso.
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}"),
))
}
}

/// Convertir columnas de SQLite en nombres de campo y tipos de Rust seguros.
///
/// La función primero impone el límite de evidencia, luego verifica nombres, rechaza
/// campos duplicados, mapea declaraciones de SQLite y aplica nulabilidad.
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 nombre de salida SQL debe ser utilizable en un literal de estructura de Rust generado.
let ident = rust_ident(&column.name, span)?;
if !names.insert(ident.to_string()) {
return Err(syn::Error::new(span, "duplicate output field name"));
}
// Las expresiones a menudo no tienen declaración de tabla; la salida tipada las rechaza.
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 representa un valor SQL posiblemente NULL como Option<T>.
let ty = if column.nullable {
quote!(::core::option::Option<#base>)
} else {
base
};
Ok(RustColumn { ident, ty })
})
.collect()
}

/// Rechazar formas SQL amplias antes de generar Rust tipado.
///
/// Las uniones y consultas compuestas necesitan un análisis de nulabilidad que este taller no
/// implementa. El rechazo conservador mantiene honesta la pequeña garantía.
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 nombre de salida de SQLite en un identificador de campo de Rust.
///
/// La forma cruda (r#type, por ejemplo) permite palabras clave de Rust cuando es legal. El
/// respaldo maneja identificadores ordinarios en versiones del compilador con diferente
/// comportamiento de análisis de identificadores crudos.
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")))
}

/// Mapear un pequeño conjunto de tipos declarados de SQLite a tokens de tipo de Rust.
///
/// SQLite usa afinidad de tipo y permite valores almacenados dinámicos. Esta tabla es un
/// subconjunto de enseñanza, no una afirmación de que cada valor almacenado debe tener este tipo de Rust.
///
/// | La declaración contiene | Tipo base generado |
/// | --- | --- |
/// | BOOL o BOOLEAN | bool |
/// | INT | i64 |
/// | REAL, FLOA, o DOUB | f64 |
/// | CHAR, CLOB, o TEXT | String |
/// | BLOB | Vec<u8> |
/// | cualquier otra cosa | error del compilador |
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

Expande la demostración y selecciona el =Record= generado más la llamada de mapeo de filas en tiempo de ejecución:

#+begin_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_bash

#+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 forma generada importante es:

#+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

  • Ejecutar el paso:

#+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

  • Observar: el bucle usa =user.id= y =user.email=; no quedan llamadas a =row.get=
    escritas a mano en el código de la aplicación.
  • Ganamos: los nombres de salida y las declaraciones admitidas se convierten en campos de Rust.
  • Pregunta aparcada para el paso 6: ¿qué evidencia justifica =String= para =email= pero
    =Option= para =display_name=?
  • Aún roto a continuación: =Record= es local a la expansión de la macro. El paso 5 mapea la
    misma evidencia en una estructura propiedad de la aplicación.
  • Paso 5: poblar una estructura propiedad de la aplicación
    :PROPERTIES:
    :MINUTES: 10
    :END:

#+begin_quote
Problema: el =Record= local generado es conveniente dentro de una expresión, pero
las aplicaciones ya tienen tipos de dominio con nombre utilizados en funciones y módulos.

Por qué importa: los resultados de la consulta deben ajustarse a esos tipos sin agregar un segundo decodificador manual.

Este paso: generar un literal ordinario de la estructura del llamador. Dejar que rustc—no
la macro—verifique los nombres de campo y los tipos de Rust.
#+end_quote

=query_as!= reutiliza la misma expansión y cambia solo el constructor final. No
inspecciona ni reflexiona sobre la definición de la estructura.

#+name: query-as-input
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Entrada analizada para query_as!(OutputType, &connection, "SQL", args...).
#[rustfmt::skip]
struct QueryAsInput { output: Type, query: QueryInput }

/// Analizar el tipo de Rust del llamador, luego reutilizar la gramática completa 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
/// Validar y ejecutar una consulta en una estructura de Rust proporcionada por el llamador.
///
/// La macro emite un literal de estructura ordinario. Por lo tanto, rustc informa de campos faltantes,
/// adicionales o con tipos incorrectos sin maquinaria de reflexión personalizada.
#[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

Expande =query_as!= para ver que reutiliza =map_rows= pero construye
=UserSummary= en lugar de definir =Record=:

#+begin_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_bash

#+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

La expresión generada es un literal de estructura ordinario:

#+begin_src rust :tangle no
UserSummary {
id: row.get::<usize, i64>(0)?,
email: row.get::<usize, String>(1)?,
}
#+end_src

  • Ejecutar el paso y ambos fallos esperados:

#+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

  • Observar: el =UserSummary= correcto compila; un campo incorrecto o un tipo de campo incorrecto produce
    un error de rustc ordinario.
  • Ganamos: las filas verificadas pueden entrar en tipos propiedad de la aplicación sin un segundo
    sistema de decodificación.
  • Aún roto: el Rust generado solo es confiable si los metadatos SQL lo justifican.
    El paso 6 define el límite de evidencia honesto.
  • Paso 6: hacer que la afirmación de tipo sea honesta
    :PROPERTIES:
    :MINUTES: 8
    :END:

#+begin_quote
Problema: generar =String= para un valor que en realidad puede ser =NULL= hace que una característica
en tiempo de compilación sea incorrecta con confianza.

Por qué importa: las uniones, vistas, subconsultas y expresiones pueden cambiar la nulabilidad incluso
cuando una columna de tabla subyacente dice =NOT NULL=.

Este paso: aceptar salida tipada solo para columnas directas de una tabla real. Leer
el esquema de esa tabla: =NOT NULL= se convierte en =T=; todo lo anulable se convierte en
=Option=. Rechazar formas cuya evidencia no podemos justificar.
#+end_quote

Este no es un pequeño analizador SQL que pretende entender cada consulta. El rechazo es
el mecanismo de corrección.

#+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

  • Ejecutar el paso y sus fallos de límite de evidencia:

#+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

  • Observar: =email= directo es =String=; =display_name= anulable es
    =Option=; las formas no admitidas fallan durante la compilación.
  • Ganamos: una afirmación de consulta tipada estrecha respaldada por evidencia de esquema directa.
  • Aún roto: la expansión en línea necesita la base de datos cada vez. El paso 7 hace
    que la evidencia sea portátil.
  • Paso 7: compilar sin la base de datos de compilación
    :PROPERTIES:
    :MINUTES: 7
    :END:

#+begin_quote
Problema: la verificación en tiempo de compilación ahora depende de abrir la base de datos de compilación.

Por qué importa: los desarrolladores y CI pueden necesitar compilaciones reproducibles donde esa base de datos no esté
disponible.

Este paso: guardar la =Description= ya validada como JSON, luego cargar esa misma
forma en modo fuera de línea. Mantener cada paso de validación y generación de código después del
cargador sin cambios.
#+end_quote

La caché es deliberadamente visible: un pequeño hash elige el nombre del archivo, mientras que
la versión, el backend y el SQL exacto se verifican antes de confiar en la evidencia en caché.

#+name: offline-cache
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
/// Leer las banderas de entorno booleanas del taller.
fn flag(name: &str) -> bool {
env::var(name)
.ok()
.is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
}

/// Producir un nombre de archivo de caché corto y determinista a partir del texto SQL exacto.
///
/// Se usa FNV-1a porque su bucle es fácil de enseñar. Una caché de producción
/// normalmente usaría un hash resistente a colisiones y garantías de concurrencia más fuertes.
fn hash(sql: &str) -> u64 {
sql.as_bytes()
.iter()
.fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
})
}

/// Ubicar el archivo de caché de esta consulta dentro del crate que invocó la macro.
///
/// CARGO_MANIFEST_DIR pertenece al llamador, por lo que cada crate obtiene su propio
/// directorio .toy-sqlx en lugar de compartir el directorio del 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))))
}

/// Serializar una descripción de SQLite en vivo como JSON legible para la compilación fuera de línea.
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())))
}

/// Cargar evidencia en caché y rechazar cualquier cosa que no coincida con esta consulta.
///
/// Verificar la versión, el backend y el SQL exacto evita que una entrada de caché creada bajo
/// diferentes suposiciones impulse silenciosamente la generación de código 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}")))?;
// Las tres verificaciones son parte del límite de confianza de la caché.
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

  • Ejecutar la demostración en línea, luego probar la compilación fuera de línea:

#+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

  • Observar: preparar metadatos en línea, eliminar la base de datos, forzar la expansión y
    compilar con éxito desde la caché.
  • Ganamos: la generación de código consume una =Description= independientemente de si
    su evidencia provino de SQLite en vivo o de una caché verificada.
  • Límite: la frescura de la caché, la resistencia a colisiones, las escrituras atómicas y los escritores
    concurrentes son preocupaciones de producción. El juguete verifica metadatos faltantes, malformados,
    versión incorrecta, backend incorrecto y SQL incorrecto.

** Verificación enredada y ejecutor de presentación :noexport:

La historia central termina arriba. Los bloques restantes automatizan pruebas, demostraciones,
fallos esperados y comprobaciones de tamaño; no son pasos de enseñanza adicionales.

#+name: focused-macro-tests
#+begin_src rust :tangle toy-sqlx-macros/src/lib.rs :mkdirp yes :comments no
// Estas pruebas se dirigen a las pequeñas políticas que son más fáciles de romper mientras se edita
// el taller: mapeo de tipos, rechazo de forma de consulta, nombres de campo y hashes de caché.
#[cfg(test)]
mod tests {
use super::*;

#[test]
#[rustfmt::skip]
fn maps_the_teaching_types() {
    // Comparar tokens emitidos como texto porque estos ayudantes generan código, no valores.
    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() {
    // Cada forma incierta debe fallar cerrada en lugar de generar un tipo no sólido.
    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() {
    // Los nombres SQL inválidos o repetidos no pueden formar una estructura Rust válida.
    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() {
    // Cambiar este valor movería cada archivo de caché y requiere una decisión explícita.
    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' ;;

  1. run_stage 1 literal_macro 'SELECT 40 + 2 AS answer' ;;
  2. run_stage 2 compile_time_prepare 'SQLite accepted during compilation' ;;
  3. run_stage 3 parameter_arity 'parameter type deliberately unchecked' ;;
  4. run_stage 4 typed_record 'grace@example.test None' ;;
  5. run_stage 5 query_as 'alan@example.test' ;;
  6. run_stage 6 nullability 'grace@example.test None' ;;
  7. 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
  • Resumen: ensamblar la tubería

La progresión fue una cadena de retroalimentación temprana:

#+begin_example
error SQL en tiempo de ejecución
-> literal en tiempo de compilación
-> validación de SQLite
-> validación de aridad
-> registro generado
-> estructura de la aplicación
-> nulabilidad respaldada por evidencia
-> compilación fuera de línea
#+end_example

Nada aquí requirió que la macro se convirtiera en una base de datos. La macro solo transporta
evidencia de SQLite al código Rust que rustc ya sabe cómo verificar.

| Afirmación final | Evidencia utilizada | Límite honesto |
|---+---+---|
| SQL es válido | SQLite lo preparó durante la expansión | solo el esquema de compilación |
| El conteo de enlaces coincide | conteo de parámetros de SQLite | sin tipos de enlace de SQLite |
| Los campos de salida existen | metadatos de columna de declaración | solo columnas directas |
| La forma de salida de Rust coincide | registro generado/literal de estructura | los valores dinámicos pueden decodificarse mal |
| Columna directa anulable | metadatos de esquema =NOT NULL= | uniones/expresiones rechazadas |
| Compilación sin BD | =Description= en caché | la frescura de la caché es disciplina externa |

El modelo de ingeniería reutilizable es: preguntar a la base de datos, preservar su evidencia,
generar Rust ordinario y dejar que rustc termine la verificación
.

  • Preguntas y respuestas

El contenido principal se detiene aquí en el minuto 80. Las siguientes secciones no son necesarias para la
historia principal.

  • Apéndice

** Tipado de parámetros dependiente del backend
Los backends tipo PostgreSQL pueden devolver metadatos de tipo de parámetro y generar código muerto
con aserciones de tipo Rust. SQLite devuelve solo aridad. Agregar un analizador de SQLite inventado
al núcleo oscurecería este límite.

** Sobrescrituras de expresión
Un juguete más grande puede analizar alias como ="count!: INTEGER"=. El núcleo rechaza
expresiones para que cada tipo inferido tenga metadatos directos.

** Nulabilidad consciente de uniones
Los sistemas reales pueden inspeccionar planes o código de bytes de SQLite. El legado avanzado
=mini-sqlx-macros= demuestra por qué esto expande rápidamente la implementación.

** Cachés más fuertes
Discutir hashes criptográficos, identidad de esquema, escrituras atómicas, bloqueos y caché de CI
frescura sin enredarlos en la implementación de enseñanza.

** Runtimes asíncronos y pools
Cambian la ejecución, no la tubería de evidencia/generación de código en tiempo de compilación.

** Migraciones y CI
La macro verifica el esquema presente durante la compilación. CI debería crear una
BD aislada, aplicar migraciones, compilar/preparar consultas y verificar la evidencia en caché.

** Mapear el juguete a SQLx
La =Description= del juguete corresponde a un resultado de descripción de backend; =load_description=
corresponde a la selección de datos de consulta en línea/fuera de línea; =expand_query= corresponde a
generación de código de argumento/salida. Los nombres son conceptuales, no afirmaciones de fidelidad de fuente.

** Github

[[https://github.com/chiefkemist/toy-sqlx][toy-sqlx -- No es un clon de SQLx]]