Refined sqlez, implemented 60% of workspace serialization sql

Mikayla Maki created

Change summary

crates/db/src/db.rs              |  52 ++--
crates/db/src/migrations.rs      |  14 -
crates/db/src/workspace.rs       | 337 +++++++++++++--------------------
crates/db/src/workspace/items.rs | 118 ++++++-----
crates/db/src/workspace/model.rs | 146 +++++++++++++-
crates/db/src/workspace/pane.rs  | 164 +++++++--------
crates/sqlez/src/bindable.rs     |  25 ++
crates/sqlez/src/connection.rs   |  78 +++++--
crates/sqlez/src/savepoint.rs    |  38 +-
crates/sqlez/src/statement.rs    |  54 ++++
crates/util/src/lib.rs           |  10 +
11 files changed, 586 insertions(+), 450 deletions(-)

Detailed changes

crates/db/src/db.rs 🔗

@@ -1,5 +1,4 @@
 pub mod kvp;
-mod migrations;
 pub mod workspace;
 
 use std::fs;
@@ -11,8 +10,9 @@ use indoc::indoc;
 use kvp::KVP_MIGRATION;
 use sqlez::connection::Connection;
 use sqlez::thread_safe_connection::ThreadSafeConnection;
-
+use workspace::items::ITEM_MIGRATIONS;
 use workspace::pane::PANE_MIGRATIONS;
+
 pub use workspace::*;
 
 #[derive(Clone)]
