- Add category-bound date and numeric values - Complete rule execution and conflict handling - Expand Markdown and HTML report output
295 lines
14 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|