// // DatabaseController.swift // MastoSearch // // Created by Shadowfacts on 12/10/21. // import Foundation import FMDB import OSLog import Combine public class DatabaseController { public static let shared = DatabaseController() private let log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DatabaseController") static let dateFormat = Date.ISO8601FormatStyle(includingFractionalSeconds: true) private let applicationSupport: URL private let databaseURL: URL private var queue: FMDatabaseQueue! public private(set) var isInitialized = false public let onInitialize = PassthroughSubject<(), Never>() private init() { // this dir will be inside the application sandbox container applicationSupport = try! FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) databaseURL = applicationSupport.appendingPathComponent("statuses").appendingPathExtension("sqlite") } public func initialize() { if !FileManager.default.fileExists(atPath: databaseURL.absoluteString) { FileManager.default.createFile(atPath: databaseURL.absoluteString, contents: nil, attributes: nil) } queue = FMDatabaseQueue(path: databaseURL.absoluteString) queue.inDatabase { db in let success = db.executeStatements(""" CREATE TABLE IF NOT EXISTS statuses ( id INTEGER PRIMARY KEY, api_id TEXT, url TEXT, summary TEXT, status_content TEXT NOT NULL, published TEXT NOT NULL ); CREATE VIRTUAL TABLE IF NOT EXISTS statuses_fts USING fts5( summary, status_content, url UNINDEXED, published UNINDEXED, api_id UNINDEXED, content = 'statuses', content_rowid = 'id', tokenize = "porter unicode61 tokenchars '@-_'" ); CREATE TRIGGER IF NOT EXISTS statuses_ai AFTER INSERT ON statuses BEGIN INSERT INTO statuses_fts(rowid, summary, status_content) VALUES (new.id, new.summary, new.status_content); END; CREATE TRIGGER IF NOT EXISTS statuses_ad AFTER DELETE ON statuses BEGIN INSERT INTO statuses_fts(statuses_fts, rowid, summary, status_content) VALUES('delete', old.id, old.summary, old.status_content); END; CREATE TRIGGER IF NOT EXISTS statuses_au AFTER UPDATE ON statuses BEGIN INSERT INTO statuses_fts(statuses_fts, rowid, summary, status_content) VALUES('delete', old.id, old.summary, new.status_content); INSERT INTO statuses_fts(rowid, summary, status_content) VALUES (new.id, new.summary, new.status_content); END; """) guard success else { fatalError("failed to create schema: \(db.lastError())") } log.info("Setup schema") } isInitialized = true // this is safe, FMDatabaseQueue calls are blocking onInitialize.send() } public func close() { // db.close() // log.info("Closed database") } public func addStatuses(_ statuses: S) where S.Element == Status { queue.inTransaction { db, rollback in var i = 0 for status in statuses { do { let summary: AnyObject if let s = status.summary { summary = s as NSString } else { summary = NSNull() } try db.executeUpdate("INSERT INTO statuses (api_id, url, summary, status_content, published) VALUES (?, ?, ?, ?, ?);", values: [ status.id as NSString, status.url as NSString, summary, status.content as NSString, DatabaseController.dateFormat.format(status.published) as NSString ]) } catch { log.error("failed to insert status: \(error.localizedDescription, privacy: .public)") rollback.pointee = true return } i += 1 if i % 100 == 0 { log.debug("Imported \(i, privacy: .public) statuses...") } } log.info("Finished import of \(i, privacy: .public) statuses") } } public func getStatuses(sortDescriptor: NSSortDescriptor?, completion: @escaping (StatusSequence) -> Void) { queue.inDatabase { db in let sortKey = sortDescriptor?.key ?? "published" let asc = sortDescriptor?.ascending == true ? "ASC" : "DESC" let results = try! db.executeQuery("SELECT * FROM statuses ORDER BY \(sortKey) \(asc)", values: nil) let sequence = StatusSequence(results: results) completion(sequence) } } public func getStatuses(query: String, sortDescriptor: NSSortDescriptor?, completion: @escaping (StatusSequence) -> Void) { queue.inDatabase { db in let sortKey = sortDescriptor?.key ?? "rank" let asc = sortDescriptor?.ascending == false ? "DESC" : "ASC" let results = try! db.executeQuery("SELECT * FROM statuses_fts WHERE statuses_fts match ? ORDER BY \(sortKey) \(asc)", values: [query as NSString]) let sequence = StatusSequence(results: results) completion(sequence) } } public func getNewestAndOldestStatuses(completion: @escaping ((Status, Status)?) -> Void) { queue.inDatabase { db in let results = try! db.executeQuery("SELECT * FROM statuses ORDER BY published DESC LIMIT 1", values: nil) if let newest = StatusSequence(results: results).makeIterator().next() { let results2 = try! db.executeQuery("SELECT * FROM statuses ORDER BY published ASC LIMIT 1", values: nil) // if there was a newest, there must also be an oldest let oldest = StatusSequence(results: results2).makeIterator().next()! completion((newest, oldest)) } else { completion(nil) } } } public func countStatuses() -> Int { var res: Int! queue.inDatabase { db in let results = try! db.executeQuery("SELECT COUNT(*) AS count FROM statuses", values: nil) results.next() res = Int(results.int(forColumn: "count")) } return res } } public struct StatusSequence: Sequence { public typealias Element = Status let results: FMResultSet public func makeIterator() -> Iterator { return Iterator(results: results) } public class Iterator: IteratorProtocol { public typealias Element = Status let results: FMResultSet init(results: FMResultSet) { self.results = results } deinit { results.close() } public func next() -> Status? { if results.next() { return Status( id: results.string(forColumn: "api_id")!, url: results.string(forColumn: "url")!, summary: results.string(forColumn: "summary"), content: results.string(forColumn: "status_content")!, published: try! DatabaseController.dateFormat.parse(results.string(forColumn: "published")!) ) } return nil } } }