From 9865f8288fb252f827fe1800e0732ec19f1aa3b2 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 1 Mar 2026 23:57:37 +0200 Subject: [PATCH] Add database layer with schema creation and default categories --- outlay-core/src/db.rs | 284 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index e69de29..11c1036 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -0,0 +1,284 @@ +use rusqlite::{Connection, Result as SqlResult, params}; +use std::path::Path; + +use crate::models::*; + +const CURRENT_SCHEMA_VERSION: i32 = 1; + +pub struct Database { + pub conn: Connection, +} + +impl Database { + pub fn open(path: &Path) -> SqlResult { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let conn = Connection::open(path)?; + let db = Database { conn }; + db.init()?; + Ok(db) + } + + pub fn open_in_memory() -> SqlResult { + let conn = Connection::open_in_memory()?; + let db = Database { conn }; + db.init()?; + Ok(db) + } + + fn init(&self) -> SqlResult<()> { + self.conn.execute_batch("PRAGMA journal_mode=WAL;")?; + self.conn.execute_batch("PRAGMA foreign_keys=ON;")?; + self.create_tables()?; + self.migrate()?; + Ok(()) + } + + fn create_tables(&self) -> SqlResult<()> { + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + color TEXT, + type TEXT NOT NULL, + is_default INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY, + amount REAL NOT NULL, + type TEXT NOT NULL, + category_id INTEGER NOT NULL REFERENCES categories(id), + currency TEXT NOT NULL DEFAULT 'USD', + exchange_rate REAL DEFAULT 1.0, + note TEXT, + date TEXT NOT NULL, + created_at TEXT NOT NULL, + recurring_id INTEGER REFERENCES recurring_transactions(id) + ); + + CREATE TABLE IF NOT EXISTS budgets ( + id INTEGER PRIMARY KEY, + category_id INTEGER NOT NULL REFERENCES categories(id), + amount REAL NOT NULL, + month TEXT NOT NULL, + UNIQUE(category_id, month) + ); + + CREATE TABLE IF NOT EXISTS recurring_transactions ( + id INTEGER PRIMARY KEY, + amount REAL NOT NULL, + type TEXT NOT NULL, + category_id INTEGER NOT NULL REFERENCES categories(id), + currency TEXT NOT NULL DEFAULT 'USD', + note TEXT, + frequency TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT, + last_generated TEXT, + active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS exchange_rates ( + base TEXT NOT NULL, + target TEXT NOT NULL, + rate REAL NOT NULL, + fetched_at TEXT NOT NULL, + PRIMARY KEY (base, target) + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS budget_notifications ( + category_id INTEGER NOT NULL, + month TEXT NOT NULL, + threshold INTEGER NOT NULL, + notified_at TEXT NOT NULL, + PRIMARY KEY (category_id, month, threshold) + );" + )?; + Ok(()) + } + + fn migrate(&self) -> SqlResult<()> { + let version = self.get_schema_version()?; + + if version == 0 { + self.seed_default_categories()?; + self.set_schema_version(CURRENT_SCHEMA_VERSION)?; + } + + // Future migrations go here: + // if version < 2 { ... self.set_schema_version(2)?; } + + Ok(()) + } + + fn get_schema_version(&self) -> SqlResult { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM schema_version", + [], + |row| row.get(0), + )?; + if count == 0 { + return Ok(0); + } + self.conn.query_row( + "SELECT version FROM schema_version LIMIT 1", + [], + |row| row.get(0), + ) + } + + fn set_schema_version(&self, version: i32) -> SqlResult<()> { + self.conn.execute("DELETE FROM schema_version", [])?; + self.conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + params![version], + )?; + Ok(()) + } + + pub fn schema_version(&self) -> SqlResult { + self.get_schema_version() + } + + fn seed_default_categories(&self) -> SqlResult<()> { + let expense_categories = [ + ("Food & Dining", "\u{1f354}", "#e74c3c"), + ("Groceries", "\u{1f6d2}", "#e67e22"), + ("Transport", "\u{1f68c}", "#3498db"), + ("Housing/Rent", "\u{1f3e0}", "#9b59b6"), + ("Utilities", "\u{1f4a1}", "#f39c12"), + ("Entertainment", "\u{1f3ac}", "#1abc9c"), + ("Shopping", "\u{1f6cd}", "#e91e63"), + ("Health", "\u{2695}", "#2ecc71"), + ("Education", "\u{1f393}", "#00bcd4"), + ("Subscriptions", "\u{1f4f1}", "#ff5722"), + ("Personal Care", "\u{2728}", "#795548"), + ("Gifts", "\u{1f381}", "#ff9800"), + ("Travel", "\u{2708}", "#607d8b"), + ("Other", "\u{1f4b8}", "#95a5a6"), + ]; + + let income_categories = [ + ("Salary", "\u{1f4b0}", "#27ae60"), + ("Freelance", "\u{1f4bb}", "#2980b9"), + ("Investment", "\u{1f4c8}", "#8e44ad"), + ("Gift", "\u{1f381}", "#f1c40f"), + ("Refund", "\u{1f504}", "#16a085"), + ("Other", "\u{1f4b5}", "#7f8c8d"), + ]; + + let mut stmt = self.conn.prepare( + "INSERT INTO categories (name, icon, color, type, is_default, sort_order) + VALUES (?1, ?2, ?3, ?4, 1, ?5)" + )?; + + for (i, (name, icon, color)) in expense_categories.iter().enumerate() { + stmt.execute(params![name, icon, color, "expense", i as i32])?; + } + + for (i, (name, icon, color)) in income_categories.iter().enumerate() { + stmt.execute(params![name, icon, color, "income", i as i32])?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_init_creates_tables() { + let db = Database::open_in_memory().unwrap(); + let count: i64 = db.conn.query_row( + "SELECT COUNT(*) FROM categories", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(count, 20); // 14 expense + 6 income + } + + #[test] + fn test_default_expense_categories() { + let db = Database::open_in_memory().unwrap(); + let count: i64 = db.conn.query_row( + "SELECT COUNT(*) FROM categories WHERE type = 'expense'", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(count, 14); + } + + #[test] + fn test_default_income_categories() { + let db = Database::open_in_memory().unwrap(); + let count: i64 = db.conn.query_row( + "SELECT COUNT(*) FROM categories WHERE type = 'income'", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(count, 6); + } + + #[test] + fn test_schema_version_set() { + let db = Database::open_in_memory().unwrap(); + assert_eq!(db.schema_version().unwrap(), CURRENT_SCHEMA_VERSION); + } + + #[test] + fn test_all_tables_exist() { + let db = Database::open_in_memory().unwrap(); + let tables = [ + "categories", "transactions", "budgets", + "recurring_transactions", "exchange_rates", + "settings", "budget_notifications", "schema_version", + ]; + for table in tables { + let exists: bool = db.conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?1", + params![table], + |row| row.get(0), + ).unwrap(); + assert!(exists, "Table '{}' should exist", table); + } + } + + #[test] + fn test_categories_have_icons_and_colors() { + let db = Database::open_in_memory().unwrap(); + let missing: i64 = db.conn.query_row( + "SELECT COUNT(*) FROM categories WHERE icon IS NULL OR color IS NULL", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(missing, 0, "All default categories should have icon and color"); + } + + #[test] + fn test_idempotent_init() { + let db = Database::open_in_memory().unwrap(); + // Re-init should not duplicate categories + db.init().unwrap(); + let count: i64 = db.conn.query_row( + "SELECT COUNT(*) FROM categories", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(count, 20); + } +}