254 lines
7.6 KiB
Swift
254 lines
7.6 KiB
Swift
/// SQLite persistence via GRDB.
|
|
/// Schema: Session -> Measurement -> DataPoint
|
|
|
|
import Foundation
|
|
import GRDB
|
|
|
|
// MARK: - Records
|
|
|
|
struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var startedAt: Date
|
|
var label: String?
|
|
var notes: String?
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var sessionId: Int64
|
|
var type: String
|
|
var startedAt: Date
|
|
var config: Data?
|
|
var resultSummary: Data?
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
struct DataPoint: Codable, FetchableRecord, MutablePersistableRecord {
|
|
var id: Int64?
|
|
var measurementId: Int64
|
|
var index: Int
|
|
var payload: Data
|
|
|
|
mutating func didInsert(_ inserted: InsertionSuccess) {
|
|
id = inserted.rowID
|
|
}
|
|
}
|
|
|
|
// MARK: - Database manager
|
|
|
|
final class Storage: @unchecked Sendable {
|
|
static let shared = Storage()
|
|
|
|
private let dbQueue: DatabaseQueue
|
|
|
|
private init() {
|
|
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
let path = dir.appendingPathComponent("eis4.sqlite").path
|
|
do {
|
|
dbQueue = try DatabaseQueue(path: path)
|
|
try migrate()
|
|
} catch {
|
|
fatalError("Database init failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func migrate() throws {
|
|
var migrator = DatabaseMigrator()
|
|
|
|
migrator.registerMigration("v1") { db in
|
|
try db.create(table: "session") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("startedAt", .datetime).notNull()
|
|
t.column("label", .text)
|
|
t.column("notes", .text)
|
|
}
|
|
|
|
try db.create(table: "measurement") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("sessionId", .integer).notNull()
|
|
.references("session", onDelete: .cascade)
|
|
t.column("type", .text).notNull()
|
|
t.column("startedAt", .datetime).notNull()
|
|
t.column("config", .blob)
|
|
t.column("resultSummary", .blob)
|
|
}
|
|
|
|
try db.create(table: "dataPoint") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("measurementId", .integer).notNull()
|
|
.references("measurement", onDelete: .cascade)
|
|
t.column("index", .integer).notNull()
|
|
t.column("payload", .blob).notNull()
|
|
}
|
|
}
|
|
|
|
try migrator.migrate(dbQueue)
|
|
}
|
|
|
|
// MARK: - Sessions
|
|
|
|
func createSession(label: String? = nil) throws -> Session {
|
|
try dbQueue.write { db in
|
|
var s = Session(startedAt: Date(), label: label)
|
|
try s.insert(db)
|
|
return s
|
|
}
|
|
}
|
|
|
|
func fetchSessions() throws -> [Session] {
|
|
try dbQueue.read { db in
|
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
|
}
|
|
}
|
|
|
|
func deleteSession(_ id: Int64) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(sql: "DELETE FROM session WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func updateSession(_ id: Int64, label: String?, notes: String?) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE session SET label = ?, notes = ? WHERE id = ?",
|
|
arguments: [label, notes, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Measurements
|
|
|
|
func addMeasurement(
|
|
sessionId: Int64,
|
|
type: MeasurementType,
|
|
config: (any Encodable)? = nil
|
|
) throws -> Measurement {
|
|
let configData: Data? = if let config {
|
|
try JSONEncoder().encode(config)
|
|
} else {
|
|
nil
|
|
}
|
|
return try dbQueue.write { db in
|
|
var m = Measurement(
|
|
sessionId: sessionId,
|
|
type: type.rawValue,
|
|
startedAt: Date(),
|
|
config: configData
|
|
)
|
|
try m.insert(db)
|
|
return m
|
|
}
|
|
}
|
|
|
|
func setMeasurementResult(_ measurementId: Int64, result: any Encodable) throws {
|
|
try dbQueue.write { db in
|
|
let data = try JSONEncoder().encode(result)
|
|
try db.execute(
|
|
sql: "UPDATE measurement SET resultSummary = ? WHERE id = ?",
|
|
arguments: [data, measurementId]
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchMeasurements(sessionId: Int64) throws -> [Measurement] {
|
|
try dbQueue.read { db in
|
|
try Measurement
|
|
.filter(Column("sessionId") == sessionId)
|
|
.order(Column("startedAt").asc)
|
|
.fetchAll(db)
|
|
}
|
|
}
|
|
|
|
func deleteMeasurement(_ id: Int64) throws {
|
|
try dbQueue.write { db in
|
|
try db.execute(sql: "DELETE FROM measurement WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func dataPointCount(measurementId: Int64) throws -> Int {
|
|
try dbQueue.read { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.fetchCount(db)
|
|
}
|
|
}
|
|
|
|
func measurementCount(sessionId: Int64) throws -> Int {
|
|
try dbQueue.read { db in
|
|
try Measurement
|
|
.filter(Column("sessionId") == sessionId)
|
|
.fetchCount(db)
|
|
}
|
|
}
|
|
|
|
// MARK: - Data points
|
|
|
|
func addDataPoint<T: Encodable>(measurementId: Int64, index: Int, point: T) throws {
|
|
let payload = try JSONEncoder().encode(point)
|
|
try dbQueue.write { db in
|
|
var dp = DataPoint(
|
|
measurementId: measurementId,
|
|
index: index,
|
|
payload: payload
|
|
)
|
|
try dp.insert(db)
|
|
}
|
|
}
|
|
|
|
func addDataPoints<T: Encodable>(measurementId: Int64, points: [(index: Int, value: T)]) throws {
|
|
let encoder = JSONEncoder()
|
|
try dbQueue.write { db in
|
|
for (idx, val) in points {
|
|
let payload = try encoder.encode(val)
|
|
var dp = DataPoint(measurementId: measurementId, index: idx, payload: payload)
|
|
try dp.insert(db)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchDataPoints(measurementId: Int64) throws -> [DataPoint] {
|
|
try dbQueue.read { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.order(Column("index").asc)
|
|
.fetchAll(db)
|
|
}
|
|
}
|
|
|
|
/// Decode stored data points into typed values.
|
|
func fetchTypedPoints<T: Decodable>(measurementId: Int64, as type: T.Type) throws -> [T] {
|
|
let rows = try fetchDataPoints(measurementId: measurementId)
|
|
let decoder = JSONDecoder()
|
|
return try rows.map { try decoder.decode(T.self, from: $0.payload) }
|
|
}
|
|
|
|
// MARK: - Observation (for SwiftUI live updates)
|
|
|
|
func observeDataPoints(
|
|
measurementId: Int64,
|
|
onChange: @escaping ([DataPoint]) -> Void
|
|
) -> DatabaseCancellable {
|
|
let observation = ValueObservation.tracking { db in
|
|
try DataPoint
|
|
.filter(Column("measurementId") == measurementId)
|
|
.order(Column("index").asc)
|
|
.fetchAll(db)
|
|
}
|
|
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
|
}
|
|
|
|
func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
|
|
let observation = ValueObservation.tracking { db in
|
|
try Session.order(Column("startedAt").desc).fetchAll(db)
|
|
}
|
|
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
|
}
|
|
}
|