persistant_relational/
sweep.rs1use crate::{Dialect, RelError, RelationalBackend, Value};
26use std::fmt;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SweepReport {
31 pub deleted: u64,
33 pub batches: u32,
35 pub complete: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SweepError {
43 BadName(String),
45 ZeroBound,
47 NullCutoff,
49 Rel(RelError),
51 Dialect(String),
53}
54
55impl fmt::Display for SweepError {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 SweepError::BadName(n) => write!(f, "not a plain identifier: {n:?}"),
59 SweepError::ZeroBound => f.write_str("batch and max_batches must be at least 1"),
60 SweepError::NullCutoff => f.write_str("the cutoff must not be NULL"),
61 SweepError::Rel(e) => write!(f, "sweep: {e}"),
62 SweepError::Dialect(d) => write!(f, "sweep: no statement for dialect {d}"),
63 }
64 }
65}
66
67impl std::error::Error for SweepError {}
68
69fn ident(s: &str) -> bool {
70 !s.is_empty()
71 && !s.starts_with(|c: char| c.is_ascii_digit())
72 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
73}
74
75fn table_name(s: &str) -> bool {
76 match s.split_once('.') {
77 Some((schema, table)) => ident(schema) && ident(table),
78 None => ident(s),
79 }
80}
81
82fn statement(dialect: &Dialect, table: &str, column: &str, batch: u32) -> Option<String> {
84 Some(match dialect {
85 Dialect::Postgres => format!(
86 "DELETE FROM {table} WHERE ctid IN \
87 (SELECT ctid FROM {table} WHERE {column} < $1 LIMIT {batch})"
88 ),
89 Dialect::Sqlite => format!(
90 "DELETE FROM {table} WHERE rowid IN \
91 (SELECT rowid FROM {table} WHERE {column} < ? LIMIT {batch})"
92 ),
93 Dialect::MySql | Dialect::MariaDb => {
94 format!("DELETE FROM {table} WHERE {column} < ? LIMIT {batch}")
95 }
96 _ => return None,
97 })
98}
99
100pub async fn sweep<B: RelationalBackend>(
103 db: &B,
104 table: &str,
105 column: &str,
106 cutoff: &Value,
107 batch: u32,
108 max_batches: u32,
109) -> Result<SweepReport, SweepError> {
110 if !table_name(table) {
111 return Err(SweepError::BadName(table.to_string()));
112 }
113 if !ident(column) {
114 return Err(SweepError::BadName(column.to_string()));
115 }
116 if batch == 0 || max_batches == 0 {
117 return Err(SweepError::ZeroBound);
118 }
119 if *cutoff == Value::Null {
120 return Err(SweepError::NullCutoff);
121 }
122 let dialect = db.dialect();
123 let sql = statement(&dialect, table, column, batch)
124 .ok_or_else(|| SweepError::Dialect(dialect.to_string()))?;
125 let mut report = SweepReport {
126 deleted: 0,
127 batches: 0,
128 complete: false,
129 };
130 while report.batches < max_batches {
131 let n = db
132 .execute(&sql, std::slice::from_ref(cutoff))
133 .await
134 .map_err(SweepError::Rel)?;
135 report.batches += 1;
136 report.deleted += n;
137 if n == 0 {
138 report.complete = true;
139 break;
140 }
141 }
142 Ok(report)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn names_are_plain_identifiers_only() {
151 for ok in ["sessions", "_t1", "public.sessions", "A_b"] {
152 assert!(table_name(ok), "{ok}");
153 }
154 for bad in [
155 "",
156 "1t",
157 "t; DROP TABLE t",
158 "a.b.c",
159 ".t",
160 "t.",
161 "a b",
162 "t\"",
163 "t-1",
164 "é",
165 ] {
166 assert!(!table_name(bad), "{bad:?}");
167 }
168 assert!(!ident("a.b"));
169 }
170
171 #[test]
172 fn each_dialect_has_a_statement_with_one_parameter() {
173 for d in [
174 Dialect::Postgres,
175 Dialect::Sqlite,
176 Dialect::MySql,
177 Dialect::MariaDb,
178 ] {
179 let s = statement(&d, "t", "c", 7).unwrap();
180 assert!(s.contains("LIMIT 7"), "{s}");
181 assert_eq!(s.matches("c < ").count(), 1);
182 }
183 assert!(statement(&Dialect::Other("x".into()), "t", "c", 1).is_none());
184 }
185}