diff --git a/src/bin/ghee.rs b/src/bin/ghee.rs index 6458e31..e04e14c 100644 --- a/src/bin/ghee.rs +++ b/src/bin/ghee.rs @@ -6,11 +6,12 @@ use rustyline::DefaultEditor; use thiserror::Error; use ghee::{ - cmd::{cp_or_mv, get, idx, init, ins, ls, rm, set, CopyOrMove}, + cmd::{cp_or_mv, del, get, idx, init, ins, ls, rm, set, CopyOrMove}, parser::{ assignment::{Assignment, AssignmentParser}, key::Key, predicate::{Predicate, PredicateParser}, + value::Value, xattr::Xattr, }, APP_NAME, PKG_NAME, XDG_DIRS, @@ -101,6 +102,18 @@ enum Commands { verbose: bool, }, + /// Remove record from a table, updating related indices + Del { + #[arg(help = "Path of the table to delete from")] + table_path: PathBuf, + #[arg(name = "where", short, long, help = "WHERE clauses specifying what to delete", value_parser = ValueParser::new(PredicateParser{}))] + where_: Vec, + #[arg(help = "Primary key values, subkeys space separated")] + key: Vec, + #[arg(short, long)] + verbose: bool, + }, + /// Index tables Idx { #[arg(help = "Path to recursively index")] @@ -255,6 +268,16 @@ fn run_command(cmd: &Commands) { ) }); } + Commands::Del { + table_path, + where_, + key, + verbose, + } => { + del(table_path, where_, key, *verbose).unwrap_or_else(|e| { + panic!("Error deleting record from {}: {}", table_path.display(), e) + }); + } Commands::Idx { src, dest, diff --git a/src/cmd/del.rs b/src/cmd/del.rs new file mode 100644 index 0000000..fe022dd --- /dev/null +++ b/src/cmd/del.rs @@ -0,0 +1,199 @@ +use anyhow::Result; +use thiserror::Error; +use walkdir::WalkDir; + +use crate::{ + get_index_info, get_key, list_xattrs, + parser::{key::Key, predicate::Predicate, relation::Relation, value::Value, xattr::Xattr}, + xattr_values, +}; + +use std::{collections::HashMap, fs::remove_file, io::Write, path::PathBuf}; + +#[derive(Error, Debug)] +pub enum DelErr { + #[error("Key not found at {0}")] + KeyNotFound(PathBuf), + #[error("Path {0} not found")] + PathNotFound(PathBuf), + #[error("An IO error occurred: {0}")] + IoError(std::io::Error), + #[error("A JSON error occurred: {0}")] + JsonError(serde_json::Error), + #[error("Raw (unnamed) json value not supported: {0}")] + RawJsonValue(serde_json::Value), + #[error( + "Either where clauses or values for the table's primary key should be provided, not both" + )] + WhereClausesOrKeyValuesNotBoth, + #[error( + "Incorrect number of primary key values provided; expected {expected} but got {provided}" + )] + WrongNumberOfPrimaryKeyValues { expected: usize, provided: usize }, +} + +/// Delete all instantiations of a record across tables +fn unlink_record( + indices: &HashMap, + record_path: &PathBuf, + verbose: bool, +) -> Result<()> { + let record = xattr_values(record_path)?; + + for (key, path) in indices { + let key_value = key.value_for_record(&record)?; + + let record_path = { + let mut path = path.clone(); + for value in key_value { + path.push(value.to_string()); + } + path + }; + + remove_file(&record_path).map_err(DelErr::IoError)?; + + if verbose { + eprintln!("Removed {}", record_path.display()); + } + } + + Ok(()) +} + +pub fn del( + table_path: &PathBuf, + where_: &Vec, + key: &Vec, + verbose: bool, +) -> Result<()> { + if !(where_.is_empty() ^ key.is_empty()) { + return Err(DelErr::WhereClausesOrKeyValuesNotBoth.into()); + } + + let info = get_index_info(table_path)?; + + let main_key = get_key(table_path)?.ok_or(DelErr::KeyNotFound(table_path.clone()))?; + + let all_indices = { + let mut indices = info.related_indices.clone(); + indices.insert(main_key.clone(), table_path.clone()); + indices + }; + + eprintln!("All indices: {:?}", all_indices); + + if key.is_empty() { + debug_assert!(!where_.is_empty()); + + panic!("WHERE not implemented"); + } else { + if main_key.subkeys.len() != key.len() { + return Err(DelErr::WrongNumberOfPrimaryKeyValues { + expected: main_key.subkeys.len(), + provided: key.len(), + } + .into()); + } + + // An individual record is uniquely identified by the provided primary key values + let record_path = { + let mut path = table_path.clone(); + + for subkey_value in key { + path.push(subkey_value.to_string()); + } + + path + }; + + unlink_record(&all_indices, &record_path, verbose)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + + use crate::{parser::value::Value, record_count, test_support::Scenario}; + + use super::del; + + #[test] + fn test_del() { + { + // Delete from dir1 using default key + let s: Scenario = Default::default(); + { + let dir1count = record_count(&s.dir1); + let dir2count = record_count(&s.dir2); + assert_eq!(dir1count, 1); + assert_eq!(dir2count, 1); + } + + del(&s.dir1, &vec![], &vec![Value::Number(0f64)], true).unwrap(); + + { + let dir1count = record_count(&s.dir1); + let dir2count = record_count(&s.dir2); + assert_eq!(dir1count, 0); + assert_eq!(dir2count, 0); + } + } + + { + // Delete from dir1 using bogus key + let s: Scenario = Default::default(); + + del(&s.dir1, &vec![], &vec![], false) + .expect_err("failed due to too few key components"); + + del( + &s.dir1, + &vec![], + &vec![Value::String("abc".into()), Value::Number(-10f64)], + false, + ) + .expect_err("failed due to too many key components"); + } + + { + // Delete from dir2 using default key + let s: Scenario = Default::default(); + { + let dir1count = record_count(&s.dir1); + let dir2count = record_count(&s.dir2); + assert_eq!(dir1count, 1); + assert_eq!(dir2count, 1); + } + + del( + &s.dir2, + &vec![], + &vec![Value::Number(1f64), Value::Number(2f64)], + true, + ) + .unwrap(); + + { + let dir1count = record_count(&s.dir1); + let dir2count = record_count(&s.dir2); + assert_eq!(dir1count, 0); + assert_eq!(dir2count, 0); + } + } + + { + // Delete from dir1 using predicate + let s: Scenario = Default::default(); + //TODO + } + + { + // Delete from dir2 using predicate + let s: Scenario = Default::default(); + //TODO + } + } +} diff --git a/src/cmd/ins.rs b/src/cmd/ins.rs index d71f4a6..9aaaa06 100644 --- a/src/cmd/ins.rs +++ b/src/cmd/ins.rs @@ -120,51 +120,16 @@ pub fn ins(table_path: &PathBuf, records_path: &Option, verbose: bool) #[cfg(test)] mod test { - use std::{fs::File, io::Write}; - - use tempdir::TempDir; - - use crate::{ - cmd::{idx, init}, - parser::{key::Key, value::Value}, - xattr_values, - }; - - use super::ins; + use crate::{parser::value::Value, test_support::Scenario, xattr_values}; #[test] fn test_ins() { - let records_dir = TempDir::new("ghee-test-ins:records").unwrap().into_path(); - let mut records_path = records_dir.clone(); - records_path.push("records.json"); + let s: Scenario = Default::default(); - { - let mut w = File::create(&records_path).unwrap(); - w.write_all(br#"{"test1": 0, "test2": 1, "test3": 2}"#) - .unwrap(); - } - - let dir1 = TempDir::new("ghee-test-ins:1").unwrap().into_path(); - - let dir2 = TempDir::new("ghee-test-ins:2").unwrap().into_path(); - - let key1 = Key::from(vec!["test1"]); - let xattr1 = key1.subkeys[0].clone(); - - let key2 = Key::from(vec!["test2", "test3"]); - let xattr2 = key2.subkeys[0].clone(); - let xattr3 = key2.subkeys[1].clone(); - - init(&dir1, &key1, false).unwrap(); - - idx(&dir1, Some(&dir2), &key2, false); - - ins(&dir1, &Some(records_path), false).unwrap(); - - let mut record_path = dir1.clone(); + let mut record_path = s.dir1.clone(); record_path.push("0"); - let mut indexed_record_path = dir2.clone(); + let mut indexed_record_path = s.dir2.clone(); indexed_record_path.push("1"); indexed_record_path.push("2"); @@ -174,15 +139,15 @@ mod test { let xattrs = xattr_values(&record_path).unwrap(); assert_eq!(xattrs.len(), 3); - assert_eq!(xattrs[&xattr1].clone(), Value::Number(0f64)); - assert_eq!(xattrs[&xattr2].clone(), Value::Number(1f64)); - assert_eq!(xattrs[&xattr3].clone(), Value::Number(2f64)); + assert_eq!(xattrs[&s.xattr1].clone(), Value::Number(0f64)); + assert_eq!(xattrs[&s.xattr2].clone(), Value::Number(1f64)); + assert_eq!(xattrs[&s.xattr3].clone(), Value::Number(2f64)); let indexed_xattrs = xattr_values(&indexed_record_path).unwrap(); assert_eq!(xattrs.len(), 3); - assert_eq!(indexed_xattrs[&xattr1].clone(), Value::Number(0f64)); - assert_eq!(indexed_xattrs[&xattr2].clone(), Value::Number(1f64)); - assert_eq!(indexed_xattrs[&xattr3].clone(), Value::Number(2f64)); + assert_eq!(indexed_xattrs[&s.xattr1].clone(), Value::Number(0f64)); + assert_eq!(indexed_xattrs[&s.xattr2].clone(), Value::Number(1f64)); + assert_eq!(indexed_xattrs[&s.xattr3].clone(), Value::Number(2f64)); } } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index eb6cae0..8eae509 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,4 +1,5 @@ mod cp_or_mv; +mod del; mod get; mod idx; mod init; @@ -7,6 +8,7 @@ mod ls; mod rm; mod set; pub use cp_or_mv::{cp_or_mv, CopyOrMove}; +pub use del::del; pub use get::get; pub use idx::idx; pub use init::init; diff --git a/src/lib.rs b/src/lib.rs index 8783907..c036872 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ pub mod cli; pub mod cmd; pub mod parser; pub mod paths; +#[cfg(test)] +mod test_support; use std::{ collections::HashMap, @@ -20,6 +22,7 @@ use parser::{ use anyhow::Result; use thiserror::Error; +use walkdir::{DirEntry, WalkDir}; use xdg::BaseDirectories; /// Uppercase the first character in a string @@ -49,6 +52,32 @@ lazy_static! { xdg::BaseDirectories::with_prefix(APP_NAME.as_str()).unwrap(); } +/// Whether a path should be skipped when iterating over records +pub fn is_hidden(entry: &DirEntry) -> bool { + entry + .file_name() + .to_str() + .map(|s| s.starts_with(":")) + .unwrap_or(false) +} + +/// The number of records recursively found in a directory +/// +/// Ignores nested indexes +pub fn record_count(dir: &PathBuf) -> usize { + WalkDir::new(dir) + .into_iter() + .filter_entry(|e| !is_hidden(e)) + .filter(|e| { + if let Ok(entry) = e { + entry.file_type().is_file() + } else { + false + } + }) + .count() +} + // Like xattr::list but parsed into our format pub fn list_xattrs>(path: P) -> Vec { let path: &Path = path.as_ref(); diff --git a/src/parser/key.rs b/src/parser/key.rs index 246eb9a..2f7513c 100644 --- a/src/parser/key.rs +++ b/src/parser/key.rs @@ -25,7 +25,7 @@ pub enum KeyErr { RecordNotObject, } -#[derive(Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Key { pub subkeys: Vec, } diff --git a/src/parser/predicate.rs b/src/parser/predicate.rs index 807fdc0..f562c48 100644 --- a/src/parser/predicate.rs +++ b/src/parser/predicate.rs @@ -20,6 +20,9 @@ pub struct Predicate { } impl Predicate { + pub fn new(xattr: Xattr, relation: Relation) -> Self { + Self { xattr, relation } + } pub fn satisfied(&self, values: &HashMap) -> bool { values .get(&self.xattr) diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..538dcd0 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,61 @@ +use std::{fs::File, io::Write, path::PathBuf}; + +use tempdir::TempDir; + +use crate::{ + cmd::{idx, init, ins}, + parser::{key::Key, xattr::Xattr}, +}; + +pub struct Scenario { + /// Table indexed by xattr1 + pub dir1: PathBuf, + + /// Table indexed by (xattr2,xattr3) + pub dir2: PathBuf, + pub key1: Key, + pub key2: Key, + pub xattr1: Xattr, + pub xattr2: Xattr, + pub xattr3: Xattr, +} +impl Default for Scenario { + fn default() -> Self { + let records_dir = TempDir::new("ghee-test-ins:records").unwrap().into_path(); + let mut records_path = records_dir.clone(); + records_path.push("records.json"); + + { + let mut w = File::create(&records_path).unwrap(); + w.write_all(br#"{"test1": 0, "test2": 1, "test3": 2}"#) + .unwrap(); + } + + let dir1 = TempDir::new("ghee-test-ins:1").unwrap().into_path(); + + let dir2 = TempDir::new("ghee-test-ins:2").unwrap().into_path(); + + let key1 = Key::from(vec!["test1"]); + let xattr1 = key1.subkeys[0].clone(); + + let key2 = Key::from(vec!["test2", "test3"]); + let xattr2 = key2.subkeys[0].clone(); + let xattr3 = key2.subkeys[1].clone(); + + init(&dir1, &key1, false).unwrap(); + + idx(&dir1, Some(&dir2), &key2, false); + + ins(&dir1, &Some(records_path), false).unwrap(); + + Self { + dir1, + dir2, + key1, + key2, + xattr1, + xattr2, + xattr3, + } + } +}