Add database layer with schema creation and default categories
This commit is contained in:
@@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<i32> {
|
||||||
|
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<i32> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user