EIS-BLE-S3/cue-ios/CueIOS/Models/Storage.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 {
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)
}
}