Bases de datos en Rust: SQLite y Postgres con sqlx, Diesel y SeaORM
Tarde o temprano, tu programa necesita recordar cosas: usuarios, pedidos, récords, tareas pendientes. Cuando el programa se detiene, un Vec o un HashMap desaparecen. Una base de datos guarda los datos a salvo en el disco, deja que muchos programas los compartan y responde muy rápido a preguntas como “enséñame todas las tareas abiertas, de la más nueva a la más antigua”.
Rust tiene crates muy buenos para bases de datos. En esta página vas a elegir uno y luego escribir código real: un pequeño “repositorio” en Rust puro (se ejecuta aquí mismo, en el Playground), después SQLite con sqlx, Postgres, migraciones y un recorrido rápido por Diesel y SeaORM. Los crates de bases de datos no están disponibles en el Playground, así que esos ejemplos son para tu ordenador, con cargo run. Todos se compilaron y ejecutaron, y la salida que ves es real.
Qué puedes construir
Sección titulada «Qué puedes construir»- La capa de datos de una API web. Toma el servidor de tareas de Proyecto: Construir una API web y guarda sus datos en Postgres en lugar de en memoria.
- Sitios web grandes y con mucho tráfico. crates.io, el sitio que aloja todos los crates de Rust, guarda sus datos en Postgres a través de Diesel. Lo mismo hace Lemmy, una plataforma de comunidades de código abierto.
- Herramientas de escritorio y de línea de comandos con un archivo SQLite local: una app de notas, un control de gastos, una caché.
- Procesos en segundo plano y tareas de datos que leen filas, las procesan y vuelven a escribir los resultados.
Dónde cuesta más: Rust no tiene un único stack “oficial” como Entity Framework en .NET o ActiveRecord en Rails, así que eliges entre unos cuantos crates buenos. La primera compilación de un crate de bases de datos tarda uno o dos minutos. Y el SQL comprobado al compilar (una función estupenda) necesita un poco de preparación.
¿Qué crate elijo?
Sección titulada «¿Qué crate elijo?»| Crate | ¿Síncrono o async? | ¿Comprueba tus consultas al compilar? | ¿ORM? | Elígelo cuando |
|---|---|---|---|---|
| rusqlite | síncrono | no | no, SQL normal | Una herramienta pequeña o una app de escritorio con un archivo SQLite. No necesitas async. |
| sqlx | async | sí, con las macros query! (opcional) |
no, SQL normal | Te gusta escribir SQL y quieres async. SQLite, Postgres, MySQL. La primera opción habitual para apps web. |
| Diesel | síncrono (diesel-async añade async) |
sí, todo el constructor de consultas se comprueba por tipos | ORM y constructor de consultas | Quieres que el compilador revise cada consulta y no te importa aprender su estilo de consultas. Muy maduro. |
| SeaORM | async (existe sea-orm-sync) |
los tipos sí, el SQL no (se comprueba al ejecutarse) | sí, ORM completo | Quieres entidades, relaciones y “guarda este objeto”, como en Entity Framework. Está construido sobre sqlx. |
Los cuatro son maduros y muy usados. Si no lo tienes claro, empieza con sqlx: escribes SQL normal, así que lo que aprendes te sirve en cualquier lenguaje. ¿Vienes de C#? La página Rust para desarrolladores de C# relaciona Entity Framework, Dapper y ADO.NET con estos crates.
Primero la forma: un repositorio en Rust puro
Sección titulada «Primero la forma: un repositorio en Rust puro»Antes de tocar ninguna base de datos, veamos un patrón que usa casi cualquier app: el repositorio. Es un único lugar que sabe guardar y cargar tus datos. El resto del programa solo llama a métodos como add y list_open, y le da igual si los datos viven en un HashMap, en SQLite o en Postgres.
En Rust, un trait (rasgo) describe lo que puede hacer el repositorio, y un struct hace el trabajo. Aquí el almacenamiento es un HashMap en memoria:
use std::collections::HashMap;
#[derive(Debug, Clone)]struct Task { id: u32, title: String, done: bool,}
// Lo que la app necesita del almacenamiento. Aquí nada dice CÓMO se guarda.trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn mark_done(&mut self, id: u32) -> Result<(), String>; fn list_open(&self) -> Vec<Task>;}
struct InMemoryRepo { tasks: HashMap<u32, Task>, next_id: u32,}
impl TaskRepository for InMemoryRepo { fn add(&mut self, title: &str) -> u32 { let id = self.next_id; self.tasks.insert(id, Task { id, title: title.to_string(), done: false }); self.next_id += 1; id }
fn mark_done(&mut self, id: u32) -> Result<(), String> { match self.tasks.get_mut(&id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("no hay ninguna tarea con id {id}")), } }
fn list_open(&self) -> Vec<Task> { let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect(); open.sort_by_key(|t| t.id); // un HashMap no tiene orden, así que ordenamos open }}
// Funciona con CUALQUIER repositorio: hoy en memoria, mañana SQLite o Postgres.fn print_open(repo: &impl TaskRepository) { for task in repo.list_open() { println!("[ ] {}: {}", task.id, task.title); }}
fn main() { let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 }; repo.add("Comprar leche"); repo.add("Aprender sqlx"); repo.add("Pasear al perro");
repo.mark_done(1).unwrap(); print_open(&repo); println!("{:?}", repo.mark_done(99));}Salida:
[ ] 2: Aprender sqlx[ ] 3: Pasear al perroErr("no hay ninguna tarea con id 99")Fíjate en lo que te va a dar una base de datos aquí. El HashMap pasa a ser una tabla, cada Task pasa a ser una fila y next_id se convierte en una columna id que se numera sola. list_open se convierte en una consulta con WHERE done = FALSE ORDER BY id. La versión en memoria también te vendrá bien más adelante: sirve como imitación rápida para tus tests.
sqlx con SQLite: tu primera base de datos de verdad
Sección titulada «sqlx con SQLite: tu primera base de datos de verdad»SQLite es una base de datos completa en un solo archivo (o solo en memoria). No hay ningún servidor que instalar, y eso la hace perfecta para aprender, para los tests y para muchas apps reales. Crea un proyecto y añade sqlx y tokio:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo add tokio --features full[dependencies]sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }tokio = { version = "1", features = ["full"] }La feature sqlite mete SQLite dentro de tu programa, así que no necesitas instalar nada más. sqlx es async, y por eso necesita tokio. Ahora reemplaza src/main.rs (los títulos de las tareas siguen en inglés porque la salida de abajo es la que imprimió exactamente este código):
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { // Una base de datos en memoria: perfecta para aprender, desaparece cuando el programa termina. let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&pool) .await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&pool) .await?; println!("Inserted task {}", result.last_insert_rowid()); }
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(1_i64) .execute(&pool) .await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id") .fetch_all(&pool) .await?;
for task in &tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.title); }
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?") .bind(42_i64) .fetch_optional(&pool) .await?; println!("Task 42: {open:?}");
Ok(())}Ejecútalo con cargo run:
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: NonePaso a paso:
sqlx::query("...")contiene el SQL. Cada?es un placeholder (marcador de posición), y.bind(valor)lo rellena, en orden..execute(&pool)ejecuta una sentencia que no devuelve filas (CREATE,INSERT,UPDATE,DELETE). El resultado te dicerows_affected()(las filas afectadas) y, en SQLite,last_insert_rowid().#[derive(sqlx::FromRow)]permite que sqlx convierta cada fila en unTask. Los nombres de las columnas tienen que coincidir con los de los campos.query_as+fetch_allte da unVec<Task>. Usafetch_onecuando tenga que haber exactamente una fila, yfetch_optionalcuando puede que no haya ninguna. Fíjate en que la fila que faltaba se convirtió enNone, unOption, y no en un fallo.- Cada llamada devuelve un
Result, así que?pasa los errores de la base de datos hacia arriba, hastamain.
Para conservar los datos cuando el programa termine, usa un archivo: SqlitePool::connect("sqlite:todos.db?mode=rwc"). La parte mode=rwc significa “leer, escribir y crear el archivo si no existe”.
Consultas comprobadas al compilar. sqlx también tiene macros como sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Al compilar, la macro se conecta a tu base de datos de desarrollo (la de DATABASE_URL) y revisa el SQL. Una errata como titel detiene la compilación con no such column: titel (“no existe la columna”), antes de que lo vea ningún usuario. Esto necesita una base de datos con tus tablas mientras compilas, así que empieza con las funciones query normales y añade las macros cuando ya tengas migraciones (más abajo).
Conectarse a Postgres
Sección titulada «Conectarse a Postgres»Postgres (PostgreSQL) es la opción más popular para apps web. Funciona como un servidor, así que necesitas uno en marcha: instálalo desde postgresql.org, usa una base de datos en la nube o arranca una con Docker:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresEl código apenas cambia. Activa la feature postgres (y añade una feature de TLS, como tls-rustls, para una base de datos en la nube, que normalmente exige cifrado):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsLuego conéctate con una cadena de conexión (connection string), una URL que dice dónde está la base de datos y cómo iniciar sesión:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgresNunca escribas la contraseña en tu código. Pon la URL en la variable de entorno DATABASE_URL y léela al arrancar (mira Variables de entorno):
use sqlx::postgres::PgPoolOptions;use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(3)) .connect(&url) .await?;
// Postgres numera sus placeholders: $1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;Dos diferencias con SQLite que conviene recordar: en Postgres los placeholders son $1, $2 en lugar de ?, y un id que se numera solo suele ser BIGINT GENERATED ALWAYS AS IDENTITY (o BIGSERIAL), que encaja en un i64. Si tu contraseña tiene caracteres como @ o /, codifícalos en la URL con el formato de porcentaje (@ pasa a ser %40). Herramientas como sqlx-cli también leen DATABASE_URL de un archivo .env en la carpeta de tu proyecto. Añade .env al .gitignore para que nunca acabe en GitHub.
Migraciones con sqlx-cli
Sección titulada «Migraciones con sqlx-cli»Tus tablas van a cambiar con el tiempo: una columna nueva por aquí, una tabla nueva por allá. Las migraciones son archivos SQL numerados que describen cada cambio. Todas las bases de datos (la tuya, la de tu compañero, la de producción) ejecutan los mismos archivos en el mismo orden, y la base de datos recuerda cuáles ha ejecutado ya. Instala la herramienta de línea de comandos una vez:
# todas las bases de datoscargo install sqlx-cli
# o solo lo que necesitas, para que compile más rápidocargo install sqlx-cli --no-default-features --features sqlite,rustlsLuego, en la carpeta de tu proyecto:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(Tu número será distinto: es la fecha y la hora.) Escribe el cambio en ese archivo:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);Y aplícalo:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)Si lo ejecutas otra vez, no pasa nada, porque esa migración ya está aplicada. sqlx migrate info muestra qué está aplicado y qué está pendiente. Nunca edites una migración que ya se ejecutó en algún sitio; añade una nueva en su lugar.
También puedes dejar que tu programa aplique las migraciones al arrancar. La macro migrate! mete los archivos de ./migrations dentro de tu binario:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // ejecuta solo las migraciones que esta base de datos no ha vistoDiesel en pocas palabras
Sección titulada «Diesel en pocas palabras»Diesel toma otro camino: en lugar de escribir cadenas de texto con SQL, construyes las consultas con métodos de Rust, y el compilador las comprueba contra una descripción de tus tablas. Por ejemplo, done es una columna bool, así que tasks::done.eq(true) compila, pero tasks::done.eq("yes") es un error de compilación. Diesel es síncrono, y eso está bien para herramientas y para muchos servidores.
[dependencies]diesel = { version = "2.3", features = ["sqlite"] }# Mete SQLite dentro de tu programa. Solo hace falta si no tienes SQLite instalado:libsqlite3-sys = { version = "0.38", features = ["bundled"] }use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// Normalmente `diesel migration run` te genera esto en src/schema.rs.diesel::table! { tasks (id) { id -> Integer, title -> Text, done -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = tasks)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct Task { id: i32, title: String, done: bool,}
#[derive(Insertable)]#[diesel(table_name = tasks)]struct NewTask<'a> { title: &'a str,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?;
// Normalmente esto lo haría una migración. diesel::sql_query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }]; diesel::insert_into(tasks::table) .values(&new_tasks[..]) .execute(&mut conn)?;
diesel::update(tasks::table.find(1)) .set(tasks::done.eq(true)) .execute(&mut conn)?;
let all: Vec<Task> = tasks::table .order(tasks::id) .select(Task::as_select()) .load(&mut conn)?; for task in &all { println!("{} {} done={}", task.id, task.title, task.done); }
let open: i64 = tasks::table .filter(tasks::done.eq(false)) .count() .get_result(&mut conn)?; println!("{open} task(s) still open"); Ok(())}1 Buy milk done=true2 Learn Diesel done=false1 task(s) still openEn un proyecto real, instalas diesel_cli (cargo install diesel_cli --no-default-features --features sqlite) y luego ejecutas diesel setup, diesel migration generate create_tasks y diesel migration run. La CLI escribe por ti src/schema.rs con el código de table!, así que siempre coincide con la base de datos. La guía Getting Started (en inglés) lo explica todo paso a paso.
SeaORM en pocas palabras
Sección titulada «SeaORM en pocas palabras»SeaORM es un ORM async: describes cada tabla como una entidad (un struct con atributos) y trabajas con objetos en lugar de con SQL. Se parece mucho a Entity Framework. La versión 2.0 incluso puede crear las tablas a partir de tus entidades.
[dependencies]sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }tokio = { version = "1", features = ["full"] }mod task { use sea_orm::entity::prelude::*;
#[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "task")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub done: bool, }
impl ActiveModelBehavior for ActiveModel {}}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]async fn main() -> Result<(), DbErr> { let db = &Database::connect("sqlite::memory:").await?;
// Crea las tablas de todas las entidades de este crate. "seaorm_demo" es el // nombre del paquete en Cargo.toml, con los - cambiados por _. db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] { task::ActiveModel { title: Set(title.to_owned()), done: Set(false), ..Default::default() } .insert(db) .await?; }
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model(); milk.done = Set(true); milk.update(db).await?;
let open = task::Entity::find() .filter(task::Column::Done.eq(false)) .all(db) .await?; for t in &open { println!("open: {} {}", t.id, t.title); } Ok(())}open: 2 Learn SeaORMEl Model es una fila que lees. El ActiveModel es una fila que estás cambiando: Set(...) marca los campos que hay que guardar, y ..Default::default() deja el id para que lo rellene la base de datos. En proyectos más grandes, sea-orm-cli genera las entidades a partir de una base de datos existente y gestiona las migraciones. Mira la documentación de SeaORM (en inglés).
Inyección SQL: usa siempre parámetros con bind
Sección titulada «Inyección SQL: usa siempre parámetros con bind»Esta es la regla más importante de la página. Nunca construyas SQL pegando la entrada del usuario dentro de la cadena con format!. Aquí tienes el porqué, en Rust puro (sin base de datos):
// Lo que recibe la base de datos, construido de dos maneras distintas.fn pasted_query(name: &str) -> String { format!("SELECT * FROM users WHERE name = '{name}'")}
fn bound_query(name: &str) -> (&'static str, Vec<String>) { ("SELECT * FROM users WHERE name = ?", vec![name.to_string()])}
fn main() { for input in ["ana", "x' OR '1'='1"] { println!("entrada: {input}"); println!(" format!: {}", pasted_query(input)); let (sql, params) = bound_query(input); println!(" bind: {sql} parámetros: {params:?}"); }}Salida:
entrada: ana format!: SELECT * FROM users WHERE name = 'ana' bind: SELECT * FROM users WHERE name = ? parámetros: ["ana"]entrada: x' OR '1'='1 format!: SELECT * FROM users WHERE name = 'x' OR '1'='1' bind: SELECT * FROM users WHERE name = ? parámetros: ["x' OR '1'='1"]Al pegarla, la comilla de la entrada cierra la cadena antes de tiempo, y OR '1'='1' pasa a formar parte del SQL. Esa condición siempre es verdadera, así que la consulta devuelve todos los usuarios. Un atacante puede usar el mismo truco para leer los datos de otras personas o borrar tablas. Esto se llama inyección SQL (SQL injection), y es uno de los fallos de seguridad más comunes de la web.
Con un parámetro de bind, el texto del SQL nunca cambia. La entrada viaja por separado, y la base de datos la trata solo como un valor. Aquí tienes las dos versiones con sqlx, ejecutadas contra una tabla SQLite real con los usuarios ana y root:
let user_input = "nobody' OR '1'='1"; // imagina que esto llegó de un formulario de login
// NO: pegar la entrada dentro del texto SQL.let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;println!("format!: {rows:?}");
// SÍ: un texto SQL fijo con un placeholder ?, y la entrada enviada por separado.let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?") .bind(user_input) .fetch_all(&pool) .await?;println!("bind: {rows:?}");format!: [("ana",), ("root",)]bind: []La consulta pegada filtró los dos usuarios. La consulta con bind no encontró, como debe ser, a nadie con ese nombre tan raro. Y fíjate en el envoltorio AssertSqlSafe(...) de la versión mala: sqlx 0.9 te protege por defecto. sqlx::query y query_as solo aceptan un &'static str fijo, así que pasar una cadena hecha con format! sin ese envoltorio no compila. El mensaje del compilador, en inglés, dice que las cadenas SQL dinámicas deben revisarse por si permiten inyecciones:
error[E0277]: dynamic SQL strings should be audited for possible injections = help: the trait `SqlSafeStr` is not implemented for `&std::string::String` = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.Entonces, ¿cuándo usarías AssertSqlSafe? Solo cuando de verdad tengas que construir SQL en tiempo de ejecución, por ejemplo, una columna de ordenación que elige el usuario. Primero comprueba el valor contra tu propia lista fija de nombres permitidos, y luego envuelve la cadena para decirle a sqlx que ya la revisaste. Para los valores, usa siempre .bind(). Diesel y SeaORM hacen el bind de los valores por ti, siempre que uses sus constructores de consultas y no cadenas de SQL en bruto.
Pools de conexiones
Sección titulada «Pools de conexiones»Abrir una conexión con la base de datos es lento: hay un viaje por la red, un inicio de sesión y algo de preparación. Un pool (grupo de conexiones) abre unas pocas conexiones una sola vez y las presta. Tu código toma prestada una conexión para una consulta y la devuelve enseguida. Por eso todos los ejemplos de arriba pasaban &pool, y no una conexión suelta.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new() .filename("visits.db") .create_if_missing(true);
let pool = SqlitePoolOptions::new() .max_connections(4) // nunca más de 4 a la vez .connect_with(options) .await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)") .execute(&pool) .await?;
// 20 tareas comparten un pool. Clonar un pool es barato: solo es un manejador (handle).let mut handles = Vec::new();for n in 0..20 { let pool = pool.clone(); handles.push(tokio::spawn(async move { sqlx::query("INSERT INTO visits (page) VALUES (?)") .bind(format!("/page/{}", n % 3)) .execute(&pool) .await }));}for handle in handles { handle.await.expect("task panicked")?; // espera a que terminen los 20 inserts}Las 20 tareas se ejecutan a la vez, pero como mucho 4 hablan con la base de datos en cada momento. Las demás esperan un instante a que quede una conexión libre. Si cuentas las filas después (con un visits.db nuevo), obtienes:
/page/0: 7 visits/page/1: 7 visits/page/2: 6 visitsAlgunas reglas prácticas:
- Crea un solo pool al arrancar el programa y compártelo. En una app con axum, ponlo en el estado de tu app (mira el proyecto de la API web).
- No crees un pool nuevo en cada petición. Así pierdes toda la ventaja.
max_connectionsdebe quedarse bastante por debajo de lo que permite la base de datos. Un servidor Postgres permite 100 conexiones por defecto, y cada copia de tu app tiene su propio pool.- Pon un
acquire_timeout, para que, cuando todas las conexiones estén ocupadas, la petición falle con un error claro en lugar de esperar para siempre.
Práctica
Sección titulada «Práctica»1. Busca en el repositorio
Sección titulada «1. Busca en el repositorio»Toma la idea del repositorio en memoria y añade un método search: devuelve las tareas cuyo título contiene una palabra, sin distinguir mayúsculas de minúsculas, ordenadas por id. Pruébalo con “leche”. Pista: title.to_lowercase().contains(&word.to_lowercase()), y aquí basta con un Vec<Task> como almacenamiento.
Ver solución
#[derive(Debug, Clone)]struct Task { id: u32, title: String,}
trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn search(&self, word: &str) -> Vec<Task>;}
struct VecRepo { tasks: Vec<Task>,}
impl TaskRepository for VecRepo { fn add(&mut self, title: &str) -> u32 { let id = self.tasks.len() as u32 + 1; self.tasks.push(Task { id, title: title.to_string() }); id }
fn search(&self, word: &str) -> Vec<Task> { let word = word.to_lowercase(); let mut found: Vec<Task> = self .tasks .iter() .filter(|t| t.title.to_lowercase().contains(&word)) .cloned() .collect(); found.sort_by_key(|t| t.id); found }}
fn main() { let mut repo = VecRepo { tasks: Vec::new() }; repo.add("Comprar leche"); repo.add("Pasear al perro"); repo.add("Leche de avena para el café");
for task in repo.search("leche") { println!("{}: {}", task.id, task.title); } println!("{} resultado(s) para \"gato\"", repo.search("gato").len());}Salida:
1: Comprar leche3: Leche de avena para el café0 resultado(s) para "gato"El almacenamiento cambió de un HashMap a un Vec, pero el código que llama al trait no necesita saberlo. En SQL, la misma búsqueda es WHERE title LIKE ? con .bind(format!("%{word}%")). Fíjate en que los signos % van en el valor del bind, no en el texto del SQL.
2. Un repositorio de verdad con sqlx (en tu ordenador)
Sección titulada «2. Un repositorio de verdad con sqlx (en tu ordenador)»Escribe un struct SqliteTaskRepo que guarde un SqlitePool y tenga tres métodos async: add(title) devuelve el id nuevo, mark_done(id) devuelve true si ha cambiado alguna fila, y list_open() devuelve las tareas abiertas. Crea la tabla en main, añade tres tareas y marca como hechas la tarea 1 y la tarea 99. Pista: rows_affected() sobre el resultado de execute te dice cuántas filas cambió un UPDATE.
Ver solución
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
struct SqliteTaskRepo { pool: SqlitePool,}
impl SqliteTaskRepo { async fn add(&self, title: &str) -> Result<i64, sqlx::Error> { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&self.pool) .await?; Ok(result.last_insert_rowid()) }
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> { let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() == 1) }
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> { sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id") .fetch_all(&self.pool) .await }}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { let pool = SqlitePool::connect("sqlite::memory:").await?; sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)") .execute(&pool) .await?;
let repo = SqliteTaskRepo { pool }; repo.add("Buy milk").await?; repo.add("Learn sqlx").await?; repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?); println!("mark 99 done: {}", repo.mark_done(99).await?); for task in repo.list_open().await? { println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title); } Ok(())}mark 1 done: truemark 99 done: false[ ] 2: Learn sqlx[ ] 3: Walk the dogTiene la misma forma que la versión en memoria, solo que es async y devuelve Result, porque una base de datos real puede fallar (un disco lleno, una red caída). Siguiente paso: cambia el Vec de tu proyecto de API web por este repositorio.
Resumen
Sección titulada «Resumen»- rusqlite para SQLite síncrono y sencillo, sqlx para SQL normal y async (la primera opción habitual), Diesel para un constructor de consultas comprobado al compilar, SeaORM para un ORM async al estilo de Entity Framework.
- Un repositorio esconde dónde viven los datos detrás de unos pocos métodos. Empieza con un
HashMapy cámbialo por una base de datos más adelante. - En sqlx:
query+.bind()+.execute()para los cambios, yquery_as+fetch_all/fetch_one/fetch_optionalpara leer filas en un struct con#[derive(FromRow)]. - Guarda la cadena de conexión en
DATABASE_URL, no en tu código. SQLite usa placeholders?y Postgres usa$1,$2. - Cambia tus tablas con migraciones (
sqlx migrate add/run), nunca a mano. - Haz siempre bind de la entrada del usuario. Nunca la pegues en el SQL con
format!. Crea un solo pool al arrancar y compártelo.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. Quieres escribir SQL normal, usar async y tener la opción de consultas comprobadas al compilar. ¿Qué crate encaja mejor?
2. ¿Qué método de sqlx deberías usar cuando una consulta puede devolver cero filas o una?
3. ¿Por qué es peligroso format!("... WHERE name = '{name}'")?
4. ¿Qué hace un pool de conexiones?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- sqlx en docs.rs (en inglés): la referencia de la API, con
query,query_asy las macrosquery!, los pools y todas las bases de datos compatibles. - sqlx-cli en docs.rs (en inglés): cómo instalar la CLI, crear bases de datos, ejecutar y revertir migraciones, y usar el modo offline para las macros
query!. - Getting Started with Diesel (en inglés): la guía oficial paso a paso, desde
diesel setuphasta insertar, actualizar y borrar filas. - Documentación de SeaORM (en inglés): entidades, relaciones, migraciones y el generador de código
sea-orm-cli. - OWASP SQL Injection Prevention Cheat Sheet (en inglés): cómo funciona la inyección SQL y cómo la detienen las consultas parametrizadas, en cualquier lenguaje.