diff options
Diffstat (limited to 'src/database.rs')
| -rw-r--r-- | src/database.rs | 687 |
1 files changed, 422 insertions, 265 deletions
diff --git a/src/database.rs b/src/database.rs index 14ff853..bd5ae65 100644 --- a/src/database.rs +++ b/src/database.rs @@ -16,93 +16,96 @@ // License along with бирка-тян. If not, see <http://www.gnu.org/licenses/>. use anyhow::Result; -use blake2::{Blake2b, Digest}; use rusqlite::Connection; use std::convert::AsRef; -use std::io; use std::path::Path; -/// A connection to the tag database. -#[derive(Debug)] -pub struct TagDatabase(Connection); - -/// Tag query with a sense of "state" for pagination. -#[derive(Debug)] -pub struct Query<'a> { - tags: &'a [&'a str], - last_id: Option<i64>, - limit: Option<i64>, +/// Type of constraint on a column. +#[derive(Debug, PartialEq)] +pub enum Atom { + Is(String), + IsNot(String), } -impl<'a> Query<'a> { - /// Construct a new query for images tagged with `tags`. - /// - /// When given to the database, this query will return at most `limit` - /// results, if specified, and will return only images newer than `last_id`, - /// if specified. - pub fn new(tags: &'a [&'a str], last_id: Option<i64>, limit: Option<i64>) -> Self { - Query { - tags, - last_id, - limit, - } - } +/// One of the addressable columns of the 'files' table. +#[derive(Debug, PartialEq)] +pub enum Field { + Tag(Atom), + Filename(Atom), } -/// The result of an image query in бирка-тян's tag database. -#[derive(Debug)] -pub struct Image { - pub id: i64, - pub blake2: String, - pub filename: String, - pub orig_dir: String, -} - -/// Return a base64-encoded BLAKE2b hash identifying the file at `path`. -pub fn hash_file(path: &Path) -> Result<String> { - let mut file = std::fs::File::open(path)?; - let mut hasher = Blake2b::new(); - io::copy(&mut file, &mut hasher)?; - Ok(base64::encode(&hasher.result())) -} +use Atom::{Is, IsNot}; +use Field::{Filename, Tag}; -/// Split the path into a tuple containing the parent directory and the filename. -pub fn split_path(path: &Path) -> Result<(String, String)> { - if path.is_dir() { - bail!("`path` does not name a file."); - } +/// A logical conjunction of `Field` contraints. +/// +/// This is the general interface for queries to the tag database. +pub type Query = Vec<Field>; - let file_name = path - .file_name() - .and_then(|os| os.to_str()) - .ok_or(anyhow!("Couldn't parse file name."))?; +/// Parse `query_string` into a `Query` object usable with the tag database. +pub fn parse_query<T: AsRef<str>>(query_string: T) -> Result<Query> { + Ok(query_string + .as_ref() + .split_whitespace() + .map(|part| { + if part.starts_with("-") { + if part.len() >= 9 && &part[1..10] == "filename:" { + Filename(IsNot(String::from(&part[10..]))) + } else { + Tag(IsNot(String::from(&part[1..]))) + } + } else { + if part.len() >= 9 && &part[..9] == "filename:" { + Filename(Is(String::from(&part[9..]))) + } else { + Tag(Is(String::from(part))) + } + } + }) + .collect()) +} - // Convert `path` to a string and strip off the `file_name` we've just - // obtained to yield the name of the parent directory. - let path = path.to_str().ok_or(anyhow!("Couldn't parse path."))?; - let split_index = path.len() - file_name.len(); - let parent_directory = &path[0..split_index]; +/// State object representing a point in a paginated query. +pub struct Keyset { + pub last_id: i64, + pub how_many: i64, +} - Ok((file_name.to_string(), parent_directory.to_string())) +/// Information about an file in the database. +/// +/// The result of any queries made to the tag database. `id` is the canonical +/// representation of the file, `path` is the path of the file in the +/// filesystem, and `tags` is a vector containing all of the strings the file is +/// tagged with. +pub struct File { + pub id: i64, + pub path: String, + pub tags: Vec<String>, } +/// Public interface to the tag database. +/// +/// Essentially, a wrapper around `rusqlite::Connection` that allows only +/// queries which are sensical given the purpose of the tag databse. +pub struct TagDatabase(Connection); + impl TagDatabase { - /// Instantiate a new `TagDatabase` whose backing database is at `path`. - pub fn new<T: AsRef<Path>>(path: &T) -> Result<Self> { + /// Return a `TagDatabase` connected to `path`. + /// + /// This constructor will create the file if it does not exist. + pub fn new<T: AsRef<Path>>(path: T) -> Result<TagDatabase> { let tb = TagDatabase(Connection::open(path)?); tb.initialize_tables()?; Ok(tb) } - /// Create the table structure for the tag database. + /// Initialize the tag database's schema. fn initialize_tables(&self) -> Result<()> { self.0.execute( - "CREATE TABLE IF NOT EXISTS images ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - blake2 TEXT NOT NULL, - filename TEXT NOT NULL, - orig_dir TEXT NOT NULL + "CREATE TABLE IF NOT EXISTS files ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL )", params![], )?; @@ -115,196 +118,235 @@ impl TagDatabase { )?; self.0.execute( "CREATE TABLE IF NOT EXISTS mapping ( - image INTEGER NOT NULL, - tag INTEGER NOT NULL + file INTEGER NOT NULL, + tag INTEGER NOT NULL )", params![], )?; Ok(()) } - /// Insert `tag` into the database, if it does not already exist. - pub fn add_tag(&self, tag: &str) -> Result<()> { - let exists = self + /// Return all tags associated with the file named by `id`. + fn tags_for_file(&self, id: i64) -> Result<Vec<String>> { + Ok(self .0 - .prepare("SELECT * FROM tags WHERE name = ?")? - .exists(params![tag])?; - if !exists { - self.0 - .execute("INSERT INTO tags (name) VALUES(?)", params![tag])?; - } - Ok(()) + .prepare( + "SELECT tags.name + FROM mapping + INNER JOIN tags ON mapping.tag = tags.id + WHERE mapping.file = ?;", + )? + .query_map(params![id], |row| row.get(0))? + .filter_map(|tag| tag.ok()) + .collect()) } - /// Return the internal id for `tag`. - pub fn tag_id(&self, tag: &str) -> Result<i64> { + /// Return the id of the file located at `path`. + fn id_for_path<T: AsRef<Path>>(&self, path: T) -> Result<i64> { + let path = String::from(path.as_ref().to_str().unwrap()); Ok(self .0 - .prepare("SELECT * FROM tags WHERE name = ?")? - .query_row(params![tag], |row| Ok(row.get::<_, i64>(0)))??) + .prepare("SELECT id FROM files WHERE path = ?")? + .query_row(params![path], |row| Ok(row.get::<_, i64>(0)))??) } - /// Insert the image at `path` into the data. - pub fn import_image(&self, path: &Path, hash: &str) -> Result<i64> { - let (file_name, parent_directory) = split_path(path)?; - Ok(self + /// Index the file at `path` in the tag database. + pub fn add_file<T, S>(&self, path: T, tags: &Vec<S>) -> Result<File> + where + T: AsRef<Path>, + S: AsRef<str>, + { + if self.id_for_path(&path).is_ok() { + bail!("'{:?}' already indexed by the tag database.", path.as_ref()) + } + + // Insert the file row. + self.0 + .prepare("INSERT INTO files (path) VALUES(?)")? + .insert(params![path.as_ref().to_str().unwrap()])?; + + // Associate each tag with the file, creating a row for the tag if it + // does not yet exist in the database. + + let path = String::from(path.as_ref().to_str().unwrap()); + let id = self .0 - .prepare("INSERT INTO images (blake2, filename, orig_dir) VALUES(?,?,?)")? - .insert(params![hash, file_name, parent_directory])?) + .prepare("SELECT id FROM files WHERE path = ?")? + .query_row(params![path], |row| Ok(row.get::<_, i64>(0)))??; + self.tag_file(id, tags)?; + + let tags = self.tags_for_file(id)?; + Ok(File { id, path, tags }) } - /// Return the internal id for the image at `path`. - pub fn image_id(&self, path: &Path) -> Result<i64> { - let (file_name, path) = split_path(path)?; + /// Remove the file named by `id` from the tag database. + pub fn remove_file(&self, id: i64) -> Result<()> { + // Remove the file from the `files` table, + self.0 + .execute("DELETE FROM files WHERE id = ?", params![id])?; + // but also remove it from the table for mapping tags. + self.0 + .execute("DELETE FROM mapping WHERE file = ?", params![id])?; + Ok(()) + } + + /// Return the identifier for `tag`, or `Err` if no such tag exists. + fn tag_id<T: AsRef<str>>(&self, tag: T) -> Result<i64> { Ok(self .0 - .prepare("SELECT id FROM images WHERE orig_dir = ? AND filename = ?")? - .query_row(params![path, file_name], |row| Ok(row.get::<_, i64>(0)))??) + .prepare("SELECT * FROM tags WHERE name = ?")? + .query_row(params![tag.as_ref()], |row| Ok(row.get::<_, i64>(0)))??) } - /// Return the `Image` struct for the image with `id`. - pub fn image_by_id(&self, id: i64) -> Result<Image> { + /// Return the identifiers for all tags named similarly to `tag`. + /// + /// This function treats `tag` as a wildcard, and therefore only makes sense + /// in the context of a query. For inserting images to the database, or any + /// other action where a "canonical" tag is desired, reach for `tag_id` + /// instead. + fn tag_ids<T: AsRef<str>>(&self, tag: T) -> Result<Vec<i64>> { Ok(self .0 - .prepare( - "SELECT id, blake2, filename, orig_dir - FROM images - WHERE id = ?", - )? - .query_row(params![id], |row| { - Ok(Image { - id: row.get(0)?, - blake2: row.get(1)?, - filename: row.get(2)?, - orig_dir: row.get(3)?, - }) - })?) + .prepare("SELECT id FROM tags WHERE name LIKE ?")? + .query_map(params![tag.as_ref().replace("*", "%")], |row| row.get(0))? + .filter_map(|id| id.ok()) + .collect()) } - /// Associate `tag` with the image specified by `image_id`. - pub fn tag_image(&self, image_id: i64, tag: &str) -> Result<()> { - self.0.execute( - "INSERT INTO mapping (image, tag) VALUES(?, ?)", - params![image_id, self.tag_id(tag)?], - )?; + /// Associate all of `tags` with the file named by `id`. + /// + /// If any tag lacks a row in the database, it will be created. The contents + /// of `tags` should not contain wildcardcard expressions. + pub fn tag_file<T: AsRef<str>>(&self, id: i64, tags: &Vec<T>) -> Result<()> { + for tag in tags.iter() { + // Ensure that a row for `tag` into the database. + if self.tag_id(tag.as_ref()).is_err() { + self.0 + .execute("INSERT INTO tags (name) VALUES(?)", params![tag.as_ref()])?; + } + self.0.execute( + "INSERT INTO mapping (file, tag) VALUES(?, ?)", + params![id, self.tag_id(tag.as_ref()).unwrap()], + )?; + } Ok(()) } - /// Disassociate `tag` with the image specified by `image_id`. - pub fn untag_image(&self, image_id: i64, tag: &str) -> Result<()> { - self.0.execute( - "DELETE FROM mapping WHERE image = ? AND tag = ?", - params![image_id, self.tag_id(tag)?], - )?; + /// Remove all of `tags` from the file named by `id`. + /// + /// The contents of `tags` can contain wildcardcard expressions. + pub fn untag_file<T: AsRef<str>>(&self, id: i64, tags: &Vec<T>) -> Result<()> { + for tag in tags.iter() { + // Silently skip any tags which do not exist in the database. + if let Ok(tags) = self.tag_ids(tag.as_ref()) { + for tag in tags.iter() { + self.0 + .prepare("DELETE FROM mapping WHERE file = ? AND tag = ?")? + .insert(params![id, tag])?; + } + } + } Ok(()) } - /// Return the tags for the image specified by `image_id`. - pub fn tags_for_image(&self, image_id: i64) -> Result<Vec<String>> { + /// Return the `File` object for the file named by `id`. + pub fn file_by_id(&self, id: i64) -> Result<File> { + let tags = self.tags_for_file(id)?; Ok(self .0 - .prepare( - "SELECT tags.name - FROM mapping - INNER JOIN tags ON mapping.tag = tags.id - WHERE mapping.image = ?;", - )? - .query_map(params![image_id], |row| row.get(0))? - .filter_map(|tag| tag.ok()) - .collect()) + .prepare("SELECT id, path FROM files WHERE id = ?")? + .query_row(params![id], |row| { + Ok(File { + id: row.get(0)?, + path: row.get(1)?, + tags, + }) + })?) } - /// Return the intersection of the sets of images matching each of `tags`. - pub fn query(&self, q: Query) -> Result<Vec<Image>> { - let ids: Vec<i64> = q - .tags - .iter() - .filter_map(|tag| self.tag_id(tag).ok()) - .collect(); - let mut tags = Vec::from(q.tags); - tags.dedup(); - - // Query contained a tag that's not in the database. - if ids.len() != tags.len() { - return Ok(vec![]); + /// Return the files in the database satisfying `q` and `k`. + pub fn query(&self, q: &Query, k: Option<Keyset>) -> Result<Vec<File>> { + // Return an empty vector if the query contains tags that aren't in the + // database. + for part in q.iter() { + if let Tag(Is(name)) = part { + if self.tag_id(name).is_err() { + return Ok(vec![]); + } + } } - let union = if tags.is_empty() { - None - } else { - Some( - (1..ids.len() + 1) - .zip(ids) - .map(|pair| { - let (n, id) = pair; - format!( - "INNER JOIN mapping as m{n} - ON images.id = m{n}.image - AND {id} = m{n}.tag", - n = n, - id = id - ) - }) - .collect::<Vec<String>>() - .join("\n"), - ) - }; + let predicates = q + .iter() + .filter_map(|field| match field { + Tag(Is(expression)) => Some( + self.tag_ids(expression) + .ok()? + .iter() + .map(|tag| format!("(mapping.tag = {})", tag)) + .collect::<Vec<_>>(), + ), + Tag(IsNot(expression)) => Some( + self.tag_ids(expression) + .ok()? + .iter() + .map(|tag| { + format!( + "(files.id NOT IN + (SELECT files.id FROM files + JOIN mapping ON files.id = mapping.file + WHERE mapping.tag = {}))", + tag + ) + }) + .collect::<Vec<_>>(), + ), + Filename(Is(expression)) => Some(vec![format!( + "(file.path LIKE %{})", + expression.replace("*", "%") + )]), + Filename(IsNot(expression)) => Some(vec![format!( + "(file.path NOT LIKE %{})", + expression.replace("*", "%") + )]), + }) + .flat_map(|s| s) + .collect::<Vec<_>>(); let query = format!( - "SELECT id, blake2, filename, orig_dir - FROM images - {union} - {since} - GROUP BY id - ORDER BY id DESC - {limit}", - since = if let Some(n) = q.last_id { - format!("WHERE id < {}", n) + "SELECT files.id FROM files + JOIN mapping + ON files.id = mapping.file + WHERE {predicate} + {since} + GROUP BY files.id + ORDER BY files.id DESC + {limit}", + predicate = if predicates.is_empty() { + "1 == 1".into() } else { - String::from("") + predicates.join(" AND ") }, - limit = if let Some(n) = q.limit { - format!("LIMIT {}", n) + since = if let Some(ref keyset) = k { + format!("AND files.id < {}", keyset.last_id) } else { - String::from("") + "".into() }, - union = union.or(Some("".into())).unwrap(), + limit = if let Some(ref keyset) = k { + format!("LIMIT {}", keyset.how_many) + } else { + "".into() + } ); Ok(self .0 .prepare(&query)? - .query_map( - params![], - |row| -> rusqlite::Result<(i64, String, String, String)> { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) - }, - )? - .filter_map(|res| { - res.map(|pair| { - let (id, blake2, filename, orig_dir) = pair; - Image { - id, - blake2, - filename, - orig_dir, - } - }) - .ok() - }) + .query_map(params![], |row| row.get::<_, i64>(0))? + .filter_map(|id| id.ok().and_then(|id| self.file_by_id(id).ok())) .collect()) } - /// Return the intersection of the sets of images matching each of `tags`. - pub fn images_by_tags(&self, tags: &[&str]) -> Result<Vec<Image>> { - self.query(Query { - tags, - last_id: None, - limit: None, - }) - } - - /// Return the `n` greatest tags, ordered by number of associated images. pub fn top_tags(&self, n: i64) -> Result<Vec<(i64, String)>> { Ok(self .0 @@ -337,95 +379,210 @@ mod tests { } #[test] - fn tables_exist() { + fn parse_query_empty() { + let parsed = parse_query("").unwrap(); + assert!(parsed.len() == 0); + } + + #[test] + fn parse_query_tags_only() { + let parsed = parse_query("tag1 tag2 tag3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Tag(Is("tag1".into())))); + assert!(parsed.contains(&Tag(Is("tag2".into())))); + assert!(parsed.contains(&Tag(Is("tag3".into())))); + } + + #[test] + fn parse_query_tag_negations_only() { + let parsed = parse_query("-tag1 -tag2 -tag3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Tag(IsNot("tag1".into())))); + assert!(parsed.contains(&Tag(IsNot("tag2".into())))); + assert!(parsed.contains(&Tag(IsNot("tag3".into())))); + } + + #[test] + fn parse_query_filenames_only() { + let parsed = parse_query("filename:1 filename:2 filename:3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Filename(Is("1".into())))); + assert!(parsed.contains(&Filename(Is("2".into())))); + assert!(parsed.contains(&Filename(Is("3".into())))); + } + + #[test] + fn parse_query_filename_negations_only() { + let parsed = parse_query("-filename:1 -filename:2 -filename:3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Filename(IsNot("1".into())))); + assert!(parsed.contains(&Filename(IsNot("2".into())))); + assert!(parsed.contains(&Filename(IsNot("3".into())))); + } + + #[test] + fn parse_query_combined() { + let parsed = parse_query("tag1 -tag2 filename:1 -filename:2").unwrap(); + assert_eq!(parsed.len(), 4); + assert!(parsed.contains(&Tag(Is("tag1".into())))); + assert!(parsed.contains(&Tag(IsNot("tag2".into())))); + assert!(parsed.contains(&Filename(Is("1".into())))); + assert!(parsed.contains(&Filename(IsNot("2".into())))); + } + + #[test] + fn test_add_file_fail_on_already_indexed() { let tb = TagDatabase::new_mem().unwrap(); - for table in ["images", "tags", "mapping"].iter() { - let exists = - tb.0.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?;") - .unwrap() - .exists(params![table]) - .unwrap(); - assert!(exists); - } + let img = tb.add_file("test", &vec!["tag1"]).unwrap(); + assert!(tb.add_file("test", &vec!["tag2"]).is_err()); } #[test] - fn insert_file() { + fn test_add_file_output_reflects_input() { let tb = TagDatabase::new_mem().unwrap(); - let id = tb - .import_image(Path::new("/fake/path"), "fakehash") - .unwrap(); - let exists = - tb.0.prepare("SELECT filename, blake2 FROM images WHERE id = ?;") - .unwrap() - .exists(params![id]) - .unwrap(); - assert!(exists); - let exists = - tb.0.prepare("SELECT filename, blake2 FROM images WHERE id = ?;") - .unwrap() - .exists(params![id + 1]) - .unwrap(); - assert!(!exists); + let img = tb.add_file("test", &vec!["tag1", "tag2"]).unwrap(); + assert_eq!(img.path, "test"); + assert_eq!(img.tags.len(), 2); + assert!(img.tags.contains(&String::from("tag1"))); + assert!(img.tags.contains(&String::from("tag2"))); + } + + #[test] + fn test_remove_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.remove_file(img.id).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_query_tags_only() { + let tb = TagDatabase::new_mem().unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.add_file("test2", &vec!["tag2"]).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); + + let query = parse_query("tag2").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_query_tags_not_indexed() { + let tb = TagDatabase::new_mem().unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + + let query = parse_query("tag3").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); } #[test] - fn add_tag() { + fn test_query_tag_negations_only() { let tb = TagDatabase::new_mem().unwrap(); - tb.add_tag("test").unwrap(); - tb.tag_id("test").unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.add_file("test2", &vec!["tag2"]).unwrap(); + + let query = parse_query("-tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test2"); } #[test] - fn tag_image() { + fn test_query_tags_with_keyset() { let tb = TagDatabase::new_mem().unwrap(); - let id = tb - .import_image(Path::new("/fake/path"), "fakehash") + tb.add_file("test1", &vec!["tag1"]).unwrap(); + tb.add_file("test2", &vec!["tag1"]).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb + .query( + &query, + Some(Keyset { + last_id: 3, + how_many: 1, + }), + ) .unwrap(); - tb.add_tag("test").unwrap(); - tb.tag_image(id, "test").unwrap(); - let tags = tb.tags_for_image(id).unwrap(); - assert_eq!(tags.len(), 1); - assert!(tags.contains(&String::from("test"))); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test2"); + + let results = tb + .query( + &query, + Some(Keyset { + last_id: 2, + how_many: 1, + }), + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); } #[test] - fn query_by_tag() { + fn test_empty_query() { let tb = TagDatabase::new_mem().unwrap(); - let ids: Vec<i64> = (1..4) - .map(|i| { - let i = i.to_string(); - tb.import_image(Path::new(&i), &i).unwrap() - }) - .collect(); - tb.add_tag("1").unwrap(); - tb.add_tag("2").unwrap(); - tb.tag_image(ids[0], "1").unwrap(); - tb.tag_image(ids[1], "2").unwrap(); - tb.tag_image(ids[2], "1").unwrap(); - tb.tag_image(ids[2], "2").unwrap(); - assert_eq!(tb.images_by_tags(&["1"]).unwrap().len(), 2); - assert_eq!(tb.images_by_tags(&["2"]).unwrap().len(), 2); - assert_eq!(tb.images_by_tags(&["1", "2"]).unwrap().len(), 1); + tb.add_file("test1", &vec!["tag1"]).unwrap(); + tb.add_file("test2", &vec!["tag1"]).unwrap(); + + let query = parse_query("").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_tag_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.tag_file(img.id, &vec!["tag3"]).unwrap(); + + let query = parse_query("tag3").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); + } + + #[test] + fn test_untag_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.untag_file(img.id, &vec!["tag2"]).unwrap(); + + let query = parse_query("tag2").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_id_resolution() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + assert_eq!(tb.file_by_id(img.id).unwrap().path, "test1"); } #[test] fn top_tags() { let tb = TagDatabase::new_mem().unwrap(); - let ids: Vec<i64> = (1..4) + let ids: Vec<_> = (1..4) .map(|i| { let i = i.to_string(); - tb.import_image(Path::new(&i), &i).unwrap() + tb.add_file(Path::new(&i), &Vec::<&str>::new()).unwrap() }) .collect(); - tb.add_tag("1").unwrap(); - tb.add_tag("2").unwrap(); - tb.tag_image(ids[0], "1").unwrap(); - tb.tag_image(ids[1], "2").unwrap(); - tb.tag_image(ids[2], "1").unwrap(); - tb.tag_image(ids[2], "2").unwrap(); + tb.tag_file(ids[0].id, &vec!["1"]).unwrap(); + tb.tag_file(ids[1].id, &vec!["2"]).unwrap(); + tb.tag_file(ids[2].id, &vec!["1"]).unwrap(); + tb.tag_file(ids[2].id, &vec!["2"]).unwrap(); assert_eq!(tb.top_tags(2).unwrap().len(), 2); assert_eq!(tb.top_tags(2).unwrap()[0].0, 2); - assert_eq!(tb.top_tags(2).unwrap()[1].0, 2); } } |