database.go
| 1 | // Package database öffnet die SQLite-Datei und spielt die Migrationen ein. |
| 2 | package database |
| 3 | |
| 4 | import ( |
| 5 | "database/sql" |
| 6 | "fmt" |
| 7 | "net/url" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | |
| 11 | _ "modernc.org/sqlite" // reiner Go-Treiber, kein CGO |
| 12 | ) |
| 13 | |
| 14 | // Open öffnet (oder erzeugt) die Datenbank. Die Pragmas stehen im DSN, damit sie |
| 15 | // für jede Verbindung gelten – foreign_keys ist in SQLite pro Verbindung. |
| 16 | func Open(path string) (*sql.DB, error) { |
| 17 | if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { |
| 18 | return nil, fmt.Errorf("db-ordner anlegen: %w", err) |
| 19 | } |
| 20 | dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + |
| 21 | "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" |
| 22 | db, err := sql.Open("sqlite", dsn) |
| 23 | if err != nil { |
| 24 | return nil, fmt.Errorf("db öffnen: %w", err) |
| 25 | } |
| 26 | // Eine Verbindung: keine SQLITE_BUSY-Konflikte zwischen Schreibern. Für eine |
| 27 | // Ein-Personen-Anwendung mehr als schnell genug. |
| 28 | db.SetMaxOpenConns(1) |
| 29 | if err := db.Ping(); err != nil { |
| 30 | db.Close() |
| 31 | return nil, fmt.Errorf("db ping: %w", err) |
| 32 | } |
| 33 | if err := migrate(db); err != nil { |
| 34 | db.Close() |
| 35 | return nil, fmt.Errorf("migration: %w", err) |
| 36 | } |
| 37 | return db, nil |
| 38 | } |
| 39 | |
| 40 | // Close schreibt das WAL zurück in die Hauptdatei und schließt die Datenbank. |
| 41 | func Close(db *sql.DB) error { |
| 42 | if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { |
| 43 | db.Close() |
| 44 | return fmt.Errorf("wal checkpoint: %w", err) |
| 45 | } |
| 46 | return db.Close() |
| 47 | } |
| 48 | |
| 49 | // Migrationen werden nur angehängt, nie geändert. Jede läuft in einer eigenen |
| 50 | // Transaktion zusammen mit dem Eintrag in schema_migrations. |
| 51 | var migrations = []struct { |
| 52 | version int |
| 53 | sql string |
| 54 | }{ |
| 55 | {1, ` |
| 56 | CREATE TABLE entries ( |
| 57 | id INTEGER PRIMARY KEY, |
| 58 | kind TEXT NOT NULL, |
| 59 | title TEXT NOT NULL, |
| 60 | summary TEXT NOT NULL DEFAULT '', |
| 61 | body TEXT NOT NULL DEFAULT '', |
| 62 | region TEXT NOT NULL DEFAULT '', |
| 63 | tags TEXT NOT NULL DEFAULT '', |
| 64 | dosage TEXT NOT NULL DEFAULT '', |
| 65 | patient_text TEXT NOT NULL DEFAULT '', |
| 66 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 67 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 68 | ); |
| 69 | CREATE INDEX idx_entries_kind ON entries(kind); |
| 70 | |
| 71 | -- Volltextsuche; remove_diacritics: "Ubung" findet "Übung" |
| 72 | CREATE VIRTUAL TABLE entries_fts USING fts5( |
| 73 | title, summary, tags, region, body, patient_text, |
| 74 | content='entries', content_rowid='id', |
| 75 | tokenize='unicode61 remove_diacritics 2' |
| 76 | ); |
| 77 | CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN |
| 78 | INSERT INTO entries_fts(rowid, title, summary, tags, region, body, patient_text) |
| 79 | VALUES (new.id, new.title, new.summary, new.tags, new.region, new.body, new.patient_text); |
| 80 | END; |
| 81 | CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN |
| 82 | INSERT INTO entries_fts(entries_fts, rowid, title, summary, tags, region, body, patient_text) |
| 83 | VALUES ('delete', old.id, old.title, old.summary, old.tags, old.region, old.body, old.patient_text); |
| 84 | END; |
| 85 | CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN |
| 86 | INSERT INTO entries_fts(entries_fts, rowid, title, summary, tags, region, body, patient_text) |
| 87 | VALUES ('delete', old.id, old.title, old.summary, old.tags, old.region, old.body, old.patient_text); |
| 88 | INSERT INTO entries_fts(rowid, title, summary, tags, region, body, patient_text) |
| 89 | VALUES (new.id, new.title, new.summary, new.tags, new.region, new.body, new.patient_text); |
| 90 | END; |
| 91 | |
| 92 | -- ungerichtete Verknüpfung, immer mit a < b gespeichert |
| 93 | CREATE TABLE entry_links ( |
| 94 | a INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 95 | b INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 96 | PRIMARY KEY (a, b), |
| 97 | CHECK (a < b) |
| 98 | ) WITHOUT ROWID; |
| 99 | CREATE INDEX idx_links_b ON entry_links(b); |
| 100 | |
| 101 | CREATE TABLE sources ( |
| 102 | id INTEGER PRIMARY KEY, |
| 103 | entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 104 | ref TEXT NOT NULL, |
| 105 | page TEXT NOT NULL DEFAULT '', |
| 106 | url TEXT NOT NULL DEFAULT '', |
| 107 | note TEXT NOT NULL DEFAULT '' |
| 108 | ); |
| 109 | CREATE INDEX idx_sources_entry ON sources(entry_id); |
| 110 | |
| 111 | CREATE TABLE media ( |
| 112 | id INTEGER PRIMARY KEY, |
| 113 | entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 114 | filename TEXT NOT NULL UNIQUE, |
| 115 | caption TEXT NOT NULL DEFAULT '', |
| 116 | created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 117 | ); |
| 118 | CREATE INDEX idx_media_entry ON media(entry_id); |
| 119 | |
| 120 | CREATE TABLE handouts ( |
| 121 | id INTEGER PRIMARY KEY, |
| 122 | title TEXT NOT NULL, |
| 123 | recipient TEXT NOT NULL DEFAULT '', |
| 124 | notes TEXT NOT NULL DEFAULT '', |
| 125 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 126 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 127 | ); |
| 128 | |
| 129 | CREATE TABLE handout_items ( |
| 130 | handout_id INTEGER NOT NULL REFERENCES handouts(id) ON DELETE CASCADE, |
| 131 | entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 132 | pos INTEGER NOT NULL DEFAULT 0, |
| 133 | dosage TEXT NOT NULL DEFAULT '', |
| 134 | note TEXT NOT NULL DEFAULT '', |
| 135 | PRIMARY KEY (handout_id, entry_id) |
| 136 | ) WITHOUT ROWID; |
| 137 | |
| 138 | CREATE TABLE settings ( |
| 139 | key TEXT PRIMARY KEY, |
| 140 | value TEXT NOT NULL |
| 141 | ) WITHOUT ROWID; |
| 142 | `}, |
| 143 | {2, ` |
| 144 | CREATE TABLE patients ( |
| 145 | id INTEGER PRIMARY KEY, |
| 146 | name TEXT NOT NULL, |
| 147 | dob TEXT NOT NULL DEFAULT '', |
| 148 | phone TEXT NOT NULL DEFAULT '', |
| 149 | notes TEXT NOT NULL DEFAULT '', |
| 150 | archived INTEGER NOT NULL DEFAULT 0, |
| 151 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 152 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 153 | ); |
| 154 | CREATE INDEX idx_patients_name ON patients(name COLLATE NOCASE); |
| 155 | |
| 156 | -- Befund: feste Spalten für Liste/Sortierung, Inhalt als JSON (Felder, Tabellen, Körperschema). |
| 157 | -- Die Feldschlüssel entsprechen der alten Befundseite, alte JSON-Exporte lassen sich importieren. |
| 158 | CREATE TABLE befunde ( |
| 159 | id INTEGER PRIMARY KEY, |
| 160 | patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE, |
| 161 | date TEXT NOT NULL, |
| 162 | title TEXT NOT NULL DEFAULT 'Befund', |
| 163 | data TEXT NOT NULL DEFAULT '{}', |
| 164 | created_at INTEGER NOT NULL DEFAULT (unixepoch()), |
| 165 | updated_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 166 | ); |
| 167 | CREATE INDEX idx_befunde_patient ON befunde(patient_id, date); |
| 168 | |
| 169 | -- Behandlungsverlauf: gehört zum Patienten, nicht zu einem einzelnen Befund |
| 170 | CREATE TABLE sessions ( |
| 171 | id INTEGER PRIMARY KEY, |
| 172 | patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE, |
| 173 | date TEXT NOT NULL, |
| 174 | goal TEXT NOT NULL DEFAULT '', |
| 175 | note TEXT NOT NULL DEFAULT '', |
| 176 | created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| 177 | ); |
| 178 | CREATE INDEX idx_sessions_patient ON sessions(patient_id, date); |
| 179 | |
| 180 | -- Wissen ↔ Patient (z. B. Krankheitsbild, Protokoll) |
| 181 | CREATE TABLE patient_entries ( |
| 182 | patient_id INTEGER NOT NULL REFERENCES patients(id) ON DELETE CASCADE, |
| 183 | entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, |
| 184 | PRIMARY KEY (patient_id, entry_id) |
| 185 | ) WITHOUT ROWID; |
| 186 | |
| 187 | ALTER TABLE handouts ADD COLUMN patient_id INTEGER REFERENCES patients(id) ON DELETE SET NULL; |
| 188 | CREATE INDEX idx_handouts_patient ON handouts(patient_id); |
| 189 | `}, |
| 190 | } |
| 191 | |
| 192 | func migrate(db *sql.DB) error { |
| 193 | if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`); err != nil { |
| 194 | return err |
| 195 | } |
| 196 | var current int |
| 197 | if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(¤t); err != nil { |
| 198 | return err |
| 199 | } |
| 200 | for _, m := range migrations { |
| 201 | if m.version <= current { |
| 202 | continue |
| 203 | } |
| 204 | tx, err := db.Begin() |
| 205 | if err != nil { |
| 206 | return err |
| 207 | } |
| 208 | if _, err := tx.Exec(m.sql); err != nil { |
| 209 | tx.Rollback() |
| 210 | return fmt.Errorf("version %d: %w", m.version, err) |
| 211 | } |
| 212 | if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil { |
| 213 | tx.Rollback() |
| 214 | return err |
| 215 | } |
| 216 | if err := tx.Commit(); err != nil { |
| 217 | return err |
| 218 | } |
| 219 | } |
| 220 | return nil |
| 221 | } |
| 222 |