Base de datos SQLite en Rust

Para una base de datos en Rust, rusqlite con SQLite es la opción con menos fricción: no hace falta levantar ningún servidor, la base es un archivo o incluso solo memoria, y la característica bundled compila SQLite dentro del programa, así que tampoco hay que instalar nada en el sistema.

La dependencia

cargo add rusqlite --features bundled
[dependencies]
rusqlite = { version = "0.40.2", features = ["bundled"] }

La característica bundled compila el SQLite en C junto con tu programa. La primera compilación tarda algo más, pero a cambio el binario no depende de ninguna biblioteca instalada en la máquina.

Crear, insertar y consultar

use rusqlite::{params, Connection};

#[derive(Debug)]
struct Articulo {
    id: i64,
    nombre: String,
    unidades: u32,
}

fn main() -> rusqlite::Result<()> {
    let con = Connection::open_in_memory()?;

    con.execute(
        "CREATE TABLE articulo (
            id       INTEGER PRIMARY KEY,
            nombre   TEXT NOT NULL,
            unidades INTEGER NOT NULL
        )",
        (),
    )?;

    for (nombre, unidades) in [("tornillos", 120), ("tuercas", 80), ("arandelas", 45)] {
        con.execute(
            "INSERT INTO articulo (nombre, unidades) VALUES (?1, ?2)",
            params![nombre, unidades],
        )?;
    }
    println!("último id insertado: {}", con.last_insert_rowid());

    let mut consulta = con.prepare("SELECT id, nombre, unidades FROM articulo WHERE unidades > ?1")?;
    let filas = consulta.query_map([50], |f| {
        Ok(Articulo { id: f.get(0)?, nombre: f.get(1)?, unidades: f.get(2)? })
    })?;

    println!("con más de 50 unidades:");
    for a in filas {
        let a = a?;
        println!("  {} {:<10} {}", a.id, a.nombre, a.unidades);
    }

    let total: u32 = con.query_row("SELECT SUM(unidades) FROM articulo", [], |f| f.get(0))?;
    println!("total de unidades: {total}");
    Ok(())
}
salidaúltimo id insertado: 3
con más de 50 unidades:
  1 tornillos  120
  2 tuercas    80
total de unidades: 245

Lo importante del ejemplo está en los ?1 de las consultas. Nunca se construye el SQL pegando cadenas: los valores van como parámetros y SQLite los trata como datos, no como código. Es lo que impide una inyección de SQL, y en Rust además te da la conversión de tipos hecha.

El cierre tampoco hay que escribirlo. La conexión se cierra sola cuando con sale de ámbito, igual que los archivos: es la misma regla de propiedad de la guía del curso aplicada a un recurso del sistema.

Las tres formas de consultar. execute para lo que no devuelve filas y da cuántas cambiaron; query_row cuando esperas exactamente una, como en la suma del final; y query_map cuando esperas varias, que entrega un iterador donde cada elemento es un Result porque una fila concreta puede fallar al convertirse.

Lo que se usa a diario

Qué quieres Cómo
Base en un archivo Connection::open("datos.db")?
Base en memoria, para pruebas Connection::open_in_memory()?
Una orden sin resultado con.execute(sql, params![...])
Una sola fila con.query_row(...)
Varias filas prepare(...) y query_map(...)
Un campo que puede ser NULL leerlo como Option<T>
Varias operaciones atómicas con.transaction()?
Cuándo no te sirve SQLite. Es una base en un archivo, pensada para un proceso. Si necesitas que varios servidores escriban a la vez, o ya tienes MariaDB o PostgreSQL, el crate habitual es sqlx, que trabaja con los tres motores y comprueba las consultas en tiempo de compilación contra la base real. A cambio es asíncrono, así que arrastra tokio y async.

El código de esta página se compiló y ejecutó con rustc 1.96.1 y rusqlite 0.40.2 antes de publicarla; la salida está copiada de la ejecución real. Documentación oficial: rusqlite en docs.rs.