use rusqlite::{Connection, params};
use crate::error::LificError;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PurgeCounts {
pub issues: usize,
pub pages: usize,
pub comments: usize,
}
impl PurgeCounts {
pub fn total(&self) -> usize {
self.issues + self.pages + self.comments
}
pub fn is_empty(&self) -> bool {
self.total() == 0
}
}
pub fn purge_tombstones(conn: &Connection, trash_days: u32) -> Result<PurgeCounts, LificError> {
if trash_days == 0 {
return Ok(PurgeCounts::default());
}
let cutoff = format!("-{trash_days} days");
let comments = conn.execute(
"DELETE FROM comments
WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?1)",
params![cutoff],
)?;
let issues = conn.execute(
"DELETE FROM issues
WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?1)",
params![cutoff],
)?;
let pages = conn.execute(
"DELETE FROM pages
WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', ?1)",
params![cutoff],
)?;
Ok(PurgeCounts {
issues,
pages,
comments,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::db::models::*;
use crate::db::queries;
fn seeded() -> (db::DbPool, i64, i64) {
let pool = db::open_memory().expect("test db");
let (project_id, user_id) = {
let conn = pool.write().unwrap();
let project = queries::create_project(
&conn,
&CreateProject {
name: "Trash".into(),
identifier: "TRA".into(),
..Default::default()
},
)
.unwrap();
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('ada', 'ada@test.local', 'x', 'Ada', 1, 0)",
[],
)
.unwrap();
(project.id, conn.last_insert_rowid())
};
(pool, project_id, user_id)
}
fn new_issue(project_id: i64, title: &str) -> CreateIssue {
CreateIssue {
project_id,
title: title.into(),
..Default::default()
}
}
fn age_tombstone(conn: &Connection, table: &str, id: i64, days: i64) {
conn.execute(
&format!(
"UPDATE {table} SET deleted_at = datetime('now', '-{days} days') WHERE id = ?1"
),
params![id],
)
.unwrap();
}
#[test]
fn purge_removes_old_tombstones_and_their_children() {
let (pool, pid, uid) = seeded();
let conn = pool.write().unwrap();
let issue = queries::create_issue(&conn, &new_issue(pid, "Ancient")).unwrap();
let comment = queries::comments::create_comment(
&conn,
queries::comments::CommentParent::Issue(issue.id),
uid,
"goes with it",
)
.unwrap();
queries::delete_issue(&conn, issue.id).unwrap();
age_tombstone(&conn, "issues", issue.id, 60);
age_tombstone(&conn, "comments", comment.id, 60);
let counts = purge_tombstones(&conn, 30).unwrap();
assert_eq!(counts.issues, 1);
assert_eq!(counts.comments, 1);
let issue_rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM issues WHERE id = ?1",
params![issue.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(issue_rows, 0, "the row itself is gone");
let comment_rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM comments WHERE id = ?1",
params![comment.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(comment_rows, 0);
}
#[test]
fn purge_spares_tombstones_inside_the_window() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let issue = queries::create_issue(&conn, &new_issue(pid, "Recent")).unwrap();
queries::delete_issue(&conn, issue.id).unwrap();
assert_eq!(purge_tombstones(&conn, 30).unwrap(), PurgeCounts::default());
assert!(queries::restore_issue(&conn, issue.id).is_ok());
}
#[test]
fn purge_never_touches_live_rows() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let live = queries::create_issue(&conn, &new_issue(pid, "Alive")).unwrap();
assert!(purge_tombstones(&conn, 1).unwrap().is_empty());
assert!(queries::get_issue(&conn, live.id).is_ok());
}
#[test]
fn trash_days_zero_disables_the_sweep() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let issue = queries::create_issue(&conn, &new_issue(pid, "Kept forever")).unwrap();
queries::delete_issue(&conn, issue.id).unwrap();
age_tombstone(&conn, "issues", issue.id, 4000);
assert_eq!(purge_tombstones(&conn, 0).unwrap(), PurgeCounts::default());
assert!(queries::restore_issue(&conn, issue.id).is_ok());
}
#[test]
fn purge_leaves_the_search_index_clean() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let issue = queries::create_issue(&conn, &new_issue(pid, "Findable widget")).unwrap();
queries::delete_issue(&conn, issue.id).unwrap();
age_tombstone(&conn, "issues", issue.id, 90);
purge_tombstones(&conn, 30).unwrap();
let indexed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM search_index WHERE entity_type = 'issue' AND entity_id = ?1",
params![issue.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(indexed, 0);
}
#[test]
fn purge_writes_no_second_audit_row() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let issue = queries::create_issue(&conn, &new_issue(pid, "Audited")).unwrap();
queries::delete_issue(&conn, issue.id).unwrap();
age_tombstone(&conn, "issues", issue.id, 90);
let before: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE entity_type = 'issue' AND entity_id = ?1",
params![issue.id],
|r| r.get(0),
)
.unwrap();
purge_tombstones(&conn, 30).unwrap();
let after: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE entity_type = 'issue' AND entity_id = ?1",
params![issue.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(before, after, "a purge is not a second deletion");
}
#[test]
fn purge_collects_page_tombstones_too() {
let (pool, pid, _) = seeded();
let conn = pool.write().unwrap();
let page = queries::create_page(
&conn,
&CreatePage {
project_id: Some(pid),
title: "Doomed doc".into(),
..Default::default()
},
)
.unwrap();
queries::delete_page(&conn, page.id).unwrap();
age_tombstone(&conn, "pages", page.id, 45);
assert_eq!(purge_tombstones(&conn, 30).unwrap().pages, 1);
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pages WHERE id = ?1",
params![page.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(rows, 0);
}
}