← Volver al listado de tecnologías

Capítulo 8: Infrastructure - Persistencia SQLite con SQLx

Por: SiempreListo
rustwasmaxumleptos

Capítulo 8: Infrastructure - Persistencia SQLite con SQLx

Introducción

Hasta ahora tenemos dominio y casos de uso que dependen de un trait CardRepository. En este capítulo implementamos el adaptador concreto que conecta con SQLite usando SQLx.

Schema SQL

El archivo de migración define la tabla cards:

CREATE TABLE IF NOT EXISTS cards (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    lane TEXT NOT NULL DEFAULT 'todo',
    position INTEGER NOT NULL DEFAULT 0,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

Por qué TEXT para id y timestamps

SQLite no tiene tipo nativo UUID ni DATETIME con timezone. Usamos:

SQLx: queries async sin ORM

SQLx es una librería de queries SQL async para Rust. A diferencia de ORMs como Diesel o SeaORM:

CaracterísticaSQLxDieselSeaORM
QueriesRaw SQLDSL propioActiveRecord
AsyncNativoRequiere adapterNativo
Compile-time checksOpcional (macro)No
Curva de aprendizajeBajaAltaMedia

Elegimos SQLx porque escribimos SQL directo, sin capas de abstracción. Si sabes SQL, sabes SQLx.

Estructura del repositorio

apps/app-backend/src/infrastructure/persistence/
└── sqlite_card_repo.rs

Implementación completa

use chrono::{DateTime, Utc};
use sqlx::SqlitePool;

use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
use crate::domain::value_objects::{CardId, Lane};

Los imports traen:

La struct SqliteCardRepo

#[derive(Clone)]
pub struct SqliteCardRepo {
    pool: SqlitePool,
}

impl SqliteCardRepo {
    pub fn new(pool: SqlitePool) -> Self {
        Self { pool }
    }
}

SqlitePool es un pool de conexiones que se comparte entre threads. Derivamos Clone porque Axum necesita clonar el estado para cada request. Clonar un pool es barato: solo copia el Arc interno.

Helpers de conversión

fn map_err(e: sqlx::Error) -> DomainError {
    DomainError::Persistence(e.to_string())
}

Convierte errores de SQLx en nuestro DomainError::Persistence. El dominio nunca conoce sqlx::Error.

fn parse_dt(s: &str) -> DateTime<Utc> {
    s.parse().unwrap_or_default()
}

Parsea strings ISO 8601 a DateTime<Utc>. Usamos unwrap_or_default() como fallback seguro (epoch 1970-01-01).

fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Result<Card, DomainError> {
    use sqlx::Row;
    let lane_str: String = row.get("lane");
    let lane = Lane::try_from(lane_str.as_str())
        .map_err(DomainError::InvalidLane)?;

    Ok(Card {
        id: CardId::from_string(row.get("id")),
        title: row.get("title"),
        description: row.get("description"),
        lane,
        position: row.get("position"),
        completed: row.get("completed"),
        created_at: parse_dt(row.get::<&str, _>("created_at")),
        updated_at: parse_dt(row.get::<&str, _>("updated_at")),
    })
}

Este helper mapea una fila SQLite a nuestra entidad Card:

Implementación del trait CardRepository

find_all

async fn find_all(&self) -> Result<Vec<Card>, DomainError> {
    let rows = sqlx::query("SELECT * FROM cards ORDER BY lane, position")
        .fetch_all(&self.pool)
        .await
        .map_err(map_err)?;

    rows.iter().map(row_to_card).collect()
}

find_by_id

async fn find_by_id(&self, id: &CardId) -> Result<Card, DomainError> {
    let row = sqlx::query("SELECT * FROM cards WHERE id = ?")
        .bind(id.as_str())
        .fetch_optional(&self.pool)
        .await
        .map_err(map_err)?
        .ok_or_else(|| DomainError::CardNotFound(id.to_string()))?;

    row_to_card(&row)
}

save

async fn save(&self, card: &Card) -> Result<(), DomainError> {
    let created = card.created_at.to_rfc3339();
    let updated = card.updated_at.to_rfc3339();
    sqlx::query(
        "INSERT INTO cards (id, title, description, lane, position, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
    )
    .bind(card.id.as_str())
    .bind(&card.title)
    .bind(&card.description)
    .bind(card.lane.to_string())
    .bind(card.position)
    .bind(card.completed)
    .bind(&created)
    .bind(&updated)
    .execute(&self.pool)
    .await
    .map_err(map_err)?;

    Ok(())
}

update

async fn update(&self, card: &Card) -> Result<(), DomainError> {
    let updated = card.updated_at.to_rfc3339();
    sqlx::query(
        "UPDATE cards SET title=?, description=?, lane=?, position=?, completed=?, updated_at=? WHERE id=?"
    )
    .bind(&card.title)
    .bind(&card.description)
    .bind(card.lane.to_string())
    .bind(card.position)
    .bind(card.completed)
    .bind(&updated)
    .bind(card.id.as_str())
    .execute(&self.pool)
    .await
    .map_err(map_err)?;

    Ok(())
}

Notar que id va al final del .bind() porque en el SQL el WHERE id=? es el último parámetro.

delete

async fn delete(&self, id: &CardId) -> Result<(), DomainError> {
    sqlx::query("DELETE FROM cards WHERE id = ?")
        .bind(id.as_str())
        .execute(&self.pool)
        .await
        .map_err(map_err)?;

    Ok(())
}

La operación más simple: un DELETE parametrizado.

Raw SQL vs query macro

SQLx ofrece la macro sqlx::query!() que valida SQL en tiempo de compilación contra una base de datos real. No la usamos porque:

  1. Requiere una DB disponible durante cargo build
  2. Agrega complejidad al CI/CD
  3. Nuestras queries son simples y el raw SQL es más explícito

Para proyectos con queries complejas, la macro es una excelente opción.

Glosario

TérminoDefinición
SqlitePoolPool de conexiones async a SQLite, gestiona reutilización de conexiones
bindParametrización de queries para evitar SQL injection
fetch_allEjecuta query y retorna todas las filas
fetch_optionalEjecuta query y retorna Option<Row>
map_errTransforma un tipo de error en otro
RFC 3339Formato estándar para representar fechas como texto