atuin_scripts/
database.rs1use std::{path::Path, str::FromStr, time::Duration};
2
3use atuin_common::utils;
4use sqlx::{
5 Result, Row,
6 sqlite::{
7 SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
8 SqliteSynchronous,
9 },
10};
11use tokio::fs;
12use tracing::debug;
13use uuid::Uuid;
14
15use crate::store::script::Script;
16
17#[derive(Debug, Clone)]
18pub struct Database {
19 pub pool: SqlitePool,
20}
21
22impl Database {
23 pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
24 let path = path.as_ref();
25 debug!("opening script sqlite database at {:?}", path);
26
27 if utils::broken_symlink(path) {
28 eprintln!(
29 "Atuin: Script sqlite db path ({path:?}) is a broken symlink. Unable to read or create replacement."
30 );
31 std::process::exit(1);
32 }
33
34 if !path.exists()
35 && let Some(dir) = path.parent()
36 {
37 fs::create_dir_all(dir).await?;
38 }
39
40 let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())?
41 .journal_mode(SqliteJournalMode::Wal)
42 .optimize_on_close(true, None)
43 .synchronous(SqliteSynchronous::Normal)
44 .with_regexp()
45 .foreign_keys(true)
46 .create_if_missing(true);
47
48 let pool = SqlitePoolOptions::new()
49 .acquire_timeout(Duration::try_from_secs_f64(timeout).map_err(|e| {
50 sqlx::Error::Decode(format!("invalid db timeout {timeout}: {e}").into())
51 })?)
52 .connect_with(opts)
53 .await?;
54
55 Self::setup_db(&pool).await?;
56 Ok(Self { pool })
57 }
58
59 pub async fn sqlite_version(&self) -> Result<String> {
60 sqlx::query_scalar("SELECT sqlite_version()")
61 .fetch_one(&self.pool)
62 .await
63 }
64
65 async fn setup_db(pool: &SqlitePool) -> Result<()> {
66 debug!("running sqlite database setup");
67
68 sqlx::migrate!("./migrations").run(pool).await?;
69
70 Ok(())
71 }
72
73 async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, s: &Script) -> Result<()> {
74 sqlx::query(
75 "insert or ignore into scripts(id, name, description, shebang, script)
76 values(?1, ?2, ?3, ?4, ?5)",
77 )
78 .bind(s.id.to_string())
79 .bind(s.name.as_str())
80 .bind(s.description.as_str())
81 .bind(s.shebang.as_str())
82 .bind(s.script.as_str())
83 .execute(&mut **tx)
84 .await?;
85
86 for tag in s.tags.iter() {
87 sqlx::query(
88 "insert or ignore into script_tags(script_id, tag)
89 values(?1, ?2)",
90 )
91 .bind(s.id.to_string())
92 .bind(tag)
93 .execute(&mut **tx)
94 .await?;
95 }
96
97 Ok(())
98 }
99
100 pub async fn save(&self, s: &Script) -> Result<()> {
101 debug!("saving script to sqlite");
102 let mut tx = self.pool.begin().await?;
103 Self::save_raw(&mut tx, s).await?;
104 tx.commit().await?;
105
106 Ok(())
107 }
108
109 pub async fn save_bulk(&self, s: &[Script]) -> Result<()> {
110 debug!("saving scripts to sqlite");
111
112 let mut tx = self.pool.begin().await?;
113
114 for i in s {
115 Self::save_raw(&mut tx, i).await?;
116 }
117
118 tx.commit().await?;
119
120 Ok(())
121 }
122
123 fn query_script(row: SqliteRow) -> Script {
124 let id = row.get("id");
125 let name = row.get("name");
126 let description = row.get("description");
127 let shebang = row.get("shebang");
128 let script = row.get("script");
129
130 let id = Uuid::parse_str(id).unwrap();
131
132 Script {
133 id,
134 name,
135 description,
136 shebang,
137 script,
138 tags: vec![],
139 }
140 }
141
142 fn query_script_tags(row: SqliteRow) -> String {
143 row.get("tag")
144 }
145
146 #[allow(dead_code)]
147 async fn load(&self, id: &str) -> Result<Option<Script>> {
148 debug!("loading script item {}", id);
149
150 let res = sqlx::query("select * from scripts where id = ?1")
151 .bind(id)
152 .map(Self::query_script)
153 .fetch_optional(&self.pool)
154 .await?;
155
156 if let Some(mut script) = res {
158 let tags = sqlx::query("select tag from script_tags where script_id = ?1")
159 .bind(id)
160 .map(Self::query_script_tags)
161 .fetch_all(&self.pool)
162 .await?;
163
164 script.tags = tags;
165 Ok(Some(script))
166 } else {
167 Ok(None)
168 }
169 }
170
171 pub async fn list(&self) -> Result<Vec<Script>> {
172 debug!("listing scripts");
173
174 let mut res = sqlx::query("select * from scripts")
175 .map(Self::query_script)
176 .fetch_all(&self.pool)
177 .await?;
178
179 for script in res.iter_mut() {
181 let tags = sqlx::query("select tag from script_tags where script_id = ?1")
182 .bind(script.id.to_string())
183 .map(Self::query_script_tags)
184 .fetch_all(&self.pool)
185 .await?;
186
187 script.tags = tags;
188 }
189
190 Ok(res)
191 }
192
193 pub async fn clear(&self) -> Result<()> {
194 debug!("clearing all scripts from sqlite");
195
196 sqlx::query("delete from script_tags")
197 .execute(&self.pool)
198 .await?;
199 sqlx::query("delete from scripts")
200 .execute(&self.pool)
201 .await?;
202
203 Ok(())
204 }
205
206 pub async fn delete(&self, id: &str) -> Result<()> {
207 debug!("deleting script {}", id);
208
209 sqlx::query("delete from scripts where id = ?1")
210 .bind(id)
211 .execute(&self.pool)
212 .await?;
213
214 sqlx::query("delete from script_tags where script_id = ?1")
216 .bind(id)
217 .execute(&self.pool)
218 .await?;
219
220 Ok(())
221 }
222
223 pub async fn update(&self, s: &Script) -> Result<()> {
224 debug!("updating script {:?}", s);
225
226 let mut tx = self.pool.begin().await?;
227
228 sqlx::query("update scripts set name = ?1, description = ?2, shebang = ?3, script = ?4 where id = ?5")
230 .bind(s.name.as_str())
231 .bind(s.description.as_str())
232 .bind(s.shebang.as_str())
233 .bind(s.script.as_str())
234 .bind(s.id.to_string())
235 .execute(&mut *tx)
236 .await?;
237
238 sqlx::query("delete from script_tags where script_id = ?1")
240 .bind(s.id.to_string())
241 .execute(&mut *tx)
242 .await?;
243
244 for tag in s.tags.iter() {
246 sqlx::query(
247 "insert or ignore into script_tags(script_id, tag)
248 values(?1, ?2)",
249 )
250 .bind(s.id.to_string())
251 .bind(tag)
252 .execute(&mut *tx)
253 .await?;
254 }
255
256 tx.commit().await?;
257
258 Ok(())
259 }
260
261 pub async fn get_by_name(&self, name: &str) -> Result<Option<Script>> {
262 let res = sqlx::query("select * from scripts where name = ?1")
263 .bind(name)
264 .map(Self::query_script)
265 .fetch_optional(&self.pool)
266 .await?;
267
268 let script = if let Some(mut script) = res {
269 let tags = sqlx::query("select tag from script_tags where script_id = ?1")
270 .bind(script.id.to_string())
271 .map(Self::query_script_tags)
272 .fetch_all(&self.pool)
273 .await?;
274
275 script.tags = tags;
276 Some(script)
277 } else {
278 None
279 };
280
281 Ok(script)
282 }
283}
284
285#[cfg(test)]
286mod test {
287 use super::*;
288 use rstest::*;
289
290 #[fixture]
291 async fn db() -> Database {
292 Database::new("sqlite::memory:", 1.0).await.unwrap()
293 }
294
295 #[fixture]
296 fn script(
297 #[default("test")] name: impl Into<String>,
298 #[default("test")] description: impl Into<String>,
299 #[default("test")] shebang: impl Into<String>,
300 #[default("test")] script_body: impl Into<String>,
301 ) -> Script {
302 Script::builder()
303 .name(name.into())
304 .description(description.into())
305 .shebang(shebang.into())
306 .script(script_body.into())
307 .build()
308 }
309
310 #[rstest]
311 #[tokio::test]
312 async fn test_list(#[future] db: Database, script: Script) {
313 let db = db.await;
314
315 let scripts = db.list().await.unwrap();
316 assert_eq!(scripts.len(), 0);
317
318 db.save(&script).await.unwrap();
319
320 let scripts = db.list().await.unwrap();
321 assert_eq!(scripts.len(), 1);
322 assert_eq!(scripts[0].name, "test");
323 }
324
325 #[rstest]
326 #[tokio::test]
327 async fn test_save_load(
328 #[future] db: Database,
329 #[with("test name", "test description", "test shebang", "test script")] script: Script,
330 ) {
331 let db = db.await;
332
333 db.save(&script).await.unwrap();
334
335 let loaded = db.load(&script.id.to_string()).await.unwrap().unwrap();
336
337 assert_eq!(loaded, script);
338 }
339
340 #[rstest]
341 #[tokio::test]
342 async fn test_save_bulk(#[future] db: Database) {
343 let db = db.await;
344
345 let scripts = vec![
346 Script::builder()
347 .name("test name".to_string())
348 .description("test description".to_string())
349 .shebang("test shebang".to_string())
350 .script("test script".to_string())
351 .build(),
352 Script::builder()
353 .name("test name 2".to_string())
354 .description("test description 2".to_string())
355 .shebang("test shebang 2".to_string())
356 .script("test script 2".to_string())
357 .build(),
358 ];
359
360 db.save_bulk(&scripts).await.unwrap();
361
362 let loaded = db.list().await.unwrap();
363 assert_eq!(loaded.len(), 2);
364 assert_eq!(loaded[0].name, "test name");
365 assert_eq!(loaded[1].name, "test name 2");
366 }
367
368 #[rstest]
369 #[tokio::test]
370 async fn test_delete(#[future] db: Database, script: Script) {
371 let db = db.await;
372
373 db.save(&script).await.unwrap();
374
375 assert_eq!(db.list().await.unwrap().len(), 1);
376 db.delete(&script.id.to_string()).await.unwrap();
377
378 let loaded = db.list().await.unwrap();
379 assert_eq!(loaded.len(), 0);
380 }
381}