use anyhow::Result; use rusqlite::Connection; pub(super) fn migrate(connection: &Connection) -> Result<()> { connection.execute_batch( "BEGIN; CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY, text TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5), when_at TEXT, done_at TEXT, alarm_at TEXT, numeric_value REAL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, discarded INTEGER NOT NULL DEFAULT 0 CHECK(discarded IN (0,1)) ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE, parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, kind TEXT NOT NULL DEFAULT 'standard', match_text TEXT NOT NULL DEFAULT '', exclusive INTEGER NOT NULL DEFAULT 0 CHECK(exclusive IN (0,1)), sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS item_categories ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, assignment TEXT NOT NULL DEFAULT 'explicit', PRIMARY KEY(item_id, category_id) ); CREATE TABLE IF NOT EXISTS dependencies ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, prerequisite_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, PRIMARY KEY(item_id, prerequisite_id), CHECK(item_id <> prerequisite_id) ); CREATE TABLE IF NOT EXISTS views ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE, kind TEXT NOT NULL DEFAULT 'list', filter_value TEXT NOT NULL DEFAULT '', sort_key TEXT NOT NULL DEFAULT 'manual', show_done INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_items_when ON items(when_at); CREATE INDEX IF NOT EXISTS idx_items_done ON items(done_at); CREATE INDEX IF NOT EXISTS idx_item_categories_category ON item_categories(category_id); COMMIT;", )?; let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; if version < 2 { add_filter_expression(connection)?; } connection.execute_batch( "CREATE TABLE IF NOT EXISTS view_columns ( id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, field TEXT NOT NULL, heading TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 20, aggregate TEXT NOT NULL DEFAULT 'none', sort_order INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS view_sections ( id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, heading TEXT NOT NULL, filter_expr TEXT NOT NULL DEFAULT '', collapsed INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS category_rules ( id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, condition_expr TEXT NOT NULL, action_kind TEXT NOT NULL, action_value TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0);", )?; if version < 2 { connection.pragma_update(None, "user_version", 2)?; } if version < 3 { add_recurrence(connection)?; connection.pragma_update(None, "user_version", 3)?; } connection.execute_batch( "CREATE TABLE IF NOT EXISTS macros ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE, source TEXT NOT NULL, key_binding TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS macro_variables ( name TEXT PRIMARY KEY COLLATE NOCASE, value TEXT NOT NULL );", )?; if version < 4 { connection.pragma_update(None, "user_version", 4)?; } if version < 5 { add_lotus_values_and_rules(connection)?; connection.pragma_update(None, "user_version", 5)?; } Ok(()) } fn add_lotus_values_and_rules(connection: &Connection) -> Result<()> { connection.execute_batch( "BEGIN; ALTER TABLE item_categories RENAME TO old_item_categories; CREATE TABLE item_categories ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, assignment TEXT NOT NULL CHECK(assignment IN ('explicit','automatic','conditional','excluded')), PRIMARY KEY(item_id, category_id, assignment) ); INSERT INTO item_categories(item_id,category_id,assignment) SELECT item_id,category_id,assignment FROM old_item_categories; DROP TABLE old_item_categories; CREATE INDEX idx_item_categories_category ON item_categories(category_id); CREATE TABLE item_values ( item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, numeric_value REAL, date_value TEXT, assignment TEXT NOT NULL DEFAULT 'explicit' CHECK(assignment IN ('explicit','automatic','conditional')), PRIMARY KEY(item_id, category_id), CHECK((numeric_value IS NULL) OR (date_value IS NULL)) ); CREATE TABLE rule_action_runs ( rule_id INTEGER NOT NULL REFERENCES category_rules(id) ON DELETE CASCADE, item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, PRIMARY KEY(rule_id,item_id) ); CREATE TABLE rule_conflicts ( item_id INTEGER PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE, message TEXT NOT NULL, updated_at TEXT NOT NULL ); COMMIT;", )?; for (column, definition) in [ ( "category_id", "INTEGER REFERENCES categories(id) ON DELETE SET NULL", ), ("number_label", "TEXT NOT NULL DEFAULT ''"), ("decimals", "INTEGER NOT NULL DEFAULT 2"), ("decimal_separator", "TEXT NOT NULL DEFAULT '.'"), ("thousands_separator", "TEXT NOT NULL DEFAULT 'comma'"), ("negative_style", "TEXT NOT NULL DEFAULT 'minus'"), ("percent_total", "INTEGER NOT NULL DEFAULT 0"), ("alignment", "TEXT NOT NULL DEFAULT 'left'"), ] { if !column_exists(connection, "view_columns", column)? { connection.execute_batch(&format!( "ALTER TABLE view_columns ADD COLUMN {column} {definition};" ))?; } } connection.execute_batch( "UPDATE view_columns SET category_id=(SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1), field='numeric' WHERE field='value'; INSERT INTO categories(name,kind,sort_order) SELECT 'Value','numeric',(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories) WHERE EXISTS(SELECT 1 FROM items WHERE numeric_value IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM categories WHERE kind='numeric') AND NOT EXISTS(SELECT 1 FROM categories WHERE name='Value' COLLATE NOCASE); INSERT INTO categories(name,kind,sort_order) SELECT 'Numeric Value','numeric',(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories) WHERE EXISTS(SELECT 1 FROM items WHERE numeric_value IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM categories WHERE kind='numeric'); UPDATE view_columns SET category_id=(SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1) WHERE field='numeric' AND category_id IS NULL; INSERT OR IGNORE INTO item_values(item_id,category_id,numeric_value,assignment) SELECT i.id, COALESCE( (SELECT ic.category_id FROM item_categories ic JOIN categories c ON c.id=ic.category_id WHERE ic.item_id=i.id AND ic.assignment<>'excluded' AND c.kind='numeric' LIMIT 1), (SELECT id FROM categories WHERE kind='numeric' ORDER BY id LIMIT 1) ), i.numeric_value,'explicit' FROM items i WHERE i.numeric_value IS NOT NULL AND EXISTS(SELECT 1 FROM categories WHERE kind='numeric'); INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) SELECT item_id,category_id,'explicit' FROM item_values; UPDATE items SET numeric_value=NULL WHERE numeric_value IS NOT NULL AND EXISTS( SELECT 1 FROM item_values WHERE item_values.item_id=items.id ); UPDATE category_rules SET action_kind='none' WHERE action_kind='assign' AND TRIM(action_value)=''; INSERT OR IGNORE INTO rule_action_runs(rule_id,item_id) SELECT r.id,positive.item_id FROM category_rules r JOIN item_categories positive ON positive.category_id=r.category_id AND positive.assignment<>'excluded' WHERE r.action_kind<>'none' AND NOT EXISTS( SELECT 1 FROM item_categories excluded WHERE excluded.item_id=positive.item_id AND excluded.category_id=positive.category_id AND excluded.assignment='excluded' );", )?; Ok(()) } fn add_filter_expression(connection: &Connection) -> Result<()> { if !column_exists(connection, "views", "filter_expr")? { connection .execute_batch("ALTER TABLE views ADD COLUMN filter_expr TEXT NOT NULL DEFAULT '';")?; } Ok(()) } fn add_recurrence(connection: &Connection) -> Result<()> { if !column_exists(connection, "items", "recurrence")? { connection .execute_batch("ALTER TABLE items ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';")?; } Ok(()) } fn column_exists(connection: &Connection, table: &str, column: &str) -> Result { let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?; let names = statement .query_map([], |row| row.get::<_, String>(1))? .collect::>>()?; Ok(names.iter().any(|name| name == column)) } #[cfg(test)] mod tests { use super::*; #[test] fn version_four_numbers_migrate_without_losing_values_or_provenance() { let connection = Connection::open_in_memory().unwrap(); connection .execute_batch( "PRAGMA foreign_keys=ON; CREATE TABLE items(id INTEGER PRIMARY KEY,text TEXT NOT NULL,note TEXT NOT NULL DEFAULT '',priority INTEGER NOT NULL DEFAULT 3,when_at TEXT,done_at TEXT,alarm_at TEXT,numeric_value REAL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,sort_order INTEGER NOT NULL DEFAULT 0,discarded INTEGER NOT NULL DEFAULT 0,recurrence TEXT NOT NULL DEFAULT ''); CREATE TABLE categories(id INTEGER PRIMARY KEY,name TEXT NOT NULL UNIQUE,parent_id INTEGER,kind TEXT NOT NULL DEFAULT 'standard',match_text TEXT NOT NULL DEFAULT '',exclusive INTEGER NOT NULL DEFAULT 0,sort_order INTEGER NOT NULL DEFAULT 0); CREATE TABLE item_categories(item_id INTEGER NOT NULL REFERENCES items(id),category_id INTEGER NOT NULL REFERENCES categories(id),assignment TEXT NOT NULL DEFAULT 'explicit',PRIMARY KEY(item_id,category_id)); CREATE TABLE views(id INTEGER PRIMARY KEY,name TEXT NOT NULL UNIQUE,kind TEXT NOT NULL DEFAULT 'list',filter_value TEXT NOT NULL DEFAULT '',sort_key TEXT NOT NULL DEFAULT 'manual',show_done INTEGER NOT NULL DEFAULT 1,sort_order INTEGER NOT NULL DEFAULT 0,filter_expr TEXT NOT NULL DEFAULT ''); CREATE TABLE view_columns(id INTEGER PRIMARY KEY,view_id INTEGER NOT NULL REFERENCES views(id),field TEXT NOT NULL,heading TEXT NOT NULL,width INTEGER NOT NULL DEFAULT 20,aggregate TEXT NOT NULL DEFAULT 'none',sort_order INTEGER NOT NULL DEFAULT 0); INSERT INTO categories(id,name,kind) VALUES(1,'Hours','numeric'); INSERT INTO items(id,text,numeric_value,created_at,updated_at) VALUES(1,'Legacy',42.25,'now','now'); INSERT INTO item_categories VALUES(1,1,'explicit'); INSERT INTO views(id,name) VALUES(1,'Legacy'); INSERT INTO view_columns(view_id,field,heading) VALUES(1,'value','Hours'); PRAGMA user_version=4;", ) .unwrap(); migrate(&connection).unwrap(); assert_eq!( connection .query_row( "SELECT numeric_value FROM item_values WHERE item_id=1 AND category_id=1", [], |row| row.get::<_, f64>(0), ) .unwrap(), 42.25 ); assert!( connection .query_row("SELECT numeric_value FROM items WHERE id=1", [], |row| { row.get::<_, Option>(0) }) .unwrap() .is_none() ); connection .execute("INSERT INTO item_categories VALUES(1,1,'conditional')", []) .unwrap(); assert_eq!( connection .query_row( "SELECT COUNT(*) FROM item_categories WHERE item_id=1 AND category_id=1", [], |row| row.get::<_, i64>(0), ) .unwrap(), 2 ); } }