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 | } | ||