Skip to main content

atuin_scripts/
database.rs

1use 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        // intentionally not joining, don't want to duplicate the script data in memory a whole bunch.
157        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        // Fetch all the tags for each script
180        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        // delete all the tags for the script
215        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        // Update the script's base fields
229        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        // Delete all existing tags for this script
239        sqlx::query("delete from script_tags where script_id = ?1")
240            .bind(s.id.to_string())
241            .execute(&mut *tx)
242            .await?;
243
244        // Insert new tags
245        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}