Built first draft of workspace serialization schemas, started writing DB tests

Co-Authored-By: kay@zed.dev
This commit is contained in:
Mikayla Maki 2022-10-19 17:10:49 -07:00
parent 60ebe33518
commit b48e28b555
5 changed files with 234 additions and 301 deletions

View file

@ -1,6 +1,7 @@
mod items;
mod kvp;
mod migrations;
mod workspace;
use std::fs;
use std::path::{Path, PathBuf};

View file

@ -6,306 +6,59 @@ use rusqlite::{named_params, params};
use super::Db;
/// Current design makes the cut at the item level,
/// - Maybe A little more bottom up, serialize 'Terminals' and 'Editors' directly, and then make a seperate
/// - items table, with a kind, and an integer that acts as a key to one of these other tables
/// This column is a foreign key to ONE OF: editors, terminals, searches
/// -
// (workspace_id, item_id)
// kind -> ::Editor::
// ->
// At the workspace level
// -> (Workspace_ID, item_id)
// -> One shot, big query, load everything up:
// -> SerializedWorkspace::deserialize(tx, itemKey)
// -> SerializedEditor::deserialize(tx, itemKey)
// ->
// -> Workspace::new(SerializedWorkspace)
// -> Editor::new(serialized_workspace[???]serializedEditor)
// //Pros: Keeps sql out of every body elese, makes changing it easier (e.g. for loading from a network or RocksDB)
// //Cons: DB has to know the internals of the entire rest of the app
// Workspace
// Worktree roots
// Pane groups
// Dock
// Items
// Sidebars
pub(crate) const ITEMS_M_1: &str = "
CREATE TABLE items(
id INTEGER PRIMARY KEY,
kind TEXT
workspace_id INTEGER,
item_id INTEGER,
kind TEXT NOT NULL,
PRIMARY KEY (workspace_id, item_id)
FOREIGN KEY(workspace_id) REFERENCES workspace_ids(workspace_id)
) STRICT;
CREATE TABLE item_path(
item_id INTEGER PRIMARY KEY,
path BLOB
CREATE TABLE project_searches(
workspace_id INTEGER,
item_id INTEGER,
query TEXT,
PRIMARY KEY (workspace_id, item_id)
FOREIGN KEY(workspace_id) REFERENCES workspace_ids(workspace_id)
) STRICT;
CREATE TABLE item_query(
item_id INTEGER PRIMARY KEY,
query TEXT
CREATE TABLE editors(
workspace_id INTEGER,
item_id INTEGER,
path BLOB NOT NULL,
PRIMARY KEY (workspace_id, item_id)
FOREIGN KEY(workspace_id) REFERENCES workspace_ids(workspace_id)
) STRICT;
";
#[derive(PartialEq, Eq, Hash, Debug)]
pub enum SerializedItemKind {
Editor,
Terminal,
ProjectSearch,
Diagnostics,
}
impl Display for SerializedItemKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("{:?}", self))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SerializedItem {
Editor(usize, PathBuf),
Terminal(usize),
ProjectSearch(usize, String),
Diagnostics(usize),
}
impl SerializedItem {
fn kind(&self) -> SerializedItemKind {
match self {
SerializedItem::Editor(_, _) => SerializedItemKind::Editor,
SerializedItem::Terminal(_) => SerializedItemKind::Terminal,
SerializedItem::ProjectSearch(_, _) => SerializedItemKind::ProjectSearch,
SerializedItem::Diagnostics(_) => SerializedItemKind::Diagnostics,
}
}
fn id(&self) -> usize {
match self {
SerializedItem::Editor(id, _)
| SerializedItem::Terminal(id)
| SerializedItem::ProjectSearch(id, _)
| SerializedItem::Diagnostics(id) => *id,
}
}
}
impl Db {
fn write_item(&self, serialized_item: SerializedItem) -> Result<()> {
self.real()
.map(|db| {
let mut lock = db.connection.lock();
let tx = lock.transaction()?;
// Serialize the item
let id = serialized_item.id();
{
let mut stmt = tx.prepare_cached(
"INSERT OR REPLACE INTO items(id, kind) VALUES ((?), (?))",
)?;
dbg!("inserting item");
stmt.execute(params![id, serialized_item.kind().to_string()])?;
}
// Serialize item data
match &serialized_item {
SerializedItem::Editor(_, path) => {
dbg!("inserting path");
let mut stmt = tx.prepare_cached(
"INSERT OR REPLACE INTO item_path(item_id, path) VALUES ((?), (?))",
)?;
let path_bytes = path.as_os_str().as_bytes();
stmt.execute(params![id, path_bytes])?;
}
SerializedItem::ProjectSearch(_, query) => {
dbg!("inserting query");
let mut stmt = tx.prepare_cached(
"INSERT OR REPLACE INTO item_query(item_id, query) VALUES ((?), (?))",
)?;
stmt.execute(params![id, query])?;
}
_ => {}
}
tx.commit()?;
let mut stmt = lock.prepare_cached("SELECT id, kind FROM items")?;
let _ = stmt
.query_map([], |row| {
let zero: usize = row.get(0)?;
let one: String = row.get(1)?;
dbg!(zero, one);
Ok(())
})?
.collect::<Vec<Result<(), _>>>();
Ok(())
})
.unwrap_or(Ok(()))
}
fn delete_item(&self, item_id: usize) -> Result<()> {
self.real()
.map(|db| {
let lock = db.connection.lock();
let mut stmt = lock.prepare_cached(
r#"
DELETE FROM items WHERE id = (:id);
DELETE FROM item_path WHERE id = (:id);
DELETE FROM item_query WHERE id = (:id);
"#,
)?;
stmt.execute(named_params! {":id": item_id})?;
Ok(())
})
.unwrap_or(Ok(()))
}
fn take_items(&self) -> Result<HashSet<SerializedItem>> {
self.real()
.map(|db| {
let mut lock = db.connection.lock();
let tx = lock.transaction()?;
// When working with transactions in rusqlite, need to make this kind of scope
// To make the borrow stuff work correctly. Don't know why, rust is wild.
let result = {
let mut editors_stmt = tx.prepare_cached(
r#"
SELECT items.id, item_path.path
FROM items
LEFT JOIN item_path
ON items.id = item_path.item_id
WHERE items.kind = ?;
"#,
)?;
let editors_iter = editors_stmt.query_map(
[SerializedItemKind::Editor.to_string()],
|row| {
let id: usize = row.get(0)?;
let buf: Vec<u8> = row.get(1)?;
let path: PathBuf = OsStr::from_bytes(&buf).into();
Ok(SerializedItem::Editor(id, path))
},
)?;
let mut terminals_stmt = tx.prepare_cached(
r#"
SELECT items.id
FROM items
WHERE items.kind = ?;
"#,
)?;
let terminals_iter = terminals_stmt.query_map(
[SerializedItemKind::Terminal.to_string()],
|row| {
let id: usize = row.get(0)?;
Ok(SerializedItem::Terminal(id))
},
)?;
let mut search_stmt = tx.prepare_cached(
r#"
SELECT items.id, item_query.query
FROM items
LEFT JOIN item_query
ON items.id = item_query.item_id
WHERE items.kind = ?;
"#,
)?;
let searches_iter = search_stmt.query_map(
[SerializedItemKind::ProjectSearch.to_string()],
|row| {
let id: usize = row.get(0)?;
let query = row.get(1)?;
Ok(SerializedItem::ProjectSearch(id, query))
},
)?;
#[cfg(debug_assertions)]
let tmp =
searches_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
#[cfg(debug_assertions)]
debug_assert!(tmp.len() == 0 || tmp.len() == 1);
#[cfg(debug_assertions)]
let searches_iter = tmp.into_iter();
let mut diagnostic_stmt = tx.prepare_cached(
r#"
SELECT items.id
FROM items
WHERE items.kind = ?;
"#,
)?;
let diagnostics_iter = diagnostic_stmt.query_map(
[SerializedItemKind::Diagnostics.to_string()],
|row| {
let id: usize = row.get(0)?;
Ok(SerializedItem::Diagnostics(id))
},
)?;
#[cfg(debug_assertions)]
let tmp =
diagnostics_iter.collect::<Vec<Result<SerializedItem, rusqlite::Error>>>();
#[cfg(debug_assertions)]
debug_assert!(tmp.len() == 0 || tmp.len() == 1);
#[cfg(debug_assertions)]
let diagnostics_iter = tmp.into_iter();
let res = editors_iter
.chain(terminals_iter)
.chain(diagnostics_iter)
.chain(searches_iter)
.collect::<Result<HashSet<SerializedItem>, rusqlite::Error>>()?;
let mut delete_stmt = tx.prepare_cached(
r#"
DELETE FROM items;
DELETE FROM item_path;
DELETE FROM item_query;
"#,
)?;
delete_stmt.execute([])?;
res
};
tx.commit()?;
Ok(result)
})
.unwrap_or(Ok(HashSet::default()))
}
}
#[cfg(test)]
mod test {
use anyhow::Result;
use super::*;
#[test]
fn test_items_round_trip() -> Result<()> {
let db = Db::open_in_memory();
let mut items = vec![
SerializedItem::Editor(0, PathBuf::from("/tmp/test.txt")),
SerializedItem::Terminal(1),
SerializedItem::ProjectSearch(2, "Test query!".to_string()),
SerializedItem::Diagnostics(3),
]
.into_iter()
.collect::<HashSet<_>>();
for item in items.iter() {
dbg!("Inserting... ");
db.write_item(item.clone())?;
}
assert_eq!(items, db.take_items()?);
// Check that it's empty, as expected
assert_eq!(HashSet::default(), db.take_items()?);
for item in items.iter() {
db.write_item(item.clone())?;
}
items.remove(&SerializedItem::ProjectSearch(2, "Test query!".to_string()));
db.delete_item(2)?;
assert_eq!(items, db.take_items()?);
Ok(())
}
}

View file

@ -3,7 +3,7 @@ use rusqlite::OptionalExtension;
use super::Db;
pub(crate) const KVP_M_1_UP: &str = "
pub(crate) const KVP_M_1: &str = "
CREATE TABLE kv_store(
key TEXT PRIMARY KEY,
value TEXT NOT NULL

View file

@ -1,7 +1,7 @@
use rusqlite_migration::{Migrations, M};
// use crate::items::ITEMS_M_1;
use crate::{items::ITEMS_M_1, kvp::KVP_M_1_UP};
use crate::kvp::KVP_M_1;
// 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!!!!
@ -9,7 +9,6 @@ use crate::{items::ITEMS_M_1, kvp::KVP_M_1_UP};
// 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_UP),
M::up(ITEMS_M_1),
M::up(KVP_M_1),
]);
}

