use std::collections::HashMap;
use std::collections::hash_map::Entry;
use sqlx::Row;
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteRow;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::db::DbError;
#[derive(Debug, Clone)]
pub struct Tarea {
pub id: Uuid,
pub target_id: Uuid,
pub repository_id: Uuid,
pub cron_expression: String,
pub keep_daily: i64,
pub keep_weekly: i64,
pub keep_monthly: i64,
pub enabled: bool,
pub created_at: OffsetDateTime,
pub name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AltaDeTarea {
pub target_id: Uuid,
pub repository_id: Uuid,
pub cron_expression: String,
pub keep_daily: i64,
pub keep_weekly: i64,
pub keep_monthly: i64,
pub enabled: bool,
pub name: Option<String>,
}
pub struct Tareas {
pool: SqlitePool,
}
const SQL_LEER: &str = "SELECT id, target_id, repository_id, cron_expression, keep_daily, keep_weekly, \
keep_monthly, enabled, created_at, name FROM backup_jobs WHERE id = ?1";
const SQL_LISTAR: &str = "SELECT id, target_id, repository_id, cron_expression, keep_daily, keep_weekly, \
keep_monthly, enabled, created_at, name FROM backup_jobs ORDER BY created_at, id";
const SQL_ALTA: &str = "INSERT INTO backup_jobs \
(id, target_id, repository_id, cron_expression, keep_daily, keep_weekly, keep_monthly, enabled, name) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \
RETURNING id, target_id, repository_id, cron_expression, keep_daily, keep_weekly, keep_monthly, \
enabled, created_at, name";
const SQL_ACTUALIZAR: &str = "UPDATE backup_jobs SET target_id = ?1, repository_id = ?2, \
cron_expression = ?3, keep_daily = ?4, keep_weekly = ?5, keep_monthly = ?6, enabled = ?7, \
name = ?8 WHERE id = ?9";
const SQL_NOMBRES: &str = "SELECT id, name FROM backup_jobs \
WHERE id IN (SELECT value FROM json_each(?1))";
const SQL_BORRAR: &str = "DELETE FROM backup_jobs WHERE id = ?1";
const CONSULTA_MAPEO: &str = "mapeo de la fila de backup_jobs";
const CONSULTA_LISTA_DE_TAREAS: &str = "lista de identificadores de tareas";
impl Tareas {
pub fn nuevo(pool: SqlitePool) -> Self {
Tareas { pool }
}
pub async fn alta(&self, alta: AltaDeTarea) -> Result<Tarea, DbError> {
let id = Uuid::new_v4();
let fila = sqlx::query(SQL_ALTA)
.bind(id.hyphenated().to_string())
.bind(alta.target_id.hyphenated().to_string())
.bind(alta.repository_id.hyphenated().to_string())
.bind(&alta.cron_expression)
.bind(alta.keep_daily)
.bind(alta.keep_weekly)
.bind(alta.keep_monthly)
.bind(alta.enabled)
.bind(&alta.name)
.fetch_one(&self.pool)
.await
.map_err(error_de_consulta(SQL_ALTA))?;
desde_fila(&fila)
}
pub async fn leer(&self, id: &Uuid) -> Result<Option<Tarea>, DbError> {
let fila = sqlx::query(SQL_LEER)
.bind(id.hyphenated().to_string())
.fetch_optional(&self.pool)
.await
.map_err(error_de_consulta(SQL_LEER))?;
match fila {
Some(fila) => desde_fila(&fila).map(Some),
None => Ok(None),
}
}
pub async fn listar(&self) -> Result<Vec<Tarea>, DbError> {
let filas = sqlx::query(SQL_LISTAR)
.fetch_all(&self.pool)
.await
.map_err(error_de_consulta(SQL_LISTAR))?;
filas.iter().map(desde_fila).collect()
}
pub async fn actualizar(&self, tarea: &Tarea) -> Result<bool, DbError> {
let resultado = sqlx::query(SQL_ACTUALIZAR)
.bind(tarea.target_id.hyphenated().to_string())
.bind(tarea.repository_id.hyphenated().to_string())
.bind(&tarea.cron_expression)
.bind(tarea.keep_daily)
.bind(tarea.keep_weekly)
.bind(tarea.keep_monthly)
.bind(tarea.enabled)
.bind(&tarea.name)
.bind(tarea.id.hyphenated().to_string())
.execute(&self.pool)
.await
.map_err(error_de_consulta(SQL_ACTUALIZAR))?;
Ok(resultado.rows_affected() == 1)
}
pub async fn nombres(&self, ids: &[Uuid]) -> Result<HashMap<Uuid, Option<String>>, DbError> {
if ids.is_empty() {
return Ok(HashMap::new());
}
let identificadores: Vec<String> =
ids.iter().map(|id| id.hyphenated().to_string()).collect();
let lista = serde_json::to_string(&identificadores).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_LISTA_DE_TAREAS,
causa: sqlx::Error::Encode(Box::new(causa)),
})?;
let filas = sqlx::query(SQL_NOMBRES)
.bind(lista)
.fetch_all(&self.pool)
.await
.map_err(error_de_consulta(SQL_NOMBRES))?;
let mut nombres = HashMap::with_capacity(ids.len());
for fila in &filas {
let id = columna::<String>(fila, "id")?;
let id = Uuid::parse_str(&id).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})?;
if let Entry::Vacant(hueco) = nombres.entry(id) {
hueco.insert(columna::<Option<String>>(fila, "name")?);
}
}
Ok(nombres)
}
pub async fn borrar(&self, id: &Uuid) -> Result<bool, DbError> {
let resultado = sqlx::query(SQL_BORRAR)
.bind(id.hyphenated().to_string())
.execute(&self.pool)
.await
.map_err(error_de_consulta(SQL_BORRAR))?;
Ok(resultado.rows_affected() == 1)
}
}
fn error_de_consulta(consulta: &'static str) -> impl FnOnce(sqlx::Error) -> DbError {
move |causa| DbError::Consulta { consulta, causa }
}
fn columna<'fila, T>(fila: &'fila SqliteRow, nombre: &str) -> Result<T, DbError>
where
T: sqlx::Decode<'fila, sqlx::Sqlite> + sqlx::Type<sqlx::Sqlite>,
{
fila.try_get::<T, _>(nombre)
.map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa,
})
}
fn desde_fila(fila: &SqliteRow) -> Result<Tarea, DbError> {
let id = columna::<String>(fila, "id")?;
let id = Uuid::parse_str(&id).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})?;
let target_id = columna::<String>(fila, "target_id")?;
let target_id = Uuid::parse_str(&target_id).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})?;
let repository_id = columna::<String>(fila, "repository_id")?;
let repository_id = Uuid::parse_str(&repository_id).map_err(|causa| DbError::Consulta {
consulta: CONSULTA_MAPEO,
causa: sqlx::Error::Decode(Box::new(causa)),
})?;
Ok(Tarea {
id,
target_id,
repository_id,
cron_expression: columna(fila, "cron_expression")?,
keep_daily: columna(fila, "keep_daily")?,
keep_weekly: columna(fila, "keep_weekly")?,
keep_monthly: columna(fila, "keep_monthly")?,
enabled: columna(fila, "enabled")?,
created_at: columna(fila, "created_at")?,
name: columna(fila, "name")?,
})
}
#[cfg(test)]
mod tests {
use sqlx::SqlitePool;
use time::OffsetDateTime;
use uuid::Uuid;
use super::{AltaDeTarea, Tareas};
use crate::db::DbError;
use crate::db::entities::execution::{AltaDeEjecucion, Ejecuciones, Estado};
use crate::db::entities::repository::{AltaDeRepositorio, Repositorios};
use crate::db::entities::target::{AltaDeTarget, Targets, TipoDeTarget};
use crate::secret::SecretString;
use crate::test_support::{DirectorioDePrueba, base_con_clave, clave_de_prueba};
fn secreto(valor: &str) -> SecretString {
SecretString::new(valor.to_owned())
}
fn alta_repositorio(nombre: &str) -> AltaDeRepositorio {
AltaDeRepositorio {
name: nombre.to_owned(),
endpoint: format!("https://s3.example.test/{nombre}"),
bucket: format!("cubo-{nombre}"),
region: "eu-west-1".to_owned(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_owned(),
secret_access_key: secreto("AKIAIOSFODNN7EXAMPLE-de-tarea"),
passphrase: secreto("passphrase-distinguible-de-tarea"),
}
}
fn alta_target(nombre: &str) -> AltaDeTarget {
AltaDeTarget {
name: nombre.to_owned(),
target_type: TipoDeTarget::Directory,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: Some(format!("/srv/{nombre}")),
db_path: None,
}
}
fn alta_tarea(target_id: &Uuid, repository_id: &Uuid, cron: &str) -> AltaDeTarea {
AltaDeTarea {
target_id: *target_id,
repository_id: *repository_id,
cron_expression: cron.to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
name: None,
}
}
async fn base_con_tarea_posible() -> (DirectorioDePrueba, SqlitePool, Tareas, Uuid, Uuid) {
let (directorio, pool, clave) = base_con_clave().await;
let repositorio = Repositorios::nuevo(pool.clone(), clave.clone())
.alta(alta_repositorio("repo-de-tarea"))
.await
.expect("el alta del repositorio no puede fallar");
let target = Targets::nuevo(pool.clone(), clave)
.alta(alta_target("target-de-tarea"))
.await
.expect("el alta del target no puede fallar");
let tareas = Tareas::nuevo(pool.clone());
(directorio, pool, tareas, target.id, repositorio.id)
}
#[tokio::test]
async fn el_ciclo_de_una_tarea_con_su_retencion_y_su_interruptor() {
let (_directorio, _pool, tareas, target_id, repository_id) = base_con_tarea_posible().await;
let dado_de_alta = tareas
.alta(alta_tarea(&target_id, &repository_id, "0 3 * * *"))
.await
.expect("el alta no puede fallar");
let leido = tareas
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila recién dada de alta debe estar");
assert_eq!(leido.id, dado_de_alta.id, "el id leído no es el del alta");
assert_eq!(leido.target_id, target_id);
assert_eq!(leido.repository_id, repository_id);
assert_eq!(leido.cron_expression, "0 3 * * *");
assert_eq!(leido.keep_daily, 7);
assert_eq!(leido.keep_weekly, 4);
assert_eq!(leido.keep_monthly, 12);
assert!(leido.enabled, "la tarea se dio de alta activa");
assert_eq!(leido.created_at, dado_de_alta.created_at);
let listado = tareas.listar().await.expect("el listado no puede fallar");
assert_eq!(listado.len(), 1, "el listado debe traer la tarea");
let mut cambiado = leido.clone();
cambiado.cron_expression = "0 5 * * *".to_owned();
cambiado.keep_daily = 14;
cambiado.enabled = false;
assert!(
tareas
.actualizar(&cambiado)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let releido = tareas
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
releido.cron_expression, "0 5 * * *",
"el campo tocado no cambió"
);
assert_eq!(releido.keep_daily, 14, "la retención no cambió");
assert!(!releido.enabled, "el interruptor no cambió");
assert_eq!(
releido.created_at, dado_de_alta.created_at,
"el created_at no debía cambiar"
);
assert!(
tareas
.borrar(&dado_de_alta.id)
.await
.expect("el borrado no puede fallar"),
"la fila existía, así que el borrado debe decir que sí"
);
let tras_borrar = tareas
.leer(&dado_de_alta.id)
.await
.expect("la lectura no puede fallar");
assert!(tras_borrar.is_none(), "la fila debía desaparecer");
}
#[tokio::test]
async fn el_enabled_se_guarda_como_uno_o_cero() {
let (_directorio, pool, tareas, target_id, repository_id) = base_con_tarea_posible().await;
let activa = tareas
.alta(alta_tarea(&target_id, &repository_id, "0 3 * * *"))
.await
.expect("el alta no puede fallar");
let inactiva = tareas
.alta(AltaDeTarea {
enabled: false,
..alta_tarea(&target_id, &repository_id, "0 4 * * *")
})
.await
.expect("el alta no puede fallar");
let activa_cruda: i64 = sqlx::query_scalar("SELECT enabled FROM backup_jobs WHERE id = ?1")
.bind(activa.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el interruptor debe poder leerse en crudo");
assert_eq!(activa_cruda, 1, "una tarea activa debe guardar un 1");
let inactiva_cruda: i64 =
sqlx::query_scalar("SELECT enabled FROM backup_jobs WHERE id = ?1")
.bind(inactiva.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el interruptor debe poder leerse en crudo");
assert_eq!(inactiva_cruda, 0, "una tarea inactiva debe guardar un 0");
assert!(
tareas
.leer(&activa.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar")
.enabled,
"el 1 almacenado debe leerse como `true`"
);
assert!(
!tareas
.leer(&inactiva.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe estar")
.enabled,
"el 0 almacenado debe leerse como `false`"
);
}
#[tokio::test]
async fn una_insercion_huerfana_se_rechaza_y_no_deja_fila() {
let (_directorio, pool, tareas, target_id, repository_id) = base_con_tarea_posible().await;
let inexistente = Uuid::new_v4();
let error = tareas
.alta(alta_tarea(&inexistente, &repository_id, "0 3 * * *"))
.await
.expect_err("un target inexistente no puede dar una tarea");
assert!(
matches!(error, DbError::Consulta { .. }),
"el fallo debe llegar como error tipado: {error}"
);
let error = tareas
.alta(alta_tarea(&target_id, &inexistente, "0 4 * * *"))
.await
.expect_err("un repositorio inexistente no puede dar una tarea");
assert!(
matches!(error, DbError::Consulta { .. }),
"el fallo debe llegar como error tipado: {error}"
);
let ejecuciones = Ejecuciones::nuevo(pool.clone(), clave_de_prueba());
let error = ejecuciones
.alta(AltaDeEjecucion {
job_id: inexistente,
status: Estado::Pending,
snapshot_id: None,
bytes_processed: 0,
bytes_uploaded: 0,
error_message: None,
started_at: OffsetDateTime::now_utc(),
finished_at: None,
})
.await
.expect_err("una tarea inexistente no puede dar una ejecución");
assert!(
matches!(error, DbError::Consulta { .. }),
"el fallo debe llegar como error tipado: {error}"
);
let tareas_que_quedaron: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs")
.fetch_one(&pool)
.await
.expect("el recuento de tareas no puede fallar");
assert_eq!(
tareas_que_quedaron, 0,
"la inserción huérfana dejó una tarea"
);
let ejecuciones_que_quedaron: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM job_executions")
.fetch_one(&pool)
.await
.expect("el recuento de ejecuciones no puede fallar");
assert_eq!(
ejecuciones_que_quedaron, 0,
"la inserción huérfana dejó una ejecución"
);
}
#[tokio::test]
async fn un_alta_directa_sin_name_deja_la_columna_a_null() {
let (_directorio, pool, _tareas, target_id, repository_id) = base_con_tarea_posible().await;
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO backup_jobs (id, target_id, repository_id, cron_expression) \
VALUES (?1, ?2, ?3, ?4)",
)
.bind(id.hyphenated().to_string())
.bind(target_id.hyphenated().to_string())
.bind(repository_id.hyphenated().to_string())
.bind("0 3 * * *")
.execute(&pool)
.await
.expect(
"una fila sin `name` tiene que poder insertarse: si la columna fuera `NOT NULL` sin \
valor por defecto, esto fallaría",
);
let nombre =
sqlx::query_scalar::<_, Option<String>>("SELECT name FROM backup_jobs WHERE id = ?1")
.bind(id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("la columna `name` tiene que poder leerse en la fila insertada sin ella");
assert!(
nombre.is_none(),
"una fila insertada sin `name` tiene que quedar a NULL, y quedó {nombre:?}"
);
}
#[tokio::test]
async fn la_actualizacion_persiste_el_nombre_nuevo() {
let (_directorio, _pool, tareas, target_id, repository_id) = base_con_tarea_posible().await;
let mut tarea = tareas
.alta(alta_tarea(&target_id, &repository_id, "0 3 * * *"))
.await
.expect("el alta no puede fallar");
assert!(
tarea.name.is_none(),
"el alta de la prueba entra sin nombre, así que lo que se mide es la edición"
);
tarea.name = Some("copia nocturna".to_owned());
assert!(
tareas
.actualizar(&tarea)
.await
.expect("la actualización no puede fallar"),
"la fila existía, así que la actualización debe decir que sí"
);
let releida = tareas
.leer(&tarea.id)
.await
.expect("la lectura no puede fallar")
.expect("la fila debe seguir existiendo");
assert_eq!(
releida.name.as_deref(),
Some("copia nocturna"),
"el nombre pasado a `actualizar` tiene que quedar en la columna"
);
}
}