Files
RogueAgenda/src/db/schema.rs
Chili Palmer 16150b5a8f complete category-scoped values, rules, and report parity
- Add category-bound date and numeric values
- Complete rule execution and conflict handling
- Expand Markdown and HTML report output
2026-08-20 21:01:02 +02:00

295 lines
14 KiB
Rust

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<bool> {
let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
let names = statement
.query_map([], |row| row.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
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<f64>>(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
);
}
}