diff --git a/src/db.rs b/src/db.rs index c2ceff8..7ca64e9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,13 +1,13 @@ #[cfg(feature = "ssr")] mod db_impl { + use crate::models::item::Item; + use leptos::logging; + use leptos::logging::log; use rusqlite::{Connection, Error}; use serde::{Deserialize, Serialize}; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::Mutex; - use leptos::logging; - use std::collections::{HashMap, HashSet}; - use crate::models::item::Item; - use leptos::logging::log; #[cfg(test)] mod tests { @@ -17,19 +17,29 @@ mod db_impl { // Helper function to create test database async fn create_test_db() -> Database { + log!("[TEST] Creating in-memory test database"); let db = Database::new(":memory:").unwrap(); db.create_schema().await.unwrap(); + log!("[TEST] Database schema created"); db } // Test database schema creation #[tokio::test] async fn test_schema_creation() { + log!("[TEST] Starting test_schema_creation"); let db = create_test_db().await; + // Verify tables exist let conn = db.conn.lock().await; - let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table'").unwrap(); - let tables: Vec = stmt.query_map([], |row| row.get(0)).unwrap().collect::>().unwrap(); + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .unwrap(); + let tables: Vec = stmt + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); assert!(tables.contains(&"urls".to_string())); assert!(tables.contains(&"items".to_string())); @@ -41,6 +51,7 @@ mod db_impl { // Item Lifecycle Tests #[tokio::test] async fn test_full_item_lifecycle() { + log!("[TEST] Starting test_full_item_lifecycle"); let db = create_test_db().await; let test_url = "https://example.com"; let test_item = Item { @@ -50,62 +61,87 @@ mod db_impl { wikidata_id: Some("Q123".into()), custom_properties: vec![ ("price".into(), "100".into()), - ("color".into(), "red".into()) - ].into_iter().collect(), + ("color".into(), "red".into()), + ] + .into_iter() + .collect(), }; - + // Test insertion + log!("[TEST] Testing item insertion"); db.insert_item_by_url(test_url, &test_item).await.unwrap(); + log!("[TEST] Item insertion - PASSED"); // Test retrieval + log!("[TEST] Testing item retrieval"); let items = db.get_items_by_url(test_url).await.unwrap(); assert_eq!(items.len(), 1); let stored_item = &items[0]; assert_eq!(stored_item.name, test_item.name); assert_eq!(stored_item.custom_properties.len(), 2); - + log!("[TEST] Item retrieval and validation - PASSED"); + // Test update + log!("[TEST] Testing item update"); let mut updated_item = test_item.clone(); updated_item.name = "Updated Name".into(); - db.insert_item_by_url(test_url, &updated_item).await.unwrap(); + db.insert_item_by_url(test_url, &updated_item) + .await + .unwrap(); // Verify update let items = db.get_items_by_url(test_url).await.unwrap(); assert_eq!(items[0].name, "Updated Name"); - + log!("[TEST] Item update - PASSED"); + // Test deletion - db.delete_item_by_url(test_url, &test_item.id).await.unwrap(); + log!("[TEST] Testing item deletion"); + db.delete_item_by_url(test_url, &test_item.id) + .await + .unwrap(); let items = db.get_items_by_url(test_url).await.unwrap(); assert!(items.is_empty()); + log!("[TEST] Item deletion - PASSED"); + log!("[TEST] test_full_item_lifecycle completed successfully"); } //URL Management Tests #[tokio::test] async fn test_url_management() { + log!("[TEST] Starting test_url_management"); let db = create_test_db().await; let test_url = "https://test.com"; - + // Test URL creation + log!("[TEST] Testing URL creation"); let url_id = db.insert_url(test_url).await.unwrap(); assert!(url_id > 0); - + log!("[TEST] URL creation - PASSED"); + // Test duplicate URL handling + log!("[TEST] Testing duplicate URL handling"); let duplicate_id = db.insert_url(test_url).await.unwrap(); assert_eq!(url_id, duplicate_id); - + log!("[TEST] Duplicate URL handling - PASSED"); + // Test URL retrieval + log!("[TEST] Testing URL retrieval"); let conn = db.conn.lock().await; - let stored_url: String = conn.query_row( - "SELECT url FROM urls WHERE id = ?", - [url_id], - |row| row.get(0) - ).unwrap(); + let stored_url: String = conn + .query_row("SELECT url FROM urls WHERE id = ?", [url_id], |row| { + row.get(0) + }) + .unwrap(); assert_eq!(stored_url, test_url); + log!("[TEST] URL retrieval - PASSED"); + + log!("[TEST] test_url_management completed successfully"); } //property management tests #[tokio::test] async fn test_property_operations() { + log!("[TEST] Starting test_property_operations"); let db = create_test_db().await; let test_url = "https://props.com"; let test_item = Item { @@ -115,43 +151,59 @@ mod db_impl { wikidata_id: Some("Q123".into()), custom_properties: vec![ ("price".into(), "100".into()), - ("color".into(), "red".into()) - ].into_iter().collect(), - }; + ("color".into(), "red".into()), + ] + .into_iter() + .collect(), + }; // Test property creation + log!("[TEST] Testing property creation"); db.insert_item_by_url(test_url, &test_item).await.unwrap(); // Verify properties stored let items = db.get_items_by_url(test_url).await.unwrap(); assert_eq!(items[0].custom_properties.len(), 2); - + log!("[TEST] Property creation - PASSED"); + // Test property deletion + log!("[TEST] Testing property deletion"); db.delete_property_by_url(test_url, "price").await.unwrap(); let items = db.get_items_by_url(test_url).await.unwrap(); assert_eq!(items[0].custom_properties.len(), 1); assert!(!items[0].custom_properties.contains_key("price")); + log!("[TEST] Property deletion - PASSED"); + + log!("[TEST] test_property_operations completed successfully"); } //selected properties test #[tokio::test] async fn test_selected_properties() { + log!("[TEST] Starting test_selected_properties"); let db = create_test_db().await; let test_url = "https://selected.com"; // Add test properties + log!("[TEST] Adding selected properties"); db.add_selected_property(test_url, "price").await.unwrap(); db.add_selected_property(test_url, "weight").await.unwrap(); - + // Test retrieval + log!("[TEST] Testing property retrieval"); let props = db.get_selected_properties(test_url).await.unwrap(); assert_eq!(props.len(), 2); assert!(props.contains(&"price".to_string())); assert!(props.contains(&"weight".to_string())); - + log!("[TEST] Property retrieval - PASSED"); + // Test duplicate prevention + log!("[TEST] Testing duplicate prevention"); db.add_selected_property(test_url, "price").await.unwrap(); let props = db.get_selected_properties(test_url).await.unwrap(); assert_eq!(props.len(), 2); // No duplicate added + log!("[TEST] Duplicate prevention - PASSED"); + + log!("[TEST] test_selected_properties completed successfully"); } } @@ -165,7 +217,7 @@ mod db_impl { // Create a new database connection pub fn new(db_path: &str) -> Result { let conn = Connection::open(db_path)?; - logging::log!("Database connection established at: {}", db_path); + logging::log!("Database connection established at: {}", db_path); Ok(Database { conn: Arc::new(Mutex::new(conn)), }) @@ -174,15 +226,16 @@ mod db_impl { // Create the database schema pub async fn create_schema(&self) -> Result<(), Error> { let conn = self.conn.lock().await; - + // 1. Properties table conn.execute_batch( "CREATE TABLE IF NOT EXISTS properties ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, global_usage_count INTEGER DEFAULT 0 - );" - ).map_err(|e| { + );", + ) + .map_err(|e| { eprintln!("Failed creating properties table: {}", e); e })?; @@ -194,7 +247,8 @@ mod db_impl { url TEXT NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );", - ).map_err(|e| { + ) + .map_err(|e| { eprintln!("Failed creating urls table: {}", e); e })?; @@ -209,7 +263,8 @@ mod db_impl { wikidata_id TEXT, FOREIGN KEY (url_id) REFERENCES urls(id) ON DELETE CASCADE );", - ).map_err(|e| { + ) + .map_err(|e| { eprintln!("Failed creating items table: {}", e); e })?; @@ -222,12 +277,13 @@ mod db_impl { PRIMARY KEY (url_id, property_id), FOREIGN KEY (url_id) REFERENCES urls(id) ON DELETE CASCADE, FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE - );" - ).map_err(|e| { + );", + ) + .map_err(|e| { eprintln!("Failed creating properties table: {}", e); e })?; - + // 5. Junction table for custom properties conn.execute_batch( "CREATE TABLE IF NOT EXISTS item_properties ( @@ -237,8 +293,9 @@ mod db_impl { PRIMARY KEY (item_id, property_id), FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE - );" - ).map_err(|e| { + );", + ) + .map_err(|e| { eprintln!("Failed creating item_properties table: {}", e); e })?; @@ -249,19 +306,13 @@ mod db_impl { pub async fn insert_url(&self, url: &str) -> Result { let mut conn = self.conn.lock().await; let tx = conn.transaction()?; - + // Use INSERT OR IGNORE to handle duplicates - tx.execute( - "INSERT OR IGNORE INTO urls (url) VALUES (?)", - [url] - )?; + tx.execute("INSERT OR IGNORE INTO urls (url) VALUES (?)", [url])?; // Get the URL ID whether it was inserted or already existed - let url_id = tx.query_row( - "SELECT id FROM urls WHERE url = ?", - [url], - |row| row.get(0) - )?; + let url_id = + tx.query_row("SELECT id FROM urls WHERE url = ?", [url], |row| row.get(0))?; tx.commit()?; logging::log!("URL inserted: {}", url); @@ -277,12 +328,15 @@ mod db_impl { pub async fn delete_property(&self, property: &str) -> Result<(), Error> { let conn = self.conn.lock().await; - let query = format!("UPDATE items SET custom_properties = json_remove(custom_properties, '$.{}')", property); + let query = format!( + "UPDATE items SET custom_properties = json_remove(custom_properties, '$.{}')", + property + ); conn.execute(&query, []).map_err(|e| Error::from(e))?; logging::log!("Property deleted: {}", property); Ok(()) } - + // Retrieve all items from the database pub async fn get_items(&self) -> Result, Error> { let conn = self.conn.lock().await; @@ -306,16 +360,15 @@ mod db_impl { // Retrieve all items from the database for a specific URL pub async fn get_items_by_url(&self, url: &str) -> Result, Error> { let conn = self.conn.lock().await; - let url_id: Option = match conn.query_row( - "SELECT id FROM urls WHERE url = ?", - &[url], - |row| row.get(0) - ) { - Ok(id) => Some(id), - Err(rusqlite::Error::QueryReturnedNoRows) => None, - Err(e) => return Err(e), - }; - + let url_id: Option = + match conn.query_row("SELECT id FROM urls WHERE url = ?", &[url], |row| { + row.get(0) + }) { + Ok(id) => Some(id), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(e) => return Err(e), + }; + let url_id = match url_id { Some(id) => id, None => return Ok(Vec::new()), // Return empty list if URL not found @@ -323,28 +376,27 @@ mod db_impl { log!("Fetching items for URL '{}' (ID: {})", url, url_id); - let mut stmt = conn.prepare( "SELECT i.id, i.name, i.description, i.wikidata_id, p.name AS prop_name, ip.value FROM items i LEFT JOIN item_properties ip ON i.id = ip.item_id LEFT JOIN properties p ON ip.property_id = p.id - WHERE i.url_id = ?" + WHERE i.url_id = ?", )?; let mut items: HashMap = HashMap::new(); - + let rows = stmt.query_map([url_id], |row| { Ok(( - row.get::<_, String>(0)?, // id - row.get::<_, String>(1)?, // name - row.get::<_, String>(2)?, // description - row.get::<_, Option>(3)?, // wikidata_id - row.get::<_, Option>(4)?, // prop_name - row.get::<_, Option>(5)?, // value + row.get::<_, String>(0)?, // id + row.get::<_, String>(1)?, // name + row.get::<_, String>(2)?, // description + row.get::<_, Option>(3)?, // wikidata_id + row.get::<_, Option>(4)?, // prop_name + row.get::<_, Option>(5)?, // value )) })?; - + for row in rows { let (id, name, desc, wd_id, prop, val) = row?; let item = items.entry(id.clone()).or_insert(Item { @@ -354,25 +406,23 @@ mod db_impl { wikidata_id: wd_id, custom_properties: HashMap::new(), }); - + if let (Some(p), Some(v)) = (prop, val) { item.custom_properties.insert(p, v); } } - + Ok(items.into_values().collect()) } async fn get_or_create_property( - &self, - tx: &mut rusqlite::Transaction<'_>, - prop: &str + &self, + tx: &mut rusqlite::Transaction<'_>, + prop: &str, ) -> Result { - match tx.query_row( - "SELECT id FROM properties WHERE name = ?", - [prop], - |row| row.get::<_, i64>(0) - ) { + match tx.query_row("SELECT id FROM properties WHERE name = ?", [prop], |row| { + row.get::<_, i64>(0) + }) { Ok(id) => Ok(id), Err(rusqlite::Error::QueryReturnedNoRows) => { tx.execute("INSERT INTO properties (name) VALUES (?)", [prop])?; @@ -385,30 +435,28 @@ mod db_impl { // Insert a new item into the database for a specific URL pub async fn insert_item_by_url(&self, url: &str, item: &Item) -> Result<(), Error> { log!("[DB] Starting insert for URL: {}, Item: {}", url, item.id); - + // 1. Check database lock acquisition let lock_start = std::time::Instant::now(); let mut conn = self.conn.lock().await; log!("[DB] Lock acquired in {:?}", lock_start.elapsed()); - + // 2. Transaction handling log!("[DB] Starting transaction"); let mut tx = conn.transaction().map_err(|e| { log!("[DB] Transaction start failed: {:?}", e); e })?; - + // 3. URL handling log!("[DB] Checking URL existence: {}", url); - let url_id = match tx.query_row( - "SELECT id FROM urls WHERE url = ?", - [url], - |row| row.get::<_, i64>(0) - ) { + let url_id = match tx.query_row("SELECT id FROM urls WHERE url = ?", [url], |row| { + row.get::<_, i64>(0) + }) { Ok(id) => { log!("[DB] Found existing URL ID: {}", id); id - }, + } Err(rusqlite::Error::QueryReturnedNoRows) => { log!("[DB] Inserting new URL"); tx.execute("INSERT INTO urls (url) VALUES (?)", [url])?; @@ -418,7 +466,7 @@ mod db_impl { } Err(e) => return Err(e.into()), }; - + // 4. Item insertion log!("[DB] Upserting item"); tx.execute( @@ -446,7 +494,7 @@ mod db_impl { "SELECT p.name, ip.value FROM item_properties ip JOIN properties p ON ip.property_id = p.id - WHERE ip.item_id = ?" + WHERE ip.item_id = ?", )?; let mapped_rows = stmt.query_map([&item.id], |row| { @@ -455,13 +503,18 @@ mod db_impl { mapped_rows.collect::, _>>()? }; - + for (prop, value) in &item.custom_properties { // Update existing or insert new let prop_id = self.get_or_create_property(&mut tx, prop).await?; if let Some(existing_value) = existing_props.get(prop) { if existing_value != value { - log!("[DB] Updating property {} from '{}' to '{}'", prop, existing_value, value); + log!( + "[DB] Updating property {} from '{}' to '{}'", + prop, + existing_value, + value + ); tx.execute( "UPDATE item_properties SET value = ? @@ -481,7 +534,8 @@ mod db_impl { } // Remove deleted properties - let current_props: HashSet<&str> = item.custom_properties.keys().map(|s| s.as_str()).collect(); + let current_props: HashSet<&str> = + item.custom_properties.keys().map(|s| s.as_str()).collect(); for (existing_prop, _) in existing_props { if !current_props.contains(existing_prop.as_str()) { log!("[DB] Removing deleted property {}", existing_prop); @@ -504,18 +558,15 @@ mod db_impl { let tx = conn.transaction()?; // Get URL ID - let url_id: i64 = tx.query_row( - "SELECT id FROM urls WHERE url = ?", - [url], - |row| row.get(0) - )?; - + let url_id: i64 = + tx.query_row("SELECT id FROM urls WHERE url = ?", [url], |row| row.get(0))?; + // Delete item and properties tx.execute( "DELETE FROM items WHERE id = ? AND url_id = ?", [item_id, &url_id.to_string()], )?; - + tx.commit()?; Ok(()) } @@ -524,14 +575,11 @@ mod db_impl { pub async fn delete_property_by_url(&self, url: &str, property: &str) -> Result<(), Error> { let mut conn = self.conn.lock().await; let tx = conn.transaction()?; - + // Get URL ID - let url_id: i64 = tx.query_row( - "SELECT id FROM urls WHERE url = ?", - [url], - |row| row.get(0) - )?; - + let url_id: i64 = + tx.query_row("SELECT id FROM urls WHERE url = ?", [url], |row| row.get(0))?; + // Delete property from all items in this URL tx.execute( "DELETE FROM item_properties @@ -543,7 +591,7 @@ mod db_impl { )", [property, &url_id.to_string()], )?; - + tx.commit()?; Ok(()) } @@ -551,25 +599,20 @@ mod db_impl { pub async fn add_selected_property(&self, url: &str, property: &str) -> Result<(), Error> { let mut conn = self.conn.lock().await; let tx = conn.transaction()?; - + // Insert URL if it does not exists - tx.execute( - "INSERT OR IGNORE INTO urls (url) VALUES (?)", - [url] - )?; - + tx.execute("INSERT OR IGNORE INTO urls (url) VALUES (?)", [url])?; + // Get URL ID - let url_id = tx.query_row( - "SELECT id FROM urls WHERE url = ?", - [url], - |row| row.get::<_, i64>(0) - )?; - + let url_id = tx.query_row("SELECT id FROM urls WHERE url = ?", [url], |row| { + row.get::<_, i64>(0) + })?; + // Get/Create property let prop_id = match tx.query_row( "SELECT id FROM properties WHERE name = ?", [property], - |row| row.get::<_, i64>(0) + |row| row.get::<_, i64>(0), ) { Ok(id) => id, Err(_) => { @@ -577,13 +620,13 @@ mod db_impl { tx.last_insert_rowid() } }; - + // Insert into selected_properties tx.execute( "INSERT OR IGNORE INTO selected_properties (url_id, property_id) VALUES (?, ?)", - [url_id, prop_id] + [url_id, prop_id], )?; - + tx.commit()?; Ok(()) } @@ -595,34 +638,42 @@ mod db_impl { FROM selected_properties sp JOIN properties p ON sp.property_id = p.id JOIN urls u ON sp.url_id = u.id - WHERE u.url = ?" + WHERE u.url = ?", )?; - + let properties = stmt.query_map([url], |row| row.get(0))?; properties.collect() } - + // function to log database state pub async fn debug_dump(&self) -> Result<(), Error> { let conn = self.conn.lock().await; log!("[DATABASE DEBUG] URLs:"); let mut stmt = conn.prepare("SELECT id, url FROM urls")?; let urls = stmt.query_map([], |row| { - Ok(format!("ID: {}, URL: {}", row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + Ok(format!( + "ID: {}, URL: {}", + row.get::<_, i64>(0)?, + row.get::<_, String>(1)? + )) })?; for url in urls { log!("[DATABASE DEBUG] {}", url?); } - + log!("[DATABASE DEBUG] Items:"); let mut stmt = conn.prepare("SELECT id, name FROM items")?; let items = stmt.query_map([], |row| { - Ok(format!("ID: {}, Name: '{}'", row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + Ok(format!( + "ID: {}, Name: '{}'", + row.get::<_, String>(0)?, + row.get::<_, String>(1)? + )) })?; for item in items { log!("[DATABASE DEBUG] {}", item?); } - + Ok(()) } } @@ -638,4 +689,4 @@ mod db_impl { } #[cfg(feature = "ssr")] -pub use db_impl::{Database, DbItem}; \ No newline at end of file +pub use db_impl::{Database, DbItem};