Commit 3bc8d0673f
Verified · cmc
Layout: unified · split
Sources/OrgIndex/IndexStore.swift added +315
| @@ -0,0 +1,315 @@ | ||
| 1 | import Foundation | |
| 2 | import GRDB | |
| 3 | ||
| 4 | /// What the index knows about a file without reading it. | |
| 5 | public struct FileState: Sendable, Equatable { | |
| 6 | public let kind: FileKind | |
| 7 | public let size: Int | |
| 8 | public let mtime: Double | |
| 9 | public let hash: String | |
| 10 | public let settingsVersion: Int | |
| 11 | } | |
| 12 | ||
| 13 | /// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to, | |
| 14 | /// so a caller can tell whether they still apply to an open buffer. | |
| 15 | public struct HeadingLocation: Sendable, Equatable { | |
| 16 | public let path: String | |
| 17 | public let ordinal: Int | |
| 18 | public let title: String | |
| 19 | public let start: Int | |
| 20 | public let contentHash: String | |
| 21 | } | |
| 22 | ||
| 23 | /// One reconciliation's worth of changes, applied in a single transaction. | |
| 24 | public struct IndexChange: Sendable { | |
| 25 | public var records: [FileRecord] = [] | |
| 26 | /// Unchanged content with a new modification time. | |
| 27 | public var touches: [(path: String, mtime: Double)] = [] | |
| 28 | /// Renamed files whose content didn't change. | |
| 29 | public var moves: [(from: String, to: String, mtime: Double)] = [] | |
| 30 | public var removals: [String] = [] | |
| 31 | ||
| 32 | public init() {} | |
| 33 | ||
| 34 | public var isEmpty: Bool { records.isEmpty && touches.isEmpty && moves.isEmpty && removals.isEmpty } | |
| 35 | } | |
| 36 | ||
| 37 | /// The SQLite index. A cache: deleting it loses nothing that the files don't hold. | |
| 38 | public final class IndexStore: Sendable { | |
| 39 | let database: DatabaseQueue | |
| 40 | ||
| 41 | /// `path` nil opens an in-memory index. | |
| 42 | public init(path: String? = nil) throws { | |
| 43 | database = try path.map { try DatabaseQueue(path: $0) } ?? DatabaseQueue() | |
| 44 | try Self.migrator.migrate(database) | |
| 45 | } | |
| 46 | ||
| 47 | static var migrator: DatabaseMigrator { | |
| 48 | var migrator = DatabaseMigrator() | |
| 49 | migrator.registerMigration("v1") { db in | |
| 50 | try db.execute(sql: """ | |
| 51 | CREATE TABLE files ( | |
| 52 | id INTEGER PRIMARY KEY, | |
| 53 | path TEXT NOT NULL UNIQUE, | |
| 54 | root TEXT NOT NULL, | |
| 55 | kind TEXT NOT NULL, | |
| 56 | size INTEGER NOT NULL, | |
| 57 | mtime REAL NOT NULL, | |
| 58 | hash TEXT NOT NULL, | |
| 59 | settings_version INTEGER NOT NULL, | |
| 60 | parsed_at REAL NOT NULL | |
| 61 | ); | |
| 62 | CREATE INDEX files_root ON files(root); | |
| 63 | CREATE TABLE headings ( | |
| 64 | id INTEGER PRIMARY KEY, | |
| 65 | file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, | |
| 66 | ordinal INTEGER NOT NULL, | |
| 67 | parent_ordinal INTEGER, | |
| 68 | start_offset INTEGER NOT NULL, | |
| 69 | end_offset INTEGER NOT NULL, | |
| 70 | level INTEGER NOT NULL, | |
| 71 | todo TEXT, | |
| 72 | is_done INTEGER NOT NULL, | |
| 73 | priority TEXT, | |
| 74 | title TEXT NOT NULL, | |
| 75 | outline_path TEXT NOT NULL, | |
| 76 | org_id TEXT, | |
| 77 | archived INTEGER NOT NULL | |
| 78 | ); | |
| 79 | CREATE INDEX headings_file ON headings(file_id); | |
| 80 | CREATE INDEX headings_org_id ON headings(org_id); | |
| 81 | CREATE TABLE tags ( | |
| 82 | heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE, | |
| 83 | tag TEXT NOT NULL, | |
| 84 | inherited INTEGER NOT NULL | |
| 85 | ); | |
| 86 | CREATE INDEX tags_heading ON tags(heading_id); | |
| 87 | CREATE TABLE properties ( | |
| 88 | heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE, | |
| 89 | key TEXT NOT NULL, | |
| 90 | value TEXT NOT NULL, | |
| 91 | inherited INTEGER NOT NULL | |
| 92 | ); | |
| 93 | CREATE INDEX properties_heading ON properties(heading_id); | |
| 94 | CREATE TABLE timestamps ( | |
| 95 | heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE, | |
| 96 | kind TEXT NOT NULL, | |
| 97 | start_at TEXT NOT NULL, | |
| 98 | end_at TEXT, | |
| 99 | repeater TEXT, | |
| 100 | warning TEXT | |
| 101 | ); | |
| 102 | CREATE INDEX timestamps_heading ON timestamps(heading_id); | |
| 103 | CREATE TABLE clocks ( | |
| 104 | heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE, | |
| 105 | start_at TEXT NOT NULL, | |
| 106 | end_at TEXT, | |
| 107 | minutes INTEGER | |
| 108 | ); | |
| 109 | CREATE INDEX clocks_heading ON clocks(heading_id); | |
| 110 | CREATE TABLE links ( | |
| 111 | heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE, | |
| 112 | type TEXT NOT NULL, | |
| 113 | target TEXT NOT NULL | |
| 114 | ); | |
| 115 | CREATE INDEX links_heading ON links(heading_id); | |
| 116 | CREATE VIRTUAL TABLE headings_fts USING fts5(title, body, tokenize = 'unicode61 remove_diacritics 2'); | |
| 117 | CREATE TRIGGER headings_fts_delete AFTER DELETE ON headings BEGIN | |
| 118 | DELETE FROM headings_fts WHERE rowid = old.id; | |
| 119 | END; | |
| 120 | """) | |
| 121 | } | |
| 122 | return migrator | |
| 123 | } | |
| 124 | ||
| 125 | // MARK: - Writing | |
| 126 | ||
| 127 | public func write(_ record: FileRecord) throws { | |
| 128 | var change = IndexChange() | |
| 129 | change.records = [record] | |
| 130 | try apply(change) | |
| 131 | } | |
| 132 | ||
| 133 | public func apply(_ change: IndexChange) throws { | |
| 134 | guard !change.isEmpty else { return } | |
| 135 | try database.write { db in | |
| 136 | for path in change.removals { | |
| 137 | try db.execute(sql: "DELETE FROM files WHERE path = ?", arguments: [path]) | |
| 138 | } | |
| 139 | for move in change.moves { | |
| 140 | try db.execute(sql: "UPDATE files SET path = ?, mtime = ? WHERE path = ?", arguments: [move.to, move.mtime, move.from]) | |
| 141 | } | |
| 142 | for touch in change.touches { | |
| 143 | try db.execute(sql: "UPDATE files SET mtime = ? WHERE path = ?", arguments: [touch.mtime, touch.path]) | |
| 144 | } | |
| 145 | for record in change.records { | |
| 146 | try insert(record, db) | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | private func insert(_ record: FileRecord, _ db: Database) throws { | |
| 152 | try db.execute(sql: "DELETE FROM files WHERE path = ?", arguments: [record.path]) | |
| 153 | try db.execute( | |
| 154 | sql: """ | |
| 155 | INSERT INTO files (path, root, kind, size, mtime, hash, settings_version, parsed_at) | |
| 156 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 157 | """, | |
| 158 | arguments: [record.path, record.root, record.kind.rawValue, record.size, record.mtime, record.hash, | |
| 159 | record.settingsVersion, Date().timeIntervalSince1970] | |
| 160 | ) | |
| 161 | let fileID = db.lastInsertedRowID | |
| 162 | for heading in record.headings { | |
| 163 | try db.execute( | |
| 164 | sql: """ | |
| 165 | INSERT INTO headings (file_id, ordinal, parent_ordinal, start_offset, end_offset, level, todo, | |
| 166 | is_done, priority, title, outline_path, org_id, archived) | |
| 167 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 168 | """, | |
| 169 | arguments: [fileID, heading.ordinal, heading.parent, heading.start, heading.end, heading.level, heading.todo, | |
| 170 | heading.isDone, heading.priority, heading.title, heading.outlinePath.joined(separator: "\u{1F}"), | |
| 171 | heading.orgID, heading.archived] | |
| 172 | ) | |
| 173 | let id = db.lastInsertedRowID | |
| 174 | try db.execute(sql: "INSERT INTO headings_fts (rowid, title, body) VALUES (?, ?, ?)", arguments: [id, heading.title, heading.body]) | |
| 175 | for tag in heading.tags { | |
| 176 | try db.execute(sql: "INSERT INTO tags VALUES (?, ?, ?)", arguments: [id, tag.name, tag.inherited]) | |
| 177 | } | |
| 178 | for property in heading.properties { | |
| 179 | try db.execute(sql: "INSERT INTO properties VALUES (?, ?, ?, ?)", arguments: [id, property.key, property.value, property.inherited]) | |
| 180 | } | |
| 181 | for stamp in heading.timestamps { | |
| 182 | try db.execute( | |
| 183 | sql: "INSERT INTO timestamps VALUES (?, ?, ?, ?, ?, ?)", | |
| 184 | arguments: [id, stamp.kind.rawValue, stamp.start, stamp.end, stamp.repeater, stamp.warning] | |
| 185 | ) | |
| 186 | } | |
| 187 | for clock in heading.clocks { | |
| 188 | try db.execute(sql: "INSERT INTO clocks VALUES (?, ?, ?, ?)", arguments: [id, clock.start, clock.end, clock.minutes]) | |
| 189 | } | |
| 190 | for link in heading.links { | |
| 191 | try db.execute(sql: "INSERT INTO links VALUES (?, ?, ?)", arguments: [id, link.type, link.target]) | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | // MARK: - Reading | |
| 197 | ||
| 198 | /// Indexed files under `root`, by path. | |
| 199 | public func fileStates(root: String) throws -> [String: FileState] { | |
| 200 | try database.read { db in | |
| 201 | let rows = try Row.fetchAll(db, sql: "SELECT path, kind, size, mtime, hash, settings_version FROM files WHERE root = ?", arguments: [root]) | |
| 202 | var states: [String: FileState] = [:] | |
| 203 | for row in rows { | |
| 204 | states[row["path"]] = FileState( | |
| 205 | kind: FileKind(rawValue: row["kind"]) ?? .org, | |
| 206 | size: row["size"], mtime: row["mtime"], hash: row["hash"], settingsVersion: row["settings_version"] | |
| 207 | ) | |
| 208 | } | |
| 209 | return states | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | public func files() throws -> [(path: String, kind: FileKind)] { | |
| 214 | try database.read { db in | |
| 215 | try Row.fetchAll(db, sql: "SELECT path, kind FROM files ORDER BY path").map { | |
| 216 | (path: $0["path"], kind: FileKind(rawValue: $0["kind"]) ?? .org) | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | // MARK: - Queries | |
| 222 | // | |
| 223 | // `overlay` holds records for open documents with unsaved edits, keyed by path. Their rows | |
| 224 | // replace that file's indexed rows in every query. | |
| 225 | ||
| 226 | /// Headings whose title or body contain every word of `query` as a word prefix. | |
| 227 | public func search(_ query: String, overlay: [String: FileRecord] = [:], limit: Int = 50) throws -> [HeadingLocation] { | |
| 228 | let terms = query.split(whereSeparator: \.isWhitespace).map(String.init) | |
| 229 | guard !terms.isEmpty else { return [] } | |
| 230 | let match = terms.map { "\"" + $0.replacingOccurrences(of: "\"", with: "\"\"") + "\"*" }.joined(separator: " ") | |
| 231 | let indexed = try database.read { db in | |
| 232 | try Row.fetchAll( | |
| 233 | db, | |
| 234 | sql: """ | |
| 235 | SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset | |
| 236 | FROM headings_fts | |
| 237 | JOIN headings h ON h.id = headings_fts.rowid | |
| 238 | JOIN files f ON f.id = h.file_id | |
| 239 | WHERE headings_fts MATCH ? | |
| 240 | ORDER BY bm25(headings_fts) | |
| 241 | LIMIT ? | |
| 242 | """, | |
| 243 | arguments: [match, limit + overlay.count * 10] | |
| 244 | ).map(location) | |
| 245 | } | |
| 246 | // Unsaved buffers get the same word-prefix matching as the full-text index. | |
| 247 | let prefixes = terms.flatMap(Self.words) | |
| 248 | let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in | |
| 249 | record.headings.filter { heading in | |
| 250 | let words = Self.words(heading.title + "\n" + heading.body) | |
| 251 | return prefixes.allSatisfy { prefix in words.contains { $0.hasPrefix(prefix) } } | |
| 252 | }.map { location(record, $0) } | |
| 253 | } | |
| 254 | return Array((live + indexed.filter { overlay[$0.path] == nil }).prefix(limit)) | |
| 255 | } | |
| 256 | ||
| 257 | /// Headings with `:ID: id`. More than one means the ID is duplicated. | |
| 258 | public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] { | |
| 259 | let indexed = try database.read { db in | |
| 260 | try Row.fetchAll( | |
| 261 | db, | |
| 262 | sql: """ | |
| 263 | SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset | |
| 264 | FROM headings h JOIN files f ON f.id = h.file_id | |
| 265 | WHERE h.org_id = ? ORDER BY f.path, h.ordinal | |
| 266 | """, | |
| 267 | arguments: [id] | |
| 268 | ).map(location) | |
| 269 | } | |
| 270 | let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in | |
| 271 | record.headings.filter { $0.orgID == id }.map { location(record, $0) } | |
| 272 | } | |
| 273 | return indexed.filter { overlay[$0.path] == nil } + live | |
| 274 | } | |
| 275 | ||
| 276 | /// IDs used by more than one heading. | |
| 277 | public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] { | |
| 278 | let indexed = try database.read { db in | |
| 279 | try Row.fetchAll( | |
| 280 | db, | |
| 281 | sql: """ | |
| 282 | SELECT h.org_id, f.path, f.hash, h.ordinal, h.title, h.start_offset | |
| 283 | FROM headings h JOIN files f ON f.id = h.file_id | |
| 284 | WHERE h.org_id IN (SELECT org_id FROM headings WHERE org_id IS NOT NULL GROUP BY org_id HAVING count(*) > 1) | |
| 285 | OR (h.org_id IS NOT NULL AND ? > 0) | |
| 286 | ORDER BY f.path, h.ordinal | |
| 287 | """, | |
| 288 | arguments: [overlay.count] | |
| 289 | ).map { (id: $0["org_id"] as String, location: location($0)) } | |
| 290 | } | |
| 291 | var byID: [String: [HeadingLocation]] = [:] | |
| 292 | for row in indexed where overlay[row.location.path] == nil { | |
| 293 | byID[row.id, default: []].append(row.location) | |
| 294 | } | |
| 295 | for record in overlay.values.sorted(by: { $0.path < $1.path }) { | |
| 296 | for heading in record.headings { | |
| 297 | if let id = heading.orgID { byID[id, default: []].append(location(record, heading)) } | |
| 298 | } | |
| 299 | } | |
| 300 | return byID.filter { $0.value.count > 1 } | |
| 301 | } | |
| 302 | ||
| 303 | /// Lower-cased runs of letters and digits, as the unicode61 tokenizer splits them. | |
| 304 | static func words(_ text: String) -> [String] { | |
| 305 | text.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init) | |
| 306 | } | |
| 307 | ||
| 308 | private func location(_ row: Row) -> HeadingLocation { | |
| 309 | HeadingLocation(path: row["path"], ordinal: row["ordinal"], title: row["title"], start: row["start_offset"], contentHash: row["hash"]) | |
| 310 | } | |
| 311 | ||
| 312 | private func location(_ record: FileRecord, _ heading: HeadingRecord) -> HeadingLocation { | |
| 313 | HeadingLocation(path: record.path, ordinal: heading.ordinal, title: heading.title, start: heading.start, contentHash: record.hash) | |
| 314 | } | |
| 315 | } | |
Tests/OrgIndexTests/IndexStoreTests.swift added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | import OrgCore | |
| 2 | import Testing | |
| 3 | @testable import OrgIndex | |
| 4 | ||
| 5 | struct IndexStoreTests { | |
| 6 | @Test func searchFindsTitlesAndBodies() throws { | |
| 7 | let store = try IndexStore() | |
| 8 | try store.write(record("/notes/a.org", "* Groceries\nbuy apples\n* Taxes\nfile forms\n")) | |
| 9 | #expect(try store.search("appl").map(\.title) == ["Groceries"]) | |
| 10 | #expect(try store.search("tax").map(\.title) == ["Taxes"]) | |
| 11 | #expect(try store.search("file forms").map(\.title) == ["Taxes"]) | |
| 12 | #expect(try store.search("\"oops").isEmpty) | |
| 13 | #expect(try store.search(" ").isEmpty) | |
| 14 | } | |
| 15 | ||
| 16 | @Test func rewritingAFileReplacesItsRows() throws { | |
| 17 | let store = try IndexStore() | |
| 18 | try store.write(record("/notes/a.org", "* Old title\n")) | |
| 19 | try store.write(record("/notes/a.org", "* New title\n")) | |
| 20 | #expect(try store.search("old").isEmpty) | |
| 21 | #expect(try store.search("new").count == 1) | |
| 22 | } | |
| 23 | ||
| 24 | @Test func removeMoveAndTouch() throws { | |
| 25 | let store = try IndexStore() | |
| 26 | try store.write(record("/notes/a.org", "* Alpha\n")) | |
| 27 | try store.write(record("/notes/b.org", "* Beta\n")) | |
| 28 | var change = IndexChange() | |
| 29 | change.removals = ["/notes/a.org"] | |
| 30 | change.moves = [(from: "/notes/b.org", to: "/notes/c.org", mtime: 5)] | |
| 31 | try store.apply(change) | |
| 32 | #expect(try store.search("alpha").isEmpty) | |
| 33 | #expect(try store.search("beta").map(\.path) == ["/notes/c.org"]) | |
| 34 | #expect(try store.fileStates(root: "/notes")["/notes/c.org"]?.mtime == 5) | |
| 35 | } | |
| 36 | ||
| 37 | @Test func overlayReplacesIndexedRows() throws { | |
| 38 | let store = try IndexStore() | |
| 39 | try store.write(record("/notes/a.org", "* Saved title\n")) | |
| 40 | let overlay = ["/notes/a.org": record("/notes/a.org", "* Unsaved title\n")] | |
| 41 | #expect(try store.search("saved", overlay: overlay).isEmpty) | |
| 42 | #expect(try store.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved title"]) | |
| 43 | } | |
| 44 | ||
| 45 | @Test func idsAndDuplicates() throws { | |
| 46 | let store = try IndexStore() | |
| 47 | try store.write(record("/notes/a.org", "* A\n:PROPERTIES:\n:ID: x\n:END:\n")) | |
| 48 | try store.write(record("/notes/b.org", "* B\n:PROPERTIES:\n:ID: y\n:END:\n")) | |
| 49 | #expect(try store.headings(withID: "x").map(\.path) == ["/notes/a.org"]) | |
| 50 | #expect(try store.duplicateIDs().isEmpty) | |
| 51 | let overlay = ["/notes/b.org": record("/notes/b.org", "* B\n:PROPERTIES:\n:ID: x\n:END:\n")] | |
| 52 | #expect(try store.headings(withID: "x", overlay: overlay).map(\.path) == ["/notes/a.org", "/notes/b.org"]) | |
| 53 | #expect(try store.duplicateIDs(overlay: overlay).keys.sorted() == ["x"]) | |
| 54 | } | |
| 55 | ||
| 56 | @Test func fileStatesAndKinds() throws { | |
| 57 | let store = try IndexStore() | |
| 58 | let a = record("/notes/a.org", "* A\n") | |
| 59 | try store.write(a) | |
| 60 | try store.write(record("/notes/a.sync-conflict-1.org", "* A\n", kind: .conflict)) | |
| 61 | let states = try store.fileStates(root: "/notes") | |
| 62 | #expect(states["/notes/a.org"] == FileState(kind: .org, size: a.size, mtime: 1, hash: a.hash, settingsVersion: 1)) | |
| 63 | #expect(try store.files().map(\.kind) == [.org, .conflict]) | |
| 64 | #expect(try store.search("a").map(\.path) == ["/notes/a.org"]) | |
| 65 | } | |
| 66 | } | |