@@ -35,32 +35,21 @@ impl Db {
             .expect("Should be able to create the database directory");
         let db_path = current_db_dir.join(Path::new("db.sqlite"));
 
-        Db(
-            ThreadSafeConnection::new(db_path.to_string_lossy().as_ref(), true)
-                .with_initialize_query(indoc! {"
-                    PRAGMA journal_mode=WAL;
-                    PRAGMA synchronous=NORMAL;
-                    PRAGMA foreign_keys=TRUE;
-                    PRAGMA case_sensitive_like=TRUE;
-                "})
-                .with_migrations(&[KVP_MIGRATION, WORKSPACES_MIGRATION, PANE_MIGRATIONS]),
-        )
-    }
-
-    pub fn persisting(&self) -> bool {
-        self.persistent()
+        Db(initialize_connection(ThreadSafeConnection::new(
+            db_path.to_string_lossy().as_ref(),
+            true,
+        )))
     }
 
     /// Open a in memory database for testing and as a fallback.
     pub fn open_in_memory(db_name: &str) -> Self {
-        Db(ThreadSafeConnection::new(db_name, false)
-            .with_initialize_query(indoc! {"
-                    PRAGMA journal_mode=WAL;
-                    PRAGMA synchronous=NORMAL;
-                    PRAGMA foreign_keys=TRUE;
-                    PRAGMA case_sensitive_like=TRUE;
-                    "})
-            .with_migrations(&[KVP_MIGRATION, WORKSPACES_MIGRATION, PANE_MIGRATIONS]))
+        Db(initialize_connection(ThreadSafeConnection::new(
+            db_name, false,
+        )))
+    }
+
+    pub fn persisting(&self) -> bool {
+        self.persistent()
     }
 
     pub fn write_to<P: AsRef<Path>>(&self, dest: P) -> Result<()> {
@@ -68,3 +57,18 @@ impl Db {
         self.backup_main(&destination)
     }
 }
+
+fn initialize_connection(conn: ThreadSafeConnection) -> ThreadSafeConnection {
+    conn.with_initialize_query(indoc! {"
+        PRAGMA journal_mode=WAL;
+        PRAGMA synchronous=NORMAL;
+        PRAGMA foreign_keys=TRUE;
+        PRAGMA case_sensitive_like=TRUE;
+        "})
+        .with_migrations(&[
+            KVP_MIGRATION,
+            WORKSPACES_MIGRATION,
+            PANE_MIGRATIONS,
+            ITEM_MIGRATIONS,
+        ])
+}

crates/db/src/migrations.rs 🔗

@@ -1,14 +0,0 @@
-// // use crate::items::ITEMS_M_1;
-// use crate::{kvp::KVP_M_1, pane::PANE_M_1, WORKSPACES_MIGRATION};
-
-// // This must be ordered by development time! Only ever add new migrations to the end!!
-// // Bad things will probably happen if you don't monotonically edit this vec!!!!
-// // And no re-ordering ever!!!!!!!!!! The results of these migrations are on the user's
-// // file system and so everything we do here is locked in _f_o_r_e_v_e_r_.
-// lazy_static::lazy_static! {
-//     pub static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![
-//         M::up(KVP_M_1),
-//         M::up(WORKSPACE_M_1),
-//         M::up(PANE_M_1)
-//     ]);
-// }

crates/db/src/workspace.rs 🔗

@@ -1,4 +1,4 @@
-mod items;
+pub(crate) mod items;
 pub mod model;
 pub(crate) mod pane;
 
@@ -58,8 +58,14 @@ impl Db {
         .flatten()?;
 
         Some(SerializedWorkspace {
-            dock_pane: self.get_dock_pane(&workspace_id)?,
-            center_group: self.get_center_group(&workspace_id),
+            dock_pane: self
+                .get_dock_pane(&workspace_id)
+                .context("Getting dock pane")
+                .log_err()?,
+            center_group: self
+                .get_center_group(&workspace_id)
+                .context("Getting center group")
+                .log_err()?,
             dock_anchor,
             dock_visible,
         })
@@ -70,231 +76,152 @@ impl Db {
     pub fn save_workspace<P: AsRef<Path>>(
         &self,
         worktree_roots: &[P],
-        workspace: SerializedWorkspace,
+        old_roots: Option<&[P]>,
+        workspace: &SerializedWorkspace,
     ) {
         let workspace_id: WorkspaceId = worktree_roots.into();
 
-        self.with_savepoint("update_worktrees", |conn| {
+        self.with_savepoint("update_worktrees", || {
+            if let Some(old_roots) = old_roots {
+                let old_id: WorkspaceId = old_roots.into();
+
+                self.prepare("DELETE FROM WORKSPACES WHERE workspace_id = ?")?
+                    .with_bindings(&old_id)?
+                    .exec()?;
+            }
+
             // Delete any previous workspaces with the same roots. This cascades to all
             // other tables that are based on the same roots set.
             // Insert new workspace into workspaces table if none were found
-            self.prepare(indoc!{"
-                DELETE FROM workspaces WHERE workspace_id = ?1;
-                INSERT INTO workspaces(workspace_id, dock_anchor, dock_visible) VALUES (?1, ?, ?)"})?
+            self.prepare("DELETE FROM workspaces WHERE workspace_id = ?;")?
+                .with_bindings(&workspace_id)?
+                .exec()?;
+
+            self.prepare(
+                "INSERT INTO workspaces(workspace_id, dock_anchor, dock_visible) VALUES (?, ?, ?)",
+            )?
             .with_bindings((&workspace_id, workspace.dock_anchor, workspace.dock_visible))?
             .exec()?;
-            
+
             // Save center pane group and dock pane
-            Self::save_center_group(&workspace_id, &workspace.center_group, conn)?;
-            Self::save_dock_pane(&workspace_id, &workspace.dock_pane, conn)?;
+            self.save_center_group(&workspace_id, &workspace.center_group)?;
+            self.save_dock_pane(&workspace_id, &workspace.dock_pane)?;
 
             Ok(())
         })
-        .with_context(|| format!("Update workspace with roots {:?}", worktree_roots.iter().map(|p| p.as_ref()).collect::<Vec<_>>()))
+        .with_context(|| {
+            format!(
+                "Update workspace with roots {:?}",
+                worktree_roots
+                    .iter()
+                    .map(|p| p.as_ref())
+                    .collect::<Vec<_>>()
+            )
+        })
         .log_err();
     }
 
     /// Returns the previous workspace ids sorted by last modified along with their opened worktree roots
     pub fn recent_workspaces(&self, limit: usize) -> Vec<Vec<PathBuf>> {
         iife!({
-            Ok::<_, anyhow::Error>(self.prepare("SELECT workspace_id FROM workspaces ORDER BY timestamp DESC LIMIT ?")?
+            // TODO, upgrade anyhow: https://docs.rs/anyhow/1.0.66/anyhow/fn.Ok.html
+            Ok::<_, anyhow::Error>(
+                self.prepare(
+                    "SELECT workspace_id FROM workspaces ORDER BY timestamp DESC LIMIT ?",
+                )?
                 .with_bindings(limit)?
                 .rows::<WorkspaceId>()?
-                .into_iter().map(|id| id.0)
-                .collect::<Vec<Vec<PathBuf>>>())
-            
-        }).log_err().unwrap_or_default()
+                .into_iter()
+                .map(|id| id.paths())
+                .collect::<Vec<Vec<PathBuf>>>(),
+            )
+        })
+        .log_err()
+        .unwrap_or_default()
     }
 }
 
 #[cfg(test)]
 mod tests {
-
-    // use std::{path::PathBuf, thread::sleep, time::Duration};
-
-    // use crate::Db;
-
-    // use super::WorkspaceId;
-
-    // #[test]
-    // fn test_workspace_saving() {
-    //     env_logger::init();
-    //     let db = Db::open_in_memory("test_new_worktrees_for_roots");
-
-    //     // Test nothing returned with no roots at first
-    //     assert_eq!(db.workspace_for_roots::<String>(&[]), None);
-
-    //     // Test creation
-    //     let workspace_1 = db.workspace_for_roots::<String>(&[]);
-    //     assert_eq!(workspace_1.workspace_id, WorkspaceId(1));
-
-    //     // Ensure the timestamps are different
-    //     sleep(Duration::from_secs(1));
-    //     db.make_new_workspace::<String>(&[]);
-
-    //     // Test pulling another value from recent workspaces
-    //     let workspace_2 = db.workspace_for_roots::<String>(&[]);
-    //     assert_eq!(workspace_2.workspace_id, WorkspaceId(2));
-
-    //     // Ensure the timestamps are different
-    //     sleep(Duration::from_secs(1));
-
-    //     // Test creating a new workspace that doesn't exist already
-    //     let workspace_3 = db.workspace_for_roots(&["/tmp", "/tmp2"]);
-    //     assert_eq!(workspace_3.workspace_id, WorkspaceId(3));
-
-    //     // Make sure it's in the recent workspaces....
-    //     let workspace_3 = db.workspace_for_roots::<String>(&[]);
-    //     assert_eq!(workspace_3.workspace_id, WorkspaceId(3));
-
-    //     // And that it can be pulled out again
-    //     let workspace_3 = db.workspace_for_roots(&["/tmp", "/tmp2"]);
-    //     assert_eq!(workspace_3.workspace_id, WorkspaceId(3));
-    // }
-
-    // #[test]
-    // fn test_empty_worktrees() {
-    //     let db = Db::open_in_memory("test_empty_worktrees");
-
-    //     assert_eq!(None, db.workspace::<String>(&[]));
-
-    //     db.make_new_workspace::<String>(&[]); //ID 1
-    //     db.make_new_workspace::<String>(&[]); //ID 2
-    //     db.update_worktrees(&WorkspaceId(1), &["/tmp", "/tmp2"]);
-
-    //     // Sanity check
-    //     assert_eq!(db.workspace(&["/tmp", "/tmp2"]).unwrap().0, WorkspaceId(1));
-
-    //     db.update_worktrees::<String>(&WorkspaceId(1), &[]);
-
-    //     // Make sure 'no worktrees' fails correctly. returning [1, 2] from this
-    //     // call would be semantically correct (as those are the workspaces that
-    //     // don't have roots) but I'd prefer that this API to either return exactly one
-    //     // workspace, and None otherwise
-    //     assert_eq!(db.workspace::<String>(&[]), None,);
-
-    //     assert_eq!(db.last_workspace().unwrap().0, WorkspaceId(1));
-
-    //     assert_eq!(
-    //         db.recent_workspaces(2),
-    //         vec![Vec::<PathBuf>::new(), Vec::<PathBuf>::new()],
-    //     )
-    // }
-
-    // #[test]
-    // fn test_more_workspace_ids() {
-    //     let data = &[
-    //         (WorkspaceId(1), vec!["/tmp1"]),
-    //         (WorkspaceId(2), vec!["/tmp1", "/tmp2"]),
-    //         (WorkspaceId(3), vec!["/tmp1", "/tmp2", "/tmp3"]),
-    //         (WorkspaceId(4), vec!["/tmp2", "/tmp3"]),
-    //         (WorkspaceId(5), vec!["/tmp2", "/tmp3", "/tmp4"]),
-    //         (WorkspaceId(6), vec!["/tmp2", "/tmp4"]),
-    //         (WorkspaceId(7), vec!["/tmp2"]),
-    //     ];
-
-    //     let db = Db::open_in_memory("test_more_workspace_ids");
-
-    //     for (workspace_id, entries) in data {
-    //         db.make_new_workspace::<String>(&[]);
-    //         db.update_worktrees(workspace_id, entries);
-    //     }
-
-    //     assert_eq!(WorkspaceId(1), db.workspace(&["/tmp1"]).unwrap().0);
-    //     assert_eq!(db.workspace(&["/tmp1", "/tmp2"]).unwrap().0, WorkspaceId(2));
-    //     assert_eq!(
-    //         db.workspace(&["/tmp1", "/tmp2", "/tmp3"]).unwrap().0,
-    //         WorkspaceId(3)
-    //     );
-    //     assert_eq!(db.workspace(&["/tmp2", "/tmp3"]).unwrap().0, WorkspaceId(4));
-    //     assert_eq!(
-    //         db.workspace(&["/tmp2", "/tmp3", "/tmp4"]).unwrap().0,
-    //         WorkspaceId(5)
-    //     );
-    //     assert_eq!(db.workspace(&["/tmp2", "/tmp4"]).unwrap().0, WorkspaceId(6));
-    //     assert_eq!(db.workspace(&["/tmp2"]).unwrap().0, WorkspaceId(7));
-
-    //     assert_eq!(db.workspace(&["/tmp1", "/tmp5"]), None);
-    //     assert_eq!(db.workspace(&["/tmp5"]), None);
-    //     assert_eq!(db.workspace(&["/tmp2", "/tmp3", "/tmp4", "/tmp5"]), None);
-    // }
-
-    // #[test]
-    // fn test_detect_workspace_id() {
-    //     let data = &[
-    //         (WorkspaceId(1), vec!["/tmp"]),
-    //         (WorkspaceId(2), vec!["/tmp", "/tmp2"]),
-    //         (WorkspaceId(3), vec!["/tmp", "/tmp2", "/tmp3"]),
-    //     ];
-
-    //     let db = Db::open_in_memory("test_detect_workspace_id");
-
-    //     for (workspace_id, entries) in data {
-    //         db.make_new_workspace::<String>(&[]);
-    //         db.update_worktrees(workspace_id, entries);
-    //     }
-
-    //     assert_eq!(db.workspace(&["/tmp2"]), None);
-    //     assert_eq!(db.workspace(&["/tmp2", "/tmp3"]), None);
-    //     assert_eq!(db.workspace(&["/tmp"]).unwrap().0, WorkspaceId(1));
-    //     assert_eq!(db.workspace(&["/tmp", "/tmp2"]).unwrap().0, WorkspaceId(2));
-    //     assert_eq!(
-    //         db.workspace(&["/tmp", "/tmp2", "/tmp3"]).unwrap().0,
-    //         WorkspaceId(3)
-    //     );
-    // }
-
-    // #[test]
-    // fn test_tricky_overlapping_updates() {
-    //     // DB state:
-    //     // (/tree) -> ID: 1
-    //     // (/tree, /tree2) -> ID: 2
-    //     // (/tree2, /tree3) -> ID: 3
-
-    //     // -> User updates 2 to: (/tree2, /tree3)
-
-    //     // DB state:
-    //     // (/tree) -> ID: 1
-    //     // (/tree2, /tree3) -> ID: 2
-    //     // Get rid of 3 for garbage collection
-
-    //     let data = &[
-    //         (WorkspaceId(1), vec!["/tmp"]),
-    //         (WorkspaceId(2), vec!["/tmp", "/tmp2"]),
-    //         (WorkspaceId(3), vec!["/tmp2", "/tmp3"]),
-    //     ];
-
-    //     let db = Db::open_in_memory("test_tricky_overlapping_update");
-
-    //     // Load in the test data
-    //     for (workspace_id, entries) in data {
-    //         db.make_new_workspace::<String>(&[]);
-    //         db.update_worktrees(workspace_id, entries);
-    //     }
-
-    //     sleep(Duration::from_secs(1));
-    //     // Execute the update
-    //     db.update_worktrees(&WorkspaceId(2), &["/tmp2", "/tmp3"]);
-
-    //     // Make sure that workspace 3 doesn't exist
-    //     assert_eq!(db.workspace(&["/tmp2", "/tmp3"]).unwrap().0, WorkspaceId(2));
-
-    //     // And that workspace 1 was untouched
-    //     assert_eq!(db.workspace(&["/tmp"]).unwrap().0, WorkspaceId(1));
-
-    //     // And that workspace 2 is no longer registered under these roots
-    //     assert_eq!(db.workspace(&["/tmp", "/tmp2"]), None);
-
-    //     assert_eq!(db.last_workspace().unwrap().0, WorkspaceId(2));
-
-    //     let recent_workspaces = db.recent_workspaces(10);
-    //     assert_eq!(
-    //         recent_workspaces.get(0).unwrap(),
-    //         &vec![PathBuf::from("/tmp2"), PathBuf::from("/tmp3")]
-    //     );
-    //     assert_eq!(
-    //         recent_workspaces.get(1).unwrap(),
-    //         &vec![PathBuf::from("/tmp")]
-    //     );
-    // }
+    use crate::{
+        model::{
+            DockAnchor::{Bottom, Expanded, Right},
+            SerializedWorkspace,
+        },
+        Db,
+    };
+
+    #[test]
+    fn test_basic_functionality() {
+        env_logger::init();
+
+        let db = Db::open_in_memory("test_basic_functionality");
+
+        let workspace_1 = SerializedWorkspace {
+            dock_anchor: Bottom,
+            dock_visible: true,
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+        };
+
+        let workspace_2 = SerializedWorkspace {
+            dock_anchor: Expanded,
+            dock_visible: false,
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+        };
+
+        let workspace_3 = SerializedWorkspace {
+            dock_anchor: Right,
+            dock_visible: true,
+            center_group: Default::default(),
+            dock_pane: Default::default(),
+        };
+
+        db.save_workspace(&["/tmp", "/tmp2"], None, &workspace_1);
+        db.save_workspace(&["/tmp"], None, &workspace_2);
+
+        db.write_to("test.db").unwrap();
+
+        // Test that paths are treated as a set
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_1
+        );
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp2", "/tmp"]).unwrap(),
+            workspace_1
+        );
+
+        // Make sure that other keys work
+        assert_eq!(db.workspace_for_roots(&["/tmp"]).unwrap(), workspace_2);
+        assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None);
+
+        // Test 'mutate' case of updating a pre-existing id
+        db.save_workspace(&["/tmp", "/tmp2"], Some(&["/tmp", "/tmp2"]), &workspace_2);
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_2
+        );
+
+        // Test other mechanism for mutating
+        db.save_workspace(&["/tmp", "/tmp2"], None, &workspace_3);
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp", "/tmp2"]).unwrap(),
+            workspace_3
+        );
+
+        // Make sure that updating paths differently also works
+        db.save_workspace(
+            &["/tmp3", "/tmp4", "/tmp2"],
+            Some(&["/tmp", "/tmp2"]),
+            &workspace_3,
+        );
+        assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None);
+        assert_eq!(
+            db.workspace_for_roots(&["/tmp2", "/tmp3", "/tmp4"])
+                .unwrap(),
+            workspace_3
+        );
+    }
 }

crates/db/src/workspace/items.rs 🔗

@@ -1,13 +1,11 @@
-// use std::{
-//     ffi::OsStr,
-//     fmt::Display,
-//     hash::Hash,
-//     os::unix::prelude::OsStrExt,
-//     path::{Path, PathBuf},
-//     sync::Arc,
-// };
-
-// use anyhow::Result;
+use anyhow::{Context, Result};
+use indoc::indoc;
+use sqlez::migrations::Migration;
+
+use crate::{
+    model::{ItemId, PaneId, SerializedItem, SerializedItemKind, WorkspaceId},
+    Db,
+};
 // use collections::HashSet;
 // use rusqlite::{named_params, params, types::FromSql};
 
@@ -65,45 +63,61 @@
 // ) STRICT;
 // ";
 
-// enum SerializedItemKind {
-//     Editor,
-//     Diagnostics,
-//     ProjectSearch,
-//     Terminal,
-// }
-
-// struct SerializedItemRow {
-//     kind: SerializedItemKind,
-//     item_id: usize,
-//     path: Option<Arc<Path>>,
-//     query: Option<String>,
-// }
-
-// #[derive(Debug, PartialEq, Eq)]
-// pub enum SerializedItem {
-//     Editor { item_id: usize, path: Arc<Path> },
-//     Diagnostics { item_id: usize },
-//     ProjectSearch { item_id: usize, query: String },
-//     Terminal { item_id: usize },
-// }
-
-// impl SerializedItem {
-//     pub fn item_id(&self) -> usize {
-//         match self {
-//             SerializedItem::Editor { item_id, .. } => *item_id,
-//             SerializedItem::Diagnostics { item_id } => *item_id,
-//             SerializedItem::ProjectSearch { item_id, .. } => *item_id,
-//             SerializedItem::Terminal { item_id } => *item_id,
-//         }
-//     }
-// }
-
-// impl Db {
-//     pub fn get_item(&self, item_id: ItemId) -> SerializedItem {
-//         unimplemented!()
-//     }
-
-//     pub fn save_item(&self, workspace_id: WorkspaceId, item: &SerializedItem) {}
-
-//     pub fn close_item(&self, item_id: ItemId) {}
-// }
+pub(crate) const ITEM_MIGRATIONS: Migration = Migration::new(
+    "item",
+    &[indoc! {"
+        CREATE TABLE items(
+            item_id INTEGER NOT NULL, -- This is the item's view id, so this is not unique
+            workspace_id BLOB NOT NULL,
+            pane_id INTEGER NOT NULL,
+            kind TEXT NOT NULL,
+            position INTEGER NOT NULL,
+            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
+            FOREIGN KEY(pane_id) REFERENCES panes(pane_id) ON DELETE CASCADE
+            PRIMARY KEY(item_id, workspace_id)
+        ) STRICT;
+    "}],
+);
+
+impl Db {
+    pub(crate) fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
+        Ok(self
+            .prepare(indoc! {"
+                SELECT item_id, kind FROM items
+                WHERE pane_id = ?
+                ORDER BY position"})?
+            .with_bindings(pane_id)?
+            .rows::<(ItemId, SerializedItemKind)>()?
+            .into_iter()
+            .map(|(item_id, kind)| match kind {
+                SerializedItemKind::Terminal => SerializedItem::Terminal { item_id },
+                _ => unimplemented!(),
+            })
+            .collect())
+    }
+
+    pub(crate) fn save_items(
+        &self,
+        workspace_id: &WorkspaceId,
+        pane_id: PaneId,
+        items: &[SerializedItem],
+    ) -> Result<()> {
+        let mut delete_old = self
+            .prepare("DELETE FROM items WHERE workspace_id = ? AND pane_id = ? AND item_id = ?")
+            .context("Preparing deletion")?;
+        let mut insert_new = self.prepare(
+            "INSERT INTO items(item_id, workspace_id, pane_id, kind, position) VALUES (?, ?, ?, ?, ?)",
+        ).context("Preparing insertion")?;
+        for (position, item) in items.iter().enumerate() {
+            delete_old
+                .with_bindings((workspace_id, pane_id, item.item_id()))?
+                .exec()?;
+
+            insert_new
+                .with_bindings((item.item_id(), workspace_id, pane_id, item.kind(), position))?
+                .exec()?;
+        }
+
+        Ok(())
+    }
+}

crates/db/src/workspace/model.rs 🔗

@@ -1,4 +1,7 @@
-use std::path::{Path, PathBuf};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use anyhow::{bail, Result};
 
@@ -8,8 +11,14 @@ use sqlez::{
     statement::Statement,
 };
 
-#[derive(Debug, PartialEq, Eq, Clone)]
-pub(crate) struct WorkspaceId(pub(crate) Vec<PathBuf>);
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct WorkspaceId(Vec<PathBuf>);
+
+impl WorkspaceId {
+    pub fn paths(self) -> Vec<PathBuf> {
+        self.0
+    }
+}
 
 impl<P: AsRef<Path>, T: IntoIterator<Item = P>> From<T> for WorkspaceId {
     fn from(iterator: T) -> Self {
@@ -74,7 +83,7 @@ impl Column for DockAnchor {
 
 pub(crate) type WorkspaceRow = (WorkspaceId, DockAnchor, bool);
 
-#[derive(Debug)]
+#[derive(Debug, PartialEq, Eq)]
 pub struct SerializedWorkspace {
     pub dock_anchor: DockAnchor,
     pub dock_visible: bool,
@@ -82,19 +91,134 @@ pub struct SerializedWorkspace {
     pub dock_pane: SerializedPane,
 }
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq, Eq, Default)]
 pub struct SerializedPaneGroup {
     axis: Axis,
     children: Vec<SerializedPaneGroup>,
 }
 
-#[derive(Debug)]
+impl SerializedPaneGroup {
+    pub fn new() -> Self {
+        SerializedPaneGroup {
+            axis: Axis::Horizontal,
+            children: Vec::new(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Default)]
 pub struct SerializedPane {
-    _children: Vec<SerializedItem>,
+    pub(crate) children: Vec<SerializedItem>,
+}
+
+impl SerializedPane {
+    pub fn new(children: Vec<SerializedItem>) -> Self {
+        SerializedPane { children }
+    }
+}
+
+pub type GroupId = i64;
+pub type PaneId = i64;
+pub type ItemId = usize;
+
+pub(crate) enum SerializedItemKind {
+    Editor,
+    Diagnostics,
+    ProjectSearch,
+    Terminal,
+}
+
+impl Bind for SerializedItemKind {
+    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
+        match self {
+            SerializedItemKind::Editor => "Editor",
+            SerializedItemKind::Diagnostics => "Diagnostics",
+            SerializedItemKind::ProjectSearch => "ProjectSearch",
+            SerializedItemKind::Terminal => "Terminal",
+        }
+        .bind(statement, start_index)
+    }
+}
+
+impl Column for SerializedItemKind {
+    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
+        String::column(statement, start_index).and_then(|(anchor_text, next_index)| {
+            Ok((
+                match anchor_text.as_ref() {
+                    "Editor" => SerializedItemKind::Editor,
+                    "Diagnostics" => SerializedItemKind::Diagnostics,
+                    "ProjectSearch" => SerializedItemKind::ProjectSearch,
+                    "Terminal" => SerializedItemKind::Terminal,
+                    _ => bail!("Stored serialized item kind is incorrect"),
+                },
+                next_index,
+            ))
+        })
+    }
 }
 
-#[derive(Debug)]
-pub enum SerializedItemKind {}
+#[derive(Debug, PartialEq, Eq)]
+pub enum SerializedItem {
+    Editor { item_id: usize, path: Arc<Path> },
+    Diagnostics { item_id: usize },
+    ProjectSearch { item_id: usize, query: String },
+    Terminal { item_id: usize },
+}
 
-#[derive(Debug)]
-pub enum SerializedItem {}
+impl SerializedItem {
+    pub fn item_id(&self) -> usize {
+        match self {
+            SerializedItem::Editor { item_id, .. } => *item_id,
+            SerializedItem::Diagnostics { item_id } => *item_id,
+            SerializedItem::ProjectSearch { item_id, .. } => *item_id,
+            SerializedItem::Terminal { item_id } => *item_id,
+        }
+    }
+
+    pub(crate) fn kind(&self) -> SerializedItemKind {
+        match self {
+            SerializedItem::Editor { .. } => SerializedItemKind::Editor,
+            SerializedItem::Diagnostics { .. } => SerializedItemKind::Diagnostics,
+            SerializedItem::ProjectSearch { .. } => SerializedItemKind::ProjectSearch,
+            SerializedItem::Terminal { .. } => SerializedItemKind::Terminal,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use sqlez::connection::Connection;
+
+    use crate::model::DockAnchor;
+
+    use super::WorkspaceId;
+
+    #[test]
+    fn test_workspace_round_trips() {
+        let db = Connection::open_memory("workspace_id_round_trips");
+
+        db.exec(indoc::indoc! {"
+            CREATE TABLE workspace_id_test(
+                workspace_id BLOB,
+                dock_anchor TEXT
+            );"})
+            .unwrap();
+
+        let workspace_id: WorkspaceId = WorkspaceId::from(&["\test2", "\test1"]);
+
+        db.prepare("INSERT INTO workspace_id_test(workspace_id, dock_anchor) VALUES (?,?)")
+            .unwrap()
+            .with_bindings((&workspace_id, DockAnchor::Bottom))
+            .unwrap()
+            .exec()
+            .unwrap();
+
+        assert_eq!(
+            db.prepare("SELECT workspace_id, dock_anchor FROM workspace_id_test LIMIT 1")
+                .unwrap()
+                .row::<(WorkspaceId, DockAnchor)>()
+                .unwrap(),
+            (WorkspaceId::from(&["\test1", "\test2"]), DockAnchor::Bottom)
+        );
+    }
+}

crates/db/src/workspace/pane.rs 🔗

@@ -1,8 +1,9 @@
-use anyhow::Result;
+use anyhow::{Context, Result};
 use indoc::indoc;
-use sqlez::{connection::Connection, migrations::Migration};
+use sqlez::migrations::Migration;
+use util::unzip_option;
 
-use crate::model::SerializedPane;
+use crate::model::{GroupId, PaneId, SerializedPane};
 
 use super::{
     model::{SerializedPaneGroup, WorkspaceId},
@@ -19,79 +20,31 @@ pub(crate) const PANE_MIGRATIONS: Migration = Migration::new(
             axis TEXT NOT NULL, -- Enum:  'Vertical' / 'Horizontal'
             FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
             FOREIGN KEY(parent_group) REFERENCES pane_groups(group_id) ON DELETE CASCADE
-            PRIMARY KEY(group_id, workspace_id)
         ) STRICT;
         
         CREATE TABLE panes(
             pane_id INTEGER PRIMARY KEY,
             workspace_id BLOB NOT NULL,
             group_id INTEGER, -- If null, this is a dock pane
-            idx INTEGER NOT NULL,
+            position INTEGER, -- If null, this is a dock pane
             FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
             FOREIGN KEY(group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
-            PRIMARY KEY(pane_id, workspace_id)
-        ) STRICT;
-        
-        CREATE TABLE items(
-            item_id INTEGER NOT NULL, -- This is the item's view id, so this is not unique
-            pane_id INTEGER NOT NULL,
-            workspace_id BLOB NOT NULL,
-            kind TEXT NOT NULL,
-            FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
-            FOREIGN KEY(pane_id) REFERENCES panes(pane_id) ON DELETE CASCADE
-            PRIMARY KEY(item_id, workspace_id)
         ) STRICT;
     "}],
 );
 
 impl Db {
-    pub(crate) fn get_center_group(&self, _workspace: &WorkspaceId) -> SerializedPaneGroup {
-        unimplemented!()
-    }
-
-    pub(crate) fn _get_pane_group(&self, _workspace: &WorkspaceId) -> SerializedPaneGroup {
-        unimplemented!()
-        // let axis = self.get_pane_group_axis(pane_group_id);
-        // let mut children: Vec<(usize, PaneGroupChild)> = Vec::new();
-        // for child_row in self.get_pane_group_children(pane_group_id) {
-        //     if let Some(child_pane_id) = child_row.child_pane_id {
-        //         children.push((
-        //             child_row.index,
-        //             PaneGroupChild::Pane(self.get_pane(PaneId {
-        //                 workspace_id: pane_group_id.workspace_id,
-        //                 pane_id: child_pane_id,
-        //             })),
-        //         ));
-        //     } else if let Some(child_group_id) = child_row.child_group_id {
-        //         children.push((
-        //             child_row.index,
-        //             PaneGroupChild::Group(self.get_pane_group(PaneGroupId {
-        //                 workspace_id: pane_group_id.workspace_id,
-        //                 group_id: child_group_id,
-        //             })),
-        //         ));
-        //     }
-        // }
-        // children.sort_by_key(|(index, _)| *index);
-
-        // SerializedPaneGroup {
-        //     group_id: pane_group_id,
-        //     axis,
-        //     children: children.into_iter().map(|(_, child)| child).collect(),
-        // }
+    pub(crate) fn get_center_group(
+        &self,
+        _workspace_id: &WorkspaceId,
+    ) -> Result<SerializedPaneGroup> {
+        Ok(SerializedPaneGroup::new())
     }
 
-    // fn _get_pane_group_children(
-    //     &self,
-    //     _pane_group_id: PaneGroupId,
-    // ) -> impl Iterator<Item = PaneGroupChildRow> {
-    //     Vec::new().into_iter()
-    // }
-
     pub(crate) fn save_center_group(
-        _workspace: &WorkspaceId,
+        &self,
+        _workspace_id: &WorkspaceId,
         _center_pane_group: &SerializedPaneGroup,
-        _connection: &Connection,
     ) -> Result<()> {
         // Delete the center pane group for this workspace and any of its children
         // Generate new pane group IDs as we go through
@@ -99,51 +52,86 @@ impl Db {
         Ok(())
     }
 
-    pub(crate) fn get_dock_pane(&self, _workspace: &WorkspaceId) -> Option<SerializedPane> {
-        unimplemented!()
+    pub(crate) fn get_dock_pane(&self, workspace_id: &WorkspaceId) -> Result<SerializedPane> {
+        let pane_id = self
+            .prepare(indoc! {"
+                SELECT pane_id FROM panes 
+                WHERE workspace_id = ? AND group_id IS NULL AND position IS NULL"})?
+            .with_bindings(workspace_id)?
+            .row::<PaneId>()?;
+
+        Ok(SerializedPane::new(
+            self.get_items(pane_id).context("Reading items")?,
+        ))
     }
 
     pub(crate) fn save_dock_pane(
-        _workspace: &WorkspaceId,
-        _dock_pane: &SerializedPane,
-        _connection: &Connection,
+        &self,
+        workspace: &WorkspaceId,
+        dock_pane: &SerializedPane,
     ) -> Result<()> {
-        // iife!({
-        //     self.prepare(
-        //         "INSERT INTO dock_panes (workspace_id, anchor_position, visible) VALUES (?, ?, ?);",
-        //     )?
-        //     .with_bindings(dock_pane.to_row(workspace))?
-        //     .insert()
-        // })
-        // .log_err();
-        Ok(())
+        self.save_pane(workspace, &dock_pane, None)
+    }
+
+    pub(crate) fn save_pane(
+        &self,
+        workspace_id: &WorkspaceId,
+        pane: &SerializedPane,
+        parent: Option<(GroupId, usize)>,
+    ) -> Result<()> {
+        let (parent_id, order) = unzip_option(parent);
+
+        let pane_id = self
+            .prepare("INSERT INTO panes(workspace_id, group_id, position) VALUES (?, ?, ?)")?
+            .with_bindings((workspace_id, parent_id, order))?
+            .insert()? as PaneId;
+
+        self.save_items(workspace_id, pane_id, &pane.children)
+            .context("Saving items")
     }
 }
 
 #[cfg(test)]
 mod tests {
 
-    // use crate::{items::ItemId, pane::SerializedPane, Db, DockAnchor};
-
-    // use super::{PaneGroupChild, SerializedDockPane, SerializedPaneGroup};
+    use crate::{
+        model::{SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace},
+        Db,
+    };
+
+    fn default_workspace(
+        dock_pane: SerializedPane,
+        center_group: SerializedPaneGroup,
+    ) -> SerializedWorkspace {
+        SerializedWorkspace {
+            dock_anchor: crate::model::DockAnchor::Right,
+            dock_visible: false,
+            center_group,
+            dock_pane,
+        }
+    }
 
-    // #[test]
-    // fn test_basic_dock_pane() {
-    //     let db = Db::open_in_memory("basic_dock_pane");
+    #[test]
+    fn test_basic_dock_pane() {
+        let db = Db::open_in_memory("basic_dock_pane");
 
-    //     let workspace = db.workspace_for_roots(&["/tmp"]);
+        let dock_pane = crate::model::SerializedPane {
+            children: vec![
+                SerializedItem::Terminal { item_id: 1 },
+                SerializedItem::Terminal { item_id: 4 },
+                SerializedItem::Terminal { item_id: 2 },
+                SerializedItem::Terminal { item_id: 3 },
+            ],
+        };
 
-    //     let dock_pane = SerializedDockPane {
-    //         anchor_position: DockAnchor::Expanded,
-    //         visible: true,
-    //     };
+        let workspace = default_workspace(dock_pane, SerializedPaneGroup::new());
 
-    //     db.save_dock_pane(&workspace.workspace_id, &dock_pane);
+        db.save_workspace(&["/tmp"], None, &workspace);
 
-    //     let new_workspace = db.workspace_for_roots(&["/tmp"]);
+        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
 
-    //     assert_eq!(new_workspace.dock_pane.unwrap(), dock_pane);
-    // }
+        assert_eq!(workspace.dock_pane, new_workspace.dock_pane);
+    }
 
     // #[test]
     // fn test_dock_simple_split() {

crates/sqlez/src/bindable.rs 🔗

@@ -178,8 +178,29 @@ impl<T1: Column, T2: Column, T3: Column, T4: Column> Column for (T1, T2, T3, T4)
         let (first, next_index) = T1::column(statement, start_index)?;
         let (second, next_index) = T2::column(statement, next_index)?;
         let (third, next_index) = T3::column(statement, next_index)?;
-        let (forth, next_index) = T4::column(statement, next_index)?;
-        Ok(((first, second, third, forth), next_index))
+        let (fourth, next_index) = T4::column(statement, next_index)?;
+        Ok(((first, second, third, fourth), next_index))
+    }
+}
+
+impl<T1: Bind, T2: Bind, T3: Bind, T4: Bind, T5: Bind> Bind for (T1, T2, T3, T4, T5) {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = self.0.bind(statement, start_index)?;
+        let next_index = self.1.bind(statement, next_index)?;
+        let next_index = self.2.bind(statement, next_index)?;
+        let next_index = self.3.bind(statement, next_index)?;
+        self.4.bind(statement, next_index)
+    }
+}
+
+impl<T1: Column, T2: Column, T3: Column, T4: Column, T5: Column> Column for (T1, T2, T3, T4, T5) {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (first, next_index) = T1::column(statement, start_index)?;
+        let (second, next_index) = T2::column(statement, next_index)?;
+        let (third, next_index) = T3::column(statement, next_index)?;
+        let (fourth, next_index) = T4::column(statement, next_index)?;
+        let (fifth, next_index) = T5::column(statement, next_index)?;
+        Ok(((first, second, third, fourth, fifth), next_index))
     }
 }
 

crates/sqlez/src/connection.rs 🔗

@@ -99,7 +99,29 @@ impl Connection {
     }
 
     pub(crate) fn last_error(&self) -> Result<()> {
-        unsafe { error_to_result(sqlite3_errcode(self.sqlite3)) }
+        unsafe {
+            let code = sqlite3_errcode(self.sqlite3);
+            const NON_ERROR_CODES: &[i32] = &[SQLITE_OK, SQLITE_ROW];
+            if NON_ERROR_CODES.contains(&code) {
+                return Ok(());
+            }
+
+            let message = sqlite3_errmsg(self.sqlite3);
+            let message = if message.is_null() {
+                None
+            } else {
+                Some(
+                    String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
+                        .into_owned(),
+                )
+            };
+
+            Err(anyhow!(
+                "Sqlite call failed with code {} and message: {:?}",
+                code as isize,
+                message
+            ))
+        }
     }
 }
 
@@ -109,31 +131,6 @@ impl Drop for Connection {
     }
 }
 
-pub(crate) fn error_to_result(code: std::os::raw::c_int) -> Result<()> {
-    const NON_ERROR_CODES: &[i32] = &[SQLITE_OK, SQLITE_ROW];
-    unsafe {
-        if NON_ERROR_CODES.contains(&code) {
-            return Ok(());
-        }
-
-        let message = sqlite3_errstr(code);
-        let message = if message.is_null() {
-            None
-        } else {
-            Some(
-                String::from_utf8_lossy(CStr::from_ptr(message as *const _).to_bytes())
-                    .into_owned(),
-            )
-        };
-
-        Err(anyhow!(
-            "Sqlite call failed with code {} and message: {:?}",
-            code as isize,
-            message
-        ))
-    }
-}
-
 #[cfg(test)]
 mod test {
     use anyhow::Result;
@@ -213,6 +210,35 @@ mod test {
         );
     }
 
+    #[test]
+    fn bool_round_trips() {
+        let connection = Connection::open_memory("bool_round_trips");
+        connection
+            .exec(indoc! {"
+                CREATE TABLE bools (
+                    t INTEGER,
+                    f INTEGER
+                );"})
+            .unwrap();
+
+        connection
+            .prepare("INSERT INTO bools(t, f) VALUES (?, ?);")
+            .unwrap()
+            .with_bindings((true, false))
+            .unwrap()
+            .exec()
+            .unwrap();
+
+        assert_eq!(
+            &connection
+                .prepare("SELECT * FROM bools;")
+                .unwrap()
+                .row::<(bool, bool)>()
+                .unwrap(),
+            &(true, false)
+        );
+    }
+
     #[test]
     fn backup_works() {
         let connection1 = Connection::open_memory("backup_works");

crates/sqlez/src/savepoint.rs 🔗

@@ -8,11 +8,11 @@ impl Connection {
     // point is released.
     pub fn with_savepoint<R, F>(&self, name: impl AsRef<str>, f: F) -> Result<R>
     where
-        F: FnOnce(&Connection) -> Result<R>,
+        F: FnOnce() -> Result<R>,
     {
         let name = name.as_ref().to_owned();
         self.exec(format!("SAVEPOINT {}", &name))?;
-        let result = f(self);
+        let result = f();
         match result {
             Ok(_) => {
                 self.exec(format!("RELEASE {}", name))?;
@@ -30,11 +30,11 @@ impl Connection {
     // point is released.
     pub fn with_savepoint_rollback<R, F>(&self, name: impl AsRef<str>, f: F) -> Result<Option<R>>
     where
-        F: FnOnce(&Connection) -> Result<Option<R>>,
+        F: FnOnce() -> Result<Option<R>>,
     {
         let name = name.as_ref().to_owned();
         self.exec(format!("SAVEPOINT {}", &name))?;
-        let result = f(self);
+        let result = f();
         match result {
             Ok(Some(_)) => {
                 self.exec(format!("RELEASE {}", name))?;
@@ -69,21 +69,21 @@ mod tests {
         let save1_text = "test save1";
         let save2_text = "test save2";
 
-        connection.with_savepoint("first", |save1| {
-            save1
+        connection.with_savepoint("first", || {
+            connection
                 .prepare("INSERT INTO text(text, idx) VALUES (?, ?)")?
                 .with_bindings((save1_text, 1))?
                 .exec()?;
 
-            assert!(save1
-                .with_savepoint("second", |save2| -> Result<Option<()>, anyhow::Error> {
-                    save2
+            assert!(connection
+                .with_savepoint("second", || -> Result<Option<()>, anyhow::Error> {
+                    connection
                         .prepare("INSERT INTO text(text, idx) VALUES (?, ?)")?
                         .with_bindings((save2_text, 2))?
                         .exec()?;
 
                     assert_eq!(
-                        save2
+                        connection
                             .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                             .rows::<String>()?,
                         vec![save1_text, save2_text],
@@ -95,20 +95,20 @@ mod tests {
                 .is_some());
 
             assert_eq!(
-                save1
+                connection
                     .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                     .rows::<String>()?,
                 vec![save1_text],
             );
 
-            save1.with_savepoint_rollback::<(), _>("second", |save2| {
-                save2
+            connection.with_savepoint_rollback::<(), _>("second", || {
+                connection
                     .prepare("INSERT INTO text(text, idx) VALUES (?, ?)")?
                     .with_bindings((save2_text, 2))?
                     .exec()?;
 
                 assert_eq!(
-                    save2
+                    connection
                         .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                         .rows::<String>()?,
                     vec![save1_text, save2_text],
@@ -118,20 +118,20 @@ mod tests {
             })?;
 
             assert_eq!(
-                save1
+                connection
                     .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                     .rows::<String>()?,
                 vec![save1_text],
             );
 
-            save1.with_savepoint_rollback("second", |save2| {
-                save2
+            connection.with_savepoint_rollback("second", || {
+                connection
                     .prepare("INSERT INTO text(text, idx) VALUES (?, ?)")?
                     .with_bindings((save2_text, 2))?
                     .exec()?;
 
                 assert_eq!(
-                    save2
+                    connection
                         .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                         .rows::<String>()?,
                     vec![save1_text, save2_text],
@@ -141,7 +141,7 @@ mod tests {
             })?;
 
             assert_eq!(
-                save1
+                connection
                     .prepare("SELECT text FROM text ORDER BY text.idx ASC")?
                     .rows::<String>()?,
                 vec![save1_text, save2_text],

crates/sqlez/src/statement.rs 🔗

@@ -6,7 +6,7 @@ use anyhow::{anyhow, Context, Result};
 use libsqlite3_sys::*;
 
 use crate::bindable::{Bind, Column};
-use crate::connection::{error_to_result, Connection};
+use crate::connection::Connection;
 
 pub struct Statement<'a> {
     raw_statement: *mut sqlite3_stmt,
@@ -48,7 +48,9 @@ impl<'a> Statement<'a> {
                 0 as *mut _,
             );
 
-            connection.last_error().context("Prepare call failed.")?;
+            connection
+                .last_error()
+                .with_context(|| format!("Prepare call failed for query:\n{}", query.as_ref()))?;
         }
 
         Ok(statement)
@@ -309,10 +311,7 @@ impl<'a> Statement<'a> {
 
 impl<'a> Drop for Statement<'a> {
     fn drop(&mut self) {
-        unsafe {
-            let error = sqlite3_finalize(self.raw_statement);
-            error_to_result(error).expect("failed error");
-        };
+        unsafe { sqlite3_finalize(self.raw_statement) };
     }
 }
 
@@ -327,9 +326,9 @@ mod test {
         let connection1 = Connection::open_memory("blob_round_trips");
         connection1
             .exec(indoc! {"
-            CREATE TABLE blobs (
-            data BLOB
-            );"})
+                CREATE TABLE blobs (
+                data BLOB
+                );"})
             .unwrap();
 
         let blob = &[0, 1, 2, 4, 8, 16, 32, 64];
@@ -352,4 +351,41 @@ mod test {
         let mut read = connection1.prepare("SELECT * FROM blobs;").unwrap();
         assert_eq!(read.step().unwrap(), StepResult::Done);
     }
+
+    #[test]
+    pub fn maybe_returns_options() {
+        let connection = Connection::open_memory("maybe_returns_options");
+        connection
+            .exec(indoc! {"
+                CREATE TABLE texts (
+                    text TEXT 
+                );"})
+            .unwrap();
+
+        assert!(connection
+            .prepare("SELECT text FROM texts")
+            .unwrap()
+            .maybe_row::<String>()
+            .unwrap()
+            .is_none());
+
+        let text_to_insert = "This is a test";
+
+        connection
+            .prepare("INSERT INTO texts VALUES (?)")
+            .unwrap()
+            .with_bindings(text_to_insert)
+            .unwrap()
+            .exec()
+            .unwrap();
+
+        assert_eq!(
+            connection
+                .prepare("SELECT text FROM texts")
+                .unwrap()
+                .maybe_row::<String>()
+                .unwrap(),
+            Some(text_to_insert.to_string())
+        );
+    }
 }

crates/util/src/lib.rs 🔗

@@ -204,6 +204,16 @@ impl<T: Rng> Iterator for RandomCharIter<T> {
     }
 }
 
+// copy unstable standard feature option unzip
+// https://github.com/rust-lang/rust/issues/87800
+// Remove when this ship in Rust 1.66 or 1.67
+pub fn unzip_option<T, U>(option: Option<(T, U)>) -> (Option<T>, Option<U>) {
+    match option {
+        Some((a, b)) => (Some(a), Some(b)),
+        None => (None, None),
+    }
+}
+
 #[macro_export]
 macro_rules! iife {
     ($block:block) => {