use rusqlite::{Connection, Result as SqliteResult};
use std::path::Path;
pub const SCHEMA_VERSION: u32 = 9;
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open(path: &Path) -> SqliteResult<Self> {
let conn = Connection::open(path)?;
let mut db = Self { conn };
db.ensure_schema()?;
Ok(db)
}
pub fn open_in_memory() -> SqliteResult<Self> {
let conn = Connection::open_in_memory()?;
let mut db = Self { conn };
db.ensure_schema()?;
Ok(db)
}
pub fn conn(&self) -> &Connection {
&self.conn
}
pub fn needs_rebuild(&self) -> bool {
match self.get_schema_version() {
Ok(version) => version != SCHEMA_VERSION || self.is_dirty(),
Err(_) => true,
}
}
fn get_schema_version(&self) -> SqliteResult<u32> {
let version: String = self.conn.query_row(
"SELECT value FROM meta WHERE key = 'schema_version'",
[],
|row| row.get(0),
)?;
version.parse().map_err(|_| rusqlite::Error::InvalidQuery)
}
fn is_dirty(&self) -> bool {
self.conn
.query_row("SELECT value FROM meta WHERE key = 'dirty'", [], |row| {
row.get::<_, String>(0)
})
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
pub fn set_dirty(&self, dirty: bool) -> SqliteResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('dirty', ?1)",
[if dirty { "true" } else { "false" }],
)?;
Ok(())
}
fn ensure_schema(&mut self) -> SqliteResult<()> {
match self.get_schema_version() {
Ok(version) if version == SCHEMA_VERSION && !self.is_dirty() => {
return Ok(());
}
Ok(version) if version < SCHEMA_VERSION && !self.is_dirty() => {
match super::migrations::run_migrations(&self.conn, version) {
Ok(true) => {
self.conn.execute_batch(include_str!("schema.sql"))?;
self.conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
)?;
return Ok(());
}
Ok(false) => {
}
Err(_) => {
}
}
}
_ => {
}
}
self.drop_all_tables()?;
self.conn.execute_batch(include_str!("schema.sql"))?;
self.conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
)?;
Ok(())
}
fn drop_all_tables(&mut self) -> SqliteResult<()> {
self.conn.execute_batch(
"
DROP TABLE IF EXISTS embeddings;
DROP TABLE IF EXISTS fts;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS critiques;
DROP TABLE IF EXISTS solutions;
DROP TABLE IF EXISTS problems;
DROP TABLE IF EXISTS milestones;
DROP TABLE IF EXISTS meta;
",
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_in_memory() {
let db = Database::open_in_memory().expect("Failed to open in-memory database");
let tables: Vec<String> = db
.conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(tables.contains(&"meta".to_string()));
assert!(tables.contains(&"problems".to_string()));
assert!(tables.contains(&"solutions".to_string()));
assert!(tables.contains(&"critiques".to_string()));
assert!(tables.contains(&"milestones".to_string()));
assert!(tables.contains(&"events".to_string()));
}
#[test]
fn test_schema_version() {
let db = Database::open_in_memory().expect("Failed to open database");
let version = db
.get_schema_version()
.expect("Failed to get schema version");
assert_eq!(version, SCHEMA_VERSION);
assert!(!db.needs_rebuild());
}
#[test]
fn test_dirty_flag() {
let db = Database::open_in_memory().expect("Failed to open database");
assert!(!db.is_dirty());
db.set_dirty(true).expect("Failed to set dirty flag");
assert!(db.is_dirty());
db.set_dirty(false).expect("Failed to clear dirty flag");
assert!(!db.is_dirty());
}
#[test]
fn test_indexes_created() {
let db = Database::open_in_memory().expect("Failed to open database");
let indexes: Vec<String> = db
.conn
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(indexes.contains(&"idx_events_timestamp".to_string()));
assert!(indexes.contains(&"idx_events_event_type".to_string()));
assert!(indexes.contains(&"idx_events_entity_id".to_string()));
assert!(indexes.contains(&"idx_solutions_problem_id".to_string()));
assert!(indexes.contains(&"idx_critiques_solution_id".to_string()));
assert!(indexes.contains(&"idx_problems_milestone_id".to_string()));
assert!(indexes.contains(&"idx_problems_parent_id".to_string()));
}
#[test]
fn test_fts_table_exists() {
let db = Database::open_in_memory().expect("Failed to open database");
let fts_exists: bool = db
.conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='fts'",
[],
|row| row.get(0),
)
.unwrap();
assert!(fts_exists);
}
#[test]
fn test_embeddings_table_exists() {
let db = Database::open_in_memory().expect("Failed to open database");
let embeddings_exists: bool = db
.conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='embeddings'",
[],
|row| row.get(0),
)
.unwrap();
assert!(embeddings_exists);
let index_exists: bool = db
.conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_embeddings_model'",
[],
|row| row.get(0),
)
.unwrap();
assert!(index_exists);
}
#[test]
fn test_open_file_database() {
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("test.db");
{
let db = Database::open(&db_path).expect("Failed to create database");
assert!(!db.needs_rebuild());
}
{
let db = Database::open(&db_path).expect("Failed to reopen database");
assert!(!db.needs_rebuild());
assert_eq!(db.get_schema_version().unwrap(), SCHEMA_VERSION);
}
}
}