MastoSearch/MastoSearch/DatabaseController.swift

197 lines
7.0 KiB
Swift

//
// DatabaseController.swift
// MastoSearch
//
// Created by Shadowfacts on 12/10/21.
//
import Foundation
import FMDB
import OSLog
import Combine
class DatabaseController {
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!
private(set) var isInitialized = false
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")
}
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()
}
func close() {
// db.close()
// log.info("Closed database")
}
func addStatuses<S: Sequence>(_ 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")
}
}
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)
}
}
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)
}
}
func getNewestStatus(completion: @escaping (Status?) -> Void) {
queue.inDatabase { db in
let results = try! db.executeQuery("SELECT * FROM statuses ORDER BY published DESC LIMIT 1", values: nil)
completion(StatusSequence(results: results).makeIterator().next())
}
}
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
}
}
struct StatusSequence: Sequence {
typealias Element = Status
let results: FMResultSet
func makeIterator() -> Iterator {
return Iterator(results: results)
}
class Iterator: IteratorProtocol {
typealias Element = Status
let results: FMResultSet
init(results: FMResultSet) {
self.results = results
}
deinit {
results.close()
}
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
}
}
}