180
crates/db/src/workspace.rs Normal file
View file

@ -0,0 +1,180 @@
use std::{path::Path, sync::Arc};
use super::Db;
pub(crate) const WORKSPACE_M_1: &str = "
CREATE TABLE workspaces(
workspace_id INTEGER PRIMARY KEY,
center_group INTEGER NOT NULL,
dock_pane INTEGER NOT NULL,
timestamp INTEGER,
FOREIGN KEY(center_group) REFERENCES pane_groups(group_id)
FOREIGN KEY(dock_pane) REFERENCES pane_items(pane_id)
) STRICT;
CREATE TABLE worktree_roots(
worktree_root BLOB NOT NULL,
workspace_id INTEGER NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspace_ids(workspace_id)
) STRICT;
CREATE TABLE pane_groups(
workspace_id INTEGER,
group_id INTEGER,
split_direction STRING, -- 'Vertical' / 'Horizontal' /
PRIMARY KEY (workspace_id, group_id)
) STRICT;
CREATE TABLE pane_group_children(
workspace_id INTEGER,
group_id INTEGER,
child_pane_id INTEGER, -- Nullable
child_group_id INTEGER, -- Nullable
PRIMARY KEY (workspace_id, group_id)
) STRICT;
CREATE TABLE pane_items(
workspace_id INTEGER,
pane_id INTEGER,
item_id INTEGER, -- Array
PRIMARY KEY (workspace_id, pane_id)
) STRICT;
";
// Zed stores items with ids which are a combination of a view id during a given run and a workspace id. This
// Case 1: Starting Zed Contextless
// > Zed -> Reopen the last
// Case 2: Starting Zed with a project folder
// > Zed ~/projects/Zed
// Case 3: Starting Zed with a file
// > Zed ~/projects/Zed/cargo.toml
// Case 4: Starting Zed with multiple project folders
// > Zed ~/projects/Zed ~/projects/Zed.dev
#[derive(Debug, PartialEq, Eq)]
pub struct WorkspaceId(usize);
impl Db {
/// Finds or creates a workspace id for the given set of worktree roots. If the passed worktree roots is empty, return the
/// the last workspace id
pub fn workspace_id(&self, worktree_roots: &[Arc<Path>]) -> WorkspaceId {
// Find the workspace id which is uniquely identified by this set of paths return it if found
// Otherwise:
// Find the max workspace_id and increment it as our new workspace id
// Store in the worktrees table the mapping from this new id to the set of worktree roots
unimplemented!();
}
/// Updates the open paths for the given workspace id. Will garbage collect items from
/// any workspace ids which are no replaced by the new workspace id. Updates the timestamps
/// in the workspace id table
pub fn update_worktree_roots(&self, workspace_id: &WorkspaceId, worktree_roots: &[Arc<Path>]) {
// Lookup any WorkspaceIds which have the same set of roots, and delete them. (NOTE: this should garbage collect other tables)
// Remove the old rows which contain workspace_id
// Add rows for the new worktree_roots
// zed /tree
// -> add tree2
// -> udpate_worktree_roots() -> ADDs entries for /tree and /tree2, LEAVING BEHIND, the initial entry for /tree
unimplemented!();
}
/// Returns the previous workspace ids sorted by last modified
pub fn recent_workspaces(&self) -> Vec<(WorkspaceId, Vec<Arc<Path>>)> {
// Return all the workspace ids and their associated paths ordered by the access timestamp
//ORDER BY timestamps
unimplemented!();
}
pub fn center_pane(&self, workspace: WorkspaceId) -> SerializedPaneGroup {}
pub fn dock_pane(&self, workspace: WorkspaceId) -> SerializedPane {}
}
#[cfg(test)]
mod tests {
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use crate::Db;
use super::WorkspaceId;
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
fn arc_path(path: &'static str) -> Arc<Path> {
PathBuf::from(path).into()
}
let data = &[
(WorkspaceId(1), vec![arc_path("/tmp")]),
(WorkspaceId(2), vec![arc_path("/tmp"), arc_path("/tmp2")]),
(WorkspaceId(3), vec![arc_path("/tmp2"), arc_path("/tmp3")]),
];
let db = Db::open_in_memory();
for (workspace_id, entries) in data {
db.update_worktree_roots(workspace_id, entries); //??
assert_eq!(&db.workspace_id(&[]), workspace_id)
}
for (workspace_id, entries) in data {
assert_eq!(&db.workspace_id(entries.as_slice()), workspace_id);
}
db.update_worktree_roots(&WorkspaceId(2), &[arc_path("/tmp2")]);
// todo!(); // make sure that 3 got garbage collected
assert_eq!(db.workspace_id(&[arc_path("/tmp2")]), WorkspaceId(2));
assert_eq!(db.workspace_id(&[arc_path("/tmp")]), WorkspaceId(1));
let recent_workspaces = db.recent_workspaces();
assert_eq!(recent_workspaces.get(0).unwrap().0, WorkspaceId(2));
assert_eq!(recent_workspaces.get(1).unwrap().0, WorkspaceId(3));
assert_eq!(recent_workspaces.get(2).unwrap().0, WorkspaceId(1));
}
}
// [/tmp, /tmp2] -> ID1?
// [/tmp] -> ID2?
/*
path | id
/tmp ID1
/tmp ID2
/tmp2 ID1
SELECT id
FROM workspace_ids
WHERE path IN (path1, path2)
INTERSECT
SELECT id
FROM workspace_ids
WHERE path = path_2
... and etc. for each element in path array
If contains row, yay! If not,
SELECT max(id) FROm workspace_ids
Select id WHERE path IN paths
SELECT MAX(id)
*/